@museumwnf/viewer-core 2.0.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
@@ -11,6 +11,29 @@ Texts are handled here, without an i18n library: see [Texts](#texts). Nothing in
11
11
  the platform depends on `vue-i18n` any more, and this package no longer asks for
12
12
  it.
13
13
 
14
+ ## Entry points and layers
15
+
16
+ Each entry point belongs to one layer of the platform's
17
+ [architecture reference](https://github.com/museumwithnofrontiers/inventory-app/issues/1510), which says what goes where across
18
+ all the packages:
19
+
20
+ | Entry | Layer | Holds |
21
+ | --- | --- | --- |
22
+ | `@museumwnf/viewer-core` | engine and data layer | the application factory, the router and the texts runtime; the **data layer** — how a site reads its data package ([records](#records-useentities-the-standard-way-a-website-reads-them), [a website's data composable](#a-websites-data-composable), [`useDataPackage`](#usedatapackage--data-access-the-only-allowed-way-to-read-the-data-package)); the engines of list pages, timelines and record pages; the view-model builders the blocks of `@museumwnf/viewer-layout` are fed with ([record pages](#record-pages), [collection trees](#collection-trees), [partners](#partners)) |
23
+ | `@museumwnf/viewer-core/dxa` | DXA family layer | the data composables of the gallery and the exhibition families ([below](#museumwnfviewer-coredxa)) |
24
+ | `@museumwnf/viewer-core/legacy` | legacy address mapping | pure functions decoding `backward_compatibility` for legacy URL shapes |
25
+ | `@museumwnf/viewer-core/testing` | a website's tests | the shared smoke-test kit ([Testing a website](#testing-a-website)) |
26
+ | `@museumwnf/viewer-core/vite` | a website's build | `defineViewerConfig` |
27
+
28
+ The root is generic: every kind of website reads its data through it — the
29
+ standalone products, the scaffold of `website-template`, and the DXA
30
+ family's own composables. A composable only the galleries or the
31
+ exhibitions use belongs under `/dxa`, never in the root. The dependency runs
32
+ one way: the root, `/legacy`, `/testing` and `/vite` never import from
33
+ `/dxa`, and `/dxa` builds only on what the root and `/legacy` publish. A
34
+ function that moves between entries keeps its old export as an alias until
35
+ the websites have moved; a major release then removes it.
36
+
14
37
  ## Install
15
38
 
16
39
  Published to npmjs (`registry.npmjs.org`), publicly — no authentication needed
@@ -447,13 +470,14 @@ under the main partner it belongs to.
447
470
  | --- | --- |
448
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. |
449
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 (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. |
450
474
 
451
475
  ### Conventions
452
476
 
453
477
  | Export | Meaning |
454
478
  | --- | --- |
455
479
  | `useSection()` | the `meta.section` of the current route — see Routing |
456
- | `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 |
457
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 |
458
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 |
459
483
 
@@ -494,16 +518,21 @@ needs it; the standalone sites never import it.
494
518
 
495
519
  ### `@museumwnf/viewer-core/dxa`
496
520
 
497
- The gallery/exhibition-pair composables the four live DXA sites each wrote
498
- for themselves — `useCollection`/`useGalleryData`, the timeline, the
499
- partner specs and the item sheet — byte-identical within each pair
500
- (carpets/amulets — a gallery; the-use-of-colours-in-art/water-in-islam — an
501
- exhibition). Promoted here per family, never as one composable that papers
502
- over the two: the cross-family diff is real behaviour (the exhibition
503
- shape's per-language-build 404 gate, its own local-vs-country timeline
504
- switch, its manifest-driven citation line), confirmed file by file against
505
- both pairs' `origin/main` (epic metanull/inventory-app#1730). A separate
506
- entry point because only a gallery/exhibition site needs it.
521
+ This entry is the data half of the DXA family layer; the family's pages are
522
+ in `@museumwnf/viewer-layout/dxa`. The galleries are one site with different
523
+ data, and so are the exhibitions — the legacy served each family from one
524
+ application — so what is the same on every site of a family lives here
525
+ once instead of in each site.
526
+
527
+ What may go here: a composable or a constant that only the gallery or the
528
+ exhibition family uses, and that is the same on every site of that family,
529
+ including the data instance each site of the family builds from these
530
+ composables. Each family keeps its own composables, never one that papers
531
+ over the two: the difference between the families is real behaviour (the
532
+ exhibition's per-language-build 404 gate, its own local-vs-country timeline
533
+ switch, its manifest-driven citation line). A difference between two sites
534
+ of one family is a `config` value, not a copy. Nothing outside `/dxa`
535
+ imports from it; it builds only on what the root and `/legacy` publish.
507
536
 
508
537
  A site composes its own data/catalogue/timeline/partner/sheet module from
509
538
  these, threading the `data` object one family's
@@ -609,6 +638,49 @@ export const { tr, md, mdInline, mdStrip, labelOf, loadEnglish } = catalogue
609
638
  | `index(name, key = 'id')` | a `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId` |
610
639
  | `availableLanguages`, `loadTranslations`, `translations` | the package's own, re-exported so a site reads everything through the one object |
611
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
+
612
684
  ### Routing
613
685
 
614
686
  The convention, applied by the router every website shares:
@@ -724,12 +796,12 @@ The package exports shared smoke-test helpers at @museumwnf/viewer-core/testing:
724
796
  | checkRoutes(config, { names, legacyPaths }) | Verify every route has a name, no route is a catch-all, and expected names and legacy paths are present. Returns the list of problems found. |
725
797
  | checkSectionMeta(config) | Verify every route has a meta.section string (used by the menu to highlight the current page). Returns the list of problems found. |
726
798
  | checkTextsRendered(host, { namespaces }) | Match text keys in the rendered host against a regex like /\b(ns1\|ns2)\.[a-z]/i. Returns the list of namespaces found (empty if none). |
727
- | defineViewerConfig({ dataPackage, inline, plugins, root }) | Return a Vite config object the seven websites share: the @inventory-data alias, optimizeDeps to inline viewer packages, testTimeout of 60s, and the test environment. Exported from `@museumwnf/viewer-core/vite` for use in `vite.config.js` (separate entry because Vite's config loader runs under Node.js, which cannot parse `.vue` files; the `/testing` barrel reaches a `.vue` file). dataPackage is the npm package name; inline is an optional array of extra packages; plugins is the Vite plugins array (e.g. @vitejs/plugin-vue); root is the project root path (default `process.cwd()`), used to resolve the data package from the project root. |
799
+ | defineViewerConfig({ dataPackage, inline, plugins, root }) | Return the Vite config object every website shares: the @inventory-data alias, optimizeDeps to inline viewer packages, testTimeout of 60s, and the test environment. Exported from `@museumwnf/viewer-core/vite` for use in `vite.config.js` (separate entry because Vite's config loader runs under Node.js, which cannot parse `.vue` files; the `/testing` barrel reaches a `.vue` file). dataPackage is the npm package name; inline is an optional array of extra packages; plugins is the Vite plugins array (e.g. @vitejs/plugin-vue); root is the project root path (default `process.cwd()`), used to resolve the data package from the project root. |
728
800
  | checkOfferedLanguages(config) | Check that the website offers only languages with content, in the declared order, with labels on the switcher. Returns the list of problems found. |
729
801
 
730
802
  @museumwnf/viewer-core/testing is not imported by websites, only by their tests. A website test might be:
731
803
 
732
- \\\js
804
+ ```js
733
805
  import { checkOfferedLanguages, checkRoutes, checkSectionMeta, mountSite } from '@museumwnf/viewer-core/testing'
734
806
  import config from '../src/dataset.config.js'
735
807
  import { mergeMessages } from '@museumwnf/viewer-core'
@@ -747,7 +819,8 @@ describe('smoke test', () => {
747
819
  app.unmount()
748
820
  })
749
821
  })
750
- \\\
822
+ ```
823
+
751
824
  ## Licence
752
825
 
753
826
  This package is Content of the MWNF Website under the [MWNF legal
@@ -759,5 +832,10 @@ notice text also ships in this package as `LICENSE.md`.
759
832
  ## Release procedure
760
833
 
761
834
  1. Merge to `main` via PR (CI: tests + a downstream build of every website).
762
- 2. Create a GitHub release with tag `vX.Y.Z` — CI publishes to GitHub Packages; never publish from a laptop.
835
+ 2. Create a GitHub release with tag `vX.Y.Z` — CI publishes to npmjs through
836
+ trusted publishing; never publish from a laptop.
763
837
  3. Semver rules: **patch** = fix; **minor** = backward-compatible addition; **major** = breaking change (breaking = any change requiring an edit in a consuming website).
838
+
839
+ Websites receive a release through the platform's propagation run — see
840
+ [MAINTENANCE.md](https://github.com/museumwithnofrontiers/viewer-workflows/blob/main/MAINTENANCE.md)
841
+ in `viewer-workflows`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.0.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
@@ -45,8 +45,12 @@ export function useExhibitionSheet(data, config = {}) {
45
45
  { key: 'name', label: 'sheet.field.name', value: 'name' },
46
46
  { key: 'aka', label: 'sheet.field.alsoKnownAs', value: 'alternate_name' },
47
47
  { key: 'location', label: 'sheet.field.location', value: (c) => [c.text.location, labelOf('countries', c.record.country_id)].filter(Boolean).join(', ') },
48
- // A link to the partner's page, rendered by the wrapper's `museum` slot.
49
- { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : ''), render: 'custom' },
48
+ // The holding museum, rendered by the wrapper's `museum` slot: the item's
49
+ // holder text, then the partner it refers to (decision D3,
50
+ // inventory-app#2015). The value is the partner's id when the package
51
+ // carries the partner, otherwise the holder text, so the row stays for an
52
+ // item whose holder has no partner page.
53
+ { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : (c.text.holder ?? '')), render: 'custom' },
50
54
  { key: 'originalOwner', label: 'sheet.field.originalOwner', value: 'initial_owner' },
51
55
  { key: 'currentOwner', label: 'sheet.field.currentOwner', value: 'owner' },
52
56
  { key: 'date', label: 'sheet.field.date', value: 'dates' },
@@ -45,8 +45,12 @@ export function useGallerySheet(data, config = {}) {
45
45
  { key: 'name', label: 'sheet.field.name', value: 'name' },
46
46
  { key: 'aka', label: 'sheet.field.alsoKnownAs', value: 'alternate_name' },
47
47
  { key: 'location', label: 'sheet.field.location', value: (c) => [c.text.location, labelOf('countries', c.record.country_id)].filter(Boolean).join(', ') },
48
- // A link to the partner's page, rendered by the wrapper's `museum` slot.
49
- { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : ''), render: 'custom' },
48
+ // The holding museum, rendered by the wrapper's `museum` slot: the item's
49
+ // holder text, then the partner it refers to (decision D3,
50
+ // inventory-app#2015). The value is the partner's id when the package
51
+ // carries the partner, otherwise the holder text, so the row stays for an
52
+ // item whose holder has no partner page.
53
+ { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : (c.text.holder ?? '')), render: 'custom' },
50
54
  { key: 'originalOwner', label: 'sheet.field.originalOwner', value: 'initial_owner' },
51
55
  { key: 'currentOwner', label: 'sheet.field.currentOwner', value: 'owner' },
52
56
  { key: 'date', label: 'sheet.field.date', value: 'dates' },
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'
@@ -35,7 +42,7 @@ export {
35
42
  citation, relatedRecords, searchGlossary, timelineLinkFor, useGlossaryPopup, useRelatedRecords,
36
43
  } from './record/derivations.js'
37
44
  export { buildCollectionTree, collectionTreeFromThemes, useCollectionTree } from './record/collectionTree.js'
38
- export { groupByCountry, partnerHierarchy } from './record/partners.js'
45
+ export { groupByCountry, partnerHierarchy, partnerView } from './record/partners.js'
39
46
 
40
47
  // Conventions every website had written for itself.
41
48
  export {
@@ -10,6 +10,15 @@
10
10
  // nowhere: legacy nested an associated partner under the main partner it
11
11
  // belongs to, and the port flattened that into the country column instead.
12
12
  // `partnerHierarchy` is that relationship, read once.
13
+ //
14
+ // `partnerView` is what every rendering of one partner reads — a list line,
15
+ // the summary under an item's holder, the partner page — built once here so
16
+ // the contact block, the contact persons and the picture captions are read
17
+ // the same way on every site. A partner is one type in every exporter family
18
+ // (decision D4 of inventory-app#1699); what differs by family is only what
19
+ // `ctx` carries.
20
+
21
+ import { md as mdBlock, mdInline as mdInlineDefault, mdStrip as mdStripDefault } from '../i18n/markdown.js'
13
22
 
14
23
  /**
15
24
  * Whether `record` counts as a main (rather than associated) partner.
@@ -85,3 +94,132 @@ export function partnerHierarchy(partners) {
85
94
  roots,
86
95
  }
87
96
  }
97
+
98
+ // ── One partner, for rendering ───────────────────────────────────────────────
99
+
100
+ /**
101
+ * A partner's website or extra link, as an address a link can use. A value
102
+ * with no scheme (`www.example.org`, about a quarter of the packages' links)
103
+ * gets `https://` (decided 2026-09-25, inventory-app#2031); `http://` and
104
+ * `https://` are kept as they are. Any other scheme (`javascript:`, `data:`,
105
+ * `mailto:`) is not a website and yields `''`, so it never reaches an `href`.
106
+ * A port after a bare host (`host:8080`) is not a scheme.
107
+ */
108
+ function websiteUrl(value) {
109
+ const url = String(value ?? '').trim()
110
+ if (!url) return ''
111
+ if (/^https?:\/\//i.test(url)) return url
112
+ if (url.startsWith('//')) return `https:${url}`
113
+ if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(url)) return ''
114
+ return `https://${url}`
115
+ }
116
+
117
+ const byDisplayOrder = (a, b) => (a?.display_order ?? 0) - (b?.display_order ?? 0)
118
+
119
+ /**
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.
122
+ */
123
+ function contactPersons(partner) {
124
+ const list = Array.isArray(partner?.contact_persons) ? partner.contact_persons : []
125
+ return list
126
+ .filter((person) => person && (person.name || person.title))
127
+ .map((person) => ({
128
+ title: person.title ?? '',
129
+ name: person.name ?? '',
130
+ phone: person.phone ?? '',
131
+ fax: person.fax ?? '',
132
+ email: person.email ?? '',
133
+ }))
134
+ }
135
+
136
+ /**
137
+ * The view-model of one partner: what viewer-layout's `PartnerPanel` renders
138
+ * in each of its variants, and what any other rendering of a partner reads.
139
+ *
140
+ * `partner` is a `partners.json` record; `text` its translation in the
141
+ * language shown (`{ name, description, city, address, phone, fax, email,
142
+ * website }`). `ctx` carries what differs by family, nothing else:
143
+ *
144
+ * - `countryLabel(countryId)` — the country's name;
145
+ * - `md`, `mdInline`, `mdStrip` — the renderers, when the site binds its
146
+ * own (a glossary); the package's by default;
147
+ * - `route(partner)` — the partner's own page;
148
+ * - `objectsRoute(partner)` — the page of the items it holds;
149
+ * - `hidden(partner)` — a partner with no page of its own (the exhibitions'
150
+ * rule): it keeps its name, and loses both links.
151
+ *
152
+ * Text is rendered once here: `name` and `description` are HTML (inline and
153
+ * block), `address` is block HTML; every other field is plain text.
154
+ */
155
+ export function partnerView(partner, text = {}, ctx = {}) {
156
+ if (!partner) return null
157
+ const t = text ?? {}
158
+ const {
159
+ countryLabel = (id) => (id ?? ''),
160
+ md = mdBlock,
161
+ mdInline = mdInlineDefault,
162
+ mdStrip = mdStripDefault,
163
+ route = null,
164
+ objectsRoute = null,
165
+ hidden = null,
166
+ } = ctx
167
+
168
+ const source = String(t.name ?? partner.internal_name ?? partner.id)
169
+ const plainName = mdStrip(source)
170
+ const city = t.city ? String(t.city) : ''
171
+ const country = partner.country_id ? (countryLabel(partner.country_id) ?? '') : ''
172
+ const isHidden = hidden ? Boolean(hidden(partner)) : false
173
+ const itemCount = partner.item_count ?? 0
174
+ const website = websiteUrl(t.website)
175
+ const links = (partner.additional_urls ?? [])
176
+ .map((link) => ({ url: websiteUrl(link?.url), label: link?.title || link?.url || '' }))
177
+ .filter((link) => link.url)
178
+ const hasMap = Number.isFinite(partner.latitude) && Number.isFinite(partner.longitude)
179
+
180
+ const view = {
181
+ id: partner.id,
182
+ type: partner.type ?? '',
183
+ name: mdInline(source),
184
+ plainName,
185
+ city,
186
+ country,
187
+ location: [city, country].filter(Boolean).join(', '),
188
+ logos: [...(partner.logos ?? [])].sort(byDisplayOrder).map((logo) => ({
189
+ url: logo.url,
190
+ alt: logo.alt_text || plainName,
191
+ type: logo.logo_type ?? '',
192
+ })),
193
+ // A partner's pictures carry `alt_text`, not the per-language
194
+ // `captions` an item's images do: the alt text is the caption.
195
+ pictures: [...(partner.images ?? [])].sort(byDisplayOrder).map((picture) => ({
196
+ url: picture.url,
197
+ alt: picture.alt_text || plainName,
198
+ caption: picture.alt_text ?? '',
199
+ photographer: picture.photographer ?? '',
200
+ copyright: picture.copyright ?? '',
201
+ })),
202
+ description: md(t.description ?? ''),
203
+ contact: {
204
+ address: md(t.address ?? ''),
205
+ phone: t.phone ?? '',
206
+ fax: t.fax ?? '',
207
+ email: t.email ?? '',
208
+ website: website ? { url: website, label: String(t.website).trim() } : null,
209
+ links,
210
+ },
211
+ persons: contactPersons(partner),
212
+ map: hasMap
213
+ ? { latitude: partner.latitude, longitude: partner.longitude, zoom: partner.map_zoom ?? 15, label: plainName }
214
+ : null,
215
+ itemCount,
216
+ hidden: isHidden,
217
+ route: !isHidden && route ? (route(partner) ?? null) : null,
218
+ objectsRoute: !isHidden && itemCount && objectsRoute ? (objectsRoute(partner) ?? null) : null,
219
+ }
220
+ view.hasContact = Boolean(
221
+ view.contact.address || view.contact.phone || view.contact.fax || view.contact.email
222
+ || view.contact.website || view.contact.links.length || view.persons.length,
223
+ )
224
+ return view
225
+ }