/ 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);
                }
            }
        }
    }
    fillCsvBlockUsers(document);
    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();
}
/*
 * The width below which the reading column and the panel beside it cannot
 * both have the room they want, matching the stylesheet.
 */
var SEARCH_PANEL_NARROW = 1150;
/*
 * Carries the panel beside the results into the reading column when the
 * window is too narrow to hold both, and back out again when it widens.
 * In the reading column it goes after the advertisements, where a phone
 * has always shown it, so it never stands above them. Moving the one
 * panel rather than drawing a second keeps the page saying each thing
 * once.
 */
function placeSearchCallout()
{
    var panel = document.querySelector(".search-callout");
    var beside = document.querySelector(".opposite-container");
    var reading = document.querySelector(".center-container");
    if (!panel || !reading) {
        return;
    }
    var narrow = (window.innerWidth <= SEARCH_PANEL_NARROW);
    if (narrow && panel.parentNode !== reading) {
        var after = reading.querySelector(".display-ad");
        var ads = reading.querySelectorAll("[class*='advertisement']");
        if (ads.length > 0) {
            after = ads[ads.length - 1];
        }
        if (after && after.parentNode === reading) {
            reading.insertBefore(panel, after.nextSibling);
        } else {
            reading.insertBefore(panel, reading.firstChild);
        }
        return;
    }
    if (!narrow && beside && panel.parentNode !== beside) {
        beside.appendChild(panel);
    }
}
/*
 * Copies what a block of kept words holds into every list that names
 * it, which is how a list written into a page comes by its choices. A
 * page does this once as it opens; the preview beside an editor does it
 * again each time it draws, since what it drew is new each time.
 *
 * @param Object root the part of the page to look through
 */
function fillCsvBlockUsers(root)
{
    if (!root) {
        return;
    }
    var users = root.querySelectorAll('*[data-block]');
    for (var index = 0; index < users.length; index++) {
        var user = users[index];
        var named = user.getAttribute('data-block');
        if (!named) {
            continue;
        }
        var block = root.querySelector('#' + CSS.escape(named)) ||
            document.getElementById(named);
        if (block) {
            user.innerHTML = block.innerHTML;
        }
    }
}
/*
 * 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();
   });
}

/*
 * Builds the address a file kept with a wiki page is asked for at. The
 * site serves such a file two ways: as a path, which needs the pretty
 * addresses to be switched on, and as a query, which works either way.
 * Which is used has to match the site, since the path form is not
 * understood when pretty addresses are off and the query form is what
 * the site falls back on.
 *
 * @param String group_id which group the page belongs to
 * @param String page_id which page the file is kept with
 * @param String name what the file is called
 * @param String token_key what the mark guarding a request is called
 * @param String token_value the mark itself, empty where none is needed
 * @param bool pretty whether the site serves pretty addresses
 * @param String base where the site is rooted, since a path form read
 *      against the page being edited would point under that page
 * @return String the address to ask for the file at
 */
function wikiResourceUrl(group_id, page_id, name, token_key, token_value,
    pretty, base)
{
    var group = (group_id === undefined || group_id === null) ? "" :
        group_id;
    var page = (page_id === undefined || page_id === null) ? "" : page_id;
    if (pretty) {
        /* A private group's file needs the mark; a public one is served
           without it, and a dash stands where no mark is given. The
           address is rooted at the site, since read against the page
           being edited it would point under that page rather than at
           the site's own file route. */
        var carried = (token_key && token_value) ?
            token_key + "=" + token_value : "-";
        var root = (base === undefined || base === null) ? "/" : base;
        if (root.charAt(root.length - 1) !== "/") {
            root += "/";
        }
        return root + "wd/resources/" + carried + "/" + group + "/" +
            page + "/" + name;
    }
    var query = "?c=resource&a=get&f=resources&g=" + group + "&p=" + page +
        "&n=" + name;
    if (token_key && token_value) {
        query += "&" + token_key + "=" + token_value;
    }
    return query;
}
/*
 * Gives the address of the small picture standing for a file kept with a
 * page. The site keeps such a picture beside the file rather than in it,
 * so a document has one as much as a photograph does, and it is asked
 * for at the file's own address with the word resources changed for
 * thumbs, or with that word carried alongside where the site does not
 * serve pretty addresses.
 *
 * @param String resource_url the address of the file itself
 * @param bool pretty whether the site serves pretty addresses
 * @return String the address of the small picture standing for it
 */
function wikiThumbUrl(resource_url, pretty)
{
    if (pretty) {
        return String(resource_url).replace("wd/resources", "wd/thumbs");
    }
    return resource_url + "&t=thumbs";
}
/*
 * Writes a date the way the site writes one into a form, so what a
 * reader would send is what a writer sees in the preview.
 *
 * @param Object moment the day and time to write
 * @return String the date written out
 */
function wikiWrittenDate(moment)
{
    var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
    var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug",
        "Sep", "Oct", "Nov", "Dec"];
    var pad = function (value)
    {
        return ("0" + value).slice(-2);
    };
    var away = -moment.getTimezoneOffset();
    var sign = (away < 0) ? "-" : "+";
    away = Math.abs(away);
    return days[moment.getDay()] + ", " + pad(moment.getDate()) + " " +
        months[moment.getMonth()] + " " + moment.getFullYear() + " " +
        pad(moment.getHours()) + ":" + pad(moment.getMinutes()) + ":" +
        pad(moment.getSeconds()) + " " + sign + pad(Math.floor(away / 60)) +
        pad(away % 60);
}
/*
 * Fills in the standing-in marks a page carries once it has been read,
 * the way the site fills them when it shows the page to a reader. A
 * preview is meant to show what a reader would see, and a reader coming
 * to a form fresh sees empty fields and nothing ticked, so those marks
 * come out empty rather than being shown as words. The date is the day
 * the page is being looked at.
 *
 * @param String html the page as read so far
 * @return String the same, with the standing-in marks filled
 */
