PMA.UI Documentation by Pathomation

components/js/metadataSearch.js

import { Resources } from '../../resources/resources';
import { Context } from './context';
import { Events } from './components';
import { checkBrowserCompatibility } from '../../view/helpers';

/**
 * Represents a component that searches in form meta data for slides. It has a visual representation as a text field and also provides programmatic methods to search.
 * @param {Context} context
 * @param {object} [options=null] - Configuration options. If not provided, the component has no visual representation.
 * @param {string|HTMLElement} options.element - The container in which an input field will be added to function as a search box. It can be either a valid CSS selector or an HTMLElement instance.
 * @fires Components.Events#SearchStarted
 * @fires Components.Events#SearchFinished
 * @tutorial 08-meta-search
 */
export class MetadataSearch {
    constructor(context, options) {
        if (!checkBrowserCompatibility()) {
            return;
        }

        if (!options) {
            options = {};
        }

        this.context = context;
        this.servers = {};
        this.currentFocus = null;
        this.searchTimeout = 0;

        if (options.element instanceof HTMLElement) {
            this.element = options.element;
        }
        else if (typeof options.element == "string") {
            var el = document.querySelector(options.element);
            if (!el) {
                console.error("Invalid selector for element");
            }
            else {
                this.element = el;
            }
        }
        else {
            this.element = null;
        }

        this.listeners = {};
        this.listeners[Events.SearchStarted] = [];
        this.listeners[Events.SearchFinished] = [];
        this.options = options;
        this.query = "";
        this.position = 0;

        if (this.element) {
            initializeTextBox.call(this);
        }

        this.retries = {};
    }
    /**
     * Attaches an event listener
     * @param {Components.Events} eventName - The name of the event to listen to
     * @param {function} callback - The function to call when the event occurs
     */
    listen(eventName, callback) {
        if (!this.listeners.hasOwnProperty(eventName)) {
            console.error(eventName + " is not a valid event");
        }

        this.listeners[eventName].push(callback);
    }
    // fires an event
    fireEvent(eventName, eventArgs) {
        if (!this.listeners.hasOwnProperty(eventName)) {
            console.error(eventName + " does not exist");
            return;
        }

        for (var i = 0, max = this.listeners[eventName].length; i < max; i++) {
            this.listeners[eventName][i].call(this, eventArgs);
        }
    }
    /*************** Parsing & searching *************/
    /**
     * Searches for slides in meta data entries
     * @param {string} serverUrl - The url of the PMA.core search to search against
     * @param {string} queryString - The query to search with
     * @returns {Promise}
     */
    search(serverUrl, queryString) {
        var self = this;

        var searchToken = (new Date()).getTime();
        self.searchToken = searchToken;
        if (self.retries.hasOwnProperty(serverUrl) && self.retries[serverUrl] > 2) {
            return Promise.reject();
        }

        return initServer.call(this, serverUrl).
            then(function () {
                self.retries[serverUrl] = 0;
                return parse.call(self, queryString);
            }, function () {
                self.retries[serverUrl] = (self.retries[serverUrl] || 0) + 1;
            }).
            then(function (query) {
                if (query.expressions.length === 0) {
                    return getFormSuggestions.call(self, serverUrl, query);
                }

                return getSuggestions.call(self, serverUrl, query);
            }).
            then(function (processedQuery) {
                if (!processedQuery || self.searchToken !== searchToken) {
                    return null;
                }

                var searchTerms = {
                    "sessionID": self.servers[serverUrl].sessionId,
                    "expressions": []
                };

                for (var i = 0; i < processedQuery.expressions.length; i++) {
                    var expr = processedQuery.expressions[i];
                    if (!isExpressionValid.call(self, expr)) {
                        continue;
                    }

                    var form = findForm.call(self, serverUrl, expr[0].value);
                    if (!form) {
                        continue;
                    }

                    var field = findField.call(self, form, expr[1].value);

                    if (!field) {
                        continue;
                    }

                    searchTerms.expressions.push({
                        "FormID": form.FormID,
                        "FieldID": field.FieldID,
                        "Operator": 0,
                        "Value": field.FormList ? listLabelToID(field.FormList.FormListValues, expr[3].value) : expr[3].value
                    });
                }

                if (self.searchToken === searchToken) {
                    if (searchTerms.expressions.length === 0) {
                        return { serverUrl: serverUrl, query: processedQuery, results: [] };
                    }

                    var searchTermsJson = JSON.stringify(searchTerms);

                    if (self.lastSearchTerms == searchTermsJson) {
                        return { serverUrl: serverUrl, query: processedQuery, results: self.lastSearchResults.slice() };
                    }

                    self.lastSearchTerms = "";
                    self.lastSearchResults = [];
                    return searchWithDelay.call(self, serverUrl, processedQuery, searchTerms, searchToken);
                }
            });
    }
}


