All files / src helper.lunrSnippet.js

100% Statements 100/100
93.88% Branches 46/49
100% Functions 11/11
100% Lines 98/98

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450                                                                                                                                                      21x 21x   21x 21x 21x   34x     14x 14x   14x       14x         21x   1x       21x   24x       21x   16x     21x 21x                     1941x   45x   1896x                   460x 460x                   24x   436x                       1361x 1361x 1361x             28x   1333x                       10x   10x   10x         10x   168x       10x   10x                     10x   7x       10x                         21x     21x       21x     21x     454x   21x     433x   433x       21x     1181x   19x     1162x             21x                 21x   15x 15x   1x       1x         20x   20x                                               17x         17x     17x       14x   2x     12x       12x       12x 12x   1x         12x   12x           12x   13x 13x 13x       13x   13x 13x           13x         13x     12x   12x             12x   3x         12x     17x               17x 17x     17x     17x   16x       17x 17x   7x       10x             13x 13x          
/*
 * Helper functions used to extract text for display in search results
 * from lunr metadata.
 *
 * Part of Pleiar.no - a collection of tools for nurses
 *
 * Copyright (C) Fagforbundet 2019
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */
 
// @flow
 
import type { lunrMetadata, lunrStringOffsets } from './types/libs';
 
 
/**
 * This is the end result that we return (in the form of an array of
 * searchSnippetComponents)
 */
export type searchSnippetComponent =
 | {| type: 'normal', str: string |}
 | {| type: 'highlight', str: string |}
 | {| type: 'delimiter' |};
 
/**
 * This is the index used to indicate the location of a string that should be
 * highlighted
 */
type hightlightIndex = {|
    start: number,
    stop: number
|};
 
/**
 * This is a container used to indicate a single sentence of text that should
 * be highlighted.
 */
type singleSnippetContainer = {|
    highlight: Array<hightlightIndex>,
    from: number,
    length: number,
    snippet: string
|};
 
/**
 * A lunrResultSnippet object represents a single search result
 */
class lunrResultSnippet
{
    // This is the text string that we are extracting a snippet from
    _body: string;
    // This is the metadata that was provided by lunr
    _metadata: lunrMetadata;
    // The primary queue of text instances to highlight
    _targets: Array<lunrStringOffsets>;
    // The secondary queue of text instances to highlight. Used if we exhaust
    // _targets
    _highlight: Array<lunrStringOffsets>;
 
    // eslint-disable-next-line require-jsdoc
    constructor(bodyString: string, metadata: lunrMetadata)
    {
        this._body = bodyString;
        this._metadata = metadata;
 
        let highlight: Array<lunrStringOffsets> = [];
        const targets = ([]: Array<lunrStringOffsets>);
        for(const sub in metadata)
        {
            if(metadata[sub].body && metadata[sub].body.position.length > 0)
            {
                // Take a shallow copy of the array
                const metaEntryCopy = metadata[sub].body.position.concat([]);
                (metaEntryCopy: Array<lunrStringOffsets>);
                // Queue the first hit for display already now
                targets.push( metaEntryCopy.shift() );
                // And add the remaining entries to the secondary queue, which
                // will be pulled from if we don't have enough obligatory
                // targets
                highlight = highlight.concat(metaEntryCopy);
            }
        }
        // It should have at most three targets, if we have more, we move them
        // to the highlight queue
        while(targets.length > 3)
        {
            highlight.push( targets.pop() );
        }
        // Sort the highlight queue, so that we pull the earliest instance (if
        // needed) in the next block
        highlight.sort((a: lunrStringOffsets, b: lunrStringOffsets) =>
        {
            return a[0] - b[0];
        });
 
        // Make sure we have at least 3 target entries queued up
        while(targets.length < 3 && highlight.length > 0)
        {
            targets.push( highlight.shift() );
        }
 
        this._highlight = highlight;
        this._targets = targets;
    }
 
    /**
     * Checks if the string supplied is a sentence boundary character,
     * that is, on of . … ! or ?
     *
     * If it is, it returns true, otherwise false
     */
    _strIsBoundaryCharacter (str: string): boolean
    {
        if(str === "." || str === "…" || str === "!" || str === "?")
        {
            return true;
        }
        return false;
    }
 
