/ src / scripts / basic.js
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
/*
 * Display a two second message in the message div at the top of the web page
 *
 * @param String msg  string to display
 */
function doMessage(msg, duration)
{
    if (duration === undefined) {
        duration = 2000;
    }
    message_tag = document.getElementById("message");
    if (!message_tag) {
        return;
    }
    message_tag.innerHTML = msg;
    msg_timer = setInterval("undoMessage()", duration);
}
/*
 * Undisplays the message display in the message div and clears associated
 * message display timer
 */
function undoMessage()
{
    message_tag = document.getElementById("message");
    message_tag.innerHTML = "";
    clearInterval(msg_timer);
}
/*
 * Function to set up a request object even in  older IE's
 *
 * @return Object the request object
 */
function makeRequest()
{
    try {
        request = new XMLHttpRequest();
    } catch (e) {
        try {
            request = new ActiveXObject('MSXML2.XMLHTTP');
        } catch (e) {
            try {
            request = new ActiveXObject('Microsoft.XMLHTTP');
            } catch (e) {
            return false;
            }
        }
    }
    return request;
}
/*
 * Make an AJAX request for a url and put the results as inner HTML of a tag
 * If the response is the empty string then the tag is not replaced
 *
 * @param Object tag  a DOM element to put the results of the AJAX request
 * @param String url  web page to fetch using AJAX
 * @param Function success_callback function to call on success
 * @param Function fail_callback function to call on failure
 */
function getPage(tag, url)
{
    var request = makeRequest();
    if (request) {
        let self = this;
        let success_callback = (typeof arguments[2] == 'undefined') ?
            null : arguments[2];
        let fail_callback = (typeof arguments[3] == 'undefined') ?
            null : arguments[3];
        request.onreadystatechange = function()
        {
            if (self.request.readyState == 4) {
                if( self.request.responseText.trim() != "" &&
                    self.request.responseText.trim() != "false") {
                    if (tag != null) {
                        tag.innerHTML = self.request.responseText;
                    }
                    if (success_callback) {
                        success_callback(self.request.responseText);
                    }
                } else {
                    if (fail_callback) {
                        fail_callback(self.request.responseText);
                    }
                }
            }
        }
        request.open("GET", url, true);
        request.send();
    }
}
/*
 * Returns the position of the caret within a node
 *
 * @param String input type element
 */
function caret(node)
{
    if (node.selectionStart) {
        return node.selectionStart;
    } else if (!document.selection) {
        return false;
    }
    // old ie hack
    var insert_char = "\001",
    sel = document.selection.createRange(),
    dul = sel.duplicate(),
    len = 0;

    dul.moveToElementText(node);
    sel.text = insert_char;
    len = dul.text.indexOf(insert_char);
    sel.moveStart('character',-1);
    sel.text = "";
    return len;
}
/*
 * Shorthand for document.createElement()
 *
 * @param String name tag name of element desired
 * @return Element the create element
 */
function ce(name)
{
    return document.createElement(name);
}
/*
 * Shorthand for document.getElementById()
 *
 * @param String id  the id of the DOM element one wants
 */
function elt(id)
{
    return document.getElementById(id);
}
/*
 * Shorthand for document.getElementsByTagName()
 *
 * @param String name the name of the DOM element one wants
 */
function tag(name)
{
    return document.getElementsByTagName(name);
}
/*
 * Shorthand for document.querySelectorAll()
 *
 * @param String css selector of the DOM element one wants
 */
function sel(css_selector)
{
    return document.querySelectorAll(css_selector);
}
/*
 * Used to add an event listener to an object
 * @param object object to add to lister to
 * @param String event_type the kind of event to listen for
 * @param Function handler callback function to handle event
 */
function listen(object, event_type, handler)
{
    object.addEventListener(event_type, handler, false);
}
/*
 * Used to remove an event listener previously added with listen()
 * @param object object to remove the listener from
 * @param String event_type the kind of event that was listened for
 * @param Function handler the same callback that was passed to listen()
 */
function unlisten(object, event_type, handler)
{
    object.removeEventListener(event_type, handler, false);
}
/*
 * Shorthand for object.getAttribute()
 * @param object object whose attribute is wanted
 * @param String name the name of the attribute to read
 * @return String the attribute value, or null if it is not set
 */
function attr(object, name)
{
    return object.getAttribute(name);
}
/*
 * Used to add an event listener to all objects in DOM matching a css selector
 * @param String css selector to query DOM with
 * @param String event_type the kind of event to listen for
 * @param Function handler callback function to handle event
 */
function listenAll(css_selector, event_type, handler)
{
    var selected_objects = sel(css_selector);
    for (var i = 0; i < selected_objects.length; i++) {
        selected_objects[i].addEventListener(event_type, handler, false);
    }
}
/*
 * Used to toggle the options menu controlled  by the settings-toggle
 * Hamburger menu icon
 */
function toggleOptions()
{
    toggleDisplay('menu-options-background');
    let body_tag_obj = tag('body')[0];
    let nav_container_obj = elt('nav-container');
    if (nav_container_obj.style.left == '0px' ||
        nav_container_obj.style.right == '0px') {
        if (body_tag_obj.classList.contains('html-ltr')) {
            nav_container_obj.style.left = '-300px';
        } else {
            nav_container_obj.style.right = '-300px';
        }
    } else {
        if (body_tag_obj.classList.contains('html-ltr')) {
            nav_container_obj.style.left = '0px';
        } else {
            nav_container_obj.style.right = '0px';
        }
    }
}
/*
 * Used to store a copy of the states of elements on a form so that they can
 * be compared with the state at a later time. Typically, this would be used
 * to figure out if the form needs to be submitted.
 *
 * @param Object form a web form to make copies of the states to of
 */
function saveFormState(form)
{
    for (var i = 0;  i < form.length; i++) {
        var elt = form[i];
        elt.dataset.stateValue = elt.value;
        elt.dataset.stateChecked = elt.checked;
    }
}
/*
 * Used to compare the states of elements on a form with a saved state for
 * those elements. Returns true or false depending on whether these two states
 * are the same. Typically, this would be used
 * to figure out if the form needs to be submitted.
 * @param Object form a web form to compare if form elements equal to saved
 *    state
 * @return Boolean true or false depending on if equal
 */
function equalFormSaveState(form)
{
    for (var i = 0; i < form.length; i++) {
        var elt = form[i];
        if (('stateValue' in elt.dataset &&
            elt.dataset.stateValue !== elt.value) || (
            'stateChecked' in elt.dataset &&
            "" + elt.dataset.stateChecked != "" + elt.checked)) {
            return false;
        }
    }
    return true;
}
/*
 * Create a callback function used to get the next group of search/feed results
 * when continuous scrolling based reulst displaying is used.
 *
 * @param int limit what is ranked position of first result to get
 * @param int total_results total number of search results for query
 * @param int results_per_page number of results which should be returned
 * @param String base_url url to process query at
 * @param String end_result_string human language string to write if no
 *      more results
 * @param String container_id of the div tag used for all results
 * @param String next_results_id of tag to use for the next block of
 *  results within this container
 */
function initNextResultsPage(limit, total_results, results_per_page,
    base_url, end_result_string, container_id, results_id)
{
    let can_add_state = 1;
    let scroll_obj = null;
    if (container_id === undefined) {
        container_id = "search-body";
        scroll_obj = window;
        scroll_body = document.body;
    } else {
         scroll_obj = document.getElementById(container_id);
         scroll_body = scroll_obj;
    }
    if (scroll_obj) {
        scroll_obj.scrollTo(0, 0);
    }
    if (results_id === undefined) {
        results_id = "search-results";
    }
    /**
     * Infinite-scroll forward pager: fetches the next page of
     * search results via AJAX and appends them to the results
     * container, bumping $limit and recording history state.
     *
     * @return {undefined}
     */
    let nextPage = function () {
        if (limit < total_results && can_add_state > 0) {
            can_add_state = 0;
            let tmp_hr = elt("limit-" + limit);
            if (tmp_hr != null) {
                var tmp_total = tmp_hr.getAttribute('data-total');
                if (tmp_total > 0) {
                    total_results = parseInt(tmp_total);
                }
            }
            limit += results_per_page;
            getPage(null, base_url + "&limit=" + limit +
                "&f=api", function(text) {
                let container_body = document.getElementById(
                    container_id);
                let next_results = ce("div");
                let button_parent = null;
                next_results.setAttribute("class", results_id);
                next_results.innerHTML = text;
                let next_button = elt('next-button');
                if (next_button && next_button.parentNode &&
                    next_button.parentNode.parentNode &&
                    next_button.parentNode.parentNode == container_body) {
                    button_parent = container_body.removeChild(
                        next_button.parentNode);
                }
                container_body.appendChild(next_results);
                if (button_parent != null) {
                    container_body.appendChild(button_parent);
                }
                if (results_id == 'search-results' &&
                    next_results.children.length < results_per_page + 2 &&
                    (next_results.children.length != 3 ||
                    next_results.children[2].children.length <
                    results_per_page)) {
                    total_results = limit - (results_per_page + 2 -
                        next_results.children.length);
                }
                can_add_state = 1;
            }, function() {
                limit = total_results;
                can_add_state = 0;
                return;
            });
        }
        if (limit >= total_results && can_add_state >= 0) {
            can_add_state = -1;
            setDisplay('next-button', false);
            if (end_result_string != "") {
                var end_results = ce("h3");
                end_results.setAttribute("class", "center");
                end_results.innerHTML = end_result_string;
                end_results.style.background = '#F8F8F8';
                elt(container_id).appendChild(end_results);
            }
        }
    }
    if (scroll_obj) {
        scroll_obj.addEventListener("scroll", function() {
            if ((scroll_obj.scrollTop !== undefined && scroll_obj.scrollTop >=
                scroll_body.scrollHeight - scroll_obj.clientHeight) ||
                (scroll_obj.scrollY  !== undefined && scroll_obj.scrollY >=
                    scroll_body.scrollHeight - scroll_obj.innerHeight)) {
                nextPage();
            }
        });
    }
    return nextPage;
}
/*
 * Create a callback function used to get the previous group of
 * search/feed results when continuous scrolling based reulst displaying is
 * used.
 *
 * @param int limit what is ranked position of first result to get
 * @param int total_results total number of search results for query
 * @param int results_per_page number of results which should be returned
 * @param String base_url url to process query at
 * @param String end_result_string human language string to write if no
 *      more results
 * @param String container_id of the div tag used for all results
 * @param String next_results_id of tag to use for the next block of
 *  results within this container
 */
