@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
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()];
|
|
@@ -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
|
|