const tokenTypeEnum = {
    literal: "literal",
    equalOperator: "equal to",
    greaterThanOperator: "greater than",
    greaterThanOrEqualToOperator: "greater than or equal to",
    lessThanOperator: "less than",
    lessThanOrEqualToOperator: "less than or equal to",
    differentOperator: "different than",
    similarOperator: "similar to"
};

function initServer(serverUrl) {
    var self = this;
    if (self.inputBox) {
        self.inputBox.readonly = true;
    }

    return new Promise(function (resolve, reject) {
        self.context.getVersionInfo(serverUrl, function (version) {
            if (!version || version.startsWith("1.")) {
                reject();
                return;
            }

            self.context.getSession(serverUrl, function (sessionId) {
                self.servers[serverUrl] = self.servers[serverUrl] || {};
                self.servers[serverUrl].sessionId = sessionId;

                loadForms.call(self, serverUrl, function () {
                    if (self.inputBox) {
                        self.inputBox.readonly = false;
                    }

                    resolve(sessionId);
                }, reject);

            }, reject);
        }, reject);
    });
}

function loadForms(serverUrl, resolve, reject) {
    if (this.servers[serverUrl].forms) {
        resolve();
        return;
    }

    var self = this;
    this.context.getFormDefinitions(serverUrl, [], "", function (sessionId, definitions) {
        self.servers[serverUrl].forms = definitions; // .filter(function (x) { return !x.ReadOnly; });
        self.servers[serverUrl].forms.push({ FormID: -1, FormName: "Slide", FormFields: [{ FieldID: 0, Label: "Path" }, { FieldID: 1, Label: "Barcode" }/*, { FieldID: 0, Label: "Extension" }*/] });
        resolve();
    }, reject);
}


function searchWithDelay(serverUrl, processedQuery, searchTerms, searchToken) {
    var self = this;
    clearTimeout(self.searchTimeout);

    var searchTermsJson = JSON.stringify(searchTerms);

    var slideSearchExpressions = searchTerms.expressions.filter(function (item) {
        return item.FormID === -1 && item.FieldID === 0;
    });

    var searchFilesOnly = slideSearchExpressions.length > 0 && slideSearchExpressions.length === searchTerms.expressions.length;

    var metaSearchQuery = searchTerms;

    return new Promise(function (resolve) {
        self.searchTimeout = setTimeout(function () {
            self.fireEvent(Events.SearchStarted, { serverUrl: serverUrl, queryString: processedQuery.getText() });

            if (searchFilesOnly) {
                searchPaths.call(self, serverUrl, slideSearchExpressions).
                    then(function (pathResults) {
                        var results = [];
                        if (pathResults instanceof Array) {
                            results = pathResults;
                        }

                        if (self.searchToken !== searchToken) {
                            return;
                        }

                        self.lastSearchTerms = searchTermsJson;
                        self.lastSearchResults = results;
                        self.fireEvent(Events.SearchFinished, results);
                        resolve({ serverUrl: serverUrl, query: processedQuery, results: results });
                    });
            }
            else {
                fetch(serverUrl + "query/json/Metadata", {
                    method: "POST",
                    mode: "cors",
                    credentials: "same-origin",
                    headers: { "Content-Type": "application/json; charset=utf-8" },
                    body: JSON.stringify(metaSearchQuery)
                }).
                    then(function (response) {
                        if (response.ok) {
                            return response.json();
                        }
                        else {
                            return [];
                        }
                    }).
                    then(function (metaResults) {
                        if (self.searchToken !== searchToken) {
                            return;
                        }

                        self.lastSearchTerms = searchTermsJson;
                        self.lastSearchResults = metaResults;

                        self.fireEvent(Events.SearchFinished, metaResults);
                        resolve({ serverUrl: serverUrl, query: processedQuery, results: metaResults });
                    });
            }
        }, 1000);
    });
}

