glossarist 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/concept-model/README.md +44 -0
- package/data/concept-model/shapes/glossarist.shacl.ttl +704 -0
- package/package.json +24 -4
- package/src/concept-collection.js +57 -0
- package/src/concept-parser.js +1 -1
- package/src/concept-reader.js +1 -1
- package/src/concept-serializer.js +1 -1
- package/src/gcr-reader.js +1 -1
- package/src/index.js +15 -0
- package/src/models/bibliography-data.js +1 -1
- package/src/models/concept.js +6 -0
- package/src/models/dataset-color.js +79 -0
- package/src/models/designation.js +54 -0
- package/src/models/gcr-metadata.js +1 -1
- package/src/models/index.d.ts +121 -4
- package/src/models/index.js +13 -0
- package/src/models/register.js +99 -1
- package/src/models/relation-categories.js +151 -0
- package/src/models/relation-colors.js +78 -0
- package/src/rdf/deterministic-id.js +17 -0
- package/src/rdf/document-writer.js +87 -0
- package/src/rdf/gloss-concept.js +43 -0
- package/src/rdf/gloss-designation.js +72 -0
- package/src/rdf/gloss-detailed-definition.js +69 -0
- package/src/rdf/gloss-localized-concept.js +72 -0
- package/src/rdf/gloss-source.js +51 -0
- package/src/rdf/index.d.ts +107 -0
- package/src/rdf/index.js +27 -0
- package/src/rdf/normalize-enum.js +23 -0
- package/src/rdf/predicates.d.ts +443 -0
- package/src/rdf/predicates.js +245 -0
- package/src/rdf/prefixes.js +31 -0
- package/src/rdf/shacl.js +98 -0
- package/src/rdf/terms.js +15 -0
- package/src/transforms/concept-to-gloss.transform.js +75 -0
- package/src/transforms/index.d.ts +26 -0
- package/src/transforms/index.js +1 -0
- package/src/v1-reader.js +1 -1
- package/src/validators/gcr-validator.js +1 -1
package/src/models/register.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GlossaristModel } from './base.js';
|
|
2
2
|
import { Section } from './section.js';
|
|
3
|
+
import { resolveColor } from './dataset-color.js';
|
|
3
4
|
|
|
4
5
|
export const REGISTER_STATUSES = Object.freeze([
|
|
5
6
|
'current',
|
|
@@ -27,6 +28,7 @@ export class Register extends GlossaristModel {
|
|
|
27
28
|
this.languageOrder = data.languageOrder ?? data.language_order ?? [];
|
|
28
29
|
this.ordering = data.ordering ?? null;
|
|
29
30
|
this.logo = data.logo ?? null;
|
|
31
|
+
this.color = data.color ?? null;
|
|
30
32
|
this.description = data.description ?? {};
|
|
31
33
|
this.about = data.about ?? {};
|
|
32
34
|
this.provenance = data.provenance ?? [];
|
|
@@ -45,7 +47,7 @@ export class Register extends GlossaristModel {
|
|
|
45
47
|
'status', 'supersedes', 'owner',
|
|
46
48
|
'sourceRepo', 'source_repo', 'tags',
|
|
47
49
|
'languages', 'languageOrder', 'language_order',
|
|
48
|
-
'ordering', 'logo', 'description', 'about',
|
|
50
|
+
'ordering', 'logo', 'color', 'description', 'about',
|
|
49
51
|
'provenance', 'contributors', 'sections',
|
|
50
52
|
]);
|
|
51
53
|
const extra = {};
|
|
@@ -64,11 +66,60 @@ export class Register extends GlossaristModel {
|
|
|
64
66
|
return null;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Walks the section tree upward from `sectionId`, returning the
|
|
70
|
+
// ancestor chain in immediate-parent-first order. The returned array
|
|
71
|
+
// does NOT include sectionId itself. Returns [] when sectionId is
|
|
72
|
+
// unknown or is a top-level section.
|
|
73
|
+
//
|
|
74
|
+
// Mirrors glossarist-ruby's section-cascading walk (commit 43dca6b)
|
|
75
|
+
// and the ontology's owl:TransitiveProperty declaration on
|
|
76
|
+
// gloss:hasParentSection.
|
|
77
|
+
sectionAncestorIds(sectionId) {
|
|
78
|
+
if (!sectionId) return [];
|
|
79
|
+
for (const root of this.sections) {
|
|
80
|
+
const chain = _ancestorChain(root, sectionId);
|
|
81
|
+
if (chain) return chain;
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Returns the closure of `sectionId`: the section plus all of its
|
|
87
|
+
// ancestors. Concept-section membership tests should intersect the
|
|
88
|
+
// concept's section list with this closure so a concept in section
|
|
89
|
+
// "3.1.1" matches a filter on "3.1" or "3".
|
|
90
|
+
sectionClosure(sectionId) {
|
|
91
|
+
return [sectionId, ...this.sectionAncestorIds(sectionId)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Returns all section IDs the concept belongs to: the concept's own
|
|
95
|
+
// sections plus every ancestor of each. Concept-side section
|
|
96
|
+
// membership comes from `concept.sections` if set, otherwise from
|
|
97
|
+
// `concept.groups` (a flat list of section IDs as strings).
|
|
98
|
+
//
|
|
99
|
+
// Returns the union (deduped) so callers can do a single
|
|
100
|
+
// intersection test.
|
|
101
|
+
conceptSectionIds(concept) {
|
|
102
|
+
const own = conceptSectionIdList(concept);
|
|
103
|
+
if (own.length === 0) return [];
|
|
104
|
+
const closure = new Set();
|
|
105
|
+
for (const id of own) {
|
|
106
|
+
for (const ancestor of this.sectionClosure(id)) closure.add(ancestor);
|
|
107
|
+
}
|
|
108
|
+
return [...closure];
|
|
109
|
+
}
|
|
110
|
+
|
|
67
111
|
sectionName(sectionId, lang) {
|
|
68
112
|
const section = this.sectionById(sectionId);
|
|
69
113
|
return section ? section.name(lang) : null;
|
|
70
114
|
}
|
|
71
115
|
|
|
116
|
+
// Returns the hex color for the requested UI mode, falling back to
|
|
117
|
+
// the single hex when the spec is a string. Returns null when no
|
|
118
|
+
// color is set or when the requested mode is missing.
|
|
119
|
+
resolvedColor(mode) {
|
|
120
|
+
return resolveColor(this.color, mode);
|
|
121
|
+
}
|
|
122
|
+
|
|
72
123
|
toJSON() {
|
|
73
124
|
const obj = { ...this._raw, schema_version: this.schemaVersion };
|
|
74
125
|
if (this.id != null) obj.id = this.id;
|
|
@@ -86,6 +137,11 @@ export class Register extends GlossaristModel {
|
|
|
86
137
|
if (this.languageOrder.length > 0) obj.languageOrder = [...this.languageOrder];
|
|
87
138
|
if (this.ordering != null) obj.ordering = this.ordering;
|
|
88
139
|
if (this.logo != null) obj.logo = { ...this.logo };
|
|
140
|
+
if (this.color != null) {
|
|
141
|
+
obj.color = (typeof this.color === 'object' && this.color !== null)
|
|
142
|
+
? { ...this.color }
|
|
143
|
+
: this.color;
|
|
144
|
+
}
|
|
89
145
|
if (Object.keys(this.description).length > 0) obj.description = { ...this.description };
|
|
90
146
|
if (Object.keys(this.about).length > 0) obj.about = { ...this.about };
|
|
91
147
|
if (this.provenance.length > 0) obj.provenance = this.provenance.map(p => ({ ...p }));
|
|
@@ -108,3 +164,45 @@ export class Register extends GlossaristModel {
|
|
|
108
164
|
});
|
|
109
165
|
}
|
|
110
166
|
}
|
|
167
|
+
|
|
168
|
+
// Recursive walk: returns the ancestor chain of `targetId` within the
|
|
169
|
+
// subtree rooted at `section`, or null if targetId is not in this
|
|
170
|
+
// subtree. The chain is immediate-parent-first; the section itself is
|
|
171
|
+
// not included. Built root-first, then reversed for the documented order.
|
|
172
|
+
function _ancestorChain(section, targetId, ancestors = []) {
|
|
173
|
+
if (section.id === targetId) return [...ancestors].reverse();
|
|
174
|
+
for (const child of section.children) {
|
|
175
|
+
const found = _ancestorChain(child, targetId, [...ancestors, section.id]);
|
|
176
|
+
if (found) return found;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Returns the list of section IDs a concept claims membership in.
|
|
182
|
+
// Concepts may carry section IDs via either `sections` (preferred) or
|
|
183
|
+
// `groups` (legacy). Each entry may be a string or an object with an
|
|
184
|
+
// `id` field; both forms are flattened to a string list.
|
|
185
|
+
function conceptSectionIdList(concept) {
|
|
186
|
+
if (!concept) return [];
|
|
187
|
+
const fromSections = concept.sections
|
|
188
|
+
? _flattenSectionIds(concept.sections)
|
|
189
|
+
: [];
|
|
190
|
+
const fromGroups = concept.groups
|
|
191
|
+
? _flattenSectionIds(concept.groups)
|
|
192
|
+
: [];
|
|
193
|
+
return [...fromSections, ...fromGroups];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _flattenSectionIds(value) {
|
|
197
|
+
if (!Array.isArray(value)) return [];
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const entry of value) {
|
|
200
|
+
if (typeof entry === 'string') {
|
|
201
|
+
out.push(entry);
|
|
202
|
+
} else if (entry && typeof entry === 'object') {
|
|
203
|
+
const id = entry.id ?? entry.sectionId ?? entry.ref?.id;
|
|
204
|
+
if (id) out.push(String(id));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Categorization SSOT for the 52 glossarist relationship types.
|
|
2
|
+
//
|
|
3
|
+
// Each type in RELATIONSHIP_TYPES belongs to exactly one category. The
|
|
4
|
+
// categories are semantic buckets used for filtering and visualization
|
|
5
|
+
// in concept-browser. Bringing the categorization into glossarist-js
|
|
6
|
+
// means all consumers see the same buckets.
|
|
7
|
+
|
|
8
|
+
import { RELATIONSHIP_TYPES } from './related-concept.js';
|
|
9
|
+
|
|
10
|
+
// Category definitions. The `types` array is the exhaustive list of
|
|
11
|
+
// relationship-type local names that fall into the category. Each type
|
|
12
|
+
// must appear in exactly one category (verified by MECE test).
|
|
13
|
+
export const RELATION_CATEGORIES = Object.freeze({
|
|
14
|
+
lifecycle: Object.freeze({
|
|
15
|
+
label: 'Lifecycle',
|
|
16
|
+
description: 'Concept lifecycle transitions: supersession, deprecation, replacement.',
|
|
17
|
+
types: Object.freeze([
|
|
18
|
+
'deprecates', 'deprecated_by',
|
|
19
|
+
'supersedes', 'superseded_by',
|
|
20
|
+
'replaces', 'replaced_by',
|
|
21
|
+
'invalidates', 'invalidated_by',
|
|
22
|
+
'retires', 'retired_by',
|
|
23
|
+
]),
|
|
24
|
+
}),
|
|
25
|
+
|
|
26
|
+
hierarchical: Object.freeze({
|
|
27
|
+
label: 'Hierarchical',
|
|
28
|
+
description: 'Broader/narrower relations, including SKOS and ISO 25964 generic/partitive/instantial.',
|
|
29
|
+
types: Object.freeze([
|
|
30
|
+
'broader', 'narrower',
|
|
31
|
+
'broader_generic', 'narrower_generic',
|
|
32
|
+
'broader_partitive', 'narrower_partitive',
|
|
33
|
+
'broader_instantial', 'narrower_instantial',
|
|
34
|
+
]),
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
associative: Object.freeze({
|
|
38
|
+
label: 'Associative',
|
|
39
|
+
description: 'Generic see-also references between concepts.',
|
|
40
|
+
types: Object.freeze([
|
|
41
|
+
'see', 'related_concept',
|
|
42
|
+
'related_concept_broader', 'related_concept_narrower',
|
|
43
|
+
'references',
|
|
44
|
+
]),
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
comparative: Object.freeze({
|
|
48
|
+
label: 'Comparative',
|
|
49
|
+
description: 'Compare / contrast relations between concepts.',
|
|
50
|
+
types: Object.freeze(['compare', 'contrast']),
|
|
51
|
+
}),
|
|
52
|
+
|
|
53
|
+
spatiotemporal: Object.freeze({
|
|
54
|
+
label: 'Spatiotemporal',
|
|
55
|
+
description: 'Sequential, spatial, and temporal relations.',
|
|
56
|
+
types: Object.freeze([
|
|
57
|
+
'sequentially_related_concept',
|
|
58
|
+
'spatially_related_concept',
|
|
59
|
+
'temporally_related_concept',
|
|
60
|
+
]),
|
|
61
|
+
}),
|
|
62
|
+
|
|
63
|
+
lexical: Object.freeze({
|
|
64
|
+
label: 'Lexical',
|
|
65
|
+
description: 'Lexical relations between designations across languages.',
|
|
66
|
+
types: Object.freeze(['homograph', 'false_friend']),
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
mapping: Object.freeze({
|
|
70
|
+
label: 'Mapping',
|
|
71
|
+
description: 'SKOS mapping properties for cross-vocabulary alignment.',
|
|
72
|
+
types: Object.freeze([
|
|
73
|
+
'equivalent', 'close_match',
|
|
74
|
+
'broad_match', 'narrow_match', 'related_match',
|
|
75
|
+
]),
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
conceptInstance: Object.freeze({
|
|
79
|
+
label: 'Concept / Instance',
|
|
80
|
+
description: 'ISO 19135 concept-to-concept relations: definition, part, instance, inheritance.',
|
|
81
|
+
types: Object.freeze([
|
|
82
|
+
'has_concept', 'is_concept_of',
|
|
83
|
+
'instance_of', 'has_instance',
|
|
84
|
+
'has_definition', 'definition_of',
|
|
85
|
+
'has_part', 'is_part_of',
|
|
86
|
+
'inherits', 'inherited_by',
|
|
87
|
+
]),
|
|
88
|
+
}),
|
|
89
|
+
|
|
90
|
+
versioning: Object.freeze({
|
|
91
|
+
label: 'Versioning',
|
|
92
|
+
description: 'Version lineage within the same concept identity.',
|
|
93
|
+
types: Object.freeze([
|
|
94
|
+
'has_version', 'version_of',
|
|
95
|
+
'current_version', 'current_version_of',
|
|
96
|
+
]),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Reverse index built once. Maps each type local name to its category.
|
|
101
|
+
const _CATEGORY_BY_TYPE = (() => {
|
|
102
|
+
const map = new Map();
|
|
103
|
+
for (const [cat, def] of Object.entries(RELATION_CATEGORIES)) {
|
|
104
|
+
for (const t of def.types) map.set(t, cat);
|
|
105
|
+
}
|
|
106
|
+
return map;
|
|
107
|
+
})();
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the category key for the given relationship type, or null
|
|
111
|
+
* when the type is not categorized (e.g. an unrecognized custom type).
|
|
112
|
+
*/
|
|
113
|
+
export function categoryOf(type) {
|
|
114
|
+
return _CATEGORY_BY_TYPE.get(type) ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns the category definition ({ label, description, types }) for
|
|
119
|
+
* the given category key, or null when the key is unknown.
|
|
120
|
+
*/
|
|
121
|
+
export function categoryDefinition(categoryKey) {
|
|
122
|
+
return RELATION_CATEGORIES[categoryKey] ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* MECE sanity check. Returns the list of relationship types that are
|
|
127
|
+
* NOT categorized (i.e. present in RELATIONSHIP_TYPES but missing from
|
|
128
|
+
* RELATION_CATEGORIES). Used by the test suite to verify SSOT drift.
|
|
129
|
+
*/
|
|
130
|
+
export function uncategorizedTypes() {
|
|
131
|
+
return RELATIONSHIP_TYPES.filter(t => !_CATEGORY_BY_TYPE.has(t));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* MECE sanity check. Returns a list of { type, categories } entries
|
|
136
|
+
* for any type that appears in MORE than one category. Should be
|
|
137
|
+
* empty when the categorization is mutually exclusive.
|
|
138
|
+
*/
|
|
139
|
+
export function duplicatedTypes() {
|
|
140
|
+
const counts = new Map();
|
|
141
|
+
for (const def of Object.values(RELATION_CATEGORIES)) {
|
|
142
|
+
for (const t of def.types) {
|
|
143
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const [type, count] of counts) {
|
|
148
|
+
if (count > 1) out.push({ type, count });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Default color palette for relation categories and individual types.
|
|
2
|
+
//
|
|
3
|
+
// The defaults mirror concept-browser/data/colors.json. Per-deployment
|
|
4
|
+
// overrides pass through resolveRelationColor(type, { overrides, mode })
|
|
5
|
+
// and take precedence over category defaults.
|
|
6
|
+
|
|
7
|
+
import { categoryOf, categoryDefinition } from './relation-categories.js';
|
|
8
|
+
|
|
9
|
+
// One { light, dark } pair per category. Keeping these in glossarist-js
|
|
10
|
+
// lets the defaults ship with the library; consumers only need to
|
|
11
|
+
// customize when they want to deviate.
|
|
12
|
+
export const RELATION_COLOR_DEFAULTS = Object.freeze({
|
|
13
|
+
byCategory: Object.freeze({
|
|
14
|
+
lifecycle: { light: '#B43A2E', dark: '#F87171' },
|
|
15
|
+
hierarchical: { light: '#1F6FEB', dark: '#6EA8FE' },
|
|
16
|
+
associative: { light: '#6B7280', dark: '#9CA3AF' },
|
|
17
|
+
comparative: { light: '#A855F7', dark: '#C4B5FD' },
|
|
18
|
+
spatiotemporal: { light: '#0D9488', dark: '#5EEAD4' },
|
|
19
|
+
lexical: { light: '#D97706', dark: '#FBBF24' },
|
|
20
|
+
mapping: { light: '#2563EB', dark: '#93BBFD' },
|
|
21
|
+
conceptInstance:{ light: '#65A30D', dark: '#A3E635' },
|
|
22
|
+
versioning: { light: '#9333EA', dark: '#C4B5FD' },
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
// Per-type overrides. Use the same { light, dark } shape. Keys are
|
|
26
|
+
// relationship-type local names. Empty by default — consumers may
|
|
27
|
+
// populate this through `overrides.byType` at call time.
|
|
28
|
+
byType: Object.freeze({}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the color for a relationship type.
|
|
33
|
+
*
|
|
34
|
+
* Resolution order:
|
|
35
|
+
* 1. overrides.byType[type]
|
|
36
|
+
* 2. RELATION_COLOR_DEFAULTS.byType[type]
|
|
37
|
+
* 3. overrides.byCategory[category]
|
|
38
|
+
* 4. RELATION_COLOR_DEFAULTS.byCategory[category]
|
|
39
|
+
* 5. null (uncategorized or unrecognized type)
|
|
40
|
+
*
|
|
41
|
+
* @param {string} type - relationship type local name
|
|
42
|
+
* @param {{ overrides?: { byType?: object, byCategory?: object }, mode?: 'light'|'dark' }} [options]
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRelationColor(type, options = {}) {
|
|
46
|
+
const mode = options.mode ?? 'light';
|
|
47
|
+
const overrides = options.overrides ?? {};
|
|
48
|
+
|
|
49
|
+
const typeOverride = overrides.byType?.[type] ?? RELATION_COLOR_DEFAULTS.byType[type];
|
|
50
|
+
if (typeOverride) return pickMode(typeOverride, mode);
|
|
51
|
+
|
|
52
|
+
const category = categoryOf(type);
|
|
53
|
+
if (!category) return null;
|
|
54
|
+
|
|
55
|
+
const categoryOverride = overrides.byCategory?.[category] ?? RELATION_COLOR_DEFAULTS.byCategory[category];
|
|
56
|
+
if (!categoryOverride) return null;
|
|
57
|
+
|
|
58
|
+
return pickMode(categoryOverride, mode);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the { light, dark } pair for a category, merging overrides
|
|
63
|
+
* over defaults. Useful for UIs that want to render a legend.
|
|
64
|
+
*/
|
|
65
|
+
export function categoryColorPair(categoryKey, overrides = {}) {
|
|
66
|
+
const def = categoryDefinition(categoryKey);
|
|
67
|
+
if (!def) return null;
|
|
68
|
+
const fromOverride = overrides.byCategory?.[categoryKey];
|
|
69
|
+
const fromDefault = RELATION_COLOR_DEFAULTS.byCategory[categoryKey];
|
|
70
|
+
if (!fromDefault && !fromOverride) return null;
|
|
71
|
+
return { ...(fromDefault ?? {}), ...(fromOverride ?? {}) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickMode(pair, mode) {
|
|
75
|
+
if (pair == null) return null;
|
|
76
|
+
if (typeof pair === 'string') return pair;
|
|
77
|
+
return pair[mode] ?? pair.light ?? pair.dark ?? null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// 12-char hex prefix, mirroring glossarist-ruby's
|
|
4
|
+
// `Digest::MD5.hexdigest(content)[0..11]`. Same content → same ID across
|
|
5
|
+
// processes, machines, and language runtimes (Ruby vs JS).
|
|
6
|
+
export function deterministicId(...parts) {
|
|
7
|
+
const seed = parts.filter(p => p !== null && p !== undefined).join('|');
|
|
8
|
+
return createHash('md5').update(seed).digest('hex').slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Stable blank-node label for an RDF fragment identified by its parent
|
|
12
|
+
// subject + role + index. Useful for reified resources like DetailedDefinition,
|
|
13
|
+
// Designation, ConceptSource where the bnode ID must be deterministic to
|
|
14
|
+
// produce byte-equivalent Turtle across runs.
|
|
15
|
+
export function deterministicBnode(subject, role, index) {
|
|
16
|
+
return `_:b${deterministicId(subject, role, index)}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Quads → canonical Turtle / N-Triples / JSON-LD document.
|
|
2
|
+
//
|
|
3
|
+
// Turtle is produced via N3.Writer. JSON-LD via the `jsonld` package
|
|
4
|
+
// (fromRDF + compact against the canonical context). N-Triples is provided
|
|
5
|
+
// as a stable fallback that doesn't depend on prefix maps.
|
|
6
|
+
import { Writer as N3Writer } from 'n3';
|
|
7
|
+
import jsonld from 'jsonld';
|
|
8
|
+
import { PREFIXES } from './predicates.js';
|
|
9
|
+
|
|
10
|
+
// Collects all quads yielded by an emitter into an array. Useful for tests
|
|
11
|
+
// and for callers that want to inspect the quad stream directly.
|
|
12
|
+
export function collectQuads(quadsIterable) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const q of quadsIterable) out.push(q);
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Returns a Turtle serialization of `quads` using the canonical prefix map
|
|
19
|
+
// generated from the vendored concept-model JSON-LD context. Quads are
|
|
20
|
+
// sorted deterministically so the same input always produces byte-equivalent
|
|
21
|
+
// output across runs.
|
|
22
|
+
export function writeTurtle(quads, { prefixes = PREFIXES } = {}) {
|
|
23
|
+
const sorted = sortQuads(quads);
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const writer = new N3Writer({ prefixes, format: 'Turtle' });
|
|
26
|
+
for (const q of sorted) writer.addQuad(q);
|
|
27
|
+
writer.end((err, result) => {
|
|
28
|
+
if (err) reject(err);
|
|
29
|
+
else resolve(result);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns an N-Triples serialization of `quads` — no prefixes, no bnode
|
|
35
|
+
// shortening. Useful when consumers need a totally stable wire format.
|
|
36
|
+
export function writeNTriples(quads) {
|
|
37
|
+
const sorted = sortQuads(quads);
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const writer = new N3Writer({ format: 'N-Triples' });
|
|
40
|
+
for (const q of sorted) writer.addQuad(q);
|
|
41
|
+
writer.end((err, result) => {
|
|
42
|
+
if (err) reject(err);
|
|
43
|
+
else resolve(result);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Returns a compacted JSON-LD serialization of `quads`. The context defaults
|
|
49
|
+
// to the canonical glossarist JSON-LD context derived from the vendored
|
|
50
|
+
// PREFIXES. Quads are sorted first for deterministic output.
|
|
51
|
+
export async function writeJsonld(quads, { context = defaultJsonldContext() } = {}) {
|
|
52
|
+
const sorted = sortQuads(quads);
|
|
53
|
+
const nquads = await writeNTriples(sorted);
|
|
54
|
+
const expanded = await jsonld.fromRDF(nquads, { format: 'application/n-quads' });
|
|
55
|
+
const compacted = await jsonld.compact(expanded, context);
|
|
56
|
+
return JSON.stringify(compacted, null, 2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function defaultJsonldContext() {
|
|
60
|
+
const ctx = {};
|
|
61
|
+
for (const [prefix, uri] of Object.entries(PREFIXES)) {
|
|
62
|
+
ctx[prefix] = uri;
|
|
63
|
+
}
|
|
64
|
+
return ctx;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Sort by (subject.value, predicate.value, object.termType, object.value,
|
|
68
|
+
// graph.value). Bnodes sort by their generated ID which is deterministic
|
|
69
|
+
// because we use content-addressed IDs.
|
|
70
|
+
export function sortQuads(quads) {
|
|
71
|
+
return [...quads].sort(compareQuad);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function compareQuad(a, b) {
|
|
75
|
+
let cmp = cmpTerm(a.subject, b.subject);
|
|
76
|
+
if (cmp !== 0) return cmp;
|
|
77
|
+
cmp = cmpTerm(a.predicate, b.predicate);
|
|
78
|
+
if (cmp !== 0) return cmp;
|
|
79
|
+
cmp = cmpTerm(a.object, b.object);
|
|
80
|
+
if (cmp !== 0) return cmp;
|
|
81
|
+
return cmpTerm(a.graph, b.graph);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cmpTerm(a, b) {
|
|
85
|
+
if (a.termType !== b.termType) return a.termType.localeCompare(b.termType);
|
|
86
|
+
return String(a.value).localeCompare(String(b.value));
|
|
87
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Concept → RDF quads. Mirrors glossarist-ruby's `Rdf::GlossConcept`.
|
|
2
|
+
//
|
|
3
|
+
// URI shape: `<uriBase>/<registerId>/concept/<conceptId>`.
|
|
4
|
+
// Localized concepts: `<uriBase>/<registerId>/concept/<conceptId>/<lang>`.
|
|
5
|
+
import { PRED } from './predicates.js';
|
|
6
|
+
import { WELL_KNOWN } from './prefixes.js';
|
|
7
|
+
import { localizedConceptToQuads } from './gloss-localized-concept.js';
|
|
8
|
+
import { conceptSourceToQuads } from './gloss-source.js';
|
|
9
|
+
import { namedNode, literal, quad } from './terms.js';
|
|
10
|
+
|
|
11
|
+
export function conceptUri(concept, { registerId, uriBase }) {
|
|
12
|
+
const id = String(concept.id ?? concept.termid ?? '');
|
|
13
|
+
const base = String(uriBase ?? '').replace(/\/+$/, '');
|
|
14
|
+
return `${base}/${registerId}/concept/${id}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function* conceptToQuads(concept, options) {
|
|
18
|
+
const subjectUri = conceptUri(concept, options);
|
|
19
|
+
const s = namedNode(subjectUri);
|
|
20
|
+
|
|
21
|
+
yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.Concept));
|
|
22
|
+
yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(WELL_KNOWN.skosConcept));
|
|
23
|
+
|
|
24
|
+
yield quad(s, namedNode(PRED.gloss.identifier), literal(String(concept.id ?? concept.termid ?? '')));
|
|
25
|
+
|
|
26
|
+
if (concept.status) {
|
|
27
|
+
yield quad(s, namedNode(PRED.gloss.hasStatus), namedNode(`${PRED.gloss.$ns}status/${concept.status}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const lang of concept.languages) {
|
|
31
|
+
const lc = concept.localization(lang);
|
|
32
|
+
if (!lc) continue;
|
|
33
|
+
const lcUri = `${subjectUri}/${lang}`;
|
|
34
|
+
yield quad(s, namedNode(PRED.gloss.hasLocalization), namedNode(lcUri));
|
|
35
|
+
yield* localizedConceptToQuads(lc, { parentUri: subjectUri, language: lang });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let srcIndex = 0;
|
|
39
|
+
for (const source of concept.sources ?? []) {
|
|
40
|
+
yield* conceptSourceToQuads(source, { subjectUri, index: srcIndex });
|
|
41
|
+
srcIndex += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Designation → RDF quads. Mirrors glossarist-ruby's
|
|
2
|
+
// `Rdf::GlossDesignation` + the per-subtype classes (GlossExpression,
|
|
3
|
+
// GlossAbbreviation, etc.). The ontology class for each subtype is
|
|
4
|
+
// provided by the model itself via Designation#rdfClass (OCP: new
|
|
5
|
+
// subtypes register without editing this emitter).
|
|
6
|
+
import { PRED, PREFIXES } from './predicates.js';
|
|
7
|
+
import { SKOSXL, WELL_KNOWN } from './prefixes.js';
|
|
8
|
+
import { deterministicBnode } from './deterministic-id.js';
|
|
9
|
+
import { normalizeEnum } from './normalize-enum.js';
|
|
10
|
+
import { namedNode, literal, quad } from './terms.js';
|
|
11
|
+
|
|
12
|
+
const XSD_BOOLEAN = 'http://www.w3.org/2001/XMLSchema#boolean';
|
|
13
|
+
|
|
14
|
+
// Boolean flag fields on designation subtypes. Each entry maps a model
|
|
15
|
+
// field name to its ontology predicate. Only emitted when the value is
|
|
16
|
+
// truthy, so legacy data without the fields round-trips unchanged.
|
|
17
|
+
const BOOLEAN_FLAGS = [
|
|
18
|
+
['acronym', PRED.gloss.isAcronym],
|
|
19
|
+
['initialism', PRED.gloss.isInitialism],
|
|
20
|
+
['truncation', PRED.gloss.isTruncation],
|
|
21
|
+
['international', PRED.gloss.isInternational],
|
|
22
|
+
['absent', PRED.gloss.isAbsent],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Returns the SKOS-XL predicate (as a string URI) appropriate for this
|
|
26
|
+
// designation's normative status. Mirrors `skosxl_label_for` in Ruby.
|
|
27
|
+
// The dispatch lives on the Designation model so a new status requires
|
|
28
|
+
// a single edit there, not here.
|
|
29
|
+
export function skosxlLabelPredicate(designation) {
|
|
30
|
+
return designation.skosxlLabelPredicate(PREFIXES.skosxl);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Returns the matching plain-SKOS predicate URI (skos:prefLabel etc.).
|
|
34
|
+
// Used when emitting direct SKOS alongside reified SKOS-XL.
|
|
35
|
+
export function skosLabelPredicate(designation) {
|
|
36
|
+
return designation.skosLabelPredicate(PREFIXES.skos);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Emits quads for one designation. Yields them lazily so callers can
|
|
40
|
+
// compose many designations into a single stream without intermediate
|
|
41
|
+
// arrays.
|
|
42
|
+
//
|
|
43
|
+
// `subjectUri` is the URI of the LocalizedConcept that owns this designation.
|
|
44
|
+
// `index` is the position of the designation within its parent's terms list
|
|
45
|
+
// — used to make the bnode ID deterministic.
|
|
46
|
+
export function* designationToQuads(designation, { subjectUri, language, index }) {
|
|
47
|
+
const desigSubject = deterministicBnode(subjectUri, 'desig', index);
|
|
48
|
+
|
|
49
|
+
const labelPredicate = namedNode(skosxlLabelPredicate(designation));
|
|
50
|
+
yield quad(namedNode(subjectUri), labelPredicate, namedNode(desigSubject));
|
|
51
|
+
|
|
52
|
+
yield quad(namedNode(desigSubject), namedNode(WELL_KNOWN.rdfType), namedNode(`${PRED.gloss.$ns}${designation.rdfClass()}`));
|
|
53
|
+
yield quad(namedNode(desigSubject), namedNode(WELL_KNOWN.rdfType), namedNode(WELL_KNOWN.skosxlLabel));
|
|
54
|
+
yield quad(namedNode(desigSubject), namedNode(SKOSXL.literalForm), literal(designation.designation ?? '', language));
|
|
55
|
+
|
|
56
|
+
if (designation.normativeStatus) {
|
|
57
|
+
const statusToken = normalizeEnum(designation.normativeStatus);
|
|
58
|
+
if (statusToken) {
|
|
59
|
+
const statusUri = `${PRED.gloss.$ns}norm/${statusToken}`;
|
|
60
|
+
yield quad(namedNode(desigSubject), namedNode(PRED.gloss.normativeStatus), namedNode(statusUri));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Subtype-specific boolean flags. Skipping when falsy preserves
|
|
65
|
+
// backward compatibility with simpler designations.
|
|
66
|
+
const booleanType = namedNode(XSD_BOOLEAN);
|
|
67
|
+
for (const [field, predicate] of BOOLEAN_FLAGS) {
|
|
68
|
+
if (designation[field]) {
|
|
69
|
+
yield quad(namedNode(desigSubject), namedNode(predicate), literal('true', booleanType));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// DetailedDefinition → RDF quads. Mirrors glossarist-ruby's
|
|
2
|
+
// `Rdf::GlossDetailedDefinition`. Used for definitions, notes, examples,
|
|
3
|
+
// and annotations — they all share the same shape (content + optional
|
|
4
|
+
// sources + nested examples).
|
|
5
|
+
//
|
|
6
|
+
// The emitter takes a `linkPredicate` (e.g. PRED.gloss.hasDefinition,
|
|
7
|
+
// PRED.gloss.hasNote, PRED.gloss.hasExample) so callers can reuse it for
|
|
8
|
+
// any role.
|
|
9
|
+
import { PRED } from './predicates.js';
|
|
10
|
+
import { WELL_KNOWN } from './prefixes.js';
|
|
11
|
+
import { deterministicBnode } from './deterministic-id.js';
|
|
12
|
+
import { namedNode, literal, quad } from './terms.js';
|
|
13
|
+
|
|
14
|
+
export function* detailedDefinitionToQuads(definition, {
|
|
15
|
+
subjectUri, language, index, role,
|
|
16
|
+
}) {
|
|
17
|
+
const linkPredicate = linkPredicateFor(role);
|
|
18
|
+
const defSubject = deterministicBnode(subjectUri, role, index);
|
|
19
|
+
|
|
20
|
+
yield quad(namedNode(subjectUri), namedNode(linkPredicate), namedNode(defSubject));
|
|
21
|
+
yield quad(namedNode(defSubject), namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.DetailedDefinition));
|
|
22
|
+
|
|
23
|
+
if (definition.content) {
|
|
24
|
+
yield quad(
|
|
25
|
+
namedNode(defSubject),
|
|
26
|
+
namedNode(WELL_KNOWN.rdfValue),
|
|
27
|
+
literal(definition.content, language),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Plain SKOS direct literal — so non-SKOS-XL consumers see definitions.
|
|
32
|
+
// The reified form above carries the same text with sources/examples;
|
|
33
|
+
// this direct literal makes SPARQL `?c skos:definition ?d` work.
|
|
34
|
+
const directPredicate = directSkosPredicateFor(role);
|
|
35
|
+
if (directPredicate && definition.content) {
|
|
36
|
+
yield quad(namedNode(subjectUri), namedNode(directPredicate), literal(definition.content, language));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let exampleIndex = 0;
|
|
40
|
+
for (const example of definition.examples ?? []) {
|
|
41
|
+
yield* detailedDefinitionToQuads(example, {
|
|
42
|
+
subjectUri: defSubject, language, index: exampleIndex, role: 'hasScopedExample',
|
|
43
|
+
});
|
|
44
|
+
exampleIndex += 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function linkPredicateFor(role) {
|
|
49
|
+
switch (role) {
|
|
50
|
+
case 'hasDefinition': return PRED.gloss.hasDefinition;
|
|
51
|
+
case 'hasNote': return PRED.gloss.hasNote;
|
|
52
|
+
case 'hasExample': return PRED.gloss.hasExample;
|
|
53
|
+
case 'hasScopedExample': return PRED.gloss.hasScopedExample;
|
|
54
|
+
case 'hasAnnotation': return PRED.gloss.hasAnnotation;
|
|
55
|
+
default: throw new Error(`Unknown detailed-definition role: ${role}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function directSkosPredicateFor(role) {
|
|
60
|
+
switch (role) {
|
|
61
|
+
case 'hasDefinition': return PRED.skos.definition;
|
|
62
|
+
case 'hasNote': return PRED.skos.scopeNote;
|
|
63
|
+
case 'hasExample': return PRED.skos.example;
|
|
64
|
+
case 'hasScopedExample': return PRED.skos.example;
|
|
65
|
+
// No direct SKOS counterpart for annotations.
|
|
66
|
+
case 'hasAnnotation': return null;
|
|
67
|
+
default: return null;
|
|
68
|
+
}
|
|
69
|
+
}
|