Home Reference Source

js/models/Search.js

import Data, { EnvKey } from '~/models/Data';
import ErrorManager from '~/helpers/ErrorManager';
import algoliasearch from 'algoliasearch/lite';

import Answer from '~/models/Answer';
import Post from '~/models/Post';
import User from '~/models/User';

export class SearchCategory {
    /**
     * @param {Object} opts
     * @param {string} opts.name - Backend facing name
     * @param {string} opts.title - Title friendly to user
     * @param {Function} opts.formatter - Takes object as arg and spits out
     *                                  full object
     */
    constructor({ name, title, formatter } = {}) {
        /** @private */
        this.name = name;

        /** @type {string} */
        this.title = title;

        /** @type {Function} */
        this.format = formatter;
    }
}

export const SearchCategories = [
    new SearchCategory({
        name: 'posts',
        title: 'Challenges',
        formatter: Post.fromIndexJSON
    }),
    new SearchCategory({
        name: 'answers',
        title: 'Answers',
        formatter: Answer.fromIndexJSON
    }),
    new SearchCategory({
        name: 'users',
        title: 'Users',
        formatter: User.fromIndexJSON
    })
];

export default class Search {

    /**
     * Creates a standard instance
     * @return {Search}
     */
    static createClient() {
        return new Search(
            Data.shared.envValueForKey(EnvKey.algoliaAppId),
            Data.shared.envValueForKey(EnvKey.algoliaSearchKey),
            Data.shared.envValueForKey(EnvKey.indexPrefix)
        );
    }

    /**
     * Obtains an index name
     * @param {string} name - Input name
     * @return {string}
     */
    getIndexName(name) {
        if (this.prefix) {
            return `${this.prefix}_${name}`;
        } else {
            return name;
        }
    }

    /**
     * Obtains an index
     * @param {name} string
     * @return {algoliasearch.Index}
     */
    getIndex(name) {
        return this.client.initIndex(this.getIndexName(name));
    }

    /**
     * Obtains index from category
     * @param {Category} [varname] [description]
     */
    getIndexFromCategory(category) {
        return this.getIndex(category.name);
    }

    /**
     * Obtains search category with name
     */
    getCategoryFromFullName(name) {
        return this.indexMap.get(name.replace(/^.+?_/, ''));
    }

    /**
     * Creates site search instance
     * @param {string} appId       generally from the data ids
     * @param {string} searchToken generally from the data ids
     * @param {string} prefix      generally from the data ids
     * @param {SearchCategory[]} [categories=[]] Additional search categories
     */
    constructor(appId, searchToken, prefix, categories = []) {
        /** @type {string} */
        this.prefix = prefix;

        /** @type {algoliasearch.Client} */
        this.client = algoliasearch(appId, searchToken);

        /** @type {SearchCategory[]} */
        this.allCategories = SearchCategories.concat(categories);

        /** @type {Map} */
        this.indexMap = new Map(this.allCategories.map(category => [category.name, category]));

        /** @type {algoliasearch.Index[]} */
        this.indices = this.allCategories.map(category => this.getIndexFromCategory(category));
    }

    /**
     * Performs a search across indexes.
     * @param {string} query - The text for search
     * @param {Object} opts - See {@link MultiIndexSearch}
     * @return {MultiIndexSearch} Use this to iterate through pages.
     */
    globalSearch(query, opts) {
        return new MultiIndexSearch(
            this,
            query,
            this.allCategories.map(
                category => this.getIndexName(category.name)),
            opts
        );
    }

    /**
     * Formats a result
     * @param {Object} result
     */
    formatResult(category, result) {
        let formatted;
        try {
            formatted = category.format(result);
        } catch(error) {
            ErrorManager.silent(error, `Failed to format object of type ${category.name}`, result);
            return null;
        }
        return new SearchResult(category, formatted, result);
    }

}

/**
 * A group of categories. This supports formatting for most types
 */
export class MultiIndexSearch {
    /**
     * @param {Search} search - The search object
     * @param {string} query - Query
     * @param {string[]} indices - Names of indices to iterate over
     * @param {Object} opts - The options
     * @param {number} [perPage=20] - Amount to load per page
     */
    constructor(search, query, indices, { perPage = 20 } = {}) {
        /** @type {Search} */
        this.search = search;

        // // Parse the query
        // // Expandable in future
        // const filters = new Map();
        // const filterRegex = /(?:^|\s+)([a-z]+): ?("(?:\\.|[^\\"])*?"|[a-z0-9]+)/g;
        // let queryString = query.replace(filterRegex, '').trim();
        //
        // let match;
        // while (match = filterRegex.exec(query)) {
        //     if (filterRegex.lastIndex === match.index)
        //         filterRegex.lastIndex.lastIndex++;
        //
        //     const [querySection, key, value] = match;
        //     filters.set(key, value);
        // }

        // Generate options
        /** @private */
        this.opts = indices.map(indexName => ({
            indexName: indexName,
            query: query,
            params: {
                hitsPerPage: perPage,
                advancedSyntax: true
            }
        }));

        this._areMore = true;
        this._pages = [];
        this._pageNo = 0;
    }

