@museumwnf/viewer-core 1.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/LICENSE.md +44 -0
- package/README.md +635 -0
- package/package.json +50 -0
- package/src/AppRoot.vue +58 -0
- package/src/catalogue/dates.js +209 -0
- package/src/catalogue/facets.js +61 -0
- package/src/catalogue/keywordIndex.js +289 -0
- package/src/catalogue/listQuery.js +100 -0
- package/src/catalogue/pagination.js +57 -0
- package/src/catalogue/searchLanguage.js +32 -0
- package/src/catalogue/timeline.js +220 -0
- package/src/composables/useCatalogueData.js +141 -0
- package/src/composables/useDataPackage.js +98 -0
- package/src/composables/useEntities.js +100 -0
- package/src/composables/useRecordLanguage.js +71 -0
- package/src/conventions.js +148 -0
- package/src/createViewer.js +29 -0
- package/src/i18n/I18nText.vue +17 -0
- package/src/i18n/I18nTextInline.vue +17 -0
- package/src/i18n/index.js +88 -0
- package/src/i18n/language.js +127 -0
- package/src/i18n/markdown.js +265 -0
- package/src/index.js +50 -0
- package/src/injectionKeys.js +1 -0
- package/src/languages.js +49 -0
- package/src/legacy/index.js +58 -0
- package/src/record/collectionTree.js +307 -0
- package/src/record/derivations.js +154 -0
- package/src/record/partners.js +87 -0
- package/src/record/recordSheet.js +231 -0
- package/src/rights.js +53 -0
- package/src/router/index.js +138 -0
- package/src/siteConfig.js +37 -0
- package/src/styles/base.css +49 -0
- package/src/testing/checkOfferedLanguages.js +67 -0
- package/src/testing/index.js +3 -0
- package/src/testing/smoke.js +103 -0
- package/src/testing/viteConfig.js +90 -0
- package/src/views/DetailView.vue +53 -0
- package/src/views/HomeView.vue +20 -0
- package/src/views/ListView.vue +61 -0
- package/src/views/NotFoundView.vue +8 -0
- package/src/vite.js +8 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { entityRef } from '../composables/useEntities.js'
|
|
3
|
+
|
|
4
|
+
// Three sites each walked `collections.json` from a purpose marker by
|
|
5
|
+
// `parent_id` and `display_order`, and each wrote the same reverse
|
|
6
|
+
// "collections containing this item" scan by hand (islamicart, baroqueart,
|
|
7
|
+
// sharinghistory). A fourth family — the DXA sites — carries the identical
|
|
8
|
+
// shape pre-built as `themes.json` (`sub_themes[]`, `pictures[]`), because
|
|
9
|
+
// its importer nests the tree rather than flattening it with `parent_id`.
|
|
10
|
+
// This is that walk, written once: a pure indexer over either shape, and a
|
|
11
|
+
// composable over `entityRef` for the reactive form a page reads.
|
|
12
|
+
//
|
|
13
|
+
// A node's own items are read from whichever shape the record carries:
|
|
14
|
+
// `items: [{ id, ... }]` (collections) or a flat `item_ids` — a theme node
|
|
15
|
+
// has neither; its content is `pictures[].picture_item_id`, so the themes
|
|
16
|
+
// adapter passes its own extractor rather than reusing this one.
|
|
17
|
+
function itemIdsOfCollection(node) {
|
|
18
|
+
if (Array.isArray(node?.items)) {
|
|
19
|
+
return node.items.map((item) => (item && typeof item === 'object' ? item.id : item)).filter((id) => id != null)
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(node?.item_ids)) {
|
|
22
|
+
return node.item_ids.filter((id) => id != null)
|
|
23
|
+
}
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function itemIdsOfTheme(node) {
|
|
28
|
+
return Array.isArray(node?.pictures)
|
|
29
|
+
? node.pictures.map((picture) => picture?.picture_item_id).filter((id) => id != null)
|
|
30
|
+
: []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Legacy's "unordered records sort last" convention (the three composables
|
|
34
|
+
// this replaces all wrote `?? 9999` by hand); carried over rather than
|
|
35
|
+
// improved, since a package with more than 9999 siblings under one node
|
|
36
|
+
// does not exist.
|
|
37
|
+
const NO_ORDER = 9999
|
|
38
|
+
|
|
39
|
+
function orderOf(node, order) {
|
|
40
|
+
const value = node?.[order]
|
|
41
|
+
return typeof value === 'number' ? value : NO_ORDER
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The shared indexer behind `buildCollectionTree` and
|
|
46
|
+
* `collectionTreeFromThemes`: a flat array of `{ id, parent_id, ... }`
|
|
47
|
+
* nodes, a `rootId` among them (or a sentinel none of them carries, for a
|
|
48
|
+
* tree with no root record of its own — see `collectionTreeFromThemes`),
|
|
49
|
+
* and the interface both entry points hand back. Carries `entity` and `source`
|
|
50
|
+
* so a consumer handed a built tree can read the nodes' texts without being
|
|
51
|
+
* told the entity a second time.
|
|
52
|
+
*/
|
|
53
|
+
function buildIndex(nodes, rootId, { childType, order = 'display_order', itemIdsOf = itemIdsOfCollection, entity, source } = {}) {
|
|
54
|
+
const byIdMap = new Map(nodes.map((node) => [node.id, node]))
|
|
55
|
+
const childIds = new Map() // parent id → ordered child ids
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
if (node.parent_id == null) continue
|
|
58
|
+
if (!childIds.has(node.parent_id)) childIds.set(node.parent_id, [])
|
|
59
|
+
childIds.get(node.parent_id).push(node.id)
|
|
60
|
+
}
|
|
61
|
+
for (const list of childIds.values()) {
|
|
62
|
+
list.sort((a, b) => orderOf(byIdMap.get(a), order) - orderOf(byIdMap.get(b), order))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A node's depth below the root: the root itself is 0, its direct
|
|
66
|
+
// children 1, and so on — bounded by `rootId`, not by how far the
|
|
67
|
+
// `parent_id` chain actually goes (an explicit `rootId` partway down the
|
|
68
|
+
// data is depth 0 for this tree, even though it has ancestors of its own).
|
|
69
|
+
// `parents()` below walks the same chain but doesn't stop at `rootId`,
|
|
70
|
+
// because a breadcrumb wants the true ancestry; this does, because a
|
|
71
|
+
// depth-indexed `childType` cares only about position within this tree.
|
|
72
|
+
function depthOf(id) {
|
|
73
|
+
let depth = 0
|
|
74
|
+
let current = byIdMap.get(id)
|
|
75
|
+
const seen = new Set()
|
|
76
|
+
while (current && current.id !== rootId && current.parent_id != null && !seen.has(current.id)) {
|
|
77
|
+
seen.add(current.id)
|
|
78
|
+
depth++
|
|
79
|
+
if (current.parent_id === rootId) break
|
|
80
|
+
current = byIdMap.get(current.parent_id)
|
|
81
|
+
}
|
|
82
|
+
return depth
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `childType` filters a node's children, in one of three forms:
|
|
86
|
+
// - a string: applied at every depth, not just the root's — a marker's
|
|
87
|
+
// direct children are sometimes a mix of the real tree and siblings
|
|
88
|
+
// that only look like it (sharinghistory's country-specific "National
|
|
89
|
+
// Context" collections sit next to the real themes), and a caller who
|
|
90
|
+
// names the type wants it held to at every level, not only the first.
|
|
91
|
+
// - an array, indexed by depth below the root: `childType[0]` filters the
|
|
92
|
+
// root's children, `childType[1]` filters their children, and so on.
|
|
93
|
+
// A depth past the end of the array is unfiltered. For example,
|
|
94
|
+
// sharinghistory's exhibitions → themes → chapters uses `['exhibition',
|
|
95
|
+
// 'theme', 'subtheme']` because the root's direct children are
|
|
96
|
+
// exhibitions (depth 0), their children are themes (depth 1), and so on;
|
|
97
|
+
// a tree rooted at one exhibition uses `['theme', 'subtheme']` instead.
|
|
98
|
+
// A single type at every depth (e.g. `['theme', 'theme', …]`) is not the
|
|
99
|
+
// pattern to use when levels differ; it drops real nodes.
|
|
100
|
+
// - a function `(node, depth, parent) => boolean`, for a rule neither of
|
|
101
|
+
// the above expresses.
|
|
102
|
+
function children(id) {
|
|
103
|
+
const kids = (childIds.get(id) ?? []).map((cid) => byIdMap.get(cid)).filter(Boolean)
|
|
104
|
+
if (!childType) return kids
|
|
105
|
+
if (typeof childType === 'function') {
|
|
106
|
+
const parent = byIdMap.get(id) ?? null
|
|
107
|
+
const depth = depthOf(id) + 1
|
|
108
|
+
return kids.filter((node) => childType(node, depth, parent))
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(childType)) {
|
|
111
|
+
const type = childType[depthOf(id)]
|
|
112
|
+
return type ? kids.filter((node) => node.type === type) : kids
|
|
113
|
+
}
|
|
114
|
+
return kids.filter((node) => node.type === childType)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parents(id) {
|
|
118
|
+
const chain = []
|
|
119
|
+
let current = byIdMap.get(id)
|
|
120
|
+
while (current && current.parent_id != null && current.parent_id !== current.id) {
|
|
121
|
+
const parent = byIdMap.get(current.parent_id)
|
|
122
|
+
if (!parent) break
|
|
123
|
+
chain.unshift(parent)
|
|
124
|
+
current = parent
|
|
125
|
+
}
|
|
126
|
+
return chain
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function breadcrumb(id) {
|
|
130
|
+
const node = byIdMap.get(id)
|
|
131
|
+
return node ? [...parents(id), node] : []
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A node's own item ids plus every descendant's, depth-first, each once. */
|
|
135
|
+
function itemsUnder(id) {
|
|
136
|
+
const node = byIdMap.get(id)
|
|
137
|
+
if (!node) return []
|
|
138
|
+
const seen = new Set()
|
|
139
|
+
const out = []
|
|
140
|
+
const visit = (current) => {
|
|
141
|
+
for (const itemId of itemIdsOf(current)) {
|
|
142
|
+
if (!seen.has(itemId)) {
|
|
143
|
+
seen.add(itemId)
|
|
144
|
+
out.push(itemId)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const child of children(current.id)) visit(child)
|
|
148
|
+
}
|
|
149
|
+
visit(node)
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The reverse lookup every site wrote by hand: which collections carry
|
|
154
|
+
// this item directly. Not recursive — a page's items do not make its
|
|
155
|
+
// theme "contain" them for this purpose, the way `itemsUnder` treats them;
|
|
156
|
+
// a caller wanting the enclosing theme or exhibition walks up with
|
|
157
|
+
// `parents` from what this returns, as the sites' own link-builders did.
|
|
158
|
+
function containing(itemId) {
|
|
159
|
+
const out = []
|
|
160
|
+
for (const node of byIdMap.values()) {
|
|
161
|
+
if (itemIdsOf(node).includes(itemId)) out.push(node)
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The tree, flattened depth-first, root excluded (the root is the anchor
|
|
167
|
+
// the tree hangs from, not a page a visitor lands on). `previous`/`next`
|
|
168
|
+
// read this sequence by index, which is what makes them cross a branch
|
|
169
|
+
// boundary for free: the last node of one branch is simply followed in
|
|
170
|
+
// the array by the first node of the next, container or leaf alike.
|
|
171
|
+
function walk() {
|
|
172
|
+
const out = []
|
|
173
|
+
const visit = (id) => {
|
|
174
|
+
for (const child of children(id)) {
|
|
175
|
+
out.push(child)
|
|
176
|
+
visit(child.id)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (rootId != null) visit(rootId)
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function previous(id) {
|
|
184
|
+
const sequence = walk()
|
|
185
|
+
const index = sequence.findIndex((node) => node.id === id)
|
|
186
|
+
return index > 0 ? sequence[index - 1] : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function next(id) {
|
|
190
|
+
const sequence = walk()
|
|
191
|
+
const index = sequence.findIndex((node) => node.id === id)
|
|
192
|
+
return index !== -1 && index < sequence.length - 1 ? sequence[index + 1] : null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
root: rootId != null ? (byIdMap.get(rootId) ?? null) : null,
|
|
197
|
+
byId: byIdMap,
|
|
198
|
+
children,
|
|
199
|
+
parents,
|
|
200
|
+
breadcrumb,
|
|
201
|
+
itemsUnder,
|
|
202
|
+
containing,
|
|
203
|
+
walk,
|
|
204
|
+
previous,
|
|
205
|
+
next,
|
|
206
|
+
entity,
|
|
207
|
+
source,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A collection tree rooted at a purpose marker, over a flat
|
|
213
|
+
* `collections.json`-shaped array: `{ id, parent_id, display_order, type,
|
|
214
|
+
* purpose, items }`. `purpose` finds the root by its `purpose` field (the
|
|
215
|
+
* anchor the importer keys the tree from, e.g. `"exhibitions-root"`);
|
|
216
|
+
* `rootId` takes an id directly, for the sites that resolve a marker's
|
|
217
|
+
* single child as the real root themselves (islamicart's Artistic
|
|
218
|
+
* Introduction). Exactly one of the two is expected. `childType` filters a
|
|
219
|
+
* node's children by `type`: a string applies the same type at every depth
|
|
220
|
+
* (sharinghistory's themes, sitting next to National Context collections
|
|
221
|
+
* that are not themes); an array is indexed by depth below the root, where
|
|
222
|
+
* `childType[0]` filters the root's children, `childType[1]` filters their
|
|
223
|
+
* children, and so on (sharinghistory's exhibitions → themes → chapters uses
|
|
224
|
+
* `['exhibition', 'theme', 'subtheme']`, and a tree rooted at one exhibition
|
|
225
|
+
* uses `['theme', 'subtheme']`); a depth past the end is unfiltered; a
|
|
226
|
+
* function `(node, depth, parent) => boolean` covers anything else. Pure —
|
|
227
|
+
* no reactivity, so it is also what a Node script or a one-off script can
|
|
228
|
+
* call directly. `entity` is the translations entity the tree reads (default
|
|
229
|
+
* `'collections'`); `source` is always `'collections'`.
|
|
230
|
+
*/
|
|
231
|
+
export function buildCollectionTree(collections, { purpose, rootId, childType, order = 'display_order', entity = 'collections' } = {}) {
|
|
232
|
+
const nodes = collections ?? []
|
|
233
|
+
const resolvedRootId = rootId ?? nodes.find((node) => node.purpose === purpose)?.id ?? null
|
|
234
|
+
return buildIndex(nodes, resolvedRootId, { childType, order, itemIdsOf: itemIdsOfCollection, entity, source: 'collections' })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A sentinel no real record's id collides with: `themes.json` is already the
|
|
238
|
+
// whole tree, an ordered array of top-level themes with `sub_themes[]`
|
|
239
|
+
// nested inside each — there is no marker record to key a root on. Flatten
|
|
240
|
+
// it to the same `{ id, parent_id }` shape `buildIndex` reads, with every
|
|
241
|
+
// top-level theme's synthetic parent set to this sentinel, and `root` comes
|
|
242
|
+
// back null: there genuinely isn't one, and no site has read it from
|
|
243
|
+
// `themes.json` before this.
|
|
244
|
+
const THEMES_ROOT = Symbol('collectionTree.themesRoot')
|
|
245
|
+
|
|
246
|
+
function flattenThemes(themes, parentId) {
|
|
247
|
+
const out = []
|
|
248
|
+
for (const theme of themes ?? []) {
|
|
249
|
+
const { sub_themes: subThemes, ...rest } = theme
|
|
250
|
+
out.push({ ...rest, parent_id: parentId })
|
|
251
|
+
out.push(...flattenThemes(subThemes, theme.id))
|
|
252
|
+
}
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The same interface as `buildCollectionTree`, from a `themes.json` shape
|
|
258
|
+
* (top-level themes, each with `sub_themes[]` and `pictures[]`) instead of a
|
|
259
|
+
* flat, `parent_id`-linked `collections.json`. `root` is always null — see
|
|
260
|
+
* above; `walk`/`children`/… all work the same from the (nulled) top. `entity`
|
|
261
|
+
* is the translations entity the tree reads (default `'themes'`); `source` is
|
|
262
|
+
* always `'themes'`.
|
|
263
|
+
*/
|
|
264
|
+
export function collectionTreeFromThemes(themes, { childType, order = 'display_order', entity = 'themes' } = {}) {
|
|
265
|
+
const nodes = flattenThemes(themes, THEMES_ROOT)
|
|
266
|
+
return buildIndex(nodes, THEMES_ROOT, { childType, order, itemIdsOf: itemIdsOfTheme, entity, source: 'themes' })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The reactive form: `useCollectionTree({ purpose | rootId, childType?,
|
|
271
|
+
* entity = 'collections', order = 'display_order' })` (see
|
|
272
|
+
* `buildCollectionTree` for the forms `childType` accepts) for the
|
|
273
|
+
* `collections.json` shape, or `useCollectionTree({ source: 'themes',
|
|
274
|
+
* entity: 'themes' })` for a `themes.json` package, where `purpose`/`rootId`
|
|
275
|
+
* do not apply (see `collectionTreeFromThemes`). Reads `entity` through
|
|
276
|
+
* `entityRef`, so the tree is empty (not an error) before that entity's
|
|
277
|
+
* chunk has arrived, and recomputes once it does — a website loads the
|
|
278
|
+
* entity itself, through `meta.entities` or `loadEntities`, exactly as it
|
|
279
|
+
* does for every other record. Returns `entity` (the translations entity the
|
|
280
|
+
* tree reads, the value resolved from the options) and `source` (`'collections'`
|
|
281
|
+
* or `'themes'`), so a consumer handed a built tree can read the nodes' texts
|
|
282
|
+
* without being told the entity a second time.
|
|
283
|
+
*/
|
|
284
|
+
export function useCollectionTree(options = {}) {
|
|
285
|
+
const { entity = 'collections', source = 'collections' } = options
|
|
286
|
+
const records = entityRef(entity)
|
|
287
|
+
const tree = computed(() => {
|
|
288
|
+
const nodes = records.value ?? []
|
|
289
|
+
return source === 'themes' ? collectionTreeFromThemes(nodes, options) : buildCollectionTree(nodes, options)
|
|
290
|
+
})
|
|
291
|
+
return {
|
|
292
|
+
root: computed(() => tree.value.root),
|
|
293
|
+
byId: computed(() => tree.value.byId),
|
|
294
|
+
children: (id) => tree.value.children(id),
|
|
295
|
+
parents: (id) => tree.value.parents(id),
|
|
296
|
+
breadcrumb: (id) => tree.value.breadcrumb(id),
|
|
297
|
+
itemsUnder: (id) => tree.value.itemsUnder(id),
|
|
298
|
+
containing: (itemId) => tree.value.containing(itemId),
|
|
299
|
+
walk: () => tree.value.walk(),
|
|
300
|
+
previous: (id) => tree.value.previous(id),
|
|
301
|
+
next: (id) => tree.value.next(id),
|
|
302
|
+
// entity and source are fixed by the options at call time and never change;
|
|
303
|
+
// a consumer reads them as plain strings, not refs (entityRef and pkg.tr lookups).
|
|
304
|
+
entity,
|
|
305
|
+
source,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { computed, ref, toValue } from 'vue'
|
|
2
|
+
import { useDataPackage } from '../composables/useDataPackage.js'
|
|
3
|
+
import { byId, entityRef } from '../composables/useEntities.js'
|
|
4
|
+
import { renderPlain } from '../i18n/markdown.js'
|
|
5
|
+
import { roundOutward } from '../catalogue/dates.js'
|
|
6
|
+
|
|
7
|
+
const BASE_LANGUAGE = 'en'
|
|
8
|
+
|
|
9
|
+
// Four derivations every record page makes, written seven times.
|
|
10
|
+
|
|
11
|
+
// ── The glossary popup ─────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// The renderer marks every glossary spelling as
|
|
14
|
+
// `<span class="gloss-term" data-gid="…">` while it parses. Those spans are
|
|
15
|
+
// inside `v-html`, so nothing binds to them one by one: one listener on the
|
|
16
|
+
// container reads the click and answers with the term.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `{ active, onClick, close }`: attach `onClick` to the element the sheet
|
|
20
|
+
* renders into; `active` is `{ id, word, spelling, definition }` for the
|
|
21
|
+
* term clicked, null otherwise. `entries` is the `glossary` list of
|
|
22
|
+
* `useRecordSheet` (a ref, getter or array).
|
|
23
|
+
*/
|
|
24
|
+
export function useGlossaryPopup(entries) {
|
|
25
|
+
const active = ref(null)
|
|
26
|
+
function onClick(event) {
|
|
27
|
+
const element = event?.target?.closest?.('.gloss-term')
|
|
28
|
+
if (!element) return
|
|
29
|
+
event.preventDefault()
|
|
30
|
+
const id = element.dataset.gid
|
|
31
|
+
const entry = (toValue(entries) ?? []).find((e) => e.id === id)
|
|
32
|
+
active.value = {
|
|
33
|
+
id,
|
|
34
|
+
spelling: element.textContent,
|
|
35
|
+
word: entry?.word ?? element.textContent,
|
|
36
|
+
definition: entry?.definition ?? '',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function close() {
|
|
40
|
+
active.value = null
|
|
41
|
+
}
|
|
42
|
+
return { active, onClick, close }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The glossary terms whose spelling starts with `input`, in `language`
|
|
47
|
+
* (falling back to English), for a glossary search box: `[{ id, word,
|
|
48
|
+
* spelling, definition }]`, one per term, sorted by spelling.
|
|
49
|
+
*/
|
|
50
|
+
export function searchGlossary(input, language, { entity = 'glossary' } = {}) {
|
|
51
|
+
const needle = String(input ?? '').trim().toLowerCase()
|
|
52
|
+
if (!needle) return []
|
|
53
|
+
const { translations } = useDataPackage()
|
|
54
|
+
const rows = translations(entity, language)
|
|
55
|
+
const fallback = translations(entity, BASE_LANGUAGE)
|
|
56
|
+
const out = []
|
|
57
|
+
for (const term of entityRef(entity).value ?? []) {
|
|
58
|
+
const t = rows[term.id] ?? fallback[term.id] ?? {}
|
|
59
|
+
const spellings = t.spellings?.length ? t.spellings : [term.word]
|
|
60
|
+
for (const spelling of spellings) {
|
|
61
|
+
if (String(spelling ?? '').toLowerCase().startsWith(needle)) {
|
|
62
|
+
out.push({ id: term.id, word: term.word, spelling: String(spelling).trim(), definition: t.definition ?? '' })
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out.sort((a, b) => a.spelling.localeCompare(b.spelling))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Citation ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The sentence both families print under a sheet:
|
|
74
|
+
* `Author "Name" in Project, Publisher, Year. Permalink` — assembled here in
|
|
75
|
+
* that order, from parts, rather than written into a text with holes in it.
|
|
76
|
+
* `inWord` is the site's `t('record.citation.in')`. Parts that are missing
|
|
77
|
+
* are left out with their punctuation.
|
|
78
|
+
*/
|
|
79
|
+
export function citation({ author, name, project, publisher = 'Museum With No Frontiers', year, permalink, inWord = 'in' } = {}) {
|
|
80
|
+
const title = renderPlain(name ?? '')
|
|
81
|
+
if (!title) return ''
|
|
82
|
+
const when = year ?? new Date().getFullYear()
|
|
83
|
+
const where = [project, publisher].filter(Boolean).join(', ')
|
|
84
|
+
const parts = []
|
|
85
|
+
if (author) parts.push(String(author).trim())
|
|
86
|
+
parts.push(`"${title}"`)
|
|
87
|
+
if (where) parts.push(`${inWord} ${where},`)
|
|
88
|
+
let sentence = `${parts.join(' ')} ${when}.`
|
|
89
|
+
if (permalink) sentence += ` ${permalink}`
|
|
90
|
+
return sentence
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Related records ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A record's `related_items`, split by whether the package carries them:
|
|
97
|
+
* `inPackage` is `[{ reference, record, justification }]` with the
|
|
98
|
+
* justification in `language` (falling back to English); `outside` is the
|
|
99
|
+
* references the package does not hold, kept as the references they are — a
|
|
100
|
+
* link is never dropped for being unresolvable, and never invented either.
|
|
101
|
+
*/
|
|
102
|
+
export function relatedRecords(record, { entity = 'items', language = BASE_LANGUAGE, field = 'related_items' } = {}) {
|
|
103
|
+
const index = byId(entity).value
|
|
104
|
+
const inPackage = []
|
|
105
|
+
const outside = []
|
|
106
|
+
const seen = new Set()
|
|
107
|
+
for (const reference of record?.[field] ?? []) {
|
|
108
|
+
if (!reference || seen.has(reference.id)) continue
|
|
109
|
+
seen.add(reference.id)
|
|
110
|
+
const found = reference.in_package === false ? null : index.get(reference.id)
|
|
111
|
+
if (!found) {
|
|
112
|
+
outside.push(reference)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
const justifications = reference.justifications ?? {}
|
|
116
|
+
inPackage.push({
|
|
117
|
+
reference,
|
|
118
|
+
record: found,
|
|
119
|
+
justification: justifications[language] ?? justifications[BASE_LANGUAGE] ?? '',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return { inPackage, outside }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `relatedRecords` over reactive sources. */
|
|
126
|
+
export function useRelatedRecords(record, options = {}) {
|
|
127
|
+
return computed(() => relatedRecords(toValue(record), { ...options, language: toValue(options.language) ?? BASE_LANGUAGE }))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── The timeline link ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The route location of the timeline results for a record's country and
|
|
134
|
+
* dates, or null when the record has neither a country nor a date. `name`
|
|
135
|
+
* is the site's timeline-results route; `keys` the query keys it reads
|
|
136
|
+
* (`country`, `begin`, `end` by default); `country(record)` the value the
|
|
137
|
+
* route wants for the country (the id by default — a site whose timeline
|
|
138
|
+
* reads legacy codes passes its own); `round` rounds the dates outward to
|
|
139
|
+
* the century, as the DXA "Timeline for this item" does.
|
|
140
|
+
*/
|
|
141
|
+
export function timelineLinkFor(record, { name = 'timeline-results', keys = {}, country, round = false } = {}) {
|
|
142
|
+
if (!record) return null
|
|
143
|
+
const countryValue = country ? country(record) : record.country_id
|
|
144
|
+
const start = typeof record.start_date === 'number' ? record.start_date : null
|
|
145
|
+
const end = typeof record.end_date === 'number' ? record.end_date : null
|
|
146
|
+
if (!countryValue && start == null && end == null) return null
|
|
147
|
+
let [from, to] = [start, end]
|
|
148
|
+
if (round) [from, to] = roundOutward(start ?? undefined, end ?? undefined)
|
|
149
|
+
const query = {}
|
|
150
|
+
if (countryValue) query[keys.country ?? 'country'] = String(countryValue)
|
|
151
|
+
if (from != null) query[keys.begin ?? 'begin'] = String(from)
|
|
152
|
+
if (to != null) query[keys.end ?? 'end'] = String(to)
|
|
153
|
+
return { name, query }
|
|
154
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Seven partner lists group by country seven ways. The standalone sites
|
|
2
|
+
// (islamicart, baroqueart, sharinghistory) bucket each country's partners
|
|
3
|
+
// into "main" and "associated" on a `level` field and render an accordion;
|
|
4
|
+
// the DXA sites (carpets and its siblings) group the same way but add an
|
|
5
|
+
// A-Z / Z-A toggle and no tiers at all. Both are this one derivation with a
|
|
6
|
+
// couple of options — pure, so the site's own view supplies the toggle and
|
|
7
|
+
// the label lookup.
|
|
8
|
+
//
|
|
9
|
+
// `parent_id` is carried on a third of the standalone partners and read
|
|
10
|
+
// nowhere: legacy nested an associated partner under the main partner it
|
|
11
|
+
// belongs to, and the port flattened that into the country column instead.
|
|
12
|
+
// `partnerHierarchy` is that relationship, read once.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether `record` counts as a main (rather than associated) partner.
|
|
16
|
+
* `tier` names the field the converged partner shape carries (`level`).
|
|
17
|
+
* A record is associated only when its tier value is a non-empty value
|
|
18
|
+
* other than `'partner'` (legacy values: `'associated_partner'`,
|
|
19
|
+
* `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean
|
|
20
|
+
* main. This is necessary because exporters emit `level: null` for every
|
|
21
|
+
* partner without curated hierarchy. Without a `tier` at all there is nothing
|
|
22
|
+
* to divide records on, so every record is main, which is what a caller with
|
|
23
|
+
* no tiers (the DXA lists) wants.
|
|
24
|
+
*/
|
|
25
|
+
function isMainTier(record, tier) {
|
|
26
|
+
if (!tier) return true
|
|
27
|
+
const tierValue = record?.[tier]
|
|
28
|
+
// Treat null, undefined, empty string, or 'partner' as main
|
|
29
|
+
return tierValue == null || tierValue === '' || tierValue === 'partner'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Partner records grouped by country: `[{ country, label, main, associated
|
|
34
|
+
* }]`, one entry per distinct `country_id` (partners with none share a
|
|
35
|
+
* `null` group), sorted by `label`, ascending unless `order: 'desc'`.
|
|
36
|
+
* `label(country)` names a group's country — the caller's own lookup, the
|
|
37
|
+
* same shape as a facet's `label(value)` — since a partner record carries
|
|
38
|
+
* only the id. `tier` names the field that marks a main partner (see
|
|
39
|
+
* `isMainTier`); without it every record lands in `main` and `associated`
|
|
40
|
+
* is always empty, which covers the DXA lists' plain grouping. Partners
|
|
41
|
+
* keep the relative order `records` was given in within each tier — sorting
|
|
42
|
+
* them by their own label is the view's concern, not this derivation's.
|
|
43
|
+
*/
|
|
44
|
+
export function groupByCountry(records, { label, tier, order = 'asc' } = {}) {
|
|
45
|
+
const groups = new Map()
|
|
46
|
+
for (const record of records ?? []) {
|
|
47
|
+
const country = record?.country_id ?? null
|
|
48
|
+
if (!groups.has(country)) groups.set(country, { country, main: [], associated: [] })
|
|
49
|
+
const group = groups.get(country)
|
|
50
|
+
;(isMainTier(record, tier) ? group.main : group.associated).push(record)
|
|
51
|
+
}
|
|
52
|
+
const rows = [...groups.values()].map((group) => ({ ...group, label: label ? label(group.country) : group.country }))
|
|
53
|
+
rows.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
|
54
|
+
return order === 'desc' ? rows.reverse() : rows
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The partner hierarchy `parent_id` encodes: `children(id)` (a partner's
|
|
59
|
+
* associated partners), `parentOf(id)` (the main partner it belongs to, or
|
|
60
|
+
* null), `roots` (every partner with no parent in the list, or whose
|
|
61
|
+
* `parent_id` points outside it). A partner referencing a `parent_id` this
|
|
62
|
+
* list does not carry is treated as a root rather than dropped — the same
|
|
63
|
+
* "keep the reference, don't invent or discard" rule `relatedRecords` uses.
|
|
64
|
+
*/
|
|
65
|
+
export function partnerHierarchy(partners) {
|
|
66
|
+
const list = partners ?? []
|
|
67
|
+
const byId = new Map(list.map((partner) => [partner.id, partner]))
|
|
68
|
+
const childrenOf = new Map()
|
|
69
|
+
const roots = []
|
|
70
|
+
for (const partner of list) {
|
|
71
|
+
const parent = partner?.parent_id != null ? byId.get(partner.parent_id) : null
|
|
72
|
+
if (parent) {
|
|
73
|
+
if (!childrenOf.has(parent.id)) childrenOf.set(parent.id, [])
|
|
74
|
+
childrenOf.get(parent.id).push(partner)
|
|
75
|
+
} else {
|
|
76
|
+
roots.push(partner)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
children: (id) => childrenOf.get(id) ?? [],
|
|
81
|
+
parentOf: (id) => {
|
|
82
|
+
const partner = byId.get(id)
|
|
83
|
+
return partner?.parent_id != null ? (byId.get(partner.parent_id) ?? null) : null
|
|
84
|
+
},
|
|
85
|
+
roots,
|
|
86
|
+
}
|
|
87
|
+
}
|