    /**
     * This checks if the index supplied is at the absolute start of a sentence
     * (ie. first character of first word). Returns true if it is, otherwise
     * returns false.
     */
    _idxIsStartOfSentence (idx: number): boolean
    {
        const body = this._body;
        if(
            // A . at the start indicates a sentence boundary
            this._strIsBoundaryCharacter(body.substr(idx,1)) ||
            // A . immediately preceeding a " " indicates a sentence boundary
            (body.substr(idx,1) === " " && this._strIsBoundaryCharacter(body.substr( (idx-1),1))) ||
            // Two newlines indicate a sentence boundary
            body.substr( (idx-1),2) === "\n\n" ||
            // If we're at index zero, we are by definition at the start of a sentence
            idx === 0)
        {
            return true;
        }
        return false;
    }
 
    /**
     * This checks if the index supplied is at the absolute end of a sentence
     * (ie. the last character of the last word (including punctuation).
     * Returns true if it is, otherwise returns false
     */
    _idxIsEndOfSentence (from: number, to: number): boolean
    {
        // FIXME: Should figure out if this is actually a mid-sentence entry, so it
        // should check if the following character is upper case or not.
        const body = this._body;
        const idx = from+to;
        if(
            // A . is the end of the sentence
            this._strIsBoundaryCharacter(body.substr(idx,1)) ||
            // Two newlines indicate a sentence boundary
            body.substr(idx,2) === "\n\n"
        )
        {
            return true;
        }
        return false;
    }
 
    /**
     * This returns a "generic" text snippet from the body that we were
     * provided. This is used in cases where none of the hits were in the body
     * (but in other fields), so that we return something in those cases as
     * well.
     */
    getGenericSnippet (): Array<searchSnippetComponent>
    {
        // The start position is always 0 for the generic snippet
        const start = 0;
        // We start with a minimum length of 120
        let end: number = 120;
        // The resulting searchSnippetComponents will be stored here
        const result = ([]: Array<searchSnippetComponent>);
 
        // While end is shorter than the total length of the body, and isn't
        // the last character of a sentence, we bump end by 1. This is used to
        // find the end of the sentence that is active at character 120.
        while(end < this._body.length && !this._idxIsEndOfSentence(end,0))
        {
            end++;
        }
        // The delimiter isn't included in end at the moment, so we bump it by
        // one so that we include it
        end++;
 
        result.push({
            // We replace \n\n by punctuation, since previous algorithms treats
            // "\n\n" as a boundary between sentences (and this avoids some
            // strangeness with headers that have no punctuation immediately
            // following some other text
            str: this._body.substr(start,end).replace("\n\n",". "),
            type: 'normal'
        });
        // If there's more text in the body than we have included (which it
        // would be strange if there weren't, but we check anyway, then we tell
        // the renderer to add a delimiter to the end of it.
        if(end < this._body.length)
        {
            result.push({
                type: 'delimiter'
            });
        }
        return result;
    }
 
    /**
     * Locates a single highlight snippet in the body, then expands from there
     * to find the start and end of the sentence of said snippet, finally it
     * returns a singleSnippetContainer. If the snippet provided is already
     * within anoter, previously found snippet (in the alreadyFound array),
     * then it will latch on to that singleSnippetContainer by appending to its
     * highlight array instead and will then return null.
     */
    findSingleSnippet (start: number, stop: number, alreadyFound: Array<singleSnippetContainer>): singleSnippetContainer | null
    {
        const body = this._body;
        // From indicates the start of our string. This will end up as the
        // first character of the first word of a sentence
        let from: number = start;
        // To indicates how many characters from "from" to include. This will
        // end up as the number of characters up to and including the
        // punctuation for this sentence.
        let to: number = stop;
 
        // Locate the beginning of the sentence
        while(from > 0)
        {
            // If this index is the start, then we can break out of the loop
            if(this._idxIsStartOfSentence(from))
            {
                break;
            }
            // Reduce from by 1 to try the next one
            from--;
            // Increase to by 1 to compensate for from being reduced by one
            to++;
        }
 
        // Locate the end of the sentence
        while(to < body.length)
        {
            // If this index is at the end, then we break out of the loop
            if(this._idxIsEndOfSentence(from,to))
            {
                break;
            }
            // Increase to by 1 to try the next character
            to++;
        }
 
        // Okay, so now from is the substring index in body that where the snippet
        // should start. To is the length of the snippet.
 
        // Modify the start index so that it is relative to our new string
        start = start - from;
 
        // Figure out if an existing entry is already using this snippet. If it
        // is, we latch on to that instead of doing our own thing.
        //
        // We make the assumption that all indexes within a sentence, no matter
        // their location, will always end up with the same start and end
        // locations. Thus all we do is look through alreadyFound to see if any
        // of the entries there have the same .from as we do
        for(let existingIDX: number = 0; existingIDX < alreadyFound.length; existingIDX++)
        {
            const found = alreadyFound[existingIDX];
            if(found.from == from)
            {
                found.highlight.push(({
                    start,
                    stop
                }: hightlightIndex));
                return null;
            }
        }
 
        // Finally, get the end result from this entry
        const sub = body.substr(from,to+1);
 
        return ({
            highlight: [
                {
                    start,
                    stop
                },
            ],
            from,
            length: to,
            snippet: sub,
        }: singleSnippetContainer);
    }
 
