ochre-sdk 1.0.73 → 1.0.75

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
@@ -121,9 +121,37 @@ const result = await fetchSetItems(
121
121
  Use `fetchSetPropertyValues` with the same query shape when you need facet data
122
122
  for a filtered result set.
123
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 word node in that layer holds a single OCR word in its `CONTENT` attribute, 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
+
124
152
  ## OCR Data
125
153
 
126
- 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.
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 word node within it, at any depth, is read as one positioned OCR string. A word node is any element named `string` in any casing and any namespace, and its text comes from the `CONTENT` attribute rather than from the element's text content.
127
155
 
128
156
  ```ts
129
157
  import { fetchItemOcrData } from "ochre-sdk";
@@ -137,9 +165,9 @@ for (const ocrString of result.ocrStrings ?? []) {
137
165
  }
138
166
  ```
139
167
 
140
- `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.
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` comes from `VERTICES` and is its full bounding polygon, which is not always rectangular. Each geometry field is null when the source attribute is absent or unparseable, and `vertices` is then empty. `resourceUuid` names the Resource that owns the OCR layer, which differs from the requested item when the OCR lives on a child Resource.
141
169
 
142
- 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, which pairs with how the `ocrText` query target tokenizes. Requesting an item that does not exist is an error; an item with no OCR layer, or no matches, returns an empty array.
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.
143
171
 
144
172
  ## Helpers And Types
145
173
 
@@ -4,12 +4,15 @@ import { OcrString } from "../types/index.mjs";
4
4
  * Fetches and parses the OCR strings of an OCHRE item that match a search value
5
5
  *
6
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.
7
+ * and therefore not parsed. Only its word nodes are returned, wherever they
8
+ * occur in that subtree, in document order. A word node is any element whose
9
+ * name is `string` in any casing and any namespace, and matching reads its
10
+ * `CONTENT` attribute rather than its text content.
11
+ *
12
+ * Each word node holds a single OCR word, so a multi-word search value is split
13
+ * on whitespace and a node is returned when it matches any one term. Nested
14
+ * child Resources are searched too, with `resourceUuid` naming the Resource
15
+ * each match belongs to.
13
16
  *
14
17
  * @param uuid - The UUID of the OCHRE item to read the OCR layer of
15
18
  * @param value - The search value to match against each OCR string's content
@@ -52,9 +52,14 @@ function parseOcrStringVertices(value) {
52
52
  * Build an XQuery string to fetch matching OCR strings from the OCHRE API
53
53
  *
54
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.
55
+ * the supplemental stripping the other fetchers apply. Word nodes are projected
56
+ * at any depth, because OCHRE does not guarantee the shape of the surrounding
57
+ * hierarchy.
58
+ *
59
+ * Both the container and the word nodes are matched on a case-folded
60
+ * `local-name()` rather than a name test, because OCHRE varies the casing of
61
+ * these elements and may serve them in a namespace. A plain `//ocr//string`
62
+ * name test silently matches nothing in either of those cases.
58
63
  *
59
64
  * The matches are wrapped in an `<ocrStrings>` element rather than returned
60
65
  * directly under `<ochre>`: the API collapses an `<ochre>` element that has no
@@ -78,10 +83,10 @@ declare variable $terms := (${termValues.join(", ")});
78
83
 
79
84
  let $ochre := doc(${stringLiteral(uuid)})/ochre
80
85
  let $ocrStrings :=
81
- for $string in $ochre//ocr//string[@CONTENT]
86
+ for $string in $ochre//*[lower-case(local-name(.)) = "ocr"]//*[lower-case(local-name(.)) = "string"][@CONTENT]
82
87
  where (some $term in $terms satisfies ${matchExpression})
83
88
  return <ocrString
84
- resourceUuid="{string($string/ancestor::resource[1]/@uuid)}"
89
+ resourceUuid="{string($string/ancestor::*[local-name(.) = "resource"][1]/@uuid)}"
85
90
  content="{string($string/@CONTENT)}"
86
91
  x="{string($string/@HPOS)}"
87
92
  y="{string($string/@VPOS)}"
@@ -95,12 +100,15 @@ return <ochre><ocrStrings found="{exists($ochre)}">{$ocrStrings}</ocrStrings></o
95
100
  * Fetches and parses the OCR strings of an OCHRE item that match a search value
96
101
  *
97
102
  * 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.
103
+ * and therefore not parsed. Only its word nodes are returned, wherever they
104
+ * occur in that subtree, in document order. A word node is any element whose
105
+ * name is `string` in any casing and any namespace, and matching reads its
106
+ * `CONTENT` attribute rather than its text content.
107
+ *
108
+ * Each word node holds a single OCR word, so a multi-word search value is split
109
+ * on whitespace and a node is returned when it matches any one term. Nested
110
+ * child Resources are searched too, with `resourceUuid` naming the Resource
111
+ * each match belongs to.
104
112
  *
105
113
  * @param uuid - The UUID of the OCHRE item to read the OCR layer of
106
114
  * @param value - The search value to match against each OCR string's content
@@ -4,7 +4,7 @@ import { iso639_3Schema, setItemsParametersSchema } from "../../schemas.mjs";
4
4
  import { restoreXMLMetadata } from "../../xml/metadata.mjs";
5
5
  import { parseSetItems } from "../../parsers/index.mjs";
6
6
  import { XMLSetItemsData } from "../../xml/schemas.mjs";
7
- import { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan } from "../../query.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,9 +138,11 @@ 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/*";
143
- const belongsToCollectionQueryExpression = buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID);
141
+ const compiledQueryPlan = buildQueryPlan({
142
+ queries,
143
+ baseItemsExpression: "doc()/ochre/set[@uuid = $setScopeUuids]/items/*",
144
+ scopeQueryExpression: buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID)
145
+ });
144
146
  const orderedItemsClause = buildOrderedItemsClause(sort);
