ochre-sdk 1.0.72 → 1.0.74

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
@@ -57,6 +57,8 @@ present and `error` is `null`; on failure, the parsed value is `null` and
57
57
  `category` lets the XQuery search only the matching OCHRE collection.
58
58
  - `fetchItemLinks(uuid, options)` fetches items linked from a source item and
59
59
  parses them as embedded OCHRE items.
60
+ - `fetchItemOcrData(uuid, value, options)` fetches the positioned OCR strings of
61
+ an item that match a search value, for drawing hit boxes over a scanned page.
60
62
  - `fetchGallery(params, options)` fetches paginated resource galleries with an
61
63
  optional label filter.
62
64
  - `fetchWebsite(abbreviation, options)` fetches an OCHRE website presentation
@@ -119,6 +121,54 @@ const result = await fetchSetItems(
119
121
  Use `fetchSetPropertyValues` with the same query shape when you need facet data
120
122
  for a filtered result set.
121
123
 
124
+ ### OCR Text Queries
125
+
126
+ The `ocr` target searches the OCR text layer of the Resource items in a Set. It takes no `language`, because OCR text carries none.
127
+
128
+ ```ts
129
+ const queries: Query = {
130
+ and: [
131
+ {
132
+ target: "ocr",
133
+ value: "Cappaert",
134
+ matchMode: "includes",
135
+ isCaseSensitive: false,
136
+ },
137
+ {
138
+ target: "title",
139
+ value: "Convocation",
140
+ matchMode: "includes",
141
+ isCaseSensitive: false,
142
+ language: "eng",
143
+ },
144
+ ],
145
+ };
146
+ ```
147
+
148
+ Every `<string>` node in that layer holds a single OCR word, so `includes` matches each search term as its own word anywhere in the layer, in any order, with `*` and `?` wildcards supported. `exact` instead matches the terms as a run of adjacent whole words, so `"THE COLLEGE"` matches a page carrying that phrase but not one where the two words merely appear apart. An item matches when the OCR layer of the item itself or of any of its child Resources matches.
149
+
150
+ Set item projections do not carry the OCR layer, so an `ocr` leaf is resolved by an extra index-only search over the Resource documents whose matching UUIDs are then joined back onto the Set items. It still composes with `and`, `or`, and `isNegated` like any other leaf, and repeating the same OCR search inside one tree only costs one search.
151
+
152
+ ## OCR Data
153
+
154
+ A Resource may carry an `<ocr>` layer holding the positioned output of an OCR run. The node hierarchy inside that layer is irregular and is not parsed, but any `<string>` node within it, at any depth, is read as one positioned OCR string.
155
+
156
+ ```ts
157
+ import { fetchItemOcrData } from "ochre-sdk";
158
+
159
+ const result = await fetchItemOcrData("<item-uuid>", "Artifact", {
160
+ matchMode: "exact",
161
+ });
162
+
163
+ for (const ocrString of result.ocrStrings ?? []) {
164
+ console.log(ocrString.content, ocrString.x, ocrString.y, ocrString.vertices);
165
+ }
166
+ ```
167
+
168
+ `x` and `y` come from `HPOS` and `VPOS` and give the top-left corner of the box, `width` and `height` its size, and `vertices` its full bounding polygon, which is not always rectangular. Each geometry field is null when the source attribute is absent or unparseable. `resourceUuid` names the Resource that owns the OCR layer, which differs from the requested item when the OCR lives on a child Resource.
169
+
170
+ Matching defaults to case-insensitive `includes` and runs against each string's `CONTENT`. Because a `<string>` holds a single OCR word, a multi-word search value is split on whitespace and a string is returned when it matches any one term. Requesting an item that does not exist is an error; an item with no OCR layer, or no matches, returns an empty array.
171
+
122
172
  ## Helpers And Types
123
173
 
124
174
  The root export includes the SDK's public TypeScript model, website component
@@ -30,11 +30,6 @@ const XML_ARRAY_TAGS = [
30
30
  "reference",
31
31
  "coord",
32
32
  "area",
33
- "Page",
34
- "TextBlock",
35
- "TextLine",
36
- "ocrItem",
37
- "ocrMatch",
38
33
  "footnote",
39
34
  "language",
40
35
  "section",
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_LANGUAGES, XML_PARSER_OPTIONS } from "../constants.mjs";
2
- import { createSchemaValidationError, getErrorOutput, stringLiteral } from "../utilities.mjs";
2
+ import { SUPPLEMENTAL_XQUERY_PROLOG, createSchemaValidationError, getErrorOutput, omitSupplemental, stringLiteral } from "../utilities.mjs";
3
3
  import { gallerySchema, iso639_3Schema } from "../schemas.mjs";
4
4
  import { restoreXMLMetadata } from "../xml/metadata.mjs";
5
5
  import { parseGallery } from "../parsers/index.mjs";
@@ -37,7 +37,11 @@ function buildXQuery(parameters) {
37
37
  const { uuid, filter, page, perPage } = parameters;
38
38
  const start = (page - 1) * perPage + 1;
39
39
  const filterLiteral = stringLiteral(filter?.trim() ?? "");
40
- return `<ochre>{
40
+ return `xquery version "1.0-ml";
41
+
42
+ ${SUPPLEMENTAL_XQUERY_PROLOG}
43
+
44
+ <ochre>{
41
45
  for $q in doc()/ochre[@uuid=${stringLiteral(uuid)}]
42
46
  let $filter := ${filterLiteral}
43
47
  let $resources := $q//items/resource
@@ -47,9 +51,11 @@ function buildXQuery(parameters) {
47
51
  else $resources[contains(lower-case(string-join(identification/label//text(), "")), lower-case($filter))]
48
52
  let $maxLength := count($filtered)
49
53
  return <gallery maxLength="{$maxLength}">{
50
- $q/metadata/project,
51
- $q/metadata/item,
52
- subsequence($filtered, ${start}, ${perPage})
54
+ ${omitSupplemental(`(
55
+ $q/metadata/project,
56
+ $q/metadata/item,
57
+ subsequence($filtered, ${start}, ${perPage})
58
+ )`)}
53
59
  }</gallery>
54
60
  }</ochre>`;
55
61
  }
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_LANGUAGES, XML_PARSER_OPTIONS } from "../constants.mjs";
2
- import { createSchemaValidationError, getErrorOutput, stringLiteral } from "../utilities.mjs";
2
+ import { SUPPLEMENTAL_XQUERY_PROLOG, createSchemaValidationError, getErrorOutput, omitSupplemental, stringLiteral } from "../utilities.mjs";
3
3
  import { iso639_3Schema, uuidSchema } from "../schemas.mjs";
4
4
  import { restoreXMLMetadata } from "../xml/metadata.mjs";
5
5
  import { parseLinkedItems } from "../parsers/index.mjs";
@@ -51,6 +51,8 @@ function buildXQuery(uuid, category) {
51
51
  const collectionQueries = Array.from(categories, (possibleCategory) => `cts:search(fn:collection("ochre/${possibleCategory}")/ochre, $uuid-query)`);
52
52
  return `xquery version "1.0-ml";
53
53
 
54
+ ${SUPPLEMENTAL_XQUERY_PROLOG}
55
+
54
56
  declare function local:item-children($nodes as node()*) as node()* {
55
57
  for $node in $nodes
56
58
  return
@@ -89,7 +91,7 @@ let $children :=
89
91
  else ()
90
92
  return
91
93
  <ochre>
92
- <items>{$children}</items>
94
+ <items>{${omitSupplemental("$children")}}</items>
93
95
  </ochre>`;
94
96
  }
95
97
  async function fetchItemChildren(uuid, options) {
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_LANGUAGES, XML_PARSER_OPTIONS } from "../constants.mjs";
2
- import { createSchemaValidationError, getErrorOutput } from "../utilities.mjs";
2
+ import { SUPPLEMENTAL_XQUERY_PROLOG, createSchemaValidationError, getErrorOutput, omitSupplemental, stringLiteral } from "../utilities.mjs";
3
3
  import { iso639_3Schema, uuidSchema } from "../schemas.mjs";
4
4
  import { restoreXMLMetadata } from "../xml/metadata.mjs";
5
5
  import { parseLinkedItems } from "../parsers/index.mjs";
@@ -40,7 +40,7 @@ function resolveItemLinksLanguages(data, requestedLanguages) {
40
40
  * @returns An XQuery string
41
41
  */
42
42
  function buildXQuery(uuid) {
43
- return `<ochre>{${`let $item-uuid := "${uuid}"
43
+ const xquery = `let $item-uuid := ${stringLiteral(uuid)}
44
44
 
45
45
  let $source-items := (
46
46
  fn:collection("ochre/resource")/ochre[@uuid = $item-uuid]/resource,
@@ -64,7 +64,7 @@ let $link-nodes := (
64
64
 
65
65
  return
66
66
  <items>{
67
- for $link at $position in $link-nodes
67
+ ${omitSupplemental(`for $link at $position in $link-nodes
68
68
  let $uuid := $link/@uuid/string()
69
69
  let $category := name($link)
70
70
  where $uuid ne "" and not($uuid = $link-nodes[position() lt $position]/@uuid/string())
@@ -80,8 +80,13 @@ return
80
80
  else if ($category = "set") then fn:collection("ochre/set")/ochre/set[@uuid = $uuid]
81
81
  else if ($category = "spatialUnit") then fn:collection("ochre/spatialUnit")/ochre/spatialUnit[@uuid = $uuid]
82
82
  else if ($category = "concept") then fn:collection("ochre/concept")/ochre/concept[@uuid = $uuid]
83
- else ()
84
- }</items>`}}</ochre>`;
83
+ else ()`)}
84
+ }</items>`;
85
+ return `xquery version "1.0-ml";
86
+
87
+ ${SUPPLEMENTAL_XQUERY_PROLOG}
88
+
89
+ <ochre>{${xquery}}</ochre>`;
85
90
  }
86
91
  async function fetchItemLinks(uuid, options) {
87
92
  try {
@@ -0,0 +1,37 @@
1
+ import { OcrString } from "../types/index.mjs";
2
+ //#region src/fetchers/item-ocr-data.d.ts
3
+ /**
4
+ * Fetches and parses the OCR strings of an OCHRE item that match a search value
5
+ *
6
+ * Resources may carry an `<ocr>` layer whose internal hierarchy is irregular
7
+ * and therefore not parsed. Only its `<string>` nodes are returned, wherever
8
+ * they occur in that subtree, in document order. Matching runs per string, and
9
+ * each `<string>` holds a single OCR word, so a multi-word search value is
10
+ * split on whitespace and a string is returned when it matches any one term.
11
+ * Nested child Resources are searched too, with `resourceUuid` naming the
12
+ * Resource each match belongs to.
13
+ *
14
+ * @param uuid - The UUID of the OCHRE item to read the OCR layer of
15
+ * @param value - The search value to match against each OCR string's content
16
+ * @param options - Options for the fetch
17
+ * @param options.matchMode - Whether a term has to be contained in a string's content ("includes", the default) or equal it ("exact")
18
+ * @param options.isCaseSensitive - Whether matching is case sensitive, defaulting to false
19
+ * @param options.fetch - The fetch function to use
20
+ * @returns The matching OCR strings, an empty array when the item has no OCR
21
+ * layer or nothing matches, and a null output on fetch/parse errors
22
+ */
23
+ declare function fetchItemOcrData(uuid: string, value: string, options?: {
24
+ matchMode?: "includes" | "exact";
25
+ isCaseSensitive?: boolean;
26
+ fetch?: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>;
27
+ }): Promise<{
28
+ ocrStrings: Array<OcrString>;
29
+ error: null;
30
+ detailedError: null;
31
+ } | {
32
+ ocrStrings: null;
33
+ error: string;
34
+ detailedError: string;
35
+ }>;
36
+ //#endregion
37
+ export { fetchItemOcrData };
@@ -0,0 +1,166 @@
1
+ import { XML_PARSER_OPTIONS } from "../constants.mjs";
2
+ import { createSchemaValidationError, getErrorOutput, stringLiteral } from "../utilities.mjs";
3
+ import { itemOcrDataParametersSchema } from "../schemas.mjs";
4
+ import * as v from "valibot";
5
+ import { XMLParser } from "fast-xml-parser";
6
+ //#region src/fetchers/item-ocr-data.ts
7
+ const OCR_STRING_VERTEX_REGEX = /\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
8
+ /**
9
+ * Schema for a single matched OCR string in the OCHRE API response
10
+ */
11
+ const ocrStringSchema = v.object({
12
+ resourceUuid: v.optional(v.string(), ""),
13
+ content: v.optional(v.string(), ""),
14
+ x: v.optional(v.string(), ""),
15
+ y: v.optional(v.string(), ""),
16
+ width: v.optional(v.string(), ""),
17
+ height: v.optional(v.string(), ""),
18
+ vertices: v.optional(v.string(), "")
19
+ });
20
+ /**
21
+ * Schema for the item OCR data OCHRE API response
22
+ */
23
+ const responseSchema = v.object({ result: v.object({ ochre: v.object({ ocrStrings: v.object({
24
+ found: v.optional(v.string(), "false"),
25
+ ocrString: v.optional(v.union([v.array(ocrStringSchema), ocrStringSchema]))
26
+ }) }) }) });
27
+ function getSearchTerms(parameters) {
28
+ const { value, isCaseSensitive } = parameters;
29
+ const terms = [];
30
+ for (const term of value.split(/\s+/u)) if (term !== "") terms.push(isCaseSensitive ? term : term.toLocaleLowerCase("en-US"));
31
+ return terms;
32
+ }
33
+ function parseOcrStringNumber(value) {
34
+ const trimmedValue = value.trim();
35
+ if (trimmedValue === "") return null;
36
+ const numericValue = Number(trimmedValue);
37
+ return Number.isFinite(numericValue) ? numericValue : null;
38
+ }
39
+ function parseOcrStringVertices(value) {
40
+ const vertices = [];
41
+ for (const match of value.matchAll(OCR_STRING_VERTEX_REGEX)) {
42
+ const x = Number(match[1]);
43
+ const y = Number(match[2]);
44
+ if (Number.isFinite(x) && Number.isFinite(y)) vertices.push({
45
+ x,
46
+ y
47
+ });
48
+ }
49
+ return vertices;
50
+ }
51
+ /**
52
+ * Build an XQuery string to fetch matching OCR strings from the OCHRE API
53
+ *
54
+ * The `<ocr>` layer is marked supplemental, so it is deliberately read without
55
+ * the supplemental stripping the other fetchers apply. Only the `<string>`
56
+ * nodes are projected, at any depth, because OCHRE does not guarantee the shape
57
+ * of the surrounding hierarchy.
58
+ *
59
+ * The matches are wrapped in an `<ocrStrings>` element rather than returned
60
+ * directly under `<ochre>`: the API collapses an `<ochre>` element that has no
61
+ * element children down to a bare `<ochre/>`, which would drop the `found`
62
+ * flag and make "no such item" indistinguishable from "no matches".
63
+ * @param parameters - The parameters for the fetch
64
+ * @param parameters.uuid - The UUID of the OCHRE item to read the OCR layer of
65
+ * @param parameters.terms - The whitespace-separated search terms to match against, already lowercased for case-insensitive matching
66
+ * @param parameters.matchMode - Whether a term has to be contained in a string's content or equal it
67
+ * @param parameters.isCaseSensitive - Whether matching is case sensitive
68
+ * @returns An XQuery string
69
+ */
70
+ function buildXQuery(parameters) {
71
+ const { uuid, terms, matchMode, isCaseSensitive } = parameters;
72
+ const termValues = terms.map((term) => stringLiteral(term));
73
+ const contentExpression = isCaseSensitive ? "string($string/@CONTENT)" : "lower-case(string($string/@CONTENT))";
74
+ const matchExpression = matchMode === "exact" ? `normalize-space(${contentExpression}) = $term` : `contains(${contentExpression}, $term)`;
75
+ return `xquery version "1.0-ml";
76
+
77
+ declare variable $terms := (${termValues.join(", ")});
78
+
79
+ let $ochre := doc(${stringLiteral(uuid)})/ochre
80
+ let $ocrStrings :=
81
+ for $string in $ochre//ocr//string[@CONTENT]
82
+ where (some $term in $terms satisfies ${matchExpression})
83
+ return <ocrString
84
+ resourceUuid="{string($string/ancestor::resource[1]/@uuid)}"
85
+ content="{string($string/@CONTENT)}"
86
+ x="{string($string/@HPOS)}"
87
+ y="{string($string/@VPOS)}"
88
+ width="{string($string/@WIDTH)}"
89
+ height="{string($string/@HEIGHT)}"
90
+ vertices="{string($string/@VERTICES)}"/>
91
+
92
+ return <ochre><ocrStrings found="{exists($ochre)}">{$ocrStrings}</ocrStrings></ochre>`;
93
+ }
94
+ /**
95
+ * Fetches and parses the OCR strings of an OCHRE item that match a search value
96
+ *
97
+ * Resources may carry an `<ocr>` layer whose internal hierarchy is irregular
98
+ * and therefore not parsed. Only its `<string>` nodes are returned, wherever
99
+ * they occur in that subtree, in document order. Matching runs per string, and
100
+ * each `<string>` holds a single OCR word, so a multi-word search value is
101
+ * split on whitespace and a string is returned when it matches any one term.
102
+ * Nested child Resources are searched too, with `resourceUuid` naming the
103
+ * Resource each match belongs to.
104
+ *
105
+ * @param uuid - The UUID of the OCHRE item to read the OCR layer of
106
+ * @param value - The search value to match against each OCR string's content
107
+ * @param options - Options for the fetch
108
+ * @param options.matchMode - Whether a term has to be contained in a string's content ("includes", the default) or equal it ("exact")
109
+ * @param options.isCaseSensitive - Whether matching is case sensitive, defaulting to false
110
+ * @param options.fetch - The fetch function to use
111
+ * @returns The matching OCR strings, an empty array when the item has no OCR
112
+ * layer or nothing matches, and a null output on fetch/parse errors
113
+ */
114
+ async function fetchItemOcrData(uuid, value, options) {
115
+ try {
116
+ const parameters = v.parse(itemOcrDataParametersSchema, {
117
+ uuid,
118
+ value,
119
+ matchMode: options?.matchMode,
120
+ isCaseSensitive: options?.isCaseSensitive
121
+ });
122
+ const terms = getSearchTerms({
123
+ value: parameters.value,
124
+ isCaseSensitive: parameters.isCaseSensitive
125
+ });
126
+ const xquery = buildXQuery({
127
+ uuid: parameters.uuid,
128
+ terms,
129
+ matchMode: parameters.matchMode,
130
+ isCaseSensitive: parameters.isCaseSensitive
131
+ });
132
+ const response = await (options?.fetch ?? fetch)("https://ochre.lib.uchicago.edu/ochre/v2/ochre.php?xquery&xsl=none&lang=\"*\"", {
133
+ method: "POST",
134
+ body: xquery,
135
+ headers: { "Content-Type": "application/xquery" }
136
+ });
137
+ if (!response.ok) throw new Error(`OCHRE API responded with status: ${response.status}`, { cause: response.statusText });
138
+ const dataRaw = await response.text();
139
+ const data = new XMLParser(XML_PARSER_OPTIONS).parse(dataRaw);
140
+ const { success, issues, output } = v.safeParse(responseSchema, data);
141
+ if (!success) throw createSchemaValidationError("Failed to parse OCHRE item OCR data", issues);
142
+ const { found, ocrString } = output.result.ochre.ocrStrings;
143
+ if (found !== "true") throw new Error(`No OCHRE item found for UUID: ${parameters.uuid}`, { cause: parameters.uuid });
144
+ const parsedOcrStrings = ocrString == null ? [] : Array.isArray(ocrString) ? ocrString : [ocrString];
145
+ return {
146
+ ocrStrings: Array.from(parsedOcrStrings, (parsedOcrString) => ({
147
+ resourceUuid: parsedOcrString.resourceUuid !== "" ? parsedOcrString.resourceUuid : null,
148
+ content: parsedOcrString.content,
149
+ x: parseOcrStringNumber(parsedOcrString.x),
150
+ y: parseOcrStringNumber(parsedOcrString.y),
151
+ width: parseOcrStringNumber(parsedOcrString.width),
152
+ height: parseOcrStringNumber(parsedOcrString.height),
153
+ vertices: parseOcrStringVertices(parsedOcrString.vertices)
154
+ })),
155
+ error: null,
156
+ detailedError: null
157
+ };
158
+ } catch (error) {
159
+ return {
160
+ ocrStrings: null,
161
+ ...getErrorOutput(error, "Failed to fetch item OCR data")
162
+ };
163
+ }
164
+ }
165
+ //#endregion
166
+ export { fetchItemOcrData };
@@ -1,5 +1,5 @@
1
1
  import { XML_PARSER_OPTIONS } from "../constants.mjs";
2
- import { createSchemaValidationError, getErrorOutput, stringLiteral } from "../utilities.mjs";
2
+ import { SUPPLEMENTAL_XQUERY_PROLOG, createSchemaValidationError, getErrorOutput, omitSupplemental, stringLiteral } from "../utilities.mjs";
3
3
  import { iso639_3Schema, uuidSchema } from "../schemas.mjs";
4
4
  import { restoreXMLMetadata } from "../xml/metadata.mjs";
5
5
  import { parseItem } from "../parsers/index.mjs";
@@ -31,27 +31,24 @@ function assertItemCategoryAllowed(category, containedItemCategory) {
31
31
  for (const possibleCategory of categories) if (isItemContainerCategory(possibleCategory)) return;
32
32
  throw new Error(`containedItemCategory can only be used when category is "tree" or "set"; received category "${categories.join(", ")}"`);
33
33
  }
34
- function buildOmitEmbeddedItemsXQuery(uuid, category) {
35
- const collectionCategories = [];
36
- const categories = category == null ? [
37
- "tree",
38
- "bibliography",
39
- "concept",
40
- "spatialUnit",
41
- "period",
42
- "resource",
43
- "set"
44
- ] : typeof category === "string" ? [category] : category;
45
- for (const possibleCategory of categories) if (!collectionCategories.includes(possibleCategory)) collectionCategories.push(possibleCategory);
46
- const collectionQueries = Array.from(collectionCategories, (collectionCategory) => `cts:search(fn:collection("ochre/${collectionCategory}")/ochre, $uuid-query)`);
47
- return `xquery version "1.0-ml";
48
-
49
- let $uuid := ${stringLiteral(uuid)}
50
- let $uuid-query := cts:element-attribute-value-query(xs:QName("ochre"), xs:QName("uuid"), $uuid, "exact")
51
- let $ochre := (
52
- ${collectionQueries.join(",\n ")}
53
- )[1]
54
- let $item := (
34
+ /**
35
+ * Build an XQuery string to fetch a single OCHRE item document by UUID.
36
+ *
37
+ * Nodes marked `supplemental="true"` are always dropped. `$item` only ever
38
+ * binds the item categories that carry embedded items, so the omission branch
39
+ * is a no-op for every other category.
40
+ *
41
+ * @param parameters - The parameters for the fetch
42
+ * @param parameters.uuid - The UUID of the OCHRE item to fetch
43
+ * @param parameters.shouldOmitEmbeddedItems - Whether to drop the embedded item hierarchy
44
+ * @returns An XQuery string
45
+ */
46
+ function buildXQuery(parameters) {
47
+ const { uuid, shouldOmitEmbeddedItems } = parameters;
48
+ const letClauses = [`let $ochre := doc(${stringLiteral(uuid)})/ochre`];
49
+ let itemNodesExpression = "$ochre/node()";
50
+ if (shouldOmitEmbeddedItems) {
51
+ letClauses.push(`let $item := (
55
52
  $ochre/tree,
56
53
  $ochre/bibliography,
57
54
  $ochre/concept,
@@ -59,17 +56,25 @@ let $item := (
59
56
  $ochre/period,
60
57
  $ochre/resource,
61
58
  $ochre/set
62
- )[1]
63
- let $embedded-child-name := if (local-name($item) = ("tree", "set")) then "items" else local-name($item)
59
+ )[1]`, `let $embedded-child-name := if (local-name($item) = ("tree", "set")) then "items" else local-name($item)`);
60
+ itemNodesExpression = `(
61
+ for $node in $ochre/node()
62
+ return
63
+ if ($node is $item)
64
+ then element { node-name($item) } { $item/@*, $item/node()[not(self::*[local-name() = $embedded-child-name])] }
65
+ else $node
66
+ )`;
67
+ }
68
+ return `xquery version "1.0-ml";
69
+
70
+ ${SUPPLEMENTAL_XQUERY_PROLOG}
71
+
72
+ ${letClauses.join("\n")}
64
73
  return
65
- if (empty($ochre) or empty($item)) then ()
74
+ if (empty($ochre)) then ()
66
75
  else element ochre {
67
76
  $ochre/@*,
68
- for $node in $ochre/node()
69
- return
70
- if ($node is $item)
71
- then element { node-name($item) } { $item/@*, $item/node()[not(self::*[local-name() = $embedded-child-name])] }
72
- else $node
77
+ ${omitSupplemental(itemNodesExpression)}
73
78
  }`;
74
79
  }
75
80
  function omitEmbeddedItems(item) {
@@ -106,39 +111,19 @@ async function fetchItem(uuid, options) {
106
111
  const parsedUuid = v.parse(uuidSchema, uuid);
107
112
  assertItemCategoryAllowed(options?.category, options?.containedItemCategory);
108
113
  const shouldOmitEmbeddedItems = options?.shouldOmitEmbeddedItems === true;
109
- let shouldFetchOmittedEmbeddedItems = shouldOmitEmbeddedItems;
110
- let omitEmbeddedItemsCategory;
111
- if (options?.category != null) if (typeof options.category === "string") if (isItemCategoryWithEmbeddedItems(options.category)) omitEmbeddedItemsCategory = options.category;
112
- else shouldFetchOmittedEmbeddedItems = false;
113
- else {
114
- const categories = [];
115
- for (const possibleCategory of options.category) if (isItemCategoryWithEmbeddedItems(possibleCategory)) categories.push(possibleCategory);
116
- omitEmbeddedItemsCategory = categories;
117
- shouldFetchOmittedEmbeddedItems = shouldOmitEmbeddedItems && categories.length > 0;
118
- }
119
114
  const languages = options?.languages == null ? [] : parseLanguages(options.languages);
120
- const fetcher = options?.fetch ?? fetch;
121
- const regularItemUrl = `https://ochre.lib.uchicago.edu/ochre/v2/ochre.php?uuid=${parsedUuid}&xsl=none&lang="*"`;
122
- let response = shouldFetchOmittedEmbeddedItems ? await fetcher("https://ochre.lib.uchicago.edu/ochre/v2/ochre.php?xquery&xsl=none&lang=\"*\"", {
115
+ const response = await (options?.fetch ?? fetch)("https://ochre.lib.uchicago.edu/ochre/v2/ochre.php?xquery&xsl=none&lang=\"*\"", {
123
116
  method: "POST",
124
- body: buildOmitEmbeddedItemsXQuery(parsedUuid, omitEmbeddedItemsCategory),
117
+ body: buildXQuery({
118
+ uuid: parsedUuid,
119
+ shouldOmitEmbeddedItems
120
+ }),
125
121
  headers: { "Content-Type": "application/xquery" }
126
- }) : await fetcher(regularItemUrl);
122
+ });
127
123
  if (!response.ok) throw new Error("Failed to fetch OCHRE data", { cause: response.statusText });
128
124
  const dataRaw = await response.text();
129
- const parser = new XMLParser(XML_PARSER_OPTIONS);
130
- let data = parser.parse(dataRaw);
131
- if (shouldFetchOmittedEmbeddedItems && typeof data === "object" && data != null && "result" in data) {
132
- const result = data.result;
133
- if (typeof result === "object" && result != null && "ochre" in result) {
134
- const ochre = result.ochre;
135
- if (typeof ochre === "object" && ochre != null && (Object.keys(ochre).length === 0 || "payload" in ochre && ochre.payload === "" && Object.keys(ochre).length === 1)) {
136
- response = await fetcher(regularItemUrl);
137
- if (!response.ok) throw new Error("Failed to fetch OCHRE data", { cause: response.statusText });
138
- data = parser.parse(await response.text());
139
- }
140
- }
141
- }
125
+ const data = new XMLParser(XML_PARSER_OPTIONS).parse(dataRaw);
126
+ if (data.result?.ochre?.uuid == null) throw new Error(`No OCHRE item found for UUID "${parsedUuid}"`, { cause: dataRaw });
142
127
  const { success, issues, output } = v.safeParse(XMLData, data);
143
128
  if (!success) throw createSchemaValidationError("Failed to parse OCHRE data", issues);
144
129
  restoreXMLMetadata(output, data);
@@ -1,10 +1,10 @@
1
1
  import { BELONGS_TO_COLLECTION_UUID, DEFAULT_LANGUAGES, XML_PARSER_OPTIONS } from "../../constants.mjs";
2
- import { createSchemaValidationError, getErrorOutput, stringLiteral } from "../../utilities.mjs";
3
- import { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan } from "../../query.mjs";
2
+ import { SUPPLEMENTAL_XQUERY_PROLOG, createSchemaValidationError, getErrorOutput, omitSupplemental, stringLiteral } from "../../utilities.mjs";
4
3
  import { iso639_3Schema, setItemsParametersSchema } from "../../schemas.mjs";
5
4
  import { restoreXMLMetadata } from "../../xml/metadata.mjs";
6
5
  import { parseSetItems } from "../../parsers/index.mjs";
7
6
  import { XMLSetItemsData } from "../../xml/schemas.mjs";
7
+ import { buildBelongsToCollectionQueryExpression, buildQueryPlan } from "../../query.mjs";
8
8
  import * as v from "valibot";
9
9
  import { XMLParser } from "fast-xml-parser";
10
10
  //#region src/fetchers/set/items.ts
@@ -138,30 +138,28 @@ function buildXQuery(parameters) {
138
138
  const { queries, sort, setScopeUuids, belongsToCollectionScopeUuids, page, pageSize } = parameters;
139
139
  const startPosition = (page - 1) * pageSize + 1;
140
140
  const setScopeDeclaration = `declare variable $setScopeUuids := (${setScopeUuids.map((uuid) => stringLiteral(uuid)).join(", ")});`;
141
- const compiledQueryPlan = buildQueryPlan({ queries });
142
- const baseItemsExpression = `doc()/ochre/set[@uuid = $setScopeUuids]/items/*${compiledQueryPlan.itemPredicates}`;
143
- const itemsQueryExpressions = [];
144
- const belongsToCollectionQueryExpression = buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID);
145
- if (compiledQueryPlan.queryExpression != null) itemsQueryExpressions.push(compiledQueryPlan.queryExpression);
146
- if (belongsToCollectionQueryExpression != null) itemsQueryExpressions.push(belongsToCollectionQueryExpression);
147
- const itemsQueryExpression = buildAndCtsQueryExpression(itemsQueryExpressions);
141
+ const compiledQueryPlan = buildQueryPlan({
142
+ queries,
143
+ baseItemsExpression: "doc()/ochre/set[@uuid = $setScopeUuids]/items/*",
144
+ scopeQueryExpression: buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID)
145
+ });
148
146
  const orderedItemsClause = buildOrderedItemsClause(sort);
149
- const xqueryDeclarations = ["xquery version \"1.0-ml\";", setScopeDeclaration];
147
+ const xqueryDeclarations = [
148
+ "xquery version \"1.0-ml\";",
149
+ setScopeDeclaration,
150
+ SUPPLEMENTAL_XQUERY_PROLOG
151
+ ];
150
152
  if (compiledQueryPlan.prolog !== "") xqueryDeclarations.push(compiledQueryPlan.prolog);
151
- const letClauses = Array.from(compiledQueryPlan.ocrBindings, (binding) => `let ${binding.name} := ${binding.expression}`);
152
- if (itemsQueryExpression == null) letClauses.push(`let $items := ${baseItemsExpression}`);
153
- else letClauses.push(`let $query := ${itemsQueryExpression}`, `let $items := cts:search(${baseItemsExpression}, $query)`);
154
- const itemsClause = letClauses.join("\n ");
155
153
  return `${xqueryDeclarations.join("\n\n")}
156
154
 
157
155
  <ochre>{
158
- ${itemsClause}
156
+ ${compiledQueryPlan.itemsClause}
159
157
  let $totalCount := count($items)
160
158
  ${orderedItemsClause}
161
159
  let $pagedItems := subsequence($orderedItems, ${startPosition}, ${pageSize})
162
160
 
163
161
  return <items totalCount="{$totalCount}" page="${page}" pageSize="${pageSize}">{
164
- $pagedItems
162
+ ${omitSupplemental("$pagedItems")}
165
163
  }</items>
166
164
  }</ochre>`;
167
165
  }
@@ -1,9 +1,9 @@
1
1
  import { BELONGS_TO_COLLECTION_UUID, DEFAULT_LANGUAGES, XML_PARSER_OPTIONS } from "../../constants.mjs";
2
- import { createSchemaValidationError, getErrorOutput, stringLiteral } from "../../utilities.mjs";
2
+ import { NOT_SUPPLEMENTAL_PREDICATE, createSchemaValidationError, getErrorOutput, stringLiteral } from "../../utilities.mjs";
3
3
  import { MultilingualString } from "../../parsers/multilingual.mjs";
4
- import { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan } from "../../query.mjs";
5
4
  import { setPropertyValuesParametersSchema } from "../../schemas.mjs";
6
5
  import { parseXMLContent } from "../../parsers/string.mjs";
6
+ import { buildBelongsToCollectionQueryExpression, buildQueryPlan } from "../../query.mjs";
7
7
  import * as v from "valibot";
8
8
  import { XMLParser } from "fast-xml-parser";
9
9
  //#region src/fetchers/set/property-values.ts
@@ -190,13 +190,11 @@ const responseSchema = v.object({ result: v.object({ ochre: v.object({
190
190
  function buildXQuery(parameters) {
191
191
  const { setScopeUuids, belongsToCollectionScopeUuids, queries, propertyFacetSelectors, attributes, isLimitedToLeafPropertyValues } = parameters;
192
192
  const setScopeDeclaration = `declare variable $setScopeUuids := (${setScopeUuids.map((uuid) => stringLiteral(uuid)).join(", ")});`;
193
- const compiledQueryPlan = buildQueryPlan({ queries: getItemFilterQueriesFromPropertyValueQueries(queries) });
194
- const baseItemsExpression = `doc()/ochre/set[@uuid = $setScopeUuids]/items/*${compiledQueryPlan.itemPredicates}`;
195
- const itemsQueryExpressions = [];
196
- const belongsToCollectionQueryExpression = buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID);
197
- if (compiledQueryPlan.queryExpression != null) itemsQueryExpressions.push(compiledQueryPlan.queryExpression);
198
- if (belongsToCollectionQueryExpression != null) itemsQueryExpressions.push(belongsToCollectionQueryExpression);
199
- const itemsQueryExpression = buildAndCtsQueryExpression(itemsQueryExpressions);
193
+ const compiledQueryPlan = buildQueryPlan({
194
+ queries: getItemFilterQueriesFromPropertyValueQueries(queries),
195
+ baseItemsExpression: "doc()/ochre/set[@uuid = $setScopeUuids]/items/*",
196
+ scopeQueryExpression: buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID)
197
+ });
200
198
  const valueFilter = isLimitedToLeafPropertyValues ? "[not(@i)]" : "";
201
199
  const queryBlocks = [];
202
200
  const returnedSequences = [];
@@ -331,9 +329,9 @@ let $_property-aggregation := xdmp:eager(
331
329
  let $global-seen := map:map()
332
330
  let $variable-seen := map:map()
333
331
  return
334
- for $p in $item/properties/property[${facetPropertyPredicate}]
332
+ for $p in $item/properties/property[${facetPropertyPredicate}]${NOT_SUPPLEMENTAL_PREDICATE}
335
333
  let $variable-uuid := string($p/label/@uuid)
336
- for $v in $p/value${valueFilter}
334
+ for $v in $p/value${valueFilter}${NOT_SUPPLEMENTAL_PREDICATE}
337
335
  let $value-uuid := string($v/@uuid)
338
336
  let $raw-value := string($v/@rawValue)
339
337
  let $data-type := string($v/@dataType)
@@ -370,7 +368,7 @@ let $_bibliography-aggregation := xdmp:eager(
370
368
  for $item in $items
371
369
  let $seen := map:map()
372
370
  return
373
- for $bibliography in $item/bibliographies/bibliography
371
+ for $bibliography in $item/bibliographies/bibliography${NOT_SUPPLEMENTAL_PREDICATE}
374
372
  let $label := string-join($bibliography/identification/label/content[@xml:lang="eng"]//text(), "")
375
373
  where string-length($label) gt 0
376
374
  return local:add-attribute-facet($bibliography-counts, $seen, $label)
@@ -390,7 +388,7 @@ let $_period-aggregation := xdmp:eager(
390
388
  for $item in $items
391
389
  let $seen := map:map()
392
390
  return
393
- for $period in $item/periods/period
391
+ for $period in $item/periods/period${NOT_SUPPLEMENTAL_PREDICATE}
394
392
  let $label := string-join($period/identification/label/content[@xml:lang="eng"]//text(), "")
395
393
  where string-length($label) gt 0
396
394
  return local:add-attribute-facet($period-counts, $seen, $label)
@@ -404,14 +402,10 @@ let $period-values :=
404
402
  )`);
405
403
  returnedSequences.push("$period-values");
406
404
  }
407
- const letClauses = Array.from(compiledQueryPlan.ocrBindings, (binding) => `let ${binding.name} := ${binding.expression}`);
408
- if (itemsQueryExpression == null) letClauses.push(`let $items := ${baseItemsExpression}`);
409
- else letClauses.push(`let $query := ${itemsQueryExpression}`, `let $items := cts:search(${baseItemsExpression}, $query)`);
410
- const itemsClause = letClauses.join("\n ");
411
405
  return `${xqueryDeclarations.join("\n\n")}
412
406
 
413
407
  <ochre>{
414
- ${itemsClause}
408
+ ${compiledQueryPlan.itemsClause}
415
409
  ${queryBlocks.join("\n\n")}
416
410
 
417
411
  return (${returnedSequences.join(", ")})