function fillWikiStandIns(html)
{
    var today = new Date();
    var written = today.getFullYear() + "-" +
        ("0" + (today.getMonth() + 1)).slice(-2) + "-" +
        ("0" + today.getDate()).slice(-2);
    return String(html)
        .replace(/\[\{csv-checked-[^}]*\}\]/g, "")
        .replace(/\[\{csv-[^}]*\}\]/g, "")
        .replace(/\[\{(?:presubmit|onsubmit)\|[^}]*\}\]/g, "")
        .replace(/\[\{(?:require-signin|secret-ballot)\|[^}]*\}\]/g, "")
        .replace(/\[\{(?:share-edited-form|user-record-form)\}\]/g, "")
        .replace(/\[\{hash-record-form\}\]/g, "");
}
/*
 * How wide a plain field is when a page does not say, matching what the
 * site uses.
 */
var WIKI_FORM_FIELD_WIDTH = 64;
/*
 * How much a writing box holds when a page does not say, matching what
 * the site uses.
 */
var WIKI_FORM_AREA_WIDTH = 4096;
/*
 * Draws one field of a form written into a page, so a writer sees the
 * form taking shape rather than the words that ask for it. What comes
 * out matches what the site makes of the same words, placeholders and
 * all, since those are filled in later by whoever shows the page.
 *
 * @param String kind which sort of field is asked for
 * @param Array parts what the page gave between the bars
 * @return String the html of the field, or nothing where the kind is
 *      not one that stands on its own
 */
function wikiFormField(kind, parts)
{
    var named = String(kind).toLowerCase();
    var required = named.substring(0, 2) === "r-";
    var star = required ? "<span class='csv-star'>*</span>" : "";
    var label = parts[0] === undefined ? "" : parts[0];
    var field = parts[1] === undefined ? "" : parts[1];
    var hidden = function (under, says)
    {
        return "<input type='hidden' name='CSVFORM[" + under + "]' value='" +
            says + "' >";
    };
    if (named === "textfield" || named === "r-textfield") {
        var width = (parts.length >= 3) ? parts[2] : WIKI_FORM_FIELD_WIDTH;
        return "<div class='csv-form-field'><label for='" + field +
            "-id'>" + label + "</label><input id='" + field +
            "-id' type='text' name='" + field + "' maxlength='" + width +
            "' value='[{csv-" + field + "}]'>" + hidden(field, named) +
            star + "<span class='gray'  id='" + field + "-ctr' data-ctr='" +
            field + "-id'></span></div>";
    }
    if (named === "textarea" || named === "r-textarea") {
        var holds = (parts.length >= 3) ? parts[2] : WIKI_FORM_AREA_WIDTH;
        var counted = required ? "" : "data-count-field='true' ";
        var opener = required ? star : "<br>";
        return "<div class='csv-form-field'><label for='" + field +
            "-id'>" + label + "</label>" + opener + "<textarea id='" +
            field + "-id' name='" + field + "' " + counted +
            "class='short-text-area' maxlength='" + holds + "'>[{csv-" +
            field + "}]</textarea><div class='gray align-opposite'  id='" +
            field + "-ctr' data-ctr='" + field + "-id'></div>" +
            hidden(field, named) + "</div>";
    }
    if (named === "checkbox" || named === "r-checkbox" ||
        named === "lcheckbox" || named === "r-lcheckbox") {
        var stands = (named.indexOf("r-") === 0) ? "r-checkbox" : "checkbox";
        var box = "<input id='" + field + "-id' type='checkbox' name='" +
            field + "' [{csv-checked-" + field + "}] >";
        var says_it = "<label for='" + field + "-id'>" + label + "</label>";
        /* The l forms put the words after the box rather than before
           it, which is how the site draws them. */
        var ordered = (named.indexOf("lcheckbox") >= 0) ?
            box + says_it : says_it + box;
        return "<div class='csv-form-field'>" + ordered +
            hidden(field, stands) + "</div>";
    }
    if (named === "radio" || named === "r-radio" || named === "lradio" ||
        named === "r-lradio") {
        var value = parts[2] === undefined ? "" : parts[2];
        var says = (named.indexOf("r-") === 0) ? "r-radio" : "radio";
        var button = "<input id='" + field + "-" + value +
            "-id' type='radio' name='" + field + "' value='" + value +
            "' [{csv-checked-" + field + "}] >";
        var named_it = "<label for='" + field + "-" + value + "-id'>" +
            label + "</label>";
        var laid = (named.indexOf("lradio") >= 0) ?
            button + named_it : named_it + button;
        return "<div class='csv-form-field'>" + laid +
            hidden(field, says) + "</div>";
    }
    /* A list to pick from is opened by one template, filled by an option
       apiece, and closed by another, so each stands on its own here as
       it does on the site. */
    if (named === "dropdown" || named === "r-dropdown") {
        var wants = (named === "r-dropdown") ? "r-textfield" : "textfield";
        return "<div class='csv-form-field'>" + hidden(label, wants) +
            "<select name='" + label + "' data-value='[{csv-" + label +
            "}]' data-block='" + field + "'>";
    }
    if (named === "sorter" || named === "r-sorter") {
        return "<div class='csv-form-field'>" + hidden(label, "sorter") +
            "<select name='" + label + "' data-sortable='true' " +
            "data-value='[{csv-" + label + "}]' data-block='" + field +
            "'>";
    }
    if (named === "choosek" || named === "r-choosek") {
        var cutoff = parts[2] === undefined ? "" : parts[2];
        return "<div class='csv-form-field'>" + hidden(label, "choosek") +
            "<select name='" + label + "' data-sortable='true' " +
            "data-value='[{csv-" + label + "}]' data-cutoff='" + field +
            "' data-block='" + cutoff + "'>";
    }
    if (named === "option") {
        return "<option value='" + field + "'>" + label + "</option>";
    }
    if (named === "end-r-dropdown") {
        return "</select><span class='csv-star'>*</span></div>";
    }
    if (named === "end-dropdown" || named === "end-sorter" ||
        named === "end-choosek") {
        return "</select></div>";
    }
    if (named === "data-block") {
        return "<x-data-block style='display:none' id='" + label + "' >";
    }
    if (named === "end-data-block") {
        return "</x-data-block>";
    }
    /* A word that stands for something the page does rather than
       something it shows is left as the site leaves it, for whoever
       shows the page to fill in. */
    var markers = {"require-signin": true, "secret-ballot": true,
        "presubmit": true, "onsubmit": true};
    var bare = {"share-edited-form": true, "user-record-form": true,
        "hash-record-form": true};
    if (markers[named] !== undefined) {
        return "[{" + named + "|" + label + "}]";
    }
    if (bare[named] !== undefined) {
        return "[{" + named + "}]";
    }
    if (named === "submit") {
        return "<div class='csv-form-field'><input id='" + label +
            "-id' type='submit' name='" + field + "' value='" + label +
            "' >" + hidden(label, "submit") + "</div>";
    }
    /* The date, the reader's name and the moment are carried along
       with a form without being shown, so the site writes each as a
       field kept out of sight. The date is written the way the site
       writes it, so what a reader would send is what shows here. */
    if (named === "date" || named === "username" ||
        named === "timestamp") {
        var under = (label === "") ? named : label;
        var carried = "";
        if (named === "date") {
            carried = wikiWrittenDate(new Date());
        } else if (named === "timestamp") {
            carried = Math.floor(Date.now() / 1000);
        } else {
            carried = "[{username}]";
        }
        return "<input type='hidden' name='" + under + "' value='" +
            carried + "' >" + hidden(under, "textfield") + "";
    }
    return "";
}
/*
 * Global used by initializeFileHandler fileUploadSubmit to determine if
 * a submit event has already occurred
 * @var Boolean
 */