function initPreviousResultsPage(limit, total_results, results_per_page,
    base_url, container_id, results_id)
{
    let can_add_state = true;
    let scroll_obj = null;
    if (container_id === undefined) {
        container_id = "search-body";
        scroll_obj = window;
        scroll_body = document.body;
    } else {
         scroll_obj = document.getElementById(container_id);
         scroll_body = scroll_obj;
    }
    if (results_id === undefined) {
        results_id = "search-results";
    }
    /**
     * Infinite-scroll backward pager: fetches the previous page
     * of search results via AJAX and prepends them to the results
     * container, walking $limit back toward zero.
     *
     * @return {undefined}
     */
    let previousPage = function () {
        if (limit > 0 && can_add_state) {
            can_add_state = false;
            let tmp_hr = elt("limit-" + limit);
            if (tmp_hr != null) {
                var tmp_total = tmp_hr.getAttribute('data-total');
                if (tmp_total > 0) {
                    total_results = parseInt(tmp_total);
                }
            }
            let top = (scroll_obj.scrollTop !== undefined) ?
                scroll_obj.scrollTop : scroll_obj.scrollY;
            limit -= results_per_page;
            limit = (limit > 0) ? limit : 0;
            getPage(null, base_url + "&limit=" + limit +
                "&f=api", function(text) {
                let container_body = document.getElementById(
                    container_id);
                let previous_results = ce("div");
                let button_parent = null;
                previous_results.setAttribute("class", results_id);
                previous_results.innerHTML = text;
                let previous_button = elt('previous-button');
                if (previous_button && previous_button.parentNode &&
                    previous_button.parentNode.parentNode &&
                    previous_button.parentNode.parentNode == container_body) {
                    container_body.insertBefore(previous_results,
                        previous_button.parentNode);
                    button_parent = container_body.removeChild(
                        previous_button.parentNode);
                }
                if (button_parent != null) {
                    container_body.insertBefore(button_parent,
                        previous_results);
                }
                scroll_obj.scrollTo(0, top + previous_results.clientHeight);
                if (previous_results.children.length <
                    results_per_page + 2 && (previous_results.children.length
                    != 3 || previous_results.children[2].children.length <
                    results_per_page)) {
                    total_results = limit - (results_per_page + 2 -
                        previous_results.children.length);
                }
                can_add_state = true;
            });
        }
        if (limit <= 0 && can_add_state) {
            can_add_state = false;
            setDisplay('previous-button', false);
        }
    }
    if (total_results - limit < results_per_page) {
        previousPage();
    }
    scroll_obj.addEventListener("scroll", function() {
        if ((scroll_obj.scrollTop !== undefined && scroll_obj.scrollTop <= 10)||
            (scroll_obj.scrollY  !== undefined && scroll_obj.scrollY <= 10)) {
            previousPage();
        }
    });
    return previousPage;
}
/*
 * Used to set up a listener for a right-to-left swipe event
 *
 * @param Object obj element on which to listen for a swipe event
 * @param Function handler callback to handle swipe event
 */
function leftSwipe(obj, handler)
{
    listen(obj, 'touchstart', startLeft);
    listen(obj, 'touchmove', leftMoveChecker);
    listen(obj, 'mousedown', startLeft);
    listen(obj, 'mousemove', leftMoveChecker);
    var x_begin = null;
    var y_begin = null;
    /**
     * touchstart/mousedown handler: records the starting
     * coordinates of the gesture into the enclosing closure.
     *
     * @param {Event} evt
     */
    function startLeft(evt)
    {
        if (evt.type == 'touchstart') {
            evt = evt.touches[0];
        }
        x_begin = evt.clientX;
        y_begin = evt.clientY;
    }
    /**
     * touchmove/mousemove handler: invokes the swipe handler
     * once the cursor has moved more than 5px to the left
     * (and more horizontally than vertically); resets the
     * gesture afterward.
     *
     * @param {Event} evt
     */
    function leftMoveChecker(evt)
    {
        if ( !x_begin || !y_begin ) {
            return;
        }
        if (evt.type == 'touchmove') {
            evt = evt.touches[0];
        }
        var x_end = evt.clientX;
        var y_end = evt.clientY;
        var delta_x = x_end - x_begin;
        var delta_y = y_end - y_begin;
        // check whether moved more in x or y direction
        if ( Math.abs( delta_x ) > Math.abs( delta_y ) ) {
            if (delta_x < -5) {
                handler(evt);
            }
        }
        /* reset values */
        x_begin = null;
        y_begin = null;
    }
}
/*
 * Used to set up a listener for a left-to-right swipe event
 *
 * @param Object obj element on which to listen for a swipe event
 * @param Function handler callback to handle swipe event
 */
function rightSwipe(obj, handler)
{
    listen(obj, 'touchstart', startRight);
    listen(obj, 'touchmove', rightMoveChecker);
    listen(obj, 'mousedown', startRight);
    listen(obj, 'mousemove', rightMoveChecker);
    var x_begin = null;
    var y_begin = null;
    /**
     * touchstart/mousedown handler: records the starting
     * coordinates of the gesture into the enclosing closure.
     *
     * @param {Event} evt
     */
    function startRight(evt)
    {
        if (evt.type == 'touchstart') {
            evt = evt.touches[0];
        }
        x_begin = evt.clientX;
        y_begin = evt.clientY;
    }
    /**
     * touchmove/mousemove handler: invokes the swipe handler
     * once the cursor has moved more than 5px to the right
     * (and more horizontally than vertically); resets the
     * gesture afterward.
     *
     * @param {Event} evt
     */
    function rightMoveChecker(evt)
    {
        if ( !x_begin || !y_begin ) {
            return;
        }
        if (evt.type == 'touchmove') {
            evt = evt.touches[0];
        }
        var x_end = evt.clientX;
        var y_end = evt.clientY;
        var delta_x = x_end - x_begin;
        var delta_y = y_end - y_begin;
        // check whether moved more in x or y direction
        if ( Math.abs( delta_x ) > Math.abs( delta_y ) ) {
            if (delta_x > 5) {
                handler(evt);
            }
        }
        /* reset values */
        x_begin = null;
        y_begin = null;
    }
}
/*
 * Used to countdown the number of remaining characters that can be entered in
 * a text field
 *
 * @param String text_field_id id of input text field to count down
 * @param String display_box_id id of element in which to display countdown
 */
function updateCharCountdown(text_field_id, display_box_id)
{
    text_field = elt(text_field_id);
    display_box = elt(display_box_id);
    if (typeof text_field.maxLength != 'undefined' && display_box) {
        display_box.innerHTML = text_field.maxLength - text_field.value.length;
    }
}
/*
 * Initializes select dropdowns for CVS forms on wiki pages to the values
 * that had previously been submitted (if a CVS form was submitted
 */
function initCvsFormTags()
{
    let csv_count_fields = sel('*[data-ctr]');
    for (let i = 0; i < csv_count_fields.length; i++) {
        let count_tag = csv_count_fields[i];
        if (count_tag.attributes['data-ctr']) {
            let counted_elt_name = count_tag.attributes['data-ctr'].value;
            let counted_elt = elt(counted_elt_name);
            if (counted_elt) {
                counted_elt.onkeypress = (evt) => {
                    updateCharCountdown(counted_elt.id, count_tag.id);
                }
            }
        }
    }
    let csv_block_users = sel('*[data-block]');
    for (let i = 0; i < csv_block_users.length; i++) {
        let block_user_tag = csv_block_users[i];
        if (block_user_tag.attributes['data-block']) {
            let use_block = block_user_tag.attributes['data-block'].value;
            let block = elt(use_block);
            if (block) {
                block_user_tag.innerHTML = block.innerHTML;
            }
        }
    }
    let csv_selects = sel('select[data-value]');
    for (let i = 0; i < csv_selects.length; i++) {
        let select_tag = csv_selects[i];
        let csv_value = select_tag.attributes['data-value'].value;
        if (csv_value) {
            select_tag.value = csv_value;
        }
    }
    initSortWidgets();
}
/*
 * Init Sortable form selects on form (if present)
 */
