@museumwnf/viewer-core 2.1.0 → 2.2.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/README.md CHANGED
@@ -470,14 +470,14 @@ under the main partner it belongs to.
470
470
  | --- | --- |
471
471
  | `groupByCountry(records, { label, tier?, order = 'asc' })` | `[{ country, label, main, associated }]`, one entry per distinct `country_id`, sorted by `label`. `label(country)` names a group's country, the same shape as a facet's `label(value)`. `tier` names the field that marks a main partner (the converged shape's `level`); a record is associated only when its tier value is a non-empty value other than `'partner'` (legacy values: `'associated_partner'`, `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean main. Without a `tier` every record is main, which is the DXA lists' plain grouping. Partners keep the order `records` was given in within each tier. |
472
472
  | `partnerHierarchy(partners)` | `{ children(id), parentOf(id), roots }` from `parent_id`: `children(id)` a partner's associated partners, `parentOf(id)` the main partner it belongs to (or null), `roots` every partner with no parent in the list or whose `parent_id` points outside it. |
473
- | `partnerView(partner, text, ctx)` | The view-model every rendering of one partner reads — `@museumwnf/viewer-layout`'s `PartnerPanel` in each of its variants: `{ id, type, name (inline HTML), plainName, city, country, location, logos: [{ url, alt, type }], pictures: [{ url, alt, caption, photographer, copyright }], description (block HTML), contact: { address (block HTML), phone, fax, email, website: { url, label } \| null, links: [{ url, label }] }, persons: [{ title, name, phone, fax, email }], hasContact, map: { latitude, longitude, zoom, label } \| null, itemCount, hidden, route, objectsRoute }`. `text` is the partner's translation in the language shown. `ctx` carries only what differs by family: `countryLabel(id)`, the renderers (`md`, `mdInline`, `mdStrip`, the package's by default), `route(partner)`, `objectsRoute(partner)` and `hidden(partner)` (a hidden partner keeps its name and loses both links). Contact persons come from `contact_persons`, in order, keeping those with a name or a title. A picture's caption is its `alt_text`. A website or link with no scheme gets `https://`; any scheme other than `http(s)` is dropped. |
473
+ | `partnerView(partner, text, ctx)` | The view-model every rendering of one partner reads — `@museumwnf/viewer-layout`'s `PartnerPanel` in each of its variants: `{ id, type, name (inline HTML), plainName, city, country, location, logos: [{ url, alt, type }], pictures: [{ url, alt, caption, photographer, copyright }], description (block HTML), contact: { address (block HTML), phone, fax, email, website: { url, label } \| null, links: [{ url, label }] }, persons: [{ title, name, phone, fax, email }], hasContact, map: { latitude, longitude, zoom, label } \| null, itemCount, hidden, route, objectsRoute }`. `text` is the partner's translation in the language shown. `ctx` carries only what differs by family: `countryLabel(id)`, the renderers (`md`, `mdInline`, `mdStrip`, the package's by default), `route(partner)`, `objectsRoute(partner)` and `hidden(partner)` (a hidden partner keeps its name and loses both links). Contact persons come from `contact_persons`, in order, keeping those with a name or a title (the legacy `contact_person_1`/`_2` pair is not read). A picture's caption is its `alt_text`. A website or link with no scheme gets `https://`; any scheme other than `http(s)` is dropped. |
474
474
 
475
475
  ### Conventions
476
476
 
477
477
  | Export | Meaning |
478
478
  | --- | --- |
479
479
  | `useSection()` | the `meta.section` of the current route — see Routing |
480
- | `useFeaturedRecord(entity, { withImage = true, seed })` | one record at random for a landing page's spotlight, among those with an image; null until the entity is loaded; `seed` pins the pick |
480
+ | `useFeaturedRecord(entity, { withImage = true, seed, filter })` | one record at random for a landing page's spotlight, among those with an image; null until the entity is loaded; `seed` pins the pick; `filter(record)` applies a site's own visible rule first |
481
481
  | `sectionMeta(chrome = [])` | returns `meta(section, ...entities) => ({ section, entities: [...chrome, ...entities] })` — a route's `meta` in one call, `chrome` being the entities every page of the site loads regardless of which one it is |
482
482
  | `mwnfLinks` | the portal and sibling-site addresses the four DXA configs each repeat under `links` — frozen, spread into a site's own `links` rather than retyped |
483
483
 
@@ -638,6 +638,49 @@ export const { tr, md, mdInline, mdStrip, labelOf, loadEnglish } = catalogue
638
638
  | `index(name, key = 'id')` | a `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId` |
639
639
  | `availableLanguages`, `loadTranslations`, `translations` | the package's own, re-exported so a site reads everything through the one object |
640
640
 
641
+ ### The catalogue data layer
642
+
643
+ `useCatalogue` is `useCatalogueData` plus what a catalogue site — a
644
+ standalone product, or a new product scaffolded from `website-template` —
645
+ otherwise re-types for itself: the catalogue entities, their lookups, the
646
+ labels, the routes and the one result row. A site calls it once, in its
647
+ `data.js`, and keeps next to it only what is its own (a scope rule, a
648
+ collection tree, its legacy address mappings).
649
+
650
+ ```js
651
+ import { useCatalogue } from '@museumwnf/viewer-core'
652
+
653
+ export const data = useCatalogue({
654
+ eager: ['items', 'countries', 'partners', 'dynasties', 'glossary'],
655
+ visible: { items: (item) => item.display_status !== 'N' },
656
+ })
657
+ data.loadEnglish()
658
+
659
+ export const { items, itemRow, countryLabel, tr, md, mdInline } = data
660
+ ```
661
+
662
+ | Export | Meaning |
663
+ | --- | --- |
664
+ | `useCatalogue(options)` | `useCatalogueData(options)`'s own return, plus: `items`, `countries`, `partners`, `dynasties` (the visible records, `null` until loaded); `itemById` (every item, the visible rule not applied — for a page that deliberately shows a hidden one), `partnerById`, `countryById`, `dynastyById`; `itemLabel(item)`, `countryLabel(id)`, `partnerLabel(id)`, `dynastyLabel(id)` (plain text, `''` for nothing); `itemRoute(item)`, `partnerRoute(partner)` (the `item`/`partner` routes by name); `itemRow(item, meta = ['country', 'dates'])` — the row `RecordList`/`RecordGrid`/`RelatedRecords` share, `meta` naming what follows the name in order: `'country'`, `'dates'`, `'location'`, `'dynasties'`, `'holder'` (the partner, only when the package carries it), or a function `(item, text) => string` |
665
+ | `CATALOGUE_PAGE_SIZE`, `CATALOGUE_DATE_MODE` | `20` and `'overlap'`: the standalone pages' size and date rule (decision D5) — named apart from `/dxa`'s `PAGE_SIZE`/`DATE_MODE` (`9`, `'contain'`), which a DXA site imports next to this root |
666
+ | `objectsAndMonumentsSummary({ matching, t })` | legacy's "[N objects, M monuments]", a results spec's `summary` |
667
+
668
+ The field search of the legacy `database.php` form — keyword rows each
669
+ naming a field, folded with AND/OR — around `useKeywordIndex`'s `fields`
670
+ grammar:
671
+
672
+ | Export | Meaning |
673
+ | --- | --- |
674
+ | `searchFields({ dynastyLabel })` | the haystack of each field, as legacy's form searched it: `keyword`, `name`, `location`, `provenance`, `patron`, `artist`, `material`, `other`, and `dynasty` when a `dynastyLabel` is given |
675
+ | `searchFieldOptions(fields)` | `[{ key, label }]` (entry names), in legacy's order, for the fields `fields` carries — `SearchFormView`'s `fields` |
676
+ | `useSearchFieldOptions(fields)`, `searchFieldLabel(key, t)` | the same resolved, `[{ value, label }]`, for a select a site draws itself; one field's label |
677
+ | `searchRows(filters, count = 4)`, `searchRowKeys(count = 4)` | the keyword rows the query carries (`q`/`field`, then `qN`/`fieldN`/`opN`), and their query keys for a results spec's `keys` |
678
+ | `searchedFor(filters, t, { rows, extras })`, `searchSummary(ctx, options)` | the "Name: "bowl" · OR Location: "Cairo" · From 900 · Language: FR" line — `extras`, `(filters, t) => string`, append a site's own parts — and the results summary built on it (what was searched, or all items; then the count) |
679
+ | `useFieldSearch({ fields, entity = 'items', rows = 4, rank = 'hits', expand })` | one long-lived index (the glossary and country expansions by default), its search language following the query's `lang`; `narrow(list, filters)` is a results spec's `narrow` — the matches in rank order, kept to the scoped `list`. Pass `sort: false` to the spec. |
680
+
681
+ `useFeaturedRecord(entity, { filter })` narrows the candidates by a site's
682
+ own visible rule first: it reads the raw entity.
683
+
641
684
  ### Routing
642
685
 
643
686
  The convention, applied by the router every website shares:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,128 @@
1
+ import { useCatalogueData } from '../composables/useCatalogueData.js'
2
+ import { byId } from '../composables/useEntities.js'
3
+
4
+ // The catalogue data layer: what a website built on the generic entries —
5
+ // the standalone products, and every new product scaffolded from
6
+ // `website-template` — re-typed around `useCatalogueData` for itself, three
7
+ // times (inventory-app#2037): the entity refs and lookups a catalogue reads,
8
+ // the label and route helpers, the one result row every list shows, the
9
+ // "[N objects, M monuments]" count. A site keeps one instance of it, in its
10
+ // `data.js`, next to what is genuinely its own (a scope rule, a collection
11
+ // tree, its legacy address mappings).
12
+ //
13
+ // The DXA families have their own layer (`@museumwnf/viewer-core/dxa`), over
14
+ // the same `useCatalogueData`; nothing here reads it.
15
+
16
+ /** Twenty rows a page: the legacy standalone pages' own size. */
17
+ export const CATALOGUE_PAGE_SIZE = 20
18
+
19
+ /**
20
+ * The standalone date rule (decision D5): a record overlapping the window
21
+ * counts, a record with one date is tolerated. The DXA families contain
22
+ * (`@museumwnf/viewer-core/dxa`'s `DATE_MODE`); a site names its rule once.
23
+ */
24
+ export const CATALOGUE_DATE_MODE = 'overlap'
25
+
26
+ /**
27
+ * The website's catalogue: everything `useCatalogueData` returns, plus the
28
+ * catalogue entities, their lookups, the labels, the routes and the result
29
+ * row. Takes `useCatalogueData`'s own options (`eager`, `defaultLanguage`,
30
+ * `visible`, `glossary`); called once, at the top of the site's `data.js`.
31
+ *
32
+ * - `items`, `countries`, `partners`, `dynasties`: the visible records
33
+ * (`visible` applies), `null` until loaded.
34
+ * - `itemById`: every item, the visible rule not applied — a page that
35
+ * deliberately shows a hidden item (a timeline's illustration) reads it.
36
+ * `partnerById`, `countryById`, `dynastyById`: the visible records.
37
+ * - `itemLabel(item)`, `countryLabel(id)`, `partnerLabel(id)`,
38
+ * `dynastyLabel(id)`: plain text, `''` for nothing.
39
+ * - `itemRoute(item)`, `partnerRoute(partner)`: the `item` and `partner`
40
+ * routes by name.
41
+ * - `itemRow(item, meta)`: the row `RecordList`/`RecordGrid`/
42
+ * `RelatedRecords` share. `meta` lists what follows the name, in order:
43
+ * `'country'`, `'dates'`, `'location'`, `'dynasties'`, `'holder'` (the
44
+ * partner's name, only when the package carries the partner, so a label
45
+ * is never an id), or a function `(item, text) => string`.
46
+ */
47
+ export function useCatalogue(options = {}) {
48
+ const data = useCatalogueData(options)
49
+ const { tr, labelOf, mdInline } = data
50
+
51
+ const items = data.entity('items')
52
+ const countries = data.entity('countries')
53
+ const partners = data.entity('partners')
54
+ const dynasties = data.entity('dynasties')
55
+
56
+ const itemById = byId('items')
57
+ const partnerById = data.index('partners')
58
+ const countryById = data.index('countries')
59
+ const dynastyById = data.index('dynasties')
60
+
61
+ const itemLabel = (item) => (item ? labelOf('items', item.id) : '')
62
+ const countryLabel = (id) => (id ? labelOf('countries', id) : '')
63
+ const partnerLabel = (id) => (id ? labelOf('partners', id) : '')
64
+ const dynastyLabel = (id) => (id ? labelOf('dynasties', id) : '')
65
+
66
+ const itemRoute = (item) => ({ name: 'item', params: { id: item.id } })
67
+ const partnerRoute = (partner) => ({ name: 'partner', params: { id: partner.id } })
68
+
69
+ const META = {
70
+ country: (item) => countryLabel(item.country_id),
71
+ dates: (item, text) => text.dates,
72
+ location: (item, text) => text.location,
73
+ dynasties: (item) => (item.dynasty_ids ?? []).map(dynastyLabel).filter(Boolean).join(', '),
74
+ holder: (item) => (partnerById.value.has(item.partner_id) ? partnerLabel(item.partner_id) : ''),
75
+ }
76
+
77
+ function itemRow(item, meta = ['country', 'dates']) {
78
+ const text = tr('items', item.id)
79
+ return {
80
+ id: item.id,
81
+ image: item.images?.[0]?.url ?? '',
82
+ imageAlt: itemLabel(item),
83
+ name: mdInline(text.name ?? item.internal_name ?? item.id),
84
+ meta: meta
85
+ .map((part) => (typeof part === 'function' ? part(item, text) : META[part]?.(item, text)))
86
+ .filter(Boolean),
87
+ badge: item.type ?? '',
88
+ to: itemRoute(item),
89
+ }
90
+ }
91
+
92
+ return {
93
+ ...data,
94
+ items,
95
+ countries,
96
+ partners,
97
+ dynasties,
98
+ itemById,
99
+ partnerById,
100
+ countryById,
101
+ dynastyById,
102
+ itemLabel,
103
+ countryLabel,
104
+ partnerLabel,
105
+ dynastyLabel,
106
+ itemRoute,
107
+ partnerRoute,
108
+ itemRow,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Legacy's count, "[N objects, M monuments]", over what matched: a results
114
+ * spec's `summary`. A monument is a record of type `monument`; everything
115
+ * else counts as an object.
116
+ */
117
+ export function objectsAndMonumentsSummary({ matching, t }) {
118
+ let objects = 0
119
+ let monuments = 0
120
+ for (const item of matching ?? []) {
121
+ if (item.type === 'monument') monuments++
122
+ else objects++
123
+ }
124
+ return [
125
+ { label: t('catalogue.results.objectsFound'), count: objects },
126
+ { label: t('catalogue.results.monumentsFound'), count: monuments },
127
+ ]
128
+ }
@@ -0,0 +1,176 @@
1
+ import { computed, ref } from 'vue'
2
+ import { useI18n } from '../i18n/index.js'
3
+ import { combineExpansions, countryExpansion, glossaryExpansion, useKeywordIndex } from './keywordIndex.js'
4
+
5
+ // The field search of the legacy `database.php` form — keyword rows, each
6
+ // naming a field, folded with AND/OR — which the three standalone products
7
+ // each wired for themselves (inventory-app#2037): the fields' haystacks, the
8
+ // field options and their labels, the rows the query string carries, the
9
+ // "searched for" line, the index. `useKeywordIndex`'s `fields` grammar is
10
+ // the engine; this is the rest of the machinery around it, written once.
11
+
12
+ /**
13
+ * What each field of the form searches, field for field as legacy's form
14
+ * did. `text` is the record's translation in the search language, English
15
+ * behind it. `keyword` and `location` also carry the record's `country_id`:
16
+ * `countryExpansion` turns a typed country name into its id, and a field is
17
+ * only found by that expansion if its haystack carries the id.
18
+ *
19
+ * `dynastyLabel(id)` turns on the `dynasty` field — Islamic Art's form has
20
+ * it, Baroque Art's and Sharing History's do not.
21
+ */
22
+ export function searchFields({ dynastyLabel } = {}) {
23
+ const fields = {
24
+ keyword: (item, text) => [
25
+ text.name ?? item.internal_name, text.alternate_name, text.description, item.country_id, ...(item.tags ?? []),
26
+ ],
27
+ name: (item, text) => text.name ?? item.internal_name,
28
+ location: (item, text) => [text.location, item.country_id],
29
+ provenance: (item, text) => text.provenance,
30
+ patron: (item, text) => text.patrons ?? text.initial_owner,
31
+ artist: (item, text) => [...(item.artist_names ?? []), text.architects],
32
+ material: (item, text) => text.type,
33
+ // The catch-all across the descriptive fields the others leave out.
34
+ other: (item, text) => [
35
+ text.description, text.method_for_datation, text.method_for_provenance, text.obtention,
36
+ text.bibliography, text.workshop, text.scriber, text.binding_desc, text.history,
37
+ ],
38
+ }
39
+ if (dynastyLabel) fields.dynasty = (item) => (item.dynasty_ids ?? []).map(dynastyLabel)
40
+ return fields
41
+ }
42
+
43
+ // Every field of the form, in legacy's order, with its entry name.
44
+ const FIELD_OPTIONS = [
45
+ { key: 'keyword', label: 'catalogue.field.keywords' },
46
+ { key: 'name', label: 'sheet.field.name' },
47
+ { key: 'location', label: 'sheet.field.location' },
48
+ { key: 'provenance', label: 'sheet.field.provenance' },
49
+ { key: 'dynasty', label: 'catalogue.facet.periodDynasty' },
50
+ { key: 'patron', label: 'catalogue.field.patron' },
51
+ { key: 'artist', label: 'catalogue.field.artist' },
52
+ { key: 'material', label: 'catalogue.field.material' },
53
+ { key: 'other', label: 'catalogue.field.other' },
54
+ ]
55
+
56
+ /**
57
+ * The options of the form's field select — `[{ key, label }]`, `label` an
58
+ * entry name, in legacy's order — for the fields `fields` carries: what
59
+ * `SearchFormView`'s `fields` takes.
60
+ */
61
+ export function searchFieldOptions(fields) {
62
+ return FIELD_OPTIONS.filter((option) => option.key in fields)
63
+ }
64
+
65
+ /** One field's label, resolved: `key` is what the query string carries. */
66
+ export function searchFieldLabel(key, t) {
67
+ switch (key) {
68
+ case 'keyword': return t('catalogue.field.keywords')
69
+ case 'name': return t('sheet.field.name')
70
+ case 'location': return t('sheet.field.location')
71
+ case 'provenance': return t('sheet.field.provenance')
72
+ case 'dynasty': return t('catalogue.facet.periodDynasty')
73
+ case 'patron': return t('catalogue.field.patron')
74
+ case 'artist': return t('catalogue.field.artist')
75
+ case 'material': return t('catalogue.field.material')
76
+ case 'other': return t('catalogue.field.other')
77
+ default: return key
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The same options resolved, `[{ value, label }]`, for a select a site draws
83
+ * itself — the results page's refine row.
84
+ */
85
+ export function useSearchFieldOptions(fields) {
86
+ const { t } = useI18n()
87
+ return computed(() => searchFieldOptions(fields).map((option) => ({ value: option.key, label: searchFieldLabel(option.key, t) })))
88
+ }
89
+
90
+ /**
91
+ * The keyword rows the query string carries: `q`/`field` for the first
92
+ * (`SearchFormView`'s own, unnumbered), `qN`/`fieldN`/`opN` for the next —
93
+ * three from the entrance, a fourth for the results page's refine row.
94
+ * A row with no field searches `keyword`; with no operator, AND.
95
+ */
96
+ export function searchRows(filters, count = 4) {
97
+ const rows = []
98
+ for (let n = 1; n <= count; n++) {
99
+ rows.push({
100
+ keyword: n === 1 ? filters.q : filters[`q${n}`],
101
+ field: (n === 1 ? filters.field : filters[`field${n}`]) || 'keyword',
102
+ cond: n === 1 ? 'AND' : filters[`op${n}`] || 'AND',
103
+ })
104
+ }
105
+ return rows
106
+ }
107
+
108
+ /** The query keys `searchRows` reads, for a results spec's `keys`. */
109
+ export function searchRowKeys(count = 4) {
110
+ const keys = ['q', 'field']
111
+ for (let n = 2; n <= count; n++) keys.push(`q${n}`, `field${n}`, `op${n}`)
112
+ return keys
113
+ }
114
+
115
+ /**
116
+ * What was searched, spelled out: `Name: "bowl"`, `AND Location: "Cairo"`,
117
+ * the years, the search language, then the site's own `extras` —
118
+ * `(filters, t) => string` each, an empty string for nothing. Legacy's
119
+ * recap line, never just a count, so a visitor who scrolls past the form
120
+ * still reads what for.
121
+ */
122
+ export function searchedFor(filters, t, { rows = 4, extras = [] } = {}) {
123
+ const parts = searchRows(filters, rows)
124
+ .filter((row) => row.keyword)
125
+ .map((row, i) => `${i > 0 ? `${row.cond} ` : ''}${searchFieldLabel(row.field, t)}: "${row.keyword}"`)
126
+ if (filters.from) parts.push(`${t('catalogue.filter.from')} ${filters.from}`)
127
+ if (filters.to) parts.push(`${t('catalogue.filter.to')} ${filters.to}`)
128
+ if (filters.lang) parts.push(`${t('catalogue.search.language')}: ${String(filters.lang).toUpperCase()}`)
129
+ for (const extra of extras) {
130
+ const part = extra(filters, t)
131
+ if (part) parts.push(part)
132
+ }
133
+ return parts
134
+ }
135
+
136
+ /**
137
+ * The results page's summary: what was searched (`searchedFor`), or "all
138
+ * items" for an empty query, then the count. `options` is `searchedFor`'s.
139
+ */
140
+ export function searchSummary({ filters, t, pageInfo }, options = {}) {
141
+ const parts = searchedFor(filters, t, options)
142
+ return [
143
+ { label: t('catalogue.search.summary'), value: parts.length ? parts.join(' · ') : t('catalogue.results.allItems') },
144
+ { label: t('catalogue.results.itemsFound'), count: pageInfo.total },
145
+ ]
146
+ }
147
+
148
+ /**
149
+ * The index behind the results page: one long-lived `useKeywordIndex` over
150
+ * `entity` (`fields` grammar, legacy's `rank: 'hits'` order, the glossary
151
+ * and country expansions of `database_results.php`), its search language
152
+ * kept in step with the query's `lang`. `narrow(list, filters)` is a results
153
+ * spec's `narrow`: the matches in rank order, kept to what `list` (the
154
+ * records the spec's `scope` let through) carries. Pass `sort: false` to the
155
+ * spec, so the rank order stands.
156
+ */
157
+ export function useFieldSearch({
158
+ fields,
159
+ entity = 'items',
160
+ rows = 4,
161
+ rank = 'hits',
162
+ expand = combineExpansions(glossaryExpansion(), countryExpansion()),
163
+ } = {}) {
164
+ const language = ref('')
165
+ const index = useKeywordIndex(entity, { grammar: 'fields', fields, language, rank, expand })
166
+
167
+ function narrow(list, filters) {
168
+ language.value = filters.lang || ''
169
+ const matches = index.search(searchRows(filters, rows))
170
+ if (!list) return matches
171
+ const allowed = new Set(list.map((record) => record.id))
172
+ return matches.filter((record) => allowed.has(record.id))
173
+ }
174
+
175
+ return { narrow, index }
176
+ }
@@ -108,13 +108,16 @@ function seeded(seed) {
108
108
  * among those with an image when `withImage` is set (the default), among
109
109
  * all when none has one. Null until the entity is loaded. The pick is made
110
110
  * once per visit and again only when the records change; `seed` makes it
111
- * deterministic.
111
+ * deterministic. `filter(record)` narrows the candidates first — the site's
112
+ * own visible rule, since this reads the raw entity (Sharing History's
113
+ * hidden illustrations, `display_status: 'N'`, must never be picked).
112
114
  */
113
- export function useFeaturedRecord(entity, { withImage = true, seed, images = 'images' } = {}) {
115
+ export function useFeaturedRecord(entity, { withImage = true, seed, images = 'images', filter } = {}) {
114
116
  const records = entityRef(entity)
115
117
  const random = seed == null ? Math.random : seeded(seed)
116
118
  return computed(() => {
117
- const all = toValue(records) ?? []
119
+ const loaded = toValue(records)
120
+ const all = filter ? (loaded ?? []).filter(filter) : loaded ?? []
118
121
  if (all.length === 0) return null
119
122
  const pool = withImage ? all.filter((record) => (record?.[images]?.length ?? 0) > 0) : all
120
123
  const candidates = pool.length > 0 ? pool : all
package/src/index.js CHANGED
@@ -1,6 +1,13 @@
1
1
  export { createViewer } from './createViewer.js'
2
2
  export { createStandardViewer } from './createStandardViewer.js'
3
3
  export { useCatalogueData } from './composables/useCatalogueData.js'
4
+ export {
5
+ CATALOGUE_DATE_MODE, CATALOGUE_PAGE_SIZE, objectsAndMonumentsSummary, useCatalogue,
6
+ } from './catalogue/catalogue.js'
7
+ export {
8
+ searchFieldLabel, searchFieldOptions, searchFields, searchRowKeys, searchRows, searchSummary, searchedFor,
9
+ useFieldSearch, useSearchFieldOptions,
10
+ } from './catalogue/fieldSearch.js'
4
11
  export { useDataPackage } from './composables/useDataPackage.js'
5
12
  export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'
6
13
  export { resolveRecordLanguage, useRecordLanguage } from './composables/useRecordLanguage.js'
@@ -118,14 +118,10 @@ const byDisplayOrder = (a, b) => (a?.display_order ?? 0) - (b?.display_order ??
118
118
 
119
119
  /**
120
120
  * The contact persons in legacy order (person 1 first), keeping only those
121
- * with a name or a title — legacy printed a person block only then. Reads
122
- * `contact_persons`; a package built before that list existed carries
123
- * `contact_person_1`/`_2` instead (inventory-app#2007 removes them).
121
+ * with a name or a title — legacy printed a person block only then.
124
122
  */
125
123
  function contactPersons(partner) {
126
- const list = Array.isArray(partner?.contact_persons)
127
- ? partner.contact_persons
128
- : [partner?.contact_person_1, partner?.contact_person_2]
124
+ const list = Array.isArray(partner?.contact_persons) ? partner.contact_persons : []
129
125
  return list
130
126
  .filter((person) => person && (person.name || person.title))
131
127
  .map((person) => ({