@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.11.0",
3
+ "version": "2.13.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",
@@ -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
- if (filters.lang) parts.push(`${t('catalogue.search.language')}: ${String(filters.lang).toUpperCase()}`)
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 `lang`. `narrow(list, filters)` is a results
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.lang || ''
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'