function initSortWidgets()
{
    sel('select[data-sortable]').forEach(select => {
        const items = [...select.options].map(o =>
            ({ value: o.value, label: o.text }));
        select.style.display = 'none';
        // Build the drag list UI
        const ul = ce('ul');
        var raw_cutoff = select.getAttribute('data-cutoff');
        var cutoff = (raw_cutoff !== null) ? parseInt(raw_cutoff, 10) : null;
        var has_cutoff = cutoff !== null && cutoff < items.length;
        ul.classList.add('wiki-sorter');
        select.insertAdjacentElement('afterend', ul);
        // Hidden input carries the ordered JSON array on submit
        var hidden = ce("input");
        hidden.type = "hidden";
        hidden.name = (select.name || select.id);
        select.name = hidden.name + "_setup";
        select.insertAdjacentElement('afterend', hidden);
        let cutoff_elt = null;
        if (has_cutoff) {
            cutoff_elt = ce('li');
            cutoff_elt.setAttribute('data-cutoff', 'true');
            cutoff_elt.setAttribute('role', 'separator');
            cutoff_elt.setAttribute('aria-hidden', 'true');
            cutoff_elt.innerHTML = `<span></span>`;
        }
        let dragged = null;
        var drag_clone = null;  // floating ghost used during touch drag
        var drag_offset_y = 0; // finger position within the item at touchstart
        var keyboard_grabbed = null;           // item picked up via keyboard
        var keyboard_grabbed_origin_index = null;
            // its index at pickup time (for Escape)
        /* ── helpers ── */
        function listItems() {
            return [...ul.querySelectorAll('li:not([data-cutoff])')];
        }
        /**
         * @return {number} number of non-cutoff items currently
         *      in the sortable list
         */
        function itemCount() {
            return listItems().length;
        }
        /**
         * @param {HTMLElement} li list item to locate
         * @return {number} 0-based index of $li among the
         *      non-cutoff items, or -1 if not present
         */
        function indexOfItem(li) {
            return listItems().indexOf(li);
        }
        /* keep cutoff line pinned at position N after any drag */
        function repositionCutoff()
        {
            if (has_cutoff) {
                const list_items = listItems();
                var anchor = list_items[cutoff] || null;
                ul.insertBefore(cutoff_elt, anchor);
            }
        }
        /* keyboard reorder */
        function keyboardMove(li, direction)
        {
            var list_items = listItems();
            var index = list_items.indexOf(li);
            var target = direction === -1 ? list_items[index - 1] :
                list_items[index + 1];
            if (!target) {
                return;
            }
            if (direction === -1) {
            ul.insertBefore(li, target);
            } else {
            ul.insertBefore(li, target.nextSibling);
            }
            if (has_cutoff) {
                repositionCutoff();
            }
            li.focus();
            syncSortOrderToForm();
        }
        /* insert dragged before/after target by Y */
        function insertAtPoint(client_y)
        {
            /* Hide clone momentarily so elementFromPoint sees the list
               underneath */
            if (drag_clone) {
                drag_clone.style.visibility = 'hidden';
            }
            var elt = document.elementFromPoint(
                ul.getBoundingClientRect().left + 1, client_y
            );
            if (drag_clone) {
                drag_clone.style.visibility = '';
            }
            var target = elt && elt.closest('li:not([data-cutoff])');
            if (!target || target === dragged) {
                return;
            }
            ul.querySelectorAll('li:not([data-cutoff]').forEach(function (x) {
                x.classList.remove('wiki-sorter-over');
            });
            target.classList.add('wiki-sorter-over');
            var mid = target.getBoundingClientRect().top +
                target.getBoundingClientRect().height / 2;
            ul.insertBefore(dragged, client_y < mid ?
                target : target.nextSibling);
            if (has_cutoff) {
                repositionCutoff();
            }
        }
        /* touch clone helpers*/
        function createClone(li)
        {
            var rect = li.getBoundingClientRect();
            var clone = li.cloneNode(true);
            clone.style.cssText = [
                'position:fixed',
                'left:' + rect.left + 'px',
                'top:' + rect.top + 'px',
                'width:' + rect.width + 'px',
                'margin:0',
                'pointer-events:none',
                'opacity:0.85',
                'z-index:9999'
            ].join(';');
            clone.classList.add('wiki-sorter-dragging');
            document.body.appendChild(clone);
            return clone;
        }
        /**
         * Removes the drag-clone element from the document if
         * present, and resets the closure variable.
         */
        function removeClone()
        {
            if (drag_clone && drag_clone.parentNode) {
                drag_clone.parentNode.removeChild(drag_clone);
            }
            drag_clone = null;
        }
        /**
         * Mirrors the current visual order of the sortable list
         * back into the hidden form input (as a JSON-encoded array
         * of dataset values) and updates per-item rank badges and
         * ARIA attributes.
         */
        function syncSortOrderToForm()
        {
            const list_items = listItems();
            const total = list_items.length;
            if (has_cutoff) {
                var cutoff_index = [...ul.children].indexOf(cutoff_elt);
                let rank = 1;
                list_items.forEach(li => {
                    const excluded = list_items.indexOf(li) >=
                        cutoff;
                    li.style.opacity = excluded ? '0.4' : '1';
                    li.querySelector('.rank').textContent = excluded ? '' :
                        rank++;
                    // ARIA
                    li.setAttribute('aria-setsize', total);
                    li.setAttribute('aria-posinset', indexOfItem(li) + 1);
                    li.setAttribute('aria-selected', excluded ? 'false' :
                        'true');
                });
                hidden.value = JSON.stringify(
                    list_items
                    .filter(li => [...ul.children].indexOf(li) < cutoff)
                    .map(li => li.dataset.value)
                );
            } else {
                list_items.forEach((li, i) => {
                    li.querySelector('.rank').textContent = i + 1;
                    li.setAttribute('aria-setsize', total);
                    li.setAttribute('aria-posinset', i + 1);
                    li.setAttribute('aria-selected', 'true');
                });
                hidden.value = JSON.stringify(
                    list_items.map(li => li.dataset.value));
            }
        }
        /**
         * Reads the select element's data-value attribute (a
         * JSON-encoded list of dataset values), reorders the
         * <li> children to match, and re-runs syncSortOrderToForm
         * so the hidden input stays consistent.
         */
        function syncDataValueToList()
        {
            var raw = select.getAttribute('data-value');
            if (!raw) {
                return;
            }
            var order;
            try {
                order = JSON.parse(raw);
            } catch (e) {
                return;
            }
            if (!Array.isArray(order)) {
                return;
            }
            var item_map = {};
            [...ul.querySelectorAll('li:not([data-cutoff])')].forEach(
                function (li) {
                    item_map[li.dataset.value] = li;
                }
            );
            var remainder =
                [...ul.querySelectorAll('li:not([data-cutoff])')].filter(
                function (li) {
                    return order.indexOf(li.dataset.value) === -1;
                }
            );
            order.forEach(function (val) {
                if (item_map[val]) ul.appendChild(item_map[val]);
            });
            remainder.forEach(function (li) {
                ul.appendChild(li);
            });
            if (has_cutoff) {
                repositionCutoff();
            }
        }
        items.forEach(({ value, label }) => {
            const li = ce('li');
            li.dataset.value = value;
            li.setAttribute('role', 'option');
            li.setAttribute('tabindex', '0');
            li.setAttribute('aria-selected', 'true');
            li.setAttribute('draggable', 'true');
            li.innerHTML = `<span class="rank" aria-hidden="true"></span>
                <span class="rank-label" aria-hidden="true">${label}</span>`;
            /* ── keyboard ── */
            listen(li, 'keydown', function (e) {
              switch (e.key) {
                case ' ':
                case 'Enter':
                  e.preventDefault();
                  if (keyboard_grabbed === li) {
                    // Drop
                    li.classList.remove('wiki-sorter-grabbed');
                    li.setAttribute('aria-grabbed', 'false');
                    keyboard_grabbed = null;
                    keyboard_grabbed_origin_index = null;
                    syncSortOrderToForm();
                  } else {
                    // Pick up
                    if (keyboard_grabbed) {
                      // Drop any previously grabbed item first
                      keyboard_grabbed.classList.remove('wiki-sorter-grabbed');
                      keyboard_grabbed.setAttribute('aria-grabbed', 'false');
                      keyboard_grabbed = null;
                    }
                    keyboard_grabbed = li;
                    keyboard_grabbed_origin_index = indexOfItem(li);
                    li.classList.add('wiki-sorter-grabbed');
                    li.setAttribute('aria-grabbed', 'true');
                  }
                  break;

                case 'ArrowUp':
                  e.preventDefault();
                  if (keyboard_grabbed === li) {
                    keyboardMove(li, -1);
                  }
                  break;

                case 'ArrowDown':
                  e.preventDefault();
                  if (keyboard_grabbed === li) {
                    keyboardMove(li, 1);
                  }
                  break;

                case 'Escape':
                  if (keyboard_grabbed === li) {
                    e.preventDefault();
                    // Return to original position
                    var list_items = listItems();
                    var origin_anchor =
                        list_items[keyboard_grabbed_origin_index] || null;
                    ul.insertBefore(li, origin_anchor);
                    li.classList.remove('wiki-sorter-grabbed');
                    li.setAttribute('aria-grabbed', 'false');
                    keyboard_grabbed = null;
                    keyboard_grabbed_origin_index = null;
                    li.focus();
                    repositionCutoff();
                    syncSortOrderToForm();
                  }
                  break;
              }
            });
            /* mouse drag */
            listen(li, 'dragstart',
                e => {
                    dragged = li;
                    setTimeout(() => li.style.opacity = '0.3', 0);
                    e.dataTransfer.effectAllowed = 'move';
                });
            listen(li, 'dragend', () => {
                dragged = null;
                li.style.opacity = '1';
                repositionCutoff();
                syncSortOrderToForm();
            });
            listen(li, 'dragover', e => {
                e.preventDefault();
                if (li === dragged) {
                    return;
                }
                const mid = li.getBoundingClientRect().top +
                    li.getBoundingClientRect().height / 2;
                ul.insertBefore(dragged, e.clientY < mid ? li : li.nextSibling);
                repositionCutoff();
            });
            listen(li, 'drop', e => { e.preventDefault();});
            /* touch drag */
            listen(li, 'touchstart', function (e) {
                dragged = li;
                var touch = e.touches[0];
                drag_offset_y = touch.clientY - li.getBoundingClientRect().top;
                drag_clone = createClone(li);
                li.classList.add('wiki-sorter-dragging');
                // Prevent page scroll while sorting
                e.preventDefault();
            });
            listen(li, 'touchmove', function (e) {
                if (!dragged) {
                    return;
                }
                e.preventDefault();
                var touch = e.touches[0];
                // Move the floating clone with the finger
                drag_clone.style.top = (touch.clientY - drag_offset_y) + 'px';
                insertAtPoint(touch.clientY);
            });
            listen(li, 'touchend', function () {
                if (!dragged) {
                    return;
                }
                li.classList.remove('wiki-sorter-dragging');
                ul.querySelectorAll('li:not([data-cutoff])').forEach(
                    function (x) { x.classList.remove('wiki-sorter-over'); });
                removeClone();
                dragged = null;
                syncSortOrderToForm();
            });
            listen(li, 'touchcancel', function () {
              if (!dragged) return;
                li.classList.remove('wiki-sorter-dragging');
                ul.querySelectorAll('li:not([data-cutoff])').forEach(
                    function (x) { x.classList.remove('wiki-sorter-over'); });
                removeClone();
                dragged = null;
                syncSortOrderToForm();
            });
            ul.appendChild(li);
        });
        if (has_cutoff) {
            ul.insertBefore(cutoff_elt, ul.children[cutoff] || null);
        }
        syncDataValueToList();
        syncSortOrderToForm();
   });
}

