@glossarist/concept-browser 0.3.7 → 0.4.0

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 (54) hide show
  1. package/README.md +3 -2
  2. package/cli/index.mjs +2 -1
  3. package/env.d.ts +5 -0
  4. package/package.json +4 -3
  5. package/scripts/build-edges.js +78 -10
  6. package/scripts/generate-data.mjs +152 -20
  7. package/scripts/generate-ontology-data.mjs +184 -0
  8. package/scripts/generate-ontology-schema.mjs +315 -0
  9. package/src/__tests__/concept-card.test.ts +1 -1
  10. package/src/__tests__/concept-detail-interaction.test.ts +40 -18
  11. package/src/__tests__/concept-formats.test.ts +32 -30
  12. package/src/__tests__/concept-timeline.test.ts +108 -83
  13. package/src/__tests__/concept-view.test.ts +15 -2
  14. package/src/__tests__/dataset-adapter.test.ts +172 -23
  15. package/src/__tests__/dataset-view.test.ts +6 -5
  16. package/src/__tests__/designation-registry.test.ts +161 -0
  17. package/src/__tests__/graph.test.ts +62 -0
  18. package/src/__tests__/language-detail.test.ts +117 -60
  19. package/src/__tests__/ontology-registry.test.ts +109 -0
  20. package/src/__tests__/relationship-categories.test.ts +62 -0
  21. package/src/__tests__/test-helpers.ts +11 -8
  22. package/src/adapters/DatasetAdapter.ts +171 -48
  23. package/src/adapters/model-bridge.ts +277 -0
  24. package/src/adapters/ontology-registry.ts +75 -0
  25. package/src/adapters/ontology-schema.ts +100 -0
  26. package/src/adapters/types.ts +52 -77
  27. package/src/components/AppSidebar.vue +1 -1
  28. package/src/components/CitationDisplay.vue +35 -0
  29. package/src/components/ConceptDetail.vue +334 -93
  30. package/src/components/ConceptRdfView.vue +397 -0
  31. package/src/components/ConceptTimeline.vue +56 -52
  32. package/src/components/GraphPanel.vue +96 -31
  33. package/src/components/LanguageDetail.vue +45 -37
  34. package/src/components/NavIcon.vue +1 -0
  35. package/src/components/NonVerbalRepDisplay.vue +38 -0
  36. package/src/components/RelationshipList.vue +99 -0
  37. package/src/config/use-site-config.ts +3 -0
  38. package/src/data/ontology-schema.json +1551 -0
  39. package/src/data/taxonomies.json +543 -0
  40. package/src/graph/GraphEngine.ts +7 -4
  41. package/src/router/index.ts +5 -0
  42. package/src/shims/empty.ts +1 -0
  43. package/src/shims/node-crypto.ts +6 -0
  44. package/src/shims/node-path.ts +10 -0
  45. package/src/stores/vocabulary.ts +75 -25
  46. package/src/style.css +74 -20
  47. package/src/utils/concept-formats.ts +22 -20
  48. package/src/utils/concept-helpers.ts +43 -23
  49. package/src/utils/designation-registry.ts +124 -0
  50. package/src/utils/relationship-categories.ts +84 -0
  51. package/src/views/OntologySchemaView.vue +302 -0
  52. package/src/views/PageView.vue +28 -17
  53. package/src/views/StatsView.vue +34 -12
  54. package/vite.config.ts +8 -0
@@ -1,6 +1,7 @@
1
1
  import { createPinia, setActivePinia } from 'pinia';
2
2
  import { createRouter, createMemoryHistory } from 'vue-router';
3
- import type { Manifest, ConceptSummary, LocalizedConcept, SearchHit } from '../adapters/types';
3
+ import { LocalizedConcept } from 'glossarist';
4
+ import type { Manifest, ConceptSummary, SearchHit } from '../adapters/types';
4
5
 
5
6
  // ── Manifest Factory ──────────────────────────────────────────────────
6
7
 
@@ -43,6 +44,7 @@ export interface AdapterStubOptions {
43
44
  ensureChunksForRange?: () => Promise<void>;
44
45
  ensureAllChunksLoaded?: () => Promise<void>;
45
46
  extractEdges?: () => any[];
47
+ extractDomainEdges?: () => any[];
46
48
  getIndexEntry?: () => any;
47
49
  }