was_submitted = false;
/*
 * Whether what comes back from a send is to be left unshown. A file
 * added while a page is being written goes up on its own, and the writer
 * stays where they were rather than having the reply put on screen in
 * place of the work in front of them.
 * @var Boolean
 */
upload_keeps_page = 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;
        }
        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" ||
                element.type == "radio") {
                /* One of a set of buttons stands for the whole set, so
                   only the one chosen is sent. Sending them all left the
                   last of them standing for the choice, whichever the
                   person had picked. */
                if (element.checked) {
                    form_data.append(element.name, element.value);
                }
            } else if (element.name !== "") {
                /* An element with no name has nothing to be sent under.
                   The unnamed buttons around the message field were each
                   adding an empty part to every send. */
                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);
    }
    /**
     * 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);
            }
            /* The words naming the file go into the page at once, but
               the file itself waits for someone to press save. Where the
               page keeps itself there is no such press, so the file
               would never arrive and the page would name something that
               was not there. */
            if (files.length > 0 &&
                typeof wikiAutoSaveIsOn === "function" &&
                wikiAutoSaveIsOn()) {
                if (typeof noteWikiPlace === "function") {
                    noteWikiPlace();
                }
                upload_keeps_page = true;
                fileUploadSubmit(null);
            }
        } 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)
    {
        /* The message has gone, so what was written and any recording
           made for it are cleared. This happens on the reply rather than
           on sending, since clearing while the request is still going
           takes away what it is reading. */
        clearSentMessage();
        if (upload_keeps_page) {
            /* The writer is in the middle of a page and asked for
               nothing but the file to go up, so what comes back is not
               put on screen in place of what they are looking at. The
               mark guarding the next save is taken from it, since the
               one in the form is now older than the page. */
            upload_keeps_page = false;
            was_submitted = false;
            /* The count of how much had gone up was written where a
               message goes, and nothing came along afterwards to take
               it away, since the page it would have gone with is the
               one being kept. */
            var progress = elt('message');
            if (progress) {
                progress.innerHTML = "";
            }
            if (typeof takeFreshWikiToken === "function") {
                takeFreshWikiToken(parent_form,
                    event.target.responseText);
            }
            return;
        }
        /* 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>');
    }
}
/**
 * The names of the resources currently picked out in the resource listing,
 * in the order they appear. Shared so that both the keyboard shortcuts and
 * a drag onto a folder act on the whole picked set rather than one row.
 *
 * @return Array names of the picked resources
 */
function selectedResourceNames()
{
    let names = [];
    let picked = sel(".selected-resource[data-resource-name]");
    for (let i = 0; i < picked.length; i++) {
        names.push(attr(picked[i], "data-resource-name"));
    }
    return names;
}
/**
 * The resources a drag is carrying: the whole picked set when the row being
 * dragged is one of them, otherwise just that row. Dragging one of several
 * chosen rows moves all of them, so what moves matches what is shown as
 * chosen.
 *
 * @param String target name of the folder being dropped on, left out of the
 *      result so a folder is never asked to move into itself; pass the
 *      empty string when dropping somewhere that is not one of the rows
 * @return Array names of the resources the drag is carrying
 */
function draggedResourceNames(target)
{
    let names = selectedResourceNames();
    if (names.indexOf(dragged_resource_name) < 0) {
        names = [dragged_resource_name];
    }
    let carried = [];
    for (let i = 0; i < names.length; i++) {
        if (names[i] != target) {
            carried.push(names[i]);
        }
    }
    return carried;
}
/**
 * Lets a person pick out several resources at once and act on them from the
 * keyboard, the way a desktop file manager does.
 *
 * Clicking a row picks it and drops any earlier pick; holding Shift picks
 * everything between the last pick and this one; holding Control or Command
 * adds or removes the one row without disturbing the rest. Clicks landing on
 * a link, button, or text field are left alone so the row's own controls
 * keep working. With rows picked, Delete removes them after one
 * confirmation, F2 starts renaming when exactly one is picked, and Control
 * or Command with C or X puts them on the clipboard; Control or Command with
 * V pastes the clipboard into the folder being looked at, whether or not
 * anything is picked. Escape drops the selection.
 *
 * Copying or cutting empties the clipboard first, so it afterwards holds
 * what was just picked and nothing else, which is what pasting the last
 * thing copied depends on.
 *
 * Picking rows talks to no one. When an action is finally asked for, the
 * whole picked set goes to the server as one request naming all of them, so
 * nothing depends on several requests all arriving and answering.
 *
 * @param Object action_urls page edit urls the shortcuts are built on, as
 *      delete_url, clip_copy_url and clip_cut_url, each of which the list
 *      of picked names is added to, together with paste_all_url, which is
 *      used as it stands
 * @param String confirm_message what to ask before removing the picked
 *      resources, already in the reader's language
 */
function initResourceSelection(action_urls, confirm_message)
{
    let rows = sel("[data-resource-name]");
    if (rows.length == 0) {
        return;
    }
    let anchor_index = -1;
    let selectRow = function(row, picked)
    {
        if (picked) {
            row.classList.add("selected-resource");
        } else {
            row.classList.remove("selected-resource");
        }
    };
    let clearSelection = function()
    {
        for (let i = 0; i < rows.length; i++) {
            selectRow(rows[i], false);
        }
    };
    let rowIndex = function(row)
    {
        for (let i = 0; i < rows.length; i++) {
            if (rows[i] === row) {
                return i;
            }
        }
        return -1;
    };
    let ownControl = function(target)
    {
        let node = target;
        while (node && node != document) {
            let node_name = node.nodeName;
            if (node_name == "A" || node_name == "BUTTON" ||
                node_name == "INPUT" || node_name == "TEXTAREA" ||
                node_name == "SELECT" || node_name == "IFRAME") {
                return true;
            }
            node = node.parentNode;
        }
        return false;
    };
    let actOn = function(base_url, names)
    {
        window.location = base_url +
            encodeURIComponent(JSON.stringify(names));
    };
    listenAll("[data-resource-name]", "click", function(event)
    {
        if (ownControl(event.target)) {
            return;
        }
        let index = rowIndex(event.currentTarget);
        if (index < 0) {
            return;
        }
        if (event.shiftKey && anchor_index >= 0) {
            let low = Math.min(anchor_index, index);
            let high = Math.max(anchor_index, index);
            clearSelection();
            for (let i = low; i <= high; i++) {
                selectRow(rows[i], true);
            }
            return;
        }
        if (event.ctrlKey || event.metaKey) {
            selectRow(rows[index],
                !rows[index].classList.contains("selected-resource"));
        } else {
            clearSelection();
            selectRow(rows[index], true);
        }
        anchor_index = index;
    });
    listen(document, "keydown", function(event)
    {
        if (ownControl(event.target)) {
            return;
        }
        if (event.key == "Escape") {
            clearSelection();
            anchor_index = -1;
            return;
        }
        let held = (event.ctrlKey || event.metaKey);
        if (held && (event.key == "v" || event.key == "V")) {
            event.preventDefault();
            window.location = action_urls.paste_all_url;
            return;
        }
        let names = selectedResourceNames();
        if (names.length == 0) {
            return;
        }
        if (event.key == "Delete" || event.key == "Backspace") {
            event.preventDefault();
            if (confirm(confirm_message)) {
                actOn(action_urls.delete_url, names);
            }
        } else if (event.key == "F2") {
            event.preventDefault();
            let fields = sel(".selected-resource input[type=text]");
            if (names.length == 1 && fields.length > 0) {
                fields[0].focus();
                fields[0].select();
            }
        } else if (held && (event.key == "c" || event.key == "C")) {
            event.preventDefault();
            actOn(action_urls.clip_copy_url, names);
        } else if (held && (event.key == "x" || event.key == "X")) {
            event.preventDefault();
            actOn(action_urls.clip_cut_url, names);
        }
    });
}
/* 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 = "";
/**
 * Shows which resources a drag is carrying while it is under way: every row
 * being carried is dimmed, and when there is more than one the thing that
 * follows the pointer says how many, rather than picturing only the row the
 * drag started from.
 *
 * @param Object event the dragstart event, whose dataTransfer is told what
 *      to show under the pointer
 * @param Array names the resources the drag is carrying
 * @param String dragged_label the word for what is being carried, already
 *      in the reader's language, as in "3 items"
 */
function showResourcesDragging(event, names, dragged_label)
{
    let rows = sel("[data-resource-name]");
    for (let i = 0; i < rows.length; i++) {
        if (names.indexOf(attr(rows[i], "data-resource-name")) >= 0) {
            rows[i].classList.add("dragging-resource");
        }
    }
    if (names.length < 2 || !event.dataTransfer ||
        !event.dataTransfer.setDragImage) {
        return;
    }
    let badge = ce("div");
    badge.className = "resource-drag-count";
    badge.textContent = names.length + " " + dragged_label;
    document.body.appendChild(badge);
    event.dataTransfer.setDragImage(badge, 0, 0);
    setTimeout(function()
    {
        document.body.removeChild(badge);
    }, 0);
}
/**
 * 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 resources being carried and the target folder are
 *      appended to it to carry out the move
 * @param String dragged_label the word for what is being carried, already
 *      in the reader's language, shown as "3 items" while dragging several
 */
function initResourceDragDrop(move_base_url, dragged_label)
{
    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 = attr(row, "data-resource-name");
            event.dataTransfer.effectAllowed = "move";
            showResourcesDragging(event, draggedResourceNames(""),
                dragged_label);
        });
        listen(row, "dragend", function(event)
        {
            dragged_resource_name = "";
            let dragging = sel(".dragging-resource");
            for (let k = 0; k < dragging.length; k++) {
                dragging[k].classList.remove("dragging-resource");
            }
        });
        suspendRowDragWithinFields(row);
    }
    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);
        });
    }
}
/**
 * Lets a reader select the text in a row's name field. A resource row is
 * draggable so it can be dropped onto a folder, and a drag that begins
 * anywhere in the row, the name field included, is the row being
 * dragged; that leaves no way to sweep the pointer across the name to
 * select part of it for renaming. Pressing in the field now turns the
 * row's dragging off for as long as the pointer is down, so the sweep
 * selects text as it would in any other field, and dragging the row from
 * anywhere else still works.
 *
 * @param Object row the resource row element to wire up
 */