/*
 * Global used by initializeFileHandler fileUploadSubmit to determine if
 * a submit event has already occurred
 * @var Boolean
 */
was_submitted = false;
/*
 * Global used by initializeFileHandler to keep track of all files
 * associated with a form to be uploaded. Assume only one form on a page.
 * @var Boolean
 */
file_list = new Array();
/*
 * Used to handle drag and drop file attachment and uploads on wiki and
 * group feed pages
 *
 * @param String drop_id id of element to listen for drop events
 * @param String file_id id of form file input that dropped objects
 *      will be associated with
 * @param String drop_kind what kind of element drop_id is. One of text (for
 *      textfield), textarea will add text to textarea, image
 *      (will replace image with what's dropped), or all.
 * @param Array types what file types can be upload
 * @param Boolean multiple whether multiple items dan be dropped in one go
 *      or selected from the file input picker.
 */
function initializeFileHandler(drop_id, file_id, max_size, drop_kind, types,
    multiple)
{
    var drop_elt = document.getElementById(drop_id);
    var file_elt = document.getElementById(file_id);
    var parent_form = file_elt.form;
    var last_call = "clear";
    var tl = document.tl;
    listen(parent_form, "submit", fileUploadSubmit);
    listen(drop_elt, "dragenter", stopNoPropagate);
    listen(drop_elt, "dragexit", stopNoPropagate);
    listen(drop_elt, "dragover", stopNoPropagate);
    listen(drop_elt, "drop", drop);
    listen(file_elt, "change",
        function(event)
        {
            if (last_call != "drop") {
                checkAndSetFiles(file_elt.files);
            }
            last_call = "clear";
        }
    );
    /**
     * Form-submission handler: collects every selected file from
     * file_list into a FormData, then XHR-uploads it to the form's
     * action URL with progress/complete/fail callbacks wired up.
     *
     * @param {Event|null} event the originating submit event, or
     *      null when invoked programmatically
     */
    function fileUploadSubmit(event)
    {
        if (event) {
            stopNoPropagate(event);
        }
        if (was_submitted) {
            return;
        }
        addAudioToFileList();
        was_submitted = true;
        var form_data = new FormData();
        var form_elements = parent_form.elements;
        var k = 0;
        for (var i = 0; i <  form_elements.length; i++) {
            var element = form_elements[i];
            if (element.type == "file") {
                var name = element.name;
                if (file_list[name] === undefined) {
                    continue;
                }
                if (file_list[name].length == 1 &&
                    file_list[name][0].length == 1) {
                    form_data.append(name,
                        file_list[name][0][0]);
                        k++;
                } else {
                    for (var j = 0; j < file_list[name].length; j++) {
                        for (var m = 0; m < file_list[name][j].length; m++) {
                            form_data.append(name + "[" + k + "]",
                                file_list[name][j][m]);
                            k++;
                        }
                    }
                }
            } else if (element.type == "checkbox") {
                if (element.checked) {
                    form_data.append(element.name, element.value);
                }
            } else {
                form_data.append(element.name, element.value);
            }
        }
        var request = makeRequest();
        if (k > 0) {
            listen(request.upload, "progress", uploadProgress, false);
        }
        listen(request, "load", uploadComplete, false);
        listen(request, "error", uploadFailed, false);
        listen(request, "abort", uploadCanceled, false);
        //keep ie happy
        var submit_to = (parent_form.action) ? parent_form.action :
            document.location;
        request.open("post", submit_to);
        request.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        request.send(form_data);
        var self = this;
        request.onreadystatechange = function() {
            if (self.request.readyState == 4) {
                 console.log(self.request.responseText);
            }
        };
    }
    /**
     * Recursively walks a DataTransferItem / FileSystemEntry and
     * appends every file it contains into $files. Directories are
     * recursed; empty directories get a `.yp_placeholder` synthetic
     * file so the empty folder is still uploaded.
     *
     * @param {DataTransferItem|FileSystemEntry|File} item the item
     *      to walk
     * @param {File[]} files accumulator the discovered files are
     *      appended into
     * @param {boolean} is_entry true when $item is already a
     *      FileSystemEntry (so we skip the webkitGetAsEntry call)
     */
    /**
     * Collects every file contained in a dropped item into the files
     * array, following dropped folders down into their contents.
     * Returns a promise that resolves once everything underneath the
     * item has been gathered, so the caller can wait for all dropped
     * files before continuing rather than guessing with a timer.
     * Reading a folder's contents and turning a folder entry into a
     * file both happen through callbacks, which this wraps in
     * promises. An empty dropped folder still contributes a single
     * placeholder file so the empty folder itself gets uploaded.
     *
     * @param {DataTransferItem|FileSystemEntry|File} item the item
     *      to walk
     * @param {File[]} files accumulator the discovered files are
     *      appended into
     * @param {boolean} is_entry true when item is already a
     *      FileSystemEntry (so we skip the webkitGetAsEntry call)
     * @return {Promise} resolves when all files under item are added
     */
    function flattenItemToFiles(item, files, is_entry = false)
    {
        var entry = (is_entry || item instanceof File) ? item :
            item.webkitGetAsEntry();
        if (item instanceof File) {
            files.push(item);
            return Promise.resolve();
        }
        if (!entry) {
            return Promise.resolve();
        }
        if (entry.isFile) {
            if (is_entry) {
                return new Promise(function(resolve) {
                    entry.file(function(file) {
                        files.push(file);
                        resolve();
                    }, function() {
                        resolve();
                    });
                });
            }
            let file = item.getAsFile();
            if (file) {
                files.push(file);
            }
            return Promise.resolve();
        }
        if (entry.isDirectory) {
            return new Promise(function(resolve) {
                let directory_reader = entry.createReader();
                directory_reader.readEntries(function(entries) {
                    if (entries.length > 0) {
                        let waits = [];
                        for (let i = 0; i < entries.length; i++) {
                            waits.push(flattenItemToFiles(entries[i],
                                files, true));
                        }
                        Promise.all(waits).then(resolve);
                    } else {
                        let path = entry.fullPath.substring(1);
                        let name = path + '/' + '.yp_placeholder';
                        let blob = new Blob([""],
                            { type: 'text/plain' });
                        let file = new File([blob], name,
                            { type: 'text/plain',
                            lastModified: Date.now() });
                        files.push(file);
                        resolve();
                    }
                }, function() {
                    resolve();
                });
            });
        }
        return Promise.resolve();
    }
    /**
     * dragdrop drop-event handler: invokes checkAndSetFiles with
     * the dropped items and records that the last selection came
     * from a drop (vs a clear or an input event).
     *
     * @param {DragEvent} event
     */
    function drop(event)
    {
        stopNoPropagate(event);
        var files = event.dataTransfer.items;
        var count = files.length;
        if (count > 0) {
            last_call = "drop";
            checkAndSetFiles(files);
        }
    }
    /**
     * Walks the supplied DataTransferItemList via
     * flattenItemToFiles, validates each file against the input's
     * `multiple`, `accept` and `data-max-size` attributes, then
     * stores them into the per-element file_list and updates the
     * drop-zone preview (image src, textarea name list, etc.).
     *
     * @param {DataTransferItemList|FileList} items the items the
     *      user just selected or dropped
     */
    async function checkAndSetFiles(items)
    {
        let files = [];
        let waits = [];
        /* Start walking every dropped item right away, while the drop
           event's items are still valid. Each walk returns a promise;
           waiting on all of them gathers every file (including files
           inside dropped folders) before we go on, so multiple files
           dropped at once are all picked up. */
        for (const item of items) {
            waits.push(flattenItemToFiles(item, files));
        }
        await Promise.all(waits);
        if (!multiple && files.length > 1) {
            doMessage('<h1 class=\"red\" >' +
                tl["basic_js_too_many_files"] + '</h1>');
            return;
        }
        for (let i = 0; i < files.length; i++) {
            if (!checkAllowedType(files[i])) {
                doMessage('<h1 class=\"red\" >' +
                    tl["basic_js_invalid_filetype"] + '</h1>');
                return;
            }
            if (max_size > 0 && files[i].size > max_size) {
                doMessage('<h1 class=\"red\" >' +
                    tl["basic_js_file_too_big"] + '</h1>');
                return;
            }
        }
        if (file_list[file_elt.name] === undefined) {
            file_list[file_elt.name] = new Array();
        }
        if (multiple) {
            file_list[file_elt.name][file_list[file_elt.name].length] = files;
        } else {
            file_list[file_elt.name][0] = files;
        }
        if (drop_kind == "image") {
            var img_url = URL.createObjectURL(files[0]);
            drop_elt.src = img_url;
        } else if (drop_kind == "textarea") {
            for (var j = 0; j < files.length; j++) {
                addToPage(files[j].name, drop_id);
            }
        } else if (drop_kind == "all" || drop_kind == "text") {
            if (drop_elt.innerHTML == "&nbsp;") {
                drop_elt.innerHTML = "";
            }
            for (var i = 0; i < files.length; i++) {
                var br = (drop_elt.innerHTML == "") ? "" : "<br>";
                drop_elt.innerHTML += br + files[i].name;
            }
        } else if (drop_kind == "immediate") {
            fileUploadSubmit(null);
        }
    }
    /**
     * Convenience helper that stops both default-action and
     * bubbling on an event. Used as the body of dragenter /
     * dragover / dragleave handlers on file-upload zones.
     *
     * @param {Event} event
     */
    function stopNoPropagate(event)
    {
        event.stopPropagation();
        event.preventDefault();
    }
    /**
     * Returns true when $to_check's MIME type is in the configured
     * `types` allowlist, or when no types restriction is configured.
     *
     * @param {File} to_check
     * @return {boolean}
     */
    function checkAllowedType(to_check)
    {
        if (types == null || types.length == 0) {
            return true;
        }
        for (type in types) {
            if (to_check.type == types[type]) {
                return true;
            }
        }
        return false;
    }
    /**
     * Converts a number to a short string followed by nothing, K, M,
     * G, or T depending on its size, the same way the server-side
     * intToMetric helper does, so an upload's size reads the same as
     * sizes shown elsewhere in Yioop (for example the wiki resource
     * list). Uses powers of 1000.
     *
     * @param {number} num number to convert
     * @return {string} the number followed by its metric letter
     */
    function intToMetric(num)
    {
        var metric_letters = ["", "K", "M", "G", "T", "P", "E"];
        var power = Math.max(Math.floor(Math.log(num) /
            Math.log(1000)), 0);
        if (metric_letters[power] === undefined) {
            power = 6;
        }
        return Math.round(num / Math.pow(1000, power)) +
            metric_letters[power];
    }
    /**
     * XHR progress handler: updates the percent-complete badge in
     * the `message` element.
     *
     * @param {ProgressEvent} event
     */
    function uploadProgress(event)
    {
        var progress = elt('message');
        if (event.lengthComputable) {
            var percent_complete =
                Math.round(event.loaded * 100 / event.total);
            var now = (new Date()).getTime();
            var speed_text = "";
            /* Work out how fast bytes have arrived since the previous
               progress update. The previous sample is kept on the
               upload object itself so each upload measures its own
               rate and nothing leaks between uploads. */
            if (event.target.last_progress_time !== undefined) {
                var seconds = (now -
                    event.target.last_progress_time) / 1000;
                var added = event.loaded -
                    event.target.last_progress_loaded;
                if (seconds > 0 && added >= 0) {
                    /* Keep the last few instantaneous rates and show
                       their average, so the number is smoother and
                       does not jump around as much between updates.
                       The samples live on the upload object so each
                       upload averages only its own rates. */
                    var this_rate = added / seconds;
                    if (event.target.recent_rates === undefined) {
                        event.target.recent_rates = [];
                    }
                    event.target.recent_rates.push(this_rate);
                    var sample_count = 3;
                    while (event.target.recent_rates.length >
                        sample_count) {
                        event.target.recent_rates.shift();
                    }
                    var rate_total = 0;
                    for (var pos = 0;
                        pos < event.target.recent_rates.length; pos++) {
                        rate_total += event.target.recent_rates[pos];
                    }
                    var average_rate = rate_total /
                        event.target.recent_rates.length;
                    /* Pad the speed value to a constant width so the
                       line does not shift as the rate changes. A
                       non-breaking space is used because a plain
                       leading space would collapse in HTML. */
                    var speed_value =
                        intToMetric(Math.round(average_rate));
                    var speed_width = 5;
                    while (speed_value.length < speed_width) {
                        speed_value = '\u00A0' + speed_value;
                    }
                    speed_text = " at " + speed_value + "B/s";
                }
            }
            event.target.last_progress_time = now;
            event.target.last_progress_loaded = event.loaded;
            progress.innerHTML = '<h1 class=\"red\" >' +
                tl["basic_js_upload_progress"] +
                percent_complete.toString() + '% (' +
                intToMetric(event.total) + ')' + speed_text +
                '</h1>';
        } else {
            progress.innerHTML = '<h1 class=\"red\" >' +
                tl["basic_js_progress_meter_disabled"] +'</h1>';
        }
    }
    /**
     * XHR completion handler: when the server response begins with
     * "go" the rest of the response is treated as a redirect URL;
     * otherwise the response body replaces the current document.
     *
     * @param {ProgressEvent} event
     */
    function uploadComplete(event)
    {
        /* This event is raised when the server sends back a response */
        if (event.target.responseText.substring(0,2) == "go") {
            window.location = event.target.responseText.substring(2);
        } else {
            document.open();
            document.write(event.target.responseText);
            document.close();
        }
    }
    /**
     * XHR failure handler: shows an upload-error message.
     *
     * @param {ProgressEvent} event
     */
    function uploadFailed(event)
    {
        doMessage('<h1 class=\"red\" >' +
            tl["basic_js_upload_error"] +'</h1>');
    }
    /**
     * XHR cancellation handler: shows an upload-cancelled message.
     *
     * @param {ProgressEvent} event
     */
    function uploadCanceled(event)
    {
        doMessage('<h1 class=\"red\" >' +
            tl["basic_js_upload_cancelled"] +'</h1>');
    }
}
/* Name of the resource currently being dragged within the resource
   list, or the empty string when the drag is coming from outside the
   page (operating-system files). Set on dragstart, cleared on drop. */