function searchPaths(serverUrl, expressions) {
    var self = this;

    if (!expressions || expressions.length === 0) {
        return Promise.resolve(null);
    }

    return new Promise(function (resolve, reject) {
        var q = expressions.map(function (item) { return item.Value; }).join("|");

        self.context.queryFilename(serverUrl, "", q, function (sessionId, results) { resolve(results); }, reject);
    });
}

function intersect(a, b) {
    var t;
    if (b.length > a.length) {
        // indexOf to loop over shorter
        t = b;
        b = a;
        a = t;
    }

    return a.filter(function (e) {
        return b.indexOf(e) > -1;
    }).
        filter(function (e, i, c) { // extra step to remove duplicates
            return c.indexOf(e) === i;
        });
}

function parse(queryString) {
    if (typeof (queryString) !== "string") {
        throw "Query must be a string";
    }

    this.query = queryString;
    this.position = 0;

    var tokens = [];

    while (canRead.call(this)) {
        skipWhitespace.call(this);

        var t = parseToken.call(this);
        if (t) {
            tokens.push(t);
        }
    }

    var query = {
        getText: function () {
            if (!this.expressions || this.expressions.length === 0) {
                return "";
            }

            var result = "";

            for (var i = 0; i < this.expressions.length; i++) {
                if (i > 0) {
                    result += ",";
                }

                var expr = this.expressions[i];

                for (var t = 0; t < expr.length; t++) {
                    if (result.length > 0) {
                        result += " ";
                    }

                    if (expr[t].quoted) {
                        result += '"';
                    }

                    result += expr[t].value;

                    if (expr[t].quoted) {
                        result += '"';
                    }
                }
            }

            return result;
        },
        getLastToken: function () {
            if (!this.expressions || this.expressions.length === 0) {
                return null;
            }

            var lastExpr = this.expressions[this.expressions.length - 1];
            if (lastExpr.length === 0) {
                return null;
            }

            return lastExpr[lastExpr.length - 1];
        },
        expressions: [],
        suggestions: []
    };

    var i = 0;
    while (i < tokens.length) {
        if (i <= tokens.length - 4 &&
            tokens[i + 0].type === tokenTypeEnum.literal &&
            tokens[i + 1].type === tokenTypeEnum.literal &&
            tokens[i + 2].operator &&
            tokens[i + 3].type === tokenTypeEnum.literal) {

            query.expressions.push([
                tokens[i + 0],
                tokens[i + 1],
                tokens[i + 2],
                tokens[i + 3]
            ]);

            i += 4;
        }
        else if (i === tokens.length - 3 &&
            tokens[i + 0].type === tokenTypeEnum.literal &&
            tokens[i + 1].type === tokenTypeEnum.literal &&
            tokens[i + 2].operator) {

            query.expressions.push([
                tokens[i + 0],
                tokens[i + 1],
                tokens[i + 2]
            ]);

            i += 3;
        }
        else if (i === tokens.length - 2 &&
            tokens[i + 0].type === tokenTypeEnum.literal &&
            tokens[i + 1].type === tokenTypeEnum.literal) {

            query.expressions.push([
                tokens[i + 0],
                tokens[i + 1]
            ]);

            i += 2;
        }
        else if (tokens[i].type === tokenTypeEnum.literal) {
            query.expressions.push([tokens[i]]);

            i++;
        }
        else {
            i++;
        }
    }

    return new Promise(function (resolve, reject) { resolve(query); });
}

function canRead() {
    return this.position < this.query.length;
}

function readChar() {
    if (canRead.call(this)) {
        return this.query[this.position++];
    }
    else {
        return "";
    }
}

function peekChar() {
    if (canRead.call(this)) {
        return this.query[this.position];
    }
    else {
        return "";
    }
}

function isExpressionValid(expression) {
    return expression &&
        expression.length === 4 &&
        expression[0].type === tokenTypeEnum.literal &&
        expression[1].type === tokenTypeEnum.literal &&
        expression[2].operator &&
        expression[3].type === tokenTypeEnum.literal;
}

