@glossarist/concept-browser 0.7.31 → 0.7.33
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/package.json +1 -1
- package/scripts/build-edges.js +46 -4
- package/scripts/extract-source-refs.js +32 -0
- package/scripts/generate-data.mjs +2 -0
- package/src/__tests__/citation-display.test.ts +143 -0
- package/src/__tests__/concept-helpers.test.ts +129 -0
- package/src/__tests__/designation-relationship.test.ts +35 -4
- package/src/__tests__/extract-source-refs.test.ts +136 -0
- package/src/__tests__/factory-lazy.test.ts +2 -2
- package/src/__tests__/load-source-refs.test.ts +128 -0
- package/src/__tests__/slugify.test.ts +28 -0
- package/src/__tests__/source-refs.test.ts +191 -0
- package/src/adapters/DatasetAdapter.ts +51 -19
- package/src/adapters/ReferenceResolver.ts +5 -0
- package/src/adapters/factory.ts +36 -40
- package/src/adapters/model-bridge.ts +29 -19
- package/src/adapters/ontology-registry.ts +21 -4
- package/src/adapters/types.ts +2 -0
- package/src/components/CitationDisplay.vue +123 -14
- package/src/components/ConceptDetail.vue +25 -197
- package/src/components/DesignationList.vue +3 -3
- package/src/composables/use-concept-content.ts +160 -0
- package/src/composables/use-concept-edges.ts +181 -0
- package/src/data/taxonomies.json +13 -1
- package/src/graph/GraphEngine.ts +15 -0
- package/src/utils/concept-helpers.ts +4 -7
- package/src/utils/designation-registry.ts +15 -37
- package/src/utils/index.ts +1 -0
- package/src/utils/slugify.ts +3 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { getFactory, resetFactory } from '../adapters/factory';
|
|
3
|
+
import type { Resolution } from '../adapters/types';
|
|
4
|
+
|
|
5
|
+
type InternalResolution = Extract<Resolution, { type: 'internal' }>;
|
|
6
|
+
|
|
7
|
+
function asInternal(r: Resolution | null): InternalResolution | null {
|
|
8
|
+
return r?.type === 'internal' ? (r as InternalResolution) : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('AdapterFactory — bibliography resolution from registry', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
resetFactory();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('registers bibliography from registry ref/refAliases', async () => {
|
|
18
|
+
const factory = getFactory();
|
|
19
|
+
const mockFetch = vi.fn((url: string) => {
|
|
20
|
+
if (url.endsWith('datasets.json')) {
|
|
21
|
+
return Promise.resolve({
|
|
22
|
+
ok: true,
|
|
23
|
+
json: () => Promise.resolve([
|
|
24
|
+
{
|
|
25
|
+
id: 'vim-2012',
|
|
26
|
+
manifestUrl: '/data/vim-2012/manifest.json',
|
|
27
|
+
summary: {
|
|
28
|
+
title: 'VIM 2012',
|
|
29
|
+
description: 'Vocabulary of metrology',
|
|
30
|
+
conceptCount: 144,
|
|
31
|
+
languages: ['eng', 'fra'],
|
|
32
|
+
owner: 'OIML',
|
|
33
|
+
tags: [],
|
|
34
|
+
},
|
|
35
|
+
datasetUri: 'urn:oiml:pub:v:2:2012',
|
|
36
|
+
uriBase: 'https://glossarist.org',
|
|
37
|
+
uriAliases: ['urn:oiml:pub:v:2:2012*'],
|
|
38
|
+
ref: 'OIML V 2-200:2012',
|
|
39
|
+
refAliases: ['VIM'],
|
|
40
|
+
},
|
|
41
|
+
]),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
45
|
+
});
|
|
46
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
47
|
+
|
|
48
|
+
await factory.discoverDatasets('/datasets.json');
|
|
49
|
+
|
|
50
|
+
// No source-refs.json fetch — bibliography comes from registry config
|
|
51
|
+
expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining('source-refs'));
|
|
52
|
+
|
|
53
|
+
// ref resolves via bibliography config
|
|
54
|
+
const result = factory.resolveCitation('OIML V 2-200:2012', '2.2', 'viml-2022');
|
|
55
|
+
expect(result).toEqual({
|
|
56
|
+
type: 'internal',
|
|
57
|
+
registerId: 'vim-2012',
|
|
58
|
+
conceptId: '2.2',
|
|
59
|
+
crossDataset: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// refAlias resolves too
|
|
63
|
+
const alias = factory.resolveCitation('VIM', '2.2', 'viml-2022');
|
|
64
|
+
expect(asInternal(alias)?.registerId).toBe('vim-2012');
|
|
65
|
+
|
|
66
|
+
// URN resolves directly via URI pattern matching (no bibliography entry needed)
|
|
67
|
+
const urn = factory.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
|
|
68
|
+
expect(asInternal(urn)?.registerId).toBe('vim-2012');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resolves without ref/refAliases when only datasetUri is present', async () => {
|
|
72
|
+
const factory = getFactory();
|
|
73
|
+
const mockFetch = vi.fn((url: string) => {
|
|
74
|
+
if (url.endsWith('datasets.json')) {
|
|
75
|
+
return Promise.resolve({
|
|
76
|
+
ok: true,
|
|
77
|
+
json: () => Promise.resolve([
|
|
78
|
+
{
|
|
79
|
+
id: 'ds1',
|
|
80
|
+
manifestUrl: '/data/ds1/manifest.json',
|
|
81
|
+
summary: {
|
|
82
|
+
title: 'DS1',
|
|
83
|
+
description: 'Test',
|
|
84
|
+
conceptCount: 10,
|
|
85
|
+
languages: ['eng'],
|
|
86
|
+
owner: 'Test',
|
|
87
|
+
tags: [],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
94
|
+
});
|
|
95
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
96
|
+
|
|
97
|
+
const adapters = await factory.discoverDatasets('/datasets.json');
|
|
98
|
+
expect(adapters).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('skips registry entries without datasetUri for bibliography', async () => {
|
|
102
|
+
const factory = getFactory();
|
|
103
|
+
const mockFetch = vi.fn((url: string) => {
|
|
104
|
+
if (url.endsWith('datasets.json')) {
|
|
105
|
+
return Promise.resolve({
|
|
106
|
+
ok: true,
|
|
107
|
+
json: () => Promise.resolve([
|
|
108
|
+
{
|
|
109
|
+
id: 'ds1',
|
|
110
|
+
manifestUrl: '/data/ds1/manifest.json',
|
|
111
|
+
summary: { title: 'DS1', description: 'Test', conceptCount: 10, languages: ['eng'], owner: 'Test', tags: [] },
|
|
112
|
+
ref: 'Some Ref',
|
|
113
|
+
// No datasetUri — bibliography entry skipped
|
|
114
|
+
},
|
|
115
|
+
]),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
119
|
+
});
|
|
120
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
121
|
+
|
|
122
|
+
await factory.discoverDatasets('/datasets.json');
|
|
123
|
+
|
|
124
|
+
// ref without URN can't resolve — no datasetUri to route to
|
|
125
|
+
const result = factory.resolveCitation('Some Ref', '1.1');
|
|
126
|
+
expect(result).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { slugify } from '../utils/slugify';
|
|
3
|
+
|
|
4
|
+
describe('slugify', () => {
|
|
5
|
+
it('lowercases text', () => {
|
|
6
|
+
expect(slugify('Hello World')).toBe('hello-world');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('strips slashes and joins surrounding text', () => {
|
|
10
|
+
expect(slugify('foo bar/baz')).toBe('foo-barbaz');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('strips non-word characters', () => {
|
|
14
|
+
expect(slugify('géométrie (2D)')).toBe('gomtrie-2d');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('collapses multiple spaces into single hyphen', () => {
|
|
18
|
+
expect(slugify('a b')).toBe('a-b');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles empty string', () => {
|
|
22
|
+
expect(slugify('')).toBe('');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('preserves hyphens in input', () => {
|
|
26
|
+
expect(slugify('iso-19107')).toBe('iso-19107');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ReferenceResolver } from '../adapters/ReferenceResolver';
|
|
3
|
+
import type { Resolution } from '../adapters/types';
|
|
4
|
+
|
|
5
|
+
type InternalResolution = Extract<Resolution, { type: 'internal' }>;
|
|
6
|
+
|
|
7
|
+
function asInternal(r: Resolution | null): InternalResolution | null {
|
|
8
|
+
return r?.type === 'internal' ? (r as InternalResolution) : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('Source reference resolution (citation linking)', () => {
|
|
12
|
+
let resolver: ReferenceResolver;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resolver = new ReferenceResolver();
|
|
16
|
+
// Register datasets with URI patterns
|
|
17
|
+
resolver.registerDataset('vim-2012', ['urn:oiml:pub:v:2:2012*']);
|
|
18
|
+
resolver.registerDataset('viml-2022', ['urn:oiml:pub:v:1:2022*']);
|
|
19
|
+
resolver.registerDataset('vim-2007', ['urn:oiml:pub:v:2:2007*']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('hasSourceRef', () => {
|
|
23
|
+
it('returns true for registered source ref', () => {
|
|
24
|
+
resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
25
|
+
expect(resolver.hasSourceRef('VIM')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns false for unregistered source ref', () => {
|
|
29
|
+
expect(resolver.hasSourceRef('Unknown')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('registerSourceRef', () => {
|
|
34
|
+
it('resolves citation when source ref matches exactly', () => {
|
|
35
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
36
|
+
const result = resolver.resolveCitation('OIML V 2-200:2012', '2.2', 'viml-2022');
|
|
37
|
+
expect(result).toEqual({
|
|
38
|
+
type: 'internal',
|
|
39
|
+
registerId: 'vim-2012',
|
|
40
|
+
conceptId: '2.2',
|
|
41
|
+
crossDataset: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('resolves citation using variant source string', () => {
|
|
46
|
+
// Manifest has "OIML V 2-200:2012" but concepts use "OIML V2-200:2012"
|
|
47
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
48
|
+
resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
49
|
+
|
|
50
|
+
const result = resolver.resolveCitation('OIML V2-200:2012', '2.2', 'viml-2022');
|
|
51
|
+
expect(result).toEqual({
|
|
52
|
+
type: 'internal',
|
|
53
|
+
registerId: 'vim-2012',
|
|
54
|
+
conceptId: '2.2',
|
|
55
|
+
crossDataset: true,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('resolves citation using URN as source', () => {
|
|
60
|
+
resolver.registerSourceRef('urn:oiml:pub:v:2:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
61
|
+
const result = resolver.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
type: 'internal',
|
|
64
|
+
registerId: 'vim-2012',
|
|
65
|
+
conceptId: '2.2',
|
|
66
|
+
crossDataset: true,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('resolves citation using ref alias (e.g. "VIM")', () => {
|
|
71
|
+
resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
72
|
+
const result = resolver.resolveCitation('VIM', '2.2', 'viml-2022');
|
|
73
|
+
expect(result).toEqual({
|
|
74
|
+
type: 'internal',
|
|
75
|
+
registerId: 'vim-2012',
|
|
76
|
+
conceptId: '2.2',
|
|
77
|
+
crossDataset: true,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns null when source ref is not registered', () => {
|
|
82
|
+
const result = resolver.resolveCitation('Unknown Source', '2.2', 'viml-2022');
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns null when source ref matches but concept does not exist in dataset', () => {
|
|
87
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
88
|
+
// The resolver can only check if the dataset exists, not if the concept exists
|
|
89
|
+
// It will still return internal if the dataset is registered
|
|
90
|
+
const result = resolver.resolveCitation('OIML V 2-200:2012', '99.99', 'viml-2022');
|
|
91
|
+
expect(result?.type).toBe('internal');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('handles same-dataset citation (crossDataset=false)', () => {
|
|
95
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
96
|
+
const result = resolver.resolveCitation('OIML V 2-200:2012', '2.2', 'vim-2012');
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
type: 'internal',
|
|
99
|
+
registerId: 'vim-2012',
|
|
100
|
+
conceptId: '2.2',
|
|
101
|
+
crossDataset: false,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('resolveCitation fallback to URN', () => {
|
|
107
|
+
it('resolves URN-based source without registerSourceRef', () => {
|
|
108
|
+
// When source starts with "urn:", tryResolveCitationUri handles it
|
|
109
|
+
const result = resolver.resolveCitation('urn:oiml:pub:v:2:2012', '2.2', 'viml-2022');
|
|
110
|
+
expect(result).toEqual({
|
|
111
|
+
type: 'internal',
|
|
112
|
+
registerId: 'vim-2012',
|
|
113
|
+
conceptId: '2.2',
|
|
114
|
+
crossDataset: true,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('multiple source strings for same dataset', () => {
|
|
120
|
+
it('registers multiple source refs pointing to the same dataset', () => {
|
|
121
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
122
|
+
resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
123
|
+
resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
124
|
+
resolver.registerSourceRef('ISO Guide 99:2007', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
125
|
+
|
|
126
|
+
expect(asInternal(resolver.resolveCitation('OIML V 2-200:2012', '2.2'))?.registerId).toBe('vim-2012');
|
|
127
|
+
expect(asInternal(resolver.resolveCitation('OIML V2-200:2012', '2.2'))?.registerId).toBe('vim-2012');
|
|
128
|
+
expect(asInternal(resolver.resolveCitation('VIM', '2.2'))?.registerId).toBe('vim-2012');
|
|
129
|
+
expect(asInternal(resolver.resolveCitation('ISO Guide 99:2007', '2.2'))?.registerId).toBe('vim-2012');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('ISO source references', () => {
|
|
134
|
+
it('resolves ISO/IEC references when registered', () => {
|
|
135
|
+
resolver.registerDataset('iso-17000', ['urn:iso:std:iso:iec:17000*']);
|
|
136
|
+
resolver.registerSourceRef('ISO/IEC 17000:2020', 'iso-17000', 'urn:iso:std:iso:iec:17000');
|
|
137
|
+
|
|
138
|
+
const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
|
|
139
|
+
expect(result).toEqual({
|
|
140
|
+
type: 'internal',
|
|
141
|
+
registerId: 'iso-17000',
|
|
142
|
+
conceptId: '3.1',
|
|
143
|
+
crossDataset: true,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns null for unresolvable ISO references', () => {
|
|
148
|
+
// ISO dataset not loaded — no registerSourceRef or dataset pattern match
|
|
149
|
+
const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
|
|
150
|
+
expect(result).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('edge cases', () => {
|
|
155
|
+
it('returns internal with empty conceptId when referenceFrom is empty', () => {
|
|
156
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
157
|
+
const result = resolver.resolveCitation('OIML V 2-200:2012', '');
|
|
158
|
+
// Resolver still resolves but with empty conceptId — UI handles this
|
|
159
|
+
expect(result?.type).toBe('internal');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('handles concept IDs with dots', () => {
|
|
163
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
164
|
+
const result = resolver.resolveCitation('OIML V 2-200:2012', '5.12.3', 'viml-2022');
|
|
165
|
+
expect(result).toEqual({
|
|
166
|
+
type: 'internal',
|
|
167
|
+
registerId: 'vim-2012',
|
|
168
|
+
conceptId: '5.12.3',
|
|
169
|
+
crossDataset: true,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('handles source strings with special characters', () => {
|
|
174
|
+
resolver.registerDataset('vim-1993', ['urn:oiml:pub:v:2:1993*']);
|
|
175
|
+
resolver.registerSourceRef('OIML V 2:1993', 'vim-1993', 'urn:oiml:pub:v:2:1993');
|
|
176
|
+
const result = resolver.resolveCitation('OIML V 2:1993', '3.6');
|
|
177
|
+
expect(asInternal(result)?.registerId).toBe('vim-1993');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not confuse similar source strings', () => {
|
|
181
|
+
resolver.registerSourceRef('OIML V 2-200:2007', 'vim-2007', 'urn:oiml:pub:v:2:2007');
|
|
182
|
+
resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
|
|
183
|
+
|
|
184
|
+
const result2007 = resolver.resolveCitation('OIML V 2-200:2007', '2.2');
|
|
185
|
+
const result2012 = resolver.resolveCitation('OIML V 2-200:2012', '2.2');
|
|
186
|
+
|
|
187
|
+
expect(asInternal(result2007)?.registerId).toBe('vim-2007');
|
|
188
|
+
expect(asInternal(result2012)?.registerId).toBe('vim-2012');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -9,15 +9,47 @@ import type {
|
|
|
9
9
|
SectionNode,
|
|
10
10
|
DatasetSummary,
|
|
11
11
|
} from './types';
|
|
12
|
-
import type { Concept, LocalizedConcept, Designation } from 'glossarist';
|
|
12
|
+
import type { Concept, LocalizedConcept, Designation, RelatedConcept } from 'glossarist';
|
|
13
13
|
import { conceptFromJson, conceptUri } from './model-bridge';
|
|
14
14
|
import { UriRouter } from './UriRouter';
|
|
15
|
+
import { slugify } from '../utils/slugify';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// ── Wire-format types for JSON responses ────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface IndexJson {
|
|
20
|
+
registerId: string;
|
|
21
|
+
schemaVersion: string;
|
|
22
|
+
conceptCount: number;
|
|
23
|
+
chunkSize: number;
|
|
24
|
+
chunks: { file: string; count: number }[];
|
|
25
|
+
concepts: IndexConceptJson[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface IndexConceptJson {
|
|
29
|
+
id: string;
|
|
30
|
+
designations?: Record<string, string>;
|
|
31
|
+
eng?: string;
|
|
32
|
+
status: string;
|
|
33
|
+
groups?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DomainNodeJson {
|
|
37
|
+
uri?: string;
|
|
38
|
+
id?: string;
|
|
39
|
+
registerId?: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
names?: Record<string, string>;
|
|
42
|
+
conceptCount?: number;
|
|
43
|
+
children?: DomainNodeJson[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface SectionJson {
|
|
47
|
+
id: string;
|
|
48
|
+
names?: Record<string, string>;
|
|
49
|
+
children?: SectionJson[];
|
|
18
50
|
}
|
|
19
51
|
|
|
20
|
-
function resolveRefTarget(rc:
|
|
52
|
+
function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
|
|
21
53
|
if (!rc.ref) return '';
|
|
22
54
|
const ref = rc.ref;
|
|
23
55
|
if (ref.id) {
|
|
@@ -99,13 +131,13 @@ export class DatasetAdapter {
|
|
|
99
131
|
const data = await resp.json();
|
|
100
132
|
|
|
101
133
|
// Handle both old format (with eng/status fields) and new format (with designations map)
|
|
102
|
-
this.index = this.normalizeIndex(data);
|
|
134
|
+
this.index = this.normalizeIndex(data as IndexJson);
|
|
103
135
|
this.buildSummaryIndex();
|
|
104
136
|
return this.index;
|
|
105
137
|
}
|
|
106
138
|
|
|
107
|
-
private normalizeIndex(data:
|
|
108
|
-
const concepts: ConceptSummary[] = (data.concepts || []).map((c
|
|
139
|
+
private normalizeIndex(data: IndexJson): ConceptIndex {
|
|
140
|
+
const concepts: ConceptSummary[] = (data.concepts || []).map((c) => ({
|
|
109
141
|
id: c.id,
|
|
110
142
|
designations: c.designations || {},
|
|
111
143
|
eng: c.eng || c.designations?.eng || Object.values(c.designations || {})[0] || '',
|
|
@@ -144,7 +176,7 @@ export class DatasetAdapter {
|
|
|
144
176
|
const resp = await fetch(`${this.baseUrl}/index.json`);
|
|
145
177
|
if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}`);
|
|
146
178
|
const data = await resp.json();
|
|
147
|
-
this.index = this.normalizeIndex(data);
|
|
179
|
+
this.index = this.normalizeIndex(data as IndexJson);
|
|
148
180
|
this.buildSummaryIndex();
|
|
149
181
|
return this.index;
|
|
150
182
|
}
|
|
@@ -412,34 +444,34 @@ export class DatasetAdapter {
|
|
|
412
444
|
const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
|
|
413
445
|
if (!resp.ok) return [];
|
|
414
446
|
const data = await resp.json();
|
|
415
|
-
return (data.domainNodes || []).map((dn:
|
|
447
|
+
return (data.domainNodes || []).map((dn: DomainNodeJson) => this.mapDomainNode(dn));
|
|
416
448
|
}
|
|
417
449
|
|
|
418
|
-
private mapDomainNode(dn:
|
|
450
|
+
private mapDomainNode(dn: DomainNodeJson): GraphNode {
|
|
419
451
|
const node: GraphNode = {
|
|
420
|
-
uri: dn.uri,
|
|
421
|
-
register: dn.registerId,
|
|
452
|
+
uri: dn.uri ?? '',
|
|
453
|
+
register: dn.registerId ?? '',
|
|
422
454
|
conceptId: dn.uri?.split('/domain/')[1] || dn.id || '',
|
|
423
|
-
designations: dn.names || { eng: dn.label },
|
|
455
|
+
designations: dn.names || (dn.label ? { eng: dn.label } : {}),
|
|
424
456
|
status: 'domain',
|
|
425
457
|
loaded: true,
|
|
426
458
|
nodeType: 'domain' as const,
|
|
427
459
|
conceptCount: dn.conceptCount || 0,
|
|
428
460
|
};
|
|
429
461
|
if (dn.children && dn.children.length > 0) {
|
|
430
|
-
node.children = dn.children.map((c
|
|
462
|
+
node.children = dn.children.map((c) => this.mapSectionNode(c));
|
|
431
463
|
}
|
|
432
464
|
return node;
|
|
433
465
|
}
|
|
434
466
|
|
|
435
|
-
private mapSectionNode(dn:
|
|
467
|
+
private mapSectionNode(dn: DomainNodeJson): SectionNode {
|
|
436
468
|
const node: SectionNode = {
|
|
437
|
-
id: dn.id,
|
|
438
|
-
names: dn.names || { eng: dn.label },
|
|
469
|
+
id: dn.id ?? '',
|
|
470
|
+
names: dn.names || (dn.label ? { eng: dn.label } : {}),
|
|
439
471
|
conceptCount: dn.conceptCount || 0,
|
|
440
472
|
};
|
|
441
473
|
if (dn.children && dn.children.length > 0) {
|
|
442
|
-
node.children = dn.children.map((c
|
|
474
|
+
node.children = dn.children.map((c) => this.mapSectionNode(c));
|
|
443
475
|
}
|
|
444
476
|
return node;
|
|
445
477
|
}
|
|
@@ -450,7 +482,7 @@ export class DatasetAdapter {
|
|
|
450
482
|
return nodes.map(s => this.mapManifestSection(s));
|
|
451
483
|
}
|
|
452
484
|
|
|
453
|
-
private mapManifestSection(s:
|
|
485
|
+
private mapManifestSection(s: SectionJson): SectionNode {
|
|
454
486
|
const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
|
|
455
487
|
if (s.children && s.children.length > 0) {
|
|
456
488
|
node.children = s.children.map(c => this.mapManifestSection(c));
|
|
@@ -40,6 +40,10 @@ export class ReferenceResolver {
|
|
|
40
40
|
this.sourceRefs.set(sourceRef, { datasetId, uriPrefix });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
hasSourceRef(sourceRef: string): boolean {
|
|
44
|
+
return this.sourceRefs.has(sourceRef);
|
|
45
|
+
}
|
|
46
|
+
|
|
43
47
|
loadRouting(entries: RoutingEntry[]): void {
|
|
44
48
|
this.routing = entries;
|
|
45
49
|
}
|
|
@@ -90,6 +94,7 @@ export class ReferenceResolver {
|
|
|
90
94
|
resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
|
|
91
95
|
const entry = this.sourceRefs.get(source);
|
|
92
96
|
if (!entry) {
|
|
97
|
+
// URN-based source strings resolve directly via dataset URI patterns
|
|
93
98
|
if (!source.startsWith('urn:')) return null;
|
|
94
99
|
return this.tryResolveCitationUri(source, referenceFrom, sourceDatasetId);
|
|
95
100
|
}
|
package/src/adapters/factory.ts
CHANGED
|
@@ -46,18 +46,25 @@ export class AdapterFactory {
|
|
|
46
46
|
await Promise.all(needManifest.map(a => a.loadManifest().catch(() => {})));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Register all datasets' URI patterns eagerly so cross-dataset refs resolve
|
|
50
49
|
for (const adapter of adapters) {
|
|
51
50
|
if (adapter.manifest) {
|
|
52
|
-
this.
|
|
51
|
+
this.registerDataset(adapter.registerId, adapter.manifest);
|
|
53
52
|
}
|
|
54
53
|
}
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
|
|
55
|
+
// Register bibliography from registry config (ref/refAliases → URN)
|
|
56
|
+
// This is the single source of truth — no separate source-refs file needed.
|
|
57
|
+
for (const reg of registry) {
|
|
58
|
+
if (!reg.datasetUri) continue;
|
|
59
|
+
if (reg.ref) {
|
|
60
|
+
this.resolver.registerSourceRef(reg.ref, reg.id, reg.datasetUri);
|
|
61
|
+
}
|
|
62
|
+
for (const alias of reg.refAliases ?? []) {
|
|
63
|
+
this.resolver.registerSourceRef(alias, reg.id, reg.datasetUri);
|
|
64
|
+
}
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
return adapters;
|
|
60
|
-
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
getAdapter(registerId: string): DatasetAdapter | undefined {
|
|
@@ -68,8 +75,9 @@ export class AdapterFactory {
|
|
|
68
75
|
return [...this.adapters.values()];
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
private registerDataset(registerId: string, manifest: Manifest): void {
|
|
79
|
+
this.router.registerDataset(registerId, `${import.meta.env.BASE_URL}data/${registerId}`, manifest);
|
|
71
80
|
|
|
72
|
-
private registerUriPatterns(registerId: string, manifest: Manifest): void {
|
|
73
81
|
const uriPatterns = [
|
|
74
82
|
manifest.datasetUri,
|
|
75
83
|
...(manifest.uriAliases ?? []),
|
|
@@ -77,18 +85,15 @@ export class AdapterFactory {
|
|
|
77
85
|
].filter(Boolean) as string[];
|
|
78
86
|
this.resolver.registerDataset(registerId, uriPatterns);
|
|
79
87
|
|
|
80
|
-
if (manifest.ref) {
|
|
81
|
-
this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
|
|
82
|
-
}
|
|
83
|
-
for (const alias of manifest.refAliases ?? []) {
|
|
84
|
-
this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
88
|
if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
|
|
88
89
|
for (const alias of manifest.uriAliases ?? []) {
|
|
89
90
|
const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
|
|
90
91
|
if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
|
|
91
92
|
}
|
|
93
|
+
|
|
94
|
+
for (const adapter of this.adapters.values()) {
|
|
95
|
+
adapter.setUrnMap(this.urnMap);
|
|
96
|
+
}
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
async loadDataset(registerId: string): Promise<DatasetAdapter> {
|
|
@@ -98,33 +103,7 @@ export class AdapterFactory {
|
|
|
98
103
|
const manifest = await adapter.loadManifest();
|
|
99
104
|
await adapter.loadIndex();
|
|
100
105
|
|
|
101
|
-
this.
|
|
102
|
-
|
|
103
|
-
const uriPatterns = [
|
|
104
|
-
manifest.datasetUri,
|
|
105
|
-
...(manifest.uriAliases ?? []),
|
|
106
|
-
manifest.uriBase ? `${manifest.uriBase}/${registerId}/*` : undefined,
|
|
107
|
-
].filter(Boolean) as string[];
|
|
108
|
-
this.resolver.registerDataset(registerId, uriPatterns);
|
|
109
|
-
|
|
110
|
-
if (manifest.ref) {
|
|
111
|
-
this.resolver.registerSourceRef(manifest.ref, registerId, manifest.datasetUri);
|
|
112
|
-
}
|
|
113
|
-
for (const alias of manifest.refAliases ?? []) {
|
|
114
|
-
this.resolver.registerSourceRef(alias, registerId, manifest.datasetUri);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Build URN→datasetId map from manifest
|
|
118
|
-
if (manifest.datasetUri) this.urnMap.set(manifest.datasetUri, registerId);
|
|
119
|
-
for (const alias of manifest.uriAliases ?? []) {
|
|
120
|
-
const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
|
|
121
|
-
if (base.startsWith('urn:')) this.urnMap.set(base, registerId);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Distribute the updated URN map to all loaded adapters
|
|
125
|
-
for (const adapter of this.adapters.values()) {
|
|
126
|
-
adapter.setUrnMap(this.urnMap);
|
|
127
|
-
}
|
|
106
|
+
this.registerDataset(registerId, manifest);
|
|
128
107
|
|
|
129
108
|
return adapter;
|
|
130
109
|
}
|
|
@@ -137,6 +116,23 @@ export class AdapterFactory {
|
|
|
137
116
|
return this.resolver.resolveReference(uri, sourceDatasetId);
|
|
138
117
|
}
|
|
139
118
|
|
|
119
|
+
resolveRelatedRef(ref: { source: string | null; id: string | null } | null, sourceDatasetId?: string): { registerId: string; conceptId: string } | null {
|
|
120
|
+
if (!ref?.source || !ref?.id) return null;
|
|
121
|
+
const uri = `${ref.source}/${ref.id}`;
|
|
122
|
+
const resolution = this.resolve(uri, sourceDatasetId);
|
|
123
|
+
if (resolution.type === 'internal') {
|
|
124
|
+
return { registerId: resolution.registerId, conceptId: resolution.conceptId.replace(/^\//, '') };
|
|
125
|
+
}
|
|
126
|
+
if (ref.source.startsWith('urn:')) {
|
|
127
|
+
const directUri = ref.source + ref.id;
|
|
128
|
+
const directRes = this.resolve(directUri, sourceDatasetId);
|
|
129
|
+
if (directRes.type === 'internal') {
|
|
130
|
+
return { registerId: directRes.registerId, conceptId: directRes.conceptId.replace(/^\//, '') };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
140
136
|
resolveCitation(source: string, referenceFrom: string, sourceDatasetId?: string): Resolution | null {
|
|
141
137
|
return this.resolver.resolveCitation(source, referenceFrom, sourceDatasetId);
|
|
142
138
|
}
|