@@ -364,15 +364,12 @@ const searchAsPhrase = async function (req, res, next) {
364364 const phraseOptions = {
365365 slop : 2
366366 }
367-
368367 const [ queryPresi3 , queryPresi2 ] = buildDualIndexQueries ( searchText , { type : "phrase" , options : phraseOptions } , limit , skip )
369-
370368 try {
371369 const [ resultsPresi3 , resultsPresi2 ] = await Promise . all ( [
372370 db . aggregate ( queryPresi3 ) . toArray ( ) . catch ( ( ) => [ ] ) ,
373371 db . aggregate ( queryPresi2 ) . toArray ( ) . catch ( ( ) => [ ] )
374372 ] )
375-
376373 const merged = mergeSearchResults ( resultsPresi3 , resultsPresi2 )
377374 let results = merged . slice ( skip , skip + limit )
378375 results = results . map ( o => idNegotiation ( o ) )
@@ -384,7 +381,319 @@ const searchAsPhrase = async function (req, res, next) {
384381 }
385382}
386383
384+ /**
385+ * Fuzzy text search endpoint - searches for approximate matches allowing for typos and misspellings.
386+ *
387+ * @route POST /fuzzySearch
388+ * @param {Object } req.body - Request body containing search text
389+ * @param {string } req.body.searchText - The text to search for (can also be a plain string body)
390+ * @param {number } [req.query.limit=100] - Maximum number of results to return
391+ * @param {number } [req.query.skip=0] - Number of results to skip for pagination
392+ * @returns {Array<Object> } JSON array of matching annotation objects sorted by relevance score
393+ *
394+ * @description
395+ * Performs a fuzzy MongoDB Atlas Search that allows for approximate matches:
396+ * - Tolerates up to 1 character edit (insertion, deletion, substitution, transposition)
397+ * - Requires at least 2 characters to match exactly before fuzzy matching begins
398+ * - Expands to up to 50 similar terms
399+ * - Searches across both IIIF Presentation API 3.0 and 2.1 indexes in parallel
400+ *
401+ * Fuzzy Options:
402+ * - maxEdits: 1 (allows one character difference)
403+ * - prefixLength: 2 (first 2 characters must match exactly)
404+ * - maxExpansions: 50 (considers up to 50 similar terms)
405+ *
406+ * Search Behavior Examples:
407+ * - "Bryan" → matches "Bryan", "Brian" (1 edit)
408+ * - "Haberberger" → matches "Haberberger", "Haberburger" (1 edit)
409+ * - "manuscript" → matches "manuscript", "manuscripr" (1 edit)
410+ * - "ab" → only exact matches (too short for fuzzy, at prefixLength)
411+ *
412+ * Use Cases:
413+ * - Handling user typos
414+ * - Finding names with spelling variations
415+ * - Searching when exact spelling is uncertain
416+ * - More lenient search than standard text search
417+ *
418+ * Note: Fuzzy search typically returns more results than standard search and may
419+ * have slightly lower precision due to approximate matching.
420+ *
421+ * @example
422+ * POST /fuzzySearch?limit=200
423+ * Body: "manuscripr"
424+ * Returns: Annotations containing "manuscript" (correcting the typo)
425+ */
426+ const searchFuzzily = async function ( req , res , next ) {
427+ res . set ( "Content-Type" , "application/json; charset=utf-8" )
428+ let searchText = req . body ?. searchText ?? req . body
429+ if ( ! searchText ) {
430+ let err = {
431+ message : "You did not provide text to search for in the search request." ,
432+ status : 400
433+ }
434+ next ( utils . createExpressError ( err ) )
435+ return
436+ }
437+ const limit = parseInt ( req . query . limit ?? 100 )
438+ const skip = parseInt ( req . query . skip ?? 0 )
439+ const fuzzyOptions = {
440+ fuzzy : {
441+ maxEdits : 1 ,
442+ prefixLength : 2 ,
443+ maxExpansions : 50
444+ }
445+ }
446+ const [ queryPresi3 , queryPresi2 ] = buildDualIndexQueries ( searchText , { type : "text" , options : fuzzyOptions } , limit , skip )
447+ try {
448+ const [ resultsPresi3 , resultsPresi2 ] = await Promise . all ( [
449+ db . aggregate ( queryPresi3 ) . toArray ( ) . catch ( ( ) => [ ] ) ,
450+ db . aggregate ( queryPresi2 ) . toArray ( ) . catch ( ( error ) => { console . error ( error ) ; return [ ] ; } )
451+ ] )
452+ const merged = mergeSearchResults ( resultsPresi3 , resultsPresi2 )
453+ let results = merged . slice ( skip , skip + limit )
454+ results = results . map ( o => idNegotiation ( o ) )
455+ res . set ( utils . configureLDHeadersFor ( results ) )
456+ res . json ( results )
457+ } catch ( error ) {
458+ console . error ( error )
459+ next ( utils . createExpressError ( error ) )
460+ }
461+ }
462+
463+ /**
464+ * Wildcard pattern search endpoint - searches using wildcard patterns for partial matches.
465+ *
466+ * @route POST /wildcardSearch
467+ * @param {Object } req.body - Request body containing search pattern
468+ * @param {string } req.body.searchText - The wildcard pattern to search for (must contain * or ?)
469+ * @param {number } [req.query.limit=100] - Maximum number of results to return
470+ * @param {number } [req.query.skip=0] - Number of results to skip for pagination
471+ * @returns {Array<Object> } JSON array of matching annotation objects sorted by relevance score
472+ *
473+ * @description
474+ * Performs a wildcard search using pattern matching:
475+ * - '*' matches zero or more characters (any length)
476+ * - '?' matches exactly one character
477+ * - Searches across both IIIF Presentation API 3.0 and 2.1 indexes in parallel
478+ * - Requires at least one wildcard character in the search pattern
479+ *
480+ * Wildcard Options:
481+ * - allowAnalyzedField: true (enables wildcard search on analyzed text fields)
482+ *
483+ * Pattern Matching Examples:
484+ * - "Bryan*" → matches "Bryan", "Bryanna", "Bryan Haberberger"
485+ * - "*berger" → matches "Haberberger", "hamburger", "cheeseburger"
486+ * - "B?yan" → matches "Bryan", "Broan", "Bruan"
487+ * - "man*script" → matches "manuscript", "manuscripts", "manuscript illumination"
488+ * - "*the*" → matches any text containing "the"
489+ *
490+ * Use Cases:
491+ * - Searching for word prefixes or suffixes
492+ * - Finding variations of a term
493+ * - Partial word matching
494+ * - Pattern-based discovery
495+ *
496+ * Important Notes:
497+ * - Search pattern MUST contain at least one wildcard (* or ?)
498+ * - Returns 400 error if no wildcards are present
499+ * - Wildcard searches may be slower than standard text searches
500+ * - Leading wildcards (*term) are less efficient but supported
501+ *
502+ * Performance Tips:
503+ * - Avoid leading wildcards when possible ("term*" is faster than "*term")
504+ * - Be specific to reduce result set size
505+ * - Use with limit parameter for large result sets
506+ *
507+ * @example
508+ * POST /wildcardSearch
509+ * Body: "*berger"
510+ * Returns: All annotations with words ending in "berger"
511+ *
512+ * @example
513+ * POST /wildcardSearch
514+ * Body: "man?script"
515+ * Returns: Annotations matching "manuscript", "manuscript", etc.
516+ */
517+ const searchWildly = async function ( req , res , next ) {
518+ res . set ( "Content-Type" , "application/json; charset=utf-8" )
519+ let searchText = req . body ?. searchText ?? req . body
520+ if ( ! searchText ) {
521+ let err = {
522+ message : "You did not provide text to search for in the search request." ,
523+ status : 400
524+ }
525+ next ( utils . createExpressError ( err ) )
526+ return
527+ }
528+ // Require wildcards in the search text
529+ if ( ! searchText . includes ( '*' ) && ! searchText . includes ( '?' ) ) {
530+ let err = {
531+ message : "Wildcards must be used in wildcard search. Use '*' to match any characters or '?' to match a single character." ,
532+ status : 400
533+ }
534+ next ( utils . createExpressError ( err ) )
535+ return
536+ }
537+ const limit = parseInt ( req . query . limit ?? 100 )
538+ const skip = parseInt ( req . query . skip ?? 0 )
539+ const wildcardOptions = {
540+ allowAnalyzedField : true
541+ }
542+ const [ queryPresi3 , queryPresi2 ] = buildDualIndexQueries ( searchText , { type : "wildcard" , options : wildcardOptions } , limit , skip )
543+ try {
544+ const [ resultsPresi3 , resultsPresi2 ] = await Promise . all ( [
545+ db . aggregate ( queryPresi3 ) . toArray ( ) . catch ( ( ) => [ ] ) ,
546+ db . aggregate ( queryPresi2 ) . toArray ( ) . catch ( ( ) => [ ] )
547+ ] )
548+ const merged = mergeSearchResults ( resultsPresi3 , resultsPresi2 )
549+ let results = merged . slice ( skip , skip + limit )
550+ results = results . map ( o => idNegotiation ( o ) )
551+ res . set ( utils . configureLDHeadersFor ( results ) )
552+ res . json ( results )
553+ } catch ( error ) {
554+ console . error ( error )
555+ next ( utils . createExpressError ( error ) )
556+ }
557+ }
558+
559+ /**
560+ * "More Like This" search endpoint - finds documents similar to a provided example document.
561+ *
562+ * @route POST /searchAlikes
563+ * @param {Object } req.body - A complete JSON document to use as the search example
564+ * @param {number } [req.query.limit=100] - Maximum number of results to return
565+ * @param {number } [req.query.skip=0] - Number of results to skip for pagination
566+ * @returns {Array<Object> } JSON array of similar annotation objects sorted by relevance score
567+ *
568+ * @description
569+ * Performs a "moreLikeThis" search that finds documents similar to an example document:
570+ * - Analyzes the provided document's text content
571+ * - Extracts significant terms and patterns
572+ * - Finds other documents with similar content
573+ * - Uses both IIIF 3.0 (presi3AnnotationText) and IIIF 2.1 (presi2AnnotationText) indexes
574+ * - Great for discovery and finding related content
575+ *
576+ * How It Works:
577+ * 1. You provide a complete JSON document (annotation, manifest, etc.)
578+ * 2. MongoDB Atlas Search extracts key terms from the document
579+ * 3. Searches for other documents containing similar terms
580+ * 4. Returns results ranked by similarity score
581+ *
582+ * Use Cases:
583+ * - "Find more annotations like this one"
584+ * - Discovering related content after viewing a document
585+ * - Building recommendation systems
586+ * - Content clustering and grouping
587+ * - Finding duplicates or near-duplicates
588+ *
589+ * Workflow:
590+ * 1. User performs standard search → gets results
591+ * 2. User selects an interesting result
592+ * 3. Pass that document to /searchAlikes
593+ * 4. Get more documents with similar content
594+ *
595+ * Important Notes:
596+ * - Requires a full JSON document in request body (not just text)
597+ * - Searches both IIIF 3.0 (presi3AnnotationText) and IIIF 2.1 (presi2AnnotationText) indexes
598+ * - Returns 400 error if body is empty or invalid
599+ * - More effective with documents containing substantial text content
600+ *
601+ * Input Document Structure:
602+ * - Can be any annotation object from your collection
603+ * - Should contain text in body.value, bodyValue, or nested fields
604+ * - The more text content, the better the similarity matching
605+ *
606+ * @example
607+ * POST /searchAlikes
608+ * body: {
609+ * "type": "Annotation",
610+ * "body": {
611+ * "value": "Medieval manuscript with gold leaf illumination..."
612+ * }
613+ * }
614+ * Returns: Other annotations about medieval manuscripts and illumination
615+ *
616+ * @example
617+ * // Typical workflow:
618+ * // 1. Search for "illuminated manuscripts"
619+ * const results = await fetch('/search', {body: {searchText: "illuminated manuscripts"}})
620+ * // 2. User likes result[0], find more like it
621+ * const similar = await fetch('/searchAlikes', {body: results[0]})
622+ */
623+ const searchAlikes = async function ( req , res , next ) {
624+ res . set ( "Content-Type" , "application/json; charset=utf-8" )
625+ let likeDocument = req . body
626+ // Validate that a document was provided
627+ if ( ! likeDocument || ( typeof likeDocument !== 'object' ) || Object . keys ( likeDocument ) . length === 0 ) {
628+ let err = {
629+ message : "You must provide a JSON document in the request body to find similar documents." ,
630+ status : 400
631+ }
632+ next ( utils . createExpressError ( err ) )
633+ return
634+ }
635+ const limit = parseInt ( req . query . limit ?? 100 )
636+ const skip = parseInt ( req . query . skip ?? 0 )
637+ // Build moreLikeThis queries for both IIIF 3.0 and IIIF 2.1 indexes
638+ const searchQuery_presi3 = [
639+ {
640+ $search : {
641+ index : "presi3AnnotationText" ,
642+ moreLikeThis : {
643+ like : Array . isArray ( likeDocument ) ? likeDocument : [ likeDocument ]
644+ }
645+ }
646+ } ,
647+ {
648+ $addFields : {
649+ "__rerum.score" : { $meta : "searchScore" }
650+ }
651+ } ,
652+ {
653+ $limit : limit + skip // Get extra to handle deduplication
654+ }
655+ ]
656+ const searchQuery_presi2 = [
657+ {
658+ $search : {
659+ index : "presi2AnnotationText" ,
660+ moreLikeThis : {
661+ like : Array . isArray ( likeDocument ) ? likeDocument : [ likeDocument ]
662+ }
663+ }
664+ } ,
665+ {
666+ $addFields : {
667+ "__rerum.score" : { $meta : "searchScore" }
668+ }
669+ } ,
670+ {
671+ $limit : limit + skip // Get extra to handle deduplication
672+ }
673+ ]
674+ try {
675+ // Execute both queries in parallel
676+ const [ results_presi3 , results_presi2 ] = await Promise . all ( [
677+ db . aggregate ( searchQuery_presi3 ) . toArray ( ) ,
678+ db . aggregate ( searchQuery_presi2 ) . toArray ( )
679+ ] )
680+ // Merge and deduplicate results
681+ const merged = mergeSearchResults ( results_presi3 , results_presi2 )
682+ // Apply pagination after merging
683+ let results = merged . slice ( skip , skip + limit )
684+ results = results . map ( o => idNegotiation ( o ) )
685+ res . set ( utils . configureLDHeadersFor ( paginatedResults ) )
686+ res . json ( paginatedResults )
687+ } catch ( error ) {
688+ console . error ( error )
689+ next ( utils . createExpressError ( error ) )
690+ }
691+ }
692+
387693export {
388694 searchAsWords ,
389- searchAsPhrase
695+ searchAsPhrase ,
696+ searchWildly ,
697+ searchFuzzily ,
698+ searchAlikes
390699}
0 commit comments