ochre-sdk 1.3.2 → 1.3.3

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.
@@ -2,7 +2,7 @@ import { requestOchre } from "./request.mjs";
2
2
  import { parseRequestedLanguages, resolveContentLanguages } from "../parsers/languages.mjs";
3
3
  import { parseSetItems } from "../parsers/index.mjs";
4
4
  import { XMLSetItemsData } from "../xml/schemas.mjs";
5
- import { stringLiteral } from "../xquery.mjs";
5
+ import { getContentLanguage, stringLiteral } from "../xquery.mjs";
6
6
  import { compileContainerItemsQuery } from "../query.mjs";
7
7
  //#region src/fetchers/container-items.ts
8
8
  function hasArray(items) {
@@ -149,6 +149,7 @@ async function fetchContainerItems(parameters) {
149
149
  scopeUuids,
150
150
  belongsToCollectionScopeUuids,
151
151
  queries,
152
+ contentLanguage: getContentLanguage(options),
152
153
  body: ({ items, omitSupplemental }) => ` let $totalCount := count(${items})
153
154
  ${buildOrderedItemsClause(sort)}
154
155
  let $pagedItems := subsequence($orderedItems, ${startPosition}, ${pageSize})
@@ -4,14 +4,16 @@ import { gallerySchema } from "../schemas.mjs";
4
4
  import { parseRequestedLanguages, resolveContentLanguages } from "../parsers/languages.mjs";
5
5
  import { parseGallery } from "../parsers/index.mjs";
6
6
  import { XMLGalleryData } from "../xml/schemas.mjs";
7
- import { OCHRE_RESPONSE_PADDING, compileOchreQuery, stringLiteral } from "../xquery.mjs";
7
+ import { OCHRE_RESPONSE_PADDING, compileOchreQuery, getContentLanguage, stringLiteral } from "../xquery.mjs";
8
8
  import * as v from "valibot";
9
9
  //#region src/fetchers/gallery.ts
10
10
  function buildXQuery(parameters) {
11
- const { uuid, filter, page, perPage } = parameters;
11
+ const { uuid, filter, page, perPage, contentLanguage } = parameters;
12
12
  const start = (page - 1) * perPage + 1;
13
13
  const filterLiteral = stringLiteral(filter?.trim() ?? "");
14
- return compileOchreQuery({ body: ({ omitSupplemental }) => `<ochre>${OCHRE_RESPONSE_PADDING}{
14
+ return compileOchreQuery({
15
+ contentLanguage,
16
+ body: ({ omitSupplemental }) => `<ochre>${OCHRE_RESPONSE_PADDING}{
15
17
  for $q in doc()/ochre[@uuid=${stringLiteral(uuid)}]
16
18
  let $filter := ${filterLiteral}
17
19
  let $resources := $q//items/resource
@@ -27,7 +29,8 @@ function buildXQuery(parameters) {
27
29
  subsequence($filtered, ${start}, ${perPage})
28
30
  )`)}
29
31
  }</gallery>
30
- }</ochre>` });
32
+ }</ochre>`
33
+ });
31
34
  }
