Source: searchExpression.js

"use strict";
// SPDX-License-Identifier: GPL-3.0-or-later
// myMPD (c) 2018-2024 Juergen Mang <mail@jcgames.de>
// https://github.com/jcorporation/mympd

/** @module searchExpression_js */

//list of search tags that need no operator
/** @type {Array} */
const searchTagsNoOp = [
    'base',
    'modified-since',
    'added-since'
];

//list of search tags that compare against an unix timestamp
/** @type {Array} */
const searchTagsTimestamp = [
    'modified-since',
    'added-since'
];

/**
 * Toggles the advanced search bar
 * @param {Event} event Click event
 * @returns {void}
 */
//eslint-disable-next-line no-unused-vars
function toggleSearchExpr(event) {
    event.preventDefault();
    const target = event.target;
    if (target.nextElementSibling.classList.contains('d-none')) {
        elShow(target.nextElementSibling);
        elShow(target.parentNode.nextElementSibling);
    }
    else {
        elHide(target.nextElementSibling);
        elHide(target.parentNode.nextElementSibling);
    }
}

/**
 * Parses search expressions and update the ui for specified appid
 * @param {string} appid the application id
 * @returns {void}
 */
function handleSearchExpression(appid) {
    const searchStrEl = elGetById(appid + 'SearchStr');
    const searchCrumbEl = elGetById(appid + 'SearchCrumb');
    setFocus(searchStrEl);
    createSearchCrumbs(app.current.search, searchStrEl, searchCrumbEl);
    if (app.current.search === '') {
        searchStrEl.value = '';
    }
    selectTag(appid + 'SearchTags', appid + 'SearchTagsDesc', app.current.filter);
    selectSearchMatch(appid);
}

/**
 * Toggles the state of the SearchMatch select, based on selected tag
 * @param {string} appid the application id
 * @returns {void}
 */
function selectSearchMatch(appid) {
    const searchMatchEl = elGetById(appid + 'SearchMatch');
    //@ts-ignore
    if (searchTagsNoOp.includes(app.current.filter)) {
        elDisable(searchMatchEl);
        searchMatchEl.value = '';
    }
    else {
        elEnable(searchMatchEl);
        if (getSelectValue(searchMatchEl) === undefined) {
            searchMatchEl.value = 'contains';
        }
    }
    setSearchExpressionPlaceholder(appid);
}

/**
 * Sets the placeholder string for the search expression input
 * @param {string} appid the application id
 * @returns {void}
 */
function setSearchExpressionPlaceholder(appid) {
    const searchMatchEl = elGetById(appid + 'SearchMatch');
    const searchTagEl = elGetById(appid + 'SearchTagsDesc');
    elGetById(appid + 'SearchStr').setAttribute('placeholder', tn(searchTagEl.textContent) + ' ' + tn(searchMatchEl.value));
}

/**
 * Removes the search timer
 * @returns {void}
 */
function clearSearchTimer() {
    if (searchTimer !== null) {
        clearTimeout(searchTimer);
        searchTimer = null;
    }
}

/**
 * Initializes advanced search elements for specified appid
 * @param {string} appid the application id
 * @returns {void}
 */
