@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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/__tests__/standards-index.test.ts +121 -0
  3. package/scripts/generate-data.js +51 -3
  4. package/scripts/generate-data.ts +37 -3
  5. package/src/__tests__/app-footer.test.ts +36 -0
  6. package/src/__tests__/app-header.test.ts +32 -5
  7. package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
  8. package/src/__tests__/concept-view.test.ts +103 -0
  9. package/src/__tests__/dataset-adapter.test.ts +150 -0
  10. package/src/__tests__/dataset-view.test.ts +41 -1
  11. package/src/__tests__/model-bridge-citation.test.ts +20 -0
  12. package/src/__tests__/same-standard-pane.test.ts +174 -0
  13. package/src/__tests__/scripts/generate-data.test.ts +74 -0
  14. package/src/__tests__/search-across-datasets.test.ts +102 -0
  15. package/src/__tests__/search-bar.test.ts +304 -0
  16. package/src/__tests__/search-results.test.ts +54 -0
  17. package/src/__tests__/search-utils.test.ts +64 -1
  18. package/src/__tests__/search-view.test.ts +194 -2
  19. package/src/__tests__/source-standard.test.ts +70 -0
  20. package/src/__tests__/ui-store.test.ts +26 -0
  21. package/src/__tests__/vocabulary-layered.test.ts +7 -7
  22. package/src/adapters/DatasetAdapter.ts +16 -8
  23. package/src/adapters/jsonld-types.ts +1 -0
  24. package/src/adapters/model-bridge.ts +1 -0
  25. package/src/adapters/standards-index.ts +7 -0
  26. package/src/adapters/types.ts +1 -0
  27. package/src/components/AppFooter.vue +7 -0
  28. package/src/components/AppHeader.vue +6 -5
  29. package/src/components/SameStandardPane.vue +70 -0
  30. package/src/components/SearchBar.vue +73 -18
  31. package/src/components/SearchResults.vue +46 -5
  32. package/src/components/site/SiteFooter.astro +6 -0
  33. package/src/composables/use-same-standard-terms.ts +106 -0
  34. package/src/config/types.ts +11 -0
  35. package/src/config/use-site-config.ts +1 -0
  36. package/src/i18n/locales/eng.yml +10 -1
  37. package/src/i18n/locales/fra.yml +10 -1
  38. package/src/stores/ui.ts +16 -1
  39. package/src/stores/vocabulary.ts +27 -31
  40. package/src/utils/search.ts +14 -1
  41. package/src/utils/source-standard.ts +34 -0
  42. package/src/views/ConceptView.vue +43 -12
  43. package/src/views/DatasetView.vue +25 -5
  44. 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, onMounted, computed, nextTick } from 'vue';
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
- results.value = await store.searchAcrossDatasets(q);
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 clearSearch() {
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 capped = results.value.slice(0, 100);
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 capped) {
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(() => ui.searchQuery, (q) => {
118
- if (q && q !== query.value) {
119
- query.value = q;
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
- onMounted(() => {
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="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
- <p class="text-sm text-ink-400 mb-4">{{ results.length === 1 ? t('search.oneResultFor', { query: searchQuery }) : t('search.manyResultsFor', { count: String(results.length), query: searchQuery }) }}</p>
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">&empty;</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 > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
95
- {{ t('search.showingFirst', { max: '100', total: String(results.length) }) }}
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
+ }
@@ -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>;
@@ -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.showingFirst: Showing first {max} of {total} results. Refine your search for more specific matches.
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
@@ -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.showingFirst: Affichage des {max} premiers résultats sur {total}. Affinez votre recherche.
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
  });
@@ -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 MIN_RESULTS = 20;
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
- if (!adapter.index) {
275
- try {
281
+ try {
282
+ if (!adapter.index) {
276
283
  await adapter.loadIndex();
284
+ }
285
+ try {
286
+ await adapter.ensureAllChunksLoaded();
277
287
  } catch {
278
- continue;
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
- // Skip datasets that fail to load
293
+ // Other datasets may still provide useful results.
294
+ incomplete = true;
302
295
  }
303
296
  }
304
297
 
305
- return deduplicateSearchHits(allHits);
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
  };