var dragged_resource_name = "";
/**
 * Wires up drag-and-drop on the wiki page resource list so a resource
 * can be dragged onto a folder to move it there, and so operating
 * system files dropped onto a folder upload into that folder. Resource
 * rows carry a data-resource-name attribute and folders additionally
 * carry data-folder-target; this reads those to know what was dragged
 * and where it was dropped.
 *
 * @param String move_base_url page edit url that the move request is
 *      built on; the dragged resource and target folder are appended
 *      to it to carry out the move
 */
function initResourceDragDrop(move_base_url)
{
    let rows = document.querySelectorAll("[data-resource-name]");
    for (let i = 0; i < rows.length; i++) {
        let row = rows[i];
        listen(row, "dragstart", function(event) {
            dragged_resource_name = row.getAttribute(
                "data-resource-name");
            event.dataTransfer.effectAllowed = "move";
        });
        listen(row, "dragend", function(event) {
            dragged_resource_name = "";
        });
    }
    let folders = document.querySelectorAll("[data-folder-target]");
    for (let j = 0; j < folders.length; j++) {
        let folder = folders[j];
        listen(folder, "dragover", function(event) {
            event.preventDefault();
        });
        listen(folder, "drop", function(event) {
            let target = folder.getAttribute("data-folder-target");
            dropOnFolder(event, target, move_base_url);
        });
    }
    let path_targets = document.querySelectorAll("[data-folder-path]");
    for (let path_index = 0; path_index < path_targets.length;
        path_index++) {
        let path_target = path_targets[path_index];
        listen(path_target, "dragover", function(event) {
            event.preventDefault();
        });
        listen(path_target, "drop", function(event) {
            let target_path = path_target.getAttribute(
                "data-folder-path");
            dropOnFolderPath(event, target_path, move_base_url);
        });
    }
}
/**
 * Handles a drop onto a folder named by its full path, such as the
 * parent-folder row. When a resource from the list was being dragged,
 * navigates to the move url so the server relocates the resource into
 * that folder. Operating system files dropped here are ignored, since
 * uploading up into a parent is not offered.
 *
 * @param DragEvent event the drop event
 * @param String target_path folder path dropped onto, empty for the
 *      top resource folder
 * @param String move_base_url page edit url the move request builds on
 */
function dropOnFolderPath(event, target_path, move_base_url)
{
    event.preventDefault();
    event.stopPropagation();
    if (!dragged_resource_name) {
        return;
    }
    let location = move_base_url +
        "&move_resource=" +
        encodeURIComponent(dragged_resource_name) +
        "&move_to_path=" + encodeURIComponent(target_path);
    dragged_resource_name = "";
    window.location = location;
}
/**
 * Handles a drop onto a folder row. If a resource from the list was
 * being dragged, navigates to the move url so the server relocates it
 * into the folder. Otherwise the drop is operating system files, which
 * are uploaded into the folder by prefixing the folder name onto each
 * dropped file before handing them to the page upload input.
 *
 * @param DragEvent event the drop event
 * @param String target name of the folder dropped onto
 * @param String move_base_url page edit url the move request builds on
 */
function dropOnFolder(event, target, move_base_url)
{
    event.preventDefault();
    event.stopPropagation();
    if (dragged_resource_name && dragged_resource_name != target) {
        let location = move_base_url +
            "&move_resource=" +
            encodeURIComponent(dragged_resource_name) +
            "&move_target=" + encodeURIComponent(target);
        dragged_resource_name = "";
        window.location = location;
        return;
    }
    dragged_resource_name = "";
    let upload_input = document.getElementById("media-page-resource");
    if (!upload_input || !event.dataTransfer ||
        event.dataTransfer.items.length == 0) {
        return;
    }
    uploadDroppedItemsToFolder(event.dataTransfer.items, target,
        upload_input);
}
/**
 * Uploads operating system files that were dropped onto a folder into
 * that folder. Each dropped file's path has the target folder name put
 * in front of it so the existing upload code, which already creates
 * any folders named in a file's path, places the file inside the
 * folder. The prepared files are placed on the page upload input and
 * that input's form is submitted.
 *
 * @param DataTransferItemList items the dropped items
 * @param String target name of the folder to upload into
 * @param Object upload_input the page resource file input element
 */
