@glossarist/concept-browser 0.7.139 → 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/generate-data.js +51 -3
- package/scripts/generate-data.ts +37 -3
- 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 +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__/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,13 +1,15 @@
|
|
|
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
|
-
import { ref, watch,
|
|
5
|
+
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
|
6
6
|
import type { SearchHit } from '../adapters/types';
|
|
7
7
|
import { useI18n } from '../i18n';
|
|
8
8
|
import SearchResults from './SearchResults.vue';
|
|
9
|
+
import { orderSearchHits } from '../utils/search';
|
|
9
10
|
|
|
10
11
|
const router = useRouter();
|
|
12
|
+
const route = useRoute();
|
|
11
13
|
const ui = useUiStore();
|
|
12
14
|
const store = useVocabularyStore();
|
|
13
15
|
const { t } = useI18n();
|
|
@@ -15,23 +17,39 @@ const query = ref('');
|
|
|
15
17
|
const results = ref<SearchHit[]>([]);
|
|
16
18
|
const searched = ref(false);
|
|
17
19
|
const selectedIdx = ref(-1);
|
|
20
|
+
const pageSize = 20;
|
|
21
|
+
const currentPage = ref(1);
|
|
18
22
|
const loading = ref(false);
|
|
19
23
|
const searchError = ref<string | null>(null);
|
|
24
|
+
const searchWarning = ref<string | null>(null);
|
|
20
25
|
const searchInputEl = ref<HTMLInputElement | null>(null);
|
|
21
26
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
let searchGeneration = 0;
|
|
28
|
+
let activeQuery = '';
|
|
22
29
|
|
|
23
30
|
async function doSearch() {
|
|
24
31
|
const q = query.value.trim();
|
|
25
32
|
if (!q) return;
|
|
33
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
34
|
+
debounceTimer = null;
|
|
35
|
+
const generation = ++searchGeneration;
|
|
36
|
+
activeQuery = q;
|
|
37
|
+
currentPage.value = 1;
|
|
38
|
+
selectedIdx.value = -1;
|
|
26
39
|
ui.searchQuery = q;
|
|
27
40
|
loading.value = true;
|
|
28
41
|
searchError.value = null;
|
|
42
|
+
searchWarning.value = null;
|
|
29
43
|
try {
|
|
30
|
-
|
|
44
|
+
const hits = await store.searchAcrossDatasets(q);
|
|
45
|
+
if (generation !== searchGeneration) return;
|
|
46
|
+
results.value = hits;
|
|
47
|
+
searchWarning.value = store.searchWarning;
|
|
31
48
|
} catch (e: any) {
|
|
49
|
+
if (generation !== searchGeneration) return;
|
|
32
50
|
searchError.value = e.message || 'Search failed';
|
|
33
51
|
} finally {
|
|
34
|
-
loading.value = false;
|
|
52
|
+
if (generation === searchGeneration) loading.value = false;
|
|
35
53
|
}
|
|
36
54
|
searched.value = true;
|
|
37
55
|
selectedIdx.value = -1;
|
|
@@ -52,11 +70,24 @@ function onInput() {
|
|
|
52
70
|
}, 300);
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
function
|
|
73
|
+
function resetSearch() {
|
|
74
|
+
searchGeneration++;
|
|
75
|
+
activeQuery = '';
|
|
76
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
77
|
+
debounceTimer = null;
|
|
78
|
+
ui.searchQuery = '';
|
|
56
79
|
query.value = '';
|
|
57
80
|
results.value = [];
|
|
81
|
+
loading.value = false;
|
|
82
|
+
searchError.value = null;
|
|
83
|
+
searchWarning.value = null;
|
|
58
84
|
searched.value = false;
|
|
85
|
+
currentPage.value = 1;
|
|
59
86
|
selectedIdx.value = -1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clearSearch() {
|
|
90
|
+
resetSearch();
|
|
60
91
|
router.replace({ query: {} });
|
|
61
92
|
}
|
|
62
93
|
|
|
@@ -66,10 +97,20 @@ interface GroupedResults {
|
|
|
66
97
|
hits: SearchHit[];
|
|
67
98
|
}
|
|
68
99
|
|
|
100
|
+
const orderedResults = computed(() => ui.searchSort === 'language'
|
|
101
|
+
? orderSearchHits(results.value, store.searchPriorityLanguages)
|
|
102
|
+
: results.value);
|
|
103
|
+
|
|
104
|
+
watch(() => ui.searchSort, () => {
|
|
105
|
+
currentPage.value = 1;
|
|
106
|
+
selectedIdx.value = -1;
|
|
107
|
+
});
|
|
108
|
+
|
|
69
109
|
const groupedResults = computed(() => {
|
|
70
|
-
const
|
|
110
|
+
const start = (currentPage.value - 1) * pageSize;
|
|
111
|
+
const pageHits = orderedResults.value.slice(start, start + pageSize);
|
|
71
112
|
const map = new Map<string, SearchHit[]>();
|
|
72
|
-
for (const hit of
|
|
113
|
+
for (const hit of pageHits) {
|
|
73
114
|
const group = map.get(hit.registerId) ?? [];
|
|
74
115
|
group.push(hit);
|
|
75
116
|
map.set(hit.registerId, group);
|
|
@@ -84,6 +125,11 @@ const groupedResults = computed(() => {
|
|
|
84
125
|
|
|
85
126
|
const flatHits = computed(() => groupedResults.value.flatMap(g => g.hits));
|
|
86
127
|
|
|
128
|
+
function changePage(page: number) {
|
|
129
|
+
currentPage.value = page;
|
|
130
|
+
selectedIdx.value = -1;
|
|
131
|
+
}
|
|
132
|
+
|
|
87
133
|
function goToHit(hit: SearchHit) {
|
|
88
134
|
router.push({
|
|
89
135
|
name: 'concept',
|
|
@@ -114,19 +160,20 @@ function scrollToSelected() {
|
|
|
114
160
|
});
|
|
115
161
|
}
|
|
116
162
|
|
|
117
|
-
watch(() =>
|
|
118
|
-
|
|
119
|
-
query.
|
|
163
|
+
watch(() => [route.name, route.query.q], () => {
|
|
164
|
+
const q = route.name === 'search' && typeof route.query.q === 'string'
|
|
165
|
+
? route.query.q.trim()
|
|
166
|
+
: '';
|
|
167
|
+
// A completed search writes its query to the URL; do not run it twice.
|
|
168
|
+
if (q && q === activeQuery) return;
|
|
169
|
+
resetSearch();
|
|
170
|
+
query.value = q;
|
|
171
|
+
if (q) {
|
|
120
172
|
doSearch();
|
|
121
173
|
}
|
|
122
|
-
});
|
|
174
|
+
}, { immediate: true });
|
|
123
175
|
|
|
124
|
-
|
|
125
|
-
if (ui.searchQuery) {
|
|
126
|
-
query.value = ui.searchQuery;
|
|
127
|
-
doSearch();
|
|
128
|
-
}
|
|
129
|
-
});
|
|
176
|
+
onBeforeUnmount(resetSearch);
|
|
130
177
|
</script>
|
|
131
178
|
|
|
132
179
|
<template>
|
|
@@ -164,14 +211,22 @@ onMounted(() => {
|
|
|
164
211
|
</div>
|
|
165
212
|
</form>
|
|
166
213
|
|
|
214
|
+
<div v-if="searched && !loading && searchWarning" role="alert" class="card p-4 mb-4 border-red-200 bg-red-50/50">
|
|
215
|
+
<p class="text-sm text-red-700 mb-3">{{ t(searchWarning) }}</p>
|
|
216
|
+
<button @click="doSearch" class="btn-primary">{{ t('search.retry') }}</button>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
167
219
|
<SearchResults
|
|
168
220
|
:loading="loading"
|
|
169
221
|
:search-error="searchError"
|
|
170
222
|
:searched="searched"
|
|
171
223
|
:search-query="ui.searchQuery"
|
|
172
|
-
:results="
|
|
224
|
+
:results="orderedResults"
|
|
173
225
|
:grouped-results="groupedResults"
|
|
174
226
|
:selected-idx="selectedIdx"
|
|
227
|
+
:current-page="currentPage"
|
|
228
|
+
:page-size="pageSize"
|
|
229
|
+
@change-page="changePage"
|
|
175
230
|
@retry="doSearch"
|
|
176
231
|
@go-hit="goToHit"
|
|
177
232
|
/>
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import type { SearchHit } from '../adapters/types';
|
|
4
4
|
import { langName } from '../utils/lang';
|
|
5
|
+
import { entryStatusColor, entryStatusLabel, entryStatusDefinition } from '../utils/concept-helpers';
|
|
5
6
|
import { useDsStyle } from '../utils/dataset-style';
|
|
6
7
|
import { useI18n } from '../i18n';
|
|
8
|
+
import { useUiStore } from '../stores/ui';
|
|
7
9
|
|
|
8
10
|
interface GroupedResults {
|
|
9
11
|
registerId: string;
|
|
@@ -11,7 +13,7 @@ interface GroupedResults {
|
|
|
11
13
|
hits: SearchHit[];
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
const props = defineProps<{
|
|
16
|
+
const props = withDefaults(defineProps<{
|
|
15
17
|
loading: boolean;
|
|
16
18
|
searchError: string | null;
|
|
17
19
|
searched: boolean;
|
|
@@ -19,15 +21,26 @@ const props = defineProps<{
|
|
|
19
21
|
results: SearchHit[];
|
|
20
22
|
groupedResults: GroupedResults[];
|
|
21
23
|
selectedIdx: number;
|
|
22
|
-
|
|
24
|
+
currentPage?: number;
|
|
25
|
+
pageSize?: number;
|
|
26
|
+
}>(), { currentPage: 1, pageSize: 20 });
|
|
23
27
|
|
|
24
28
|
const emit = defineEmits<{
|
|
25
29
|
retry: [];
|
|
30
|
+
changePage: [page: number];
|
|
26
31
|
goHit: [hit: SearchHit];
|
|
27
32
|
}>();
|
|
28
33
|
|
|
29
34
|
const { getStyle } = useDsStyle();
|
|
30
35
|
const { t } = useI18n();
|
|
36
|
+
const uiStore = useUiStore();
|
|
37
|
+
|
|
38
|
+
function updateSearchSort(event: Event) {
|
|
39
|
+
const value = (event.target as HTMLSelectElement).value;
|
|
40
|
+
if (value === 'language' || value === 'relevance') {
|
|
41
|
+
uiStore.setSearchSort(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
31
44
|
|
|
32
45
|
const flatHits = computed(() => props.groupedResults.flatMap(g => g.hits));
|
|
33
46
|
const hitIndexMap = computed(() => {
|
|
@@ -55,7 +68,18 @@ const hitIndexMap = computed(() => {
|
|
|
55
68
|
</div>
|
|
56
69
|
|
|
57
70
|
<div v-else-if="searched" class="search-results">
|
|
58
|
-
<
|
|
71
|
+
<div class="flex items-center justify-between gap-3 text-sm text-ink-400 mb-4">
|
|
72
|
+
<span>{{ results.length === 1 ? t('search.oneResultFor', { query: searchQuery }) : t('search.manyResultsFor', { count: String(results.length), query: searchQuery }) }}</span>
|
|
73
|
+
<select
|
|
74
|
+
:value="uiStore.searchSort"
|
|
75
|
+
:aria-label="t('search.sort')"
|
|
76
|
+
class="text-xs text-ink-500 bg-surface border border-ink-200 rounded px-2 py-1"
|
|
77
|
+
@change="updateSearchSort"
|
|
78
|
+
>
|
|
79
|
+
<option value="language">{{ t('search.sortEnglishFirst') }}</option>
|
|
80
|
+
<option value="relevance">{{ t('search.sortRelevance') }}</option>
|
|
81
|
+
</select>
|
|
82
|
+
</div>
|
|
59
83
|
<div v-if="results.length === 0" class="text-center py-16">
|
|
60
84
|
<div class="text-ink-200 text-5xl mb-4 font-serif">∅</div>
|
|
61
85
|
<p class="text-ink-500 font-medium">{{ t('search.noResults') }}</p>
|
|
@@ -84,6 +108,7 @@ const hitIndexMap = computed(() => {
|
|
|
84
108
|
</div>
|
|
85
109
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
86
110
|
<span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">{{ t('search.idMatch') }}</span>
|
|
111
|
+
<span v-if="hit.status" class="badge text-[10px]" :class="entryStatusColor(hit.status)" :title="entryStatusDefinition(hit.status) ?? ''">{{ entryStatusLabel(hit.status) }}</span>
|
|
87
112
|
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
|
|
88
113
|
</div>
|
|
89
114
|
</button>
|
|
@@ -91,8 +116,24 @@ const hitIndexMap = computed(() => {
|
|
|
91
116
|
</div>
|
|
92
117
|
</div>
|
|
93
118
|
|
|
94
|
-
<div v-if="results.length >
|
|
95
|
-
|
|
119
|
+
<div v-if="results.length > 0" class="flex items-center justify-between gap-4 text-sm text-ink-500 mt-6 pt-4 border-t border-ink-100/60">
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
class="btn-secondary disabled:opacity-50"
|
|
123
|
+
:disabled="currentPage === 1"
|
|
124
|
+
@click="emit('changePage', currentPage - 1)"
|
|
125
|
+
>{{ t('search.prev') }}</button>
|
|
126
|
+
<span aria-live="polite">{{ t('search.range', {
|
|
127
|
+
start: String((currentPage - 1) * pageSize + 1),
|
|
128
|
+
end: String(Math.min(currentPage * pageSize, results.length)),
|
|
129
|
+
total: String(results.length),
|
|
130
|
+
}) }}</span>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
class="btn-secondary disabled:opacity-50"
|
|
134
|
+
:disabled="currentPage * pageSize >= results.length"
|
|
135
|
+
@click="emit('changePage', currentPage + 1)"
|
|
136
|
+
>{{ t('search.next') }}</button>
|
|
96
137
|
</div>
|
|
97
138
|
</div>
|
|
98
139
|
</template>
|
|
@@ -8,6 +8,12 @@ const siteConfig = await getSiteConfig();
|
|
|
8
8
|
<div class="flex items-center gap-4">
|
|
9
9
|
<a href="/about" class="hover:text-ink-600 dark:hover:text-ink-300">About</a>
|
|
10
10
|
<a href="/contributors" class="hover:text-ink-600 dark:hover:text-ink-300">Contributors</a>
|
|
11
|
+
{siteConfig.branding?.hostedBy && (
|
|
12
|
+
<a href={siteConfig.branding.hostedBy.url} class="inline-flex items-center gap-1 hover:text-ink-600 dark:hover:text-ink-300">
|
|
13
|
+
<span>Hosted by</span>
|
|
14
|
+
<img src={siteConfig.branding.hostedBy.logo} alt={siteConfig.branding.hostedBy.name || 'Host'} class="h-4 w-auto" />
|
|
15
|
+
</a>
|
|
16
|
+
)}
|
|
11
17
|
{siteConfig.branding?.poweredBy && <span>{siteConfig.branding.poweredBy}</span>}
|
|
12
18
|
</div>
|
|
13
19
|
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { computed, shallowRef, watch } from 'vue';
|
|
2
|
+
import type { StandardsIndex } from '../adapters/standards-index';
|
|
3
|
+
import type { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
4
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
|
+
import { useUiStore } from '../stores/ui';
|
|
6
|
+
import { collectStandardKeys } from '../utils/source-standard';
|
|
7
|
+
|
|
8
|
+
export interface SameStandardTerms {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
terms: { conceptId: string; designation: string }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Share in-flight requests too, including when multiple panes mount together.
|
|
15
|
+
const indexes = new Map<string, Promise<StandardsIndex>>();
|
|
16
|
+
const summaryLoads = new WeakMap<DatasetAdapter, Promise<void>>();
|
|
17
|
+
|
|
18
|
+
function loadSummaries(adapter: DatasetAdapter): Promise<void> {
|
|
19
|
+
let pending = summaryLoads.get(adapter);
|
|
20
|
+
if (!pending) {
|
|
21
|
+
pending = adapter.ensureAllChunksLoaded().finally(() => summaryLoads.delete(adapter));
|
|
22
|
+
summaryLoads.set(adapter, pending);
|
|
23
|
+
}
|
|
24
|
+
return pending;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadIndex(url: string): Promise<StandardsIndex> {
|
|
28
|
+
let pending = indexes.get(url);
|
|
29
|
+
if (!pending) {
|
|
30
|
+
pending = (async () => {
|
|
31
|
+
const response = await fetch(url);
|
|
32
|
+
return response.ok ? await response.json() as StandardsIndex : {};
|
|
33
|
+
})().catch(() => {
|
|
34
|
+
// A transient failure should allow a later mount to retry.
|
|
35
|
+
indexes.delete(url);
|
|
36
|
+
return {};
|
|
37
|
+
});
|
|
38
|
+
indexes.set(url, pending);
|
|
39
|
+
}
|
|
40
|
+
return pending;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Related terms from the lightweight index, without fetching other concepts. */
|
|
44
|
+
export function useSameStandardTerms(baseUrl: string = import.meta.env.BASE_URL) {
|
|
45
|
+
const vocabulary = useVocabularyStore();
|
|
46
|
+
const ui = useUiStore();
|
|
47
|
+
const index = shallowRef<StandardsIndex>({});
|
|
48
|
+
const staleConcept = shallowRef<typeof vocabulary.currentConcept>(null);
|
|
49
|
+
const summaryRevision = shallowRef(0);
|
|
50
|
+
|
|
51
|
+
watch(() => vocabulary.currentRegisterId, async (registerId, _previous, onCleanup) => {
|
|
52
|
+
let active = true;
|
|
53
|
+
onCleanup(() => { active = false; });
|
|
54
|
+
index.value = {};
|
|
55
|
+
// The store changes registers before replacing the concept; IDs may overlap.
|
|
56
|
+
if (_previous !== undefined) staleConcept.value = vocabulary.currentConcept;
|
|
57
|
+
if (!registerId) return;
|
|
58
|
+
|
|
59
|
+
const loaded = await loadIndex(`${baseUrl}data/${registerId}/standards.json`);
|
|
60
|
+
if (active) index.value = loaded;
|
|
61
|
+
}, { immediate: true, flush: 'sync' });
|
|
62
|
+
|
|
63
|
+
watch([() => vocabulary.currentConcept, () => vocabulary.currentAdapter, index],
|
|
64
|
+
async ([concept, adapter, standards], _previous, onCleanup) => {
|
|
65
|
+
let active = true;
|
|
66
|
+
onCleanup(() => { active = false; });
|
|
67
|
+
if (!concept || concept === staleConcept.value || !adapter) return;
|
|
68
|
+
const missing = collectStandardKeys(concept).some(key =>
|
|
69
|
+
standards[key]?.conceptIds.some(id => id !== concept.id && !adapter.getIndexEntry(id)));
|
|
70
|
+
if (!missing) return;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await loadSummaries(adapter);
|
|
74
|
+
} catch {
|
|
75
|
+
// Keep available names and ID fallbacks if a summary chunk is unavailable.
|
|
76
|
+
} finally {
|
|
77
|
+
// Adapter summary maps are not reactive; refresh names after loading.
|
|
78
|
+
if (active) summaryRevision.value++;
|
|
79
|
+
}
|
|
80
|
+
}, { immediate: true });
|
|
81
|
+
|
|
82
|
+
return computed<SameStandardTerms[]>(() => {
|
|
83
|
+
summaryRevision.value;
|
|
84
|
+
const concept = vocabulary.currentConcept;
|
|
85
|
+
if (!concept || concept === staleConcept.value) return [];
|
|
86
|
+
|
|
87
|
+
return collectStandardKeys(concept).flatMap(key => {
|
|
88
|
+
const standard = index.value[key];
|
|
89
|
+
if (!standard) return [];
|
|
90
|
+
|
|
91
|
+
return [{
|
|
92
|
+
key,
|
|
93
|
+
label: standard.label,
|
|
94
|
+
terms: standard.conceptIds
|
|
95
|
+
.filter(conceptId => conceptId !== concept.id)
|
|
96
|
+
.map(conceptId => {
|
|
97
|
+
const designations = vocabulary.currentAdapter?.getIndexEntry(conceptId)?.designations;
|
|
98
|
+
return {
|
|
99
|
+
conceptId,
|
|
100
|
+
designation: designations?.[ui.selectedLang] || designations?.eng || conceptId,
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
}];
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface SiteBranding {
|
|
|
83
83
|
favicon?: string | FaviconConfig;
|
|
84
84
|
ownerName?: string;
|
|
85
85
|
ownerUrl?: string;
|
|
86
|
+
/** Hosting organization identity, rendered in the footer as "Hosted by ⟨logo⟩". */
|
|
87
|
+
hostedBy?: HostedByConfig;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
// === Features ===
|
|
@@ -92,6 +94,15 @@ export interface PoweredByConfig {
|
|
|
92
94
|
url?: string;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
export interface HostedByConfig {
|
|
98
|
+
/** Organization name, used as the logo alt text. */
|
|
99
|
+
name?: string;
|
|
100
|
+
/** Link target for the hosted-by credit. */
|
|
101
|
+
url: string;
|
|
102
|
+
/** Logo image path or URL. */
|
|
103
|
+
logo: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
export interface SiteFeatures {
|
|
96
107
|
news?: boolean;
|
|
97
108
|
stats?: boolean;
|
|
@@ -32,6 +32,7 @@ export interface RuntimeSiteConfig {
|
|
|
32
32
|
footerLogo?: { path: string; alt: string; url?: string; light?: string; dark?: string };
|
|
33
33
|
ownerName?: string;
|
|
34
34
|
ownerUrl?: string;
|
|
35
|
+
hostedBy?: { name?: string; url: string; logo: string };
|
|
35
36
|
};
|
|
36
37
|
analytics?: { googleAnalyticsId?: string };
|
|
37
38
|
features?: Record<string, unknown>;
|
package/src/i18n/locales/eng.yml
CHANGED
|
@@ -143,11 +143,14 @@ home.browseConcepts: Browse concepts
|
|
|
143
143
|
|
|
144
144
|
# Search
|
|
145
145
|
search.placeholder: Search...
|
|
146
|
+
search.quickPlaceholder: Quick search...
|
|
147
|
+
search.quickSearch: Quick search
|
|
146
148
|
search.conceptSearch: Search concepts
|
|
147
149
|
search.fullPlaceholder: Search terms across all datasets...
|
|
148
150
|
search.button: Search
|
|
149
151
|
search.searching: Searching across datasets...
|
|
150
152
|
search.failed: Search failed
|
|
153
|
+
search.incomplete: Some results could not be loaded. Retry to search all entries.
|
|
151
154
|
search.retry: Retry
|
|
152
155
|
search.result: result
|
|
153
156
|
search.results: results
|
|
@@ -155,7 +158,12 @@ search.oneResultFor: '1 result for "{query}"'
|
|
|
155
158
|
search.manyResultsFor: '{count} results for "{query}"'
|
|
156
159
|
search.noResults: No concepts found matching your search
|
|
157
160
|
search.tryDifferent: Try a different term or check the spelling.
|
|
158
|
-
search.
|
|
161
|
+
search.range: '{start}–{end} of {total}'
|
|
162
|
+
search.sort: Sort search results
|
|
163
|
+
search.sortEnglishFirst: English first
|
|
164
|
+
search.sortRelevance: Relevance
|
|
165
|
+
search.prev: Prev
|
|
166
|
+
search.next: Next
|
|
159
167
|
search.idMatch: ID match
|
|
160
168
|
|
|
161
169
|
# Page view
|
|
@@ -172,6 +180,7 @@ concept.note: Note
|
|
|
172
180
|
concept.examples: Examples
|
|
173
181
|
concept.example: Example
|
|
174
182
|
concept.sources: Sources
|
|
183
|
+
concept.sameStandardTerms: Terms from the same standard
|
|
175
184
|
concept.designations: Designations
|
|
176
185
|
concept.relations: Relations
|
|
177
186
|
concept.compareWith: Compare with this edition
|
package/src/i18n/locales/fra.yml
CHANGED
|
@@ -142,11 +142,14 @@ home.browseConcepts: Parcourir les concepts
|
|
|
142
142
|
|
|
143
143
|
# Search
|
|
144
144
|
search.placeholder: Rechercher…
|
|
145
|
+
search.quickPlaceholder: Recherche rapide…
|
|
146
|
+
search.quickSearch: Recherche rapide
|
|
145
147
|
search.conceptSearch: Rechercher des concepts
|
|
146
148
|
search.fullPlaceholder: Rechercher des termes dans tous les jeux de données…
|
|
147
149
|
search.button: Rechercher
|
|
148
150
|
search.searching: Recherche dans les jeux de données...
|
|
149
151
|
search.failed: Échec de la recherche
|
|
152
|
+
search.incomplete: Certains résultats n’ont pas pu être chargés. Réessayez pour rechercher toutes les entrées.
|
|
150
153
|
search.retry: Réessayer
|
|
151
154
|
search.result: résultat
|
|
152
155
|
search.results: résultats
|
|
@@ -154,7 +157,12 @@ search.oneResultFor: '1 résultat pour « {query} »'
|
|
|
154
157
|
search.manyResultsFor: '{count} résultats pour « {query} »'
|
|
155
158
|
search.noResults: Aucun concept trouvé correspondant à votre recherche
|
|
156
159
|
search.tryDifferent: Essayez un autre terme ou vérifiez l'orthographe.
|
|
157
|
-
search.
|
|
160
|
+
search.range: '{start}–{end} sur {total}'
|
|
161
|
+
search.sort: Trier les résultats de recherche
|
|
162
|
+
search.sortEnglishFirst: Anglais en premier
|
|
163
|
+
search.sortRelevance: Pertinence
|
|
164
|
+
search.prev: Préc.
|
|
165
|
+
search.next: Suiv.
|
|
158
166
|
search.idMatch: Correspondance d'identifiant
|
|
159
167
|
|
|
160
168
|
# Page view
|
|
@@ -171,6 +179,7 @@ concept.note: Note
|
|
|
171
179
|
concept.examples: Exemples
|
|
172
180
|
concept.example: Exemple
|
|
173
181
|
concept.sources: Sources
|
|
182
|
+
concept.sameStandardTerms: Termes issus de la même norme
|
|
174
183
|
concept.designations: Désignations
|
|
175
184
|
concept.relations: Relations
|
|
176
185
|
concept.classification: Classification
|
package/src/stores/ui.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { defineStore } from 'pinia';
|
|
|
2
2
|
import { ref, computed } from 'vue';
|
|
3
3
|
|
|
4
4
|
export type Theme = 'light' | 'dark' | 'system';
|
|
5
|
+
export type SearchSort = 'language' | 'relevance';
|
|
6
|
+
|
|
7
|
+
const SEARCH_SORT_KEY = 'searchSort';
|
|
8
|
+
const DEFAULT_SEARCH_SORT: SearchSort = 'language';
|
|
5
9
|
|
|
6
10
|
function getSystemDark(): boolean {
|
|
7
11
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
@@ -11,12 +15,18 @@ function getStoredTheme(): Theme {
|
|
|
11
15
|
return (localStorage.getItem('theme') as Theme) || 'system';
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
function getStoredSearchSort(): SearchSort {
|
|
19
|
+
const v = localStorage.getItem(SEARCH_SORT_KEY);
|
|
20
|
+
return v === 'language' || v === 'relevance' ? v : DEFAULT_SEARCH_SORT;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export const useUiStore = defineStore('ui', () => {
|
|
15
24
|
const sidebarOpen = ref(false);
|
|
16
25
|
const selectedLang = ref('eng');
|
|
17
26
|
const searchQuery = ref('');
|
|
18
27
|
const showGraphPanel = ref(false);
|
|
19
28
|
const themePref = ref<Theme>(getStoredTheme());
|
|
29
|
+
const searchSort = ref<SearchSort>(getStoredSearchSort());
|
|
20
30
|
|
|
21
31
|
const isDark = computed(() => {
|
|
22
32
|
return themePref.value === 'dark' || (themePref.value === 'system' && getSystemDark());
|
|
@@ -32,6 +42,11 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
32
42
|
applyTheme();
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
function setSearchSort(s: SearchSort) {
|
|
46
|
+
searchSort.value = s;
|
|
47
|
+
localStorage.setItem(SEARCH_SORT_KEY, s);
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
function toggleTheme() {
|
|
36
51
|
if (isDark.value) {
|
|
37
52
|
setTheme('light');
|
|
@@ -55,5 +70,5 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
55
70
|
if (themePref.value === 'system') applyTheme();
|
|
56
71
|
});
|
|
57
72
|
|
|
58
|
-
return { sidebarOpen, selectedLang, searchQuery, showGraphPanel, themePref, isDark, toggleSidebar, setLang, setTheme, toggleTheme };
|
|
73
|
+
return { sidebarOpen, selectedLang, searchQuery, showGraphPanel, themePref, searchSort, isDark, toggleSidebar, setLang, setTheme, setSearchSort, toggleTheme };
|
|
59
74
|
});
|
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
|
};
|