@glossarist/concept-browser 0.7.138 → 0.7.140
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 +20 -3
- package/scripts/build-edges.ts +1 -6
- package/scripts/generate-data.js +51 -3
- package/scripts/generate-data.ts +37 -3
- package/src/__fixtures__/isotc211-domain-slugs.json +201 -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 +151 -1
- 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 +74 -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__/slugify.test.ts +74 -1
- 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/slugify.ts +25 -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
package/src/stores/vocabulary.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { conceptUri } from '../adapters/model-bridge';
|
|
|
8
8
|
import { GraphEngine } from '../graph';
|
|
9
9
|
import { UriRouter } from '../adapters/UriRouter';
|
|
10
10
|
import { deduplicateSearchHits } from '../utils/search';
|
|
11
|
+
import { useSiteConfig } from '../config/use-site-config';
|
|
12
|
+
import { DEFAULT_LANG } from '../utils/lang';
|
|
11
13
|
import { UnknownDatasetError } from '../errors';
|
|
12
14
|
|
|
13
15
|
export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
@@ -19,6 +21,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
19
21
|
const currentConceptId = ref<string>('');
|
|
20
22
|
const loading = ref(false);
|
|
21
23
|
const error = ref<string | null>(null);
|
|
24
|
+
const searchWarning = ref<string | null>(null);
|
|
22
25
|
const graph = shallowRef(new GraphEngine());
|
|
23
26
|
const conceptEdges = ref<GraphEdge[]>([]);
|
|
24
27
|
const initialized = ref(false);
|
|
@@ -32,6 +35,14 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
32
35
|
|
|
33
36
|
const factory = getFactory();
|
|
34
37
|
|
|
38
|
+
const { config } = useSiteConfig();
|
|
39
|
+
const searchPriorityLanguages = computed(() => {
|
|
40
|
+
const defaults = config.value?.defaults;
|
|
41
|
+
return defaults?.languageOrder?.length
|
|
42
|
+
? defaults.languageOrder
|
|
43
|
+
: [defaults?.language ?? DEFAULT_LANG];
|
|
44
|
+
});
|
|
45
|
+
|
|
35
46
|
// Computed
|
|
36
47
|
const currentManifest = computed(() =>
|
|
37
48
|
manifests.value.get(currentRegisterId.value)
|
|
@@ -263,46 +274,29 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
263
274
|
await discoverDatasets();
|
|
264
275
|
}
|
|
265
276
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
// Pass 1: search loaded data only
|
|
269
|
-
const loadedHits: SearchHit[] = [];
|
|
270
|
-
const unloadedAdapters: DatasetAdapter[] = [];
|
|
271
|
-
|
|
277
|
+
const allHits: SearchHit[] = [];
|
|
278
|
+
let incomplete = false;
|
|
272
279
|
for (const adapter of datasets.value.values()) {
|
|
273
280
|
if (!adapter.manifest) continue;
|
|
274
|
-
|
|
275
|
-
|
|
281
|
+
try {
|
|
282
|
+
if (!adapter.index) {
|
|
276
283
|
await adapter.loadIndex();
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await adapter.ensureAllChunksLoaded();
|
|
277
287
|
} catch {
|
|
278
|
-
|
|
288
|
+
// Preserve loaded hits, but make the incomplete result set visible.
|
|
289
|
+
incomplete = true;
|
|
279
290
|
}
|
|
280
|
-
|
|
281
|
-
const hits = adapter.search(query);
|
|
282
|
-
if (hits.length > 0) {
|
|
283
|
-
loadedHits.push(...hits);
|
|
284
|
-
} else {
|
|
285
|
-
unloadedAdapters.push(adapter);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const pass1 = deduplicateSearchHits(loadedHits);
|
|
290
|
-
if (pass1.length >= MIN_RESULTS) return pass1;
|
|
291
|
-
|
|
292
|
-
// Pass 2: load chunks lazily for datasets that found nothing in index
|
|
293
|
-
let allHits = [...loadedHits];
|
|
294
|
-
for (const adapter of unloadedAdapters) {
|
|
295
|
-
if (deduplicateSearchHits(allHits).length >= MIN_RESULTS) break;
|
|
296
|
-
try {
|
|
297
|
-
await adapter.ensureAllChunksLoaded();
|
|
298
|
-
const hits = adapter.search(query);
|
|
299
|
-
allHits.push(...hits);
|
|
291
|
+
allHits.push(...adapter.search(query));
|
|
300
292
|
} catch {
|
|
301
|
-
//
|
|
293
|
+
// Other datasets may still provide useful results.
|
|
294
|
+
incomplete = true;
|
|
302
295
|
}
|
|
303
296
|
}
|
|
304
297
|
|
|
305
|
-
|
|
298
|
+
searchWarning.value = incomplete ? 'search.incomplete' : null;
|
|
299
|
+
return deduplicateSearchHits(allHits, searchPriorityLanguages.value);
|
|
306
300
|
}
|
|
307
301
|
|
|
308
302
|
async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
|
|
@@ -324,6 +318,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
324
318
|
currentConceptId,
|
|
325
319
|
loading,
|
|
326
320
|
error,
|
|
321
|
+
searchWarning,
|
|
327
322
|
graph,
|
|
328
323
|
graphVersion,
|
|
329
324
|
conceptEdges,
|
|
@@ -338,6 +333,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
338
333
|
viewConcept,
|
|
339
334
|
navigateToUri,
|
|
340
335
|
searchAcrossDatasets,
|
|
336
|
+
searchPriorityLanguages,
|
|
341
337
|
loadAllGraphData,
|
|
342
338
|
getRandomConcept,
|
|
343
339
|
};
|
package/src/utils/search.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { SearchHit } from '../adapters/types';
|
|
2
2
|
|
|
3
|
-
export function
|
|
3
|
+
export function orderSearchHits(hits: SearchHit[], priorityLangs: string[]): SearchHit[] {
|
|
4
|
+
const index = new Map(priorityLangs.map((language, i) => [language, i]));
|
|
5
|
+
const tier = (hit: SearchHit) => hit.language === '' ? -1 : (index.get(hit.language) ?? priorityLangs.length);
|
|
6
|
+
// Stable sorting preserves the adapter's score/alphabetical order within each tier.
|
|
7
|
+
return [...hits].sort((a, b) => tier(a) - tier(b));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function deduplicateSearchHits(hits: SearchHit[], priorityLangs: string[] = []): SearchHit[] {
|
|
11
|
+
const index = new Map(priorityLangs.map((language, i) => [language, i]));
|
|
4
12
|
const best = new Map<string, SearchHit>();
|
|
5
13
|
for (const hit of hits) {
|
|
6
14
|
const key = `${hit.registerId}:${hit.conceptId}`;
|
|
@@ -9,6 +17,11 @@ export function deduplicateSearchHits(hits: SearchHit[]): SearchHit[] {
|
|
|
9
17
|
best.set(key, hit);
|
|
10
18
|
} else if (hit.matchField === 'designation' && existing.matchField === 'id') {
|
|
11
19
|
best.set(key, hit);
|
|
20
|
+
} else if (
|
|
21
|
+
hit.matchField === 'designation' && existing.matchField === 'designation' &&
|
|
22
|
+
(index.get(hit.language) ?? priorityLangs.length) < (index.get(existing.language) ?? priorityLangs.length)
|
|
23
|
+
) {
|
|
24
|
+
best.set(key, hit);
|
|
12
25
|
}
|
|
13
26
|
}
|
|
14
27
|
return [...best.values()];
|
package/src/utils/slugify.ts
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
// Slug for a domain label. Keeps letters and digits of every script so that
|
|
2
|
+
// non-Latin and accented labels stay distinct and readable. Pure-ASCII input
|
|
3
|
+
// that contains a letter or digit yields exactly what the earlier ASCII-only
|
|
4
|
+
// implementation did, so already-published URLs do not move.
|
|
5
|
+
|
|
6
|
+
const KEEP = /[^\p{L}\p{N}\p{M}_\s-]/gu;
|
|
7
|
+
const HAS_WORD = /[\p{L}\p{N}]/u;
|
|
8
|
+
|
|
9
|
+
// FNV-1a, 32 bit, over UTF-16 code units. Sync and dependency-free so it runs
|
|
10
|
+
// in the browser and in the Node build scripts alike.
|
|
11
|
+
function shortHash(text: string): string {
|
|
12
|
+
let h = 0x811c9dc5;
|
|
13
|
+
for (let i = 0; i < text.length; i++) {
|
|
14
|
+
h ^= text.charCodeAt(i);
|
|
15
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
16
|
+
}
|
|
17
|
+
return h.toString(16).padStart(8, '0');
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
export function slugify(text: string): string {
|
|
2
|
-
|
|
21
|
+
if (text === '') return '';
|
|
22
|
+
const folded = text.normalize('NFKC').toLowerCase();
|
|
23
|
+
const slug = folded.replace(KEEP, '').replace(/[\s/]+/g, '-');
|
|
24
|
+
// No letter or digit left (e.g. "()", "---"): a hash keeps such labels
|
|
25
|
+
// from collapsing into one empty node.
|
|
26
|
+
return HAS_WORD.test(slug) ? slug : `x-${shortHash(folded.trim())}`;
|
|
3
27
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Concept } from 'glossarist';
|
|
2
|
+
|
|
3
|
+
interface StandardRef {
|
|
4
|
+
readonly source?: string | null;
|
|
5
|
+
readonly id?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Combine source and id, retaining edition years and ignoring clause/locality fields. */
|
|
9
|
+
export function standardLabel(ref: StandardRef | null | undefined): string | null {
|
|
10
|
+
const parts = [ref?.source, ref?.id]
|
|
11
|
+
.map(part => part?.trim().replace(/\s+/g, ' ') ?? '')
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
return parts.join(' ') || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Case-insensitive, whitespace-normalized identity shared by build and browser. */
|
|
17
|
+
export function standardKey(ref: StandardRef | null | undefined): string | null {
|
|
18
|
+
return standardLabel(ref)?.toLowerCase() ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Unique authoritative standards, in concept-then-localization encounter order. */
|
|
22
|
+
export function collectStandardKeys(concept: Pick<Concept, 'sources' | 'languages' | 'localization'>): string[] {
|
|
23
|
+
const sources = [
|
|
24
|
+
...concept.sources,
|
|
25
|
+
...concept.languages.flatMap(lang => concept.localization(lang)?.sources ?? []),
|
|
26
|
+
];
|
|
27
|
+
const keys = new Set<string>();
|
|
28
|
+
for (const source of sources) {
|
|
29
|
+
if (source.type !== 'authoritative') continue;
|
|
30
|
+
const key = standardKey(source.origin?.ref);
|
|
31
|
+
if (key !== null) keys.add(key);
|
|
32
|
+
}
|
|
33
|
+
return [...keys];
|
|
34
|
+
}
|
|
@@ -3,9 +3,11 @@ import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
|
|
|
3
3
|
import { useRouter } from 'vue-router';
|
|
4
4
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
5
|
import ConceptDetail from '../components/ConceptDetail.vue';
|
|
6
|
+
import SameStandardPane from '../components/SameStandardPane.vue';
|
|
6
7
|
import RelationSphere from '../components/RelationSphere.vue';
|
|
7
8
|
import ShortcutsModal from '../components/ShortcutsModal.vue';
|
|
8
9
|
import { useConceptEdges } from '../composables/use-concept-edges';
|
|
10
|
+
import { useSameStandardTerms } from '../composables/use-same-standard-terms';
|
|
9
11
|
import { useI18n } from '../i18n';
|
|
10
12
|
|
|
11
13
|
const { t } = useI18n();
|
|
@@ -16,6 +18,8 @@ const props = defineProps<{
|
|
|
16
18
|
}>();
|
|
17
19
|
|
|
18
20
|
const store = useVocabularyStore();
|
|
21
|
+
const sameStandardGroups = useSameStandardTerms();
|
|
22
|
+
const hasSameStandardTerms = computed(() => sameStandardGroups.value.some(group => group.terms.length > 0));
|
|
19
23
|
const router = useRouter();
|
|
20
24
|
const conceptLoading = ref(false);
|
|
21
25
|
const localError = ref<string | null>(null);
|
|
@@ -86,11 +90,16 @@ async function copyPermalink() {
|
|
|
86
90
|
async function loadAdjacent() {
|
|
87
91
|
const adapter = store.datasets.get(props.registerId);
|
|
88
92
|
if (!adapter?.index) return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
try {
|
|
94
|
+
const idx = adapter.getConceptPosition(props.conceptId);
|
|
95
|
+
if (idx >= 0) {
|
|
96
|
+
await adapter.ensureChunksForRange(Math.max(0, idx - 1), 3);
|
|
97
|
+
}
|
|
98
|
+
adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
adjacent.value = { prev: null, next: null };
|
|
101
|
+
console.error('Failed to load adjacent concepts:', e);
|
|
92
102
|
}
|
|
93
|
-
adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
function goAdjacent(id: string) {
|
|
@@ -293,14 +302,23 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|
|
293
302
|
@navigate="onSphereNavigate"
|
|
294
303
|
/>
|
|
295
304
|
<!-- Detail mode -->
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
305
|
+
<div v-else class="detail-layout" :class="{ 'has-standard-pane': hasSameStandardTerms }">
|
|
306
|
+
<div class="detail-main">
|
|
307
|
+
<ConceptDetail
|
|
308
|
+
:concept="concept as any"
|
|
309
|
+
:manifest="manifest"
|
|
310
|
+
:edges="edges"
|
|
311
|
+
:adjacent="adjacent"
|
|
312
|
+
:register-id="registerId"
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
<SameStandardPane
|
|
316
|
+
v-if="hasSameStandardTerms"
|
|
317
|
+
:groups="sameStandardGroups"
|
|
318
|
+
:register-id="activeRegisterId"
|
|
319
|
+
:concept-id="concept.id"
|
|
320
|
+
/>
|
|
321
|
+
</div>
|
|
304
322
|
</div>
|
|
305
323
|
</template>
|
|
306
324
|
|
|
@@ -333,6 +351,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|
|
333
351
|
display: flex;
|
|
334
352
|
flex-direction: column;
|
|
335
353
|
}
|
|
354
|
+
.detail-layout {
|
|
355
|
+
display: grid;
|
|
356
|
+
gap: 1.5rem;
|
|
357
|
+
align-items: start;
|
|
358
|
+
}
|
|
359
|
+
.detail-main {
|
|
360
|
+
min-width: 0;
|
|
361
|
+
}
|
|
362
|
+
@media (min-width: 1024px) {
|
|
363
|
+
.detail-layout.has-standard-pane {
|
|
364
|
+
grid-template-columns: minmax(0, 1fr) 18rem;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
336
367
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
|
337
368
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
338
369
|
</style>
|
|
@@ -156,6 +156,8 @@ async function ensureAllChunksForFilter(needsLoad: boolean) {
|
|
|
156
156
|
try {
|
|
157
157
|
await a.ensureAllChunksLoaded();
|
|
158
158
|
allChunksLoaded.value = true;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
localError.value = e instanceof Error ? e.message : String(e);
|
|
159
161
|
} finally {
|
|
160
162
|
chunkLoading.value = false;
|
|
161
163
|
}
|
|
@@ -271,15 +273,33 @@ const totalPages = computed(() => {
|
|
|
271
273
|
});
|
|
272
274
|
|
|
273
275
|
// Load chunks needed for current page
|
|
274
|
-
|
|
276
|
+
async function loadPageChunks() {
|
|
275
277
|
if (!adapter.value || isFiltering.value) return;
|
|
276
278
|
const start = (page.value - 1) * perPage;
|
|
277
279
|
if (!adapter.value.isRangeLoaded(start, perPage)) {
|
|
278
280
|
chunkLoading.value = true;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
+
try {
|
|
282
|
+
await adapter.value.ensureChunksForRange(start, perPage);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
localError.value = e instanceof Error ? e.message : String(e);
|
|
285
|
+
} finally {
|
|
286
|
+
chunkLoading.value = false;
|
|
287
|
+
}
|
|
281
288
|
}
|
|
282
|
-
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
watch(page, loadPageChunks, { immediate: true });
|
|
292
|
+
|
|
293
|
+
async function retryLoad() {
|
|
294
|
+
localError.value = null;
|
|
295
|
+
if (!adapter.value?.index) {
|
|
296
|
+
await ensureLoaded();
|
|
297
|
+
} else if (isFiltering.value) {
|
|
298
|
+
await ensureAllChunksForFilter(true);
|
|
299
|
+
} else {
|
|
300
|
+
await loadPageChunks();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
283
303
|
|
|
284
304
|
// Visible page numbers for pagination (avoids iterating 445+ pages)
|
|
285
305
|
const visiblePages = computed(() => {
|
|
@@ -383,7 +403,7 @@ function clearSection() {
|
|
|
383
403
|
<p class="text-red-700 font-medium mb-1">{{ t('dataset.failedToLoad') }}</p>
|
|
384
404
|
<p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
|
|
385
405
|
<div class="flex gap-2 justify-center">
|
|
386
|
-
<button @click="
|
|
406
|
+
<button @click="retryLoad" class="btn-primary">{{ t('dataset.retry') }}</button>
|
|
387
407
|
<router-link :to="{ name: 'home' }" class="btn-secondary">{{ t('dataset.backToHome') }}</router-link>
|
|
388
408
|
</div>
|
|
389
409
|
</div>
|
package/src/views/SearchView.vue
CHANGED
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import { useRoute
|
|
2
|
+
import { onBeforeUnmount, watch } from 'vue';
|
|
3
|
+
import { useRoute } from 'vue-router';
|
|
4
4
|
import SearchBar from '../components/SearchBar.vue';
|
|
5
5
|
import { useUiStore } from '../stores/ui';
|
|
6
6
|
import { useI18n } from '../i18n';
|
|
7
7
|
|
|
8
8
|
const route = useRoute();
|
|
9
|
-
const router = useRouter();
|
|
10
9
|
const ui = useUiStore();
|
|
11
10
|
const { t } = useI18n();
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
12
|
+
watch(() => [route.name, route.query.q], () => {
|
|
13
|
+
ui.searchQuery = route.name === 'search' && typeof route.query.q === 'string'
|
|
14
|
+
? route.query.q.trim()
|
|
15
|
+
: '';
|
|
16
|
+
}, { immediate: true });
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ui.searchQuery = q;
|
|
22
|
-
}
|
|
18
|
+
onBeforeUnmount(() => {
|
|
19
|
+
ui.searchQuery = '';
|
|
23
20
|
});
|
|
24
21
|
</script>
|
|
25
22
|
|