function isTokenComplete(token) {
    if (!token || (!token.quoted && isWhitespace.call(this, this.query[this.query.length - 1]))) {
        return true;
    }
    else if (token.quoted) {
        var trimmed = this.query.trimRight();
        if (trimmed.length >= 2 && trimmed[trimmed.length - 1] === '"' && trimmed[trimmed.length - 1] === '\\') {
            return false;
        }
        else if (trimmed[trimmed.length - 1] === '"') {
            return true;
        }
    }

    return false;
}

function getSuggestions(serverUrl, processedQuery) {
    var lastExpression = processedQuery.expressions[processedQuery.expressions.length - 1];

    if (lastExpression.length === 1) {
        // either suggest forms or fields
        if (isTokenComplete.call(this, lastExpression[0])) {
            return getFieldSuggestions.call(this, serverUrl, processedQuery, lastExpression[0]);
        }
        else {
            return getFormSuggestions.call(this, serverUrl, processedQuery);
        }
    }
    else if (lastExpression.length === 2) {
        // either suggest fields or operators
        if (isTokenComplete.call(this, lastExpression[1])) {
            return getOperatorSuggestions.call(this, processedQuery);
        }
        else {
            return getFieldSuggestions.call(this, serverUrl, processedQuery, lastExpression[0]);
        }
    }
    else if (lastExpression.length === 3) {
        // either suggest operators or values
        if (isTokenComplete.call(this, lastExpression[2])) {
            return getValueSuggestions.call(this, serverUrl, processedQuery, lastExpression[0], lastExpression[1]);
        }
        else {
            return getOperatorSuggestions.call(this, processedQuery);
        }
    }
    else {
        // either suggest forms or fields
        if (isTokenComplete.call(this, lastExpression[3])) {
            return getFormSuggestions.call(this, serverUrl, processedQuery);
        }
        else {
            return getValueSuggestions.call(this, serverUrl, processedQuery, lastExpression[0], lastExpression[1]);
        }
    }
}

function findForm(serverUrl, name) {
    if (!this.servers[serverUrl] || !this.servers[serverUrl].forms) {
        return null;
    }

    return this.servers[serverUrl].forms.find(function (x) { return x.FormName.localeCompare(name, {}, { sensitivity: "base" }) === 0; });
}

function findField(form, name) {
    if (!form) {
        return null;
    }

    return form.FormFields.find(function (x) { return x.Label.localeCompare(name, {}, { sensitivity: "base" }) === 0; });
}

function getFormSuggestions(serverUrl, processedQuery) {
    processedQuery.suggestions = this.servers[serverUrl].forms.map(function (x) { return x.FormName; });

    return new Promise(function (resolve, reject) {
        resolve(processedQuery);
    });
}

function getFieldSuggestions(serverUrl, processedQuery, form) {
    var formObj = findForm.call(this, serverUrl, form.value);
    if (formObj) {
        processedQuery.suggestions = formObj.FormFields.map(function (x) { return x.Label; });
    }

    return new Promise(function (resolve, reject) {
        resolve(processedQuery);
    });
}

function getOperatorSuggestions(processedQuery) {
    var self = this;
    return new Promise(function (resolve, reject) {
        processedQuery.suggestions = getOperators.call(self);
        resolve(processedQuery);
    });
}

function listLabelToID(list, label) {
    var result = [];
    for (var j = 0; j < list.length; j++) {
        if (list[j].Value == label) {
            return list[j].ValueID;
        }
    }

    return "";
}

function valuesToListLabels(list, values) {
    var result = [];
    var i, j;
    for (i = 0; i < values.length; i++) {
        for (j = 0; j < list.length; j++) {
            if (list[j].ValueID == values[i]) {
                result.push(list[j].Value);
                break;
            }
        }
    }

    return result;
}

