@museumwnf/viewer-core 1.14.0 → 1.15.1
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 +72 -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,154 +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
|
-
}
|
|
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
|
+
}
|
package/src/record/partners.js
CHANGED
|
@@ -1,87 +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
|
-
}
|
|
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
|
+
}
|