function suspendRowDragWithinFields(row)
{
    let fields = row.querySelectorAll("input, textarea");
    for (let i = 0; i < fields.length; i++) {
        listen(fields[i], "pointerdown", function(event)
        {
            row.setAttribute("draggable", "false");
        });
    }
    if (fields.length > 0) {
        listen(document, "pointerup", function(event)
        {
            row.setAttribute("draggable", "true");
        });
    }
}
/**
 * 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 names = draggedResourceNames("");
    dragged_resource_name = "";
    if (names.length == 0) {
        return;
    }
    window.location = move_base_url + "&move_resources=" +
        encodeURIComponent(JSON.stringify(names)) +
        "&move_to_path=" + encodeURIComponent(target_path);
}
/**
 * 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 names = draggedResourceNames(target);
        dragged_resource_name = "";
        if (names.length > 0) {
            window.location = move_base_url + "&move_resources=" +
                encodeURIComponent(JSON.stringify(names)) +
                "&move_target=" + encodeURIComponent(target);
        }
        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();
}
/*
 * Empties the message area once a message has gone. Whatever was written,
 * and any recording made for it, belongs to the message that was sent, so
 * leaving them behind means the next message carries them again.
 */
function clearSentMessage()
{
    let written = elt("new-message");
    if (written) {
        written.value = "";
        written.dispatchEvent(new Event("input"));
    }
    audio_blob = null;
    current_audio_filename = null;
    words_heard_in_message = "";
    words_heard = "";
    words_unsettled = "";
    /* The guard against double submission is let go with the rest, so
       a second message on the same page load is not silently held. */
    was_submitted = false;
    file_list = new Array();
    let controls = elt("audio-controls");
    if (controls) {
        controls.style.display = "none";
    }
}
/**
 * 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;
    }
    /* The recording carries the type it was actually made in. Safari
       records mp4 and Firefox webm, and labelling every recording webm
       stored the wrong type, so a Safari recording would not play. */
    const audio_file = new File([audio_blob], current_audio_filename, {
        type: audio_blob.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;
/*
 * How many characters of a transcription the browser sends.
 */
var MOST_HEARD = 4096;
/*
 * What was heard while the recording waiting to be sent was made. It
 * travels inside the message itself, as the recording's description, so
 * no separate file of words is ever made or sent.
 */
var words_heard_in_message = "";
/*
 * 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;
/*
 * The name the recording waiting to be sent will have once the site has
 * made it over into the one kind every browser plays. The message
 * points at this name rather than at the kind the browser wrote.
 */
var current_audio_played_name = null;
/*
 * Shows or hides what was said in a recording, under the recording
 * itself. The words travel inside the message as the recording's
 * description and were put on the page by addTranscriptToggles, so
 * showing them asks the server for nothing.
 *
 * @param String media_id which recording on the page
 */
function showTranscript(media_id)
{
    let holder = elt("transcript-" + media_id);
    if (!holder) {
        return;
    }
    holder.classList.toggle("show");
}
/*
 * Tidies the words the browser heard so they can sit inside the
 * message markup: the character that separates a resource from its
 * description is taken out, a pair of closing parentheses is split so
 * it cannot end the markup early, and runs of whitespace become one
 * space.
 *
 * @param String said what the browser heard
 * @return String the words, safe to carry as a resource description
 */
function cleanTranscript(said)
{
    return said.replace(/\|/g, " ").replace(/\)\)/g, ") )")
        .replace(/\s+/g, " ").trim();
}
/*
 * Puts a toggle beside each recording in a container that reveals the
 * words the recording carried as its description. The words are read
 * from the page itself, being the text a browser would show if it
 * could not play the media, so nothing is fetched and the server
 * computes nothing. A recording already given its toggle is passed
 * over, so this can be called again as new messages arrive.
 *
 * @param Object container element holding rendered messages
 */