function getValueSuggestions(serverUrl, processedQuery, form, field) {
    var self = this;

    var formObj = findForm.call(this, serverUrl, form.value);
    if (!formObj) {
        return new Promise(function (resolve, reject) {
            resolve(processedQuery);
        });
    }

    var fieldObject = formObj.FormFields.find(function (x) { return x.Label.localeCompare(field.value, {}, { sensitivity: "base" }) === 0; });
    if (!fieldObject) {
        return new Promise(function (resolve, reject) {
            resolve(processedQuery);
        });
    }

    if (fieldObject.values) {
        processedQuery.suggestions = fieldObject.values;

        return new Promise(function (resolve, reject) {
            resolve(processedQuery);
        });
    }

    return fetch(serverUrl + "query/json/DistinctValues?formID=" + formObj.FormID + "&fieldID=" + fieldObject.FieldID + "&sessionID=" + self.servers[serverUrl].sessionId, {
        method: "GET",
        mode: "cors",
        credentials: "same-origin",
        headers: {
            "Content-Type": "application/json; charset=utf-8"
        }
    }).
        then(function (response) {
            if (response.ok) {
                return response.json();
            }
            else {
                return [];
            }
        }).
        then(function (values) {
            if (fieldObject.FormList) {
                fieldObject.values = valuesToListLabels(fieldObject.FormList.FormListValues, values);
            }
            else {
                fieldObject.values = values.map(function (x) { return x + ""; });
            }

            processedQuery.suggestions = fieldObject.values;
            return processedQuery;
        });
}

function getOperators() {
    return ["="];
    ////return ["=", ">", "<>", "<", ">=", "<=", "~="];
}

function isWhitespace(char) {
    return [' ', ','].indexOf(char) !== -1;
}

function skipWhitespace() {
    while (isWhitespace.call(this, peekChar.call(this))) {
        readChar.call(this);
    }
}

function isOperatorChar(char) {
    return ['=', '>', '<', '~'].indexOf(char) !== -1;
}

function isTokenChar(char) {
    return !isWhitespace.call(this, char) && !isOperatorChar.call(this, char);
}

function parseToken() {
    if (!canRead.call(this)) {
        return null;
    }

    var tokenChars = [];

    var isQuoted = false;
    if (peekChar.call(this) === '"') {
        readChar.call(this);
        isQuoted = true;
    }
    else if (isOperatorChar.call(this, peekChar.call(this))) {
        return parseOperator.call(this);
    }

    while (canRead.call(this)) {
        var c = readChar.call(this);

        if (isQuoted) {
            if (c === "\\" && peekChar.call(this) === '"') {
                tokenChars.push(readChar.call(this));
                continue;
            }
            else if (c === '"') {
                break;
            }
        }
        else if (!isTokenChar.call(this, c)) {
            this.position--;
            break;
        }

        tokenChars.push(c);
    }

    if (tokenChars.length === 0) {
        return null;
    }

    return {
        value: tokenChars.join(""),
        type: tokenTypeEnum.literal,
        operator: false,
        quoted: isQuoted
    };
}

function parseOperator() {
    var tokenChars = [];
    while (canRead.call(this)) {
        if (isOperatorChar.call(this, peekChar.call(this))) {
            tokenChars.push(readChar.call(this));
        }
        else {
            break;
        }
    }

    if (tokenChars.length === 0) {
        return null;
    }

    var tokenString = tokenChars.join("");
    var tokenType = tokenTypeEnum.literal;
    switch (tokenString) {
        case "=":
            tokenType = tokenTypeEnum.equalOperator;
            break;
        case "<>":
            tokenType = tokenTypeEnum.differentOperator;
            break;
        case ">":
            tokenType = tokenTypeEnum.greaterThanOperator;
            break;
        case ">=":
            tokenType = tokenTypeEnum.greaterThanOrEqualToOperator;
            break;
        case "<":
            tokenType = tokenTypeEnum.lessThanOperator;
            break;
        case "<=":
            tokenType = tokenTypeEnum.lessThanOrEqualToOperator;
            break;
        case "~=":
            tokenType = tokenTypeEnum.similarOperator;
            break;
        default:
            throw "Unknown operator";
    }

    return {
        value: tokenString,
        type: tokenType,
        operator: true,
        quoted: false
    };
}



/*************** End of parsing & searching *************/

