@glossarist/concept-browser 0.7.77 → 0.7.80

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.
Files changed (58) hide show
  1. package/astro.config.mjs +1 -0
  2. package/package.json +2 -2
  3. package/scripts/bridge-to-astro.mjs +6 -1
  4. package/scripts/build-edges.js +154 -1
  5. package/scripts/generate-data.mjs +138 -54
  6. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  7. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  8. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  9. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  10. package/src/__tests__/config/page-types.test.ts +82 -0
  11. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  12. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  13. package/src/__tests__/ontology-registry.test.ts +2 -2
  14. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  15. package/src/__tests__/partitive-relation.test.ts +285 -0
  16. package/src/__tests__/sphere-math.test.ts +56 -0
  17. package/src/adapters/GraphDataSource.ts +22 -5
  18. package/src/adapters/UriRouter.ts +5 -0
  19. package/src/adapters/dataset-stats.ts +13 -0
  20. package/src/adapters/model-bridge.ts +1 -1
  21. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  22. package/src/adapters/types.ts +36 -0
  23. package/src/components/AppSidebar.vue +14 -57
  24. package/src/components/ConceptDetail.vue +29 -0
  25. package/src/components/ConceptDiffModal.vue +120 -0
  26. package/src/components/ConceptDiffView.vue +192 -0
  27. package/src/components/ConceptEditionRail.vue +61 -36
  28. package/src/components/PartitiveRelationList.vue +139 -0
  29. package/src/components/RelationSphere.vue +2 -36
  30. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  31. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  32. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  33. package/src/composables/use-concept-diff.ts +231 -0
  34. package/src/composables/use-concept-edges.ts +98 -5
  35. package/src/composables/use-dataset-stats.ts +52 -0
  36. package/src/config/page-types.ts +67 -0
  37. package/src/config/types.ts +6 -1
  38. package/src/config/use-site-config.ts +13 -29
  39. package/src/data/taxonomies.json +24 -1
  40. package/src/i18n/locales/eng.yml +6 -0
  41. package/src/utils/partitive-relation-styling.ts +62 -0
  42. package/src/utils/sphere-math.ts +54 -0
  43. package/src/views/DatasetView.vue +5 -3
  44. package/src/views/StatsView.vue +9 -22
  45. package/src/islands/BibliographyIsland.vue +0 -26
  46. package/src/islands/ConceptDetailIsland.vue +0 -27
  47. package/src/islands/ConceptDiffIsland.vue +0 -214
  48. package/src/islands/ConceptIsland.vue +0 -102
  49. package/src/islands/GraphIsland.vue +0 -15
  50. package/src/islands/GroupRendererIsland.vue +0 -15
  51. package/src/islands/HeaderIsland.vue +0 -10
  52. package/src/islands/NonVerbalIsland.vue +0 -75
  53. package/src/islands/RelationSphereIsland.vue +0 -20
  54. package/src/islands/SearchIsland.vue +0 -23
  55. package/src/islands/SeriesCardIsland.vue +0 -12
  56. package/src/islands/ShortcutsIsland.vue +0 -13
  57. package/src/islands/SidebarIsland.vue +0 -15
  58. package/src/islands/SupersessionDiffIsland.vue +0 -267
