@glossarist/concept-browser 0.7.34 → 0.7.37
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 +2 -2
- package/scripts/build-edges.js +16 -8
- package/scripts/generate-data.mjs +284 -86
- package/src/__tests__/citation-display.test.ts +165 -3
- package/src/__tests__/cite-ref.test.ts +112 -0
- package/src/__tests__/concept-detail-interaction.test.ts +1 -5
- package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
- package/src/__tests__/escape.test.ts +76 -0
- package/src/__tests__/graph-data-source.test.ts +155 -0
- package/src/__tests__/model-bridge-bridges.test.ts +150 -0
- package/src/__tests__/model-bridge-citation.test.ts +163 -0
- package/src/__tests__/reference-resolver-cite.test.ts +122 -0
- package/src/__tests__/reference-resolver.test.ts +12 -7
- package/src/__tests__/resolve-view.test.ts +1 -1
- package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
- package/src/__tests__/source-refs.test.ts +9 -6
- package/src/__tests__/test-helpers.ts +20 -0
- package/src/__tests__/uri-router.test.ts +39 -12
- package/src/adapters/DatasetAdapter.ts +35 -143
- package/src/adapters/GraphDataSource.ts +178 -0
- package/src/adapters/ReferenceResolver.ts +101 -47
- package/src/adapters/UriRouter.ts +82 -10
- package/src/adapters/factory.ts +35 -28
- package/src/adapters/model-bridge.ts +121 -71
- package/src/adapters/types.ts +3 -0
- package/src/components/AppSidebar.vue +7 -4
- package/src/components/CitationDisplay.vue +86 -30
- package/src/components/ConceptDetail.vue +24 -126
- package/src/components/LanguageDetail.vue +6 -6
- package/src/composables/use-concept-content.ts +8 -8
- package/src/composables/use-ontology-nav.ts +129 -130
- package/src/composables/use-render-options.ts +1 -1
- package/src/graph/GraphEngine.ts +65 -0
- package/src/stores/vocabulary.ts +12 -73
- package/src/utils/content-renderer.ts +312 -0
- package/src/utils/markdown-lite.ts +2 -2
- package/src/utils/math.ts +0 -189
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
4
|
+
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
|
+
import AppSidebar from '../components/AppSidebar.vue';
|
|
6
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
+
|
|
8
|
+
function makeManifest(id = 'test') {
|
|
9
|
+
return { id, datasetUri: `https://glossarist.org/${id}/concept`, title: `Test ${id}`, description: 'A test dataset',
|
|
10
|
+
owner: 'ISO', baseUrl: `/data/${id}`, languages: ['eng'], conceptCount: 50,
|
|
11
|
+
conceptUrlTemplate: `/data/${id}/concepts/{id}.json`, indexUrl: `/data/${id}/index.json`,
|
|
12
|
+
contextUrl: `/data/${id}/context.json`, uriBase: 'https://glossarist.org', status: 'published',
|
|
13
|
+
schemaVersion: '1.0', tags: [], lastUpdated: '2025-01-01', sourceRepo: 'https://example.com/repo',
|
|
14
|
+
chunkSize: 1000, color: '#3366ff' } as any;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createTestRouter(initialPath = '/') {
|
|
18
|
+
return createRouter({
|
|
19
|
+
history: createMemoryHistory(),
|
|
20
|
+
routes: [
|
|
21
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
22
|
+
{ path: '/dataset/:registerId', name: 'dataset', component: { template: '<div/>' } },
|
|
23
|
+
{ path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
|
|
24
|
+
{ path: '/dataset/:registerId/stats', name: 'stats', component: { template: '<div/>' } },
|
|
25
|
+
{ path: '/dataset/:registerId/about', name: 'about', component: { template: '<div/>' } },
|
|
26
|
+
{ path: '/search', name: 'search', component: { template: '<div/>' } },
|
|
27
|
+
{ path: '/about', name: 'about-global', component: { template: '<div/>' } },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function seedStore(datasets: string[] = ['test']) {
|
|
33
|
+
const store = useVocabularyStore();
|
|
34
|
+
for (const id of datasets) {
|
|
35
|
+
store.manifests.set(id, makeManifest(id));
|
|
36
|
+
store.datasets.set(id, { index: [], getConceptCount: () => 0, getConcepts: () => [] } as any);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns all <a> elements with the custom 'active' class.
|
|
42
|
+
* This is the class added by the isActive() function in AppSidebar,
|
|
43
|
+
* distinct from Vue Router's built-in router-link-active.
|
|
44
|
+
*/
|
|
45
|
+
function getActiveHrefs(wrapper: ReturnType<typeof mount>): string[] {
|
|
46
|
+
return wrapper.findAll('a.active').map(l => l.attributes('href') || '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('AppSidebar — nav highlighting (isActive)', () => {
|
|
50
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
pinia = createPinia();
|
|
54
|
+
setActivePinia(pinia);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function mountSidebar(initialPath = '/') {
|
|
58
|
+
const router = createTestRouter(initialPath);
|
|
59
|
+
router.push(initialPath);
|
|
60
|
+
await router.isReady();
|
|
61
|
+
return mount(AppSidebar, {
|
|
62
|
+
global: { plugins: [pinia, router], stubs: { NavIcon: true } },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── The bug: dataset About should NOT activate global About ─────────
|
|
67
|
+
|
|
68
|
+
it('on /dataset/test/about: ONLY dataset about link is active', async () => {
|
|
69
|
+
seedStore();
|
|
70
|
+
const wrapper = await mountSidebar('/dataset/test/about');
|
|
71
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
72
|
+
// Dataset about link IS active
|
|
73
|
+
expect(activeHrefs).toContain('/dataset/test/about');
|
|
74
|
+
// Global /about link is NOT active (this was the bug)
|
|
75
|
+
expect(activeHrefs).not.toContain('/about');
|
|
76
|
+
// Only one active link total
|
|
77
|
+
expect(activeHrefs).toEqual(['/dataset/test/about']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('on /dataset/test/about: no link to /about (global) has active class', async () => {
|
|
81
|
+
seedStore();
|
|
82
|
+
const wrapper = await mountSidebar('/dataset/test/about');
|
|
83
|
+
const allLinks = wrapper.findAll('a');
|
|
84
|
+
const globalAboutLinks = allLinks.filter(l => l.attributes('href') === '/about');
|
|
85
|
+
// If global about link exists, it must NOT have custom active class
|
|
86
|
+
for (const link of globalAboutLinks) {
|
|
87
|
+
expect(link.classes()).not.toContain('active');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── Global About page still works ───────────────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
it('on /about: dataset about links are NOT active', async () => {
|
|
95
|
+
seedStore();
|
|
96
|
+
const wrapper = await mountSidebar('/about');
|
|
97
|
+
const allLinks = wrapper.findAll('a');
|
|
98
|
+
const datasetAboutActive = allLinks.filter(
|
|
99
|
+
l => (l.attributes('href') || '').includes('/dataset/') &&
|
|
100
|
+
(l.attributes('href') || '').includes('/about') &&
|
|
101
|
+
l.classes().includes('active')
|
|
102
|
+
);
|
|
103
|
+
expect(datasetAboutActive).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Dataset root: dataset Concepts active, global Home not ──────────
|
|
107
|
+
|
|
108
|
+
it('on /dataset/test: dataset sub-nav is active', async () => {
|
|
109
|
+
seedStore();
|
|
110
|
+
const wrapper = await mountSidebar('/dataset/test');
|
|
111
|
+
// Something should be active (the dataset concepts sub-page)
|
|
112
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
113
|
+
expect(activeHrefs.length).toBeGreaterThanOrEqual(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('on /dataset/test: global nav links are NOT active', async () => {
|
|
117
|
+
seedStore();
|
|
118
|
+
const wrapper = await mountSidebar('/dataset/test');
|
|
119
|
+
const allLinks = wrapper.findAll('a');
|
|
120
|
+
// Global Search link must NOT be active
|
|
121
|
+
const searchActive = allLinks.filter(
|
|
122
|
+
l => l.attributes('href') === '/search' && l.classes().includes('active')
|
|
123
|
+
);
|
|
124
|
+
expect(searchActive).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Concept page: dataset Concepts still active ─────────────────────
|
|
128
|
+
|
|
129
|
+
it('on /dataset/test/concept/1.2: dataset sub-nav is active', async () => {
|
|
130
|
+
seedStore();
|
|
131
|
+
const wrapper = await mountSidebar('/dataset/test/concept/1.2');
|
|
132
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
133
|
+
expect(activeHrefs.length).toBeGreaterThanOrEqual(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── Cross-dataset isolation ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
it('on /dataset/ds-a/about: ds-b about is NOT active', async () => {
|
|
139
|
+
seedStore(['ds-a', 'ds-b']);
|
|
140
|
+
const wrapper = await mountSidebar('/dataset/ds-a/about');
|
|
141
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
142
|
+
expect(activeHrefs).toContain('/dataset/ds-a/about');
|
|
143
|
+
expect(activeHrefs).not.toContain('/dataset/ds-b/about');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── Root route ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
it('on /: Home link is active', async () => {
|
|
149
|
+
seedStore();
|
|
150
|
+
const wrapper = await mountSidebar('/');
|
|
151
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
152
|
+
expect(activeHrefs).toContain('/');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('on /: search and about are NOT active', async () => {
|
|
156
|
+
seedStore();
|
|
157
|
+
const wrapper = await mountSidebar('/');
|
|
158
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
159
|
+
expect(activeHrefs).not.toContain('/search');
|
|
160
|
+
expect(activeHrefs).not.toContain('/about');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── Dataset stats page ──────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
it('on /dataset/test/stats: stats sub-page is active', async () => {
|
|
166
|
+
seedStore();
|
|
167
|
+
const wrapper = await mountSidebar('/dataset/test/stats');
|
|
168
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
169
|
+
expect(activeHrefs).toContain('/dataset/test/stats');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('on /dataset/test/stats: about sub-page is NOT active', async () => {
|
|
173
|
+
seedStore();
|
|
174
|
+
const wrapper = await mountSidebar('/dataset/test/stats');
|
|
175
|
+
const activeHrefs = getActiveHrefs(wrapper);
|
|
176
|
+
expect(activeHrefs).not.toContain('/dataset/test/about');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { ReferenceResolver } from '../adapters/ReferenceResolver';
|
|
3
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
3
4
|
import type { Resolution } from '../adapters/types';
|
|
4
5
|
|
|
5
6
|
type InternalResolution = Extract<Resolution, { type: 'internal' }>;
|
|
@@ -10,13 +11,15 @@ function asInternal(r: Resolution | null): InternalResolution | null {
|
|
|
10
11
|
|
|
11
12
|
describe('Source reference resolution (citation linking)', () => {
|
|
12
13
|
let resolver: ReferenceResolver;
|
|
14
|
+
let uriRouter: UriRouter;
|
|
13
15
|
|
|
14
16
|
beforeEach(() => {
|
|
15
|
-
|
|
17
|
+
uriRouter = new UriRouter();
|
|
18
|
+
resolver = new ReferenceResolver(uriRouter);
|
|
16
19
|
// Register datasets with URI patterns
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
uriRouter.registerDataset('vim-2012', '', '', ['urn:oiml:pub:v:2:2012*']);
|
|
21
|
+
uriRouter.registerDataset('viml-2022', '', '', ['urn:oiml:pub:v:1:2022*']);
|
|
22
|
+
uriRouter.registerDataset('vim-2007', '', '', ['urn:oiml:pub:v:2:2007*']);
|
|
20
23
|
});
|
|
21
24
|
|
|
22
25
|
describe('hasSourceRef', () => {
|
|
@@ -132,7 +135,7 @@ describe('Source reference resolution (citation linking)', () => {
|
|
|
132
135
|
|
|
133
136
|
describe('ISO source references', () => {
|
|
134
137
|
it('resolves ISO/IEC references when registered', () => {
|
|
135
|
-
|
|
138
|
+
uriRouter.registerDataset('iso-17000', '', '', ['urn:iso:std:iso:iec:17000*']);
|
|
136
139
|
resolver.registerSourceRef('ISO/IEC 17000:2020', 'iso-17000', 'urn:iso:std:iso:iec:17000');
|
|
137
140
|
|
|
138
141
|
const result = resolver.resolveCitation('ISO/IEC 17000:2020', '3.1', 'viml-2022');
|
|
@@ -171,7 +174,7 @@ describe('Source reference resolution (citation linking)', () => {
|
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
it('handles source strings with special characters', () => {
|
|
174
|
-
|
|
177
|
+
uriRouter.registerDataset('vim-1993', '', '', ['urn:oiml:pub:v:2:1993*']);
|
|
175
178
|
resolver.registerSourceRef('OIML V 2:1993', 'vim-1993', 'urn:oiml:pub:v:2:1993');
|
|
176
179
|
const result = resolver.resolveCitation('OIML V 2:1993', '3.6');
|
|
177
180
|
expect(asInternal(result)?.registerId).toBe('vim-1993');
|
|
@@ -2,6 +2,8 @@ import { createPinia, setActivePinia } from 'pinia';
|
|
|
2
2
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
3
3
|
import { LocalizedConcept } from 'glossarist';
|
|
4
4
|
import type { Manifest, ConceptSummary, SearchHit } from '../adapters/types';
|
|
5
|
+
import { ReferenceResolver } from '../adapters/ReferenceResolver';
|
|
6
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
5
7
|
|
|
6
8
|
// ── Manifest Factory ──────────────────────────────────────────────────
|
|
7
9
|
|
|
@@ -173,3 +175,21 @@ export function setupPinia() {
|
|
|
173
175
|
setActivePinia(pinia);
|
|
174
176
|
return pinia;
|
|
175
177
|
}
|
|
178
|
+
|
|
179
|
+
// ── ReferenceResolver Setup ──────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
export interface ResolverPair {
|
|
182
|
+
uriRouter: UriRouter;
|
|
183
|
+
resolver: ReferenceResolver;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create a UriRouter + ReferenceResolver pair for testing.
|
|
188
|
+
* Use `pair.uriRouter.registerDataset()` to register URI patterns,
|
|
189
|
+
* then use `pair.resolver` for reference resolution.
|
|
190
|
+
*/
|
|
191
|
+
export function createResolverPair(): ResolverPair {
|
|
192
|
+
const uriRouter = new UriRouter();
|
|
193
|
+
const resolver = new ReferenceResolver(uriRouter);
|
|
194
|
+
return { uriRouter, resolver };
|
|
195
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { UriRouter } from '../adapters/UriRouter';
|
|
3
3
|
|
|
4
|
-
const MOCK_MANIFEST = { uriBase: 'https://glossarist.org' } as any;
|
|
5
|
-
|
|
6
4
|
describe('UriRouter', () => {
|
|
5
|
+
const URI_BASE = 'https://glossarist.org';
|
|
6
|
+
|
|
7
|
+
function register(router: UriRouter, registerId: string, baseUrl: string = `/data/${registerId}`) {
|
|
8
|
+
router.registerDataset(registerId, baseUrl, URI_BASE, [`${URI_BASE}/${registerId}/*`]);
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
it('resolves URIs for registered datasets', () => {
|
|
8
12
|
const router = new UriRouter();
|
|
9
|
-
router
|
|
13
|
+
register(router, 'iev');
|
|
10
14
|
|
|
11
15
|
const resolved = router.resolveUri('https://glossarist.org/iev/concept/103-01-02');
|
|
12
16
|
expect(resolved).toEqual({ registerId: 'iev', conceptId: '103-01-02' });
|
|
@@ -14,7 +18,7 @@ describe('UriRouter', () => {
|
|
|
14
18
|
|
|
15
19
|
it('resolves URIs with multi-part concept IDs', () => {
|
|
16
20
|
const router = new UriRouter();
|
|
17
|
-
router
|
|
21
|
+
register(router, 'isotc204');
|
|
18
22
|
|
|
19
23
|
const resolved = router.resolveUri('https://glossarist.org/isotc204/concept/3.1.1.1');
|
|
20
24
|
expect(resolved).toEqual({ registerId: 'isotc204', conceptId: '3.1.1.1' });
|
|
@@ -22,43 +26,66 @@ describe('UriRouter', () => {
|
|
|
22
26
|
|
|
23
27
|
it('returns null for unknown register', () => {
|
|
24
28
|
const router = new UriRouter();
|
|
25
|
-
router
|
|
29
|
+
register(router, 'iev');
|
|
26
30
|
|
|
27
31
|
expect(router.resolveUri('https://glossarist.org/unknown/concept/123')).toBeNull();
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
it('returns null for non-matching URI pattern', () => {
|
|
31
35
|
const router = new UriRouter();
|
|
32
|
-
router
|
|
36
|
+
register(router, 'iev');
|
|
33
37
|
|
|
34
38
|
expect(router.resolveUri('https://example.com/other')).toBeNull();
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
it('builds URIs from register and concept ID', () => {
|
|
38
42
|
const router = new UriRouter();
|
|
39
|
-
router
|
|
43
|
+
register(router, 'iev');
|
|
40
44
|
expect(router.buildUri('iev', '103-01-02')).toBe('https://glossarist.org/iev/concept/103-01-02');
|
|
41
45
|
});
|
|
42
46
|
|
|
43
47
|
it('lists all registered IDs', () => {
|
|
44
48
|
const router = new UriRouter();
|
|
45
|
-
router
|
|
46
|
-
router
|
|
49
|
+
register(router, 'iev');
|
|
50
|
+
register(router, 'isotc211');
|
|
47
51
|
|
|
48
52
|
expect(router.getRegisteredIds()).toEqual(['iev', 'isotc211']);
|
|
49
53
|
});
|
|
50
54
|
|
|
51
55
|
it('resolves across multiple registers', () => {
|
|
52
56
|
const router = new UriRouter();
|
|
53
|
-
router
|
|
54
|
-
router
|
|
55
|
-
router
|
|
57
|
+
register(router, 'iev');
|
|
58
|
+
register(router, 'isotc211');
|
|
59
|
+
register(router, 'isotc204');
|
|
56
60
|
|
|
57
61
|
expect(router.resolveUri('https://glossarist.org/iev/concept/102-01-01')?.registerId).toBe('iev');
|
|
58
62
|
expect(router.resolveUri('https://glossarist.org/isotc211/concept/10')?.registerId).toBe('isotc211');
|
|
59
63
|
expect(router.resolveUri('https://glossarist.org/isotc204/concept/3.1.1.1')?.registerId).toBe('isotc204');
|
|
60
64
|
});
|
|
61
65
|
|
|
66
|
+
it('resolves URN patterns with wildcard', () => {
|
|
67
|
+
const router = new UriRouter();
|
|
68
|
+
router.registerDataset('iso-10303', '', '', ['urn:iso:std:iso:10303:*']);
|
|
69
|
+
|
|
70
|
+
const resolved = router.resolveUri('urn:iso:std:iso:10303:3.1.1.1');
|
|
71
|
+
expect(resolved).toEqual({ registerId: 'iso-10303', conceptId: '3.1.1.1' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resolves URN prefix to registerId', () => {
|
|
75
|
+
const router = new UriRouter();
|
|
76
|
+
router.registerDataset('iso-10303', '', '', ['urn:iso:std:iso:10303:*']);
|
|
77
|
+
|
|
78
|
+
expect(router.resolveUrn('urn:iso:std:iso:10303')).toBe('iso-10303');
|
|
79
|
+
expect(router.resolveUrn('urn:unknown:prefix')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns uriBase for registered dataset', () => {
|
|
83
|
+
const router = new UriRouter();
|
|
84
|
+
register(router, 'iev');
|
|
85
|
+
expect(router.getUriBase('iev')).toBe(URI_BASE);
|
|
86
|
+
expect(router.getUriBase('unknown')).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
62
89
|
describe('parseUri (static)', () => {
|
|
63
90
|
it('extracts register and concept from any glossarist URI', () => {
|
|
64
91
|
expect(UriRouter.parseUri('https://glossarist.org/iev/concept/103-01-02')).toEqual({
|
|
@@ -9,10 +9,9 @@ import type {
|
|
|
9
9
|
SectionNode,
|
|
10
10
|
DatasetSummary,
|
|
11
11
|
} from './types';
|
|
12
|
-
import type { Concept
|
|
13
|
-
import { conceptFromJson
|
|
14
|
-
import {
|
|
15
|
-
import { slugify } from '../utils/slugify';
|
|
12
|
+
import type { Concept } from 'glossarist';
|
|
13
|
+
import { conceptFromJson } from './model-bridge';
|
|
14
|
+
import { GraphDataSource } from './GraphDataSource';
|
|
16
15
|
|
|
17
16
|
// ── Wire-format types for JSON responses ────────────────────────────────────
|
|
18
17
|
|
|
@@ -33,36 +32,6 @@ interface IndexConceptJson {
|
|
|
33
32
|
groups?: string[];
|
|
34
33
|
}
|
|
35
34
|
|
|
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[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
|
|
53
|
-
if (!rc.ref) return '';
|
|
54
|
-
const ref = rc.ref;
|
|
55
|
-
if (ref.id) {
|
|
56
|
-
let reg = registerId;
|
|
57
|
-
if (ref.source && !ref.source.startsWith('http')) {
|
|
58
|
-
reg = urnMap?.get(ref.source) ?? ref.source;
|
|
59
|
-
}
|
|
60
|
-
return `${uriBase}/${reg}/concept/${ref.id}`;
|
|
61
|
-
}
|
|
62
|
-
if (ref.source && ref.source.startsWith('http')) return ref.source;
|
|
63
|
-
return ref.source || '';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
35
|
export class DatasetAdapter {
|
|
67
36
|
private positionIndex = new Map<string, number>();
|
|
68
37
|
private _urnMap: ReadonlyMap<string, string> = new Map();
|
|
@@ -75,6 +44,7 @@ export class DatasetAdapter {
|
|
|
75
44
|
private conceptCache = new Map<string, Concept>();
|
|
76
45
|
private static MAX_CACHE = 100;
|
|
77
46
|
private summaryMap = new Map<string, ConceptSummary>();
|
|
47
|
+
private designationMap = new Map<string, string>();
|
|
78
48
|
private loadedChunks = new Set<number>();
|
|
79
49
|
private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
|
|
80
50
|
|
|
@@ -158,11 +128,17 @@ export class DatasetAdapter {
|
|
|
158
128
|
private buildSummaryIndex() {
|
|
159
129
|
this.summaryMap.clear();
|
|
160
130
|
this.positionIndex.clear();
|
|
131
|
+
this.designationMap.clear();
|
|
161
132
|
for (let i = 0; i < this.index!.concepts.length; i++) {
|
|
162
133
|
const entry = this.index!.concepts[i];
|
|
163
134
|
if (entry) {
|
|
164
135
|
this.summaryMap.set(entry.id, entry);
|
|
165
136
|
this.positionIndex.set(entry.id, i);
|
|
137
|
+
for (const term of Object.values(entry.designations)) {
|
|
138
|
+
if (term && !this.designationMap.has(term.toLowerCase())) {
|
|
139
|
+
this.designationMap.set(term.toLowerCase(), entry.id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
166
142
|
}
|
|
167
143
|
}
|
|
168
144
|
}
|
|
@@ -294,6 +270,11 @@ export class DatasetAdapter {
|
|
|
294
270
|
return this.summaryMap.get(conceptId);
|
|
295
271
|
}
|
|
296
272
|
|
|
273
|
+
/** Look up a concept ID by its designation string (case-insensitive). */
|
|
274
|
+
lookupByDesignation(designation: string): string | undefined {
|
|
275
|
+
return this.designationMap.get(designation.toLowerCase());
|
|
276
|
+
}
|
|
277
|
+
|
|
297
278
|
getConcepts(): (ConceptSummary | undefined)[] {
|
|
298
279
|
return this.index?.concepts ?? [];
|
|
299
280
|
}
|
|
@@ -376,131 +357,42 @@ export class DatasetAdapter {
|
|
|
376
357
|
this._urnMap = map;
|
|
377
358
|
}
|
|
378
359
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
|
|
382
|
-
const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
|
|
383
|
-
|
|
384
|
-
// Managed concept level relationships
|
|
385
|
-
for (const rc of concept.relatedConcepts) {
|
|
386
|
-
const target = resolveRefTarget(rc, uriBase, this.registerId, this._urnMap);
|
|
387
|
-
if (target && target !== sourceUri) {
|
|
388
|
-
const parsed = UriRouter.parseUri(target);
|
|
389
|
-
edges.push({
|
|
390
|
-
source: sourceUri,
|
|
391
|
-
target,
|
|
392
|
-
type: rc.type || 'references',
|
|
393
|
-
label: rc.content || undefined,
|
|
394
|
-
register: parsed?.registerId ?? this.registerId,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Per-localization references (from inline extraction in generate-data)
|
|
400
|
-
for (const lang of concept.languages) {
|
|
401
|
-
const lc = concept.localization(lang);
|
|
402
|
-
if (!lc) continue;
|
|
403
|
-
for (const rc of lc.related) {
|
|
404
|
-
const target = resolveRefTarget(rc, uriBase, this.registerId, this._urnMap);
|
|
405
|
-
if (target && target !== sourceUri) {
|
|
406
|
-
const parsed = UriRouter.parseUri(target);
|
|
407
|
-
edges.push({
|
|
408
|
-
source: sourceUri,
|
|
409
|
-
target,
|
|
410
|
-
type: rc.type || 'references',
|
|
411
|
-
label: rc.content || undefined,
|
|
412
|
-
register: parsed?.registerId ?? this.registerId,
|
|
413
|
-
lang,
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return edges;
|
|
360
|
+
get dataUrl(): string {
|
|
361
|
+
return this.baseUrl;
|
|
420
362
|
}
|
|
421
363
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const uriBase = this.manifest?.uriBase || 'https://glossarist.org';
|
|
425
|
-
const sourceUri = concept.uri || `${uriBase}/${this.registerId}/concept/${concept.id}`;
|
|
426
|
-
|
|
427
|
-
for (const lang of concept.languages) {
|
|
428
|
-
const lc = concept.localization(lang);
|
|
429
|
-
if (lc?.domain) {
|
|
430
|
-
edges.push({
|
|
431
|
-
source: sourceUri,
|
|
432
|
-
target: `${uriBase}/${this.registerId}/domain/${slugify(lc.domain)}`,
|
|
433
|
-
type: 'domain',
|
|
434
|
-
label: lc.domain,
|
|
435
|
-
register: this.registerId,
|
|
436
|
-
lang,
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
return edges;
|
|
364
|
+
get urnMap(): ReadonlyMap<string, string> {
|
|
365
|
+
return this._urnMap;
|
|
441
366
|
}
|
|
442
367
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (!
|
|
446
|
-
|
|
447
|
-
return (data.domainNodes || []).map((dn: DomainNodeJson) => this.mapDomainNode(dn));
|
|
368
|
+
private _graphDataSource: GraphDataSource | null = null;
|
|
369
|
+
get graphDataSource(): GraphDataSource {
|
|
370
|
+
if (!this._graphDataSource) this._graphDataSource = new GraphDataSource(this);
|
|
371
|
+
return this._graphDataSource;
|
|
448
372
|
}
|
|
449
373
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
uri: dn.uri ?? '',
|
|
453
|
-
register: dn.registerId ?? '',
|
|
454
|
-
conceptId: dn.uri?.split('/domain/')[1] || dn.id || '',
|
|
455
|
-
designations: dn.names || (dn.label ? { eng: dn.label } : {}),
|
|
456
|
-
status: 'domain',
|
|
457
|
-
loaded: true,
|
|
458
|
-
nodeType: 'domain' as const,
|
|
459
|
-
conceptCount: dn.conceptCount || 0,
|
|
460
|
-
};
|
|
461
|
-
if (dn.children && dn.children.length > 0) {
|
|
462
|
-
node.children = dn.children.map((c) => this.mapSectionNode(c));
|
|
463
|
-
}
|
|
464
|
-
return node;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
private mapSectionNode(dn: DomainNodeJson): SectionNode {
|
|
468
|
-
const node: SectionNode = {
|
|
469
|
-
id: dn.id ?? '',
|
|
470
|
-
names: dn.names || (dn.label ? { eng: dn.label } : {}),
|
|
471
|
-
conceptCount: dn.conceptCount || 0,
|
|
472
|
-
};
|
|
473
|
-
if (dn.children && dn.children.length > 0) {
|
|
474
|
-
node.children = dn.children.map((c) => this.mapSectionNode(c));
|
|
475
|
-
}
|
|
476
|
-
return node;
|
|
374
|
+
extractEdges(concept: Concept): GraphEdge[] {
|
|
375
|
+
return this.graphDataSource.extractEdges(concept);
|
|
477
376
|
}
|
|
478
377
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (!nodes || nodes.length === 0) return [];
|
|
482
|
-
return nodes.map(s => this.mapManifestSection(s));
|
|
378
|
+
extractDomainEdges(concept: Concept): GraphEdge[] {
|
|
379
|
+
return this.graphDataSource.extractDomainEdges(concept);
|
|
483
380
|
}
|
|
484
381
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (s.children && s.children.length > 0) {
|
|
488
|
-
node.children = s.children.map(c => this.mapManifestSection(c));
|
|
489
|
-
}
|
|
490
|
-
return node;
|
|
382
|
+
async loadDomainNodes(): Promise<GraphNode[]> {
|
|
383
|
+
return this.graphDataSource.loadDomainNodes();
|
|
491
384
|
}
|
|
492
385
|
|
|
493
386
|
async loadEdgeIndex(): Promise<GraphEdge[]> {
|
|
494
|
-
|
|
495
|
-
if (!resp.ok) return [];
|
|
496
|
-
const data = await resp.json();
|
|
497
|
-
return data.edges ?? [];
|
|
387
|
+
return this.graphDataSource.loadEdgeIndex();
|
|
498
388
|
}
|
|
499
389
|
|
|
500
390
|
async loadGraphNodes(): Promise<{ uriPrefix: string; nodes: [string, Record<string, string>, string][] }> {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
391
|
+
return this.graphDataSource.loadGraphNodes();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
getSectionTree(): SectionNode[] {
|
|
395
|
+
return this.graphDataSource.getSectionTree();
|
|
504
396
|
}
|
|
505
397
|
|
|
506
398
|
getLanguages(): string[] {
|