ochre-sdk 1.0.73 → 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
@@ -121,6 +121,34 @@ 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 `<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
+
124
152
  ## OCR Data
125
153
 
126
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.
@@ -139,7 +167,7 @@ for (const ocrString of result.ocrStrings ?? []) {
139
167
 
140
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.
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,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,81 @@ function buildItemStringQueryExpression(parameters) {
472
472
  language
473
473
  })]);
474
474
  }
475
- function buildOcrTextQueryExpression(query) {
475
+ function tokenizeOcrPhraseValue(value) {
476
+ const terms = [];
477
+ for (const term of value.split(/\s+/u)) if (term !== "") terms.push(term);
478
+ return terms;
479
+ }
480
+ /**
481
+ * Word queries against the OCR layer cannot carry a stemming option: the OCHRE
482
+ * database has unstemmed word searches turned off, and asking an element word
483
+ * query for `unstemmed` fails with `XDMP-WORDSEARCH`. Omitting the option
484
+ * altogether resolves the term against the database default instead.
485
+ */
486
+ function buildOcrWordQueryExpression(parameters) {
487
+ const { value, isCaseSensitive } = parameters;
488
+ const options = [
489
+ isCaseSensitive ? "case-sensitive" : "case-insensitive",
490
+ "diacritic-insensitive",
491
+ "punctuation-insensitive",
492
+ "whitespace-insensitive"
493
+ ];
494
+ if (hasWildcardCharacters(value)) options.push("wildcarded");
495
+ return `cts:element-word-query(xs:QName("string"), ${stringLiteral(value)}, (${options.map((option) => stringLiteral(option)).join(", ")}))`;
496
+ }
497
+ /**
498
+ * Compile an OCR text search into a query over the `<ocr>` layer of a Resource
499
+ * document
500
+ *
501
+ * Every `<string>` node in that layer holds a single OCR word, so `includes`
502
+ * matches each search term as its own word anywhere in the layer, and `exact`
503
+ * matches the terms as a run of adjacent whole string values. A phrase cannot
504
+ * be a word query here: word positions do not carry across the `<string>`
505
+ * boundaries, which makes `cts:near-query` the only phrase mechanism, and its
506
+ * distance is a total span rather than a pairwise gap.
507
+ */
508
+ function buildOcrQueryExpression(query) {
476
509
  const { value, matchMode, isCaseSensitive } = query;
477
- const phraseQueryExpression = buildRichTextPhraseQueryExpression({
478
- value,
479
- isCaseSensitive
480
- });
481
- if (matchMode === "exact") return buildNestedElementQuery(["ocrText"], phraseQueryExpression);
510
+ if (matchMode === "exact") {
511
+ const terms = tokenizeOcrPhraseValue(value);
512
+ if (terms.length === 0) return "cts:false-query()";
513
+ const termQueryExpressions = Array.from(terms, (term) => buildCtsElementValueQueryExpression({
514
+ elementName: "string",
515
+ value: term,
516
+ isCaseSensitive
517
+ }));
518
+ return buildNestedElementQuery(["ocr"], termQueryExpressions.length === 1 ? termQueryExpressions[0] ?? "cts:false-query()" : `cts:near-query((${termQueryExpressions.join(", ")}), ${termQueryExpressions.length - 1}, ("ordered"))`);
519
+ }
482
520
  const terms = tokenizeIncludesSearchValue({
483
521
  value,
484
522
  isCaseSensitive
485
523
  });
486
524
  if (terms.length === 0) return "cts:false-query()";
487
- const tokenizedQueryExpression = buildAndCtsQueryExpressionInternal(Array.from(terms, (term) => buildCtsWordQueryExpression({
525
+ return buildNestedElementQuery(["ocr"], buildAndCtsQueryExpressionInternal(Array.from(terms, (term) => buildOcrWordQueryExpression({
488
526
  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);
527
+ isCaseSensitive
528
+ }))));
498
529
  }
499
- function getOcrTextConditionKey(query) {
500
- return [
530
+ /**
531
+ * Bind the UUIDs of the Resource documents whose OCR layer matches a query,
532
+ * reusing the binding when the same search is requested more than once
533
+ */
534
+ function registerOcrBinding(context, query) {
535
+ const key = [
501
536
  query.value,
502
537
  query.matchMode,
503
538
  query.isCaseSensitive ? "case-sensitive" : "case-insensitive"
504
539
  ].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)`