async function uploadDroppedItemsToFolder(items, target, upload_input)
{
    let files = [];
    let waits = [];
    for (const item of items) {
        let entry = item.webkitGetAsEntry ?
            item.webkitGetAsEntry() : null;
        waits.push(collectDroppedEntry(item, entry, files));
    }
    await Promise.all(waits);
    if (files.length == 0) {
        return;
    }
    let prefixed = [];
    for (let i = 0; i < files.length; i++) {
        let relative = files[i].webkitRelativePath || files[i].name;
        let prefixed_name = target + "/" + relative;
        prefixed.push(new File([files[i]], prefixed_name,
            { type: files[i].type,
            lastModified: files[i].lastModified }));
    }
    let data_transfer = new DataTransfer();
    for (let j = 0; j < prefixed.length; j++) {
        data_transfer.items.add(prefixed[j]);
    }
    upload_input.files = data_transfer.files;
    let change_event = new Event("change");
    upload_input.dispatchEvent(change_event);
}
/**
 * Collects a single dropped item into the files array, following a
 * dropped folder down into its contents. Returns a promise that
 * resolves once everything under the item has been gathered. A file's
 * folder path is preserved on its webkitRelativePath so the upload can
 * recreate the folder structure.
 *
 * @param Object item the dropped DataTransferItem
 * @param Object entry the matching file system entry, or null
 * @param Array files accumulator the found files are added to
 * @return Promise resolves when all files under the item are gathered
 */
function collectDroppedEntry(item, entry, files)
{
    if (!entry) {
        let file = item.getAsFile ? item.getAsFile() : null;
        if (file) {
            files.push(file);
        }
        return Promise.resolve();
    }
    if (entry.isFile) {
        return new Promise(function(resolve) {
            entry.file(function(file) {
                if (entry.fullPath) {
                    try {
                        Object.defineProperty(file, "webkitRelativePath",
                            { value: entry.fullPath.substring(1) });
                    } catch (ignore) {
                    }
                }
                files.push(file);
                resolve();
            }, function() {
                resolve();
            });
        });
    }
    if (entry.isDirectory) {
        return new Promise(function(resolve) {
            let reader = entry.createReader();
            reader.readEntries(function(entries) {
                let waits = [];
                for (let i = 0; i < entries.length; i++) {
                    waits.push(collectDroppedEntry(null, entries[i],
                        files));
                }
                Promise.all(waits).then(resolve);
            }, function() {
                resolve();
            });
        });
    }
    return Promise.resolve();
}
/**
 * Appends the in-memory mic-recording (if any) to file_list under
 * the synthetic "audio_files" key so it gets uploaded alongside
 * the rest of the form's files.
 */
function addAudioToFileList()
{
    if (!audio_blob || !current_audio_filename) {
        return;
    }
    const audio_file = new File([audio_blob], current_audio_filename, {
        type: 'audio/webm',
        lastModified: new Date().getTime()
    });
    const audio_files = {
        0: audio_file,
        length: 1,
        /**
         * FileList-style indexer used by FormData when iterating
         * the synthetic audio_files object.
         *
         * @param {number} i index to look up
         * @return {File} the file at that index
         */
        item: function(i) { return this[i]; }
    };
    if (file_list['file_new_message'] === undefined) {
        file_list['file_new_message'] = new Array();
    }
    let newIndex = file_list['file_new_message'].length;
    file_list['file_new_message'][newIndex] = audio_files;
}
/*
 * Sets whether an elt is styled as display:none or block
 *
 * @param String id  the id of the DOM element one wants
 * @param mixed value  true means display display_type false display none;
 *     anything else will display that value
 * @param mixed display_type type to set CSS display property to in the event
 *      value is true (might be block or inline, etc).
 */
function setDisplay(id, value, display_type)
{
    if (display_type === undefined){
        display_type = "block";
    }
    obj = elt(id);
    if(!obj) {
        return;
    }
    if (value == true)  {
        value = display_type;
    }
    if (value == false) {
        value = "none";
    }
    obj.style.display = value;
    if (value == "none") {
        obj.setAttribute('aria-hidden', true);
    } else {
        obj.setAttribute('aria-hidden', false);
    }
}
/*
 * Toggles an element between display:none and display block
 * @param String id  the id of the DOM element one wants
 * @param String display_type what display type to toggle between none and.
 *  The default is block.
 */
function toggleDisplay(id, display_type)
{
    if (display_type === undefined) {
        display_type = "block";
    }
    obj = elt(id);
    if (obj.style.display == display_type)  {
        value = "none";
    } else {
        value = display_type;
    }
    obj.style.display = value;
    if (value == "none") {
        obj.setAttribute('aria-hidden', true);
    } else {
        obj.setAttribute('aria-hidden', false);
    }
}
/*
 * Toggles whether a dom element has a give in class in in class list
 * @param String id  the id of the DOM element one wants
 */
function toggleClass(id, toggle_class)
{
    obj = elt(id);
    if (obj.classList.contains(toggle_class))  {
        obj.classList.remove(toggle_class);
    } else {
        obj.classList.add(toggle_class);
    }
}
/*
 * Wires a simple add-and-remove list whose entries are kept in a hidden
 * comma-separated input so the form submits them as one value. The list
 * element holds one li.managed-csv-item per entry, each with a
 * span.managed-csv-text and a button.managed-csv-remove; the add input
 * and add button append a trimmed, de-duplicated entry; and the hidden
 * input is rebuilt from the list on every change. Used for the LDAP
 * directory-server list in the Security activity, the same shape as the
 * secure-domain list in Server Settings.
 *
 * @param String list_id  id of the ul holding the entries
 * @param String input_id  id of the text box a new entry is typed in
 * @param String button_id  id of the button that adds the typed entry
 * @param String hidden_id  id of the hidden input the form submits
 * @param Function on_change  optional function run with the current number
 *      of entries whenever the list changes and once at start up, for
 *      example to show or hide a required marker
 */
function initManagedCsvList(list_id, input_id, button_id, hidden_id,
    on_change)
{
    var list = elt(list_id);
    var add_input = elt(input_id);
    var add_button = elt(button_id);
    if (!list || !add_input || !add_button) {
        return;
    }
    /*
     * Rebuilds the hidden comma-separated value from the entries still in
     * the list so the form submits the current set.
     */
    var sync = function () {
        var hidden = elt(hidden_id);
        if (!hidden) {
            return;
        }
        var spans = list.querySelectorAll('.managed-csv-text');
        var parts = [];
        for (var i = 0; i < spans.length; i++) {
            parts.push(spans[i].textContent);
        }
        hidden.value = parts.join(',');
        if (typeof on_change === 'function') {
            on_change(parts.length);
        }
    };
    /*
     * Attaches the click handler that removes one entry's row and then
     * refreshes the hidden value.
     *
     * @param Object button  the remove button for one entry
     */
    var wire_remove = function (button) {
        listen(button, 'click', function () {
            var item = button.closest('.managed-csv-item');
            if (item && item.parentNode) {
                item.parentNode.removeChild(item);
                sync();
            }
        });
    };
    listen(add_button, 'click', function () {
        var value = add_input.value.trim();
        if (value === '' || value.indexOf(',') !== -1) {
            return;
        }
        var spans = list.querySelectorAll('.managed-csv-text');
        for (var i = 0; i < spans.length; i++) {
            if (spans[i].textContent === value) {
                add_input.value = '';
                return;
            }
        }
        var item = ce('li');
        item.className = 'mail-domain-item managed-csv-item';
        var text = ce('span');
        text.className = 'mail-domain-text managed-csv-text';
        text.textContent = value;
        var remove = ce('button');
        remove.type = 'button';
        remove.className = 'managed-csv-remove mail-domain-remove';
        remove.textContent = '\u00D7';
        remove.title = add_button.getAttribute('data-remove-label') ||
            'Remove';
        wire_remove(remove);
        item.appendChild(text);
        item.appendChild(remove);
        list.appendChild(item);
        add_input.value = '';
        sync();
    });
    listen(add_input, 'keydown', function (event) {
        if (event.key === 'Enter') {
            event.preventDefault();
            add_button.click();
        }
    });
    var removes = list.querySelectorAll('.managed-csv-remove');
    for (var j = 0; j < removes.length; j++) {
        wire_remove(removes[j]);
    }
    sync();
    var add_form = add_input.form;
    if (add_form) {
        listen(add_form, 'submit', function () {
            if (add_input.value.trim() !== '') {
                add_button.click();
            }
        });
    }
}
/*
 * Adds or removes a single CSS class on an element. Mirrors setDisplay but
 * for a class name instead of the display style: the class is put on the
 * element when value is true and taken off when value is false.
 *
 * @param String id  the id of the DOM element one wants
 * @param mixed value  true puts the class on the element, false takes it off
 * @param String class_name  the CSS class to add or remove
 */
function setClass(id, value, class_name)
{
    obj = elt(id);
    if (!obj) {
        return;
    }
    if (value) {
        obj.classList.add(class_name);
    } else {
        obj.classList.remove(class_name);
    }
}
/*
 * Make an AJAX request for a url
 *
 * @param String url  web page to fetch using AJAX
 */
function getPageWithMessage(url)
{
    var request = makeRequest();
    if (request) {
        var self = this;
        request.onreadystatechange = function()
        {
            if (self.request.readyState == 4) {
                 doMessage(self.request.responseText);
            }
        }
        request.open("GET", url, true);
        request.send();
    }
}
/*
 * Maintains in sessionStorage the scroll position of the html element
 * with id scroll_container_id
 *
 * @param String scroll_container_id id of the html elment ot track scroll
 *  position of
 */