    /**
     * This builds a single array of searchSnippetComponents from
     * an array of singleSnippetContainers.
     *
     * It splits up all strings into "normal" (non-higlighted) text components,
     * "highlight" (highlighted, ie. render in bold) text components, and
     * "delimiter" indicators (which is where something like "(…)" should
     * appear).
     */
    buildSnippetTree (from: Array<singleSnippetContainer>): Array<searchSnippetComponent>
    {
        const final = ([]: Array<searchSnippetComponent>);
        // This is the total length of the text string that we have processed
        // so far. Used to apply additional limitations in case one highlighted
        // sentence is extremely long, to avoid the result growing out of
        // proportions because of missing punctuation
        let totalLength: number = 0;
 
        // Loop through all sentences
        for(let idx: number = 0; idx < from.length; idx++)
        {
            // If we have reached a length of over 150 characters, then skip
            // adding this and any following sentences.
            if(totalLength > 150)
            {
                break;
            }
            // The current sentence
            const current = from[idx];
 
            // Indicates the offset in the string that we are at currently,
            // used to slurp non-highlighted text
            let offset: number = 0;
 
            // Sort the highlights, so that we are sure that the one at the
            // lowest index comes first (which it might not at the moment)
            const highlight = current.highlight;
            highlight.sort((a: hightlightIndex, b: hightlightIndex) =>
            {
                return a.start - b.start;
            });
 
            // If this text is not at the start of the body, then we prefix the
            // whole thing with a delimiter
            Eif(current.from > 0)
            {
                final.push({
                    type: 'delimiter'
                });
            }
 
            // Iterate through all hightlights
            while(highlight.length > 0)
            {
                const part = highlight.shift();
                const start = part.start;
                const length = part.stop;
                // If this highlights starts later than the current offset,
                // then there's text between the last highlight and this one,
                // so we build an entry with that text
                Eif(start > offset)
                {
                    const textLength = start-offset;
                    final.push({
                        str: current.snippet.substr(offset,textLength),
                        type: 'normal'
                    });
                }
                // Add the highlight snippet
                final.push({
                    str: current.snippet.substr(start,length),
                    type: 'highlight'
                });
                // Update the offset
                offset = start+length;
            }
            // Slurp up any remaining text we haven't processed
            Eif(current.snippet.length > offset)
            {
                final.push({
                    str: current.snippet.substr(offset),
                    type: 'normal'
                });
            }
            // If the body has text after this sentence, add a delimiter at the
            // end
            if((idx+1) == from.length && current.from+current.length < this._body.length)
            {
                final.push({
                    type: 'delimiter'
                });
            }
            // Update the total length
            totalLength += current.snippet.length;
        }
 
        return final;
    }
 
    /**
     * Retrieves a snippet tree for this lunrResultSnippet instance
     */
    get(): Array<searchSnippetComponent>
    {
        const results = ([]: Array<singleSnippetContainer>);
        for(const entry of this._targets)
        {
            // Get this snippet
            const snippet = this.findSingleSnippet(entry[0], entry[1], results);
            // If we got null, then the snippet is being handled by another
            // entry. Otherwise we add it to our results array.
            if(snippet !== null)
            {
                results.push(snippet);
            }
        }
        // Build and return our array of searchSnippetComponents
        const snippetTree = this.buildSnippetTree(results);
        if(snippetTree.length > 0)
        {
            return snippetTree;
        }
        // If we didn't get any searchSnippetComponents then return a generic
        // string
        return this.getGenericSnippet();
    }
}
 
export default (bodyString: string, metadata: lunrMetadata): Array<searchSnippetComponent> =>
{
    // XXX: We might want to update the api and expose the class instead
    const snippet = new lunrResultSnippet(bodyString,metadata);
    return snippet.get();
};
 
// Testing exports
export { lunrResultSnippet };