540
+ const existingName = context.ocrBindingNamesByKey.get(key);
541
+ if (existingName != null) return existingName;
542
+ const name = `$ocrItemUuids${context.ocrBindings.length + 1}`;
543
+ const queryExpression = buildOcrQueryExpression(query);
544
+ context.ocrBindingNamesByKey.set(key, name);
545
+ context.ocrBindings.push({
546
+ name,
547
+ expression: queryExpression === "cts:false-query()" ? "()" : `cts:search(/ochre/resource, ${queryExpression})/@uuid/string()`
513
548
  });
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;
526
- }
527
- /**
528
- * Enumerate every assignment of "this item is in the OCR text match set" across
529
- * the compiled conditions, least significant position first
530
- */
531
- function getOcrTextValueCombinations(count) {
532
- return Array.from({ length: 2 ** count }, (_, index) => Array.from({ length: count }, (_, position) => (index >> position & 1) === 1));
533
- }
534
- /**
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`.
538
- */
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("");
549
+ return name;
552
550
  }
553
551
  function getLeafSearchValue(query) {
554
552
  switch (query.target) {
@@ -631,8 +629,8 @@ function createQueryCompilerContext() {
631
629
  nextHelperSerial: 1,
632
630
  helperNamesByKey: /* @__PURE__ */ new Map(),
633
631
  helperDeclarations: [],
634
- ocrTextConditions: [],
635
- ocrTextConditionIndexesByKey: /* @__PURE__ */ new Map()
632
+ ocrBindingNamesByKey: /* @__PURE__ */ new Map(),
633
+ ocrBindings: []
636
634
  };
637
635
  }
638
636
  function registerConstantHelper(parameters) {
@@ -833,7 +831,7 @@ function getCompatibleIncludesGroupLeaves(query) {
833
831
  if (!("or" in query) || query.or.length <= 1) return null;
834
832
  const leafQueries = [];
835
833
  for (const childQuery of query.or) {
836
- if (!isQueryLeaf(childQuery) || childQuery.target === "ocrText") return null;
834
+ if (!isQueryLeaf(childQuery) || childQuery.target === "ocr") return null;
837
835
  leafQueries.push(childQuery);
838
836
  }
839
837
  const firstQuery = leafQueries[0];
@@ -894,16 +892,121 @@ function buildIncludesGroupQueryExpression(context, queries) {
894
892
  bodyExpression: buildOrCtsQueryExpressionInternal(exactMemberHelpers.map((helper) => helper.callExpression))
895
893
  }).callExpression, tokenizedQueryExpression]);
896
894
  }