function initScrollPositionPreserver(scroll_container_id)
{
    let scroll_container = document.getElementById(scroll_container_id);
    if (!scroll_container) {
        return;
    }
    window.onbeforeunload = function () {
        let scroll_position = scroll_container.scrollTop;
        sessionStorage.setItem(scroll_container_id + "Y",
            scroll_position.toString());
        scroll_position = scroll_container.scrollLeft;
        sessionStorage.setItem(scroll_container_id + "X",
            scroll_position.toString());
    }
    if (sessionStorage[scroll_container_id + "Y"]) {
        scroll_container.scrollTop = sessionStorage.getItem(
            scroll_container_id + "Y");
    }
    if (sessionStorage[scroll_container_id + "X"]) {
        scroll_container.scrollLeft = sessionStorage.getItem(
            scroll_container_id + "X");
    }
}
/*
 * Global media_recorder instance used to handle audio recording functionality
 * Stores the current recording session
 * @var media_recorder
 */
var media_recorder = null;
/*
 * Global array used to store audio chunks as they are recorded
 * Each chunk represents a segment of recorded audio data
 * @var Array
 */
var audio_chunks = [];
/*
 * Global Blob instance that stores the complete recorded audio
 * Created when recording stops by combining all audio_chunks
 * @var Blob
 */
var audio_blob = null;
/*
 * Global Audio instance used for playback of recorded audio
 * Created when user clicks play button
 * @var Audio
 */
var audio = null;
/*
 * Global string storing the filename for the current audio recording
 * Generated using timestamp when recording completes
 * @var String
 */
var current_audio_filename = null;
/*
 * Initializes audio recording functionality for the message input
 * Sets up event listeners for record, play, and delete buttons
 * Handles recording states and audio blob creation
 */
function initializeAudioRecorder()
{
    const record_button = elt('audio-record-btn');
    const mic_icon = elt('mic-icon');
    const audio_controls = elt('audio-controls');
    const play_button = elt('play-audio-btn');
    const delete_button = elt('delete-audio-btn');
    let is_recording = false;
    record_button.addEventListener('click', async () => {
        if (!is_recording) {
            try {
                const stream = await navigator.mediaDevices.getUserMedia({
                    audio: true
                });
                var audio_type;
                if (MediaRecorder.isTypeSupported('audio/mp4')) {
                    audio_type = 'audio/mp4';
                    extension = "mp4";
                } else if (MediaRecorder.isTypeSupported(
                    'audio/webm')) {
                    audio_type = 'audio/webm';
                    extension = "webm";
                } else {
                    console.error("no suitable mimetype found for this device");
                }
                media_recorder = new MediaRecorder(stream,
                    {mimeType: audio_type});
                audio_chunks = [];
                media_recorder.addEventListener('dataavailable', (event) => {
                    audio_chunks.push(event.data);
                });
                media_recorder.addEventListener('stop', () => {
                    // Create the blob when recording stops
                    audio_blob = new Blob(audio_chunks, {type: audio_type});
                    audio_chunks = []; // Clear the chunks array
                    // Generate filename with timestamp
                    const timestamp = new Date().toISOString()
                        .replace(/[:]/g, '-').replace(/\..+/, '');
                    current_audio_filename = `audio_message_${timestamp}.` +
                        `${extension}`;
                    record_button.style.display = 'none';
                    audio_controls.style.display = 'inline-flex';
                    // Add to file_list immediately
                    addAudioToFileList();
                    // Update the description field with resource format
                    const description_field = elt('new-message');
                    description_field.value =
                        `((resource:${current_audio_filename}|` +
                            tl["wiki_js_resource_description"] +
                            ` ${current_audio_filename}))`;
                });
                media_recorder.start();
                is_recording = true;
                mic_icon.textContent = '⬛';
                mic_icon.parentElement.classList.add('recording');
            } catch (err) {
                console.log(err);
                alert(tl["basic_js_microphone_access_error"]);
            }
        } else {
            stopRecording();
            is_recording = false;
            mic_icon.textContent = '🎤';
            mic_icon.parentElement.classList.remove('recording');
        }
    });
    play_button.addEventListener('click', () => {
        if (audio_blob && !audio) {
            const audio_url = URL.createObjectURL(audio_blob);
            audio = new Audio(audio_url);
            audio.addEventListener('ended', () => {
                URL.revokeObjectURL(audio_url);
                audio = null;
            });
            audio.play();
        }
    });
    delete_button.addEventListener('click', () => {
        if (audio) {
            audio.pause();
            audio = null;
        }
        audio_blob = null;
        current_audio_filename = null;
        // Clear from file_list when deleted
        if (file_list['file_new_message']) {
            file_list['file_new_message'] = [];
        }
        // Clear description field
        const description_field = elt('new-message');
        description_field.value = '';
        audio_controls.style.display = 'none';
        record_button.style.display = 'inline-block';
    });
}
/*
 * Stops the current audio recording session
 * Stops the media_recorder and all active audio tracks
 * Called when user clicks stop button or starts a new recording
 */
function stopRecording()
{
    if (media_recorder && media_recorder.state === 'recording') {
        media_recorder.stop();
        media_recorder.stream.getTracks().forEach(track => track.stop());
    }
}
/**
 * Creates a hash string from input text
 * Used for generating unique IDs for DOM elements
 * @param {string} text - Text to hash
 * @return {string} Hashed string
 */
function crawlHash(text) {
    let hash = 0;
    if (text.length === 0) return hash.toString();
    for (let i = 0; i < text.length; i++) {
        const char = text.charCodeAt(i);
        hash = ((hash << 5) - hash) + char;
        hash = hash & hash; // Convert to 32-bit integer
    }
    return Math.abs(hash).toString(16).toLowerCase();
}
/**
 * Handles displaying transcripts for audio messages
 * Fetches transcript from server if available, otherwise shows placeholder
 * @param {string} filename - Name of the audio file
 * @param {string} folder - Folder path containing the audio file
 * @param {string} csrf_token - CSRF token for API requests
 */