/*************** Autocomplete UI *************/
function initializeTextBox() {
    this.element.classList.add("pma-ui-meta-data-search");
    this.inputBox = document.createElement("input", { type: "text", autocomplete: "off" });
    this.element.appendChild(this.inputBox);
    this.inputBox.addEventListener("input", inputInput.bind(this));
    this.inputBox.addEventListener("keydown", inputKeyDown.bind(this));
    this.inputBox.addEventListener("focus", inputInput.bind(this));

    var self = this;
    document.addEventListener("click", function (e) {
        closeAllLists.call(self, e.target);
    });

    var context = new Context({ caller: "" });
    context.getVersionInfo(
        this.options.serverUrl,
        function (version) {
            if (version != null && version.startsWith("2")) {
                //self.inputBox.disabled = false;
            }
            else {
                console.log(version);
                console.warn("Metadata search only works with PMA.core version 2.x. Version reported: " + version);
            }
        },
        function () {
            console.warn("Cannot reach PMA.core server. Metadata search only works with PMA.core version 2.x.");
        });
}

function inputInput(e) {
    var self = this;
    var val = self.inputBox.value;

    closeAllLists.call(self);

    self.search(self.options.serverUrl, val).
        then(function (result) {
            showSuggestions.call(self, result.query);
        },
            function () {
            });
}

function showSuggestions(query) {
    var a, b, i;
    a = document.createElement("DIV");
    a.setAttribute("class", "autocomplete-items");
    var self = this;

    this.element.appendChild(a);

    var arr = query.suggestions;
    var lastToken = query.getLastToken();
    var val = "";
    if (lastToken && !isTokenComplete.call(self, lastToken)) {
        val = lastToken.value;
    }

    function itemSelected() {
        var itemValue = this.getElementsByTagName("input")[0].value;
        if (lastToken && !isTokenComplete.call(self, lastToken)) {
            lastToken.value = itemValue;
            lastToken.quoted = lastToken.value.indexOf(' ') !== -1;
            self.inputBox.value = query.getText();
        }
        else {
            self.inputBox.value = query.getText() + " " + (itemValue.indexOf(' ') !== -1 ? '"' + itemValue + '"' : itemValue);
        }

        self.search(self.options.serverUrl, self.inputBox.value);
        closeAllLists.call(self);
    }

    var io;
    for (i = 0; i < arr.length; i++) {
        if (val.length === 0) {
            b = document.createElement("DIV");
            b.innerHTML = arr[i];
        }
        else {
            io = arr[i].toUpperCase().indexOf(val.toUpperCase());
            if (io === -1) {
                continue;
            }

            b = document.createElement("DIV");

            // make the matching letters bold
            b.innerHTML = arr[i].substr(0, io);
            b.innerHTML += "<strong>" + arr[i].substr(io, val.length) + "</strong>";
            b.innerHTML += arr[i].substr(io + val.length);
        }

        // insert a input field that will hold the current array item's value
        b.innerHTML += "<input type='hidden' value='" + arr[i] + "'>";

        b.addEventListener("click", itemSelected);

        a.appendChild(b);
    }
}

function inputKeyDown(e) {
    var x = this.element.querySelectorAll(".autocomplete-items div");
    if (x.length === 0) {
        return;
    }

    var curActive = this.element.querySelector(".autocomplete-active");
    var index = -1;
    if (curActive) {
        curActive.classList.remove("autocomplete-active");
        for (var count = 0; count < x.length; count++) {
            if (x[count] === curActive) {
                index = count;
                break;
            }
        }
    }

    if (e.keyCode == 40) {
        index++;
        index = index >= x.length ? 0 : index;
        x[index].classList.add("autocomplete-active");
    }
    else if (e.keyCode == 38) {
        index--;
        index = index < 0 ? x.length - 1 : index;
        x[index].classList.add("autocomplete-active");
    }
    else if (e.keyCode == 13) {
        e.preventDefault();

        if (index !== -1) {
            x[index].click();
        }

        closeAllLists.call(this);
    }
    else if (e.keyCode == 27) {
        closeAllLists.call(this);
    }
}

function closeAllLists(element) {
    var x = document.getElementsByClassName("autocomplete-items");
    for (var i = 0; i < x.length; i++) {
        if (element != x[i] && element != this.inputBox) {
            x[i].parentNode.removeChild(x[i]);
        }
    }
}