function initSearchExpression(appid) {
    elGetById(appid + 'SearchTags').addEventListener('click', function(event) {
        if (event.target.nodeName === 'BUTTON') {
            app.current.filter = getData(event.target, 'tag');
            selectSearchMatch(appid);
            execSearchExpression(elGetById(appid + 'SearchStr').value);
        }
    }, false);

    elGetById(appid + 'SearchStr').addEventListener('keydown', function(event) {
        //handle Enter key on keydown for IME composing compatibility
        if (event.key !== 'Enter') {
            return;
        }
        clearSearchTimer();
        const value = event.target.value;
        if (value !== '') {
            const op = getSelectValueId(appid + 'SearchMatch');
            const crumbEl = elGetById(appid + 'SearchCrumb');
            crumbEl.appendChild(createSearchCrumb(app.current.filter, op, value));
            elShow(crumbEl);
            event.target.value = '';
        }
        execSearchExpression('');
    }, false);

    // Android does not support search on type
    if (userAgentData.isAndroid === false) {
        elGetById(appid + 'SearchStr').addEventListener('keyup', function(event) {
            if (ignoreKeys(event) === true) {
                return;
            }
            const value = event.target.value;
            //@ts-ignore
            if (searchTagsTimestamp.includes(app.current.filter) &&
                isNaN(parseDateFromText(value)) === true)
            {
                // disable search on type for timestamps
                return;
            }
            clearSearchTimer();
            searchTimer = setTimeout(function() {
                execSearchExpression(value);
            }, searchTimerTimeout);
        }, false);
    }

    elGetById(appid + 'SearchCrumb').addEventListener('click', function(event) {
        if (event.target.classList.contains('badge')) {
            //remove search expression
            event.preventDefault();
            event.stopPropagation();
            event.target.parentNode.remove();
            execSearchExpression('');
            elGetById(appid + 'SearchStr').updateBtn();
        }
        else if (event.target.classList.contains('btn')) {
            //edit search expression
            event.preventDefault();
            event.stopPropagation();
            const searchStrEl = elGetById(appid + 'SearchStr');
            searchStrEl.value = unescapeMPD(getData(event.target, 'filter-value'));
            selectTag(appid + 'SearchTags', appid + 'SearchTagsDesc', getData(event.target, 'filter-tag'));
            elGetById(appid + 'SearchMatch').value = getData(event.target, 'filter-op');
            event.target.remove();
            app.current.filter = getData(event.target,'filter-tag');
            execSearchExpression(searchStrEl.value);
            if (elGetById(appid + 'SearchCrumb').childElementCount === 0) {
                elHideId(appid + 'SearchCrumb');
            }
            searchStrEl.updateBtn();
        }
    }, false);

    elGetById(appid + 'SearchMatch').addEventListener('change', function() {
        execSearchExpression(elGetById(appid + 'SearchStr').value);
    }, false);
    setSearchExpressionPlaceholder(appid);
}

/**
 * Executes the search expression for the current displayed view
 * @param {string} value search value
 * @returns {void}
 */
function execSearchExpression(value) {
    const expression = createSearchExpression(elGetById(app.id + 'SearchCrumb'), app.current.filter, getSelectValueId(app.id + 'SearchMatch'), value);
    appGoto(app.current.card, app.current.tab, app.current.view, 0, app.current.limit, app.current.filter, app.current.sort, app.current.tag, expression, 0);
}

/**
 * Parses a mpd filter expression
 * @param {string} expression mpd filter
 * @returns {object} parsed expression elements or null on error
 */
function parseExpression(expression) {
    if (expression.length === 0) {
        return null;
    }
    let fields = expression.match(/^\((\w+)\s+(\S+)\s+'(.*)'\)$/);
    if (fields !== null &&
        fields.length === 4)
    {
        return {
            'tag': fields[1],
            'op': fields[2],
            'value': unescapeMPD(fields[3])
        };
    }
    // support expressions without operator, e.g. base
    fields = expression.match(/^\(([\w-]+)\s+'(.*)'\)$/);
    if (fields !== null &&
        fields.length === 3)
    {
        return {
            'tag': fields[1],
            'op': '',
            'value': unescapeMPD(fields[2])
        };
    }
    logError('Failure parsing expression: ' + expression);
    return null;
}

/**
 * Creates the search breadcrumbs from a mpd search expression
 * @param {string} searchStr the search expression
 * @param {HTMLElement} searchEl search input element
 * @param {HTMLElement} crumbEl element to add the crumbs
 * @returns {void}
 */