function transcribeAudio(filename, folder, csrf_token) {
    let audio_element = document.querySelector(
        `audio source[src*="${filename}"]`)?.parentElement;
    if (!audio_element) {
        const audio_elements = sel('audio');
        for (let audio of audio_elements) {
            const source = audio.querySelector('source');
            if (source && source.src.includes(filename)) {
                audio_element = audio;
                break;
            }
        }
    }
    if (!audio_element) {
        console.error('Could not find audio element for:', filename);
        return;
    }
    const transcript_container = document.getElementById(
        'transcript-' + audio_element.id);
    if (!transcript_container) {
        console.error('Could not find transcript container for:',
            audio_element.id);
        return;
    }
    if (transcript_container.classList.contains('show')) {
        transcript_container.classList.remove('show');
        return;
    }
    transcript_container.classList.add('show', 'loading');
    transcript_container.textContent = 'Loading transcript...';
    const audio_source = audio_element.querySelector('source');
    if (!audio_source) {
        console.error('Could not find audio source');
        transcript_container.textContent =
            'Error: Could not find audio source.';
        transcript_container.className = 'transcript-container show error';
        return;
    }
    const base_url = audio_source.src.replace(
        /\.(mp3|wav|ogg|m4a|webm|mp4|aiff|basic|L24)$/i, '');
    const transcript_url = base_url + '.txt';
    fetch(transcript_url, {
        method: 'GET',
        headers: {
            'Cache-Control': 'no-cache'
        }
    })
    .then(response => {
        if (!response.ok) {
            throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        return response.text();
    })
    .then(transcript => {
        const trimmed_transcript = transcript.trim();
        if (!trimmed_transcript) {
            throw new Error('Transcript is empty');
        }
        if (trimmed_transcript === 'Transcription pending...' ||
            trimmed_transcript === 'Transcription in progress...') {
            transcript_container.textContent =
                'Transcription is being processed. Please try again in ' +
                'a few minutes.';
            transcript_container.className =
                'transcript-container show loading';
            return;
        }
        if (trimmed_transcript.includes('Transcription failed permanently') ||
            trimmed_transcript.includes('failed permanently after')) {
            transcript_container.textContent = trimmed_transcript;
            transcript_container.className = 'transcript-container show error';
            return;
        }
        if (transcript.includes('\0') || transcript.length > 50000) {
            throw new Error('Invalid transcript content');
        }
        const cleaned_transcript = trimmed_transcript
            .replace(/\s+/g, ' ')
            .replace(/\n{3,}/g, '\n\n')
            .trim();
        transcript_container.textContent = cleaned_transcript;
        transcript_container.className = 'transcript-container show';
    })
    .catch(error => {
        let error_message = 'Transcript not available yet.';
        if (error.message.includes('404')) {
            error_message =
                'Transcript not found. Processing may still be in progress.';
        } else if (error.message.includes('403') ||
            error.message.includes('401')) {
            error_message = 'Access denied to transcript file.';
        } else if (error.message.includes('500')) {
            error_message = 'Server error while fetching transcript.';
        } else if (error.message.includes('network') ||
            error.message.includes('fetch')) {
            error_message = 'Network error. Please check your connection.';
        }
        transcript_container.textContent = error_message +
            ' Please try again later.';
        transcript_container.className = 'transcript-container show error';
    });
}
/**
 * Shows or hides a git read-view panel, such as the README contents menu
 * or the clone-url line, by flipping a class the stylesheet keys the
 * panel's visibility off. Used by the small toggle buttons in the git bar
 * and README header.
 * @param String id the id of the panel element to show or hide
 */
function toggleGitPanel(id)
{
    let panel = elt(id);
    if (panel) {
        panel.classList.toggle('git-open');
    }
}
/**
 * Loads more rows into a git commit or tag list as the reader scrolls near
 * the bottom of its box. Each further page of rows is fetched from the
 * given address and added to the end of the table; loading stops once a
 * fetch comes back with no more rows.
 * @param String box_id id of the scrolling box holding the list
 * @param String rows_id id of the table body the rows are added to
 * @param String base_url address to fetch a page of rows from, to which
 *      the starting row number is added
 * @param Number page_size number of rows in each page
 */
function gitInfiniteScroll(box_id, rows_id, base_url, page_size)
{
    let box = elt(box_id);
    let rows = elt(rows_id);
    if (!box || !rows) {
        return;
    }
    let bottom_gap = 40;
    let offset = page_size;
    let loading = false;
    let done = false;
    box.addEventListener("scroll", function () {
        if (loading || done) {
            return;
        }
        if (box.scrollTop + box.clientHeight <
            box.scrollHeight - bottom_gap) {
            return;
        }
        loading = true;
        getPage(null, base_url + "&repo_offset=" + offset,
            function (html) {
                rows.insertAdjacentHTML("beforeend", html);
                offset += page_size;
                loading = false;
            },
            function () {
                done = true;
                loading = false;
            });
    });
}
/**
 * Copies a repository's clone address to the clipboard when the copy
 * button beside it is pressed. The address is read from the button's own
 * data-clone attribute so nothing needs to be selected first.
 * @param Object button the copy button that was pressed
 */
function gitCopyClone(button)
{
    let address = button.getAttribute("data-clone");
    if (address && navigator.clipboard) {
        navigator.clipboard.writeText(address);
    }
}
/**
 * Reveals the panel for making a Git push code and hides the note that
 * says the address can only be cloned. The note is hidden as soon as the
 * refresh control is pressed so it does not linger while the person fills
 * in the panel.
 */
function gitToggleAppCode()
{
    let prompt = elt("git-app-prompt");
    if (prompt) {
        prompt.style.display = "none";
    }
    toggleGitPanel("git-app-refresh");
}
/**
 * Asks the server to mint a fresh Git application code for the person
 * editing a repository wiki page. The password they typed and the expiry
 * they chose are gathered and posted back to the same edit page, which
 * makes the new code, saves it, and reloads showing the updated clone
 * address. The password travels in the post body, never in the address.
 * @param Object button the create button that was pressed, carrying the
 *      page details and form token in its data attributes
 */
function gitCreateAppCode(button)
{
    let expiry = elt("git-app-expiry");
    let password = elt("git-app-password");
    if (!password || !expiry) {
        return;
    }
    let fields = {
        "c": button.getAttribute("data-controller"),
        "a": "wiki",
        "arg": "edit",
        "group_id": button.getAttribute("data-group"),
        "page_name": button.getAttribute("data-page"),
        "git_app_refresh": "1",
        "git_app_password": password.value,
        "git_app_expiry": expiry.value
    };
    fields[button.getAttribute("data-token-name")] =
        button.getAttribute("data-token-value");
    let form = document.createElement("form");
    form.method = "post";
    form.action = window.location.href;
    for (let key in fields) {
        let field = document.createElement("input");
        field.type = "hidden";
        field.name = key;
        field.value = fields[key];
        form.appendChild(field);
    }
    document.body.appendChild(form);
    form.submit();
}
/**
 * Filters the file listing of a git folder view as the reader types in the
 * search box, showing only the rows whose name contains what was typed.
 * The parent-folder row is always kept so a reader can still step back up.
 * @param Object field the search text field being typed in
 */
function gitTreeFilter(field)
{
    let needle = field.value.toLowerCase();
    let rows = document.querySelectorAll(".git-listing tr");
    for (let i = 0; i < rows.length; i++) {
        let cell = rows[i].querySelector(".git-name-col");
        if (!cell) {
            continue;
        }
        let name = cell.textContent.toLowerCase();
        let keep = name.indexOf(needle) >= 0 ||
            name.indexOf("\u2191") >= 0;
        rows[i].style.display = keep ? "" : "none";
    }
}
/**
 * Narrows the git issue list to the rows whose text contains what the reader
 * typed in the issue search box. An empty box brings every issue back.
 * @param Object field the issue search field whose value is the filter
 */
function gitIssueFilter(field)
{
    let query = field.value.toLowerCase();
    let tokens = query.split(/\s+/);
    let keywords = [];
    let metas = {};
    for (let i = 0; i < tokens.length; i++) {
        let token = tokens[i];
        if (token === "") {
            continue;
        }
        let colon = token.indexOf(":");
        if (colon > 0) {
            metas[token.substring(0, colon)] = token.substring(colon + 1);
        } else {
            keywords.push(token);
        }
    }
    let rows = document.querySelectorAll(".git-issue-row");
    for (let i = 0; i < rows.length; i++) {
        let row = rows[i];
        let row_text = row.textContent.toLowerCase();
        let reporter = (row.getAttribute("data-reporter") ||
            "").toLowerCase();
        let assignee = (row.getAttribute("data-assignee") ||
            "").toLowerCase();
        let number = (row.getAttribute("data-number") || "").toLowerCase();
        let status = (row.getAttribute("data-status") || "").toLowerCase();
        let updated = row.getAttribute("data-updated") || "";
        let visible = true;
        for (let j = 0; j < keywords.length; j++) {
            if (row_text.indexOf(keywords[j]) < 0) {
                visible = false;
            }
        }
        if (metas["reporter"] !== undefined &&
            reporter.indexOf(metas["reporter"]) < 0) {
            visible = false;
        }
        if (metas["assignee"] !== undefined &&
            assignee.indexOf(metas["assignee"]) < 0) {
            visible = false;
        }
        if (metas["issue"] !== undefined &&
            number.indexOf(metas["issue"]) < 0) {
            visible = false;
        }
        if (metas["status"] !== undefined &&
            status.indexOf(metas["status"]) < 0) {
            visible = false;
        }
        if (metas["since"] !== undefined && updated < metas["since"]) {
            visible = false;
        }
        if (metas["before"] !== undefined && updated >= metas["before"]) {
            visible = false;
        }
        row.style.display = visible ? "" : "none";
    }
}
/**
 * Sorts a git table, either the issue list or the file listing, by the
 * column whose header was clicked, flipping between smallest-first and
 * largest-first each time the same header is used again. A column marked
 * data-sort-type="number" sorts as numbers; the rest sort as text. The
 * header's own row and any row marked git-no-sort, such as the parent
 * folder link, stay where they are.
 * @param Object header the clicked column header cell
 */
function gitTableSort(header)
{
    let heads = header.parentNode.children;
    let index = 0;
    for (let i = 0; i < heads.length; i++) {
        if (heads[i] === header) {
            index = i;
        }
    }
    let numeric = header.getAttribute("data-sort-type") === "number";
    let ascending = header.getAttribute("data-sort") !== "asc";
    let table = header.closest("table");
    let head_row = header.parentNode;
    let rows = [];
    for (let i = 0; i < table.rows.length; i++) {
        let row = table.rows[i];
        if (row === head_row ||
            row.className.indexOf("git-no-sort") >= 0) {
            continue;
        }
        rows.push(row);
    }
    rows.sort(function (first, second) {
        let left = gitCellSortValue(first.children[index]);
        let right = gitCellSortValue(second.children[index]);
        let order;
        if (numeric) {
            order = parseInt(left.replace(/[^0-9]/g, ""), 10) -
                parseInt(right.replace(/[^0-9]/g, ""), 10);
        } else {
            order = left.localeCompare(right);
        }
        return ascending ? order : -order;
    });
    for (let i = 0; i < rows.length; i++) {
        rows[i].parentNode.appendChild(rows[i]);
    }
    for (let i = 0; i < heads.length; i++) {
        heads[i].removeAttribute("data-sort");
    }
    header.setAttribute("data-sort", ascending ? "asc" : "desc");
}
/**
 * Reads the value a git table cell should sort by: its data-sort-key
 * attribute when one is set, so the file listing's age column can sort by
 * its raw timestamp rather than by its "3 days ago" wording, and otherwise
 * the cell's trimmed text.
 * @param Object cell the table cell being compared
 * @return String the value to compare when sorting
 */
function gitCellSortValue(cell)
{
    let key = cell.getAttribute("data-sort-key");
    if (key !== null) {
        return key;
    }
    return cell.textContent.trim();
}
/**
 * Responds to a change of the issue status control. Choosing to assign the
 * issue shows a username box and choosing to mark it fixed shows a commit
 * box, each with its own send arrow, and neither is sent yet. The other
 * choices need nothing more, so they are sent at once.
 * @param Object select the status control that was changed
 */
function gitIssueStatusChange(select)
{
    let assignee = elt("git-issue-assignee-group");
    let commit = elt("git-issue-commit-group");
    if (assignee) {
        assignee.style.display =
            (select.value === "assigned") ? "inline-flex" : "none";
    }
    if (commit) {
        commit.style.display =
            (select.value === "marked_fixed") ? "inline-flex" : "none";
    }
    if (select.value !== "assigned" && select.value !== "marked_fixed") {
        select.form.submit();
    }
}
/**
 * Scrolls the issue comments to the most recent one at the bottom.
 */
function gitIssueScrollComments()
{
    let box = elt("git-issue-comments");
    if (box) {
        box.scrollTop = box.scrollHeight;
    }
}
/**
 * Runs a commit or tag list search when the reader presses Enter in the
 * search box, reloading the list at the given address with the typed text
 * added as the search term.
 * @param Object event the keydown event from the search field
 * @param String base the list address the search term is added to
 */
function gitListSearch(event, base)
{
    if (event.key != "Enter") {
        return;
    }
    let term = event.target.value;
    window.location = base + "&repo_search=" + encodeURIComponent(term);
}
X