@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.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +72 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,220 +1,220 @@
1
- import { computed } from 'vue'
2
- import { entityRef } from '../composables/useEntities.js'
3
- import { eraLabel, yearBucketsFromRange } from './dates.js'
4
-
5
- // The country chronology (`hcr` in legacy) is one merged, year-ordered list
6
- // per country, served by legacy `/v2/events` and reproduced identically on
7
- // every gallery and standalone site. An exhibition can instead carry its own
8
- // narrative chronology (`source: 'thg_local'` — legacy's `/thg/timeline`),
9
- // which has no country of its own and is never interleaved with the merge.
10
- // Sharing History's Timeline additionally splits the merge by the exhibition
11
- // each event belongs to (`collection_id` on its timeline row, `null` for the
12
- // Permanent Collection). Every one of the seven websites' Timeline pages is
13
- // one of these three axes over the same event shape — this is the one place
14
- // that shape is read.
15
- //
16
- // The event overlap rule was triplicated verbatim (islamicart, baroqueart,
17
- // sharinghistory) and mirrors legacy `class.hcr.inc.php`'s SQL; carpets and
18
- // water-in-islam wrote a related but distinct copy for the DXA sites. Both
19
- // are ported here rather than merged into a fourth variant, because the two
20
- // already ship identical answers on the cases that matter (see the tests):
21
- // the only real divergence was which of the three label rules a site reached
22
- // for, which is now one function trying all three in the order the data
23
- // disambiguates them.
24
- //
25
- // What stays out, by design: the DXA legacy 2-letter country code
26
- // (`legacyCodeOf`/`nameFor` in carpets and water-in-islam) — parsing
27
- // `backward_compatibility` and falling back to `Intl.DisplayNames` is a
28
- // concern of the sites still keying their Timeline URLs on that code, not of
29
- // the merge itself. Those sites pass `countryIdForCode` to translate their
30
- // code back to the id this module filters on.
31
-
32
- /** `year_to === 0` mirrors the legacy convention for an open-ended period. */
33
- export function effectiveYearTo(event) {
34
- return event?.year_to && event.year_to !== 0 ? event.year_to : null
35
- }
36
-
37
- /**
38
- * Whether `event` falls in `[begin, end]`, either bound optional — legacy's
39
- * `class.hcr.inc.php` rule: an event with a known end overlaps once that end
40
- * is past `begin` and its start is before `end`; an open-ended event (no
41
- * known end) overlaps on its start alone. An event with no `year_from` at
42
- * all never overlaps a bounded search — there is nothing to compare — but
43
- * still matches a search with no bound, the same as every dated event does.
44
- */
45
- export function overlapsRange(event, begin, end) {
46
- const yf = event?.year_from
47
- const yt = effectiveYearTo(event)
48
- if (begin == null && end == null) return true
49
- if (!Number.isFinite(yf)) return false
50
- if (begin != null && end != null) {
51
- return yt !== null ? yt > begin && yf < end : yf > begin && yf < end
52
- }
53
- if (begin != null) return yt !== null ? yt > begin : yf > begin
54
- return yf <= end
55
- }
56
-
57
- /**
58
- * The label under an event, trying the three rules the sites share in the
59
- * order the data disambiguates them: a curated description pair
60
- * (`date_from_description`/`date_to_description`, for an event whose date
61
- * is imprecise in a way a bare year cannot say — every package's named event
62
- * carries a date description too, so the title is not a date), a narrative
63
- * chronology's own period name (`text.name`, a sort key, only tried when an
64
- * event has no date description at all), and failing both, the derived year
65
- * range through `eraLabel` — the one reached for by the worldwide country
66
- * merge, which has neither.
67
- */
68
- export function eventDateLabel(event, text, t) {
69
- if (text?.date_from_description) {
70
- // Legacy printed the pair only when the two differ; the era branch
71
- // collapses an equal year_from/year_to the same way. Trim before
72
- // comparing so that whitespace doesn't bloat the display.
73
- if (text.date_to_description?.trim() === text.date_from_description.trim()) {
74
- return text.date_from_description
75
- }
76
- return text.date_to_description
77
- ? `${text.date_from_description} – ${text.date_to_description}`
78
- : text.date_from_description
79
- }
80
- if (text?.name) return text.name
81
- if (!Number.isFinite(event?.year_from)) return ''
82
- const from = eraLabel(event.year_from, t)
83
- const yt = effectiveYearTo(event)
84
- return yt !== null ? `${from} – ${eraLabel(yt, t)}` : `${from} –`
85
- }
86
-
87
- /**
88
- * The engine of a Timeline results page, one implementation for the three
89
- * axes legacy split across separate endpoints:
90
- *
91
- * - `'country'` — the worldwide country merge (galleries, and the standalone
92
- * sites that offer only this axis);
93
- * - `'local'` — an exhibition's own narrative chronology in place of the
94
- * merge (the `thg_local` timeline row), no country picker;
95
- * - `'collection'` — the merge again, plus filtering by which exhibition an
96
- * event's timeline is bound to (`null` = the Permanent Collection), for
97
- * Sharing History's second axis.
98
- *
99
- * `countryLabel(countryId)` names a country for the picker and is the
100
- * site's own — a country reads as its name, which this module has no way to
101
- * know. `countryIdForCode`, if given, resolves a `findEvents({ country })`
102
- * value through the site's own legacy-code table before filtering; omit it
103
- * and `country` is already the id. `tr(id)` returns an event's translated
104
- * fields already resolved for entity and language — the site's own `tr`,
105
- * bound the way every data composable already binds it. `timelinesEntity`
106
- * / `eventsEntity` name the data package files, for a site that calls its
107
- * own something else.
108
- */
109
- export function useTimelineEvents({
110
- scope = 'country',
111
- countryLabel,
112
- countryIdForCode,
113
- tr,
114
- timelinesEntity = 'timelines',
115
- eventsEntity = 'timeline_events',
116
- } = {}) {
117
- const timelines = entityRef(timelinesEntity)
118
- const events = entityRef(eventsEntity)
119
-
120
- // The narrative chronology has no country of its own, so it must be kept
121
- // out of the merge by `source` rather than by the country filter — on "all
122
- // countries" it would otherwise interleave into the worldwide list.
123
- const localTimeline = computed(
124
- () => (timelines.value ?? []).find((row) => row.source === 'thg_local') ?? null,
125
- )
126
-
127
- const timelineById = computed(() => new Map((timelines.value ?? []).map((row) => [row.id, row])))
128
-
129
- /** The events this scope works over — one chronology, never both. */
130
- const eventPool = computed(() => {
131
- const all = events.value ?? []
132
- const local = localTimeline.value
133
- if (scope === 'local') return local ? all.filter((event) => event.timeline_id === local.id) : []
134
- return local ? all.filter((event) => event.timeline_id !== local.id) : all
135
- })
136
-
137
- const hasTimeline = computed(() => eventPool.value.length > 0)
138
- const usesLocalTimeline = computed(() => scope === 'local' && Boolean(localTimeline.value))
139
-
140
- /**
141
- * Countries that actually have a chronology, alphabetized by label, with
142
- * an "all" marker first — the marker carries no label of its own, so the
143
- * picker renders it through its own catalogue entry. No picker at all for
144
- * `'local'`: legacy hides the country select once the section is the
145
- * exhibition's own chronology, because there is nothing to filter by.
146
- */
147
- const countries = computed(() => {
148
- if (scope === 'local') return []
149
- const seen = new Map()
150
- for (const row of timelines.value ?? []) {
151
- if (!row.country_id || seen.has(row.country_id)) continue
152
- seen.set(row.country_id, {
153
- value: row.country_id,
154
- label: countryLabel ? countryLabel(row.country_id) : row.country_id,
155
- })
156
- }
157
- const rows = [...seen.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)))
158
- return [{ value: 'all', label: null }, ...rows]
159
- })
160
-
161
- /** Every event year present in this scope, as the year picker's source range. */
162
- const yearRange = computed(() => {
163
- const years = eventPool.value
164
- .map((event) => event.year_from)
165
- .filter((value) => Number.isFinite(value) && value !== 0)
166
- if (!years.length) return [null, null]
167
- return [Math.min(...years), Math.max(...years)]
168
- })
169
-
170
- /** The legacy era buckets over this scope's year range. */
171
- function yearBuckets(t) {
172
- const [min, max] = yearRange.value
173
- return yearBucketsFromRange(min, max, t)
174
- }
175
-
176
- function resolveCountryId(country) {
177
- if (country == null || country === 'all') return null
178
- return countryIdForCode ? countryIdForCode(country) : country
179
- }
180
-
181
- // `null` is Sharing History's Permanent Collection sentinel — a timeline
182
- // bound to no exhibition — so it must be told apart from "no filter",
183
- // which `collection: undefined` spells instead.
184
- function matchesCollection(event, collection) {
185
- const collectionId = timelineById.value.get(event.timeline_id)?.collection_id ?? null
186
- return collection === null ? collectionId === null : collectionId === collection
187
- }
188
-
189
- /**
190
- * Legacy's `/events?ic[]=&ya=&yo=` (and Sharing History's exhibition
191
- * filter alongside it) — this scope's events in `[begin, end]`, for the
192
- * given country and/or collection, chronological. `display_order` breaks a
193
- * tie on `year_from`, the order the exhibition itself gave two events of
194
- * the same year; an event with no year at all has no place in that order
195
- * and sorts after every dated one.
196
- */
197
- function findEvents({ country, collection, begin, end } = {}) {
198
- const countryId = resolveCountryId(country)
199
- const from = begin === '' || begin == null ? null : Number(begin)
200
- const to = end === '' || end == null ? null : Number(end)
201
-
202
- return eventPool.value
203
- .filter((event) => {
204
- // A narrative chronology's events carry no country_id at all — the
205
- // filter has nothing to apply itself to, the same reason `countries`
206
- // renders no picker for this scope.
207
- if (scope !== 'local' && countryId && event.country_id !== countryId) return false
208
- if (collection !== undefined && !matchesCollection(event, collection)) return false
209
- return overlapsRange(event, from, to)
210
- })
211
- .map((event) => ({ ...event, text: tr ? tr(event.id) : {} }))
212
- .sort((a, b) => {
213
- const ya = Number.isFinite(a.year_from) ? a.year_from : Infinity
214
- const yb = Number.isFinite(b.year_from) ? b.year_from : Infinity
215
- return ya - yb || (a.display_order ?? 0) - (b.display_order ?? 0)
216
- })
217
- }
218
-
219
- return { countries, yearRange, yearBuckets, hasTimeline, usesLocalTimeline, findEvents }
220
- }
1
+ import { computed } from 'vue'
2
+ import { entityRef } from '../composables/useEntities.js'
3
+ import { eraLabel, yearBucketsFromRange } from './dates.js'
4
+
5
+ // The country chronology (`hcr` in legacy) is one merged, year-ordered list
6
+ // per country, served by legacy `/v2/events` and reproduced identically on
7
+ // every gallery and standalone site. An exhibition can instead carry its own
8
+ // narrative chronology (`source: 'thg_local'` — legacy's `/thg/timeline`),
9
+ // which has no country of its own and is never interleaved with the merge.
10
+ // Sharing History's Timeline additionally splits the merge by the exhibition
11
+ // each event belongs to (`collection_id` on its timeline row, `null` for the
12
+ // Permanent Collection). Every one of the seven websites' Timeline pages is
13
+ // one of these three axes over the same event shape — this is the one place
14
+ // that shape is read.
15
+ //
16
+ // The event overlap rule was triplicated verbatim (islamicart, baroqueart,
17
+ // sharinghistory) and mirrors legacy `class.hcr.inc.php`'s SQL; carpets and
18
+ // water-in-islam wrote a related but distinct copy for the DXA sites. Both
19
+ // are ported here rather than merged into a fourth variant, because the two
20
+ // already ship identical answers on the cases that matter (see the tests):
21
+ // the only real divergence was which of the three label rules a site reached
22
+ // for, which is now one function trying all three in the order the data
23
+ // disambiguates them.
24
+ //
25
+ // What stays out, by design: the DXA legacy 2-letter country code
26
+ // (`legacyCodeOf`/`nameFor` in carpets and water-in-islam) — parsing
27
+ // `backward_compatibility` and falling back to `Intl.DisplayNames` is a
28
+ // concern of the sites still keying their Timeline URLs on that code, not of
29
+ // the merge itself. Those sites pass `countryIdForCode` to translate their
30
+ // code back to the id this module filters on.
31
+
32
+ /** `year_to === 0` mirrors the legacy convention for an open-ended period. */
33
+ export function effectiveYearTo(event) {
34
+ return event?.year_to && event.year_to !== 0 ? event.year_to : null
35
+ }
36
+
37
+ /**
38
+ * Whether `event` falls in `[begin, end]`, either bound optional — legacy's
39
+ * `class.hcr.inc.php` rule: an event with a known end overlaps once that end
40
+ * is past `begin` and its start is before `end`; an open-ended event (no
41
+ * known end) overlaps on its start alone. An event with no `year_from` at
42
+ * all never overlaps a bounded search — there is nothing to compare — but
43
+ * still matches a search with no bound, the same as every dated event does.
44
+ */
45
+ export function overlapsRange(event, begin, end) {
46
+ const yf = event?.year_from
47
+ const yt = effectiveYearTo(event)
48
+ if (begin == null && end == null) return true
49
+ if (!Number.isFinite(yf)) return false
50
+ if (begin != null && end != null) {
51
+ return yt !== null ? yt > begin && yf < end : yf > begin && yf < end
52
+ }
53
+ if (begin != null) return yt !== null ? yt > begin : yf > begin
54
+ return yf <= end
55
+ }
56
+
57
+ /**
58
+ * The label under an event, trying the three rules the sites share in the
59
+ * order the data disambiguates them: a curated description pair
60
+ * (`date_from_description`/`date_to_description`, for an event whose date
61
+ * is imprecise in a way a bare year cannot say — every package's named event
62
+ * carries a date description too, so the title is not a date), a narrative
63
+ * chronology's own period name (`text.name`, a sort key, only tried when an
64
+ * event has no date description at all), and failing both, the derived year
65
+ * range through `eraLabel` — the one reached for by the worldwide country
66
+ * merge, which has neither.
67
+ */
68
+ export function eventDateLabel(event, text, t) {
69
+ if (text?.date_from_description) {
70
+ // Legacy printed the pair only when the two differ; the era branch
71
+ // collapses an equal year_from/year_to the same way. Trim before
72
+ // comparing so that whitespace doesn't bloat the display.
73
+ if (text.date_to_description?.trim() === text.date_from_description.trim()) {
74
+ return text.date_from_description
75
+ }
76
+ return text.date_to_description
77
+ ? `${text.date_from_description} – ${text.date_to_description}`
78
+ : text.date_from_description
79
+ }
80
+ if (text?.name) return text.name
81
+ if (!Number.isFinite(event?.year_from)) return ''
82
+ const from = eraLabel(event.year_from, t)
83
+ const yt = effectiveYearTo(event)
84
+ return yt !== null ? `${from} – ${eraLabel(yt, t)}` : `${from} –`
85
+ }
86
+
87
+ /**
88
+ * The engine of a Timeline results page, one implementation for the three
89
+ * axes legacy split across separate endpoints:
90
+ *
91
+ * - `'country'` — the worldwide country merge (galleries, and the standalone
92
+ * sites that offer only this axis);
93
+ * - `'local'` — an exhibition's own narrative chronology in place of the
94
+ * merge (the `thg_local` timeline row), no country picker;
95
+ * - `'collection'` — the merge again, plus filtering by which exhibition an
96
+ * event's timeline is bound to (`null` = the Permanent Collection), for
97
+ * Sharing History's second axis.
98
+ *
99
+ * `countryLabel(countryId)` names a country for the picker and is the
100
+ * site's own — a country reads as its name, which this module has no way to
101
+ * know. `countryIdForCode`, if given, resolves a `findEvents({ country })`
102
+ * value through the site's own legacy-code table before filtering; omit it
103
+ * and `country` is already the id. `tr(id)` returns an event's translated
104
+ * fields already resolved for entity and language — the site's own `tr`,
105
+ * bound the way every data composable already binds it. `timelinesEntity`
106
+ * / `eventsEntity` name the data package files, for a site that calls its
107
+ * own something else.
108
+ */
109
+ export function useTimelineEvents({
110
+ scope = 'country',
111
+ countryLabel,
112
+ countryIdForCode,
113
+ tr,
114
+ timelinesEntity = 'timelines',
115
+ eventsEntity = 'timeline_events',
116
+ } = {}) {
117
+ const timelines = entityRef(timelinesEntity)
118
+ const events = entityRef(eventsEntity)
119
+
120
+ // The narrative chronology has no country of its own, so it must be kept
121
+ // out of the merge by `source` rather than by the country filter — on "all
122
+ // countries" it would otherwise interleave into the worldwide list.
123
+ const localTimeline = computed(
124
+ () => (timelines.value ?? []).find((row) => row.source === 'thg_local') ?? null,
125
+ )
126
+
127
+ const timelineById = computed(() => new Map((timelines.value ?? []).map((row) => [row.id, row])))
128
+
129
+ /** The events this scope works over — one chronology, never both. */
130
+ const eventPool = computed(() => {
131
+ const all = events.value ?? []
132
+ const local = localTimeline.value
133
+ if (scope === 'local') return local ? all.filter((event) => event.timeline_id === local.id) : []
134
+ return local ? all.filter((event) => event.timeline_id !== local.id) : all
135
+ })
136
+
137
+ const hasTimeline = computed(() => eventPool.value.length > 0)
138
+ const usesLocalTimeline = computed(() => scope === 'local' && Boolean(localTimeline.value))
139
+
140
+ /**
141
+ * Countries that actually have a chronology, alphabetized by label, with
142
+ * an "all" marker first — the marker carries no label of its own, so the
143
+ * picker renders it through its own catalogue entry. No picker at all for
144
+ * `'local'`: legacy hides the country select once the section is the
145
+ * exhibition's own chronology, because there is nothing to filter by.
146
+ */
147
+ const countries = computed(() => {
148
+ if (scope === 'local') return []
149
+ const seen = new Map()
150
+ for (const row of timelines.value ?? []) {
151
+ if (!row.country_id || seen.has(row.country_id)) continue
152
+ seen.set(row.country_id, {
153
+ value: row.country_id,
154
+ label: countryLabel ? countryLabel(row.country_id) : row.country_id,
155
+ })
156
+ }
157
+ const rows = [...seen.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)))
158
+ return [{ value: 'all', label: null }, ...rows]
159
+ })
160
+
161
+ /** Every event year present in this scope, as the year picker's source range. */
162
+ const yearRange = computed(() => {
163
+ const years = eventPool.value
164
+ .map((event) => event.year_from)
165
+ .filter((value) => Number.isFinite(value) && value !== 0)
166
+ if (!years.length) return [null, null]
167
+ return [Math.min(...years), Math.max(...years)]
168
+ })
169
+
170
+ /** The legacy era buckets over this scope's year range. */
171
+ function yearBuckets(t) {
172
+ const [min, max] = yearRange.value
173
+ return yearBucketsFromRange(min, max, t)
174
+ }
175
+
176
+ function resolveCountryId(country) {
177
+ if (country == null || country === 'all') return null
178
+ return countryIdForCode ? countryIdForCode(country) : country
179
+ }
180
+
181
+ // `null` is Sharing History's Permanent Collection sentinel — a timeline
182
+ // bound to no exhibition — so it must be told apart from "no filter",
183
+ // which `collection: undefined` spells instead.
184
+ function matchesCollection(event, collection) {
185
+ const collectionId = timelineById.value.get(event.timeline_id)?.collection_id ?? null
186
+ return collection === null ? collectionId === null : collectionId === collection
187
+ }
188
+
189
+ /**
190
+ * Legacy's `/events?ic[]=&ya=&yo=` (and Sharing History's exhibition
191
+ * filter alongside it) — this scope's events in `[begin, end]`, for the
192
+ * given country and/or collection, chronological. `display_order` breaks a
193
+ * tie on `year_from`, the order the exhibition itself gave two events of
194
+ * the same year; an event with no year at all has no place in that order
195
+ * and sorts after every dated one.
196
+ */
197
+ function findEvents({ country, collection, begin, end } = {}) {
198
+ const countryId = resolveCountryId(country)
199
+ const from = begin === '' || begin == null ? null : Number(begin)
200
+ const to = end === '' || end == null ? null : Number(end)
201
+
202
+ return eventPool.value
203
+ .filter((event) => {
204
+ // A narrative chronology's events carry no country_id at all — the
205
+ // filter has nothing to apply itself to, the same reason `countries`
206
+ // renders no picker for this scope.
207
+ if (scope !== 'local' && countryId && event.country_id !== countryId) return false
208
+ if (collection !== undefined && !matchesCollection(event, collection)) return false
209
+ return overlapsRange(event, from, to)
210
+ })
211
+ .map((event) => ({ ...event, text: tr ? tr(event.id) : {} }))
212
+ .sort((a, b) => {
213
+ const ya = Number.isFinite(a.year_from) ? a.year_from : Infinity
214
+ const yb = Number.isFinite(b.year_from) ? b.year_from : Infinity
215
+ return ya - yb || (a.display_order ?? 0) - (b.display_order ?? 0)
216
+ })
217
+ }
218
+
219
+ return { countries, yearRange, yearBuckets, hasTimeline, usesLocalTimeline, findEvents }
220
+ }