function addTranscriptToggles(container)
{
    if (!container) {
        return;
    }
    let media_list = container.querySelectorAll(
        ".message-detail audio, .message-detail video");
    for (let media of media_list) {
        if (media.getAttribute("data-transcript-done")) {
            continue;
        }
        media.setAttribute("data-transcript-done", "true");
        let said = "";
        for (let child of media.childNodes) {
            if (child.nodeType === Node.TEXT_NODE) {
                said += child.textContent;
            }
        }
        said = said.replace(/\s+/g, " ").trim();
        if (said === "") {
            continue;
        }
        if (!media.id) {
            media.id = "m" + crawlHash(said + media_list.length);
        }
        let media_id = media.id;
        let toggle = document.createElement("button");
        toggle.className = "transcribe-btn";
        toggle.type = "button";
        toggle.title = tl["basic_js_show_transcript"];
        toggle.textContent = "\u{1F4DD}";
        toggle.addEventListener('click', function ()
        {
            showTranscript(media_id);
        });
        let holder = document.createElement("div");
        holder.className = "transcript-container";
        holder.id = "transcript-" + media_id;
        holder.textContent = said;
        media.insertAdjacentElement('afterend', toggle);
        toggle.insertAdjacentElement('afterend', holder);
    }
}
/*
 * What the browser made of the speech in the recording being made.
 */
var words_heard = "";
/*
 * What the browser has made of the speech so far but has not settled on.
 */
var words_unsettled = "";
/*
 * Whether the browser has refused to listen when asked. A browser can
 * offer a listener and still decline to use it, and the refusal only
 * shows on asking, so it is remembered once it happens.
 */
var listening_refused = false;
/*
 * The browser's own listener, kept while a recording is being made.
 */