32
35
  async function fetchGallery(parameters, options) {
33
36
  try {
@@ -38,7 +41,8 @@ async function fetchGallery(parameters, options) {
38
41
  uuid,
39
42
  filter,
40
43
  page,
41
- perPage
44
+ perPage,
45
+ contentLanguage: getContentLanguage(options)
42
46
  }),
43
47
  schema: XMLGalleryData,
44
48
  label: "OCHRE gallery",
@@ -5,13 +5,14 @@ import { uuidSchema } from "../schemas.mjs";
5
5
  import { parseRequestedLanguages, resolveContentLanguages } from "../parsers/languages.mjs";
6
6
  import { parseLinkedItems } from "../parsers/index.mjs";
7
7
  import { XMLItemLinksData } from "../xml/schemas.mjs";
8
- import { OCHRE_RESPONSE_PADDING, compileOchreQuery, stringLiteral } from "../xquery.mjs";
8
+ import { OCHRE_RESPONSE_PADDING, compileOchreQuery, getContentLanguage, stringLiteral } from "../xquery.mjs";
9
9
  import * as v from "valibot";
10
10
  //#region src/fetchers/item-children.ts
11
- function buildXQuery(uuid, category) {
11
+ function buildXQuery(uuid, category, contentLanguage) {
12
12
  const categories = category == null ? OCHRE_COLLECTION_CATEGORIES : typeof category === "string" ? [category] : category;
13
13
  const collectionQueries = Array.from(categories, (possibleCategory) => `cts:search(fn:collection("ochre/${possibleCategory}")/ochre, $uuid-query)`);
14
14
  return compileOchreQuery({
15
+ contentLanguage,
15
16
  declarations: [`declare function local:item-children($nodes as node()*) as node()* {
16
17
  for $node in $nodes
17
18
  return
@@ -59,7 +60,7 @@ async function fetchItemChildren(uuid, options) {
59
60
  const parsedUuid = v.parse(uuidSchema, uuid);
60
61
  const requestedLanguages = parseRequestedLanguages(options?.languages);
61
62
  const output = await requestOchre({
62
- xquery: buildXQuery(parsedUuid, options?.category),
63
+ xquery: buildXQuery(parsedUuid, options?.category, getContentLanguage(options)),
63
64
  schema: XMLItemLinksData,
64
65
  label: "OCHRE item children",
65
66
  options
@@ -5,7 +5,7 @@ import { uuidSchema } from "../schemas.mjs";
5
5
  import { parseRequestedLanguages, resolveContentLanguages } from "../parsers/languages.mjs";
6
6
  import { parseLinkedItems } from "../parsers/index.mjs";
7
7
  import { XMLItemLinksData } from "../xml/schemas.mjs";
8
- import { OCHRE_RESPONSE_PADDING, compileOchreQuery, stringLiteral } from "../xquery.mjs";
8
+ import { OCHRE_RESPONSE_PADDING, compileOchreQuery, getContentLanguage, stringLiteral } from "../xquery.mjs";
9
9
  import * as v from "valibot";
10
10
  //#region src/fetchers/item-links.ts
11
11
  /**
@@ -14,7 +14,7 @@ import * as v from "valibot";
14
14
  * @param uuid - The UUID of the OCHRE item whose links should be fetched
15
15
  * @returns An XQuery string
16
16
  */
17
- function buildXQuery(uuid) {
17
+ function buildXQuery(uuid, contentLanguage) {
18
18
  const linkedItems = `for $link at $position in $link-nodes
19
19
  let $uuid := $link/@uuid/string()
20
20
  let $category := name($link)
@@ -24,7 +24,9 @@ function buildXQuery(uuid) {
24
24
  return `if (${ITEM_CATEGORY_ALIASES[category].map((alias) => `$category = ${stringLiteral(alias)}`).join(" or ")}) then fn:collection("ochre/${category}")/ochre/${category}[@uuid = $uuid]`;
25
25
  }).join("\n else ")}
26
26
  else ()`;
27
- return compileOchreQuery({ body: ({ omitSupplemental }) => `<ochre>${OCHRE_RESPONSE_PADDING}{let $item-uuid := ${stringLiteral(uuid)}
27
+ return compileOchreQuery({
28
+ contentLanguage,
29
+ body: ({ omitSupplemental }) => `<ochre>${OCHRE_RESPONSE_PADDING}{let $item-uuid := ${stringLiteral(uuid)}
28
30
 
29
31
  let $source-items := (
30
32
  ${OCHRE_COLLECTION_CATEGORIES.map((category) => ` fn:collection("ochre/${category}")/ochre[@uuid = $item-uuid]/${category}`).join(",\n")}
@@ -39,14 +41,15 @@ let $link-nodes := (
39
41
  return
40
42
  <items>{
41
43
  ${omitSupplemental(linkedItems)}
42
- }</items>}</ochre>` });
44
+ }</items>}</ochre>`
45
+ });
43
46
  }
44
47
  async function fetchItemLinks(uuid, options) {
45
48
  try {
46
49
  const parsedUuid = v.parse(uuidSchema, uuid);
47
50
  const requestedLanguages = parseRequestedLanguages(options?.languages);
48
51
  const output = await requestOchre({
49
- xquery: buildXQuery(parsedUuid),
52
+ xquery: buildXQuery(parsedUuid, getContentLanguage(options)),
50
53
  schema: XMLItemLinksData,
51
54
  label: "OCHRE item links",
52
55
  options
@@ -5,7 +5,7 @@ import { uuidSchema } from "../schemas.mjs";
5
5
  import { parseRequestedLanguages } from "../parsers/languages.mjs";
6
6
  import { parseItem } from "../parsers/index.mjs";
7
7
  import { XMLData } from "../xml/schemas.mjs";
8
- import { OCHRE_RESPONSE_PADDING, compileOchreQuery, stringLiteral } from "../xquery.mjs";
8
+ import { OCHRE_RESPONSE_PADDING, compileOchreQuery, getContentLanguage, stringLiteral } from "../xquery.mjs";
9
9
  import { parseWebpageView } from "../parsers/website/index.mjs";
10
10
  import * as v from "valibot";
11
11
  //#region src/fetchers/item.ts
@@ -28,10 +28,11 @@ function assertItemCategoryAllowed(category, containedItemCategory) {
28
28
  * @param parameters - The parameters for the fetch
29
29
  * @param parameters.uuid - The UUID of the OCHRE item to fetch
30
30
  * @param parameters.shouldOmitEmbeddedItems - Whether to drop the embedded item hierarchy
31
+ * @param parameters.contentLanguage - Keep only this language of every multilingual field
31
32
  * @returns An XQuery string
32
33
  */
33
34
  function buildXQuery(parameters) {
34
- const { uuid, shouldOmitEmbeddedItems } = parameters;
35
+ const { uuid, shouldOmitEmbeddedItems, contentLanguage } = parameters;
35
36
  const letClauses = [`let $ochre := doc(${stringLiteral(uuid)})/ochre`];
36
37
  let itemNodesExpression = "$ochre/node()";
37
38
  if (shouldOmitEmbeddedItems) {
@@ -46,13 +47,16 @@ ${ITEM_CATEGORIES_WITH_EMBEDDED_ITEMS.map((category) => ` $ochre/${category}`).
46
47
  else $node
47
48
  )`;
48
49
  }
49
- return compileOchreQuery({ body: ({ omitSupplemental }) => `${letClauses.join("\n")}
50
+ return compileOchreQuery({
51
+ contentLanguage,
52
+ body: ({ omitSupplemental }) => `${letClauses.join("\n")}
50
53
  return
51
54
  if (empty($ochre)) then <ochre>${OCHRE_RESPONSE_PADDING}</ochre>
52
55
  else element ochre {
53
56
  $ochre/@*,
54
57
  ${omitSupplemental(itemNodesExpression)}
55
- }` });
58
+ }`
59
+ });
56
60
  }
57
61
  function omitEmbeddedItems(item) {
58
62
  const { items: _items, ...itemWithoutEmbeddedItems } = item;
@@ -67,7 +71,8 @@ async function fetchItem(uuid, options) {
67
71
  const output = await requestOchre({
68
72
  xquery: buildXQuery({
69
73
  uuid: parsedUuid,
70
- shouldOmitEmbeddedItems
74
+ shouldOmitEmbeddedItems,
75
+ contentLanguage: getContentLanguage(options)
71
76
  }),
72
77
  schema: XMLData,
73
78
  label: "OCHRE item",
@@ -6,7 +6,7 @@ import { parseRequiredContentLike } from "../../parsers/helpers.mjs";
6
6
  import { parseRequestedLanguages, resolveContentLanguages } from "../../parsers/languages.mjs";
7
7
  import { parseCoordinates, parseIdentification } from "../../parsers/index.mjs";
8
8
  import { XMLSetItemLocationsData } from "../../xml/schemas.mjs";
9
- import { stringLiteral } from "../../xquery.mjs";
9
+ import { getContentLanguage, stringLiteral } from "../../xquery.mjs";
10
10
  import { compileContainerItemsQuery } from "../../query.mjs";
11
11
  import * as v from "valibot";
12
12
  //#region src/fetchers/set/item-locations.ts
@@ -19,6 +19,7 @@ async function fetchSetItemLocations(parameters, options) {
19
19
  scopeUuids: setScopeUuids,
20
20
  belongsToCollectionScopeUuids,
21
21
  queries,
22
+ contentLanguage: getContentLanguage(options),
22
23
  body: ({ items }) => ` let $propertyVariableUuids := (${Array.from(propertyVariableUuids, (uuid) => stringLiteral(uuid)).join(", ")})
23
24
 
24
25
  return <items totalCount="{count(${items})}">{
@@ -5,6 +5,17 @@ import { XMLContent, XMLString } from "../xml/types.mjs";
5
5
  export type FetchFunction = (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>;
6
6
  export type FetchBaseOptions<TLanguages extends ReadonlyArray<string> | undefined = undefined> = {
7
7
  languages?: TLanguages;
8
+ /**
9
+ * Fetch only this language of every multilingual field, instead of every
10
+ * language OCHRE holds. A field without it keeps its `fallbackLanguage`
11
+ * content, else its content in any language; aliases are always kept.
12
+ */
13
+ language?: string;
14
+ /**
15
+ * The language shown for fields that have no `language` content, typically
16
+ * the website's default language (defaults to `"eng"`)
17
+ */
18
+ fallbackLanguage?: string;
8
19
  fetch?: FetchFunction;
9
20
  signal?: AbortSignal;
10
21
  timeoutMilliseconds?: number;
package/dist/query.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { PropertyRelation, Query } from "./types/index.mjs";
2
- import { OchreQueryContext } from "./xquery.mjs";
2
+ import { OchreContentLanguage, OchreQueryContext } from "./xquery.mjs";
3
3
  //#region src/query.d.ts
4
4
  export declare function buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids: ReadonlyArray<string>, belongsToCollectionPropertyVariableUuid: string): string | null;
5
5
  /**
@@ -75,6 +75,7 @@ export type ItemsContainer = keyof typeof ITEMS_CONTAINERS;
75
75
  * @param parameters.queries - The query tree to compile, or null to match every item
76
76
  * @param parameters.declarations - Extra prolog declarations, placed before the compiled prolog
77
77
  * @param parameters.body - Builds the body from the name of the variable holding the items
78
+ * @param parameters.contentLanguage - Keep only this language of every multilingual field in the result
78
79
  * @returns A complete XQuery document
79
80
  * @internal
80
81
  */
@@ -87,6 +88,7 @@ export declare function compileContainerItemsQuery(parameters: {
87
88
  body: (context: OchreQueryContext & {
88
89
  items: string;
89
90
  }) => string;
91
+ contentLanguage?: OchreContentLanguage | null;
90
92
  }): string;
91
93
  /**
92
94
  * Reduce a property-value facet query tree to the leaves that filter items
package/dist/query.mjs CHANGED
@@ -14,32 +14,25 @@ const CTS_INCLUDES_STOP_WORDS = /* @__PURE__ */ new Set([
14
14
  const CTS_INCLUDES_TOKEN_WORD_REGEX = /^\p{L}+$/u;
15
15
  const CTS_INCLUDES_TOKEN_REGEX = /[\p{L}\p{N}*?]+/gu;
16
16
  const CTS_EXACT_TEXT_TOKEN_REGEX = /[\p{L}\p{N}]+/gu;
17
- const CONTENT_TARGET_CONTENT_ELEMENT_PATHS = {
18
- title: [
19
- "identification",
20
- "label",
21
- "content"
22
- ],
23
- description: ["description", "content"],
17
+ const CONTENT_TARGET_FIELD_ELEMENT_PATHS = {
18
+ title: ["identification", "label"],
19
+ description: ["description"],
24
20
  image: [
25
21
  "image",
26
22
  "identification",
27
- "label",
28
- "content"
23
+ "label"
29
24
  ],
30
25
  periods: [
31
26
  "periods",
32
27
  "period",
33
28
  "identification",
34
- "label",
35
- "content"
29
+ "label"
36
30
  ],
37
31
  bibliography: [
38
32
  "bibliographies",
39
33
  "bibliography",
40
34
  "identification",
41
- "label",
42
- "content"
35
+ "label"
43
36
  ]
44
37
  };
45
38
  const HELPER_SEARCH_VALUE_REFERENCE = "$value";
@@ -246,36 +239,51 @@ function buildValueNotIdReferenceQuery() {
246
239
  value: searchValue("IDREF")
247
240
  }));
248
241
  }
249
- function buildRichTextContentQueryExpression(parameters) {
250
- const { value, matchMode, isCaseSensitive, language } = parameters;
251
- return buildAndCtsQueryExpressionInternal([buildContentLanguageQuery(language), matchMode === "exact" ? buildRichTextExactQueryExpression({
252
- value,
253
- isCaseSensitive,
254
- language
255
- }) : buildCtsWordQueryExpression({
256
- value,
257
- matchMode,
258
- isCaseSensitive,
259
- queryFamily: "text",
260
- language
261
- })]);
242
+ /**
243
+ * Match a field's `content` in a language, falling back to another language
244
+ * only for fields that carry no content in the first one
245
+ *
246
+ * Placed inside the field element (a label, a note, a property value), so the
247
+ * missing-language check is scoped to that one field. Without a distinct
248
+ * fallback it is the single-language query this always compiled to.
249
+ */
250
+ function buildLocalizedContentQueryExpression(parameters) {
251
+ const { language, fallbackLanguage, buildMatchQuery } = parameters;
252
+ function buildContentQuery(contentLanguage) {
253
+ return buildNestedElementQuery(["content"], buildAndCtsQueryExpressionInternal([buildContentLanguageQuery(contentLanguage), buildMatchQuery(contentLanguage)]));
254
+ }
255
+ if (fallbackLanguage == null || fallbackLanguage === language) return buildContentQuery(language);
256
+ return buildOrCtsQueryExpressionInternal([buildContentQuery(language), buildAndCtsQueryExpressionInternal([buildNotCtsQueryExpression(buildNestedElementQuery(["content"], buildContentLanguageQuery(language))), buildContentQuery(fallbackLanguage)])]);
262
257
  }
263
- function buildValueContentInnerQuery(parameters) {
264
- const { language, value, matchMode, isCaseSensitive } = parameters;
265
- return buildNestedElementQuery(["content"], buildRichTextContentQueryExpression({
258
+ function buildRichTextContentQueryExpression(parameters) {
259
+ const { value, matchMode, isCaseSensitive, language, fallbackLanguage } = parameters;
260
+ return buildLocalizedContentQueryExpression({
266
261
  language,
267
- value,
268
- matchMode,
269
- isCaseSensitive
270
- }));
262
+ fallbackLanguage,
263
+ buildMatchQuery: (contentLanguage) => matchMode === "exact" ? buildRichTextExactQueryExpression({
264
+ value,
265
+ isCaseSensitive,
266
+ language: contentLanguage
267
+ }) : buildCtsWordQueryExpression({
268
+ value,
269
+ matchMode,
270
+ isCaseSensitive,
271
+ queryFamily: "text",
272
+ language: contentLanguage
273
+ })
274
+ });
271
275
  }
272
276
  function buildValueContentExactInnerQuery(parameters) {
273
- const { language, value, isCaseSensitive } = parameters;
274
- return buildNestedElementQuery(["content"], buildAndCtsQueryExpressionInternal([buildContentLanguageQuery(language), buildCtsElementValueQueryExpression({
275
- elementName: "string",
276
- value,
277
- isCaseSensitive
278
- })]));
277
+ const { language, fallbackLanguage, value, isCaseSensitive } = parameters;
278
+ return buildLocalizedContentQueryExpression({
279
+ language,
280
+ fallbackLanguage,
281
+ buildMatchQuery: () => buildCtsElementValueQueryExpression({
282
+ elementName: "string",
283
+ value,
284
+ isCaseSensitive
285
+ })
286
+ });
279
287
  }
280
288
  function buildValueDirectTextInnerQuery(parameters) {
281
289
  const { value, matchMode, isCaseSensitive } = parameters;
@@ -310,26 +318,23 @@ function buildValueRawValueInnerQuery(parameters) {
310
318
  });
311
319
  }
312
320
  function buildNotesQueryExpression(parameters) {
313
- const { value, matchMode, isCaseSensitive, language } = parameters;
314
- return buildNestedElementQuery([
315
- "notes",
316
- "note",
317
- "content"
318
- ], buildRichTextContentQueryExpression({
321
+ const { value, matchMode, isCaseSensitive, language, fallbackLanguage } = parameters;
322
+ return buildNestedElementQuery(["notes", "note"], buildRichTextContentQueryExpression({
319
323
  value,
320
324
  matchMode,
321
325
  isCaseSensitive,
322
- language
326
+ language,
327
+ fallbackLanguage
323
328
  }));
324
329
  }
325
330
  function buildContentTargetQueryExpression(parameters) {
326
- const { target, value, matchMode, isCaseSensitive, language } = parameters;
327
- const contentElementPath = CONTENT_TARGET_CONTENT_ELEMENT_PATHS[target];
328
- return buildNestedElementQuery(contentElementPath, buildRichTextContentQueryExpression({
331
+ const { target, value, matchMode, isCaseSensitive, language, fallbackLanguage } = parameters;
332
+ return buildNestedElementQuery(CONTENT_TARGET_FIELD_ELEMENT_PATHS[target], buildRichTextContentQueryExpression({
329
333
  value,
330
334
  matchMode,
331
335
  isCaseSensitive,
332
- language
336
+ language,
337
+ fallbackLanguage
333
338
  }));
334
339
  }
335
340
  function buildPropertyQueryExpression(parameters) {
@@ -381,16 +386,18 @@ function buildPropertyPresenceQueryExpression(parameters) {
381
386
  });
382
387
  }
383
388
  function buildPropertyStringQueryExpression(parameters) {
384
- const { propertyVariable, propertyRelation, value, matchMode, isCaseSensitive, language } = parameters;
389
+ const { propertyVariable, propertyRelation, value, matchMode, isCaseSensitive, language, fallbackLanguage } = parameters;
385
390
  return buildPropertyTextMatchQueryExpression({
386
391
  propertyVariable,
387
392
  propertyRelation,
388
393
  contentQueryExpression: matchMode === "exact" ? buildValueContentExactInnerQuery({
389
394
  language,
395
+ fallbackLanguage,
390
396
  value,
391
397
  isCaseSensitive
392
- }) : buildValueContentInnerQuery({
398
+ }) : buildRichTextContentQueryExpression({
393
399
  language,
400
+ fallbackLanguage,
394
401
  value,
395
402
  matchMode,
396
403
  isCaseSensitive
@@ -429,8 +436,9 @@ function buildPropertyAllQueryExpression(parameters) {
429
436
  propertyVariable: query.propertyVariable,
430
437
  propertyRelation: query.propertyRelation,
431
438
  valueFilters: [buildValueNotIdReferenceQuery()],
432
- contentQueryExpression: buildValueContentInnerQuery({
439
+ contentQueryExpression: buildRichTextContentQueryExpression({
433
440
  language: query.language,
441
+ fallbackLanguage: query.fallbackLanguage,
434
442
  value,
435
443
  matchMode,
436
444
  isCaseSensitive: query.isCaseSensitive
@@ -470,18 +478,20 @@ function buildPropertyDateRangeQueryExpression(query) {
470
478
  });
471
479
  }
472
480
  function buildItemStringQueryExpression(parameters) {
473
- const { value, matchMode, isCaseSensitive, language } = parameters;
481
+ const { value, matchMode, isCaseSensitive, language, fallbackLanguage } = parameters;
474
482
  return buildOrCtsQueryExpressionInternal([buildContentTargetQueryExpression({
475
483
  target: "title",
476
484
  value,
477
485
  matchMode,
478
486
  isCaseSensitive,
479
- language
487
+ language,
488
+ fallbackLanguage
480
489
  }), buildPropertyStringQueryExpression({
481
490
  value,
482
491
  matchMode,
483
492
  isCaseSensitive,
484
- language
493
+ language,
494
+ fallbackLanguage
485
495
  })]);
486
496
  }
487
497
  function tokenizeOcrExactValue(value) {
@@ -614,13 +624,15 @@ function buildLeafValueQueryExpression(parameters) {
614
624
  value,
615
625
  matchMode,
616
626
  isCaseSensitive: query.isCaseSensitive,
617
- language: query.language
627
+ language: query.language,
628
+ fallbackLanguage: query.fallbackLanguage
618
629
  });
619
630
  case "notes": return buildNotesQueryExpression({
620
631
  value,
621
632
  matchMode,
622
633
  isCaseSensitive: query.isCaseSensitive,
623
- language: query.language
634
+ language: query.language,
635
+ fallbackLanguage: query.fallbackLanguage
624
636
  });
625
637
  case "title":
626
638
  case "description":
@@ -631,7 +643,8 @@ function buildLeafValueQueryExpression(parameters) {
631
643
  value,
632
644
  matchMode,
633
645
  isCaseSensitive: query.isCaseSensitive,
634
- language: query.language
646
+ language: query.language,
647
+ fallbackLanguage: query.fallbackLanguage
635
648
  });
636
649
  case "property": switch (query.dataType) {
637
650
  case "all": return buildPropertyAllQueryExpression({
@@ -650,7 +663,8 @@ function buildLeafValueQueryExpression(parameters) {
650
663
  value,
651
664
  matchMode,
652
665
  isCaseSensitive: query.isCaseSensitive,
653
- language: query.language
666
+ language: query.language,
667
+ fallbackLanguage: query.fallbackLanguage
654
668
  });
655
669
  case "integer":
656
670
  case "decimal":
@@ -728,7 +742,8 @@ function getLeafHelperKey(parameters) {
728
742
  query.target,
729
743
  value,
730
744
  query.isCaseSensitive ? "case-sensitive" : "case-insensitive",
731
- query.language
745
+ query.language,
746
+ query.fallbackLanguage ?? ""
732
747
  ].join("|");
733
748
  case "property": return [
734
749
  "leaf",
@@ -739,7 +754,8 @@ function getLeafHelperKey(parameters) {
739
754
  query.propertyRelation ?? "",
740
755
  value,
741
756
  query.isCaseSensitive ? "case-sensitive" : "case-insensitive",
742
- query.language
757
+ query.language,
758
+ query.fallbackLanguage ?? ""
743
759
  ].join("|");
744
760
  }
745
761
  }
@@ -775,6 +791,7 @@ function getIncludesLeafHelperKey(parameters) {
775
791
  query.target,
776
792
  query.isCaseSensitive ? "case-sensitive" : "case-insensitive",
777
793
  query.language,
794
+ query.fallbackLanguage ?? "",
778
795
  isWildcarded ? "wildcarded" : "unwildcarded",
779
796
  isStemmed ? "stemmed" : "unstemmed"
780
797
  ].join("|");
@@ -786,6 +803,7 @@ function getIncludesLeafHelperKey(parameters) {
786
803
  query.propertyRelation ?? "",
787
804
  query.isCaseSensitive ? "case-sensitive" : "case-insensitive",
788
805
  query.language,
806
+ query.fallbackLanguage ?? "",
789
807
  isWildcarded ? "wildcarded" : "unwildcarded",
790
808
  isStemmed ? "stemmed" : "unstemmed"
791
809
  ].join("|");
@@ -876,7 +894,7 @@ function getCompatibleIncludesGroupLeaves(query) {
876
894
  if (!("or" in query) || query.or.length <= 1) return null;
877
895
  const leafQueries = [];
878
896
  for (const childQuery of query.or) {
879
- if (!isQueryLeaf(childQuery) || childQuery.target === "ocr") return null;
897
+ if (!isQueryLeaf(childQuery) || childQuery.target === "ocr" || childQuery.fallbackLanguage != null && childQuery.fallbackLanguage !== childQuery.language) return null;
880
898
  leafQueries.push(childQuery);
881
899
  }
882
900
  const firstQuery = leafQueries[0];
@@ -941,7 +959,26 @@ function buildCtsItemsPlan(queryExpression) {
941
959
  return {
942
960
  kind: "search",
943
961
  itemPredicates: [],
944
- queryExpressions: [queryExpression]
962
+ queryExpressions: [queryExpression],
963
+ resultPredicates: []
964
+ };
965
+ }
966
+ /**
967
+ * Plan a leaf that falls back to a second language for fields missing the first
968
+ *
969
+ * The strict query needs a `cts:not-query` scoped to one field, which CTS
970
+ * resolves against the Set's single fragment and so rules out every item the
971
+ * moment any item holds the first language. The search therefore runs a
972
+ * superset (the leaf in either language), and `cts:contains` then applies the
973
+ * strict query to each matched item projection, where the negation is exact.
974
+ */
975
+ function buildFallbackItemsPlan(parameters) {
976
+ const { supersetQueryExpression, strictQueryExpression } = parameters;
977
+ return {
978
+ kind: "search",
979
+ itemPredicates: [],
980
+ queryExpressions: [supersetQueryExpression],
981
+ resultPredicates: [`[cts:contains(., ${strictQueryExpression})]`]
945
982
  };
946
983
  }
947
984
  /**
@@ -958,7 +995,8 @@ function buildNegatedItemsPlan(queryExpression) {
958
995
  return {
959
996
  kind: "search",
960
997
  itemPredicates: [`[not(cts:contains(., ${queryExpression}))]`],
961
- queryExpressions: []
998
+ queryExpressions: [],
999
+ resultPredicates: []
962
1000
  };
963
1001
  }
964
1002
  /**
@@ -976,6 +1014,9 @@ function flattenItemsPlans(childPlans, kind) {
976
1014
  }
977
1015
  return flattenedPlans;
978
1016
  }
1017
+ function appendUniquePredicates(predicates, addedPredicates) {
1018
+ for (const predicate of addedPredicates) if (!predicates.includes(predicate)) predicates.push(predicate);
1019
+ }
979
1020
  /**
980
1021
  * Fold the children of an `and` group into one plan
981
1022
  *
@@ -987,7 +1028,8 @@ function buildAndItemsPlan(childPlans) {
987
1028
  const mergedPlan = {
988
1029
  kind: "search",
989
1030
  itemPredicates: [],
990
- queryExpressions: []
1031
+ queryExpressions: [],
1032
+ resultPredicates: []
991
1033
  };
992
1034
  const unfoldablePlans = [];
993
1035
  for (const childPlan of flattenItemsPlans(childPlans, "intersect")) {
@@ -995,11 +1037,12 @@ function buildAndItemsPlan(childPlans) {
995
1037
  unfoldablePlans.push(childPlan);
996
1038
  continue;
997
1039
  }
998
- for (const itemPredicate of childPlan.itemPredicates) if (!mergedPlan.itemPredicates.includes(itemPredicate)) mergedPlan.itemPredicates.push(itemPredicate);
1040
+ appendUniquePredicates(mergedPlan.itemPredicates, childPlan.itemPredicates);
1041
+ appendUniquePredicates(mergedPlan.resultPredicates, childPlan.resultPredicates);
999
1042
  mergedPlan.queryExpressions.push(...childPlan.queryExpressions);
1000
1043
  }
1001
1044
  if (unfoldablePlans.length === 0) return mergedPlan;
1002
- const intersectedPlans = mergedPlan.itemPredicates.length === 0 && mergedPlan.queryExpressions.length === 0 ? unfoldablePlans : [mergedPlan, ...unfoldablePlans];
1045
+ const intersectedPlans = mergedPlan.itemPredicates.length === 0 && mergedPlan.queryExpressions.length === 0 && mergedPlan.resultPredicates.length === 0 ? unfoldablePlans : [mergedPlan, ...unfoldablePlans];
1003
1046
  return intersectedPlans.length === 1 ? intersectedPlans[0] ?? mergedPlan : {
1004
1047
  kind: "intersect",
1005
1048
  children: intersectedPlans
@@ -1015,7 +1058,7 @@ function buildOrItemsPlan(childPlans) {
1015
1058
  const mergedQueryExpressions = [];
1016
1059
  const unionedPlans = [];
1017
1060
  for (const childPlan of flattenItemsPlans(childPlans, "union")) {
1018
- if (childPlan.kind === "search" && childPlan.itemPredicates.length === 0) {
1061
+ if (childPlan.kind === "search" && childPlan.itemPredicates.length === 0 && childPlan.resultPredicates.length === 0) {
1019
1062
  mergedQueryExpressions.push(buildAndCtsQueryExpressionInternal(childPlan.queryExpressions));
1020
1063
  continue;
1021
1064
  }
@@ -1024,7 +1067,8 @@ function buildOrItemsPlan(childPlans) {
1024
1067
  if (mergedQueryExpressions.length > 0) unionedPlans.unshift({
1025
1068
  kind: "search",
1026
1069
  itemPredicates: [],
1027
- queryExpressions: [buildOrCtsQueryExpressionInternal(mergedQueryExpressions)]
1070
+ queryExpressions: [buildOrCtsQueryExpressionInternal(mergedQueryExpressions)],
1071
+ resultPredicates: []
1028
1072
  });
1029
1073
  if (unionedPlans.length === 0) return buildCtsItemsPlan("cts:false-query()");
1030
1074
  return unionedPlans.length === 1 ? unionedPlans[0] ?? buildCtsItemsPlan("cts:false-query()") : {
@@ -1039,11 +1083,24 @@ function buildItemsPlan(context, query) {
1039
1083
  return {
1040
1084
  kind: "search",
1041
1085
  itemPredicates: [query.isNegated === true ? `[not(@uuid = ${bindingName})]` : `[@uuid = ${bindingName}]`],
1042
- queryExpressions: []
1086
+ queryExpressions: [],
1087
+ resultPredicates: []
1043
1088
  };
1044
1089
  }
1045
1090
  const queryExpression = buildLeafQueryExpression(context, query);
1046
- return query.isNegated === true ? buildNegatedItemsPlan(queryExpression) : buildCtsItemsPlan(queryExpression);
1091
+ if (query.isNegated === true) return buildNegatedItemsPlan(queryExpression);
1092
+ if (query.fallbackLanguage == null || query.fallbackLanguage === query.language) return buildCtsItemsPlan(queryExpression);
1093
+ return buildFallbackItemsPlan({
1094
+ supersetQueryExpression: buildOrCtsQueryExpressionInternal([buildLeafQueryExpression(context, {
1095
+ ...query,
1096
+ fallbackLanguage: void 0
1097
+ }), buildLeafQueryExpression(context, {
1098
+ ...query,
1099
+ language: query.fallbackLanguage,
1100
+ fallbackLanguage: void 0
1101
+ })]),
1102
+ strictQueryExpression: queryExpression
1103
+ });
1047
1104
  }
1048
1105
  const optimizedIncludesGroupQueries = getCompatibleIncludesGroupLeaves(query);
1049
1106
  if (optimizedIncludesGroupQueries != null) return buildCtsItemsPlan(buildIncludesGroupQueryExpression(context, optimizedIncludesGroupQueries));
@@ -1062,7 +1119,8 @@ function buildItemsPlanExpression(parameters) {
1062
1119
  if (plan.kind === "search") {
1063
1120
  const itemsExpression = `${baseItemsExpression}${plan.itemPredicates.join("")}`;
1064
1121
  const queryName = queryNamesByPlan.get(plan);
1065
- return queryName == null ? itemsExpression : `cts:search(${itemsExpression}, ${queryName})`;
1122
+ const searchedExpression = queryName == null ? itemsExpression : `cts:search(${itemsExpression}, ${queryName})`;
1123
+ return plan.resultPredicates.length === 0 ? searchedExpression : `(${searchedExpression})${plan.resultPredicates.join("")}`;
1066
1124
  }
1067
1125
  return `(${Array.from(plan.children, (childPlan) => buildItemsPlanExpression({
1068
1126
  plan: childPlan,
@@ -1150,7 +1208,8 @@ function buildQueryPlan(parameters) {
1150
1208
  const plan = queries == null ? {
1151
1209
  kind: "search",
1152
1210
  itemPredicates: [],
1153
- queryExpressions: []
1211
+ queryExpressions: [],
1212
+ resultPredicates: []
1154
1213
  } : buildItemsPlan(context, queries);
1155
1214
  const searchPlans = [];
1156
1215
  collectItemsSearchPlans(plan, searchPlans);
@@ -1202,11 +1261,12 @@ function buildQueryPlan(parameters) {
1202
1261
  * @param parameters.queries - The query tree to compile, or null to match every item
1203
1262
  * @param parameters.declarations - Extra prolog declarations, placed before the compiled prolog
1204
1263
  * @param parameters.body - Builds the body from the name of the variable holding the items
1264
+ * @param parameters.contentLanguage - Keep only this language of every multilingual field in the result
1205
1265
  * @returns A complete XQuery document
1206
1266
  * @internal
1207
1267
  */
1208
1268
  function compileContainerItemsQuery(parameters) {
1209
- const { container, scopeUuids, belongsToCollectionScopeUuids, queries, declarations = [], body } = parameters;
1269
+ const { container, scopeUuids, belongsToCollectionScopeUuids, queries, declarations = [], body, contentLanguage } = parameters;
1210
1270
  const { scopeVariable, itemsExpression } = ITEMS_CONTAINERS[container];
1211
1271
  const plan = buildQueryPlan({
1212
1272
  queries,
@@ -1225,7 +1285,8 @@ ${body({
1225
1285
  ...context,
1226
1286
  items: plan.itemsVariable
1227
1287
  })}
1228
- }</ochre>`
1288
+ }</ochre>`,
1289
+ contentLanguage
1229
1290
  });
1230
1291
  }
1231
1292
  /**
package/dist/schemas.mjs CHANGED
@@ -84,6 +84,7 @@ const standardQueryFields = {
84
84
  matchMode: v.picklist(["includes", "exact"]),
85
85
  isCaseSensitive: v.boolean(),
86
86
  language: defaultString("eng"),
87
+ fallbackLanguage: v.optional(v.string()),
87
88
  isNegated: defaultBoolean(false)
88
89
  };
89
90
  const propertyRelationSchema = v.picklist(["related", "inverse"]);
@@ -853,6 +853,10 @@ export type SetItemsSort = {
853
853
  * layer holds a single OCR word, so `includes` matches every search term as its
854
854
  * own word and `exact` matches the terms as an adjacent run of whole words. OCR
855
855
  * text carries no language, so `ocr` leaves take no `language`.
856
+ *
857
+ * `fallbackLanguage` searches a field in that language only when the field has
858
+ * no content in `language`, so a match follows the text a reader of `language`
859
+ * is shown. Leave it unset to search `language` alone.
856
860
  */
857
861
  export type QueryLeaf = {
858
862
  target: "property";
@@ -865,6 +869,7 @@ export type QueryLeaf = {
865
869
  matchMode: "includes" | "exact";
866
870
  isCaseSensitive: boolean;
867
871
  language: string;
872
+ fallbackLanguage?: string;
868
873
  isNegated?: boolean;
869
874
  } | {
870
875
  target: "property";
@@ -877,6 +882,7 @@ export type QueryLeaf = {
877
882
  matchMode: "includes" | "exact";
878
883
  isCaseSensitive: boolean;
879
884
  language: string;
885
+ fallbackLanguage?: string;
880
886
  isNegated?: boolean;
881
887
  } | {
882
888
  target: "property";
@@ -889,6 +895,7 @@ export type QueryLeaf = {
889
895
  matchMode: "includes" | "exact";
890
896
  isCaseSensitive: boolean;
891
897
  language: string;
898
+ fallbackLanguage?: string;
892
899
  isNegated?: boolean;
893
900
  } | {
894
901
  target: "property";
@@ -901,6 +908,7 @@ export type QueryLeaf = {
901
908
  matchMode: "includes" | "exact";
902
909
  isCaseSensitive: boolean;
903
910
  language: string;
911
+ fallbackLanguage?: string;
904
912
  isNegated?: boolean;
905
913
  } | {
906
914
  target: "property";
@@ -911,6 +919,7 @@ export type QueryLeaf = {
911
919
  matchMode: "includes" | "exact";
912
920
  isCaseSensitive: boolean;
913
921
  language: string;
922
+ fallbackLanguage?: string;
914
923
  isNegated?: boolean;
915
924
  } | {
916
925
  target: "string";
@@ -918,6 +927,7 @@ export type QueryLeaf = {
918
927
  matchMode: "includes" | "exact";
919
928
  isCaseSensitive: boolean;
920
929
  language: string;
930
+ fallbackLanguage?: string;
921
931
  isNegated?: boolean;
922
932
  } | {
923
933
  target: "ocr";
@@ -931,6 +941,7 @@ export type QueryLeaf = {
931
941
  matchMode: "includes" | "exact";
932
942
  isCaseSensitive: boolean;
933
943
  language: string;
944
+ fallbackLanguage?: string;
934
945
  isNegated?: boolean;
935
946
  };
936
947
  /**
package/dist/xquery.d.mts CHANGED
@@ -17,6 +17,25 @@ export declare function stringLiteral(value: string): string;
17
17
  * @internal
18
18
  */
19
19
  export declare const OCHRE_RESPONSE_PADDING = "<!--ochre-sdk: keeps an empty result distinct from a failed query-->";
20
+ /**
21
+ * Which language a query should return, and what to show where it is missing
22
+ */
23
+ export type OchreContentLanguage = {
24
+ language: string;
25
+ fallbackLanguage: string;
26
+ };
27
+ /**
28
+ * Resolve the content language a fetch asked for
29
+ * @param options - The fetch options
30
+ * @param options.language - The language to keep
31
+ * @param options.fallbackLanguage - The language shown where `language` is missing
32
+ * @returns The content language, or null to keep every language
33
+ * @internal
34
+ */
35
+ export declare function getContentLanguage(options?: {
36
+ language?: string;
37
+ fallbackLanguage?: string;
38
+ }): OchreContentLanguage | null;
20
39
  /**
21
40
  * What a query body can ask the surrounding document for
22
41
  *
@@ -47,11 +66,13 @@ export type OchreQueryContext = {
47
66
  * @param parameters - The document parameters
48
67
  * @param parameters.declarations - Prolog declarations, emitted in order before the supplemental helper
49
68
  * @param parameters.body - Builds the query body
69
+ * @param parameters.contentLanguage - Keep only this language of every multilingual field in the result
50
70
  * @returns A complete XQuery document
51
71
  * @internal
52
72
  */
53
73
  export declare function compileOchreQuery(parameters: {
54
74
  declarations?: ReadonlyArray<string>;
55
75
  body: (context: OchreQueryContext) => string;
76
+ contentLanguage?: OchreContentLanguage | null;
56
77
  }): string;
57
78
  //#endregion
package/dist/xquery.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_LANGUAGES } from "./constants.mjs";
1
2
  //#region src/xquery.ts
2
3
  /**
3
4
  * Build a string literal for an XQuery string
@@ -46,6 +47,63 @@ const SUPPLEMENTAL_XQUERY_PROLOG = `declare function local:omit-supplemental($no
46
47
  }
47
48
  else $node
48
49
  };`;
50
+ /**
51
+ * XQuery prolog declaring `local:select-language`, which keeps one language of
52
+ * every multilingual field in a node sequence
53
+ *
54
+ * A field's `content` children are reduced to the one in `$language`, else the
55
+ * one in `$fallback`, else the first in any language, which is the choice the
56
+ * API's own `lang` parameter makes. `zxx` content is always kept, since it holds
57
+ * a field's aliases rather than a translation; the API's `lang` parameter drops
58
+ * it, which is why the SDK selects languages itself. Leaf elements are
59
+ * returned by reference; testing for multilingual descendants before copying a
60
+ * subtree measured slower than copying it.
61
+ */
62
+ const LANGUAGE_XQUERY_PROLOG = `declare function local:select-language($nodes as node()*, $language as xs:string, $fallback as xs:string) as node()* {
63
+ for $node in $nodes
64
+ return
65
+ typeswitch ($node)
66
+ case element() return
67
+ if (empty($node/*))
68
+ then $node
69
+ else
70
+ let $contents := $node/content[@xml:lang]
71
+ let $selected :=
72
+ if (empty($contents))
73
+ then ()
74
+ else (
75
+ $contents[@xml:lang = $language],
76
+ $contents[@xml:lang = $fallback],
77
+ $contents[@xml:lang != "zxx"]
78
+ )[1]
79
+ return element { node-name($node) } {
80
+ $node/@*,
81
+ for $child in $node/node()
82
+ return
83
+ if ($child instance of element(content) and exists($child/@xml:lang))
84
+ then
85
+ if ($child is $selected or $child/@xml:lang = "zxx")
86
+ then $child
87
+ else ()
88
+ else local:select-language($child, $language, $fallback)
89
+ }
90
+ default return $node
91
+ };`;
92
+ /**
93
+ * Resolve the content language a fetch asked for
94
+ * @param options - The fetch options
95
+ * @param options.language - The language to keep
96
+ * @param options.fallbackLanguage - The language shown where `language` is missing
97
+ * @returns The content language, or null to keep every language
98
+ * @internal
99
+ */
100
+ function getContentLanguage(options) {
101
+ if (options?.language == null) return null;
102
+ return {
103
+ language: options.language,
104
+ fallbackLanguage: options.fallbackLanguage ?? DEFAULT_LANGUAGES[0]
105
+ };
106
+ }
49
107
  const NOT_SUPPLEMENTAL_PREDICATE = "[not(ancestor-or-self::*[@supplemental = \"true\"])]";
50
108
  /**
51
109
  * Compile a complete XQuery document for the OCHRE API
@@ -57,21 +115,27 @@ const NOT_SUPPLEMENTAL_PREDICATE = "[not(ancestor-or-self::*[@supplemental = \"t
57
115
  * @param parameters - The document parameters
58
116
  * @param parameters.declarations - Prolog declarations, emitted in order before the supplemental helper
59
117
  * @param parameters.body - Builds the query body
118
+ * @param parameters.contentLanguage - Keep only this language of every multilingual field in the result
60
119
  * @returns A complete XQuery document
61
120
  * @internal
62
121
  */
63
122
  function compileOchreQuery(parameters) {
64
- const { declarations = [], body } = parameters;
65
- return `${[
123
+ const { declarations = [], body, contentLanguage } = parameters;
124
+ const prologDeclarations = [
66
125
  "xquery version \"1.0-ml\";",
67
126
  ...declarations,
68
- SUPPLEMENTAL_XQUERY_PROLOG
69
- ].join("\n\n")}
70
-
71
- ${body({
127
+ SUPPLEMENTAL_XQUERY_PROLOG,
128
+ ...contentLanguage == null ? [] : [LANGUAGE_XQUERY_PROLOG]
129
+ ];
130
+ const bodyExpression = body({
72
131
  omitSupplemental: (expression) => `local:omit-supplemental(${expression})`,
73
132
  notSupplemental: NOT_SUPPLEMENTAL_PREDICATE
74
- })}`;
133
+ });
134
+ return `${prologDeclarations.join("\n\n")}
135
+
136
+ ${contentLanguage == null ? bodyExpression : `local:select-language((
137
+ ${bodyExpression}
138
+ ), ${stringLiteral(contentLanguage.language)}, ${stringLiteral(contentLanguage.fallbackLanguage)})`}`;
75
139
  }
76
140
  //#endregion
77
- export { OCHRE_RESPONSE_PADDING, compileOchreQuery, stringLiteral };
141
+ export { OCHRE_RESPONSE_PADDING, compileOchreQuery, getContentLanguage, stringLiteral };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ochre-sdk",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Node.js library for working with OCHRE (Online Cultural and Historical Research Environment) data",