package/astro.config.mjs CHANGED
@@ -20,6 +20,7 @@ export default defineConfig({
20
20
  base,
21
21
  output: 'static',
22
22
  publicDir: resolve(process.cwd(), 'public'),
23
+ outDir: resolve(process.cwd(), 'dist'),
23
24
  integrations: [
24
25
  vue({ appEntrypoint: '/src/islands/app-entry' }),
25
26
  sitemap(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.77",
3
+ "version": "0.7.80",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "astro": "^7.0.7",
38
38
  "d3": "^7.9.0",
39
39
  "favicons": "^7.2.0",
40
- "glossarist": "^0.4.17",
40
+ "glossarist": "^0.4.20",
41
41
  "js-yaml": "^4.1.0",
42
42
  "jszip": "^3.10.1",
43
43
  "pinia": "^2.3.1",
@@ -1,3 +1,8 @@
1
+ function buildConceptUri(uriBase, registerId, conceptId) {
2
+ return `${uriBase}/${registerId}/concept/${conceptId}`;
3
+ }
4
+
5
+
1
6
  /**
2
7
  * Data bridge: converts public/data/*.json into Astro content collections.
3
8
  *
@@ -77,7 +82,7 @@ for (const reg of registry) {
77
82
  const conceptEntry = {
78
83
  registerId: id,
79
84
  conceptId: concept.id,
80
- uri: `${entry.uriBase || ''}/${id}/concept/${concept.id}`,
85
+ uri: buildConceptUri(entry.uriBase || '', id, concept.id),
81
86
  status: concept.status || 'valid',
82
87
  designations: concept.designations || {},
83
88
  eng: concept.eng || concept.designations?.eng || '',
@@ -1,3 +1,37 @@
1
+ function buildConceptUri(uriBase, registerId, conceptId) {
2
+ return `${uriBase}/${registerId}/concept/${conceptId}`;
3
+ }
4
+
5
+ const VALID_COMPLETENESS = new Set(['complete', 'partial']);
6
+ const VALID_CERTAINTY = new Set(['confirmed', 'possible']);
7
+
8
+ function validateCompleteness(value) {
9
+ if (value == null) return 'complete';
10
+ if (!VALID_COMPLETENESS.has(value)) {
11
+ throw new Error(
12
+ `Invalid partitive relation completeness: "${value}". Allowed: complete, partial`,
13
+ );
14
+ }
15
+ return value;
16
+ }
17
+
18
+ function validateCertainty(value) {
19
+ if (value == null) return 'confirmed';
20
+ if (!VALID_CERTAINTY.has(value)) {
21
+ throw new Error(
22
+ `Invalid partitive member certainty: "${value}". Allowed: confirmed, possible`,
23
+ );
24
+ }
25
+ return value;
26
+ }
27
+
28
+ function normalizeLocalizedString(content) {
29
+ if (content == null) return undefined;
30
+ if (typeof content === 'string') return { default: content };
31
+ return content;
32
+ }
33
+
34
+
1
35
  /**
2
36
  * Pre-computes cross-reference and domain edges for each dataset.
3
37
  * Reads all concept JSON files, extracts structured references and
@@ -108,7 +142,7 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
108
142
  const id = ref['gl:id'] || ref['id'];
109
143
  if (!source || !id) continue;
110
144
  const reg = urnMap.get(source) || source;
111
- const target = `${uriBase}/${reg}/concept/${id}`;
145
+ const target = buildConceptUri(uriBase, reg, id);
112
146
  if (target === sourceUri) continue;
113
147
  edges.push({
114
148
  source: sourceUri,
@@ -121,12 +155,116 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
121
155
  return edges;
122
156
  }
123
157
 
158
+ /**
159
+ * Extracts partitive relations from a concept's gl:partitiveRelations
160
+ * array. Returns relation objects (NOT binary GraphEdges) so the UI
161
+ * can render them with their full set-based semantics: comprehensive +
162
+ * partitives (each with certainty), completeness, plurality, and
163
+ * criterion.
164
+ *
165
+ * Relations are written to partitive_relations.json, keeping
166
+ * edges.json binary-only.
167
+ *
168
+ * v2 shape per concept-model/TODO.partitive-relation-v2.
169
+ */
170
+ function extractPartitiveRelations(concept, registerId, uriBase, urnMap) {
171
+ const relations = [];
172
+ const sourceUri = concept['@id'];
173
+ for (const rel of concept['gl:partitiveRelations'] || []) {
174
+ const compRef = rel['gl:comprehensive'];
175
+ if (!compRef) continue;
176
+ const comprehensive = resolveConceptUri(compRef, uriBase, urnMap);
177
+ if (!comprehensive) continue;
178
+
179
+ const partitives = (rel['gl:hasPartitive'] || [])
180
+ .map(p => {
181
+ const memberRef = p['gl:ref'] || p;
182
+ const uri = resolveConceptUri(memberRef, uriBase, urnMap);
183
+ if (!uri || uri === sourceUri) return null;
184
+ return {
185
+ uri,
186
+ certainty: validateCertainty(p['gl:certainty']),
187
+ };
188
+ })
189
+ .filter(p => p !== null);
190
+
191
+ if (partitives.length === 0) continue;
192
+
193
+ const plurality = resolvePlurality(rel['gl:hasPlurality']);
194
+
195
+ relations.push({
196
+ source: sourceUri,
197
+ comprehensive,
198
+ partitives,
199
+ completeness: validateCompleteness(rel['gl:completeness']),
200
+ plurality,
201
+ criterion: normalizeLocalizedString(rel['gl:criterion']),
202
+ register: registerId,
203
+ });
204
+ }
205
+ return relations;
206
+ }
207
+
208
+ function resolvePlurality(plurality) {
209
+ if (!plurality) return null;
210
+ const isShared = plurality['gl:isShared'] ?? plurality.is_shared;
211
+ if (typeof isShared !== 'boolean') return null;
212
+ const isUncertain = plurality['gl:isUncertain'] ?? plurality.is_uncertain ?? false;
213
+ const sharedTypeRef = plurality['gl:sharedType'] ?? plurality.shared_type;
214
+ let sharedType = null;
215
+ if (sharedTypeRef) {
216
+ const source = sharedTypeRef['gl:source'] ?? sharedTypeRef.source;
217
+ const id = sharedTypeRef['gl:id'] ?? sharedTypeRef.id;
218
+ if (source && id) sharedType = `${source}:${id}`;
219
+ }
220
+ return { isShared, isUncertain, sharedType };
221
+ }
222
+
223
+ function resolveConceptUri(ref, uriBase, urnMap) {
224
+ const source = ref['gl:source'] || ref['source'];
225
+ const id = ref['gl:id'] || ref['id'];
226
+ if (!source || !id) return null;
227
+ const reg = urnMap.get(source) || source;
228
+ return buildConceptUri(uriBase, reg, id);
229
+ }
230
+
124
231
  const EXTRACTORS = [extractReferences, extractRelated, extractDomains];
125
232
 
126
233
  function extractAllEdges(concept, registerId, uriBase, urnMap) {
127
234
  return EXTRACTORS.flatMap(fn => fn(concept, registerId, uriBase, urnMap));
128
235
  }
129
236
 
237
+ /**
238
+ * Per concept-model TODO.partitive-relation-v2 item 14:
239
+ * a PartitiveRelation A → {b, c, d} subsumes binary has_part edges
240
+ * for the same pairs. Warn (don't fail) when both encodings exist.
241
+ *
242
+ * Binary edge types considered partitive: has_part, is_part_of,
243
+ * broader_partitive, narrower_partitive.
244
+ */
245
+ const BINARY_PARTITIVE_TYPES = new Set(['has_part', 'is_part_of', 'broader_partitive', 'narrower_partitive']);
246
+
247
+ function warnBinaryPartitiveRedundancy(edges, relations, registerId) {
248
+ if (!relations?.length) return;
249
+ const partitivePairs = new Set();
250
+ for (const rel of relations) {
251
+ for (const member of rel.partitives ?? []) {
252
+ const memberUri = member.uri ?? member;
253
+ partitivePairs.add(`${rel.comprehensive}|${memberUri}`);
254
+ partitivePairs.add(`${memberUri}|${rel.comprehensive}`);
255
+ }
256
+ }
257
+ const redundant = edges.filter(e =>
258
+ BINARY_PARTITIVE_TYPES.has(e.type)
259
+ && partitivePairs.has(`${e.source}|${e.target}`),
260
+ );
261
+ if (redundant.length > 0) {
262
+ console.warn(
263
+ ` ⚠ ${registerId}: ${redundant.length} binary has_part/is_part_of edge(s) duplicate a PartitiveRelation member — prefer the PartitiveRelation encoding (concept-model TODO item 14).`,
264
+ );
265
+ }
266
+ }
267
+
130
268
  // --- Build ---
131
269
 
132
270
  function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest) {
@@ -140,6 +278,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
140
278
  console.log(` Processing ${files.length} concepts...`);
141
279
 
142
280
  const allEdges = [];
281
+ const allPartitiveRelations = [];
143
282
  const allSourceRefs = [];
144
283
  const domainConceptCount = new Map();
145
284
  let processed = 0;
@@ -148,7 +287,10 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
148
287
  try {
149
288
  const data = JSON.parse(readFileSync(join(conceptsDir, file), 'utf-8'));
150
289
  const edges = extractAllEdges(data, registerId, uriBase, urnMap);
290
+ const relations = extractPartitiveRelations(data, registerId, uriBase, urnMap);
291
+ warnBinaryPartitiveRedundancy(edges, relations, registerId);
151
292
  allEdges.push(...edges);
293
+ allPartitiveRelations.push(...relations);
152
294
  allSourceRefs.push(...extractSourceRefs(data, registerId));
153
295
 
154
296
  for (const edge of edges) {
@@ -186,6 +328,17 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
186
328
  writeFileSync(outputPath, JSON.stringify(output));
187
329
  console.log(` Written ${deduped.length} edges to edges.json (${(JSON.stringify(output).length / 1024).toFixed(1)} KB)`);
188
330
 
331
+ // Write partitive_relations.json (parallel to edges.json) for one-to-many
332
+ // partitive decompositions. See TODO.partitive-relation-v2.
333
+ const relationsOutput = {
334
+ registerId,
335
+ relationCount: allPartitiveRelations.length,
336
+ relations: allPartitiveRelations,
337
+ };
338
+ const relationsPath = join(datasetDir, 'partitive_relations.json');
339
+ writeFileSync(relationsPath, JSON.stringify(relationsOutput));
340
+ console.log(` Written ${allPartitiveRelations.length} partitive relations to partitive_relations.json (${(JSON.stringify(relationsOutput).length / 1024).toFixed(1)} KB)`);
341
+
189
342
  // Build domain-nodes.json from manifest sections (authoritative source)
190
343
  const manifestSections = manifest.sections;
191
344
  if (manifestSections && manifestSections.length > 0) {
@@ -14,6 +14,11 @@ import { buildVersionHistoryTurtle } from './lib/version-turtle.mjs';
14
14
  import { buildBibliographyTurtle } from './lib/bibliography-turtle.mjs';
15
15
  import { ttlLit } from './lib/turtle-escape.mjs';
16
16
  import { firstNonEmpty } from './lib/first-non-empty.mjs';
17
+ function buildConceptUri(uriBase, registerId, conceptId) {
18
+ return `${uriBase}/${registerId}/concept/${conceptId}`;
19
+ }
20
+
21
+
17
22
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
18
23
  const ROOT = process.cwd();
19
24
  const PUBLIC = path.join(ROOT, 'public');
@@ -60,6 +65,7 @@ function loadConceptFile(filePath) {
60
65
 
61
66
  // Managed concept-level fields
62
67
  if (mc.related) result._related = mc.related;
68
+ if (mc.partitive_relations) result._partitiveRelations = mc.partitive_relations;
63
69
  if (mc.data.domains) result._domains = mc.data.domains;
64
70
  if (mc.dates) result._dates = mc.dates;
65
71
  if (mc.sources) result._sources = mc.sources;
@@ -98,14 +104,14 @@ async function writeDatasetRdf(register, manifest, concepts, refMaps, opts) {
98
104
  const datasetIri = `${uriBase}/${register}/`;
99
105
  const topConceptUris = concepts
100
106
  .slice(0, 32)
101
- .map(c => `${uriBase}/${register}/concept/${c.id}`);
107
+ .map(c => buildConceptUri(uriBase, register, c.id));
102
108
 
103
109
  const sections = (manifest.sections ?? []).map(section => {
104
110
  const sectionId = section.id ?? section.slug ?? section.title;
105
111
  return {
106
112
  collectionIri: `${uriBase}/${register}/section/${sectionId}`,
107
113
  title: section.title ?? section.name ?? sectionId,
108
- memberUris: (section.members ?? []).map(id => `${uriBase}/${register}/concept/${id}`),
114
+ memberUris: (section.members ?? []).map(id => buildConceptUri(uriBase, register, id)),
109
115
  };
110
116
  });
111
117
 
@@ -242,15 +248,16 @@ function defsToJsonLd(defs) {
242
248
  .filter(d => d['gl:content']);
243
249
  }
244
250
 
245
- function refToJsonLd(ref) {
251
+ function refToJsonLd(ref, typeName = 'gl:Ref') {
246
252
  if (!ref) return undefined;
247
- const refObj = { '@type': 'gl:Ref' };
253
+ const refObj = { '@type': typeName };
248
254
  if (typeof ref === 'string') {
249
255
  refObj['gl:source'] = ref;
250
256
  } else {
251
257
  if (ref.source) refObj['gl:source'] = ref.source;
252
258
  if (ref.id) refObj['gl:id'] = ref.id;
253
259
  if (ref.version) refObj['gl:version'] = ref.version;
260
+ if (ref.text) refObj['gl:text'] = ref.text;
254
261
  }
255
262
  return refObj;
256
263
  }
@@ -366,12 +373,12 @@ function buildPatternIndex(datasets, registerCache) {
366
373
 
367
374
  function resolveRefUri(term, refMaps) {
368
375
  const resolved = refMaps.patternIndex.resolve(term);
369
- if (resolved) return `${refMaps.uriBase}/${resolved.datasetId}/concept/${resolved.conceptId}`;
376
+ if (resolved) return buildConceptUri(refMaps.uriBase, resolved.datasetId, resolved.conceptId);
370
377
 
371
378
  const ievMatch = term.match(/^IEV:(\d+[-\d]+)$/);
372
379
  if (ievMatch) {
373
380
  const dsId = refMaps.refPrefixMap['IEV'];
374
- if (dsId) return `${refMaps.uriBase}/${dsId}/concept/${ievMatch[1]}`;
381
+ if (dsId) return buildConceptUri(refMaps.uriBase, dsId, ievMatch[1]);
375
382
  }
376
383
  return null;
377
384
  }
@@ -405,7 +412,7 @@ function resolveIevRef(display, term, refPrefixMap, uriBase) {
405
412
  if (!display.startsWith('IEV:')) return null;
406
413
  const datasetId = refPrefixMap['IEV'];
407
414
  if (!datasetId) return null;
408
- return { id: `${uriBase}/${datasetId}/concept/${display.slice(4)}`, term };
415
+ return { id: buildConceptUri(uriBase, datasetId, display.slice(4)), term };
409
416
  }
410
417
 
411
418
  /**
@@ -414,7 +421,7 @@ function resolveIevRef(display, term, refPrefixMap, uriBase) {
414
421
  function resolvePatternRef(identifier, display, refPrefixMap, patternIndex, uriBase) {
415
422
  const r = patternIndex.resolve(identifier);
416
423
  if (!r) return null;
417
- return { id: `${uriBase}/${r.datasetId}/concept/${r.conceptId}`, term: display };
424
+ return { id: buildConceptUri(uriBase, r.datasetId, r.conceptId), term: display };
418
425
  }
419
426
 
420
427
  /**
@@ -444,7 +451,7 @@ function handleCiteRef(parsed, allSources) {
444
451
  function handleNumeric(parsed, register, uriBase) {
445
452
  if (!register) return null;
446
453
  const term = parsed.label ?? parsed.id;
447
- return { id: `${uriBase}/${register}/concept/${parsed.id}`, term };
454
+ return { id: buildConceptUri(uriBase, register, parsed.id), term };
448
455
  }
449
456
 
450
457
  /**
@@ -459,7 +466,7 @@ function handleDesignation(parsed, refMaps) {
459
466
  const display = parsed.label ?? designation;
460
467
  const conceptId = refMaps.designationLookup?.get(designation.toLowerCase());
461
468
  return {
462
- id: `${refMaps.uriBase}/${register}/concept/${conceptId ?? designation}`,
469
+ id: buildConceptUri(refMaps.uriBase, register, conceptId ?? designation),
463
470
  term: display,
464
471
  };
465
472
  }
@@ -485,7 +492,7 @@ function handleUnresolved(body, refMaps) {
485
492
  // Same-dataset: {{conceptId, displayTerm}} where conceptId is numeric/X.Y
486
493
  const register = refMaps.register;
487
494
  if (register && (/^\d/.test(identifier) || /^[A-Z]\.\d/.test(identifier))) {
488
- return { id: `${refMaps.uriBase}/${register}/concept/${identifier}`, term: display };
495
+ return { id: buildConceptUri(refMaps.uriBase, register, identifier), term: display };
489
496
  }
490
497
  return null;
491
498
  }
@@ -542,7 +549,7 @@ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
542
549
  const term = parsed.label ?? uri;
543
550
  const pattern = refMaps.patternIndex.resolve(uri);
544
551
  if (pattern) {
545
- ref = { id: `${refMaps.uriBase}/${pattern.datasetId}/concept/${pattern.conceptId}`, term };
552
+ ref = { id: buildConceptUri(refMaps.uriBase, pattern.datasetId, pattern.conceptId), term };
546
553
  } else {
547
554
  ref = { id: uri, term };
548
555
  }
@@ -571,7 +578,7 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
571
578
  const base = refMaps.uriBase;
572
579
  const doc = {
573
580
  '@context': 'https://glossarist.org/ns/context.jsonld',
574
- '@id': `${base}/${register}/concept/${termid}`,
581
+ '@id': buildConceptUri(base, register, termid),
575
582
  '@type': 'gl:Concept',
576
583
  'gl:identifier': termid,
577
584
  };
@@ -672,6 +679,50 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
672
679
  });
673
680
  }
674
681
 
682
+ if (conceptYaml._partitiveRelations?.length > 0) {
683
+ doc['gl:partitiveRelations'] = conceptYaml._partitiveRelations.map(rel => {
684
+ const out = { '@type': 'gl:PartitiveRelation' };
685
+ if (rel.comprehensive) {
686
+ out['gl:comprehensive'] = refToJsonLd(rel.comprehensive, 'gl:ConceptRef');
687
+ }
688
+ if (Array.isArray(rel.partitives) && rel.partitives.length > 0) {
689
+ out['gl:hasPartitive'] = rel.partitives.map(member => {
690
+ const m = { '@type': 'gl:PartitiveMember' };
691
+ if (member.ref) {
692
+ m['gl:ref'] = refToJsonLd(member.ref, 'gl:ConceptRef');
693
+ } else {
694
+ m['gl:ref'] = refToJsonLd(member, 'gl:ConceptRef');
695
+ }
696
+ if (member.certainty) m['gl:certainty'] = member.certainty;
697
+ return m;
698
+ });
699
+ }
700
+ if (rel.completeness) {
701
+ out['gl:completeness'] = rel.completeness;
702
+ }
703
+ if (rel.plurality) {
704
+ const pl = { '@type': 'gl:TypeSharedPlurality' };
705
+ pl['gl:isShared'] = rel.plurality.is_shared ?? rel.plurality.isShared ?? false;
706
+ if (rel.plurality.is_uncertain ?? rel.plurality.isUncertain) {
707
+ pl['gl:isUncertain'] = rel.plurality.is_uncertain ?? rel.plurality.isUncertain;
708
+ }
709
+ if (rel.plurality.shared_type ?? rel.plurality.sharedType) {
710
+ pl['gl:sharedType'] = refToJsonLd(
711
+ rel.plurality.shared_type ?? rel.plurality.sharedType,
712
+ 'gl:ConceptRef',
713
+ );
714
+ }
715
+ out['gl:hasPlurality'] = pl;
716
+ }
717
+ if (rel.criterion) {
718
+ out['gl:criterion'] = typeof rel.criterion === 'string'
719
+ ? { default: rel.criterion }
720
+ : rel.criterion;
721
+ }
722
+ return out;
723
+ });
724
+ }
725
+
675
726
  return doc;
676
727
  }
677
728
 
@@ -920,8 +971,74 @@ async function processDataset(dir, register, opts) {
920
971
 
921
972
  const dsRefMaps = { ...refMaps, register, designationLookup };
922
973
 
923
- const sourceMap = new Map();
924
- const relTypeCounts = {};
974
+ const stats = {
975
+ sourceMap: new Map(),
976
+ relTypeCounts: {},
977
+ partitiveRelations: {
978
+ count: 0,
979
+ byCompleteness: { complete: 0, partial: 0 },
980
+ byMemberCertainty: { confirmed: 0, possible: 0 },
981
+ withCriterion: 0,
982
+ withoutCriterion: 0,
983
+ withPlurality: 0,
984
+ },
985
+ };
986
+
987
+ const STATS_PROCESSORS = [
988
+ function collectSources(cy, termid, s) {
989
+ const refs = new Set();
990
+ const allSources = [
991
+ ...(cy._sources || []),
992
+ ...opts.languages.flatMap(l => cy[l]?.sources || []),
993
+ ];
994
+ for (const src of allSources) {
995
+ const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
996
+ refs.add(ref);
997
+ if (!s.sourceMap.has(ref)) s.sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
998
+ if (src.type) s.sourceMap.get(ref).types.add(src.type);
999
+ }
1000
+ for (const ref of refs) s.sourceMap.get(ref)?.conceptIds.push(termid);
1001
+ },
1002
+ function countRelationships(cy, _termid, s) {
1003
+ for (const r of cy._related || []) {
1004
+ const type = r.type || 'unknown';
1005
+ s.relTypeCounts[type] = (s.relTypeCounts[type] || 0) + 1;
1006
+ }
1007
+ },
1008
+ function countPartitiveRelations(cy, _termid, s) {
1009
+ for (const rel of cy._partitiveRelations || []) {
1010
+ s.partitiveRelations.count += 1;
1011
+ const completeness = rel.completeness || 'complete';
1012
+ s.partitiveRelations.byCompleteness[completeness] =
1013
+ (s.partitiveRelations.byCompleteness[completeness] || 0) + 1;
1014
+ const partitives = rel.partitives || [];
1015
+ for (const member of partitives) {
1016
+ const certainty = member.certainty || 'confirmed';
1017
+ s.partitiveRelations.byMemberCertainty[certainty] =
1018
+ (s.partitiveRelations.byMemberCertainty[certainty] || 0) + 1;
1019
+ }
1020
+ if (rel.criterion) {
1021
+ s.partitiveRelations.withCriterion += 1;
1022
+ } else {
1023
+ s.partitiveRelations.withoutCriterion += 1;
1024
+ }
1025
+ if (rel.plurality) {
1026
+ s.partitiveRelations.withPlurality += 1;
1027
+ }
1028
+ }
1029
+ },
1030
+ function countLanguages(cy, _termid, _s) {
1031
+ for (const lang of opts.languages) {
1032
+ const lc = cy[lang];
1033
+ if (lc) {
1034
+ if (lc.terms?.length > 0) langTermCounts[lang] = (langTermCounts[lang] || 0) + 1;
1035
+ if (lc.definition && (Array.isArray(lc.definition) ? lc.definition.some(d => d.content) : lc.definition)) {
1036
+ langDefCounts[lang] = (langDefCounts[lang] || 0) + 1;
1037
+ }
1038
+ }
1039
+ }
1040
+ },
1041
+ ];
925
1042
 
926
1043
  for (let i = 0; i < files.length; i++) {
927
1044
  const file = files[i];
@@ -933,21 +1050,17 @@ async function processDataset(dir, register, opts) {
933
1050
  const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
934
1051
  writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
935
1052
 
936
- // Generate Turtle format
937
1053
  const ttlContent = conceptJsonToTurtle(jsonld);
938
1054
  fs.writeFileSync(path.join(conceptsDir, `${termid}.ttl`), ttlContent);
939
1055
 
940
- // Generate SKOS JSON-LD format
941
1056
  const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
942
1057
  fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), skosJsonLd);
943
1058
 
944
- // Generate TBX-XML format
945
1059
  const tbxContent = conceptJsonToTbx(jsonld);
946
1060
  if (tbxContent) {
947
1061
  fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
948
1062
  }
949
1063
 
950
- // Copy source YAML
951
1064
  fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
952
1065
 
953
1066
  concepts.push({
@@ -957,38 +1070,8 @@ async function processDataset(dir, register, opts) {
957
1070
  status: conceptYaml.eng?.entry_status || 'valid',
958
1071
  });
959
1072
 
960
- const conceptSourceRefs = new Set();
961
- const allSourceArrays = [
962
- ...(conceptYaml._sources || []),
963
- ...opts.languages.flatMap(l => conceptYaml[l]?.sources || []),
964
- ];
965
- for (const src of allSourceArrays) {
966
- const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
967
- conceptSourceRefs.add(ref);
968
- if (!sourceMap.has(ref)) {
969
- sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
970
- }
971
- if (src.type) sourceMap.get(ref).types.add(src.type);
972
- }
973
- for (const ref of conceptSourceRefs) {
974
- sourceMap.get(ref)?.conceptIds.push(termid);
975
- }
976
-
977
- for (const r of conceptYaml._related || []) {
978
- const type = r.type || 'unknown';
979
- relTypeCounts[type] = (relTypeCounts[type] || 0) + 1;
980
- }
981
-
982
- for (const lang of opts.languages) {
983
- const lc = conceptYaml[lang];
984
- if (lc) {
985
- if (lc.terms && lc.terms.length > 0) {
986
- langTermCounts[lang] = (langTermCounts[lang] || 0) + 1;
987
- }
988
- if (lc.definition && (Array.isArray(lc.definition) ? lc.definition.some(d => d.content) : lc.definition)) {
989
- langDefCounts[lang] = (langDefCounts[lang] || 0) + 1;
990
- }
991
- }
1073
+ for (const processor of STATS_PROCESSORS) {
1074
+ processor(conceptYaml, termid, stats);
992
1075
  }
993
1076
  } catch (e) {
994
1077
  console.warn(` Skipping ${file}: ${e.message}`);
@@ -1097,7 +1180,7 @@ async function processDataset(dir, register, opts) {
1097
1180
  }
1098
1181
  }
1099
1182
 
1100
- const sourceStats = Array.from(sourceMap.values())
1183
+ const sourceStats = Array.from(stats.sourceMap.values())
1101
1184
  .map(s => ({
1102
1185
  ref: s.ref,
1103
1186
  types: Array.from(s.types),
@@ -1105,13 +1188,14 @@ async function processDataset(dir, register, opts) {
1105
1188
  conceptIds: s.conceptIds,
1106
1189
  }))
1107
1190
  .sort((a, b) => b.conceptCount - a.conceptCount);
1108
- const totalRelationships = Object.values(relTypeCounts).reduce((a, b) => a + b, 0);
1191
+ const totalRelationships = Object.values(stats.relTypeCounts).reduce((a, b) => a + b, 0);
1109
1192
 
1110
1193
  writeJson(path.join(DATA, register, 'stats.json'), {
1111
1194
  sourceCount: sourceStats.length,
1112
1195
  sources: sourceStats,
1113
1196
  relationshipCount: totalRelationships,
1114
- relationshipTypes: relTypeCounts,
1197
+ relationshipTypes: stats.relTypeCounts,
1198
+ partitiveRelations: stats.partitiveRelations,
1115
1199
  });
1116
1200
 
1117
1201
  const manifest = {