var word_listener = null;
/*
 * Sets the browser listening to what is being said while a recording is
 * made, so an audio message can carry its words with it.
 *
 * Every browser that can record can also listen, so this asks nothing of
 * the site: no program to install, no setting to configure, and nothing
 * sent anywhere the recording was not already going. A browser that will
 * not listen simply gives no words, and the recording is sent as before.
 */
function canListenForWords()
{
    if (listening_refused) {
        return false;
    }
    return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
}
/*
 * Starts the browser listening to what is said while a recording is
 * made, so the words can be offered beside it.
 */
function listenForWords()
{
    words_heard = "";
    words_unsettled = "";
    if (!canListenForWords()) {
        return;
    }
    let Listener = window.SpeechRecognition ||
        window.webkitSpeechRecognition;
    try {
        word_listener = new Listener();
    } catch (problem) {
        word_listener = null;
        return;
    }
    word_listener.continuous = true;
    /* What is heard part way through is kept as well as what is settled.
       The listener settles its last words after it is asked to stop, and
       the recording is sent before that arrives, so waiting only for
       settled words left nothing to send. */
    word_listener.interimResults = true;
    word_listener.onresult = function (event)
    {
        let unsettled = "";
        for (let index = event.resultIndex; index < event.results.length;
            index++) {
            if (event.results[index].isFinal) {
                words_heard += event.results[index][0].transcript;
            } else {
                unsettled += event.results[index][0].transcript;
            }
        }
        words_unsettled = unsettled;
    };
    word_listener.onerror = function (event)
    {
        word_listener = null;
        /* A browser can offer a listener and still refuse to use it.
           Firefox names one but will not listen, so what it says on
           being asked is taken as the answer, and what would ask it
           again is put away. */
        /* Only a plain refusal is taken as one. A browser that heard
           nothing, or that was stopped part way, says so with other
           words, and taking those for a refusal put the button away on
           a browser that listens perfectly well. */
        let refusals = ['not-allowed', 'service-not-allowed',
            'language-not-supported', 'audio-capture'];
        if (event && event.error &&
            refusals.indexOf(event.error) >= 0) {
            listening_refused = true;
            hideTranscribeButton();
        }
    };
    try {
        word_listener.start();
    } catch (problem) {
        word_listener = null;
        listening_refused = true;
        hideTranscribeButton();
    }
}
/*
 * Puts away what would ask for a recording's words, for a browser that
 * will not give them.
 */
function hideTranscribeButton()
{
    let button = elt('transcribe-audio-btn');
    if (button) {
        button.style.display = 'none';
    }
}
/*
 * Stops the browser listening and hands back what it heard, tidied and
 * bounded.
 *
 * @return String what was said, empty when nothing was heard
 */
function stopListeningForWords()
{
    if (word_listener !== null) {
        try {
            word_listener.stop();
        } catch (problem) {
            word_listener = null;
        }
        word_listener = null;
    }
    let said = (words_heard + " " + words_unsettled).replace(
        /\s+/g, " ").trim();
    if (said.length > MOST_HEARD) {
        said = said.substring(0, MOST_HEARD) + "...";
    }
    return said;
}
/*
 * The kinds of recording the page will make, best first. What matters
 * is not only whether the browser making the recording can write the
 * kind, but whether the browser receiving it can play it, so the kinds
 * every browser plays come first and the ones only some play come last.
 * mp4 and mp3 are named several ways, since a browser that will write
 * one may know it under only one of its names: the codecs part may be
 * quoted or bare, aac inside mp4 is mp4a.40.2, and mp3 inside mp4 is
 * mp4a.40.34, mp4a.69 or mp4a.6B. Each entry gives the kind and the
 * ending a file of it is named with.
 */
var RECORDING_KINDS = [
    {type: 'audio/mp4', extension: 'mp4'},
    {type: 'audio/mp4;codecs=mp4a.40.2', extension: 'mp4'},
    {type: 'audio/mp4;codecs="mp4a.40.2"', extension: 'mp4'},
    {type: 'audio/mp4;codecs=mp4a.40.5', extension: 'mp4'},
    {type: 'audio/mp4;codecs=mp4a.40.34', extension: 'mp4'},
    {type: 'audio/mp4;codecs=mp4a.69', extension: 'mp4'},
    {type: 'audio/mp4;codecs=mp4a.6B', extension: 'mp4'},
    {type: 'video/mp4;codecs=mp4a.40.2', extension: 'mp4'},
    {type: 'audio/mp4;codecs=Opus', extension: 'mp4'},
    {type: 'audio/mpeg', extension: 'mp3'},
    {type: 'audio/mp3', extension: 'mp3'},
    {type: 'audio/mpeg;codecs=mp3', extension: 'mp3'},
    {type: 'audio/aac', extension: 'aac'},
    {type: 'audio/aacp', extension: 'aac'},
    {type: 'audio/webm;codecs=opus', extension: 'webm'},
    {type: 'audio/webm', extension: 'webm'}
];

/*
 * Builds a recorder for the given sound, asking for the kinds the most
 * browsers can play before the ones fewer can. Each kind is tried by
 * building a recorder with it rather than only by asking whether it is
 * supported, since a browser may answer that question more narrowly
 * than it will actually act.
 *
 * @param Object stream the sound to record
 * @param Array kinds what to choose among, the page's own list when not
 *      given
 * @param Function build makes a recorder of a given kind, the browser's
 *      own maker when not given
 * @return Object the recorder made, with the kind and file ending it
 *      was made for, or null when no kind could be made
 */
