diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index 5e96be5..be973a8 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -7,6 +7,7 @@
* [Manipulating data](manipulation.md)
* [Working with named graphs](named-graphs.md)
* [RDF Lists](rdf-lists.md)
+ * [Tagged literals](tagged-literals.md)
* Reference
* [JSDoc](api.md)
* [TypeScript](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/clownface)
diff --git a/docs/api.md b/docs/api.md
index 8d87337..bea16e6 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -51,7 +51,7 @@ A graph pointer object, which points at 0..N nodes within a dataset
* [.literal(values, [languageOrDatatype])](#Clownface+literal) ⇒ [Clownface
](#Clownface)
* [.namedNode(values)](#Clownface+namedNode) ⇒ [Clownface
](#Clownface)
* [.in(predicates)](#Clownface+in) ⇒ [Clownface
](#Clownface)
- * [.out(predicates)](#Clownface+out) ⇒ [Clownface
](#Clownface)
+ * [.out(predicates, [options])](#Clownface+out) ⇒ [Clownface
](#Clownface)
* [.has(predicates, [objects])](#Clownface+has) ⇒ [Clownface
](#Clownface)
* [.addIn(predicates, subjects, [callback])](#Clownface+addIn) ⇒ [Clownface
](#Clownface)
* [.addOut(predicates, objects, [callback])](#Clownface+addOut) ⇒ [Clownface
](#Clownface)
@@ -279,7 +279,7 @@ Creates a graph pointer to nodes which are linked to the current pointer by `pre
-### clownface.out(predicates) ⇒ [Clownface
](#Clownface)
+### clownface.out(predicates, [options]) ⇒ [Clownface
](#Clownface)
Creates a graph pointer to nodes which link the current pointer by `predicates`
**Kind**: instance method of [Clownface
](#Clownface)
@@ -293,6 +293,10 @@ Creates a graph pointer to nodes which link the current pointer by `predicates`
predicates | Term | Array.<Term> | Clownface | Array.<Clownface> | one or more RDF/JS term identifying a property
|
+
+ [options] | object | |
+
+ [options.language] | string | Array.<string> | undefined | |
diff --git a/docs/tagged-literals.md b/docs/tagged-literals.md
new file mode 100644
index 0000000..de9639b
--- /dev/null
+++ b/docs/tagged-literals.md
@@ -0,0 +1,135 @@
+# Literals with language tags
+
+Using the `.out()` method it is possible to only find literals in specific languages by passing a second `{ language }` parameter to the method.
+
+When that parameter is defined, only string literal nodes will be returned.
+
+For any given subject, all strings in the chosen language will be returned.
+
+## Finding specific language
+
+To find string literal in a given language, pass a second object argument with a string `language` key.
+
+
+
+```js
+const cf = require('clownface')
+const RDF = require('@rdfjs/dataset')
+const { literal } = require('@rdfjs/data-model')
+const { rdf, rdfs } = require('@tpluscode/rdf-ns-builders')
+
+// create two labels for a resource
+const apple = cf({ dataset: RDF.dataset() })
+ .node(rdf.Resource)
+ .addOut(rdfs.label, literal('apple', 'en'))
+ .addOut(rdfs.label, literal('Apfel', 'de'))
+
+// find German label
+apple.out(rdfs.label, { language: 'de' }).value
+```
+
+
+
+## Finding plain literals
+
+Using an empty string for the `language` parameter will find strings without a language.
+
+
+
+```js
+const cf = require('clownface')
+const RDF = require('@rdfjs/dataset')
+const { literal } = require('@rdfjs/data-model')
+const { rdf, rdfs } = require('@tpluscode/rdf-ns-builders')
+
+// create two labels for a resource
+const apple = cf({ dataset: RDF.dataset() })
+ .node(rdf.Resource)
+ .addOut(rdfs.label, literal('apple'))
+ .addOut(rdfs.label, literal('Apfel', 'de'))
+
+// find literal without language tag
+apple.out(rdfs.label, { language: '' }).value
+```
+
+
+
+## Finding from a choice of potential languages
+
+It is possible to look up the literals in multiple alternatives byt providing an array of languages instead. The first language which gets matched to the literals will be used.
+
+
+
+```js
+const cf = require('clownface')
+const RDF = require('@rdfjs/dataset')
+const { literal } = require('@rdfjs/data-model')
+const { rdf, rdfs } = require('@tpluscode/rdf-ns-builders')
+
+// create two labels for a resource
+const apple = cf({ dataset: RDF.dataset() })
+ .node(rdf.Resource)
+ .addOut(rdfs.label, literal('apple', 'en'))
+ .addOut(rdfs.label, literal('Apfel', 'de'))
+
+// there is no French translation so English will be returned
+apple.out(rdfs.label, { language: ['fr', 'en'] }).value
+```
+
+
+
+A wildcard (asterisk) can also be used to choose any other (random) literal if the preceding choices did not yield any results. It would look similarly to previous example.
+
+```js
+apple.out(rdfs.label, { language: ['fr', '*'] }).value
+```
+
+!> The result can be either English or German with equal probability.
+
+## Matching subtags
+
+In specific cases [subtags](https://tools.ietf.org/html/bcp47#section-2.2), such as `de-CH` can be matched to a given language. By analogy, it is also possible to find a subtag of any length by applying a "starts with" match.
+
+For example, in the snippet below the more specific subtag `de-CH-1996` will indeed be matched to the more general Swiss German `de-CH`
+
+
+
+```js
+const cf = require('clownface')
+const RDF = require('@rdfjs/dataset')
+const { literal } = require('@rdfjs/data-model')
+const { rdf, rdfs } = require('@tpluscode/rdf-ns-builders')
+
+// create two labels for a resource
+const bicycle = cf({ dataset: RDF.dataset() })
+ .node(rdf.Resource)
+ .addOut(rdfs.label, literal('Fahrrad', 'de'))
+ .addOut(rdfs.label, literal('Velo', 'de-CH-1996'))
+
+// finds a Swiss translation
+bicycle.out(rdfs.label, { language: 'de-CH' }).value
+```
+
+
+
+!> However, any exact match will always take precedence before the subtag match
+
+
+
+```js
+const cf = require('clownface')
+const RDF = require('@rdfjs/dataset')
+const { literal } = require('@rdfjs/data-model')
+const { rdf, rdfs } = require('@tpluscode/rdf-ns-builders')
+
+// create two labels for a resource
+const bicycle = cf({ dataset: RDF.dataset() })
+ .node(rdf.Resource)
+ .addOut(rdfs.label, literal('Fahrrad', 'de'))
+ .addOut(rdfs.label, literal('Velo', 'de-CH-1996'))
+
+// finds the standard German label
+bicycle.out(rdfs.label, { language: 'de' }).value
+```
+
+
diff --git a/lib/Clownface.js b/lib/Clownface.js
index d33b301..4edf722 100644
--- a/lib/Clownface.js
+++ b/lib/Clownface.js
@@ -262,12 +262,14 @@ class Clownface {
/**
* Creates a graph pointer to nodes which link the current pointer by `predicates`
* @param {Term|Term[]|Clownface|Clownface[]} predicates one or more RDF/JS term identifying a property
+ * @param {object} [options]
+ * @param {string | string[] | undefined} [options.language]
* @returns {Clownface}
*/
- out (predicates) {
+ out (predicates, options = {}) {
predicates = this._toTermArray(predicates)
- const context = this._context.reduce((all, current) => all.concat(current.out(predicates)), [])
+ const context = this._context.reduce((all, current) => all.concat(current.out(predicates, options)), [])
return Clownface.fromContext(context)
}
diff --git a/lib/Context.js b/lib/Context.js
index 5851ce0..698e2c3 100644
--- a/lib/Context.js
+++ b/lib/Context.js
@@ -1,6 +1,7 @@
const inArray = require('./inArray')
const term = require('./term')
const toArray = require('./toArray')
+const { createLanguageMapper } = require('../lib/languageTag')
class Context {
constructor ({ dataset, graph, value, factory, namespace }) {
@@ -27,9 +28,18 @@ class Context {
})
}
- out (predicate) {
- return this.matchProperty(toArray(this.term), predicate, null, toArray(this.graph), 'object').map(subject => {
- return this.clone({ value: subject })
+ out (predicate, { language }) {
+ let objects = this.matchProperty(toArray(this.term), predicate, null, toArray(this.graph), 'object')
+
+ if (typeof language !== 'undefined') {
+ const languages = (typeof language === 'string' ? [language] : language)
+ const getLiteralsForLanguage = createLanguageMapper(objects)
+
+ objects = languages.map(getLiteralsForLanguage).find(Boolean) || []
+ }
+
+ return objects.map(object => {
+ return this.clone({ value: object })
})
}
diff --git a/lib/fromPrimitive.js b/lib/fromPrimitive.js
index c454d66..ad37bf7 100644
--- a/lib/fromPrimitive.js
+++ b/lib/fromPrimitive.js
@@ -1,7 +1,7 @@
const rdf = require('@rdfjs/data-model')
-const namespace = require('@rdfjs/namespace')
+const namespace = require('./namespace')
-const xsd = namespace('http://www.w3.org/2001/XMLSchema#')
+const { xsd } = namespace(rdf)
function booleanToLiteral (value, factory = rdf) {
if (typeof value !== 'boolean') {
diff --git a/lib/languageTag.js b/lib/languageTag.js
new file mode 100644
index 0000000..8f3804a
--- /dev/null
+++ b/lib/languageTag.js
@@ -0,0 +1,47 @@
+const RDF = require('@rdfjs/data-model')
+const namespace = require('./namespace')
+
+const ns = namespace(RDF)
+
+function mapLiteralsByLanguage (map, current) {
+ const notLiteral = current.termType !== 'Literal'
+ const notStringLiteral = ns.langString.equals(current.datatype) || ns.xsd.string.equals(current.datatype)
+
+ if (notLiteral || !notStringLiteral) return map
+
+ const language = current.language.toLowerCase()
+
+ if (map.has(language)) {
+ map.get(language).push(current)
+ } else {
+ map.set(language, [current])
+ }
+
+ return map
+}
+
+function createLanguageMapper (objects) {
+ const literalsByLanguage = objects.reduce(mapLiteralsByLanguage, new Map())
+ const langMapEntries = [...literalsByLanguage.entries()]
+
+ return language => {
+ const languageLowerCase = language.toLowerCase()
+
+ if (languageLowerCase === '*') {
+ return langMapEntries[0] && langMapEntries[0][1]
+ }
+
+ const exactMatch = literalsByLanguage.get(languageLowerCase)
+ if (exactMatch) {
+ return exactMatch
+ }
+
+ const secondaryMatches = langMapEntries.find(([entryLanguage]) => entryLanguage.startsWith(languageLowerCase))
+
+ return secondaryMatches && secondaryMatches[1]
+ }
+}
+
+module.exports = {
+ createLanguageMapper
+}
diff --git a/lib/namespace.js b/lib/namespace.js
index bff0c18..781c27e 100644
--- a/lib/namespace.js
+++ b/lib/namespace.js
@@ -1,8 +1,16 @@
+const namespace = require('@rdfjs/namespace')
-const ns = (factory) => ({
- first: factory.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#first'),
- nil: factory.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'),
- rest: factory.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest')
-})
+const ns = (factory) => {
+ const xsd = namespace('http://www.w3.org/2001/XMLSchema#', { factory })
+ const rdf = namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#', { factory })
+
+ return {
+ first: rdf.first,
+ nil: rdf.nil,
+ rest: rdf.rest,
+ langString: rdf.langString,
+ xsd
+ }
+}
module.exports = ns
diff --git a/package.json b/package.json
index e81cb05..6b01867 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,8 @@
},
"devDependencies": {
"@rdfjs/parser-n3": "^1.1.2",
+ "@tpluscode/rdf-ns-builders": "^0.3.6",
+ "@tpluscode/rdf-string": "^0.2.15",
"docsify-cli": "^4.4.0",
"husky": "^4.2.5",
"jsdoc-to-markdown": "^5.0.3",
@@ -39,6 +41,7 @@
"rdf-ext": "^1.3.0",
"rimraf": "^3.0.2",
"standard": "^12.0.1",
+ "string-to-stream": "^3.0.1",
"tbbt-ld": "^1.1.0"
},
"nyc": {
diff --git a/test/Clownface/out.js b/test/Clownface/out.js
index be2ce50..1bcd092 100644
--- a/test/Clownface/out.js
+++ b/test/Clownface/out.js
@@ -1,10 +1,13 @@
-/* global describe, it */
-
+const { describe, it } = require('mocha')
const assert = require('assert')
+const { turtle } = require('@tpluscode/rdf-string')
+const { rdfs } = require('@tpluscode/rdf-ns-builders')
const clownface = require('../..')
const loadExample = require('../support/example')
const rdf = require('../support/factory')
const Clownface = require('../../lib/Clownface')
+const { ex } = require('../support/namespace')
+const parse = require('../support/parse')
describe('.out', () => {
it('should be a function', () => {
@@ -65,4 +68,144 @@ describe('.out', () => {
assert.strictEqual(result._context.length, 2)
})
+
+ describe('with language option', () => {
+ const testData = turtle`${ex.ananas}
+ ${rdfs.label} "Pineapple" ;
+ ${rdfs.label} "Ananas"@pl ;
+ ${rdfs.label} "Ananas"@de ;
+ ${rdfs.label} "Ananász"@hu ;
+ ${rdfs.label} "Ananas"@sr-Latn ;
+ ${rdfs.label} "Ананас"@sr-Cyrl ;
+ .
+
+ ${ex.noLabels} ${rdfs.label} _:foo , ${ex.bar}, 41 .
+
+ ${ex.apple}
+ ${rdfs.label} "Apple"@en ;
+ ${rdfs.label} "Apfel"@de ;
+ ${rdfs.label} "Јабука"@sr-Cyrl .
+
+ ${ex.carrot}
+ ${rdfs.label} "Karotte"@de ;
+ ${rdfs.label} "Karotte"@de-AT ;
+ ${rdfs.label} "Rüebli"@de-CH ;
+ .
+
+ ${ex.eggplant}
+ ${rdfs.label} "Psianka podłużna"@pl, "Bakłażan"@pl, "Oberżyna"@pl .
+
+ ${ex.kongressstrasse}
+ ${rdfs.label} "Kongressstraße"@de ;
+ ${rdfs.label} "Kongreßstraße"@de-DE-1901 ;
+ .`.toString()
+
+ describe('filtered by single language parameter', () => {
+ it('should not return non-literals and non-string literals when language parameter is defined', async () => {
+ const apple = (await parse(testData)).node(ex.noLabels)
+
+ const label = apple.out(rdfs.label, { language: '' })
+
+ assert.strictEqual(label.terms.length, 0)
+ })
+
+ it('should return exact match for given language', async () => {
+ const apple = (await parse(testData)).node(ex.apple)
+
+ const label = apple.out(rdfs.label, { language: 'de' })
+
+ assert(label.term.equals(rdf.literal('Apfel', 'de')))
+ })
+
+ it('should return plain string when language is empty string', async () => {
+ const apple = (await parse(testData)).node(ex.ananas)
+
+ const label = apple.out(rdfs.label, { language: '' })
+
+ assert(label.term.equals(rdf.literal('Pineapple')))
+ })
+
+ it('should skip pointers which do not have matching language', async () => {
+ const apple = (await parse(testData)).node(ex.ananas)
+
+ const label = apple.out(rdfs.label, { language: 'en' })
+
+ assert.strictEqual(label.values.length, 0)
+ })
+
+ it('should return any result for wildcard language', async () => {
+ const apple = (await parse(testData)).node(ex.ananas)
+
+ const label = apple.out(rdfs.label, { language: '*' })
+
+ assert.ok(label.term)
+ })
+
+ it('should return all matching literals for a language', async () => {
+ const apple = (await parse(testData)).node(ex.eggplant)
+
+ const label = apple.out(rdfs.label, { language: 'pl' })
+
+ assert.strictEqual(label.terms.length, 3)
+ })
+
+ it('should return all matching literals for a wildcard language', async () => {
+ const apple = (await parse(testData)).node(ex.eggplant)
+
+ const label = apple.out(rdfs.label, { language: '*' })
+
+ assert.strictEqual(label.terms.length, 3)
+ })
+
+ it('should be case-insensitive', async () => {
+ const apple = (await parse(testData)).node(ex.apple)
+
+ const label = apple.out(rdfs.label, { language: 'SR-cyrl' })
+
+ assert(label.term.equals(rdf.literal('Јабука', 'sr-Cyrl')))
+ })
+
+ it('should match secondary language tag by primary', async () => {
+ const apple = (await parse(testData)).node(ex.apple)
+
+ const label = apple.out(rdfs.label, { language: 'sr' })
+
+ assert(label.term.equals(rdf.literal('Јабука', 'sr-Cyrl')))
+ })
+
+ it('should match secondary language tag by primary regardless of case', async () => {
+ const apple = (await parse(testData)).node(ex.apple)
+
+ const label = apple.out(rdfs.label, { language: 'SR' })
+
+ assert(label.term.equals(rdf.literal('Јабука', 'sr-Cyrl')))
+ })
+
+ it('should match tertiary tag by secondary language', async () => {
+ const apple = (await parse(testData)).node(ex.kongressstrasse)
+
+ const label = apple.out(rdfs.label, { language: 'de-DE' })
+
+ assert(label.term.equals(rdf.literal('Kongreßstraße', 'de-DE-1901')))
+ })
+ })
+
+ describe('filtered by multiple languages', () => {
+ it('should choose first match', async () => {
+ const apple = (await parse(testData)).node(ex.apple)
+
+ const label = apple.out(rdfs.label, { language: ['fr', 'no', 'be', 'en', 'de'] })
+
+ assert(label.term.equals(rdf.literal('Apple', 'en')))
+ })
+
+ it('should choose exact match over secondary language', async () => {
+ const apple = (await parse(testData)).node(ex.carrot)
+
+ const label = apple.out(rdfs.label, { language: ['de-1901', 'de'] })
+
+ assert(label.term.equals(rdf.literal('Karotte', 'de')))
+ })
+ })
+ })
})
diff --git a/test/support/namespace.js b/test/support/namespace.js
index 37f69f7..616d83f 100644
--- a/test/support/namespace.js
+++ b/test/support/namespace.js
@@ -1,10 +1,12 @@
const rdf = require('rdf-ext')
+const namespace = require('@rdfjs/namespace')
const ns = {
first: rdf.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#first'),
list: rdf.namedNode('http://example.org/list'),
nil: rdf.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'),
- rest: rdf.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest')
+ rest: rdf.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'),
+ ex: namespace('http://example.org/')
}
module.exports = ns
diff --git a/test/support/parse.js b/test/support/parse.js
new file mode 100644
index 0000000..d1a407b
--- /dev/null
+++ b/test/support/parse.js
@@ -0,0 +1,14 @@
+const $rdf = require('rdf-ext')
+const toStream = require('string-to-stream')
+const Parser = require('@rdfjs/parser-n3')
+const cf = require('../../')
+
+const parser = new Parser()
+
+async function parse (string) {
+ const dataset = await $rdf.dataset().import(parser.import(toStream(string)))
+
+ return cf({ dataset })
+}
+
+module.exports = parse