@museumwnf/viewer-core 2.12.0 → 2.13.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 +1 -1
- package/src/catalogue/legacySearch.js +196 -0
- package/src/index.js +1 -0
package/package.json
CHANGED
|
@@ -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/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'
|