48
50
 
@@ -61,25 +63,26 @@ export function makeAdapterStub(options: AdapterStubOptions = {}): any {
61
63
  ensureChunksForRange: options.ensureChunksForRange ?? (() => Promise.resolve()),
62
64
  ensureAllChunksLoaded: options.ensureAllChunksLoaded ?? (() => Promise.resolve()),
63
65
  extractEdges: options.extractEdges ?? (() => []),
66
+ extractDomainEdges: options.extractDomainEdges ?? (() => []),
64
67
  getIndexEntry: options.getIndexEntry ?? (() => null),
65
68
  };
66
69
  }
67
70
 
68
71
  // ── Concept Data Factories ────────────────────────────────────────────
69
72
 
70
- export function makeLocalizedConcept(overrides: Partial<LocalizedConcept> = {}): LocalizedConcept {
71
- return {
72
- '@id': 'https://glossarist.org/test/concept/1/eng',
73
- '@type': 'gl:LocalizedConcept',
74
- 'gl:languageCode': 'eng',
75
- 'gl:entryStatus': 'valid',
73
+ export function makeLocalizedConcept(overrides: Record<string, unknown> = {}): LocalizedConcept {
74
+ return LocalizedConcept.fromJSON({
75
+ language_code: 'eng',
76
+ entry_status: 'valid',
77
+ terms: [{ type: 'expression', designation: 'test term' }],
76
78
  ...overrides,
77
- };
79
+ });
78
80
  }
79
81
 
80
82
  export function makeConceptSummary(overrides: Partial<ConceptSummary> = {}): ConceptSummary {
81
83
  return {
82
84
  id: '1',
85
+ designations: { eng: 'test concept' },
83
86
  eng: 'test concept',
84
87
  status: 'valid',
85
88
  ...overrides,
@@ -3,12 +3,29 @@ import type {
3
3
  ConceptIndex,
4
4
  ConceptSummary,
5
5
  ConceptEntry,
6
- ConceptDocument,
7
6
  SearchHit,
8
7
  GraphEdge,
8
+ GraphNode,
9
9
  } from './types';
10
+ import type { Concept, LocalizedConcept, Designation } from 'glossarist';
11
+ import { conceptFromJson, conceptUri } from './model-bridge';
10
12
  import { UriRouter } from './UriRouter';
11
13
 
14
+ function slugify(text: string): string {
15
+ return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
16
+ }
17
+
18
+ function resolveRefTarget(rc: any, uriBase: string, registerId: string): string {
19
+ if (!rc.ref) return '';
20
+ const ref = rc.ref;
21
+ if (ref.id) {
22
+ const reg = (ref.source && !ref.source.startsWith('http')) ? ref.source : registerId;
23
+ return `${uriBase}/${reg}/concept/${ref.id}`;
24
+ }
25
+ if (ref.source && ref.source.startsWith('http')) return ref.source;
26
+ return ref.source || '';
27
+ }
28
+
12
29
  export class DatasetAdapter {
13
30
  private positionIndex = new Map<string, number>();
14
31
  readonly registerId: string;
@@ -16,7 +33,7 @@ export class DatasetAdapter {
16
33
  manifest: Manifest | null = null;
17
34
  index: ConceptIndex | null = null;
18
35
 
19
- private conceptCache = new Map<string, ConceptDocument>();
36
+ private conceptCache = new Map<string, Concept>();
20
37
  private summaryMap = new Map<string, ConceptSummary>();
21
38
  private loadedChunks = new Set<number>();
22
39
  private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
@@ -43,11 +60,43 @@ export class DatasetAdapter {
43
60
 
44
61
  const resp = await fetch(`${this.baseUrl}/index.json`);
45
62
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}: ${resp.status}`);
46
- this.index = (await resp.json()) as ConceptIndex;
63
+ const data = await resp.json();
64
+
65
+ // Handle both old format (with eng/status fields) and new format (with designations map)
66
+ this.index = this.normalizeIndex(data);
47
67
  this.buildSummaryIndex();
48
68
  return this.index;
49
69
  }
50
70
 
71
+ private normalizeIndex(data: any): ConceptIndex {
72
+ const concepts: ConceptSummary[] = (data.concepts || []).map((c: any) => {
73
+ if (c.designations && typeof c.designations === 'object') {
74
+ return {
75
+ id: c.id,
76
+ designations: c.designations,
77
+ eng: c.eng || c.designations.eng || Object.values(c.designations)[0] || '',
78
+ status: c.status,
79
+ };
80
+ }
81
+ // Legacy format: c.eng is a string, no designations map
82
+ return {
83
+ id: c.id,
84
+ designations: c.eng ? { eng: c.eng } : {},
85
+ eng: c.eng || '',
86
+ status: c.status,
87
+ };
88
+ });
89
+
90
+ return {
91
+ registerId: data.registerId,
92
+ schemaVersion: data.schemaVersion,
93
+ conceptCount: data.conceptCount,
94
+ chunkSize: data.chunkSize,
95
+ chunks: data.chunks || [],
96
+ concepts,
97
+ };
98
+ }
99
+
51
100
  private buildSummaryIndex() {
52
101
  this.summaryMap.clear();
53
102
  this.positionIndex.clear();
@@ -68,7 +117,8 @@ export class DatasetAdapter {
68
117
  } else {
69
118
  const resp = await fetch(`${this.baseUrl}/index.json`);
70
119
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}`);
71
- this.index = (await resp.json()) as ConceptIndex;
120
+ const data = await resp.json();
121
+ this.index = this.normalizeIndex(data);
72
122
  this.buildSummaryIndex();
73
123
  return this.index;
74
124
  }
@@ -79,7 +129,6 @@ export class DatasetAdapter {
79
129
  chunks: meta.chunks,
80
130
  };
81
131
 
82
- // Pre-allocate array so positions match concept order — undefined = not loaded yet
83
132
  this.index = {
84
133
  registerId: meta.registerId,
85
134
  schemaVersion: meta.schemaVersion,
@@ -90,7 +139,6 @@ export class DatasetAdapter {
90
139
  };
91
140
 
92
141
  await this.loadChunkAsSummaries(0);
93
-
94
142
  return this.index;
95
143
  }
96
144
 
@@ -118,9 +166,11 @@ export class DatasetAdapter {
118
166
 
119
167
  for (let i = 0; i < entries.length; i++) {
120
168
  const entry = entries[i];
169
+ const designations = entry.designations || (entry.groups ? {} : { eng: '' });
121
170
  const summary: ConceptSummary = {
122
171
  id: entry.id,
123
- eng: entry.designations?.eng || Object.values(entry.designations || {})[0] || '',
172
+ designations,
173
+ eng: designations.eng || Object.values(designations)[0] || '',
124
174
  status: entry.status,
125
175
  };
126
176
  this.index!.concepts[startPos + i] = summary;
@@ -146,7 +196,6 @@ export class DatasetAdapter {
146
196
  if (!this.indexMeta) return;
147
197
  const { chunks } = this.indexMeta;
148
198
  const toLoad = chunks.map((_, i) => i).filter(i => !this.loadedChunks.has(i));
149
- // Load in parallel batches of 5 to avoid overwhelming the browser
150
199
  for (let i = 0; i < toLoad.length; i += 5) {
151
200
  const batch = toLoad.slice(i, i + 5);
152
201
  await Promise.all(batch.map(c => this.loadChunkAsSummaries(c)));
@@ -162,15 +211,16 @@ export class DatasetAdapter {
162
211
  return true;
163
212
  }
164
213
 
165
- async fetchConcept(conceptId: string): Promise<ConceptDocument> {
214
+ async fetchConcept(conceptId: string): Promise<Concept> {
166
215
  const cached = this.conceptCache.get(conceptId);
167
216
  if (cached) return cached;
168
217
 
169
218
  const resp = await fetch(`${this.baseUrl}/concepts/${conceptId}.json`);
170
219
  if (!resp.ok) throw new Error(`Concept ${conceptId} not found in ${this.registerId}`);
171
- const doc = (await resp.json()) as ConceptDocument;
172
- this.conceptCache.set(conceptId, doc);
173
- return doc;
220
+ const json = await resp.json();
221
+ const concept = conceptFromJson(json);
222
+ this.conceptCache.set(conceptId, concept);
223
+ return concept;
174
224
  }
175
225
 
176
226
  getIndexEntry(conceptId: string): ConceptSummary | undefined {
@@ -194,12 +244,10 @@ export class DatasetAdapter {
194
244
  if (!concepts) return { prev: null, next: null };
195
245
  const idx = this.getConceptPosition(conceptId);
196
246
  if (idx === -1) return { prev: null, next: null };
197
- // Scan backward for prev (skip undefined)
198
247
  let prev: string | null = null;
199
248
  for (let i = idx - 1; i >= 0; i--) {
200
249
  if (concepts[i]) { prev = concepts[i]!.id; break; }
201
250
  }
202
- // Scan forward for next (skip undefined)
203
251
  let next: string | null = null;
204
252
  for (let i = idx + 1; i < concepts.length; i++) {
205
253
  if (concepts[i]) { next = concepts[i]!.id; break; }
@@ -207,53 +255,92 @@ export class DatasetAdapter {
207
255
  return { prev, next };
208
256
  }
209
257
 
210
- search(query: string, lang: string = 'eng'): SearchHit[] {
258
+ search(query: string): SearchHit[] {
211
259
  const q = query.toLowerCase();
212
- const hits: SearchHit[] = [];
213
260
  const arr = this.index?.concepts;
214
- if (!arr) return hits;
261
+ if (!arr) return [];
262
+
263
+ type ScoredHit = SearchHit & { _score: number };
264
+ const scored: ScoredHit[] = [];
215
265
 
216
266
  for (const entry of arr) {
217
267
  if (!entry) continue;
218
- const term = entry.eng || '';
219
- const termMatch = term.toLowerCase().includes(q);
220
- const idMatch = entry.id.toLowerCase().includes(q);
221
- if (termMatch || idMatch) {
222
- const matchField = termMatch ? 'designation' as const : 'id' as const;
223
- let snippet: string | undefined;
224
- if (!termMatch && idMatch) {
225
- snippet = `ID: ${entry.id}`;
226
- }
227
- hits.push({
268
+
269
+ // ID search — exact match highest, then starts with, then contains
270
+ const idLow = entry.id.toLowerCase();
271
+ if (idLow.includes(q)) {
272
+ const score = idLow === q ? 4 : idLow.startsWith(q) ? 3 : 2;
273
+ scored.push({
228
274
  conceptId: entry.id,
229
275
  registerId: this.registerId,
230
- designation: term,
231
- language: lang,
232
- matchField,
233
- snippet,
276
+ designation: entry.eng || '',
277
+ language: '',
278
+ matchField: 'id',
279
+ snippet: `ID: ${entry.id}`,
280
+ _score: score,
234
281
  });
282
+ continue;
283
+ }
284
+
285
+ // Multi-language designation search
286
+ for (const [language, term] of Object.entries(entry.designations)) {
287
+ if (!term) continue;
288
+ const tLow = term.toLowerCase();
289
+ if (tLow.includes(q)) {
290
+ const score = tLow === q ? 4 : tLow.startsWith(q) ? 3 : 1;
291
+ scored.push({
292
+ conceptId: entry.id,
293
+ registerId: this.registerId,
294
+ designation: term,
295
+ language,
296
+ matchField: 'designation',
297
+ _score: score,
298
+ });
299
+ }
235
300
  }
236
301
  }
237
- return hits;
302
+
303
+ // Sort by score descending, then alphabetically
304
+ scored.sort((a, b) => b._score - a._score || a.designation.localeCompare(b.designation));
305
+ return scored;
238
306
  }
239
307
 
240
- extractEdges(concept: ConceptDocument): GraphEdge[] {
308
+ extractEdges(concept: Concept): GraphEdge[] {
241
309
  const edges: GraphEdge[] = [];
242
- const sourceUri = concept['@id'];
243
-
244
- for (const [_lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) {
245
- if (lc['gl:references']) {
246
- for (const ref of lc['gl:references']) {
247
- if (ref['@id'] && ref['@id'] !== sourceUri) {
248
- const parsed = UriRouter.parseUri(ref['@id']);
249
- edges.push({
250
- source: sourceUri,
251
- target: ref['@id'],
252
- type: 'references',
253
- label: ref['gl:term'],
254
- register: parsed?.registerId ?? this.registerId,
255
- });
256
- }
310
+ const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
311
+ const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
312
+
313
+ // Managed concept level relationships
314
+ for (const rc of concept.relatedConcepts) {
315
+ const target = resolveRefTarget(rc, uriBase, this.registerId);
316
+ if (target && target !== sourceUri) {
317
+ const parsed = UriRouter.parseUri(target);
318
+ edges.push({
319
+ source: sourceUri,
320
+ target,
321
+ type: rc.type || 'references',
322
+ label: rc.content || undefined,
323
+ register: parsed?.registerId ?? this.registerId,
324
+ });
325
+ }
326
+ }
327
+
328
+ // Per-localization references (from inline extraction in generate-data)
329
+ for (const lang of concept.languages) {
330
+ const lc = concept.localization(lang);
331
+ if (!lc) continue;
332
+ for (const rc of lc.related) {
333
+ const target = resolveRefTarget(rc, uriBase, this.registerId);
334
+ if (target && target !== sourceUri) {
335
+ const parsed = UriRouter.parseUri(target);
336
+ edges.push({
337
+ source: sourceUri,
338
+ target,
339
+ type: rc.type || 'references',
340
+ label: rc.content || undefined,
341
+ register: parsed?.registerId ?? this.registerId,
342
+ lang,
343
+ });
257
344
  }
258
345
  }
259
346
  }
@@ -261,6 +348,42 @@ export class DatasetAdapter {
261
348
  return edges;
262
349
  }
263
350
 
351
+ extractDomainEdges(concept: Concept): GraphEdge[] {
352
+ const edges: GraphEdge[] = [];
353
+ const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
354
+ const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
355
+
356
+ for (const lang of concept.languages) {
357
+ const lc = concept.localization(lang);
358
+ if (lc?.domain) {
359
+ edges.push({
360
+ source: sourceUri,
361
+ target: `${uriBase}/${this.registerId}/domain/${slugify(lc.domain)}`,
362
+ type: 'domain',
363
+ label: lc.domain,
364
+ register: this.registerId,
365
+ lang,
366
+ });
367
+ }
368
+ }
369
+ return edges;
370
+ }
371
+
372
+ async loadDomainNodes(): Promise<GraphNode[]> {
373
+ const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
374
+ if (!resp.ok) return [];
375
+ const data = await resp.json();
376
+ return (data.domainNodes || []).map((dn: any) => ({
377
+ uri: dn.uri,
378
+ register: dn.registerId,
379
+ conceptId: dn.uri.split('/domain/')[1] || '',
380
+ designations: { eng: dn.label },
381
+ status: 'domain',
382
+ loaded: true,
383
+ nodeType: 'domain' as const,
384
+ }));
385
+ }
386
+
264
387
  async loadEdgeIndex(): Promise<GraphEdge[]> {
265
388
  const resp = await fetch(`${this.baseUrl}/edges.json`);
266
389
  if (!resp.ok) return [];
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Model bridge: converts between wire-format JSON and glossarist-js model instances.
3
+ *
4
+ * Supports two input formats:
5
+ * 1. JSON-LD (gl:-prefixed) — legacy format from current generate-data.mjs
6
+ * 2. Glossarist native — snake_case format from glossarist-js Concept.toJSON()
7
+ *
8
+ * All downstream code works exclusively with Concept instances.
9
+ */
10
+ import {
11
+ Concept,
12
+ LocalizedConcept,
13
+ Designation,
14
+ Expression,
15
+ Abbreviation,
16
+ Symbol as SymbolDesignation,
17
+ GraphicalSymbol,
18
+ Citation,
19
+ ConceptSource,
20
+ RelatedConcept,
21
+ ConceptDate,
22
+ DetailedDefinition,
23
+ NonVerbRep,
24
+ RELATIONSHIP_TYPES,
25
+ DATE_TYPES,
26
+ } from 'glossarist';
27
+ import {
28
+ LetterSymbol,
29
+ GrammarInfo,
30
+ Pronunciation,
31
+ ConceptReference,
32
+ Locality,
33
+ GRAMMAR_GENDERS,
34
+ GRAMMAR_NUMBERS,
35
+ GRAMMAR_PARTS_OF_SPEECH,
36
+ } from 'glossarist/models';
37
+ import type { ConceptSummary } from './types';
38
+
39
+ // ── Detection ─────────────────────────────────────────────────────────────
40
+
41
+ function isJsonLd(doc: Record<string, unknown>): boolean {
42
+ return '@type' in doc && 'gl:localizedConcept' in doc;
43
+ }
44
+
45
+ // ── JSON-LD → Glossarist native mapping ───────────────────────────────────
46
+
47
+ function mapDesignationFromJsonLd(d: any): Record<string, unknown> {
48
+ const result: Record<string, unknown> = {};
49
+ const rawType = (d['@type'] as string) || '';
50
+
51
+ // Map type
52
+ if (rawType.includes('Abbreviation')) result.type = 'abbreviation';
53
+ else if (rawType.includes('LetterSymbol')) result.type = 'letter_symbol';
54
+ else if (rawType.includes('GraphicalSymbol')) result.type = 'graphical_symbol';
55
+ else if (rawType.includes('Symbol')) result.type = 'symbol';
56
+ else result.type = 'expression';
57
+
58
+ result.designation = d['gl:term'] ?? '';
59
+ result.normative_status = d['gl:normativeStatus'] ?? null;
60
+
61
+ if (d['gl:absent'] != null) result.absent = d['gl:absent'];
62
+ if (d['gl:fieldOfApplication']) result.field_of_application = d['gl:fieldOfApplication'];
63
+ if (d['gl:usageInfo']) result.usage_info = d['gl:usageInfo'];
64
+ if (d['gl:geographicalArea']) result.geographical_area = d['gl:geographicalArea'];
65
+ if (d['gl:language']) result.language = d['gl:language'];
66
+ if (d['gl:script']) result.script = d['gl:script'];
67
+ if (d['gl:system']) result.system = d['gl:system'];
68
+ if (d['gl:international'] != null) result.international = d['gl:international'];
69
+ if (d['gl:termType']) result.term_type = d['gl:termType'];
70
+
71
+ if (d['gl:pronunciation']?.length) {
72
+ result.pronunciation = d['gl:pronunciation'].map((p: any) => ({
73
+ content: p['gl:content'] ?? null,
74
+ language: p['gl:language'] ?? null,
75
+ script: p['gl:script'] ?? null,
76
+ system: p['gl:system'] ?? null,
77
+ country: p['gl:country'] ?? null,
78
+ }));
79
+ }
80
+
81
+ if (d['gl:source']?.length) {
82
+ result.sources = d['gl:source'].map(mapSourceFromJsonLd);
83
+ }
84
+
85
+ if (d['gl:related']?.length) {
86
+ result.related = d['gl:related'].map(mapRelatedFromJsonLd);
87
+ }
88
+
89
+ // Expression-specific
90
+ if (d['gl:prefix'] != null) result.prefix = d['gl:prefix'];
91
+ if (d['gl:gender']) {
92
+ result.grammar_info = [{ gender: d['gl:gender'] }];
93
+ }
94
+ if (d['gl:grammarInfo']?.length) {
95
+ result.grammar_info = d['gl:grammarInfo'].map((gi: any) => ({
96
+ gender: gi['gl:gender'] ?? null,
97
+ number: gi['gl:number'] ?? null,
98
+ part_of_speech: gi['gl:partOfSpeech'] ?? null,
99
+ noun: gi['gl:noun'] ?? false,
100
+ verb: gi['gl:verb'] ?? false,
101
+ adj: gi['gl:adj'] ?? false,
102
+ adverb: gi['gl:adverb'] ?? false,
103
+ preposition: gi['gl:preposition'] ?? false,
104
+ participle: gi['gl:participle'] ?? false,
105
+ }));
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ function mapSourceFromJsonLd(s: any): Record<string, unknown> {
112
+ const result: Record<string, unknown> = {};
113
+ if (s['gl:sourceType']) result.type = s['gl:sourceType'];
114
+ if (s['gl:sourceStatus']) result.status = s['gl:sourceStatus'];
115
+ if (s['gl:modification']) result.modification = s['gl:modification'];
116
+
117
+ if (s['gl:origin']) {
118
+ const origin: Record<string, unknown> = {};
119
+ const o = s['gl:origin'];
120
+ if (o['gl:ref']) {
121
+ const rawRef = o['gl:ref'];
122
+ const refObj: Record<string, unknown> = {};
123
+ // v3: ref is always an object { gl:source, gl:id, gl:version }
124
+ if (rawRef['gl:source']) refObj.source = rawRef['gl:source'];
125
+ if (rawRef['gl:id']) refObj.id = rawRef['gl:id'];
126
+ if (rawRef['gl:version']) refObj.version = rawRef['gl:version'];
127
+ if (rawRef['source']) refObj.source = rawRef['source'];
128
+ if (rawRef['id']) refObj.id = rawRef['id'];
129
+ if (rawRef['version']) refObj.version = rawRef['version'];
130
+ if (Object.keys(refObj).length > 0) origin.ref = refObj;
131
+ }
132
+ if (o['gl:locality']) {
133
+ const loc: Record<string, unknown> = {};
134
+ const rawLoc = o['gl:locality'];
135
+ if (rawLoc['gl:localityType']) loc.type = rawLoc['gl:localityType'];
136
+ if (rawLoc['gl:referenceFrom']) loc.reference_from = rawLoc['gl:referenceFrom'];
137
+ if (rawLoc['gl:referenceTo']) loc.reference_to = rawLoc['gl:referenceTo'];
138
+ if (rawLoc['type']) loc.type = rawLoc['type'];
139
+ if (rawLoc['reference_from']) loc.reference_from = rawLoc['reference_from'];
140
+ if (rawLoc['reference_to']) loc.reference_to = rawLoc['reference_to'];
141
+ origin.locality = loc;
142
+ }
143
+ if (o['gl:link']) origin.link = o['gl:link'];
144
+ if (o['gl:id']) origin.id = o['gl:id'];
145
+ if (o['gl:version']) origin.version = o['gl:version'];
146
+ if (o['gl:source']) origin.source = o['gl:source'];
147
+ result.origin = origin;
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ function mapRelatedFromJsonLd(r: any): Record<string, unknown> {
154
+ const result: Record<string, unknown> = { type: 'references' };
155
+ if (r['@id']) {
156
+ // Extract concept ID from URI: ".../concept/3.1.1.6" → source=register, id=3.1.1.6
157
+ const uri = r['@id'] as string;
158
+ const idMatch = uri.match(/\/concept\/([^/]+)$/);
159
+ result.ref = idMatch
160
+ ? { source: uri.split('/').slice(-3, -2)[0] || '', id: idMatch[1] }
161
+ : { source: uri, id: null };
162
+ }
163
+ if (r['gl:term']) result.content = r['gl:term'];
164
+ return result;
165
+ }
166
+
167
+ function mapLocalizedFromJsonLd(lc: any): Record<string, unknown> {
168
+ const data: Record<string, unknown> = {};
169
+
170
+ if (lc['gl:languageCode']) data.language_code = lc['gl:languageCode'];
171
+ if (lc['gl:entryStatus']) data.entry_status = lc['gl:entryStatus'];
172
+ if (lc['gl:classification']) data.classification = lc['gl:classification'];
173
+ if (lc['gl:reviewType']) data.review_type = lc['gl:reviewType'];
174
+ if (lc['gl:domain']) data.domain = lc['gl:domain'];
175
+ if (lc['gl:release']) data.release = lc['gl:release'];
176
+ if (lc['gl:lineageSourceSimilarity'] != null) data.lineage_source_similarity = lc['gl:lineageSourceSimilarity'];
177
+ if (lc['gl:script']) data.script = lc['gl:script'];
178
+ if (lc['gl:system']) data.system = lc['gl:system'];
179
+
180
+ if (lc['gl:designation']?.length) {
181
+ data.terms = lc['gl:designation'].map(mapDesignationFromJsonLd);
182
+ }
183
+
184
+ if (lc['gl:definition']?.length) {
185
+ data.definition = lc['gl:definition'].map((d: any) => {
186
+ const def: Record<string, unknown> = { content: d['gl:content'] ?? '' };
187
+ return def;
188
+ });
189
+ }
190
+
191
+ if (lc['gl:notes']?.length) {
192
+ data.notes = lc['gl:notes'].map((n: any) => ({ content: n['gl:content'] ?? '' }));
193
+ }
194
+
195
+ if (lc['gl:examples']?.length) {
196
+ data.examples = lc['gl:examples'].map((e: any) => ({ content: e['gl:content'] ?? '' }));
197
+ }
198
+
199
+ if (lc['gl:source']?.length) {
200
+ data.sources = lc['gl:source'].map(mapSourceFromJsonLd);
201
+ }
202
+
203
+ if (lc['gl:dates']?.length) {
204
+ data.dates = lc['gl:dates'].map((d: any) => ({
205
+ date: d['gl:date'] ?? null,
206
+ type: d['gl:dateType'] ?? null,
207
+ }));
208
+ }
209
+
210
+ if (lc['gl:references']?.length) {
211
+ data.related = lc['gl:references'].map(mapRelatedFromJsonLd);
212
+ }
213
+
214
+ // Review metadata — passed through to LocalizedConcept constructor
215
+ if (lc['gl:reviewDate']) data.review_date = lc['gl:reviewDate'];
216
+ if (lc['gl:reviewDecisionDate']) data.review_decision_date = lc['gl:reviewDecisionDate'];
217
+ if (lc['gl:reviewDecisionEvent']) data.review_decision_event = lc['gl:reviewDecisionEvent'];
218
+ if (lc['gl:reviewStatus']) data.review_status = lc['gl:reviewStatus'];
219
+ if (lc['gl:reviewDecision']) data.review_decision = lc['gl:reviewDecision'];
220
+ if (lc['gl:reviewDecisionNotes']) data.review_decision_notes = lc['gl:reviewDecisionNotes'];
221
+
222
+ return data;
223
+ }
224
+
225
+ function conceptFromJsonLd(doc: Record<string, any>): Concept {
226
+ const id = String(doc['gl:identifier'] ?? doc['@id']?.split('/').pop() ?? '');
227
+ const localizations: Record<string, any> = {};
228
+
229
+ const rawLc = doc['gl:localizedConcept'] ?? {};
230
+ for (const [lang, lc] of Object.entries(rawLc)) {
231
+ if (lc && typeof lc === 'object') {
232
+ localizations[lang] = mapLocalizedFromJsonLd(lc);
233
+ }
234
+ }
235
+
236
+ const related = (doc['gl:references'] ?? []).map(mapRelatedFromJsonLd);
237
+
238
+ return Concept.fromJSON({
239
+ id,
240
+ term: doc['gl:term'] ?? null,
241
+ uri: doc['@id'] ?? null,
242
+ localizations,
243
+ related,
244
+ status: null,
245
+ });
246
+ }
247
+
248
+ // ── Public API ────────────────────────────────────────────────────────────
249
+
250
+ export function conceptFromJson(doc: Record<string, any>): Concept {
251
+ if (isJsonLd(doc)) {
252
+ return conceptFromJsonLd(doc);
253
+ }
254
+ // glossarist native format — use fromJSON directly
255
+ return Concept.fromJSON(doc as Record<string, unknown>);
256
+ }
257
+
258
+ export function conceptToSummary(concept: Concept): ConceptSummary {
259
+ const designations: Record<string, string> = {};
260
+ for (const lang of concept.languages) {
261
+ const lc = concept.localization(lang);
262
+ if (lc?.primaryDesignation) {
263
+ designations[lang] = lc.primaryDesignation;
264
+ }
265
+ }
266
+ return {
267
+ id: concept.id,
268
+ designations,
269
+ eng: designations.eng || Object.values(designations)[0] || '',
270
+ status: concept.status ?? 'valid',
271
+ };
272
+ }
273
+
274
+ export function conceptUri(concept: Concept, registerId: string, uriBase: string): string {
275
+ if (concept.uri) return concept.uri;
276
+ return `${uriBase}/${registerId}/concept/${concept.id}`;
277
+ }