897
- function buildQueryNode(context, query, ocrTextValues) {
895
+ function buildCtsItemsPlan(queryExpression) {
896
+ return {
897
+ kind: "search",
898
+ itemPredicates: [],
899
+ queryExpressions: [queryExpression]
900
+ };
901
+ }
902
+ /**
903
+ * Splice the children of same-kind child plans into their parent, so that a
904
+ * nested group of the same operator does not cost an extra search
905
+ */
906
+ function flattenItemsPlans(childPlans, kind) {
907
+ const flattenedPlans = [];
908
+ for (const childPlan of childPlans) {
909
+ if (childPlan.kind === kind) {
910
+ flattenedPlans.push(...childPlan.children);
911
+ continue;
912
+ }
913
+ flattenedPlans.push(childPlan);
914
+ }
915
+ return flattenedPlans;
916
+ }
917
+ /**
918
+ * Fold the children of an `and` group into one plan
919
+ *
920
+ * Conjunction is the direction the item path predicates already run in, so
921
+ * every child that is a plain search collapses into a single search, and only
922
+ * the children that resolved to a union stay separate.
923
+ */
924
+ function buildAndItemsPlan(childPlans) {
925
+ const mergedPlan = {
926
+ kind: "search",
927
+ itemPredicates: [],
928
+ queryExpressions: []
929
+ };
930
+ const unfoldablePlans = [];
931
+ for (const childPlan of flattenItemsPlans(childPlans, "intersect")) {
932
+ if (childPlan.kind !== "search") {
933
+ unfoldablePlans.push(childPlan);
934
+ continue;
935
+ }
936
+ for (const itemPredicate of childPlan.itemPredicates) if (!mergedPlan.itemPredicates.includes(itemPredicate)) mergedPlan.itemPredicates.push(itemPredicate);
937
+ mergedPlan.queryExpressions.push(...childPlan.queryExpressions);
938
+ }
939
+ if (unfoldablePlans.length === 0) return mergedPlan;
940
+ const intersectedPlans = mergedPlan.itemPredicates.length === 0 && mergedPlan.queryExpressions.length === 0 ? unfoldablePlans : [mergedPlan, ...unfoldablePlans];
941
+ return intersectedPlans.length === 1 ? intersectedPlans[0] ?? mergedPlan : {
942
+ kind: "intersect",
943
+ children: intersectedPlans
944
+ };
945
+ }
946
+ /**
947
+ * Fold the children of an `or` group into one plan
948
+ *
949
+ * Item path predicates cannot be disjoined, so a child carrying one becomes its
950
+ * own arm of a node union. Everything else is still a single CTS query.
951
+ */
952
+ function buildOrItemsPlan(childPlans) {
953
+ const mergedQueryExpressions = [];
954
+ const unionedPlans = [];
955
+ for (const childPlan of flattenItemsPlans(childPlans, "union")) {
956
+ if (childPlan.kind === "search" && childPlan.itemPredicates.length === 0) {
957
+ mergedQueryExpressions.push(buildAndCtsQueryExpressionInternal(childPlan.queryExpressions));
958
+ continue;
959
+ }
960
+ unionedPlans.push(childPlan);
961
+ }
962
+ if (mergedQueryExpressions.length > 0) unionedPlans.unshift({
963
+ kind: "search",
964
+ itemPredicates: [],
965
+ queryExpressions: [buildOrCtsQueryExpressionInternal(mergedQueryExpressions)]
966
+ });
967
+ if (unionedPlans.length === 0) return buildCtsItemsPlan("cts:false-query()");
968
+ return unionedPlans.length === 1 ? unionedPlans[0] ?? buildCtsItemsPlan("cts:false-query()") : {
969
+ kind: "union",
970
+ children: unionedPlans
971
+ };
972
+ }
973
+ function buildItemsPlan(context, query) {
898
974
  if (isQueryLeaf(query)) {
899
- if (query.target === "ocrText") return isOcrTextLeafMatched(context, query, ocrTextValues) ? "cts:true-query()" : "cts:false-query()";
975
+ if (query.target === "ocr") {
976
+ const bindingName = registerOcrBinding(context, query);
977
+ return {
978
+ kind: "search",
979
+ itemPredicates: [query.isNegated === true ? `[not(@uuid = ${bindingName})]` : `[@uuid = ${bindingName}]`],
980
+ queryExpressions: []
981
+ };
982
+ }
900
983
  const queryExpression = buildLeafQueryExpression(context, query);
901
- return query.isNegated === true ? buildNotCtsQueryExpression(queryExpression) : queryExpression;
984
+ return buildCtsItemsPlan(query.isNegated === true ? buildNotCtsQueryExpression(queryExpression) : queryExpression);
902
985
  }
903
986
  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);
987
+ if (optimizedIncludesGroupQueries != null) return buildCtsItemsPlan(buildIncludesGroupQueryExpression(context, optimizedIncludesGroupQueries));
988
+ const childPlans = Array.from(getQueryGroupChildren(query), (childQuery) => buildItemsPlan(context, childQuery));
989
+ return getQueryGroupOperator(query) === "and" ? buildAndItemsPlan(childPlans) : buildOrItemsPlan(childPlans);
990
+ }
991
+ function collectItemsSearchPlans(plan, searchPlans) {
992
+ if (plan.kind === "search") {
993
+ searchPlans.push(plan);
994
+ return;
995
+ }
996
+ for (const childPlan of plan.children) collectItemsSearchPlans(childPlan, searchPlans);
997
+ }
998
+ function buildItemsPlanExpression(parameters) {
999
+ const { plan, baseItemsExpression, queryNamesByPlan } = parameters;
1000
+ if (plan.kind === "search") {
1001
+ const itemsExpression = `${baseItemsExpression}${plan.itemPredicates.join("")}`;
1002
+ const queryName = queryNamesByPlan.get(plan);
1003
+ return queryName == null ? itemsExpression : `cts:search(${itemsExpression}, ${queryName})`;
1004
+ }
1005
+ return `(${Array.from(plan.children, (childPlan) => buildItemsPlanExpression({
1006
+ plan: childPlan,
1007
+ baseItemsExpression,
1008
+ queryNamesByPlan
1009
+ })).join(plan.kind === "union" ? " | " : " intersect ")})`;
907
1010
  }
908
1011
  function buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids, belongsToCollectionPropertyVariableUuid) {
909
1012
  if (belongsToCollectionScopeUuids.length === 0) return null;
@@ -917,46 +1020,58 @@ function buildBelongsToCollectionQueryExpression(belongsToCollectionScopeUuids,
917
1020
  });
918
1021
  }