    /**
     * Formats a batch result
     * @param {Object} response
     */
    formatResults(response) {
        const results = response.results;
        const resultMap = new Map();

        let areMore = false;
        for (let i = 0; i < results.length; i++) {
            const indexName = results[i].index;
            const category = this.search.getCategoryFromFullName(indexName);
            let categoryHasMore = false;

            if (results[i].page < results[i].nbPages - 1) {
                areMore = true;
                categoryHasMore = true;
            }

            let formattedResults = [];

            for (let j = 0; j < results[i].hits.length; j++) {
                const formattedResult = this.search.formatResult(category, results[i].hits[j]);
                if (formattedResult !== null) {
                    formattedResults.push(formattedResult);
                }
            }

            resultMap.set(category, { areMore: categoryHasMore, results: formattedResults });
        }

        return new SearchResults(this.search, resultMap, areMore);
    }

    /**
     * Gets the nth page
     * @param {number} pageNumber
     * @return {SearchResults}
     */
    async getPage(pageNumber) {
        if (this._pages[pageNumber])
            return this._pages[pageNumber];

        const results = await this.search.client.search(this.opts);
        return this.formatResults(results);
    }

    /**
     * Obtains the next page
     * @return {?SearchResults}
     */
    async next() {
        if (!this._areMore) return null;

        const result = await this.getPage(this._pageNo++);
        if (!result.areMore) {
            this._areMore = false;
        }
        return result;
    }

    /**
     * If they are more.
     * @type {boolean}
     */
    get areMore() { return this._areMore; }

    /**
     * Will start at the page as incremented by `next()`.
     * @return {SearchResults}
     */
    async *[Symbol.iterator]() {
        while (this.areMore) {
            yield* await this.next();
        }
    }
}

/**
 * Represents categories or list of results.
 */
export class SearchResults {
    /**
     * Represents group of search results
     * @param {Search} search
     * @param {Map} results From {@link MultiIndexSearch}
     * @param {boolean} areMore
     */
    constructor(search, results, areMore) {
        /** @type {Search} */
        this.search = search;

        /** @private */
        this.results = results;

        /**
         * @readonly
         * @type {boolean}
         */
        this.areMore = areMore;
    }

    /**
     * Checks if category has more
     * @param {SearchCategory} category
     * @return {boolean} If more results exist
     */
    areMoreResultsForCategory(category) {
        return this.results.get(category).areMore;
    }

    /**
     * Returns results for a category
     * @param {SearchCategory} category
     * @return {SearchResult[]}
     */
    getResultsForCategory(category) {
        return this.results.get(category).results;
    }

    /**
     * Checks if category is empty
     * @param {SearchCategory} category
     * @return {boolean}
     */
    categoryHasResultsForCategory(category) {
        return this.getResultsForCategory(category).length > 0;
    }

    /**
     * Iterates by category.
     * @return {SearchCategory}
     */
    *categories() {
        yield* this.results.keys();
    }

    /**
     * Iterates category/result pairs
     * @return {[SearchCategory, SearchResult[]]}
     */
    *resultsByCategory() {
        for (const [category, { results }] of this.results) {
            yield [category, results];
        }
    }

    /**
     * Iterates by item
     */
    *results() {
        for (const [category, { results }] of this.results) {
            yield* results;
        }
    }
}

/**
 * Represents a given search result as native object
 */
export class SearchResult {
    /**
     * Represents search result
     * @param {SearchCategory} category - A search category
     * @param {Object} object - Formatted object
     * @param {Object} result - the result object from algolia
     */
    constructor(category, object, result) {
        /** @type {SearchCategory}] */
        this.category = category;

        /** @private */
        this.object = object;

        /** @private */
        this._result = result;

        /**
         * Unique foreign id for object
         * @type {string}
         */
        this.id = result.objectID;

    }

    /**
     * Obtains highlight snippet for key
     * @param {string} key - Key of item eg `author.name`
     * @param {?Function} predicate - If key exists call the predicate and return
     *                              the results, else fragment.
     * @param {string} [type=span] - Node type to wrap in
     * @return {DocumentFragment} HTML node with <em> for highlights
     * @throws {TypeError} If key not found
     */
    highlightSnippetForKey(key, predicate = null, type = 'span') {
        const parts = key.split('.');
        let lastSnippet = this._result._snippetResult;

        for (let i = 0; i < parts.length; i++) {
            lastSnippet = lastSnippet[parts[i]];

            if (!lastSnippet) {
                throw new TypeError(`section ${parts[i]} of ${key} not found in object type ${this.category.name}.`);
            }
        }

        const value = lastSnippet.value;
        const tempWrapper = document.createElement(type);
        tempWrapper.innerHTML = value;

        if (predicate) {
            if (lastSnippet.matchLevel !== 'none') {
                return predicate(tempWrapper);
            } else {
                return document.createDocumentFragment();
            }
        } else {
            return tempWrapper;
        }
    }

    /**
     * Obtains the _full_ highlight
     * @param {string} key - Key of item eg `author.name`
     * @return {string} string of the value.
     * @throws {TypeError} If key not found
     */
    highlightForKey(key) {
        const parts = key.split('.');
        let lastHighlight = this._result._highlightResult;

        for (let i = 0; i < parts.length; i++) {
            lastHighlight = lastHighlight[parts[i]];

            if (!lastHighlight) {
                throw new TypeError(`section ${parts[i]} of ${key} not found in object type ${this.category.name}.`);
            }
        }

        return lastHighlight.value;
    }

    /**
     * Returns underlying value or object backing this result. This is the
     * formatted local model.
     * @return {Object}
     */
    get value() { return this.object; }
}