function createSearchCrumbs(searchStr, searchEl, crumbEl) {
    elClear(crumbEl);
    const elements = searchStr.substring(1, app.current.search.length - 1).split(' AND ');
    //add all but last element to crumbs
    for (let i = 0, j = elements.length - 1; i < j; i++) {
        const fields = parseExpression(elements[i]);
        if (fields !== null) {
            crumbEl.appendChild(createSearchCrumb(fields.tag, fields.op, fields.value));
        }
    }
    //check if we should add the last element to the crumbs
    if (searchEl.value === '' &&
        elements.length >= 1)
    {
        const fields = parseExpression(elements[elements.length - 1]);
        if (fields !== null) {
            crumbEl.appendChild(createSearchCrumb(fields.tag, fields.op, fields.value));
        }
    }
    crumbEl.childElementCount > 0
        ? elShow(crumbEl)
        : elHide(crumbEl);
}

/**
 * Creates a search crumb element
 * @param {string} filter the tag
 * @param {string} op search operator
 * @param {string} value filter value
 * @returns {HTMLElement} search crumb element
 */
function createSearchCrumb(filter, op, value) {
    if (op === undefined) {
        op = '';
    }
    const btn = elCreateNodes('div', {"class": ["btn", "btn-dark", "me-2"]}, [
        document.createTextNode(tn(filter) + ' ' + tn(op) + ' \'' + value + '\''),
        elCreateText('div', {"class": ["ml-2", "badge", "bg-secondary", "clickable"]}, '×')
    ]);
    setData(btn, 'filter-tag', filter);
    setData(btn, 'filter-op', op);
    setData(btn, 'filter-value', value);
    return btn;
}

/**
 * Creates a MPD search expression component
 * @param {string} tag tag to search
 * @param {string} op search operator
 * @param {string} value value to search
 * @returns {string} the search expression in parenthesis
 */
function createSearchExpressionComponent(tag, op, value) {
    if (op === 'starts_with' &&
        app.id !== 'BrowseDatabaseList' &&
        features.featStartsWith === false)
    {
        //mpd does not support starts_with, convert it to regex
        if (features.featPcre === true) {
            //regex is supported
            op = '=~';
            value = '^' + value;
        }
        else {
            //best option without starts_with and regex is contains
            op = 'contains';
        }
    }
    //@ts-ignore
    if (searchTagsNoOp.includes(tag)) {
        //this tags needs no operator
        return '(' + tag + ' \'' + escapeMPD(value) + '\')';
    }
    return '(' + tag + ' ' + op + ' ' +
        (op === '>='
            ? value
            : '\'' + escapeMPD(value) + '\''
        ) + ')';
}

/**
 * Creates the MPD search expression from crumbs and parameters
 * @param {HTMLElement} crumbsEl crumbs container element
 * @param {string} tag tag to search
 * @param {string} op search operator
 * @param {string} value value to search
 * @returns {string} the search expression in parenthesis
 */
function createSearchExpression(crumbsEl, tag, op, value) {
    let expression = '(';
    const crumbs = crumbsEl.children;
    for (let i = 0, j = crumbs.length; i < j; i++) {
        if (i > 0) {
            expression += ' AND ';
        }
        expression += createSearchExpressionComponent(
            getData(crumbs[i], 'filter-tag'),
            getData(crumbs[i], 'filter-op'),
            getData(crumbs[i], 'filter-value')
        );
    }
    if (value !== '') {
        if (expression.length > 1) {
            expression += ' AND ';
        }
        expression += createSearchExpressionComponent(tag, op, value);
    }
    expression += ')';
    if (expression.length <= 2) {
        expression = '';
    }
    return expression;
}

/**
 * Creates a mpd filter expression consisting of base and any tag search
 * @param {string} base the base path
 * @param {string} value value to search in any tag
 * @returns {string} the mpd search expression
 */
function createBaseSearchExpression(base, value) {
    let expression = '(base \'' + escapeMPD(base) + '\')';
    if (isEmptyTag(value) === false) {
        expression += ' AND ' + createSearchExpressionComponent('any', 'contains', value);
    }
    return '(' + expression + ')';
}