@museumwnf/viewer-core 2.12.0 → 2.14.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,196 @@
1
+ import { ref } from 'vue'
2
+ import { useDataPackage } from '../composables/useDataPackage.js'
3
+ import { entityRef, loadEntities } from '../composables/useEntities.js'
4
+ import { searchRows, SEARCH_LANGUAGE_KEY } from './fieldSearch.js'
5
+
6
+ // The standalone sites' Database search as legacy ran it
7
+ // (`database_results.php`, Decision D3): up to four keyword rows joined by
8
+ // AND/OR, each a substring of what its field searches, read the way the
9
+ // tables' collation compared text. With no search language, a record
10
+ // matches through any one of its translations, all rows within that same
11
+ // translation, as legacy's one row per language did. A word that names a
12
+ // country also finds that country's records, in the fields that said so; a
13
+ // word that is a glossary term also searches the term's spellings in the
14
+ // language the site is read in. Matches come most relevant first, then by
15
+ // start date.
16
+
17
+ // A Latin letter without its accents; any other character as it is, since
18
+ // the collation weighed the marks of other scripts (an Arabic vowel sign, a
19
+ // hamza) as characters of their own.
20
+ const latinBase = new Map()
21
+ function baseOf(char) {
22
+ let base = latinBase.get(char)
23
+ if (base === undefined) {
24
+ const bare = char.normalize('NFD').replace(/\p{M}/gu, '')
25
+ base = bare && bare.codePointAt(0) < 0x250 ? bare : char
26
+ latinBase.set(char, base)
27
+ }
28
+ return base
29
+ }
30
+
31
+ /**
32
+ * Text as MySQL's `utf8_general_ci` compared it: no case, no accents on a
33
+ * Latin letter, and Turkish dotless ı as i.
34
+ */
35
+ export function collate(value) {
36
+ const text = String(value ?? '').normalize('NFC')
37
+ if (/^[\x00-\x7f]*$/.test(text)) return text.toLowerCase()
38
+ let out = ''
39
+ for (const char of text) out += baseOf(char)
40
+ return out.toLowerCase().replace(/ı/g, 'i')
41
+ }
42
+
43
+ function strings(value) {
44
+ if (value == null || value === '') return []
45
+ if (Array.isArray(value)) return value.flatMap(strings)
46
+ return [String(value)]
47
+ }
48
+
49
+ // Legacy ordered by `LPAD(start_date, 5, '0')`: a record with no start date
50
+ // first, then the dates up.
51
+ function byStartDate(a, b) {
52
+ const da = a.record.start_date
53
+ const db = b.record.start_date
54
+ if (da == null || db == null) return (da == null ? 0 : 1) - (db == null ? 0 : 1)
55
+ return Number(da) - Number(db)
56
+ }
57
+
58
+ /**
59
+ * `fields`: `{ [field]: (record, text) => string | string[] }`, what each
60
+ * field of the form searches in a record and one of its translations.
61
+ * `countryFields`: the fields in which a word naming a country (in any of
62
+ * its names) also finds the records of that country.
63
+ * `glossaryFields`: the fields in which a glossary term also searches its
64
+ * spellings in the site's language.
65
+ * `score`: `{ [field]: (like, record, text, inCountry) => number }`, the
66
+ * relevance a matching row adds, `like(value)` telling whether a value holds
67
+ * the row's own word. Summed over the rows, the best translation counting.
68
+ *
69
+ * Returns `narrow(list, filters, helpers)` for a results spec (pass it
70
+ * `sort: false`): the records of `list` that match, in legacy's order.
71
+ * `helpers.locale` is the language the site is read in. `prepare()` loads
72
+ * every translation the search reads and sets `ready`, so a page can wait
73
+ * for it rather than show a count that grows as the languages arrive.
74
+ */
75
+ export function useLegacySearch({
76
+ fields,
77
+ entity = 'items',
78
+ countryFields = [],
79
+ glossaryFields = [],
80
+ score = {},
81
+ rows = 4,
82
+ countries = 'countries',
83
+ glossary = 'glossary',
84
+ } = {}) {
85
+ const pkg = useDataPackage()
86
+ const requested = new Set()
87
+ // What a field searches in one translation, folded once: keyed on the
88
+ // translation object, so a file loaded again starts afresh.
89
+ const folded = new WeakMap()
90
+
91
+ function hayOf(field, record, text) {
92
+ let byField = folded.get(text)
93
+ if (!byField) folded.set(text, (byField = new Map()))
94
+ let hay = byField.get(field)
95
+ if (!hay) {
96
+ const getter = fields[field] ?? fields.keyword
97
+ byField.set(field, (hay = strings(getter(record, text)).map(collate)))
98
+ }
99
+ return hay
100
+ }
101
+
102
+ function load(name, languages) {
103
+ for (const language of languages) {
104
+ const key = `${name}.${language}`
105
+ if (requested.has(key)) continue
106
+ requested.add(key)
107
+ pkg.loadTranslations(name, language)
108
+ }
109
+ }
110
+
111
+ function countryIds(needle) {
112
+ load(countries, pkg.availableLanguages(countries))
113
+ const ids = new Set()
114
+ for (const record of entityRef(countries).value ?? []) {
115
+ const names = [record.internal_name, ...pkg.availableLanguages(countries)
116
+ .map((language) => pkg.translations(countries, language)[record.id]?.name)]
117
+ if (names.some((name) => name && collate(name).includes(needle))) ids.add(record.id)
118
+ }
119
+ return ids
120
+ }
121
+
122
+ function spellings(needle, language) {
123
+ const languages = pkg.availableLanguages(glossary)
124
+ load(glossary, languages)
125
+ const out = []
126
+ for (const record of entityRef(glossary).value ?? []) {
127
+ const every = [record.word, ...languages.flatMap((l) => pkg.translations(glossary, l)[record.id]?.spellings ?? [])]
128
+ if (every.some((s) => s && collate(s) === needle)) {
129
+ out.push(...(pkg.translations(glossary, language)[record.id]?.spellings ?? []).map(collate))
130
+ }
131
+ }
132
+ return out
133
+ }
134
+
135
+ function narrow(list, filters, helpers = {}) {
136
+ loadEntities([countries, glossary].filter((name) => pkg.entityNames.includes(name)))
137
+ const searchLanguage = filters[SEARCH_LANGUAGE_KEY] || ''
138
+ const languages = searchLanguage ? [searchLanguage] : pkg.availableLanguages(entity)
139
+ load(entity, languages)
140
+ const site = helpers.locale?.value ?? helpers.locale ?? 'en'
141
+
142
+ const active = searchRows(filters, rows)
143
+ .filter((row) => String(row.keyword ?? '').trim() !== '')
144
+ .map((row) => {
145
+ const needle = collate(String(row.keyword).trim())
146
+ return {
147
+ ...row,
148
+ needle,
149
+ needles: [needle, ...(glossaryFields.includes(row.field) ? spellings(needle, site) : [])].filter(Boolean),
150
+ countries: countryFields.includes(row.field) ? countryIds(needle) : new Set(),
151
+ }
152
+ })
153
+
154
+ const matched = []
155
+ for (const record of list ?? []) {
156
+ let best = null
157
+ for (const language of languages) {
158
+ const text = pkg.translations(entity, language)[record.id]
159
+ if (!text) continue
160
+ let result = null
161
+ let relevance = 0
162
+ for (const row of active) {
163
+ const inCountry = row.countries.has(record.country_id)
164
+ const hay = hayOf(row.field, record, text)
165
+ const hit = inCountry || hay.some((h) => row.needles.some((n) => h.includes(n)))
166
+ if (result === null) result = hit
167
+ else if (String(row.cond).toUpperCase() === 'OR') result = result || hit
168
+ else result = result && hit
169
+ const rank = score[row.field]
170
+ if (rank) relevance += rank((value) => strings(value).some((v) => collate(v).includes(row.needle)), record, text, inCountry)
171
+ }
172
+ if (result === null || result) best = Math.max(best ?? 0, relevance)
173
+ }
174
+ if (best !== null) matched.push({ record, relevance: best })
175
+ }
176
+ matched.sort((a, b) => b.relevance - a.relevance || byStartDate(a, b))
177
+ return matched.map((m) => m.record)
178
+ }
179
+
180
+ const ready = ref(false)
181
+ let preparing = null
182
+ function prepare() {
183
+ preparing ??= (async () => {
184
+ const lookups = [countries, glossary].filter((name) => pkg.entityNames.includes(name))
185
+ await loadEntities(lookups)
186
+ await Promise.all([entity, ...lookups].flatMap((name) => pkg.availableLanguages(name).map((language) => {
187
+ requested.add(`${name}.${language}`)
188
+ return pkg.loadTranslations(name, language)
189
+ })))
190
+ ready.value = true
191
+ })()
192
+ return preparing
193
+ }
194
+
195
+ return { narrow, prepare, ready }
196
+ }
package/src/i18n/index.js CHANGED
@@ -47,18 +47,23 @@ export function mergeMessages(...catalogues) {
47
47
  export function createI18n({ messages = {}, locale = BASE_LANGUAGE } = {}) {
48
48
  const current = ref(locale)
49
49
 
50
- const t = (key) => {
51
- const active = messages[current.value]
50
+ // A text in a language of the caller's choosing (a record's own, say),
51
+ // English where that language has none.
52
+ const tIn = (language, key) => {
53
+ const active = messages[language]
52
54
  if (active && typeof active[key] === 'string') return active[key]
53
55
  const base = messages[BASE_LANGUAGE]
54
56
  if (base && typeof base[key] === 'string') return base[key]
55
57
  return key
56
58
  }
57
59
 
60
+ const t = (key) => tIn(current.value, key)
61
+
58
62
  const i18n = {
59
63
  locale: current,
60
64
  availableLocales: Object.keys(messages),
61
65
  t,
66
+ tIn,
62
67
  install(app) {
63
68
  app.provide(VIEWER_I18N, i18n)
64
69
  // `$t` in a template, `useI18n()` in a script — the two shapes Vue
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  SEARCH_LANGUAGE_KEY, searchFieldLabel, searchFieldOptions, searchFields, searchRowKeys, searchRows, searchSummary, searchedFor,
9
9
  useFieldSearch, useSearchFieldOptions,
10
10
  } from './catalogue/fieldSearch.js'
11
+ export { collate, useLegacySearch } from './catalogue/legacySearch.js'
11
12
  export { useDataPackage } from './composables/useDataPackage.js'
12
13
  export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'
13
14
  export { resolveRecordLanguage, useRecordLanguage } from './composables/useRecordLanguage.js'