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 +31 -3
- package/dist/fetchers/item-ocr-data.d.mts +9 -6
- package/dist/fetchers/item-ocr-data.mjs +19 -11
- package/dist/fetchers/set/items.mjs +7 -23
- package/dist/fetchers/set/property-values.mjs +7 -23
- package/dist/fetchers/website.mjs +2 -2
- package/dist/getters.d.mts +6 -2
- package/dist/parsers/multilingual.d.mts +12 -4
- package/dist/parsers/website/index.mjs +13 -12
- package/dist/query.d.mts +20 -19
- package/dist/query.mjs +262 -107
- package/dist/schemas.mjs +1 -1
- package/dist/types/index.d.mts +17 -12
- package/package.json +4 -3
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
|
|
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
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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
|
|
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
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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 {
|
|
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({
|
|
142
|
-
|
|
143
|
-
|
|
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 {
|
|
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({
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
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,
|
package/dist/getters.d.mts
CHANGED
|
@@ -4,9 +4,13 @@ import { LanguageCodes, Property, PropertyLike, PropertyValueContent, SetItemPro
|
|
|
4
4
|
* Options for property search operations.
|
|
5
5
|
*/
|
|
6
6
|
type PropertyOptions = {
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
Whether to recursively search through nested properties.
|
|
9
|
+
*/
|
|
8
10
|
includeNestedProperties?: boolean;
|
|
9
|
-
/**
|
|
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
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
Default language to use for fallbacks
|
|
31
|
+
*/
|
|
30
32
|
defaultLanguage?: string;
|
|
31
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
Available languages for this string
|
|
35
|
+
*/
|
|
32
36
|
availableLanguages?: ReadonlyArray<string>;
|
|
33
|
-
/**
|
|
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
|
-
/**
|
|
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)
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
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
|
|
5
|
+
* Compile a query tree into the XQuery `let` clauses that bind `$items` to the
|
|
6
|
+
* matching Set items
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
+
return buildNestedElementQuery(["ocr"], buildAndCtsQueryExpressionInternal(Array.from(terms, (term) => buildOcrWordQueryExpression({
|
|
488
536
|
value: term,
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
*
|
|
529
|
-
*
|
|
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
|
|
532
|
-
|
|
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
|
-
*
|
|
536
|
-
*
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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 === "
|
|
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
|
|
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 === "
|
|
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
|
|
906
|
-
return
|
|
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
|
|
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
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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 {
|
|
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("
|
|
145
|
+
target: v.literal("ocr"),
|
|
146
146
|
value: v.string(),
|
|
147
147
|
matchMode: standardQueryFields.matchMode,
|
|
148
148
|
isCaseSensitive: standardQueryFields.isCaseSensitive,
|
package/dist/types/index.d.mts
CHANGED
|
@@ -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
|
|
270
|
-
* occur.
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
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 `
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* `
|
|
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: "
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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",
|