@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 +93 -15
- package/package.json +1 -1
- package/src/catalogue/catalogue.js +128 -0
- package/src/catalogue/fieldSearch.js +176 -0
- package/src/conventions.js +6 -3
- package/src/dxa/exhibition/useExhibitionSheet.js +6 -2
- package/src/dxa/gallery/useGallerySheet.js +6 -2
- package/src/index.js +8 -1
- package/src/record/partners.js +138 -0
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/conventions.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
49
|
-
|
|
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
|
-
//
|
|
49
|
-
|
|
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 {
|
package/src/record/partners.js
CHANGED
|
@@ -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
|
+
}
|