@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,100 @@
|
|
|
1
|
+
import { computed, reactive, watch } from 'vue'
|
|
2
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
// The state of a list page lives in the URL, and the URL is what a visitor
|
|
5
|
+
// shares, bookmarks and comes back to through history. Every results page of
|
|
6
|
+
// every website therefore does the same four things: read the filters and the
|
|
7
|
+
// page out of `route.query`, keep them in step when the route changes under
|
|
8
|
+
// it, write them back when the visitor acts, and start again from page one
|
|
9
|
+
// when a filter changes. Eleven views wrote those four things by hand before
|
|
10
|
+
// this existed, each with its own idiom and its own small differences.
|
|
11
|
+
//
|
|
12
|
+
// The convention this encodes is rule R8 of the alignment pass: filters and
|
|
13
|
+
// the page travel in the query, never in the path; `page` is dropped when it
|
|
14
|
+
// is 1; a filter change is a new place in history (`push`), a page change
|
|
15
|
+
// replaces the current one (`replace`), and either navigates on the route the
|
|
16
|
+
// page is already on, by name, so no view writes a path.
|
|
17
|
+
|
|
18
|
+
const PAGE = 'page'
|
|
19
|
+
|
|
20
|
+
function firstValue(value) {
|
|
21
|
+
return Array.isArray(value) ? (value[0] ?? '') : value ?? ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pageOf(query) {
|
|
25
|
+
const parsed = Number.parseInt(firstValue(query[PAGE]), 10)
|
|
26
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Filters and page bound to the route query.
|
|
31
|
+
*
|
|
32
|
+
* `keys` names the filters this page knows. `filters` is a reactive object
|
|
33
|
+
* with one string per key — bind a control to it, then `apply()` to navigate
|
|
34
|
+
* with what it holds (or `apply(patch)` to change some and navigate at once,
|
|
35
|
+
* for a control that navigates on change). `reset()` clears every filter and
|
|
36
|
+
* navigates. `goToPage(n)` changes only the page. `page` is the current page,
|
|
37
|
+
* read from the query. `active` says whether any filter is set.
|
|
38
|
+
*
|
|
39
|
+
* Query parameters the page does not own — `lang`, a parameter of another
|
|
40
|
+
* component — are carried across every navigation untouched.
|
|
41
|
+
*/
|
|
42
|
+
export function useListQuery({ keys = [], page = true } = {}) {
|
|
43
|
+
const route = useRoute()
|
|
44
|
+
const router = useRouter()
|
|
45
|
+
|
|
46
|
+
const filters = reactive(Object.fromEntries(keys.map((key) => [key, ''])))
|
|
47
|
+
|
|
48
|
+
function readFromRoute() {
|
|
49
|
+
for (const key of keys) filters[key] = String(firstValue(route.query[key]))
|
|
50
|
+
}
|
|
51
|
+
readFromRoute()
|
|
52
|
+
// The route changing under the page — back, forward, a link to the same
|
|
53
|
+
// page with other filters — is the URL speaking; the controls follow it.
|
|
54
|
+
watch(() => route.query, readFromRoute)
|
|
55
|
+
|
|
56
|
+
const current = computed(() => (page ? pageOf(route.query) : 1))
|
|
57
|
+
const active = computed(() => keys.some((key) => filters[key] !== ''))
|
|
58
|
+
|
|
59
|
+
function foreignQuery() {
|
|
60
|
+
const out = {}
|
|
61
|
+
for (const [key, value] of Object.entries(route.query)) {
|
|
62
|
+
if (!keys.includes(key) && key !== PAGE) out[key] = value
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ownQuery() {
|
|
68
|
+
const out = {}
|
|
69
|
+
for (const key of keys) if (filters[key] !== '') out[key] = filters[key]
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function location(query) {
|
|
74
|
+
return { name: route.name, params: route.params, query }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Navigate with the filters as they are, or with `patch` applied first. Starts at page 1. */
|
|
78
|
+
function apply(patch = {}) {
|
|
79
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
80
|
+
if (keys.includes(key)) filters[key] = value == null ? '' : String(value)
|
|
81
|
+
}
|
|
82
|
+
return router.push(location({ ...foreignQuery(), ...ownQuery() }))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Clear every filter and navigate. */
|
|
86
|
+
function reset() {
|
|
87
|
+
for (const key of keys) filters[key] = ''
|
|
88
|
+
return router.push(location(foreignQuery()))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Another page of the same results: the same place in history, so back leaves the list. */
|
|
92
|
+
function goToPage(n) {
|
|
93
|
+
const target = Math.max(1, Number.parseInt(n, 10) || 1)
|
|
94
|
+
const query = { ...foreignQuery(), ...ownQuery() }
|
|
95
|
+
if (target > 1) query[PAGE] = String(target)
|
|
96
|
+
return router.replace(location(query))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { filters, page: current, active, apply, reset, goToPage }
|
|
100
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { computed, toValue } from 'vue'
|
|
2
|
+
|
|
3
|
+
// One page arithmetic for every list. Five implementations existed — this
|
|
4
|
+
// package's own ListView, a block inlined into six standalone views, a
|
|
5
|
+
// component copied into four DXA sites, and two idioms for the count — and
|
|
6
|
+
// they disagreed on nothing but shape. This is the DXA shape, which answers
|
|
7
|
+
// everything a page needs to render itself: where it is, how far it goes,
|
|
8
|
+
// and which rows it holds.
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SIZE = 20
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `{ total, lastPage, currentPage, from, to, rows }` for one page of `list`.
|
|
14
|
+
* A page past the end clamps to the last one; a page below one is the first;
|
|
15
|
+
* `from`/`to` are 1-based and both 0 for an empty list. The size is the
|
|
16
|
+
* caller's: twenty on the standalone sites and nine on the DXA sites are
|
|
17
|
+
* both legacy facts, and neither is this function's to decide.
|
|
18
|
+
*/
|
|
19
|
+
export function paginate(list, page, size = DEFAULT_SIZE) {
|
|
20
|
+
const rows = list ?? []
|
|
21
|
+
const total = rows.length
|
|
22
|
+
const perPage = Math.max(1, Number(size) || DEFAULT_SIZE)
|
|
23
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
24
|
+
const requested = Number.parseInt(page, 10)
|
|
25
|
+
const currentPage = Math.min(Math.max(1, Number.isFinite(requested) ? requested : 1), lastPage)
|
|
26
|
+
const start = (currentPage - 1) * perPage
|
|
27
|
+
return {
|
|
28
|
+
total,
|
|
29
|
+
lastPage,
|
|
30
|
+
currentPage,
|
|
31
|
+
from: total === 0 ? 0 : start + 1,
|
|
32
|
+
to: Math.min(start + perPage, total),
|
|
33
|
+
rows: rows.slice(start, start + perPage),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `paginate` over reactive sources: `list` and `page` may each be a ref, a
|
|
39
|
+
* computed, a getter or a plain value — a `useListQuery().page`, typically.
|
|
40
|
+
*/
|
|
41
|
+
export function usePagination(list, { page = 1, size = DEFAULT_SIZE } = {}) {
|
|
42
|
+
return computed(() => paginate(toValue(list), toValue(page), toValue(size)))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Chronological order by `key` (a record's `start_date`), undated records
|
|
47
|
+
* last — or first, with `undated: 'first'`. Stable: two records of the same
|
|
48
|
+
* year keep the order they arrived in. Returns a new array.
|
|
49
|
+
*/
|
|
50
|
+
export function sortChronological(list, { key = 'start_date', undated = 'last' } = {}) {
|
|
51
|
+
const missing = undated === 'first' ? -Infinity : Infinity
|
|
52
|
+
const year = (record) => {
|
|
53
|
+
const value = record?.[key]
|
|
54
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : missing
|
|
55
|
+
}
|
|
56
|
+
return [...(list ?? [])].sort((a, b) => year(a) - year(b))
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { computed, ref, watch } from 'vue'
|
|
2
|
+
import { useDataPackage } from '../composables/useDataPackage.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The search-language rule: a sorted list of languages the entity's
|
|
6
|
+
* translations exist in, and a watcher that loads them when the selection
|
|
7
|
+
* changes. Three Database pages carry this watch locally, reading three
|
|
8
|
+
* different entity names.
|
|
9
|
+
*/
|
|
10
|
+
export function useSearchLanguage(entity) {
|
|
11
|
+
const { availableLanguages, loadTranslations } = useDataPackage()
|
|
12
|
+
|
|
13
|
+
// The languages the entity actually carries translations in, sorted.
|
|
14
|
+
const languages = computed(() => {
|
|
15
|
+
const available = availableLanguages(entity)
|
|
16
|
+
return available.slice().sort()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// The user's current selection.
|
|
20
|
+
const language = ref('')
|
|
21
|
+
|
|
22
|
+
// Load translations when the selection changes; the watch runs immediately
|
|
23
|
+
// so the reference language (English, the index language) is always there.
|
|
24
|
+
watch(() => language.value, (lang) => {
|
|
25
|
+
if (lang) loadTranslations(entity, lang)
|
|
26
|
+
}, { immediate: true })
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
languages,
|
|
30
|
+
language,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { useDataPackage } from './useDataPackage.js'
|
|
3
|
+
import { byId as coreById, entityRef } from './useEntities.js'
|
|
4
|
+
import { md as coreMd, mdInline as coreMdInline, mdStrip as coreMdStrip } from '../i18n/markdown.js'
|
|
5
|
+
import { glossaryEntries } from '../record/recordSheet.js'
|
|
6
|
+
|
|
7
|
+
// The wrapper half of seven sites' data composables (2,376 lines across
|
|
8
|
+
// them), written once. Every one of them re-wrapped the same `tr`, the same
|
|
9
|
+
// memoised `loadEnglish`, the same one-shape label helper
|
|
10
|
+
// (`mdStrip(tr(entity, id).name ?? record.internal_name ?? id)`), and the
|
|
11
|
+
// same `md`/`mdInline`/`mdStrip` re-export, around whichever entities and
|
|
12
|
+
// visibility rules were its own — a per-build language filter, a hidden-
|
|
13
|
+
// partner rule, `display_status` — coded rather than declared. This is that
|
|
14
|
+
// wrapper. What survives in a site's own composable is what genuinely
|
|
15
|
+
// differs: routes, legacy key mappings, chrome images, sibling lists.
|
|
16
|
+
//
|
|
17
|
+
// Called once, at the top of a site's data composable module, and its
|
|
18
|
+
// return value re-exported from there — the way `entityRef`/`byId` are
|
|
19
|
+
// already read at module scope, so importing the module loads nothing and a
|
|
20
|
+
// page pays only for the entity it declares. `entity`/`index` read the same
|
|
21
|
+
// shared `entityRef`/`byId` this file does, so a second call would only
|
|
22
|
+
// duplicate the memoisation `loadEnglish` and the glossary binding carry —
|
|
23
|
+
// one instance per site, not one per component.
|
|
24
|
+
const BASE_LANGUAGE = 'en'
|
|
25
|
+
|
|
26
|
+
// A term's spellings and definition in one language, falling back to
|
|
27
|
+
// English, then to the term's own headword — the same row shape
|
|
28
|
+
// `useRecordSheet`'s `termRow` builds, but for the whole glossary in
|
|
29
|
+
// `defaultLanguage` rather than one record's `glossary_ids` in the record's
|
|
30
|
+
// own language, which is why it is not reused: a catalogue list or a label
|
|
31
|
+
// is always shown in `defaultLanguage`, a record sheet's text is not.
|
|
32
|
+
function glossaryRows(language, translations) {
|
|
33
|
+
const rows = translations('glossary', language)
|
|
34
|
+
const fallback = translations('glossary', BASE_LANGUAGE)
|
|
35
|
+
const terms = []
|
|
36
|
+
for (const term of entityRef('glossary').value ?? []) {
|
|
37
|
+
const t = rows[term.id] ?? fallback[term.id] ?? {}
|
|
38
|
+
const spellings = (t.spellings ?? []).map((s) => String(s).trim()).filter(Boolean)
|
|
39
|
+
const word = term.word ?? spellings[0] ?? ''
|
|
40
|
+
if (!word && spellings.length === 0) continue
|
|
41
|
+
terms.push({ id: term.id, word, definition: t.definition ?? '', spellings: spellings.length ? spellings : [word] })
|
|
42
|
+
}
|
|
43
|
+
return terms
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The wrapper half of a website's data composable: `tr`, `md`/`mdInline`/
|
|
48
|
+
* `mdStrip` bound to the site's glossary, `loadEnglish`, `labelOf`, the
|
|
49
|
+
* visible form of an entity and its index, and the package re-exports.
|
|
50
|
+
*
|
|
51
|
+
* `eager` names the entities `loadEnglish` loads — every entity the package
|
|
52
|
+
* carries by default. `defaultLanguage` is the language `tr`, the labels and
|
|
53
|
+
* the glossary binding read (`'en'` for every site so far). `visible` names,
|
|
54
|
+
* per entity, the predicate `entity()`/`index()` apply — a per-build
|
|
55
|
+
* language filter, a hidden-partner rule, a display-status check are all
|
|
56
|
+
* "which records this build shows", declared here once instead of coded
|
|
57
|
+
* into every page that lists that entity. `glossary` turns off the default
|
|
58
|
+
* binding on `md`/`mdInline` (a caller may still pass its own `glossary` to
|
|
59
|
+
* either, on or off).
|
|
60
|
+
*/
|
|
61
|
+
export function useCatalogueData({ eager, defaultLanguage = BASE_LANGUAGE, visible = {}, glossary = true } = {}) {
|
|
62
|
+
const pkg = useDataPackage()
|
|
63
|
+
const { availableLanguages, loadTranslations, translations } = pkg
|
|
64
|
+
const eagerEntities = eager ?? pkg.entityNames
|
|
65
|
+
|
|
66
|
+
/** One record's translated fields, falling back to `defaultLanguage` then `{}`. */
|
|
67
|
+
function tr(entity, id, lang = defaultLanguage) {
|
|
68
|
+
return pkg.tr(entity, id, lang, defaultLanguage)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const defaultGlossary = computed(() =>
|
|
72
|
+
glossary ? glossaryEntries(glossaryRows(defaultLanguage, translations)) : [],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// A caller's own `glossary` — a record's own terms, not every term in the
|
|
76
|
+
// package — always wins, whether the default binding is on or off; `md`
|
|
77
|
+
// and `mdInline` differ only in which core renderer they wrap, `mdStrip`
|
|
78
|
+
// has nothing to bind, since plain text carries no highlighting.
|
|
79
|
+
function bindGlossary(renderer) {
|
|
80
|
+
return (text, opts = {}) =>
|
|
81
|
+
renderer(text, { ...opts, glossary: 'glossary' in opts ? opts.glossary : defaultGlossary.value })
|
|
82
|
+
}
|
|
83
|
+
const md = bindGlossary(coreMd)
|
|
84
|
+
const mdInline = bindGlossary(coreMdInline)
|
|
85
|
+
const mdStrip = coreMdStrip
|
|
86
|
+
|
|
87
|
+
/** `mdStrip(tr(entity, id).name ?? record[fallback] ?? id)` — the one label shape every site wrote. */
|
|
88
|
+
function labelOf(entity, id, { fallback = 'internal_name' } = {}) {
|
|
89
|
+
if (!id) return ''
|
|
90
|
+
const name = tr(entity, id).name
|
|
91
|
+
if (name) return mdStrip(name)
|
|
92
|
+
const record = coreById(entity).value.get(id)
|
|
93
|
+
return mdStrip(record?.[fallback] ?? id)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let englishReady = null
|
|
97
|
+
/** Loads `defaultLanguage` for every entity in `eager`, once. */
|
|
98
|
+
function loadEnglish() {
|
|
99
|
+
if (!englishReady) {
|
|
100
|
+
englishReady = Promise.all(eagerEntities.map((name) => loadTranslations(name, defaultLanguage)))
|
|
101
|
+
}
|
|
102
|
+
return englishReady
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Cached the way `byId` is: a site reads `entity('items')`/`index('items')`
|
|
106
|
+
// from more than one place, and each should see the same ref rather than
|
|
107
|
+
// a fresh filter pass and a fresh Map per call.
|
|
108
|
+
const entityCache = new Map()
|
|
109
|
+
/** `entityRef(name)`'s records, narrowed by `visible[name]` when one is declared; `null` until loaded. */
|
|
110
|
+
function entity(name) {
|
|
111
|
+
let ref = entityCache.get(name)
|
|
112
|
+
if (!ref) {
|
|
113
|
+
const source = entityRef(name)
|
|
114
|
+
const predicate = visible[name]
|
|
115
|
+
ref = computed(() => {
|
|
116
|
+
if (source.value === null) return null
|
|
117
|
+
return predicate ? source.value.filter(predicate) : source.value
|
|
118
|
+
})
|
|
119
|
+
entityCache.set(name, ref)
|
|
120
|
+
}
|
|
121
|
+
return ref
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const indexCache = new Map()
|
|
125
|
+
/** A `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId`. */
|
|
126
|
+
function index(name, key = 'id') {
|
|
127
|
+
const cacheKey = `${name}.${key}`
|
|
128
|
+
let ref = indexCache.get(cacheKey)
|
|
129
|
+
if (!ref) {
|
|
130
|
+
const list = entity(name)
|
|
131
|
+
ref = computed(() => new Map((list.value ?? []).map((record) => [record[key], record])))
|
|
132
|
+
indexCache.set(cacheKey, ref)
|
|
133
|
+
}
|
|
134
|
+
return ref
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
tr, md, mdInline, mdStrip, loadEnglish, labelOf, entity, index,
|
|
139
|
+
availableLanguages, loadTranslations, translations,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
// package.json is npm metadata, not an entity — exclude it from the glob.
|
|
4
|
+
const entityModules = import.meta.glob(['@inventory-data/*.json', '!**/package.json'])
|
|
5
|
+
const manifestModules = import.meta.glob('@inventory-data/manifest.json', { eager: true })
|
|
6
|
+
|
|
7
|
+
// One file per entity per language; a file is simply absent when that entity
|
|
8
|
+
// has no translation in that language. Left lazy (unlike entityModules'
|
|
9
|
+
// eager-in-spirit-but-actually-per-call loaders, this one also needs a
|
|
10
|
+
// reactive cache — see below) so a build only bundles what a page visit
|
|
11
|
+
// actually requests, not every language of every entity.
|
|
12
|
+
const translationModules = import.meta.glob('@inventory-data/translations/*.json')
|
|
13
|
+
|
|
14
|
+
const manifest = Object.values(manifestModules)[0]?.default ?? {}
|
|
15
|
+
|
|
16
|
+
const entityLoaders = {}
|
|
17
|
+
for (const [path, load] of Object.entries(entityModules)) {
|
|
18
|
+
const name = path.split('/').pop().replace(/\.json$/, '')
|
|
19
|
+
if (name !== 'manifest') {
|
|
20
|
+
entityLoaders[name] = load
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function translationLoader(entity, lang) {
|
|
25
|
+
const suffix = `/translations/${entity}.${lang}.json`
|
|
26
|
+
const path = Object.keys(translationModules).find((k) => k.endsWith(suffix))
|
|
27
|
+
return path ? translationModules[path] : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `${entity}.${lang}` → record map, or `{}` once a miss has been resolved.
|
|
31
|
+
// A plain `await import()` would already be deduped by the module system;
|
|
32
|
+
// this cache exists so a component reading through `translations`/`tr` -
|
|
33
|
+
// synchronous, not awaited - re-renders once a lazy `loadTranslations` call
|
|
34
|
+
// elsewhere resolves.
|
|
35
|
+
const translationCache = ref({})
|
|
36
|
+
|
|
37
|
+
export function useDataPackage() {
|
|
38
|
+
return {
|
|
39
|
+
manifest,
|
|
40
|
+
languages: manifest.languages ?? ['en'],
|
|
41
|
+
entityNames: Object.keys(entityLoaders).sort(),
|
|
42
|
+
async loadEntity(name) {
|
|
43
|
+
const load = entityLoaders[name]
|
|
44
|
+
if (!load) {
|
|
45
|
+
throw new Error(`Unknown entity "${name}" in the data package`)
|
|
46
|
+
}
|
|
47
|
+
const module = await load()
|
|
48
|
+
return module.default ?? module
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/** Languages `translations/<entity>.<lang>.json` actually exists for. */
|
|
52
|
+
availableLanguages(entity) {
|
|
53
|
+
const prefix = `/translations/${entity}.`
|
|
54
|
+
return Object.keys(translationModules)
|
|
55
|
+
.filter((k) => k.includes(prefix))
|
|
56
|
+
.map((k) => k.slice(k.lastIndexOf(prefix) + prefix.length, -'.json'.length))
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lazy-loads `translations/<entity>.<lang>.json` (a map of record id to
|
|
61
|
+
* that record's translated fields); `{}` when the file does not exist.
|
|
62
|
+
* Cached, so calling it again for the same pair is free.
|
|
63
|
+
*/
|
|
64
|
+
async loadTranslations(entity, lang) {
|
|
65
|
+
const key = `${entity}.${lang}`
|
|
66
|
+
if (translationCache.value[key]) return translationCache.value[key]
|
|
67
|
+
const load = translationLoader(entity, lang)
|
|
68
|
+
let data = {}
|
|
69
|
+
if (load) {
|
|
70
|
+
try {
|
|
71
|
+
data = (await load()).default ?? {}
|
|
72
|
+
} catch {
|
|
73
|
+
data = {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
translationCache.value = { ...translationCache.value, [key]: data }
|
|
77
|
+
return data
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/** Already-loaded translations for one entity/lang; `{}` if not loaded yet (or absent). */
|
|
81
|
+
translations(entity, lang) {
|
|
82
|
+
return translationCache.value[`${entity}.${lang}`] ?? {}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One record's translated fields, falling back to `fallbackLang` (default
|
|
87
|
+
* `'en'`) then to `{}`. Reads only what is already loaded — call
|
|
88
|
+
* `loadTranslations` first for each language a page may need.
|
|
89
|
+
*/
|
|
90
|
+
tr(entity, id, lang, fallbackLang = 'en') {
|
|
91
|
+
return (
|
|
92
|
+
translationCache.value[`${entity}.${lang}`]?.[id] ??
|
|
93
|
+
translationCache.value[`${entity}.${fallbackLang}`]?.[id] ??
|
|
94
|
+
{}
|
|
95
|
+
)
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|