@museumwnf/viewer-core 1.14.0 → 1.15.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 -44
- package/README.md +658 -635
- package/package.json +50 -50
- package/src/AppRoot.vue +58 -58
- package/src/catalogue/dates.js +209 -209
- package/src/catalogue/facets.js +61 -61
- package/src/catalogue/keywordIndex.js +289 -289
- package/src/catalogue/listQuery.js +100 -100
- package/src/catalogue/pagination.js +57 -57
- package/src/catalogue/searchLanguage.js +32 -32
- package/src/catalogue/timeline.js +220 -220
- package/src/composables/useCatalogueData.js +141 -141
- package/src/composables/useDataPackage.js +98 -98
- package/src/composables/useEntities.js +100 -100
- package/src/composables/useRecordLanguage.js +71 -71
- package/src/conventions.js +252 -148
- package/src/createViewer.js +29 -29
- package/src/i18n/I18nText.vue +17 -17
- package/src/i18n/I18nTextInline.vue +17 -17
- package/src/i18n/index.js +88 -88
- package/src/i18n/language.js +127 -127
- package/src/i18n/markdown.js +265 -265
- package/src/index.js +51 -50
- package/src/injectionKeys.js +1 -1
- package/src/languages.js +49 -49
- package/src/legacy/index.js +58 -58
- package/src/record/collectionTree.js +307 -307
- package/src/record/derivations.js +154 -154
- package/src/record/partners.js +87 -87
- package/src/record/recordSheet.js +231 -231
- package/src/rights.js +53 -53
- package/src/router/index.js +138 -138
- package/src/siteConfig.js +37 -37
- package/src/styles/base.css +49 -49
- package/src/testing/checkOfferedLanguages.js +67 -67
- package/src/testing/index.js +3 -3
- package/src/testing/smoke.js +103 -103
- package/src/testing/viteConfig.js +90 -90
- package/src/views/DetailView.vue +53 -53
- package/src/views/HomeView.vue +20 -20
- package/src/views/ListView.vue +61 -61
- package/src/views/NotFoundView.vue +8 -8
- package/src/vite.js +8 -8
|
@@ -1,307 +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
|
-
}
|
|
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
|
+
}
|