919
1022
  /**
920
- * Compile a query tree into the CTS searches that resolve it
1023
+ * Compile a query tree into the XQuery `let` clauses that bind `$items` to the
1024
+ * matching Set items
1025
+ *
1026
+ * Most queries compile to a single `cts:search` over the Set item projections.
1027
+ * An `ocr` leaf cannot: the projections drop the `<ocr>` layer, so it resolves
1028
+ * to a search over the Resource documents whose matching UUIDs are joined back
1029
+ * in as an item path predicate. Path predicates only ever AND, so an `ocr` leaf
1030
+ * that sits under an `or` becomes its own arm of a node union instead, and one
1031
+ * that sits under an `and` alongside a union becomes an intersection.
921
1032
  *
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.
1033
+ * The searchable path has to stay inline in `cts:search`: binding it to a
1034
+ * variable first makes every query XDMP-UNSEARCHABLE.
1035
+ * @param parameters - The parameters for the compilation
1036
+ * @param parameters.queries - Recursive query tree to compile, if any
1037
+ * @param parameters.baseItemsExpression - The inline XQuery path selecting the items to search
1038
+ * @param parameters.scopeQueryExpression - An optional CTS query ANDed into every compiled search
1039
+ * @returns The prolog declaring the query helpers, and the `let` clauses binding `$items`
930
1040
  */
931
1041
  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
- };
1042
+ const { queries, baseItemsExpression, scopeQueryExpression } = parameters;
941
1043
  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)
1044
+ const plan = queries == null ? {
1045
+ kind: "search",
1046
+ itemPredicates: [],
1047
+ queryExpressions: []
1048
+ } : buildItemsPlan(context, queries);
1049
+ const searchPlans = [];
1050
+ collectItemsSearchPlans(plan, searchPlans);
1051
+ const boundSearchPlans = [];
1052
+ for (const searchPlan of searchPlans) {
1053
+ const queryExpression = buildAndCtsQueryExpression([...searchPlan.queryExpressions, ...scopeQueryExpression == null ? [] : [scopeQueryExpression]]);
1054
+ if (queryExpression != null) boundSearchPlans.push({
1055
+ plan: searchPlan,
1056
+ queryExpression
950
1057
  });
951
1058
  }
1059
+ const queryNamesByPlan = /* @__PURE__ */ new Map();
1060
+ const letClauses = Array.from(context.ocrBindings, (binding) => `let ${binding.name} := ${binding.expression}`);
1061
+ for (const [index, boundSearchPlan] of boundSearchPlans.entries()) {
1062
+ const queryName = boundSearchPlans.length === 1 ? "$query" : `$query${index + 1}`;
1063
+ queryNamesByPlan.set(boundSearchPlan.plan, queryName);
1064
+ letClauses.push(`let ${queryName} := ${boundSearchPlan.queryExpression}`);
1065
+ }
1066
+ letClauses.push(`let $items := ${buildItemsPlanExpression({
1067
+ plan,
1068
+ baseItemsExpression,
1069
+ queryNamesByPlan
1070
+ })}`);
952
1071
  return {
953
1072
  prolog: context.helperDeclarations.join("\n\n"),
954
- ocrTextBindings: Array.from(context.ocrTextConditions, (condition) => ({
955
- name: condition.variableName,
956
- expression: condition.bindingExpression
957
- })),
958
- branches
1073
+ itemsClause: letClauses.join("\n ")
959
1074
  };
960
1075
  }
961
1076
  //#endregion
962
- export { buildAndCtsQueryExpression, buildBelongsToCollectionQueryExpression, buildQueryPlan };
1077
+ 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,
@@ -815,12 +815,13 @@ type SetItemsSort = {
815
815
  /**
816
816
  * Represents a leaf query for Set items
817
817
  *
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`.
818
+ * The `ocr` target matches the OCR text layer of Resource items. Set item
819
+ * projections do not carry `<ocr>`, so it is resolved by a document join rather
820
+ * than by a CTS term, and it composes with `and`, `or`, and `isNegated` at the
821
+ * cost of one extra search per distinct OCR value. Each `<string>` in that
822
+ * layer holds a single OCR word, so `includes` matches every search term as its
823
+ * own word and `exact` matches the terms as an adjacent run of whole words. OCR
824
+ * text carries no language, so `ocr` leaves take no `language`.
824
825
  */
825
826
  type QueryLeaf = {
826
827
  target: "property";
@@ -888,7 +889,7 @@ type QueryLeaf = {
888
889
  language: string;
889
890
  isNegated?: boolean;
890
891
  } | {
891
- target: "ocrText";
892
+ target: "ocr";
892
893
  value: string;
893
894
  matchMode: "includes" | "exact";
894
895
  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.74",
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",