@glossarist/concept-browser 0.7.139 → 0.7.141
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/__tests__/standards-index.test.ts +121 -0
- package/scripts/build-edges.js +1 -23
- package/scripts/generate-data.js +206 -25
- package/scripts/generate-data.ts +235 -34
- package/scripts/lib/workers/emit-concept-worker.mjs +31 -0
- package/src/__tests__/app-footer.test.ts +36 -0
- package/src/__tests__/app-header.test.ts +32 -5
- package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
- package/src/__tests__/concept-view.test.ts +103 -0
- package/src/__tests__/dataset-adapter.test.ts +150 -0
- package/src/__tests__/dataset-view.test.ts +41 -1
- package/src/__tests__/model-bridge-citation.test.ts +20 -0
- package/src/__tests__/same-standard-pane.test.ts +174 -0
- package/src/__tests__/scripts/generate-data.test.ts +78 -0
- package/src/__tests__/search-across-datasets.test.ts +102 -0
- package/src/__tests__/search-bar.test.ts +304 -0
- package/src/__tests__/search-results.test.ts +54 -0
- package/src/__tests__/search-utils.test.ts +64 -1
- package/src/__tests__/search-view.test.ts +194 -2
- package/src/__tests__/source-standard.test.ts +70 -0
- package/src/__tests__/ui-store.test.ts +26 -0
- package/src/__tests__/vocabulary-layered.test.ts +7 -7
- package/src/adapters/DatasetAdapter.ts +16 -8
- package/src/adapters/jsonld-types.ts +1 -0
- package/src/adapters/model-bridge.ts +1 -0
- package/src/adapters/standards-index.ts +7 -0
- package/src/adapters/types.ts +1 -0
- package/src/components/AppFooter.vue +7 -0
- package/src/components/AppHeader.vue +6 -5
- package/src/components/SameStandardPane.vue +70 -0
- package/src/components/SearchBar.vue +73 -18
- package/src/components/SearchResults.vue +46 -5
- package/src/components/site/SiteFooter.astro +6 -0
- package/src/composables/use-same-standard-terms.ts +106 -0
- package/src/config/types.ts +11 -0
- package/src/config/use-site-config.ts +1 -0
- package/src/i18n/locales/eng.yml +10 -1
- package/src/i18n/locales/fra.yml +10 -1
- package/src/stores/ui.ts +16 -1
- package/src/stores/vocabulary.ts +27 -31
- package/src/utils/search.ts +14 -1
- package/src/utils/source-standard.ts +34 -0
- package/src/views/ConceptView.vue +43 -12
- package/src/views/DatasetView.vue +25 -5
- package/src/views/SearchView.vue +9 -12
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
3
|
+
import { createRouter, createMemoryHistory, RouterView } from 'vue-router';
|
|
3
4
|
import SearchView from '../views/SearchView.vue';
|
|
5
|
+
import { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
4
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
|
-
import {
|
|
7
|
+
import { useUiStore } from '../stores/ui';
|
|
8
|
+
import { createTestRouter, setupPinia, makeManifest, makeAdapterStub, makeSearchHit } from './test-helpers';
|
|
6
9
|
|
|
7
10
|
describe('SearchView', () => {
|
|
8
11
|
let pinia: ReturnType<typeof setupPinia>;
|
|
@@ -12,22 +15,119 @@ describe('SearchView', () => {
|
|
|
12
15
|
pinia = setupPinia();
|
|
13
16
|
router = await createTestRouter('search', '/search');
|
|
14
17
|
const store = useVocabularyStore();
|
|
18
|
+
store.initialized = true;
|
|
15
19
|
store.manifests.set('test', makeManifest());
|
|
16
20
|
store.datasets.set('test', makeAdapterStub());
|
|
17
21
|
});
|
|
18
22
|
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
});
|
|
26
|
+
|
|
19
27
|
function mountView() {
|
|
20
28
|
return mount(SearchView, {
|
|
21
29
|
global: { plugins: [pinia, router], stubs: { SearchBar: true } },
|
|
22
30
|
});
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
async function mountRoutedSearch(path = '/search') {
|
|
34
|
+
router = createRouter({
|
|
35
|
+
history: createMemoryHistory(),
|
|
36
|
+
routes: [
|
|
37
|
+
{ path: '/', name: 'home', component: { template: '<div>Home page</div>' } },
|
|
38
|
+
{ path: '/search', name: 'search', component: SearchView },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
await router.push(path);
|
|
42
|
+
await router.isReady();
|
|
43
|
+
const wrapper = mount({ components: { RouterView }, template: '<RouterView />' }, {
|
|
44
|
+
global: { plugins: [pinia, router] },
|
|
45
|
+
});
|
|
46
|
+
await flushPromises();
|
|
47
|
+
return wrapper;
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
it('renders breadcrumb navigation', () => {
|
|
26
51
|
const wrapper = mountView();
|
|
27
52
|
expect(wrapper.text()).toContain('Home');
|
|
28
53
|
expect(wrapper.text()).toContain('Search');
|
|
29
54
|
});
|
|
30
55
|
|
|
56
|
+
it.each(['transient', 'persistent'] as const)('warns about a %s chunk failure and retries from the routed search', async (failure) => {
|
|
57
|
+
const manifest = makeManifest({ conceptCount: 2001 });
|
|
58
|
+
const adapter = new DatasetAdapter('test', '/data/test');
|
|
59
|
+
adapter.manifest = manifest;
|
|
60
|
+
const store = useVocabularyStore();
|
|
61
|
+
store.manifests.set('test', manifest);
|
|
62
|
+
store.datasets.set('test', adapter);
|
|
63
|
+
|
|
64
|
+
const chunks = [
|
|
65
|
+
Array.from({ length: 1000 }, (_, i) => ({
|
|
66
|
+
id: i === 0 ? '276' : `first-${i}`,
|
|
67
|
+
designations: { eng: i === 0 ? 'location' : `unrelated first ${i}` },
|
|
68
|
+
status: i === 0 ? 'superseded' : 'valid',
|
|
69
|
+
})),
|
|
70
|
+
Array.from({ length: 1000 }, (_, i) => ({
|
|
71
|
+
id: i === 0 ? '2345' : `second-${i}`,
|
|
72
|
+
designations: { eng: i === 0 ? 'location' : `unrelated second ${i}` },
|
|
73
|
+
status: 'valid',
|
|
74
|
+
})),
|
|
75
|
+
[{ id: '9999', designations: { eng: 'unrelated last entry' }, status: 'valid' }],
|
|
76
|
+
];
|
|
77
|
+
let failedChunkRequests = 0;
|
|
78
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
79
|
+
const url = String(input);
|
|
80
|
+
if (url === '/data/test/index-meta.json') {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
json: async () => ({
|
|
85
|
+
registerId: 'test', schemaVersion: '1.0.0', conceptCount: 2001, chunkSize: 1000,
|
|
86
|
+
chunks: chunks.map((concepts, i) => ({ file: `index-${i}.json`, count: concepts.length })),
|
|
87
|
+
}),
|
|
88
|
+
} as Response;
|
|
89
|
+
}
|
|
90
|
+
const chunkIndex = chunks.findIndex((_, i) => url === `/data/test/chunks/index-${i}.json`);
|
|
91
|
+
if (chunkIndex < 0) throw new Error(`Unexpected fetch: ${url}`);
|
|
92
|
+
if (chunkIndex === 1 && (++failedChunkRequests === 1 || failure === 'persistent')) {
|
|
93
|
+
return { ok: false, status: 404 } as Response;
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, status: 200, json: async () => ({ concepts: chunks[chunkIndex] }) } as Response;
|
|
96
|
+
});
|
|
97
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
98
|
+
|
|
99
|
+
const wrapper = await mountRoutedSearch('/search?q=location');
|
|
100
|
+
try {
|
|
101
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('location');
|
|
102
|
+
expect(wrapper.get('[role="alert"]').text()).toContain('Some results could not be loaded');
|
|
103
|
+
expect(wrapper.get('.search-results').text()).toContain('276');
|
|
104
|
+
expect(wrapper.get('.search-results').text()).toContain('superseded');
|
|
105
|
+
expect(wrapper.get('.search-results').text()).not.toContain('2345');
|
|
106
|
+
expect(wrapper.get('.search-results').text()).not.toContain('9999');
|
|
107
|
+
|
|
108
|
+
const retry = wrapper.findAll('button').find(button => button.text() === 'Retry');
|
|
109
|
+
expect(retry).toBeDefined();
|
|
110
|
+
await retry!.trigger('click');
|
|
111
|
+
await flushPromises();
|
|
112
|
+
|
|
113
|
+
expect(failedChunkRequests).toBe(2);
|
|
114
|
+
expect(router.currentRoute.value.fullPath).toBe('/search?q=location');
|
|
115
|
+
expect(wrapper.get('.search-results').text()).toContain('276');
|
|
116
|
+
expect(wrapper.get('.search-results').text()).not.toContain('9999');
|
|
117
|
+
if (failure === 'transient') {
|
|
118
|
+
expect(wrapper.get('.search-results').text()).toContain('2345');
|
|
119
|
+
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
|
|
120
|
+
} else {
|
|
121
|
+
expect(wrapper.get('.search-results').text()).not.toContain('2345');
|
|
122
|
+
expect(wrapper.get('[role="alert"]').text()).toContain('Some results could not be loaded');
|
|
123
|
+
}
|
|
124
|
+
expect(fetchMock.mock.calls.filter(([url]) => url === '/data/test/chunks/index-0.json')).toHaveLength(1);
|
|
125
|
+
expect(fetchMock.mock.calls.filter(([url]) => url === '/data/test/chunks/index-2.json')).toHaveLength(1);
|
|
126
|
+
} finally {
|
|
127
|
+
wrapper.unmount();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
31
131
|
it('renders SearchBar component', () => {
|
|
32
132
|
const wrapper = mountView();
|
|
33
133
|
expect(wrapper.findComponent({ name: 'SearchBar' }).exists()).toBe(true);
|
|
@@ -38,4 +138,96 @@ describe('SearchView', () => {
|
|
|
38
138
|
const homeLink = wrapper.findAll('a').find(a => a.text() === 'Home');
|
|
39
139
|
expect(homeLink).toBeDefined();
|
|
40
140
|
});
|
|
141
|
+
|
|
142
|
+
it('searches an initial query parameter and restores it when returning to search', async () => {
|
|
143
|
+
const store = useVocabularyStore();
|
|
144
|
+
const search = vi.fn(() => [makeSearchHit({ designation: 'Foo result' })]);
|
|
145
|
+
store.datasets.set('test', makeAdapterStub({ search }));
|
|
146
|
+
const wrapper = await mountRoutedSearch('/search?q=foo');
|
|
147
|
+
const ui = useUiStore();
|
|
148
|
+
await flushPromises();
|
|
149
|
+
|
|
150
|
+
expect(search).toHaveBeenCalledWith('foo');
|
|
151
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('foo');
|
|
153
|
+
expect(wrapper.text()).toContain('Foo result');
|
|
154
|
+
|
|
155
|
+
await router.push('/');
|
|
156
|
+
await flushPromises();
|
|
157
|
+
expect(wrapper.text()).toContain('Home page');
|
|
158
|
+
expect(ui.searchQuery).toBe('');
|
|
159
|
+
|
|
160
|
+
await router.push('/search');
|
|
161
|
+
await flushPromises();
|
|
162
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
163
|
+
expect(ui.searchQuery).toBe('');
|
|
164
|
+
expect(wrapper.text()).not.toContain('Foo result');
|
|
165
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
166
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
167
|
+
|
|
168
|
+
await router.push('/');
|
|
169
|
+
await flushPromises();
|
|
170
|
+
await router.push('/search?q=bar');
|
|
171
|
+
await flushPromises();
|
|
172
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('bar');
|
|
173
|
+
expect(search).toHaveBeenLastCalledWith('bar');
|
|
174
|
+
wrapper.unmount();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('ignores a leftover store query when mounting without a query parameter', async () => {
|
|
178
|
+
const ui = useUiStore();
|
|
179
|
+
ui.searchQuery = 'stale';
|
|
180
|
+
const search = vi.spyOn(useVocabularyStore(), 'searchAcrossDatasets');
|
|
181
|
+
const wrapper = await mountRoutedSearch('/search');
|
|
182
|
+
|
|
183
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
184
|
+
expect(ui.searchQuery).toBe('');
|
|
185
|
+
expect(search).not.toHaveBeenCalled();
|
|
186
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
187
|
+
wrapper.unmount();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('clears the search when the query parameter is removed while mounted', async () => {
|
|
191
|
+
const store = useVocabularyStore();
|
|
192
|
+
const search = vi.fn(() => [makeSearchHit({ designation: 'Foo result' })]);
|
|
193
|
+
store.datasets.set('test', makeAdapterStub({ search }));
|
|
194
|
+
const wrapper = await mountRoutedSearch('/search?q=foo');
|
|
195
|
+
const ui = useUiStore();
|
|
196
|
+
await flushPromises();
|
|
197
|
+
expect(wrapper.text()).toContain('Foo result');
|
|
198
|
+
|
|
199
|
+
await router.replace('/search?q=bar');
|
|
200
|
+
await flushPromises();
|
|
201
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('bar');
|
|
202
|
+
expect(search).toHaveBeenLastCalledWith('bar');
|
|
203
|
+
|
|
204
|
+
await router.replace('/search');
|
|
205
|
+
await flushPromises();
|
|
206
|
+
|
|
207
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
208
|
+
expect(ui.searchQuery).toBe('');
|
|
209
|
+
expect(wrapper.text()).not.toContain('Foo result');
|
|
210
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
211
|
+
wrapper.unmount();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not let a pending search restore the URL after leaving search', async () => {
|
|
215
|
+
const store = useVocabularyStore();
|
|
216
|
+
let resolveSearch!: (hits: ReturnType<typeof makeSearchHit>[]) => void;
|
|
217
|
+
const search = vi.spyOn(store, 'searchAcrossDatasets').mockImplementation(() => new Promise(resolve => {
|
|
218
|
+
resolveSearch = resolve;
|
|
219
|
+
}));
|
|
220
|
+
const wrapper = await mountRoutedSearch('/search?q=foo');
|
|
221
|
+
await flushPromises();
|
|
222
|
+
expect(search).toHaveBeenCalledWith('foo');
|
|
223
|
+
|
|
224
|
+
await router.push('/');
|
|
225
|
+
resolveSearch([makeSearchHit({ designation: 'Late result' })]);
|
|
226
|
+
await flushPromises();
|
|
227
|
+
|
|
228
|
+
expect(router.currentRoute.value.fullPath).toBe('/');
|
|
229
|
+
expect(wrapper.text()).toContain('Home page');
|
|
230
|
+
expect(wrapper.text()).not.toContain('Late result');
|
|
231
|
+
wrapper.unmount();
|
|
232
|
+
});
|
|
41
233
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Citation, Concept } from 'glossarist';
|
|
3
|
+
import { collectStandardKeys, standardKey, standardLabel } from '../utils/source-standard';
|
|
4
|
+
|
|
5
|
+
describe('standard references', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
[{ source: 'ISO', id: '19165-2:2020' }, 'iso 19165-2:2020', 'ISO 19165-2:2020'],
|
|
8
|
+
[{ id: '19165-2:2020' }, '19165-2:2020', '19165-2:2020'],
|
|
9
|
+
[{ source: 'ISO 19165-2:2020' }, 'iso 19165-2:2020', 'ISO 19165-2:2020'],
|
|
10
|
+
[{ source: ' ISO\t IEC ', id: ' 17000:2020\n' }, 'iso iec 17000:2020', 'ISO IEC 17000:2020'],
|
|
11
|
+
[{ source: ' ', id: '19165-2:2020' }, '19165-2:2020', '19165-2:2020'],
|
|
12
|
+
])('combines usable source and id: %j', (ref, key, label) => {
|
|
13
|
+
expect(standardKey(ref)).toBe(key);
|
|
14
|
+
expect(standardLabel(ref)).toBe(label);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each([undefined, null, {}, { source: ' \t', id: '\n' }])('returns null for unusable ref %j', (ref) => {
|
|
18
|
+
expect(standardKey(ref)).toBeNull();
|
|
19
|
+
expect(standardLabel(ref)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('ignores clauses and localities without changing the input', () => {
|
|
23
|
+
const ref = Object.freeze({ source: 'ISO', id: '19165-2:2020', clause: '3.1', locality: '3.1' });
|
|
24
|
+
const otherClause = { ...ref, clause: '4.2', locality: '4.2' };
|
|
25
|
+
expect(standardKey(ref)).toBe('iso 19165-2:2020');
|
|
26
|
+
expect(standardKey(otherClause)).toBe(standardKey(ref));
|
|
27
|
+
expect(standardLabel(ref)).toBe('ISO 19165-2:2020');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('distinguishes standards and editions but ignores case', () => {
|
|
31
|
+
const ref = { source: 'ISO', id: '19165-2:2020' };
|
|
32
|
+
expect(standardKey({ ...ref, source: 'iso' })).toBe(standardKey(ref));
|
|
33
|
+
expect(standardKey({ ...ref, id: '19165-2:2024' })).not.toBe(standardKey(ref));
|
|
34
|
+
expect(standardKey({ ...ref, id: '19115:2020' })).not.toBe(standardKey(ref));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('collectStandardKeys', () => {
|
|
39
|
+
it('collects unique authoritative references across the concept and all languages', () => {
|
|
40
|
+
const source = (id: string, type = 'authoritative') => ({ type, origin: { ref: { source: 'ISO', id } } });
|
|
41
|
+
const concept = Concept.fromJSON({
|
|
42
|
+
id: '1',
|
|
43
|
+
sources: [source('19165-2:2020'), source('704:2022', 'lineage'), { type: 'authoritative' }],
|
|
44
|
+
localizations: {
|
|
45
|
+
eng: { sources: [source('19165-2:2020'), source('19165-2:2024')] },
|
|
46
|
+
fra: { sources: [source('1087:2019'), source('999:2020', 'lineage'), source('998:2020', '')] },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020', 'iso 19165-2:2024', 'iso 1087:2019']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns no keys for a concept without sources', () => {
|
|
53
|
+
expect(collectStandardKeys(Concept.fromJSON({ id: '1' }))).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('deduplicates citations of different clauses of the same standard', () => {
|
|
57
|
+
const concept = Concept.fromJSON({
|
|
58
|
+
id: '1',
|
|
59
|
+
sources: ['3.1', '4.2'].map(clause => ({
|
|
60
|
+
type: 'authoritative',
|
|
61
|
+
origin: Citation.fromJSON({
|
|
62
|
+
ref: { source: 'ISO', id: '19165-2:2020' },
|
|
63
|
+
locality: { type: 'clause', reference_from: clause },
|
|
64
|
+
}),
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
expect(concept.sources[0].origin?.locality).not.toEqual(concept.sources[1].origin?.locality);
|
|
68
|
+
expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -97,4 +97,30 @@ describe('useUiStore', () => {
|
|
|
97
97
|
ui.setTheme('light');
|
|
98
98
|
expect(ui.isDark).toBe(false);
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
it('defaults searchSort to language', () => {
|
|
102
|
+
const ui = useUiStore();
|
|
103
|
+
expect(ui.searchSort).toBe('language');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('setSearchSort updates value and localStorage', () => {
|
|
107
|
+
const ui = useUiStore();
|
|
108
|
+
ui.setSearchSort('relevance');
|
|
109
|
+
expect(ui.searchSort).toBe('relevance');
|
|
110
|
+
expect(localStorage.getItem('searchSort')).toBe('relevance');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reads stored searchSort from localStorage', () => {
|
|
114
|
+
localStorage.setItem('searchSort', 'relevance');
|
|
115
|
+
setActivePinia(createPinia());
|
|
116
|
+
const ui = useUiStore();
|
|
117
|
+
expect(ui.searchSort).toBe('relevance');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('falls back to language for a junk stored searchSort', () => {
|
|
121
|
+
localStorage.setItem('searchSort', 'bogus');
|
|
122
|
+
setActivePinia(createPinia());
|
|
123
|
+
const ui = useUiStore();
|
|
124
|
+
expect(ui.searchSort).toBe('language');
|
|
125
|
+
});
|
|
100
126
|
});
|
|
@@ -178,7 +178,7 @@ describe('vocabulary store — layered loading', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
describe('searchAcrossDatasets — two-pass', () => {
|
|
181
|
-
it('
|
|
181
|
+
it('loads every chunk even when the index already has 20 or more hits', async () => {
|
|
182
182
|
const store = useVocabularyStore();
|
|
183
183
|
store.initialized = true;
|
|
184
184
|
|
|
@@ -207,11 +207,11 @@ describe('vocabulary store — layered loading', () => {
|
|
|
207
207
|
const results = await store.searchAcrossDatasets('concept');
|
|
208
208
|
|
|
209
209
|
expect(results.length).toBe(25);
|
|
210
|
-
//
|
|
211
|
-
expect(chunksLoaded).toBe(
|
|
210
|
+
// Load every chunk even when the index already has at least 20 hits.
|
|
211
|
+
expect(chunksLoaded).toBe(true);
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
-
it('loads
|
|
214
|
+
it('loads every dataset regardless of whether its index has hits', async () => {
|
|
215
215
|
const store = useVocabularyStore();
|
|
216
216
|
store.initialized = true;
|
|
217
217
|
|
|
@@ -258,9 +258,9 @@ describe('vocabulary store — layered loading', () => {
|
|
|
258
258
|
const results = await store.searchAcrossDatasets('item');
|
|
259
259
|
|
|
260
260
|
expect(results.length).toBe(6); // 5 from ds1 index + 1 from ds2 chunks
|
|
261
|
-
// Adapter 1
|
|
262
|
-
expect(adapter1ChunksLoaded).toBe(
|
|
263
|
-
// Adapter 2
|
|
261
|
+
// Adapter 1 loads every chunk even though its index already had hits.
|
|
262
|
+
expect(adapter1ChunksLoaded).toBe(true);
|
|
263
|
+
// Adapter 2 also loads every chunk, exposing its hidden hit.
|
|
264
264
|
expect(adapter2ChunksLoaded).toBe(true);
|
|
265
265
|
});
|
|
266
266
|
|
|
@@ -26,7 +26,7 @@ interface IndexJson {
|
|
|
26
26
|
schemaVersion: string;
|
|
27
27
|
conceptCount: number;
|
|
28
28
|
chunkSize: number;
|
|
29
|
-
chunks: { file: string; count: number }[];
|
|
29
|
+
chunks: { file: string; count: number; hash?: string }[];
|
|
30
30
|
concepts: IndexConceptJson[];
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -52,7 +52,7 @@ export class DatasetAdapter {
|
|
|
52
52
|
private summaryMap = new Map<string, ConceptSummary>();
|
|
53
53
|
private designationMap = new Map<string, string>();
|
|
54
54
|
private loadedChunks = new Set<number>();
|
|
55
|
-
private indexMeta:
|
|
55
|
+
private indexMeta: Pick<IndexJson, 'conceptCount' | 'chunkSize' | 'chunks'> | null = null;
|
|
56
56
|
|
|
57
57
|
constructor(registerId: string, baseUrl: string) {
|
|
58
58
|
this.registerId = registerId;
|
|
@@ -152,7 +152,7 @@ export class DatasetAdapter {
|
|
|
152
152
|
|
|
153
153
|
private async loadIndexChunked(): Promise<ConceptIndex> {
|
|
154
154
|
const metaResp = await fetch(`${this.baseUrl}/index-meta.json`);
|
|
155
|
-
let meta:
|
|
155
|
+
let meta: Omit<IndexJson, 'concepts'>;
|
|
156
156
|
if (metaResp.ok) {
|
|
157
157
|
meta = await metaResp.json();
|
|
158
158
|
} else {
|
|
@@ -183,10 +183,17 @@ export class DatasetAdapter {
|
|
|
183
183
|
return this.index;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
private chunkUrl(chunkIndex: number): string {
|
|
187
|
+
const chunks: IndexJson['chunks'] = this.indexMeta?.chunks ?? this.index?.chunks ?? [];
|
|
188
|
+
const chunk = chunks[chunkIndex];
|
|
189
|
+
const file = chunk?.file ?? `index-${String(chunkIndex).padStart(4, '0')}.json`;
|
|
190
|
+
const query = chunk?.hash ? `?v=${encodeURIComponent(chunk.hash)}` : '';
|
|
191
|
+
return `${this.baseUrl}/chunks/${file}${query}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
186
194
|
async loadChunk(chunkIndex: number): Promise<ConceptEntry[]> {
|
|
187
195
|
if (this.loadedChunks.has(chunkIndex)) return [];
|
|
188
|
-
const
|
|
189
|
-
const resp = await fetch(`${this.baseUrl}/chunks/${chunkFile}`);
|
|
196
|
+
const resp = await fetch(this.chunkUrl(chunkIndex));
|
|
190
197
|
if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
|
|
191
198
|
const data = await resp.json();
|
|
192
199
|
this.loadedChunks.add(chunkIndex);
|
|
@@ -196,9 +203,8 @@ export class DatasetAdapter {
|
|
|
196
203
|
private async loadChunkAsSummaries(chunkIndex: number): Promise<void> {
|
|
197
204
|
if (this.loadedChunks.has(chunkIndex)) return;
|
|
198
205
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
if (!resp.ok) return;
|
|
206
|
+
const resp = await fetch(this.chunkUrl(chunkIndex));
|
|
207
|
+
if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
|
|
202
208
|
const data = await resp.json();
|
|
203
209
|
this.loadedChunks.add(chunkIndex);
|
|
204
210
|
|
|
@@ -329,6 +335,7 @@ export class DatasetAdapter {
|
|
|
329
335
|
conceptId: entry.id,
|
|
330
336
|
registerId: this.registerId,
|
|
331
337
|
designation: entry.eng || '',
|
|
338
|
+
status: entry.status,
|
|
332
339
|
language: '',
|
|
333
340
|
matchField: 'id',
|
|
334
341
|
snippet: `ID: ${entry.id}`,
|
|
@@ -347,6 +354,7 @@ export class DatasetAdapter {
|
|
|
347
354
|
conceptId: entry.id,
|
|
348
355
|
registerId: this.registerId,
|
|
349
356
|
designation: term,
|
|
357
|
+
status: entry.status,
|
|
350
358
|
language,
|
|
351
359
|
matchField: 'designation',
|
|
352
360
|
_score: score,
|
|
@@ -166,6 +166,7 @@ export interface JsonLdConcept {
|
|
|
166
166
|
'gl:identifier'?: string | number;
|
|
167
167
|
'gl:term'?: string;
|
|
168
168
|
'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
|
|
169
|
+
'gl:source'?: JsonLdSource[];
|
|
169
170
|
'gl:related'?: JsonLdRelated[];
|
|
170
171
|
'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
|
|
171
172
|
'gl:genericRelations'?: JsonLdPartitiveRelation[];
|
|
@@ -623,6 +623,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
|
|
|
623
623
|
term: doc[GL.TERM] ?? null,
|
|
624
624
|
uri: doc['@id'] ?? null,
|
|
625
625
|
localizations: localizations as Record<string, any>,
|
|
626
|
+
sources: (doc[GL.SOURCE] ?? []).map(mapSourceFromJsonLd),
|
|
626
627
|
related,
|
|
627
628
|
partitive_relations: partitiveRelations,
|
|
628
629
|
generic_relations: genericRelations,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Per-register standards.json, keyed by the normalized cited-standard identity. */
|
|
2
|
+
export type StandardsIndex = Record<string, {
|
|
3
|
+
/** Normalized display label from the first authoritative citation encountered. */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Unique concept IDs in dataset encounter order. */
|
|
6
|
+
conceptIds: string[];
|
|
7
|
+
}>;
|
package/src/adapters/types.ts
CHANGED
|
@@ -26,6 +26,7 @@ const footerNav = computed(() => config.value?.footerNav ?? []);
|
|
|
26
26
|
const copyrightOwner = computed(() => config.value?.copyright || '');
|
|
27
27
|
const ownerName = computed(() => config.value?.branding?.ownerName || config.value?.title || '');
|
|
28
28
|
const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
29
|
+
const hostedBy = computed(() => config.value?.branding?.hostedBy as { name?: string; url: string; logo: string } | undefined);
|
|
29
30
|
</script>
|
|
30
31
|
|
|
31
32
|
<template>
|
|
@@ -58,6 +59,12 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
|
58
59
|
class="hover:text-ink-700 transition-colors"
|
|
59
60
|
>{{ link.label }}</a>
|
|
60
61
|
<span class="text-ink-200">|</span>
|
|
62
|
+
<span v-if="hostedBy" class="text-xs inline-flex items-center gap-1.5">
|
|
63
|
+
<span>Hosted by</span>
|
|
64
|
+
<a :href="hostedBy.url" target="_blank" rel="noopener" class="inline-flex items-center">
|
|
65
|
+
<img :src="hostedBy.logo" :alt="hostedBy.name || 'Host'" class="h-4 w-auto opacity-80" />
|
|
66
|
+
</a>
|
|
67
|
+
</span>
|
|
61
68
|
<span class="text-xs inline-flex items-center gap-1.5">
|
|
62
69
|
<img :src="glossaristLogo" alt="Glossarist" class="w-4 h-4 opacity-80" />
|
|
63
70
|
<a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.message }}</a>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { useRouter } from 'vue-router';
|
|
2
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
3
3
|
import { useUiStore } from '../stores/ui';
|
|
4
4
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
5
|
import { useSiteConfig } from '../config/use-site-config';
|
|
@@ -9,6 +9,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
|
9
9
|
const glossaristLogo = new URL('../assets/glossarist-logo.svg', import.meta.url).href;
|
|
10
10
|
|
|
11
11
|
const router = useRouter();
|
|
12
|
+
const route = useRoute();
|
|
12
13
|
const ui = useUiStore();
|
|
13
14
|
const store = useVocabularyStore();
|
|
14
15
|
const { config: siteConfig, localizedTitle } = useSiteConfig();
|
|
@@ -26,8 +27,8 @@ const currentLangLabel = computed(() => {
|
|
|
26
27
|
function doSearch() {
|
|
27
28
|
const q = searchInput.value.trim();
|
|
28
29
|
if (q) {
|
|
29
|
-
ui.searchQuery = q;
|
|
30
30
|
router.push({ name: 'search', query: { q } });
|
|
31
|
+
searchInput.value = '';
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -85,13 +86,13 @@ onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside))
|
|
|
85
86
|
</button>
|
|
86
87
|
|
|
87
88
|
<!-- Search -->
|
|
88
|
-
<form @submit.prevent="doSearch" class="flex-1 max-w-lg mx-2 sm:mx-4">
|
|
89
|
+
<form v-if="route.name !== 'search'" @submit.prevent="doSearch" class="flex-1 max-w-lg mx-2 sm:mx-4">
|
|
89
90
|
<div class="relative">
|
|
90
91
|
<input
|
|
91
92
|
v-model="searchInput"
|
|
92
93
|
type="text"
|
|
93
|
-
:aria-label="t('search.
|
|
94
|
-
:placeholder="t('search.
|
|
94
|
+
:aria-label="t('search.quickSearch')"
|
|
95
|
+
:placeholder="t('search.quickPlaceholder')"
|
|
95
96
|
class="w-full pl-9 pr-3 py-2 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all"
|
|
96
97
|
/>
|
|
97
98
|
<svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, useId } from 'vue';
|
|
3
|
+
import { RouterLink } from 'vue-router';
|
|
4
|
+
import type { SameStandardTerms } from '../composables/use-same-standard-terms';
|
|
5
|
+
import { useI18n } from '../i18n';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
groups: SameStandardTerms[];
|
|
9
|
+
registerId: string;
|
|
10
|
+
conceptId: string;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const { t } = useI18n();
|
|
14
|
+
const contentId = `same-standard-terms-${useId()}`;
|
|
15
|
+
const storageKey = 'same-standard-pane-collapsed';
|
|
16
|
+
const visibleGroups = computed(() => props.groups
|
|
17
|
+
.map(group => ({ ...group, terms: group.terms.filter(term => term.conceptId !== props.conceptId) }))
|
|
18
|
+
.filter(group => group.terms.length > 0));
|
|
19
|
+
|
|
20
|
+
function readCollapsed(): boolean {
|
|
21
|
+
try {
|
|
22
|
+
return localStorage.getItem(storageKey) === 'true';
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const collapsed = ref(readCollapsed());
|
|
29
|
+
|
|
30
|
+
function toggle() {
|
|
31
|
+
collapsed.value = !collapsed.value;
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem(storageKey, String(collapsed.value));
|
|
34
|
+
} catch {
|
|
35
|
+
// Browsing remains available when storage is disabled or full.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<aside v-if="visibleGroups.length" class="same-standard-pane rounded-lg border border-gray-200 dark:border-gray-700">
|
|
42
|
+
<h2>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="flex w-full items-center justify-between gap-3 p-4 text-left text-sm font-semibold text-gray-800 dark:text-gray-200"
|
|
46
|
+
:aria-expanded="!collapsed"
|
|
47
|
+
:aria-controls="contentId"
|
|
48
|
+
@click="toggle"
|
|
49
|
+
>
|
|
50
|
+
{{ t('concept.sameStandardTerms') }}
|
|
51
|
+
<span aria-hidden="true">{{ collapsed ? '+' : '−' }}</span>
|
|
52
|
+
</button>
|
|
53
|
+
</h2>
|
|
54
|
+
<div :id="contentId" :hidden="collapsed" class="space-y-4 border-t border-gray-200 p-4 dark:border-gray-700">
|
|
55
|
+
<section v-for="group in visibleGroups" :key="group.key">
|
|
56
|
+
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
57
|
+
{{ group.label }} <span class="text-gray-500">({{ group.terms.length }})</span>
|
|
58
|
+
</h3>
|
|
59
|
+
<ul class="space-y-2 text-sm">
|
|
60
|
+
<li v-for="term in group.terms" :key="term.conceptId">
|
|
61
|
+
<RouterLink
|
|
62
|
+
:to="{ name: 'concept', params: { registerId, conceptId: term.conceptId } }"
|
|
63
|
+
class="break-words text-blue-600 hover:underline dark:text-blue-400"
|
|
64
|
+
>{{ term.designation }}</RouterLink>
|
|
65
|
+
</li>
|
|
66
|
+
</ul>
|
|
67
|
+
</section>
|
|
68
|
+
</div>
|
|
69
|
+
</aside>
|
|
70
|
+
</template>
|