145
147
  const xqueryDeclarations = [
146
148
  "xquery version \"1.0-ml\";",
@@ -148,28 +150,10 @@ function buildXQuery(parameters) {
148
150
  SUPPLEMENTAL_XQUERY_PROLOG
149
151
  ];
150
152
  if (compiledQueryPlan.prolog !== "") xqueryDeclarations.push(compiledQueryPlan.prolog);
151
- const letClauses = Array.from(compiledQueryPlan.ocrTextBindings, (binding) => `let ${binding.name} := ${binding.expression}`);
152
- const branchExpressions = [];
153
- for (const [index, branch] of compiledQueryPlan.branches.entries()) {
154
- const branchQueryExpressions = [];
155
- if (branch.queryExpression != null) branchQueryExpressions.push(branch.queryExpression);
156
- if (belongsToCollectionQueryExpression != null) branchQueryExpressions.push(belongsToCollectionQueryExpression);
157
- const branchQueryExpression = buildAndCtsQueryExpression(branchQueryExpressions);
158
- const branchItemsExpression = `${baseItemsExpression}${branch.itemPredicates}`;
159
- if (branchQueryExpression == null) {
160
- branchExpressions.push(branchItemsExpression);
161
- continue;
162
- }
163
- const queryVariableName = compiledQueryPlan.branches.length === 1 ? "$query" : `$query${index + 1}`;
164
- letClauses.push(`let ${queryVariableName} := ${branchQueryExpression}`);
165
- branchExpressions.push(`cts:search(${branchItemsExpression}, ${queryVariableName})`);
166
- }
167
- letClauses.push(`let $items := ${branchExpressions.length === 1 ? branchExpressions[0] : `(${branchExpressions.join(" | ")})`}`);
168
- const itemsClause = letClauses.join("\n ");
169
153
  return `${xqueryDeclarations.join("\n\n")}
170
154
 
171
155
  <ochre>{
172
- ${itemsClause}
156
+ ${compiledQueryPlan.itemsClause}
173
157
  let $totalCount := count($items)
174
158
  ${orderedItemsClause}
175
159
  let $pagedItems := subsequence($orderedItems, ${startPosition}, ${pageSize})
@@ -3,7 +3,7 @@ import { NOT_SUPPLEMENTAL_PREDICATE, createSchemaValidationError, getErrorOutput
3
3
  import { MultilingualString } from "../../parsers/multilingual.mjs";
4
4
  import { setPropertyValuesParametersSchema } from "../../schemas.mjs";
5
5
  import { parseXMLContent } from "../../parsers/string.mjs";
6
- import { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan } from "../../query.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,9 +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/*";
195
- const belongsToCollectionQueryExpression = buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, BELONGS_TO_COLLECTION_UUID);
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
+ });
196
198
  const valueFilter = isLimitedToLeafPropertyValues ? "[not(@i)]" : "";
197
199
  const queryBlocks = [];
198
200
  const returnedSequences = [];
@@ -400,28 +402,10 @@ let $period-values :=
400
402
  )`);
401
403
  returnedSequences.push("$period-values");
402
404
  }
