Skip to content

Commit c6be0eb

Browse files
committed
Bring in search logic
1 parent 3c32a20 commit c6be0eb

4 files changed

Lines changed: 419 additions & 0 deletions

File tree

controllers/search.js

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Basic CRUD operations for RERUM v1
5+
* @author Claude Sonnet 4, cubap, thehabes
6+
*/
7+
import { newID, isValidID, db } from '../database/index.js'
8+
import utils from '../utils.js'
9+
import { _contextid, idNegotiation, generateSlugId, ObjectID, createExpressError, getAgentClaim, parseDocumentID } from './utils.js'
10+
11+
/**
12+
* Merges and deduplicates results from multiple MongoDB Atlas Search index queries.
13+
*
14+
* This function combines search results from both the IIIF Presentation API 3.0 index
15+
* (presi3AnnotationText) and the IIIF Presentation API 2.1 index (presi2AnnotationText).
16+
*
17+
* @param {Array<Object>} results1 - Results from the first search index (typically IIIF 3.0)
18+
* @param {Array<Object>} results2 - Results from the second search index (typically IIIF 2.1)
19+
* @returns {Array<Object>} Merged array of unique results sorted by search score (descending)
20+
*
21+
* @description
22+
* Process:
23+
* 1. Combines both result arrays
24+
* 2. Removes duplicates based on MongoDB _id (keeps first occurrence)
25+
* 3. Sorts by search score in descending order (highest relevance first)
26+
*
27+
* The function handles different _id formats:
28+
* - ObjectId objects with $oid property
29+
* - String-based _id values
30+
*
31+
*/
32+
function mergeSearchResults(results1, results2) {
33+
const seen = new Set()
34+
const merged = []
35+
36+
for (const result of [...results1, ...results2]) {
37+
const id = result._id?.$oid || result._id?.toString()
38+
if (!seen.has(id)) {
39+
seen.add(id)
40+
merged.push(result)
41+
}
42+
}
43+
44+
// Sort by score descending
45+
return merged.sort((a, b) => (b.score || 0) - (a.score || 0))
46+
}
47+
48+
/**
49+
* Builds parallel MongoDB Atlas Search aggregation pipelines for both IIIF 3.0 and 2.1 indexes.
50+
*
51+
* This function creates two separate search queries that will be executed in parallel:
52+
* - One for IIIF Presentation API 3.0 resources (presi3AnnotationText index)
53+
* - One for IIIF Presentation API 2.1 resources (presi2AnnotationText index)
54+
*
55+
* @param {string} searchText - The text query to search for
56+
* @param {Object} operator - Search operator configuration
57+
* @param {string} operator.type - Type of search operator: "text", "wildcard", "phrase", etc.
58+
* @param {Object} operator.options - Additional options for the search operator (e.g., fuzzy options)
59+
* @param {number} limit - Maximum number of results to return per index
60+
* @param {number} skip - Number of results to skip for pagination
61+
* @returns {Array<Array>} Two-element array containing [presi3Pipeline, presi2Pipeline]
62+
*
63+
* @description
64+
* IIIF 3.0 Query Structure (presi3AnnotationText index):
65+
* - Searches direct text fields: body.value, bodyValue
66+
* - Searches embedded items: items.annotations.items.body.value
67+
* - Searches annotation items: annotations.items.body.value
68+
* - Uses compound query with "should" clauses (any match qualifies)
69+
*
70+
* IIIF 2.1 Query Structure (presi2AnnotationText index):
71+
* - Searches Open Annotation fields: resource.chars, resource.cnt:chars
72+
* - Searches AnnotationList resources: resources[].resource.chars
73+
* - Searches Canvas otherContent: otherContent[].resources[].resource.chars
74+
* - Searches Manifest sequences: sequences[].canvases[].otherContent[].resources[].resource.chars
75+
* - Uses nested embeddedDocument operators for multi-level array traversal
76+
*
77+
* Both queries use:
78+
* - $search stage with the specified operator type (text, wildcard, phrase, etc.)
79+
* - $addFields to include searchScore metadata
80+
* - $limit to cap results (limit + skip to allow for pagination)
81+
*/
82+
function buildDualIndexQueries(searchText, operator, limit, skip) {
83+
const presi3Query = {
84+
index: "presi3AnnotationText",
85+
compound: {
86+
should: [
87+
{
88+
[operator.type]: {
89+
query: searchText,
90+
path: ["body.value", "bodyValue"],
91+
...operator.options
92+
}
93+
},
94+
{
95+
embeddedDocument: {
96+
path: "items.annotations.items",
97+
operator: {
98+
[operator.type]: {
99+
query: searchText,
100+
path: ["items.annotations.items.body.value", "items.annotations.items.bodyValue"],
101+
...operator.options
102+
}
103+
}
104+
}
105+
},
106+
{
107+
embeddedDocument: {
108+
path: "annotations",
109+
operator: {
110+
[operator.type]: {
111+
query: searchText,
112+
path: ["annotations.items.body.value", "annotations.items.bodyValue"],
113+
...operator.options
114+
}
115+
}
116+
}
117+
},
118+
{
119+
embeddedDocument: {
120+
path: "annotations.items",
121+
operator: {
122+
[operator.type]: {
123+
query: searchText,
124+
path: ["annotations.items.body.value", "annotations.items.bodyValue"],
125+
...operator.options
126+
}
127+
}
128+
}
129+
},
130+
{
131+
embeddedDocument: {
132+
path: "items",
133+
operator: {
134+
[operator.type]: {
135+
query: searchText,
136+
path: [
137+
"items.body.value",
138+
"items.bodyValue",
139+
"items.annotations.items.body.value",
140+
"items.annotations.items.bodyValue"
141+
],
142+
...operator.options
143+
}
144+
}
145+
}
146+
}
147+
],
148+
minimumShouldMatch: 1
149+
}
150+
}
151+
152+
const presi2Query = {
153+
index: "presi2AnnotationText",
154+
compound: {
155+
should: [
156+
{
157+
[operator.type]: {
158+
query: searchText,
159+
path: ["resource.chars", "resource.cnt:chars"],
160+
...operator.options
161+
}
162+
},
163+
{
164+
embeddedDocument: {
165+
path: "resources",
166+
operator: {
167+
[operator.type]: {
168+
query: searchText,
169+
path: ["resources.resource.chars", "resources.resource.cnt:chars"],
170+
...operator.options
171+
}
172+
}
173+
}
174+
},
175+
{
176+
embeddedDocument: {
177+
path: "otherContent.resources",
178+
operator: {
179+
[operator.type]: {
180+
query: searchText,
181+
path: ["otherContent.resources.resource.chars", "otherContent.resources.resource.cnt:chars"],
182+
...operator.options
183+
}
184+
}
185+
}
186+
},
187+
{
188+
embeddedDocument: {
189+
path: "sequences.canvases.otherContent.resources",
190+
operator: {
191+
[operator.type]: {
192+
query: searchText,
193+
path: [
194+
"sequences.canvases.otherContent.resources.resource.chars",
195+
"sequences.canvases.otherContent.resources.resource.cnt:chars"
196+
],
197+
...operator.options
198+
}
199+
}
200+
}
201+
}
202+
],
203+
minimumShouldMatch: 1
204+
}
205+
}
206+
207+
return [
208+
[
209+
{ $search: presi3Query },
210+
{ $addFields: { score: { $meta: "searchScore" } } },
211+
{ $limit: limit + skip }
212+
],
213+
[
214+
{ $search: presi2Query },
215+
{ $addFields: { score: { $meta: "searchScore" } } },
216+
{ $limit: limit + skip }
217+
]
218+
]
219+
}
220+
221+
222+
/**
223+
* Standard text search endpoint - searches for exact word matches across both IIIF 3.0 and 2.1 resources.
224+
*
225+
* @route POST /search
226+
* @param {Object} req.body - Request body containing search text
227+
* @param {string} req.body.searchText - The text to search for (can also be a plain string body)
228+
* @param {number} [req.query.limit=100] - Maximum number of results to return
229+
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
230+
* @returns {Array<Object>} JSON array of matching annotation objects sorted by relevance score
231+
*
232+
* @description
233+
* Performs a standard MongoDB Atlas Search text query that:
234+
* - Tokenizes the search text into words
235+
* - Searches for exact word matches (case-insensitive)
236+
* - Applies standard linguistic analysis (stemming, stop words, etc.)
237+
* - Searches across both IIIF Presentation API 3.0 and 2.1 indexes in parallel
238+
* - Returns results sorted by relevance score (highest first)
239+
*
240+
* Search Behavior:
241+
* - "Bryan Haberberger" → finds documents containing both "Bryan" AND "Haberberger"
242+
* - Searches are case-insensitive
243+
* - Standard analyzer removes common stop words
244+
* - Partial word matches are NOT supported (use wildcardSearch for that)
245+
*
246+
* IIIF 3.0 Fields Searched:
247+
* - body.value, bodyValue (direct annotation text)
248+
* - items.*.body.value (nested structures)
249+
* - annotations.*.body.value (canvas annotations)
250+
*
251+
* IIIF 2.1 Fields Searched:
252+
* - resource.chars, resource.cnt:chars (direct annotation text)
253+
* - resources[].resource.chars (AnnotationList)
254+
* - otherContent[].resources[].resource.chars (Canvas)
255+
* - sequences[].canvases[].otherContent[].resources[].resource.chars (Manifest)
256+
*
257+
* @example
258+
* POST /search
259+
* Body: {"searchText": "Hello World"}
260+
* Returns: All annotations containing "Hello" and "World"
261+
*
262+
*/
263+
const searchAsWords = async function (req, res, next) {
264+
res.set("Content-Type", "application/json; charset=utf-8")
265+
let searchText = req.body?.searchText ?? req.body
266+
if (!searchText) {
267+
let err = {
268+
message: "You did not provide text to search for in the search request.",
269+
status: 400
270+
}
271+
next(utils.createExpressError(err))
272+
return
273+
}
274+
const limit = parseInt(req.query.limit ?? 100)
275+
const skip = parseInt(req.query.skip ?? 0)
276+
277+
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: {} }, limit, skip)
278+
279+
try {
280+
const [resultsPresi3, resultsPresi2] = await Promise.all([
281+
db.aggregate(queryPresi3).toArray().catch((err) => { console.error("Presi3 error:", err.message); return [] }),
282+
db.aggregate(queryPresi2).toArray().catch((err) => { console.error("Presi2 error:", err.message); return [] })
283+
])
284+
285+
const merged = mergeSearchResults(resultsPresi3, resultsPresi2)
286+
const results = merged.slice(skip, skip + limit)
287+
288+
res.set(utils.configureLDHeadersFor(results))
289+
res.json(results)
290+
} catch (error) {
291+
console.error(error)
292+
next(utils.createExpressError(error))
293+
}
294+
}
295+
296+
/**
297+
* Phrase search endpoint - searches for multi-word phrases with words in proximity.
298+
*
299+
* @route POST /phraseSearch
300+
* @param {Object} req.body - Request body containing search phrase
301+
* @param {string} req.body.searchText - The phrase to search for (can also be a plain string body)
302+
* @param {number} [req.query.limit=100] - Maximum number of results to return
303+
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
304+
* @returns {Array<Object>} JSON array of matching annotation objects sorted by relevance score
305+
*
306+
* @description
307+
* Performs a phrase search that finds documents where search terms appear near each other:
308+
* - Searches for terms in sequence or close proximity
309+
* - Allows up to 2 intervening words between search terms (slop: 2)
310+
* - More precise than standard text search for multi-word queries
311+
* - Searches across both IIIF Presentation API 3.0 and 2.1 indexes in parallel
312+
*
313+
* Phrase Options:
314+
* - slop: 2 (allows up to 2 words between search terms)
315+
*
316+
* Phrase Matching Examples (with slop: 2):
317+
* - "Bryan Haberberger" → matches:
318+
* ✓ "Bryan Haberberger"
319+
* ✓ "Bryan the Haberberger"
320+
* ✓ "Bryan A. Haberberger"
321+
* ✗ "Bryan loves to eat hamburgers with Haberberger" (too many words between)
322+
*
323+
* - "manuscript illumination" → matches:
324+
* ✓ "manuscript illumination"
325+
* ✓ "manuscript and illumination"
326+
* ✓ "illumination of manuscript" (reversed order with slop)
327+
* ✓ "illuminated manuscript"
328+
*
329+
* Use Cases:
330+
* - Finding exact or near-exact phrases
331+
* - Searching for names or titles
332+
* - Looking for specific multi-word concepts
333+
* - More precise than standard search, more flexible than exact match
334+
*
335+
* Comparison with Other Search Types:
336+
* - Standard search: Finds "Bryan" AND "Haberberger" anywhere in document
337+
* - Phrase search: Finds "Bryan" near "Haberberger" (within 2 words)
338+
* - Exact match: Would require "Bryan Haberberger" with no intervening words
339+
*
340+
* Performance:
341+
* - Generally faster than wildcard search
342+
* - Slower than standard text search due to proximity calculations
343+
* - Good balance of precision and recall
344+
*
345+
* @example
346+
* POST /phraseSearch
347+
* Body: "medieval manuscript"
348+
* Returns: Annotations with "medieval" and "manuscript" in proximity
349+
*/
350+
const searchAsPhrase = async function (req, res, next) {
351+
res.set("Content-Type", "application/json; charset=utf-8")
352+
let searchText = req.body?.searchText ?? req.body
353+
if (!searchText) {
354+
let err = {
355+
message: "You did not provide text to search for in the search request.",
356+
status: 400
357+
}
358+
next(utils.createExpressError(err))
359+
return
360+
}
361+
const limit = parseInt(req.query.limit ?? 100)
362+
const skip = parseInt(req.query.skip ?? 0)
363+
364+
const phraseOptions = {
365+
slop: 2
366+
}
367+
368+
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "phrase", options: phraseOptions }, limit, skip)
369+
370+
try {
371+
const [resultsPresi3, resultsPresi2] = await Promise.all([
372+
db.aggregate(queryPresi3).toArray().catch(() => []),
373+
db.aggregate(queryPresi2).toArray().catch(() => [])
374+
])
375+
376+
const merged = mergeSearchResults(resultsPresi3, resultsPresi2)
377+
const results = merged.slice(skip, skip + limit)
378+
379+
res.set(utils.configureLDHeadersFor(results))
380+
res.json(results)
381+
} catch (error) {
382+
console.error(error)
383+
next(utils.createExpressError(error))
384+
}
385+
}
386+
387+
export {
388+
searchAsWords,
389+
searchAsPhrase
390+
}

0 commit comments

Comments
 (0)