@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.
- package/README.md +3 -2
- package/cli/index.mjs +2 -1
- package/env.d.ts +5 -0
- package/package.json +4 -3
- package/scripts/build-edges.js +78 -10
- package/scripts/generate-data.mjs +152 -20
- package/scripts/generate-ontology-data.mjs +184 -0
- package/scripts/generate-ontology-schema.mjs +315 -0
- package/src/__tests__/concept-card.test.ts +1 -1
- package/src/__tests__/concept-detail-interaction.test.ts +40 -18
- package/src/__tests__/concept-formats.test.ts +32 -30
- package/src/__tests__/concept-timeline.test.ts +108 -83
- package/src/__tests__/concept-view.test.ts +15 -2
- package/src/__tests__/dataset-adapter.test.ts +172 -23
- package/src/__tests__/dataset-view.test.ts +6 -5
- package/src/__tests__/designation-registry.test.ts +161 -0
- package/src/__tests__/graph.test.ts +62 -0
- package/src/__tests__/language-detail.test.ts +117 -60
- package/src/__tests__/ontology-registry.test.ts +109 -0
- package/src/__tests__/relationship-categories.test.ts +62 -0
- package/src/__tests__/test-helpers.ts +11 -8
- package/src/adapters/DatasetAdapter.ts +171 -48
- package/src/adapters/model-bridge.ts +277 -0
- package/src/adapters/ontology-registry.ts +75 -0
- package/src/adapters/ontology-schema.ts +100 -0
- package/src/adapters/types.ts +52 -77
- package/src/components/AppSidebar.vue +1 -1
- package/src/components/CitationDisplay.vue +35 -0
- package/src/components/ConceptDetail.vue +334 -93
- package/src/components/ConceptRdfView.vue +397 -0
- package/src/components/ConceptTimeline.vue +56 -52
- package/src/components/GraphPanel.vue +96 -31
- package/src/components/LanguageDetail.vue +45 -37
- package/src/components/NavIcon.vue +1 -0
- package/src/components/NonVerbalRepDisplay.vue +38 -0
- package/src/components/RelationshipList.vue +99 -0
- package/src/config/use-site-config.ts +3 -0
- package/src/data/ontology-schema.json +1551 -0
- package/src/data/taxonomies.json +543 -0
- package/src/graph/GraphEngine.ts +7 -4
- package/src/router/index.ts +5 -0
- package/src/shims/empty.ts +1 -0
- package/src/shims/node-crypto.ts +6 -0
- package/src/shims/node-path.ts +10 -0
- package/src/stores/vocabulary.ts +75 -25
- package/src/style.css +74 -20
- package/src/utils/concept-formats.ts +22 -20
- package/src/utils/concept-helpers.ts +43 -23
- package/src/utils/designation-registry.ts +124 -0
- package/src/utils/relationship-categories.ts +84 -0
- package/src/views/OntologySchemaView.vue +302 -0
- package/src/views/PageView.vue +28 -17
- package/src/views/StatsView.vue +34 -12
- 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
|
|
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:
|
|
71
|
-
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
const
|
|
223
|
-
|
|
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:
|
|
231
|
-
language:
|
|
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
|
-
|
|
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:
|
|
308
|
+
extractEdges(concept: Concept): GraphEdge[] {
|
|
241
309
|
const edges: GraphEdge[] = [];
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|