@museumwnf/viewer-core 2.11.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/fieldSearch.js +12 -3
- package/src/catalogue/legacySearch.js +196 -0
- package/src/index.js +2 -1
package/package.json
CHANGED
|
@@ -105,6 +105,14 @@ export function searchRows(filters, count = 4) {
|
|
|
105
105
|
return rows
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The query key of the search language: legacy's own `searchlanguage`. Never
|
|
110
|
+
* `lang`, which carries the language the website is read in: a visitor
|
|
111
|
+
* reading in French still searches every language, as on legacy, unless
|
|
112
|
+
* they pick one.
|
|
113
|
+
*/
|
|
114
|
+
export const SEARCH_LANGUAGE_KEY = 'searchlanguage'
|
|
115
|
+
|
|
108
116
|
/** The query keys `searchRows` reads, for a results spec's `keys`. */
|
|
109
117
|
export function searchRowKeys(count = 4) {
|
|
110
118
|
const keys = ['q', 'field']
|
|
@@ -125,7 +133,8 @@ export function searchedFor(filters, t, { rows = 4, extras = [] } = {}) {
|
|
|
125
133
|
.map((row, i) => `${i > 0 ? `${row.cond} ` : ''}${searchFieldLabel(row.field, t)}: "${row.keyword}"`)
|
|
126
134
|
if (filters.from) parts.push(`${t('catalogue.filter.from')} ${filters.from}`)
|
|
127
135
|
if (filters.to) parts.push(`${t('catalogue.filter.to')} ${filters.to}`)
|
|
128
|
-
|
|
136
|
+
const language = filters[SEARCH_LANGUAGE_KEY]
|
|
137
|
+
if (language) parts.push(`${t('catalogue.search.language')}: ${String(language).toUpperCase()}`)
|
|
129
138
|
for (const extra of extras) {
|
|
130
139
|
const part = extra(filters, t)
|
|
131
140
|
if (part) parts.push(part)
|
|
@@ -149,7 +158,7 @@ export function searchSummary({ filters, t, pageInfo }, options = {}) {
|
|
|
149
158
|
* The index behind the results page: one long-lived `useKeywordIndex` over
|
|
150
159
|
* `entity` (`fields` grammar, legacy's `rank: 'hits'` order, the glossary
|
|
151
160
|
* and country expansions of `database_results.php`), its search language
|
|
152
|
-
* kept in step with the query's `
|
|
161
|
+
* kept in step with the query's `searchlanguage`. `narrow(list, filters)` is a results
|
|
153
162
|
* spec's `narrow`: the matches in rank order, kept to what `list` (the
|
|
154
163
|
* records the spec's `scope` let through) carries. Pass `sort: false` to the
|
|
155
164
|
* spec, so the rank order stands.
|
|
@@ -165,7 +174,7 @@ export function useFieldSearch({
|
|
|
165
174
|
const index = useKeywordIndex(entity, { grammar: 'fields', fields, language, rank, expand })
|
|
166
175
|
|
|
167
176
|
function narrow(list, filters) {
|
|
168
|
-
language.value = filters
|
|
177
|
+
language.value = filters[SEARCH_LANGUAGE_KEY] || ''
|
|
169
178
|
const matches = index.search(searchRows(filters, rows))
|
|
170
179
|
if (!list) return matches
|
|
171
180
|
const allowed = new Set(list.map((record) => record.id))
|
|
@@ -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
|
@@ -5,9 +5,10 @@ export {
|
|
|
5
5
|
CATALOGUE_DATE_MODE, CATALOGUE_PAGE_SIZE, objectsAndMonumentsSummary, useCatalogue,
|
|
6
6
|
} from './catalogue/catalogue.js'
|
|
7
7
|
export {
|
|
8
|
-
searchFieldLabel, searchFieldOptions, searchFields, searchRowKeys, searchRows, searchSummary, searchedFor,
|
|
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'
|