PMA.UI Documentation by Pathomation

components/js/metadataQuery.js

import { Resources } from "../../resources/resources";
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 PMA.UI.Components.Events.SearchStarted
 * @fires PMA.UI.Components.Events.SearchFinished
 * @constructor
 * @memberof PMA.UI.Components
 * @tutorial 08-meta-search
 * @ignore
 */
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);
        }
    }
    /**
     * Attaches an event listener
     * @param {PMA.UI.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)) {
        if (!Object.prototype.hasOwnProperty.call(this.listeners, 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)) {
        if (!Object.prototype.hasOwnProperty.call(this.listeners, 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;
        return initServer
            .call(this, serverUrl)
            .then(function () {
                return parse.call(self, queryString);
            })
            .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);
                }
            });
    }
}

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(PMA.UI.Components.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(PMA.UI.Components.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(PMA.UI.Components.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,
    };
}

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",
};

/*************** 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 PMA.UI.Components.Context({ caller: "" });
    context.getVersionInfo(
        this.options.serverUrl,
        function (version) {
            if (version != null && version.startsWith("2.0")) {
                //self.inputBox.disabled = false;
            } else {
                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]);
        }
    }
}