403
- const letClauses = Array.from(compiledQueryPlan.ocrTextBindings, (binding) => `let ${binding.name} := ${binding.expression}`);
404
- const branchExpressions = [];
405
- for (const [index, branch] of compiledQueryPlan.branches.entries()) {
406
- const branchQueryExpressions = [];
407
- if (branch.queryExpression != null) branchQueryExpressions.push(branch.queryExpression);
408
- if (belongsToCollectionQueryExpression != null) branchQueryExpressions.push(belongsToCollectionQueryExpression);
409
- const branchQueryExpression = buildAndCtsQueryExpression(branchQueryExpressions);
410
- const branchItemsExpression = `${baseItemsExpression}${branch.itemPredicates}`;
411
- if (branchQueryExpression == null) {
412
- branchExpressions.push(branchItemsExpression);
413
- continue;
414
- }
415
- const queryVariableName = compiledQueryPlan.branches.length === 1 ? "$query" : `$query${index + 1}`;
416
- letClauses.push(`let ${queryVariableName} := ${branchQueryExpression}`);
417
- branchExpressions.push(`cts:search(${branchItemsExpression}, ${queryVariableName})`);
418
- }
419
- letClauses.push(`let $items := ${branchExpressions.length === 1 ? branchExpressions[0] : `(${branchExpressions.join(" | ")})`}`);
420
- const itemsClause = letClauses.join("\n ");
421
405
  return `${xqueryDeclarations.join("\n\n")}
422
406
 
423
407
  <ochre>{
424
- ${itemsClause}
408
+ ${compiledQueryPlan.itemsClause}
425
409
  ${queryBlocks.join("\n\n")}
426
410
 
427
411
  return (${returnedSequences.join(", ")})
@@ -6,7 +6,7 @@ import { parseWebsite } from "../parsers/website/index.mjs";
6
6
  import * as v from "valibot";
7
7
  import { XMLParser } from "fast-xml-parser";
8
8
  //#region src/fetchers/website.ts
9
- async function validateWebsiteCredentials(uuid, credentials, fetcher) {
9
+ async function areWebsiteCredentialsValid(uuid, credentials, fetcher) {
10
10
  const security = typeof credentials === "string" ? { validate: credentials } : {
11
11
  validate: credentials.password,
12
12
  userOCHRE: credentials.username
@@ -69,7 +69,7 @@ async function fetchWebsite(abbreviation, options) {
69
69
  error: null,
70
70
  detailedError: null
71
71
  };
72
- if (!await validateWebsiteCredentials(website.uuid, options.credentials, fetcher)) throw new Error("Invalid credentials for protected website");
72
+ if (!await areWebsiteCredentialsValid(website.uuid, options.credentials, fetcher)) throw new Error("Invalid credentials for protected website");
73
73
  }
74
74
  return {
75
75
  website,
@@ -4,9 +4,13 @@ import { LanguageCodes, Property, PropertyLike, PropertyValueContent, SetItemPro
4
4
  * Options for property search operations.
5
5
  */
6
6
  type PropertyOptions = {
7
- /** Whether to recursively search through nested properties. */
7
+ /**
8
+ Whether to recursively search through nested properties.
9
+ */
8
10
  includeNestedProperties?: boolean;
9
- /** Whether to limit property values to leaf values. */
11
+ /**
12
+ Whether to limit property values to leaf values.
13
+ */
10
14
  limitToLeafPropertyValues?: boolean;
11
15
  };
12
16
  type PropertyContent<T extends LanguageCodes> = PropertyValueContent<T>["content"];
@@ -26,11 +26,17 @@ type MultilingualStringEntries<T extends ReadonlyArray<string> = ReadonlyArray<s
26
26
  * Options for creating and working with multilingual strings
27
27
  */
28
28
  type MultilingualOptions = {
29
- /** Default language to use for fallbacks */
29
+ /**
30
+ Default language to use for fallbacks
31
+ */
30
32
  defaultLanguage?: string;
31
- /** Available languages for this string */
33
+ /**
34
+ Available languages for this string
35
+ */
32
36
  availableLanguages?: ReadonlyArray<string>;
33
- /** Alias values carried by OCHRE as zxx content */
37
+ /**
38
+ Alias values carried by OCHRE as zxx content
39
+ */
34
40
  aliases?: ReadonlyArray<string>;
35
41
  };
36
42
  type MultilingualContent<T extends ReadonlyArray<string>> = Partial<Record<T[number], ReadonlyArray<MultilingualStringEntry>>>;
@@ -78,7 +84,9 @@ declare class MultilingualString<T extends ReadonlyArray<string> = ReadonlyArray
78
84
  /**
79
85
  * Create a new multilingual string from an object of language codes to text.
80
86
  */
81
- /** @internal */
87
+ /**
88
+ @internal
89
+ */
82
90
  constructor(init: MultilingualStringInternalInit<T>);
83
91
  constructor(content: MultilingualStringObject<T>, languages: T, options?: MultilingualOptions);
84
92
  constructor(content?: Partial<Record<string, MultilingualStringInput>>, languages?: undefined, options?: MultilingualOptions);
@@ -907,17 +907,18 @@ function parseWebpage(webpageResource, options, context, slugPrefix) {
907
907
  returnWebpage.properties.isNavbarSearchBarDisplayed = pageReader.valueOr("navbar-search-bar-displayed", true);
908
908
  const redirectValue = pageReader.valueNode("redirect-to");
909
909
  const redirectTarget = parseWebsiteLinkTarget(redirectValue, context);
910
- if (redirectTarget != null) if (redirectValue?.href == null && redirectValue?.uuid != null) returnWebpage.properties.redirect = {
911
- type: "page",
912
- slug: redirectTarget,
913
- uuid: redirectValue.uuid
914
- };
915
- else returnWebpage.properties.redirect = {
916
- type: "url",
917
- href: redirectTarget,
918
- isExternal: redirectTarget.startsWith("http")
919
- };
920
- else if (redirectValue?.uuid != null) returnWebpage.properties.redirect = {
910
+ if (redirectTarget != null) {
911
+ if (redirectValue?.href == null && redirectValue?.uuid != null) returnWebpage.properties.redirect = {
912
+ type: "page",
913
+ slug: redirectTarget,
914
+ uuid: redirectValue.uuid
915
+ };
916
+ else returnWebpage.properties.redirect = {
917
+ type: "url",
918
+ href: redirectTarget,
919
+ isExternal: redirectTarget.startsWith("http")
920
+ };
921
+ } else if (redirectValue?.uuid != null) returnWebpage.properties.redirect = {
921
922
  type: "item",
922
923
  uuid: redirectValue.uuid,
923
924
  pageType: "item"
@@ -1015,7 +1016,7 @@ function parseSidebar(resources, options, context) {
1015
1016
  }
1016
1017
  }
1017
1018
  }
1018
- if (items.length > 0 && title != null) returnSidebar = {
1019
+ if (title != null && items.length > 0) returnSidebar = {
1019
1020
  isDisplayed: true,
1020
1021
  items,
1021
1022
  title,
package/dist/query.d.mts CHANGED
@@ -1,31 +1,32 @@
1
1
  import { Query } from "./types/index.mjs";
2
2
  //#region src/query.d.ts
3
- declare function buildAndCtsQueryExpression(queryExpressions: Array<string>): string | null;
4
3
  declare function buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids: Array<string>, belongsToCollectionPropertyVariableUuid: string): string | null;
5
4
  /**
6
- * Compile a query tree into the CTS searches that resolve it
5
+ * Compile a query tree into the XQuery `let` clauses that bind `$items` to the
6
+ * matching Set items
7
7
  *
8
- * OCR text is not carried by Set item projections, so an `ocrText` leaf cannot
9
- * be a CTS term: it resolves to a document join whose UUID list can only be
10
- * applied as an item path predicate, and path predicates only ever AND. To keep
11
- * `ocrText` composable with `or` anyway, the tree is split on each distinct OCR
12
- * text condition, one branch per assignment of "this item is in that match
13
- * set". Every branch is a plain CTS search, and their union is the result.
14
- * Branches that the assignment already rules out are dropped, so a query whose
15
- * OCR text leaves are all conjunctive still compiles to a single search.
8
+ * Most queries compile to a single `cts:search` over the Set item projections.
9
+ * An `ocr` leaf cannot: the projections drop the `<ocr>` layer, so it resolves
10
+ * to a search over the Resource documents whose matching UUIDs are joined back
11
+ * in as an item path predicate. Path predicates only ever AND, so an `ocr` leaf
12
+ * that sits under an `or` becomes its own arm of a node union instead, and one
13
+ * that sits under an `and` alongside a union becomes an intersection.
14
+ *
15
+ * The searchable path has to stay inline in `cts:search`: binding it to a
16
+ * variable first makes every query XDMP-UNSEARCHABLE.
17
+ * @param parameters - The parameters for the compilation
18
+ * @param parameters.queries - Recursive query tree to compile, if any
19
+ * @param parameters.baseItemsExpression - The inline XQuery path selecting the items to search
20
+ * @param parameters.scopeQueryExpression - An optional CTS query ANDed into every compiled search
21
+ * @returns The prolog declaring the query helpers, and the `let` clauses binding `$items`
16
22
  */
17
23
  declare function buildQueryPlan(parameters: {
18
24
  queries: Query | null;
25
+ baseItemsExpression: string;
26
+ scopeQueryExpression?: string | null;
19
27
  }): {
20
28
  prolog: string;
21
- ocrTextBindings: Array<{
22
- name: string;
23
- expression: string;
24
- }>;
25
- branches: Array<{
26
- itemPredicates: string;
27
- queryExpression: string | null;
28
- }>;
29
+ itemsClause: string;
29
30
  };
30
31
  //#endregion
31
- export { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan };
32
+ export { buildBelongsToCollectionQueryExpression, buildQueryPlan };
package/dist/query.mjs CHANGED
@@ -12,7 +12,6 @@ const CTS_INCLUDES_STOP_WORDS = /* @__PURE__ */ new Set([
12
12
  const CTS_INCLUDES_TOKEN_WORD_REGEX = /^\p{L}+$/u;
13
13
  const CTS_INCLUDES_TOKEN_REGEX = /[\p{L}\p{N}*?]+/gu;
14
14
  const CTS_EXACT_TEXT_TOKEN_REGEX = /[\p{L}\p{N}]+/gu;
15
- const MAX_OCR_TEXT_CONDITIONS = 4;
16
15
  const CONTENT_TARGET_CONTENT_ELEMENT_PATHS = {
17
16
  title: [
18
17
  "identification",
@@ -239,7 +238,8 @@ function buildRichTextContentQueryExpression(parameters) {
239
238
  const { value, matchMode, isCaseSensitive, language } = parameters;
240
239
  return buildAndCtsQueryExpressionInternal([buildContentLanguageQuery(language), matchMode === "exact" ? buildRichTextExactQueryExpression({
241
240
  value,
242
- isCaseSensitive
241
+ isCaseSensitive,
242
+ language
243
243
  }) : buildCtsWordQueryExpression({
244
244
  value,
245
245
  matchMode,
@@ -472,83 +472,121 @@ function buildItemStringQueryExpression(parameters) {
472
472
  language
473
473
  })]);
474
474
  }
475
- function buildOcrTextQueryExpression(query) {
476
- const { value, matchMode, isCaseSensitive } = query;
477
- const phraseQueryExpression = buildRichTextPhraseQueryExpression({
478
- value,
475
+ const OCR_STRING_QNAMES = `(xs:QName("String"), fn:QName("http://www.loc.gov/standards/alto/ns-v2#", "string"))`;
476
+ function tokenizeOcrExactValue(value) {
477
+ const terms = [];
478
+ for (const term of value.split(/\s+/u)) if (term !== "") terms.push(term);
479
+ return terms;
480
+ }
481
+ /**
482
+ * Word queries against the OCR layer cannot carry a stemming option: the OCHRE
483
+ * database has unstemmed word searches turned off, and asking a word query for
484
+ * `unstemmed` fails with `XDMP-WORDSEARCH`. Omitting the option altogether
485
+ * resolves the term against the database default instead.
486
+ */
487
+ function buildOcrWordQueryExpression(parameters) {
488
+ const { value, isCaseSensitive } = parameters;
489
+ const options = [
490
+ isCaseSensitive ? "case-sensitive" : "case-insensitive",
491
+ "diacritic-insensitive",
492
+ "punctuation-insensitive",
493
+ "whitespace-insensitive"
494
+ ];
495
+ if (hasWildcardCharacters(value)) options.push("wildcarded");
496
+ return `cts:element-attribute-word-query(${OCR_STRING_QNAMES}, xs:QName("CONTENT"), ${stringLiteral(value)}, (${options.map((option) => stringLiteral(option)).join(", ")}))`;
497
+ }
498
+ function buildOcrValueQueryExpression(parameters) {
499
+ const { value, isCaseSensitive } = parameters;
500
+ return `cts:element-attribute-value-query(${OCR_STRING_QNAMES}, xs:QName("CONTENT"), ${stringLiteral(value)}, ${buildWordQueryOptionsExpression({
501
+ matchMode: "exact",
479
502
  isCaseSensitive
480
- });
481
- if (matchMode === "exact") return buildNestedElementQuery(["ocrText"], phraseQueryExpression);
503
+ })})`;
504
+ }
505
+ /**
506
+ * Compile an OCR text search into a query over the `<ocr>` layer of a Resource
507
+ * document
508
+ *
509
+ * Each word node in that layer holds a single OCR word in its `CONTENT`
510
+ * attribute, so `includes` matches every search term as a word inside that
511
+ * attribute anywhere in the layer, and `exact` requires every term to equal a
512
+ * whole `CONTENT` value.
513
+ *
514
+ * The conjunction is only an index narrowing for `exact`. Attribute values
515
+ * carry no word positions, so `cts:near-query` over them silently degenerates
516
+ * into a conjunction and cannot express a phrase at all. Word order and
517
+ * adjacency are instead enforced by {@link registerOcrPhraseHelper} over the
518
+ * documents this narrowing returns.
519
+ */
520
+ function buildOcrQueryExpression(query) {
521
+ const { value, matchMode, isCaseSensitive } = query;
522
+ if (matchMode === "exact") {
523
+ const terms = tokenizeOcrExactValue(value);
524
+ if (terms.length === 0) return "cts:false-query()";
525
+ return buildNestedElementQuery(["ocr"], buildAndCtsQueryExpressionInternal(Array.from(terms, (term) => buildOcrValueQueryExpression({
526
+ value: term,
527
+ isCaseSensitive
528
+ }))));
529
+ }
482
530
  const terms = tokenizeIncludesSearchValue({
483
531
  value,
484
532
  isCaseSensitive
485
533
  });
486
534
  if (terms.length === 0) return "cts:false-query()";
487
- const tokenizedQueryExpression = buildAndCtsQueryExpressionInternal(Array.from(terms, (term) => buildCtsWordQueryExpression({
535
+ return buildNestedElementQuery(["ocr"], buildAndCtsQueryExpressionInternal(Array.from(terms, (term) => buildOcrWordQueryExpression({
488
536
  value: term,
489
- matchMode,
490
- isCaseSensitive,
491
- queryFamily: "text"
492
- })));
493
- return buildNestedElementQuery(["ocrText"], shouldUseFullValueFallbackForIncludes({
494
- value,
495
- isCaseSensitive,
496
- terms
497
- }) ? buildOrCtsQueryExpressionInternal([phraseQueryExpression, tokenizedQueryExpression]) : tokenizedQueryExpression);
498
- }
499
- function getOcrTextConditionKey(query) {
500
- return [
501
- query.value,
502
- query.matchMode,
503
- query.isCaseSensitive ? "case-sensitive" : "case-insensitive"
504
- ].join("|");
505
- }
506
- function registerOcrTextCondition(context, query) {
507
- const key = getOcrTextConditionKey(query);
508
- if (context.ocrTextConditionIndexesByKey.has(key)) return;
509
- context.ocrTextConditionIndexesByKey.set(key, context.ocrTextConditions.length);
510
- context.ocrTextConditions.push({
511
- variableName: `$ocrTextUuids${context.ocrTextConditions.length + 1}`,
512
- bindingExpression: `for $ocrTextDocument in cts:search(doc(), ${buildOcrTextQueryExpression(query)})\n return document-uri($ocrTextDocument)`
513
- });
514
- }
515
- function collectOcrTextConditions(context, query) {
516
- if (isQueryLeaf(query)) {
517
- if (query.target === "ocrText") registerOcrTextCondition(context, query);
518
- return;
519
- }
520
- for (const childQuery of getQueryGroupChildren(query)) collectOcrTextConditions(context, childQuery);
521
- }
522
- function isOcrTextLeafMatched(context, query, ocrTextValues) {
523
- const conditionIndex = context.ocrTextConditionIndexesByKey.get(getOcrTextConditionKey(query));
524
- const isMatched = conditionIndex != null && ocrTextValues[conditionIndex] === true;
525
- return query.isNegated === true ? !isMatched : isMatched;
537
+ isCaseSensitive
538
+ }))));
526
539
  }
527
540
  /**
528
- * Enumerate every assignment of "this item is in the OCR text match set" across
529
- * the compiled conditions, least significant position first
541
+ * Declare the filter that holds an `exact` multi-term search to a run of
542
+ * adjacent OCR words, which no CTS query over the layer can express
530
543
  */
531
- function getOcrTextValueCombinations(count) {
532
- return Array.from({ length: 2 ** count }, (_, index) => Array.from({ length: count }, (_, position) => (index >> position & 1) === 1));
544
+ function registerOcrPhraseHelper(context) {
545
+ const helperName = "local:ocrHasPhrase";
546
+ if (context.helperNamesByKey.has(helperName)) return helperName;
547
+ context.helperNamesByKey.set(helperName, helperName);
548
+ context.helperDeclarations.push(`declare function ${helperName}($resource as node(), $terms as xs:string*, $isCaseSensitive as xs:boolean) as xs:boolean {
549
+ let $contents :=
550
+ for $word in $resource//*[lower-case(local-name(.)) = "ocr"]//*[lower-case(local-name(.)) = "string"][@CONTENT]
551
+ return if ($isCaseSensitive) then string($word/@CONTENT) else lower-case(string($word/@CONTENT))
552
+ let $needles :=
553
+ for $term in $terms
554
+ return if ($isCaseSensitive) then $term else lower-case($term)
555
+ let $length := count($needles)
556
+ return
557
+ some $start in (1 to (count($contents) - $length + 1))
558
+ satisfies (
559
+ every $offset in (1 to $length)
560
+ satisfies $contents[$start + $offset - 1] = $needles[$offset]
561
+ )
562
+ };`);
563
+ return helperName;
533
564
  }
534
565
  /**
535
- * Resolve a query tree against one OCR text assignment, treating every CTS leaf
536
- * as unknown. Only a definite `false` is actionable: it means the branch cannot
537
- * match anything and can be dropped before it costs a `cts:search`.
566
+ * Bind the UUIDs of the Resource documents whose OCR layer matches a query,
567
+ * reusing the binding when the same search is requested more than once
538
568
  */
539
- function evaluateOcrTextBranch(context, query, ocrTextValues) {
540
- if (isQueryLeaf(query)) return query.target === "ocrText" ? isOcrTextLeafMatched(context, query, ocrTextValues) : null;
541
- const isAndGroup = "and" in query;
542
- let result = isAndGroup;
543
- for (const childQuery of getQueryGroupChildren(query)) {
544
- const childResult = evaluateOcrTextBranch(context, childQuery, ocrTextValues);
545
- if (childResult === !isAndGroup) return !isAndGroup;
546
- if (childResult == null) result = null;
547
- }
548
- return result;
549
- }
550
- function buildOcrTextItemPredicates(context, ocrTextValues) {
551
- return Array.from(context.ocrTextConditions, (condition, index) => ocrTextValues[index] === true ? `[@uuid = ${condition.variableName}]` : `[not(@uuid = ${condition.variableName})]`).join("");
569
+ function registerOcrBinding(context, query) {
570
+ const key = [
571
+ query.value,
572
+ query.matchMode,
573
+ query.isCaseSensitive ? "case-sensitive" : "case-insensitive"
574
+ ].join("|");
575
+ const existingName = context.ocrBindingNamesByKey.get(key);
576
+ if (existingName != null) return existingName;
577
+ const name = `$ocrItemUuids${context.ocrBindings.length + 1}`;
578
+ const queryExpression = buildOcrQueryExpression(query);
579
+ const phraseTerms = query.matchMode === "exact" ? tokenizeOcrExactValue(query.value) : [];
580
+ context.ocrBindingNamesByKey.set(key, name);
581
+ const searchExpression = `cts:search(/ochre/resource, ${queryExpression})`;
582
+ const phraseHelperName = phraseTerms.length > 1 ? registerOcrPhraseHelper(context) : null;
583
+ context.ocrBindings.push({
584
+ name,
585
+ expression: queryExpression === "cts:false-query()" ? "()" : phraseHelperName == null ? `${searchExpression}/@uuid/string()` : `for $ocrResource in ${searchExpression}
586
+ where ${phraseHelperName}($ocrResource, (${phraseTerms.map((term) => stringLiteral(term)).join(", ")}), ${query.isCaseSensitive ? "true()" : "false()"})
587
+ return string($ocrResource/@uuid)`
588
+ });
589
+ return name;
552
590
  }
553
591
  function getLeafSearchValue(query) {
554
592
  switch (query.target) {
@@ -631,8 +669,8 @@ function createQueryCompilerContext() {
631
669
  nextHelperSerial: 1,
632
670
  helperNamesByKey: /* @__PURE__ */ new Map(),
633
671
  helperDeclarations: [],
634
- ocrTextConditions: [],
635
- ocrTextConditionIndexesByKey: /* @__PURE__ */ new Map()
672
+ ocrBindingNamesByKey: /* @__PURE__ */ new Map(),
673
+ ocrBindings: []
636
674
  };
637
675
  }
638
676
  function registerConstantHelper(parameters) {
@@ -833,7 +871,7 @@ function getCompatibleIncludesGroupLeaves(query) {
833
871
  if (!("or" in query) || query.or.length <= 1) return null;
834
872
  const leafQueries = [];
835
873
  for (const childQuery of query.or) {
836
- if (!isQueryLeaf(childQuery) || childQuery.target === "ocrText") return null;
874
+ if (!isQueryLeaf(childQuery) || childQuery.target === "ocr") return null;
837
875
  leafQueries.push(childQuery);
838
876
  }
839
877
  const firstQuery = leafQueries[0];
@@ -894,16 +932,121 @@ function buildIncludesGroupQueryExpression(context, queries) {
894
932
  bodyExpression: buildOrCtsQueryExpressionInternal(exactMemberHelpers.map((helper) => helper.callExpression))
895
933
  }).callExpression, tokenizedQueryExpression]);
896
934
  }
897
- function buildQueryNode(context, query, ocrTextValues) {
935
+ function buildCtsItemsPlan(queryExpression) {
936
+ return {
937
+ kind: "search",
938
+ itemPredicates: [],
939
+ queryExpressions: [queryExpression]
940
+ };
941
+ }
942
+ /**
943
+ * Splice the children of same-kind child plans into their parent, so that a
944
+ * nested group of the same operator does not cost an extra search
945
+ */
946
+ function flattenItemsPlans(childPlans, kind) {
947
+ const flattenedPlans = [];
948
+ for (const childPlan of childPlans) {
949
+ if (childPlan.kind === kind) {
950
+ flattenedPlans.push(...childPlan.children);
951
+ continue;
952
+ }
953
+ flattenedPlans.push(childPlan);
954
+ }
955
+ return flattenedPlans;
956
+ }
957
+ /**
958
+ * Fold the children of an `and` group into one plan
959
+ *
960
+ * Conjunction is the direction the item path predicates already run in, so
961
+ * every child that is a plain search collapses into a single search, and only
962
+ * the children that resolved to a union stay separate.
963
+ */
964
+ function buildAndItemsPlan(childPlans) {
965
+ const mergedPlan = {
966
+ kind: "search",
967
+ itemPredicates: [],
968
+ queryExpressions: []
969
+ };
970
+ const unfoldablePlans = [];
971
+ for (const childPlan of flattenItemsPlans(childPlans, "intersect")) {
972
+ if (childPlan.kind !== "search") {
973
+ unfoldablePlans.push(childPlan);
974
+ continue;
975
+ }
976
+ for (const itemPredicate of childPlan.itemPredicates) if (!mergedPlan.itemPredicates.includes(itemPredicate)) mergedPlan.itemPredicates.push(itemPredicate);
977
+ mergedPlan.queryExpressions.push(...childPlan.queryExpressions);
978
+ }
979
+ if (unfoldablePlans.length === 0) return mergedPlan;
980
+ const intersectedPlans = mergedPlan.itemPredicates.length === 0 && mergedPlan.queryExpressions.length === 0 ? unfoldablePlans : [mergedPlan, ...unfoldablePlans];
981
+ return intersectedPlans.length === 1 ? intersectedPlans[0] ?? mergedPlan : {
982
+ kind: "intersect",
983
+ children: intersectedPlans
984
+ };
985
+ }
986
+ /**
987
+ * Fold the children of an `or` group into one plan
988
+ *
989
+ * Item path predicates cannot be disjoined, so a child carrying one becomes its
990
+ * own arm of a node union. Everything else is still a single CTS query.
991
+ */
992
+ function buildOrItemsPlan(childPlans) {
993
+ const mergedQueryExpressions = [];
994
+ const unionedPlans = [];
995
+ for (const childPlan of flattenItemsPlans(childPlans, "union")) {
996
+ if (childPlan.kind === "search" && childPlan.itemPredicates.length === 0) {
997
+ mergedQueryExpressions.push(buildAndCtsQueryExpressionInternal(childPlan.queryExpressions));
998
+ continue;
999
+ }
1000
+ unionedPlans.push(childPlan);
1001
+ }
1002
+ if (mergedQueryExpressions.length > 0) unionedPlans.unshift({
1003
+ kind: "search",
1004
+ itemPredicates: [],
1005
+ queryExpressions: [buildOrCtsQueryExpressionInternal(mergedQueryExpressions)]
1006
+ });
1007
+ if (unionedPlans.length === 0) return buildCtsItemsPlan("cts:false-query()");
1008
+ return unionedPlans.length === 1 ? unionedPlans[0] ?? buildCtsItemsPlan("cts:false-query()") : {
1009
+ kind: "union",
1010
+ children: unionedPlans
1011
+ };
1012
+ }
1013
+ function buildItemsPlan(context, query) {
898
1014
  if (isQueryLeaf(query)) {
899
- if (query.target === "ocrText") return isOcrTextLeafMatched(context, query, ocrTextValues) ? "cts:true-query()" : "cts:false-query()";
1015
+ if (query.target === "ocr") {
1016
+ const bindingName = registerOcrBinding(context, query);
1017
+ return {
1018
+ kind: "search",
1019
+ itemPredicates: [query.isNegated === true ? `[not(@uuid = ${bindingName})]` : `[@uuid = ${bindingName}]`],
1020
+ queryExpressions: []
1021
+ };
1022
+ }
900
1023
  const queryExpression = buildLeafQueryExpression(context, query);
901
- return query.isNegated === true ? buildNotCtsQueryExpression(queryExpression) : queryExpression;
1024
+ return buildCtsItemsPlan(query.isNegated === true ? buildNotCtsQueryExpression(queryExpression) : queryExpression);
902
1025
  }
903
1026
  const optimizedIncludesGroupQueries = getCompatibleIncludesGroupLeaves(query);
904
- if (optimizedIncludesGroupQueries != null) return buildIncludesGroupQueryExpression(context, optimizedIncludesGroupQueries);
905
- const childQueryExpressions = Array.from(getQueryGroupChildren(query), (childQuery) => buildQueryNode(context, childQuery, ocrTextValues));
906
- return (getQueryGroupOperator(query) === "and" ? buildAndCtsQueryExpressionInternal : buildOrCtsQueryExpressionInternal)(childQueryExpressions);
1027
+ if (optimizedIncludesGroupQueries != null) return buildCtsItemsPlan(buildIncludesGroupQueryExpression(context, optimizedIncludesGroupQueries));
1028
+ const childPlans = Array.from(getQueryGroupChildren(query), (childQuery) => buildItemsPlan(context, childQuery));
1029
+ return getQueryGroupOperator(query) === "and" ? buildAndItemsPlan(childPlans) : buildOrItemsPlan(childPlans);
1030
+ }
1031
+ function collectItemsSearchPlans(plan, searchPlans) {
1032
+ if (plan.kind === "search") {
1033
+ searchPlans.push(plan);
1034
+ return;
1035
+ }
1036
+ for (const childPlan of plan.children) collectItemsSearchPlans(childPlan, searchPlans);
1037
+ }
1038
+ function buildItemsPlanExpression(parameters) {
1039
+ const { plan, baseItemsExpression, queryNamesByPlan } = parameters;
1040
+ if (plan.kind === "search") {
1041
+ const itemsExpression = `${baseItemsExpression}${plan.itemPredicates.join("")}`;
1042
+ const queryName = queryNamesByPlan.get(plan);
1043
+ return queryName == null ? itemsExpression : `cts:search(${itemsExpression}, ${queryName})`;
1044
+ }
1045
+ return `(${Array.from(plan.children, (childPlan) => buildItemsPlanExpression({
1046
+ plan: childPlan,
1047
+ baseItemsExpression,
1048
+ queryNamesByPlan
1049
+ })).join(plan.kind === "union" ? " | " : " intersect ")})`;
907
1050
  }
908
1051
  function buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, belongsToCollectionPropertyVariableUuid) {
909
1052
  if (belongsToCollectionScopeUuids.length === 0) return null;
@@ -917,46 +1060,58 @@ function buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids,
917
1060
  });
918
1061
  }
919
1062
  /**
920
- * Compile a query tree into the CTS searches that resolve it
1063
+ * Compile a query tree into the XQuery `let` clauses that bind `$items` to the
1064
+ * matching Set items
1065
+ *
1066
+ * Most queries compile to a single `cts:search` over the Set item projections.
1067
+ * An `ocr` leaf cannot: the projections drop the `<ocr>` layer, so it resolves
1068
+ * to a search over the Resource documents whose matching UUIDs are joined back
1069
+ * in as an item path predicate. Path predicates only ever AND, so an `ocr` leaf
1070
+ * that sits under an `or` becomes its own arm of a node union instead, and one
1071
+ * that sits under an `and` alongside a union becomes an intersection.
921
1072
  *
922
- * OCR text is not carried by Set item projections, so an `ocrText` leaf cannot
923
- * be a CTS term: it resolves to a document join whose UUID list can only be
924
- * applied as an item path predicate, and path predicates only ever AND. To keep
925
- * `ocrText` composable with `or` anyway, the tree is split on each distinct OCR
926
- * text condition, one branch per assignment of "this item is in that match
927
- * set". Every branch is a plain CTS search, and their union is the result.
928
- * Branches that the assignment already rules out are dropped, so a query whose
929
- * OCR text leaves are all conjunctive still compiles to a single search.
1073
+ * The searchable path has to stay inline in `cts:search`: binding it to a
1074
+ * variable first makes every query XDMP-UNSEARCHABLE.
1075
+ * @param parameters - The parameters for the compilation
1076
+ * @param parameters.queries - Recursive query tree to compile, if any
1077
+ * @param parameters.baseItemsExpression - The inline XQuery path selecting the items to search
1078
+ * @param parameters.scopeQueryExpression - An optional CTS query ANDed into every compiled search
1079
+ * @returns The prolog declaring the query helpers, and the `let` clauses binding `$items`
930
1080
  */
931
1081
  function buildQueryPlan(parameters) {
932
- const { queries } = parameters;
933
- if (queries == null) return {
934
- prolog: "",
935
- ocrTextBindings: [],
936
- branches: [{
937
- itemPredicates: "",
938
- queryExpression: null
939
- }]
940
- };
1082
+ const { queries, baseItemsExpression, scopeQueryExpression } = parameters;
941
1083
  const context = createQueryCompilerContext();
942
- collectOcrTextConditions(context, queries);
943
- if (context.ocrTextConditions.length > MAX_OCR_TEXT_CONDITIONS) throw new Error(`A query cannot contain more than ${MAX_OCR_TEXT_CONDITIONS} distinct OCR text searches`, { cause: context.ocrTextConditions.length });
944
- const branches = [];
945
- for (const ocrTextValues of getOcrTextValueCombinations(context.ocrTextConditions.length)) {
946
- if (evaluateOcrTextBranch(context, queries, ocrTextValues) === false) continue;
947
- branches.push({
948
- itemPredicates: buildOcrTextItemPredicates(context, ocrTextValues),
949
- queryExpression: buildQueryNode(context, queries, ocrTextValues)
1084
+ const plan = queries == null ? {
1085
+ kind: "search",
1086
+ itemPredicates: [],
1087
+ queryExpressions: []
1088
+ } : buildItemsPlan(context, queries);
1089
+ const searchPlans = [];
1090
+ collectItemsSearchPlans(plan, searchPlans);
1091
+ const boundSearchPlans = [];
1092
+ for (const searchPlan of searchPlans) {
1093
+ const queryExpression = buildAndCtsQueryExpression([...searchPlan.queryExpressions, ...scopeQueryExpression == null ? [] : [scopeQueryExpression]]);
1094
+ if (queryExpression != null) boundSearchPlans.push({
1095
+ plan: searchPlan,
1096
+ queryExpression
950
1097
  });
951
1098
  }
1099
+ const queryNamesByPlan = /* @__PURE__ */ new Map();
1100
+ const letClauses = Array.from(context.ocrBindings, (binding) => `let ${binding.name} := ${binding.expression}`);
1101
+ for (const [index, boundSearchPlan] of boundSearchPlans.entries()) {
1102
+ const queryName = boundSearchPlans.length === 1 ? "$query" : `$query${index + 1}`;
1103
+ queryNamesByPlan.set(boundSearchPlan.plan, queryName);
1104
+ letClauses.push(`let ${queryName} := ${boundSearchPlan.queryExpression}`);
1105
+ }
1106
+ letClauses.push(`let $items := ${buildItemsPlanExpression({
1107
+ plan,
1108
+ baseItemsExpression,
1109
+ queryNamesByPlan
1110
+ })}`);
952
1111
  return {
953
1112
  prolog: context.helperDeclarations.join("\n\n"),
954
- ocrTextBindings: Array.from(context.ocrTextConditions, (condition) => ({
955
- name: condition.variableName,
956
- expression: condition.bindingExpression
957
- })),
958
- branches
1113
+ itemsClause: letClauses.join("\n ")
959
1114
  };
960
1115
  }
961
1116
  //#endregion
962
- export { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan };
1117
+ export { buildBelongsToCollectionQueryExpression, buildQueryPlan };
package/dist/schemas.mjs CHANGED
@@ -142,7 +142,7 @@ const setQueryLeafSchema = v.union([
142
142
  ...standardQueryFields
143
143
  }),
144
144
  v.strictObject({
145
- target: v.literal("ocrText"),
145
+ target: v.literal("ocr"),
146
146
  value: v.string(),
147
147
  matchMode: standardQueryFields.matchMode,
148
148
  isCaseSensitive: standardQueryFields.isCaseSensitive,
@@ -266,11 +266,15 @@ type ImageMap = {
266
266
  * Positioned OCR string in OCHRE
267
267
  *
268
268
  * OCHRE gives no guarantee about the node hierarchy inside a Resource's
269
- * `<ocr>` layer, so only `<string>` nodes are parsed, at whatever depth they
270
- * occur. `x` and `y` come from `HPOS` and `VPOS` and locate the top-left
271
- * corner of the string's box, while `vertices` is its full bounding polygon,
272
- * which is not necessarily rectangular. Every geometry attribute is optional
273
- * in the source, so each one is null when absent or unparseable.
269
+ * `<ocr>` layer, so only its word nodes are parsed, at whatever depth they
270
+ * occur. A word node is any element named `string` in any casing and any
271
+ * namespace, and its text is read from the `CONTENT` attribute.
272
+ *
273
+ * `x` and `y` come from `HPOS` and `VPOS` and locate the top-left corner of
274
+ * the word's box, while `vertices` comes from `VERTICES` and is its full
275
+ * bounding polygon, which is not necessarily rectangular. Every geometry
276
+ * attribute is optional in the source, so each one is null when absent or
277
+ * unparseable, and `vertices` is then empty.
274
278
  *
275
279
  * `resourceUuid` is the Resource that owns the OCR layer, which differs from
276
280
  * the requested item when the OCR lives on a child Resource.
@@ -815,12 +819,13 @@ type SetItemsSort = {
815
819
  /**
816
820
  * Represents a leaf query for Set items
817
821
  *
818
- * The `ocrText` target matches the OCR text layer of Resource items. Because
819
- * Set item projections do not carry `<ocrText>`, it is resolved by a document
820
- * join rather than a CTS term, so it composes with `and`, `or`, and `isNegated`
821
- * at the cost of one extra search branch per distinct OCR text value in a
822
- * disjunction. OCR text carries no language, so `ocrText` leaves take no
823
- * `language`.
822
+ * The `ocr` target matches the OCR text layer of Resource items. Set item
823
+ * projections do not carry `<ocr>`, so it is resolved by a document join rather
824
+ * than by a CTS term, and it composes with `and`, `or`, and `isNegated` at the
825
+ * cost of one extra search per distinct OCR value. Each `<string>` in that
826
+ * layer holds a single OCR word, so `includes` matches every search term as its
827
+ * own word and `exact` matches the terms as an adjacent run of whole words. OCR
828
+ * text carries no language, so `ocr` leaves take no `language`.
824
829
  */
825
830
  type QueryLeaf = {
826
831
  target: "property";
@@ -888,7 +893,7 @@ type QueryLeaf = {
888
893
  language: string;
889
894
  isNegated?: boolean;
890
895
  } | {
891
- target: "ocrText";
896
+ target: "ocr";
892
897
  value: string;
893
898
  matchMode: "includes" | "exact";
894
899
  isCaseSensitive: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ochre-sdk",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
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",
@@ -51,11 +51,12 @@
51
51
  "valibot": "^1.4.2"
52
52
  },
53
53
  "devDependencies": {
54
- "@antfu/eslint-config": "^9.2.0",
54
+ "@antfu/eslint-config": "^9.3.0",
55
55
  "@types/node": "^24.13.3",
56
56
  "bumpp": "^12.2.0",
57
57
  "eslint": "^10.8.0",
58
- "knip": "^6.31.0",
58
+ "eslint-plugin-erasable-syntax-only": "^0.4.2",
59
+ "knip": "^6.32.0",
59
60
  "oxfmt": "^0.62.0",
60
61
  "tsdown": "^0.22.14",
61
62
  "typescript": "^6.0.3",