function makeRecorderOfWidestKind(stream, kinds, build)
{
    let choices = kinds || RECORDING_KINDS;
    let maker = build || function (sound, type)
    {
        return new MediaRecorder(sound, {mimeType: type});
    };
    for (let choice of choices) {
        try {
            let recorder = maker(stream, choice.type);
            if (recorder) {
                return {recorder: recorder, type: choice.type,
                    extension: choice.extension};
            }
        } catch (problem) {
            continue;
        }
    }
    return 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 transcribe_button = elt('transcribe-audio-btn');
    const delete_button = elt('delete-audio-btn');
    if (!record_button) {
        /* Recording is not offered on this site, so there is nothing
           here to set up. */
        return;
    }
    if (transcribe_button && !canListenForWords()) {
        /* A browser that will not listen cannot turn a recording into
           words, so what would ask it to is not offered at all. */
        transcribe_button.style.display = 'none';
    }
    let is_recording = false;
    record_button.addEventListener('click', async () => {
        if (!is_recording) {
            try {
                const stream = await navigator.mediaDevices.getUserMedia({
                    audio: true
                });
                let made = makeRecorderOfWidestKind(stream);
                if (made === null) {
                    console.error("no suitable mimetype found for this device");
                    return;
                }
                var audio_type = made.type;
                extension = made.extension;
                media_recorder = made.recorder;
                audio_chunks = [];
                listenForWords();
                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}`;
                    /* The message points at the recording as it will
                       stand once the site has made it over into the one
                       kind every browser plays. A browser writes
                       whichever kind it can, and none of them writes the
                       same one. */
                    current_audio_played_name =
                        `audio_message_${timestamp}.mp4`;
                    audio_controls.style.display = 'inline-flex';
                    /* The recording is put on the list once, when it
                       is made. Adding it again as the message is sent
                       appended a second copy each time, so a message sent
                       after a few recordings carried all of them. */
                    addAudioToFileList();
                    // Update the description field with resource format
                    const description_field = elt('new-message');
                    /* Whatever the browser made of the speech travels
                       inside the message as the recording's description,
                       so a reader who cannot listen still knows what was
                       said, and the server keeps nothing beside the
                       recording. When nothing was heard the description
                       falls back to naming the file. */
                    words_heard_in_message = cleanTranscript(
                        stopListeningForWords());
                    /* A description that merely names the file says
                       nothing a reader cannot already see, so where
                       nothing was heard the recording goes on its
                       own. */
                    description_field.value =
                        `((resource:${current_audio_played_name}|` +
                        words_heard_in_message + `))`;
                    description_field.dispatchEvent(new Event('input'));
                });
                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();
        }
    });
    /*
     * Lets go of the recording being composed: stops any playback,
     * forgets the blob and its name, takes it off the upload list, and
     * hides the recording controls. The message field is left alone so
     * a caller can decide what goes there instead.
     */
    function discardRecording()
    {
        if (audio) {
            audio.pause();
            audio = null;
        }
        audio_blob = null;
        current_audio_filename = null;
        if (file_list['file_new_message']) {
            file_list['file_new_message'] = [];
        }
        audio_controls.style.display = 'none';
    }
    transcribe_button.addEventListener('click', () => {
        /* The recording becomes its words: the audio is let go and the
           message field holds what the browser heard, which can be
           edited before sending. When nothing was heard there is
           nothing to become, so the recording is kept. */
        if (words_heard_in_message === "") {
            doMessage('<h1 class=\"red\" >' +
                tl["basic_js_no_transcript"] + '</h1>');
            return;
        }
        discardRecording();
        const description_field = elt('new-message');
        description_field.value = words_heard_in_message;
        description_field.dispatchEvent(new Event('input'));
        description_field.focus();
    });
    delete_button.addEventListener('click', () => {
        discardRecording();
        const description_field = elt('new-message');
        description_field.value = '';
        description_field.dispatchEvent(new Event('input'));
    });
}
/*
 * 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();
}
/**
 * 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
 * @param String field what the term is called in the address; when left
 *      out the term is added as repo_search
 */
function gitListSearch(event, base, field)
{
    if (event.key != "Enter") {
        return;
    }
    let name = field ? field : "repo_search";
    let term = event.target.value;
    window.location = base + "&" + name + "=" + encodeURIComponent(term);
}
/**
 * Lets the rows of the queue of waiting reports be picked the way rows are
 * picked elsewhere: a press picks one on its own, a press with shift
 * reaches from the last one picked to this one, and a press with the
 * platform's own modifier adds or removes one without disturbing the rest.
 * A press on a link inside a row follows the link instead. The numbers of
 * the picked rows are gathered into a hidden field just before the form is
 * sent, so a decision names all of them in one request.
 *
 * Pressing delete with rows picked asks first and then throws them away,
 * which is the quick way to clear a run of bogus reports.
 *
 * @param String form_id id of the form the picked rows are sent with
 * @param String confirm_message what to ask before throwing the picked
 *      reports away, already in the reader's language
 */
function initHeldSelection(form_id, confirm_message)
{
    let form = elt(form_id);
    let rows = sel("[data-held-number]");
    if (!form || rows.length == 0) {
        return;
    }
    let anchor_index = -1;
    let rowIndex = function(row)
    {
        for (let index = 0; index < rows.length; index++) {
            if (rows[index] === row) {
                return index;
            }
        }
        return -1;
    };
    let ownControl = function(target)
    {
        let node = target;
        while (node && node != document) {
            let node_name = node.nodeName;
            if (node_name == "A" || node_name == "BUTTON" ||
                node_name == "INPUT" || node_name == "SELECT") {
                return true;
            }
            node = node.parentNode;
        }
        return false;
    };
    listenAll("[data-held-number]", "click", function(event)
    {
        if (ownControl(event.target)) {
            return;
        }
        let row = event.currentTarget;
        let index = rowIndex(row);
        let extend = event.shiftKey && anchor_index >= 0;
        let add = event.ctrlKey || event.metaKey;
        if (!extend && !add) {
            for (let which = 0; which < rows.length; which++) {
                rows[which].classList.remove("selected-resource");
            }
            row.classList.add("selected-resource");
            anchor_index = index;
            return;
        }
        if (extend) {
            let from = Math.min(anchor_index, index);
            let to = Math.max(anchor_index, index);
            for (let which = from; which <= to; which++) {
                rows[which].classList.add("selected-resource");
            }
            return;
        }
        row.classList.toggle("selected-resource");
        anchor_index = index;
    });
    let pickedNumbers = function()
    {
        let picked = [];
        for (let index = 0; index < rows.length; index++) {
            if (rows[index].classList.contains("selected-resource")) {
                picked.push(attr(rows[index], "data-held-number"));
            }
        }
        return picked;
    };
    listen(document, "keydown", function(event)
    {
        if (event.key != "Delete" && event.key != "Backspace") {
            return;
        }
        let target = event.target;
        let name = target ? target.nodeName : "";
        if (name == "INPUT" || name == "TEXTAREA" || name == "SELECT") {
            return;
        }
        let picked = pickedNumbers();
        if (picked.length == 0) {
            return;
        }
        event.preventDefault();
        if (!confirm(confirm_message)) {
            return;
        }
        elt("git-held-picked").value = picked.join(",");
        elt("git-held-action").value = "discard";
        form.submit();
    });
    listen(form, "submit", function()
    {
        let picked = [];
        for (let index = 0; index < rows.length; index++) {
            if (rows[index].classList.contains("selected-resource")) {
                picked.push(attr(rows[index], "data-held-number"));
            }
        }
        elt("git-held-picked").value = picked.join(",");
    });
}
/**
 * Opens or closes the catch of choices at the end of the discard control.
 * The catch closes again as soon as anything outside it is used, so it
 * cannot be left hanging open over the list beneath it.
 *
 * @param Object button the catch that was pressed
 */
function gitToggleDiscardMenu(button)
{
    let split = button.parentNode;
    let opening = !split.classList.contains("git-discard-open");
    let all = sel(".git-discard-split");
    for (let index = 0; index < all.length; index++) {
        all[index].classList.remove("git-discard-open");
    }
    if (opening) {
        split.classList.add("git-discard-open");
        button.setAttribute("aria-expanded", "true");
        /* The catch is placed against the window, so where it goes is
           worked out from where the control is standing now. It hangs
           below unless there is no room there, in which case it sits
           above. */
        let menu = split.querySelector(".git-discard-menu");
        if (menu) {
            let at = split.getBoundingClientRect();
            menu.style.minWidth = at.width + "px";
            menu.style.left = "auto";
            menu.style.right = (window.innerWidth - at.right) + "px";
            menu.style.top = at.bottom + "px";
            menu.style.bottom = "auto";
            let drop = menu.getBoundingClientRect();
            if (drop.bottom > window.innerHeight && at.top > drop.height) {
                menu.style.top = "auto";
                menu.style.bottom = (window.innerHeight - at.top) + "px";
            }
        }
        setTimeout(function ()
        {
            listen(document, "click", gitCloseDiscardMenu);
        }, 0);
    } else {
        button.setAttribute("aria-expanded", "false");
    }
}
/**
 * Closes any open catch of discard choices and stops listening for the
 * press that closed it.
 */
function gitCloseDiscardMenu()
{
    let all = sel(".git-discard-split");
    for (let index = 0; index < all.length; index++) {
        all[index].classList.remove("git-discard-open");
        let catches = all[index].querySelectorAll(".git-discard-catch");
        for (let which = 0; which < catches.length; which++) {
            catches[which].setAttribute("aria-expanded", "false");
        }
    }
    unlisten(document, "click", gitCloseDiscardMenu);
}
/**
 * How near the end of a scrolled list, in pixels, brings the next page in.
 * Far enough that the rows are usually there before the end is reached.
 */
var GIT_SCROLL_PAGER_MARGIN = 80;
/**
 * Sets a list going as one that fetches its next page when it has been
 * scrolled near its end, so a long list is read by scrolling rather than
 * by turning pages. The rows that come back are added to the table already
 * on the page, and fetching stops once every row has been asked for.
 *
 * @param String table_id id of the table rows are added to
 * @param String base_url address a page of rows is asked for under, ending
 *      in the name of the field the starting row is given in
 * @param int start which row the next page begins at
 * @param int total how many rows there are altogether
 * @param int per_page how many rows a page holds
 */
function initGitScrollPager(table_id, base_url, start, total, per_page)
{
    let table = elt(table_id);
    if (!table) {
        return;
    }
    let box = table.parentNode;
    let fetching = false;
    let next_start = start;
    let fillBox = null;
    let addRows = function (text)
    {
        /* Rows are read back inside a table, which is the only place a
           browser is required to parse them as rows: read into anything
           else they may be dropped or rearranged, and what came out then
           differs between browsers. */
        let holder = ce("table");
        holder.innerHTML = "<tbody>" + text + "</tbody>";
        /* Only rows of the list itself are taken. An answer that carried
           anything else, a heading among it above all, would otherwise
           read as the list starting over part way down. */
        let arrived = holder.querySelectorAll("tr.git-issue-row");
        let body = table.tBodies.length > 0 ? table.tBodies[0] : table;
        for (let index = 0; index < arrived.length; index++) {
            body.appendChild(arrived[index]);
        }
        next_start += per_page;
        fetching = false;
        /* An answer carrying none of the list's rows means there is no
           more of the list to be had, whatever it did carry, so asking
           again would only repeat itself. */
        if (arrived.length == 0) {
            next_start = total;
            return;
        }
        fillBox();
    };
    let morePage = function ()
    {
        if (fetching || next_start >= total) {
            return;
        }
        fetching = true;
        getPage(null, base_url + next_start + "&f=api", addRows,
            function ()
            {
                fetching = false;
            });
    };
    /* A page of rows may not be tall enough to fill the box, and a box
       with nothing to scroll never reports a scroll, so pages are asked
       for until there is something to scroll or there is nothing left to
       ask for. */
    fillBox = function ()
    {
        if (box.scrollHeight <= box.clientHeight && next_start < total) {
            morePage();
        }
    };
    listen(box, "scroll", function ()
    {
        if (box.scrollTop + box.clientHeight >=
            box.scrollHeight - GIT_SCROLL_PAGER_MARGIN) {
            morePage();
        }
    });
    fillBox();
}
X