/**
 * app.js : User Interface implementation
 * This file handles the interaction between the application and the user
 *
 * Copyright 2013-2014 Mossroy and contributors
 * License GPL v3:
 *
 * This file is part of Kiwix.
 *
 * Kiwix 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.
 *
 * Kiwix 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 Kiwix (file LICENSE-GPLv3.txt).  If not, see <http://www.gnu.org/licenses/>
 */

'use strict';

// This uses require.js to structure javascript:
// http://requirejs.org/docs/api.html#define

define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesystemAccess'],
 function($, zimArchiveLoader, uiUtil, settingsStore, abstractFilesystemAccess) {

    /**
     * The delay (in milliseconds) between two "keepalive" messages sent to the ServiceWorker (so that it is not stopped
     * by the browser, and keeps the MessageChannel to communicate with the application)
     * @type Integer
     */
    const DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER = 30000;

    /**
     * The name of the Cache API cache to use for caching Service Worker requests and responses for certain asset types
     * We need access to the cache name in app.js in order to complete utility actions when Service Worker is not initialized,
     * so we have to duplicate it here
     * @type {String}
     */
    // DEV: Ensure this matches the name defined in service-worker.js (a check is provided in refreshCacheStatus() below)
    const ASSETS_CACHE = 'kiwixjs-assetsCache';

    /**
     * Memory cache for CSS styles contained in ZIM: it significantly speeds up subsequent page display
     * This cache is used by default in jQuery mode, but can be turned off in Configuration for low-memory devices
     * In Service Worker mode, the Cache API will be used instead
     * @type {Map}
     */
    var cssCache = new Map();

    /**
     * A global object for storing app state
     *
     * @type Object
     */
    var appstate = {};

    /**
     * @type ZIMArchive
     */
    var selectedArchive = null;

    /**
     * Set parameters from the Settings Store, together with any defaults
     * Note that the params global object is declared in init.js so that it is available to modules
     * WARNING: Only change these paramaeters if you know what you are doing
     */
    // The current version number of this app
    params['appVersion'] = '3.8.0'; // **IMPORTANT** Ensure this is the same as the version number in service-worker.js
    // The PWA server (currently only for use with the Mozilla extension)
    params['PWAServer'] = 'https://moz-extension.kiwix.org/current/'; // Include final slash!
    // params['PWAServer'] = 'https://kiwix.github.io/kiwix-js/'; // DEV: Uncomment this line for testing code on GitHub Pages
    // params['PWAServer'] = 'http://localhost:8080/'; // DEV: Uncomment this line (and adjust) for local testing
    // A parameter to determine the Settings Store API in use
    params['storeType'] = settingsStore.getBestAvailableStorageAPI();
    params['hideActiveContentWarning'] = settingsStore.getItem('hideActiveContentWarning') === 'true';
    params['showUIAnimations'] = settingsStore.getItem('showUIAnimations') ? settingsStore.getItem('showUIAnimations') === 'true' : true;
    // Maximum number of article titles to return (range is 5 - 50, default 25)
    params['maxSearchResultsSize'] = settingsStore.getItem('maxSearchResultsSize') || 25;
    // Turns caching of assets on or off and deletes the cache (it defaults to true unless explicitly turned off in UI)
    params['assetsCache'] = settingsStore.getItem('assetsCache') !== 'false';
    // Turns caching of the PWA's code on or off and deletes the cache (it defaults to true unless the bypass option is set in Expert Settings)
    params['appCache'] = settingsStore.getItem('appCache') !== 'false';
    // A parameter to set the app theme and, if necessary, the CSS theme for article content (defaults to 'light')
    params['appTheme'] = settingsStore.getItem('appTheme') || 'light'; // Currently implemented: light|dark|dark_invert|dark_mwInvert|auto|auto_invert|auto_mwInvert|
    // A global parameter to turn on/off the use of Keyboard HOME Key to focus search bar
    params['useHomeKeyToFocusSearchBar'] = settingsStore.getItem('useHomeKeyToFocusSearchBar') === 'true';
    // A global parameter to turn on/off opening external links in new tab (for ServiceWorker mode)
    params['openExternalLinksInNewTabs'] = settingsStore.getItem('openExternalLinksInNewTabs') ? settingsStore.getItem('openExternalLinksInNewTabs') === 'true' : true;
    // A parameter to disable drag-and-drop
    params['disableDragAndDrop'] = settingsStore.getItem('disableDragAndDrop') === 'true';
    // A parameter to access the URL of any extension that this app was launched from
    params['referrerExtensionURL'] = settingsStore.getItem('referrerExtensionURL');
    // A parameter to keep track of the fact that the user has been informed of the switch to SW mode by default
    params['defaultModeChangeAlertDisplayed'] = settingsStore.getItem('defaultModeChangeAlertDisplayed');
    // A parameter to set the content injection mode ('jquery' or 'serviceworker') used by this app
    params['contentInjectionMode'] = settingsStore.getItem('contentInjectionMode') ||
        // Defaults to serviceworker mode when the API is available
        (isServiceWorkerAvailable() ? 'serviceworker' : 'jquery');
    // A parameter to circumvent anti-fingerprinting technology in browsers that do not support WebP natively by substituting images
    // directly with the canvas elements produced by the WebP polyfill [kiwix-js #835]. NB This is only currently used in jQuery mode.
    params['useCanvasElementsForWebpTranscoding']; // Value is determined in uiUtil.determineCanvasElementsWorkaround(), called when setting the content injection mode

    // An object to hold the current search and its state (allows cancellation of search across modules)
    appstate['search'] = {
        'prefix': '', // A field to hold the original search string
        'status': '',  // The status of the search: ''|'init'|'interim'|'cancelled'|'complete'
        'type': ''    // The type of the search: 'basic'|'full' (set automatically in search algorithm)
    };

    // A Boolean to store the update status of the PWA version (currently only used with Firefox Extension)
    appstate['pwaUpdateNeeded'] = false; // This will be set to true if the Service Worker has an update waiting

    /**
     * Apply any override parameters that might be in the querystring.
     * This is used for communication between the PWA and any local code (e.g. Firefox Extension), both ways.
     * It is also possible for DEV (or user) to launch the app with certain settings, or to unset potentially
     * problematic settings, by crafting the querystring appropriately.
     */
    (function overrideParams() {
        var regexpUrlParams = /[?&]([^=]+)=([^&]+)/g;
        var matches = regexpUrlParams.exec(window.location.search);
        while (matches) {
            if (matches[1] && matches[2]) {
                var paramKey = decodeURIComponent(matches[1]);
                var paramVal = decodeURIComponent(matches[2]);
                if (paramKey !== 'title') {
                    console.debug('Setting key-pair: ' + paramKey + ':' + paramVal);
                    // Make values Boolean if 'true'/'false'
                    paramVal = paramVal === 'true' || (paramVal === 'false' ? false : paramVal);
                    settingsStore.setItem(paramKey, paramVal, Infinity);
                    params[paramKey] = paramVal;
                }
            }
            matches = regexpUrlParams.exec(window.location.search);
        }
        // If we are in the PWA version launched from an extension, send a 'success' message to the extension
        if (params.referrerExtensionURL && ~window.location.href.indexOf(params.PWAServer)) {
            var message = '?PWA_launch=success';
            // DEV: To test failure of the PWA, you could pause on next line and set message to '?PWA_launch=fail'
            // Note that, as a failsafe, the PWA_launch key is set to 'fail' (in the extension) before each PWA launch
            // so we need to send a 'success' message each time the PWA is launched
            var frame = document.createElement('iframe');
            frame.id = 'kiwixComm';
            frame.style.display = 'none';
            document.body.appendChild(frame);
            frame.src = params.referrerExtensionURL + '/www/index.html' + message;
            // Now remove redundant frame. We cannot use onload, because it doesn't give time for the script to run.
            setTimeout(function () {
                var kiwixComm = document.getElementById('kiwixComm');
                // The only browser which does not support .remove() is IE11, but it will never run this code
                if (kiwixComm) kiwixComm.remove();
            }, 3000);
        }
    })();

    /**
     * Set the State and UI settings associated with parameters defined above
     */
    document.getElementById('hideActiveContentWarningCheck').checked = params.hideActiveContentWarning;
    document.getElementById('disableDragAndDropCheck').checked = params.disableDragAndDrop;
    document.getElementById('showUIAnimationsCheck').checked = params.showUIAnimations;
    document.getElementById('titleSearchRange').value = params.maxSearchResultsSize;
    document.getElementById('titleSearchRangeVal').textContent = params.maxSearchResultsSize;
    document.getElementById('appThemeSelect').value = params.appTheme;
    document.getElementById('useHomeKeyToFocusSearchBarCheck').checked = params.useHomeKeyToFocusSearchBar;
    document.getElementById('openExternalLinksInNewTabsCheck').checked = params.openExternalLinksInNewTabs;
    switchHomeKeyToFocusSearchBar();
    document.getElementById('bypassAppCacheCheck').checked = !params.appCache;
    document.getElementById('appVersion').textContent = 'Kiwix ' + params.appVersion;
    // We check here if we have to warn the user that we switched to ServiceWorkerMode
    // This is only needed if the ServiceWorker mode is available, or we are in a Firefox Extension that supports Service Workers
    // outside of the extension environment, AND the user's settings are stuck on jQuery mode, AND the user has not already been
    // alerted about the switch to ServiceWorker mode by default
    if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^moz-extension:/i.test(window.location.protocol))
        && params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) {
        // Attempt to upgrade user to ServiceWorker mode
        params.contentInjectionMode = 'serviceworker';
    } else if (params.contentInjectionMode === 'serviceworker') {
        // User is already in SW mode, so we will never need to display the upgrade alert
        params.defaultModeChangeAlertDisplayed = true;
        settingsStore.setItem('defaultModeChangeAlertDisplayed', true, Infinity);
    }
    setContentInjectionMode(params.contentInjectionMode);

    // Define globalDropZone (universal drop area) and configDropZone (highlighting area on Config page)
    var globalDropZone = document.getElementById('search-article');
    var configDropZone = document.getElementById('configuration');

    // Unique identifier of the article expected to be displayed
    var expectedArticleURLToBeDisplayed = "";
 
    // define and store dark preference for matchMedia
    var darkPreference = window.matchMedia('(prefers-color-scheme:dark)');
    // if 'prefers-color-scheme' is not supported in the browser, then the "auto" options are not displayed to the user
    if (window.matchMedia('(prefers-color-scheme)').media === 'not all') {
        var optionsToBeRemoved = document.getElementById("appThemeSelect").querySelectorAll('.auto');
        for (var i = 0; i < optionsToBeRemoved.length; i++) {
            optionsToBeRemoved[i].parentNode.removeChild(optionsToBeRemoved[i]);
        }
    }
    // Apply previously stored appTheme
    uiUtil.applyAppTheme(params.appTheme);

    // Whenever the system theme changes, call applyAppTheme function
    darkPreference.onchange = function() {
        uiUtil.applyAppTheme(params.appTheme);
    }

    /**
     * Resize the IFrame height, so that it fills the whole available height in the window
     */
    function resizeIFrame() {
        var headerStyles = getComputedStyle(document.getElementById('top'));
        var iframe = document.getElementById('articleContent');
        var region = document.getElementById('search-article');
        if (iframe.style.display === 'none') {
            // We are in About or Configuration, so we only set the region height
            region.style.height = window.innerHeight + 'px';
        } else {
            // IE cannot retrieve computed headerStyles till the next paint, so we wait a few ticks
            setTimeout(function() {
                // Get  header height *including* its bottom margin
                var headerHeight = parseFloat(headerStyles.height) + parseFloat(headerStyles.marginBottom);
                iframe.style.height = window.innerHeight - headerHeight + 'px';
                // We have to allow a minimum safety margin of 10px for 'iframe' and 'header' to fit within 'region'
                region.style.height = window.innerHeight + 10 + 'px';
            }, 100);
        }
    }
    $(document).ready(resizeIFrame);
    $(window).resize(resizeIFrame);

    // Define behavior of HTML elements
    var searchArticlesFocused = false;
    $('#searchArticles').on('click', function() {
        var prefix = document.getElementById('prefix').value;
        // Do not initiate the same search if it is already in progress
        if (appstate.search.prefix === prefix && !/^(cancelled|complete)$/.test(appstate.search.status)) return;
        document.getElementById('welcomeText').style.display = 'none';
        document.querySelector('.kiwix-alert').style.display = 'none';
        document.getElementById('searchingArticles').style.display = '';
        pushBrowserHistoryState(null, prefix);
        // Initiate the search
        searchDirEntriesFromPrefix(prefix);
        $('.navbar-collapse').collapse('hide');
        document.getElementById('prefix').focus();
        // This flag is set to true in the mousedown event below
        searchArticlesFocused = false;
    });
    $('#searchArticles').on('mousedown', function() {
        // We set the flag so that the blur event of #prefix can know that the searchArticles button has been clicked
        searchArticlesFocused = true;
    });
    $('#formArticleSearch').on('submit', function() {
        document.getElementById('searchArticles').click();
        return false;
    });
    // Handle keyboard events in the prefix (article search) field
    var keyPressHandled = false;
    $('#prefix').on('keydown', function(e) {
        // If user presses Escape...
        // IE11 returns "Esc" and the other browsers "Escape"; regex below matches both
        if (/^Esc/.test(e.key)) {
            // Hide the article list
            e.preventDefault();
            e.stopPropagation();
            document.getElementById('articleListWithHeader').style.display = 'none';
            document.getElementById('articleContent').focus();
            keyPressHandled = true;
        }
        // Arrow-key selection code adapted from https://stackoverflow.com/a/14747926/9727685
        // IE11 produces "Down" instead of "ArrowDown" and "Up" instead of "ArrowUp"
        if (/^((Arrow)?Down|(Arrow)?Up|Enter)$/.test(e.key)) {
            // User pressed Down arrow or Up arrow or Enter
            e.preventDefault();
            e.stopPropagation();
            // This is needed to prevent processing in the keyup event : https://stackoverflow.com/questions/9951274
            keyPressHandled = true;
            var activeElement = document.querySelector("#articleList .hover") || document.querySelector("#articleList a");
            if (!activeElement) return;
            // If user presses Enter, read the dirEntry
            if (/Enter/.test(e.key)) {
                if (activeElement.classList.contains('hover')) {
                    var dirEntryId = activeElement.getAttribute('dirEntryId');
                    findDirEntryFromDirEntryIdAndLaunchArticleRead(decodeURIComponent(dirEntryId));
                    return;
                }
            }
            // If user presses ArrowDown...
            // (NB selection is limited to five possibilities by regex above)
            if (/Down/.test(e.key)) {
                if (activeElement.classList.contains('hover')) {
                    activeElement.classList.remove('hover');
                    activeElement = activeElement.nextElementSibling || activeElement;
                    var nextElement = activeElement.nextElementSibling || activeElement;
                    if (!uiUtil.isElementInView(nextElement, true)) nextElement.scrollIntoView(false);
                }
            }
            // If user presses ArrowUp...
            if (/Up/.test(e.key)) {
                activeElement.classList.remove('hover');
                activeElement = activeElement.previousElementSibling || activeElement;
                var previousElement = activeElement.previousElementSibling || activeElement;
                if (!uiUtil.isElementInView(previousElement, true)) previousElement.scrollIntoView();
                if (previousElement === activeElement) document.getElementById('top').scrollIntoView();
            }
            activeElement.classList.add('hover');
        }
    });
    // Search for titles as user types characters
    $('#prefix').on('keyup', function(e) {
        if (selectedArchive !== null && selectedArchive.isReady()) {
            // Prevent processing by keyup event if we already handled the keypress in keydown event
            if (keyPressHandled)
                keyPressHandled = false;
            else
                onKeyUpPrefix(e);
        }
    });
    // Restore the search results if user goes back into prefix field
    $('#prefix').on('focus', function() {
        if (document.getElementById('prefix').value !== '')
            document.getElementById('articleListWithHeader').style.display = '';
    });
    // Hide the search results if user moves out of prefix field
    $('#prefix').on('blur', function() {
        if (!searchArticlesFocused) {
            appstate.search.status = 'cancelled';
            document.getElementById('searchingArticles').style.display = 'none';
            document.getElementById('articleListWithHeader').style.display = 'none';
        }
    });
    $('#btnRandomArticle').on('click', function() {
        document.getElementById('prefix').value = '';
        goToRandomArticle();
        document.getElementById('welcomeText').style.display = 'none';
        document.getElementById('articleListWithHeader').style.display = 'none';
        $('.navbar-collapse').collapse('hide');
    });

    $('#btnRescanDeviceStorage').on('click', function() {
        searchForArchivesInStorage();
    });
    // Bottom bar :
    $('#btnBack').on('click', function() {
        history.back();
        return false;
    });
    $('#btnForward').on('click', function() {
        history.forward();
        return false;
    });
    $('#btnHomeBottom').on('click', function() {
        document.getElementById('btnHome').click();
        return false;
    });
    $('#btnTop').on('click', function() {
        var articleContent = document.getElementById('articleContent');
        articleContent.contentWindow.scrollTo({top: 0, behavior: 'smooth'});
        // We return true, so that the link to #top is still triggered (useful in the About section)
        return true;
    });
    // Top menu :
    $('#btnHome').on('click', function() {
        // Highlight the selected section in the navbar
        document.getElementById('liHomeNav').setAttribute('class', 'active');
        document.getElementById('liConfigureNav').setAttribute('class', '');
        document.getElementById('liAboutNav').setAttribute('class', '');
        $('.navbar-collapse').collapse('hide');
        // Show the selected content in the page
        uiUtil.removeAnimationClasses();
        if (params.showUIAnimations) {
           uiUtil.applyAnimationToSection("home");
        } else {
            document.getElementById('articleContent').style.display = '';
            document.getElementById('about').style.display = 'none';
            document.getElementById('configuration').style.display = 'none';
        }
        document.getElementById('navigationButtons').style.display = '';
        document.getElementById('formArticleSearch').style.display = '';
        document.getElementById('welcomeText').style.display = '';
        // Give the focus to the search field, and clean up the page contents
        document.getElementById('prefix').value = '';
        document.getElementById('prefix').focus();
        var articleList = document.getElementById('articleList');
        var articleListHeaderMessage =  document.getElementById('articleListHeaderMessage');
        while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
        while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
        document.getElementById('searchingArticles').style.display = 'none';
        document.getElementById('articleContent').style.display = 'none';
        // Empty and purge the article contents
        var articleContent = document.getElementById('articleContent');
        var articleContentDoc = articleContent ? articleContent.contentDocument : null;
        while (articleContentDoc.firstChild) articleContentDoc.removeChild(articleContentDoc.firstChild);
        if (selectedArchive !== null && selectedArchive.isReady()) {
            document.getElementById('welcomeText').style.display = 'none';
            goToMainArticle();
        }
        // Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms
        setTimeout(resizeIFrame, 400);
        return false;
    });
    $('#btnConfigure').on('click', function() {
        // Highlight the selected section in the navbar
        document.getElementById('liHomeNav').setAttribute('class', '');
        document.getElementById('liConfigureNav').setAttribute('class', 'active');
        document.getElementById('liAboutNav').setAttribute('class', '');
        $('.navbar-collapse').collapse('hide');
        // Show the selected content in the page
        uiUtil.removeAnimationClasses();
        if (params.showUIAnimations) {
            uiUtil.applyAnimationToSection('config');
        } else {
            document.getElementById('about').style.display = 'none';
            document.getElementById('configuration').style.display = '';
            document.getElementById('articleContent').style.display = 'none';
        }
        document.getElementById('navigationButtons').style.display = 'none';
        document.getElementById('formArticleSearch').style.display = 'none';
        document.getElementById('welcomeText').style.display = 'none';
        document.getElementById('searchingArticles').style.display = 'none';
        document.querySelector('.kiwix-alert').style.display = 'none';
        refreshAPIStatus();
        refreshCacheStatus();
        uiUtil.checkUpdateStatus(appstate);
        // Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms
        setTimeout(resizeIFrame, 400);
        return false;
    });
    $('#btnAbout').on('click', function() {
        // Highlight the selected section in the navbar
        document.getElementById('liHomeNav').setAttribute('class', '');
        document.getElementById('liConfigureNav').setAttribute('class', '');
        document.getElementById('liAboutNav').setAttribute('class', 'active');
        $('.navbar-collapse').collapse('hide');
        // Show the selected content in the page
        uiUtil.removeAnimationClasses();
        if (params.showUIAnimations) {
            uiUtil.applyAnimationToSection("about");
        } else {
            document.getElementById('about').style.display = '';
            document.getElementById('configuration').style.display = 'none';
            document.getElementById('articleContent').style.display = 'none';
        }
        document.getElementById('navigationButtons').style.display = 'none';
        document.getElementById('formArticleSearch').style.display = 'none';
        document.getElementById('welcomeText').style.display = 'none';
        document.getElementById('articleListWithHeader').style.display = 'none';
        document.getElementById('searchingArticles').style.display = 'none';
        document.querySelector('.kiwix-alert').style.display = 'none';
        // Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms
        setTimeout(resizeIFrame, 400);
        return false;
    });
    $('input:radio[name=contentInjectionMode]').on('change', function() {
        // Do the necessary to enable or disable the Service Worker
        setContentInjectionMode(this.value);
    });
    document.getElementById('useCanvasElementsCheck').addEventListener('change', function () {
        if (this.checked) {
            // User can only *disable* this auto-determined setting, not force it on, so we do not store a value of true
            settingsStore.removeItem('useCanvasElementsForWebpTranscoding');
            uiUtil.determineCanvasElementsWorkaround();
            this.checked = params.useCanvasElementsForWebpTranscoding;
        } else {
            params.useCanvasElementsForWebpTranscoding = false;
            settingsStore.setItem('useCanvasElementsForWebpTranscoding', false, Infinity);
        }
    });
    document.getElementById('btnReset').addEventListener('click', function () {
        uiUtil.systemAlert('WARNING: This will reset the app to a freshly installed state, deleting all app caches and settings!', 'Warning!', true).then(function (response) {
            if (response) {
                settingsStore.reset();
            }
        })
    });
    document.getElementById('bypassAppCacheCheck').addEventListener('change', function () {
        if (params.contentInjectionMode !== 'serviceworker') {
            uiUtil.systemAlert('This setting can only be used in ServiceWorker mode!');
            this.checked = false;
        } else {
            params.appCache = !this.checked;
            settingsStore.setItem('appCache', params.appCache, Infinity);
            settingsStore.reset('cacheAPI');
        }
        // This will also send any new values to Service Worker
        refreshCacheStatus();
    });
    document.getElementById('disableDragAndDropCheck').addEventListener('change', function () {
        params.disableDragAndDrop = this.checked ? true : false;
        settingsStore.setItem('disableDragAndDrop', params.disableDragAndDrop, Infinity);
        uiUtil.systemAlert('<p>We will now attempt to reload the app to apply the new setting.</p>' + 
            '<p>(If you cancel, then the setting will only be applied when you next start the app.)</p>', 'Reload app', true).then(function (result) {
            if (result) {
                window.location.reload();
            }
        });
    });
    $('input:checkbox[name=hideActiveContentWarning]').on('change', function () {
        params.hideActiveContentWarning = this.checked ? true : false;
        settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity);
    });
    $('input:checkbox[name=showUIAnimations]').on('change', function () {
        params.showUIAnimations = this.checked ? true : false;
        settingsStore.setItem('showUIAnimations', params.showUIAnimations, Infinity);
    });
    $('input:checkbox[name=useHomeKeyToFocusSearchBar]').on('change', function () {
        params.useHomeKeyToFocusSearchBar = this.checked ? true : false;
        settingsStore.setItem('useHomeKeyToFocusSearchBar', params.useHomeKeyToFocusSearchBar, Infinity);
        switchHomeKeyToFocusSearchBar();
    });
    $('input:checkbox[name=openExternalLinksInNewTabs]').on('change', function () {
        params.openExternalLinksInNewTabs = this.checked ? true : false;
        settingsStore.setItem('openExternalLinksInNewTabs', params.openExternalLinksInNewTabs, Infinity);
    });
    document.getElementById('appThemeSelect').addEventListener('change', function (e) {
        params.appTheme = e.target.value;
        settingsStore.setItem('appTheme', params.appTheme, Infinity);
        uiUtil.applyAppTheme(params.appTheme);
    });
    document.getElementById('cachedAssetsModeRadioTrue').addEventListener('change', function (e) {
        if (e.target.checked) {
            settingsStore.setItem('assetsCache', true, Infinity);
            params.assetsCache = true;
            refreshCacheStatus();
        }
    });
    document.getElementById('cachedAssetsModeRadioFalse').addEventListener('change', function (e) {
        if (e.target.checked) {
            settingsStore.setItem('assetsCache', false, Infinity);
            params.assetsCache = false;
            // Delete all caches
            resetCssCache();
            if ('caches' in window) caches.delete(ASSETS_CACHE);
            refreshCacheStatus();
        }
    });
    var titleSearchRangeVal = document.getElementById('titleSearchRangeVal');
    document.getElementById('titleSearchRange').addEventListener('change', function(e) {
        settingsStore.setItem('maxSearchResultsSize', e.target.value, Infinity);
        params.maxSearchResultsSize = e.target.value;
        titleSearchRangeVal.textContent = e.target.value;
    });
    document.getElementById('titleSearchRange').addEventListener('input', function(e) {
        titleSearchRangeVal.textContent = e.target.value;
    });
    document.getElementById('modesLink').addEventListener('click', function () {
        document.getElementById('btnAbout').click();
        // We have to use a timeout or the scroll is cancelled by the slide transtion animation
        // @TODO This is a workaround. The regression should be fixed as it affects the Active content warning
        // links as well.
        setTimeout(function () {
            document.getElementById('modes').scrollIntoView();
        }, 600);
    });
    // Do update checks 10s after startup
    setTimeout(function () {
        console.log('Checking for updates to the PWA...');
        uiUtil.checkUpdateStatus(appstate);
    }, 10000);
    

    //Adds an event listener to kiwix logo and bottom navigation bar which gets triggered when these elements are dragged.
    //Returning false prevents their dragging (which can cause some unexpected behavior)
    //Doing that in javascript is the only way to make it cross-browser compatible
    document.getElementById('kiwixLogo').ondragstart=function () {return false;}
    document.getElementById('navigationButtons').ondragstart=function () {return false;}

    //focus search bar (#prefix) if Home key is pressed
    function focusPrefixOnHomeKey(event) {
        //check if home key is pressed
        if (event.key === 'Home') {
            // wait to prevent interference with scrolling (default action)
            setTimeout(function() {
                document.getElementById('prefix').focus();
            },0);
        }
    }
    //switch on/off the feature to use Home Key to focus search bar
    function switchHomeKeyToFocusSearchBar() {
        var iframeContentWindow = document.getElementById('articleContent').contentWindow;
        // Test whether iframe is accessible (because if not, we do not want to throw an error at this point, before we can tell the user what is wrong)
        var isIframeAccessible = true;
        try {
            iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
        }
        catch (err) {
            console.error('The iframe is probably not accessible', err);
            isIframeAccessible = false;
        }
        if (!isIframeAccessible) return;
        // when the feature is in active state
        if (params.useHomeKeyToFocusSearchBar) {
            //Handle Home key press inside window(outside iframe) to focus #prefix
            window.addEventListener('keydown', focusPrefixOnHomeKey);
            //only for initial empty iFrame loaded using `src` attribute
            //in any other case listener gets removed on reloading of iFrame content
            iframeContentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
        }
        // when the feature is not active
        else {
            //remove event listener for window(outside iframe)
            window.removeEventListener('keydown', focusPrefixOnHomeKey);
            //if feature is deactivated and no zim content is loaded yet
            iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
        }
    }

    /**
     * Checks whether we need to display an alert that the default Content Injection Mode has now been switched to ServiceWorker Mode
     */
    function checkAndDisplayInjectionModeChangeAlert() {
        var message;
        if (!params.defaultModeChangeAlertDisplayed && isServiceWorkerAvailable() && isServiceWorkerReady()) {
            message = ['<p>We have switched you to ServiceWorker mode (this is now the default). ' +
                'It supports more types of ZIM archives and is much more robust.</p>' +
                '<p>If you experience problems with this mode, you can switch back to the (now deprecated) JQuery mode. ' +
                'In that case, please report the problems you experienced to us (see About section).</p>',
                'Change of default content injection mode'];
            uiUtil.systemAlert(message[0], message[1]).then(function () {
                settingsStore.setItem('defaultModeChangeAlertDisplayed', true, Infinity);
            });
        } else if (!params.defaultModeChangeAlertDisplayed && params.contentInjectionMode === 'jquery') {
            message = ['<p>Unfortunately, your browser does not appear to support ServiceWorker mode, which is now the default for this app.</p>' +
                '<p>You can continue to use the app in the (now deprecated) JQuery mode, but note that this mode only works well with ' +
                'ZIM archives that have static content, such as Wikipedia / Wikimedia ZIMs or Stackexchange.</p>' +
                '<p>If you can, we recommend that you update your browser to a version that supports ServiceWorker mode.</p>',
                'ServiceWorker mode unsupported'];
            uiUtil.systemAlert(message[0], message[1], true, 'Cancel', 'Okay').then(function (result) {
                if (result) {
                    // If user selected OK, then do not display again ever
                    settingsStore.setItem('defaultModeChangeAlertDisplayed', true, Infinity);
                }
            });
        }
        // This prevents the alert being displayed again this session
        params.defaultModeChangeAlertDisplayed = true;
    }

    /**
     * Displays or refreshes the API status shown to the user
     */
    function refreshAPIStatus() {
        var apiStatusPanel = document.getElementById('apiStatusDiv');
        apiStatusPanel.classList.remove('card-success', 'card-warning', 'card-danger');
        var apiPanelClass = 'card-success';
        var messageChannelStatus = document.getElementById('messageChannelStatus');
        var serviceWorkerStatus = document.getElementById('serviceWorkerStatus');
        if (isMessageChannelAvailable()) {
            messageChannelStatus.textContent = 'MessageChannel API available';
            messageChannelStatus.classList.remove('apiAvailable', 'apiUnavailable');
            messageChannelStatus.classList.add('apiAvailable');
        } else {
            apiPanelClass = 'card-warning';
            messageChannelStatus.textContent = 'MessageChannel API unavailable';
            messageChannelStatus.classList.remove('apiAvailable', 'apiUnavailable');
            messageChannelStatus.classList.add('apiUnavailable');
        }
        if (isServiceWorkerAvailable()) {
            if (isServiceWorkerReady()) {
                serviceWorkerStatus.textContent = 'ServiceWorker API available, and registered';
                serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable');
                serviceWorkerStatus.classList.add('apiAvailable');
            } else {
                apiPanelClass = 'card-warning';
                serviceWorkerStatus.textContent = 'ServiceWorker API available, but not registered';
                serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable');
                serviceWorkerStatus.classList.add('apiUnavailable');
            }
        } else {
            apiPanelClass = 'card-warning';
            serviceWorkerStatus.textContent = 'ServiceWorker API unavailable';
            serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable');
            serviceWorkerStatus.classList.add('apiUnavailable');
        }
        // Update Settings Store section of API panel with API name
        var settingsStoreStatusDiv = document.getElementById('settingsStoreStatus');
        var apiName = params.storeType === 'cookie' ? 'Cookie' : params.storeType === 'local_storage' ? 'Local Storage' : 'None';
        settingsStoreStatusDiv.textContent = 'Settings Storage API in use: ' + apiName;
        settingsStoreStatusDiv.classList.remove('apiAvailable', 'apiUnavailable');
        settingsStoreStatusDiv.classList.add(params.storeType === 'none' ? 'apiUnavailable' : 'apiAvailable');
        apiPanelClass = params.storeType === 'none' ? 'card-warning' : apiPanelClass;
        // Update Decompressor API section of panel
        var decompAPIStatusDiv = document.getElementById('decompressorAPIStatus');
        apiName = params.decompressorAPI.assemblerMachineType;
        apiPanelClass = params.decompressorAPI.errorStatus ? 'card-danger' : apiName === 'WASM' ? apiPanelClass : 'card-warning';
        decompAPIStatusDiv.className = apiName ? params.decompressorAPI.errorStatus ? 'apiBroken' : apiName === 'WASM' ? 'apiAvailable' : 'apiSuboptimal' : 'apiUnavailable';
        // Add the last used decompressor, if known, to the apiName
        if (apiName && params.decompressorAPI.decompressorLastUsed) {
            apiName += ' [&nbsp;' + params.decompressorAPI.decompressorLastUsed + '&nbsp;]';
        }
        apiName = params.decompressorAPI.errorStatus || apiName || 'Not initialized';
        // innerHTML is used here because the API name may contain HTML entities like &nbsp;
        decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName ;
        // Update Search Provider
        uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider);
        // Update PWA origin
        var pwaOriginStatusDiv = document.getElementById('pwaOriginStatus');
        pwaOriginStatusDiv.className = 'apiAvailable';
        pwaOriginStatusDiv.innerHTML = 'PWA Origin: ' + window.location.origin;
        // Add a warning colour to the API Status Panel if any of the above tests failed
        apiStatusPanel.classList.add(apiPanelClass);
        // Set visibility of UI elements according to mode
        document.getElementById('bypassAppCacheDiv').style.display = params.contentInjectionMode === 'serviceworker' ? 'block' : 'none';
        // Check to see whether we need to alert the user that we have switched to ServiceWorker mode by default
        if (!params.defaultModeChangeAlertDisplayed) checkAndDisplayInjectionModeChangeAlert();
    }

    /**
     * Queries Service Worker if possible to determine cache capability and returns an object with cache attributes
     * If Service Worker is not available, the attributes of the memory cache are returned instead
     * @returns {Promise<Object>} A Promise for an object with cache attributes 'type', 'description', and 'count'
     */
    function getAssetsCacheAttributes() {
        return new Promise(function (resolve, reject) {
            if (params.contentInjectionMode === 'serviceworker' && navigator.serviceWorker && navigator.serviceWorker.controller) {
                // Create a Message Channel
                var channel = new MessageChannel();
                // Handler for recieving message reply from service worker
                channel.port1.onmessage = function (event) {
                    var cache = event.data;
                    if (cache.error) reject(cache.error);
                    else resolve(cache);
                };
                // Ask Service Worker for its cache status and asset count
                navigator.serviceWorker.controller.postMessage({
                    'action': {
                        'assetsCache': params.assetsCache ? 'enable' : 'disable',
                        'appCache': params.appCache ? 'enable' : 'disable',
                        'checkCache': window.location.href
                    }
                }, [channel.port2]);
            } else {
                // No Service Worker has been established, so we resolve the Promise with cssCache details only
                resolve({
                    'type': params.assetsCache ? 'memory' : 'none',
                    'name': 'cssCache',
                    'description': params.assetsCache ? 'Memory' : 'None',
                    'count': cssCache.size
                });
            }
        });
    }

    /**
     * Refreshes the UI (Configuration) with the cache attributes obtained from getAssetsCacheAttributes()
     */
    function refreshCacheStatus() {
        // Update radio buttons and checkbox
        document.getElementById('cachedAssetsModeRadio' + (params.assetsCache ? 'True' : 'False')).checked = true;
        // Get cache attributes, then update the UI with the obtained data
        getAssetsCacheAttributes().then(function (cache) {
            if (cache.type === 'cacheAPI' && ASSETS_CACHE !== cache.name) {
                console.error('DEV: The ASSETS_CACHE defined in app.js does not match the ASSETS_CACHE defined in service-worker.js!');
            }
            document.getElementById('cacheUsed').textContent = cache.description;
            document.getElementById('assetsCount').textContent = cache.count;
            var cacheSettings = document.getElementById('performanceSettingsDiv');
            var cacheStatusPanel = document.getElementById('cacheStatusPanel');
            [cacheSettings, cacheStatusPanel].forEach(function (card) {
                // IE11 cannot remove more than one class from a list at a time
                card.classList.remove('card-success');
                card.classList.remove('card-warning');
                if (params.assetsCache) card.classList.add('card-success');
                else card.classList.add('card-warning');
            });
        });
    }

    var keepAliveServiceWorkerHandle;
    var serviceWorkerRegistration;

    /**
     * Send an 'init' message to the ServiceWorker with a new MessageChannel
     * to initialize it, or to keep it alive.
     * This MessageChannel allows a 2-way communication between the ServiceWorker
     * and the application
     */
    function initOrKeepAliveServiceWorker() {
        var delay = DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER;
        if (params.contentInjectionMode === 'serviceworker') {
            // Create a new messageChannel
            var tmpMessageChannel = new MessageChannel();
            tmpMessageChannel.port1.onmessage = handleMessageChannelMessage;
            // Send the init message to the ServiceWorker, with this MessageChannel as a parameter
            if (navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    'action': 'init'
                }, [tmpMessageChannel.port2]);
            } else if (keepAliveServiceWorkerHandle) {
                console.error('The Service Worker is active but is not controlling the current page! We have to reload.');
                window.location.reload();
            } else {
                // If this is the first time we are initiating the SW, allow Promises to complete by delaying potential reload till next tick
                delay = 0;
            }
            // Schedule to do it again regularly to keep the 2-way communication alive.
            // See https://github.com/kiwix/kiwix-js/issues/145 to understand why
            clearTimeout(keepAliveServiceWorkerHandle);
            keepAliveServiceWorkerHandle = setTimeout(initOrKeepAliveServiceWorker, delay, false);
        }
    }

    /**
     * Sets the given injection mode.
     * This involves registering (or re-enabling) the Service Worker if necessary
     * It also refreshes the API status for the user afterwards.
     *
     * @param {String} value The chosen content injection mode : 'jquery' or 'serviceworker'
     */
    function setContentInjectionMode(value) {
        params.contentInjectionMode = value;
        if (value === 'jquery') {
            if (!params.appCache) {
                uiUtil.systemAlert('You must deselect the "Bypass AppCache" option before switching to JQuery mode!', 'Deselect "Bypass AppCache"').then(function () {
                    setContentInjectionMode('serviceworker');
                })
                return;
            }
            if (params.referrerExtensionURL) {
                // We are in an extension, and the user may wish to revert to local code
                var message = 'This will switch to using locally packaged code only. Some configuration settings may be lost.<br/><br/>' +
                'WARNING: After this, you may not be able to switch back to SW mode without an online connection!';
                var launchLocal = function () {
                    settingsStore.setItem('allowInternetAccess', false, Infinity);
                    var uriParams = '?allowInternetAccess=false&contentInjectionMode=jquery&hideActiveContentWarning=false';
                    uriParams += '&appTheme=' + params.appTheme;
                    uriParams += '&showUIAnimations=' + params.showUIAnimations;
                    window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
                    'Beam me down, Scotty!';
                };
                uiUtil.systemAlert(message, 'Warning!', true).then(function (response) {
                    if (response) {
                        launchLocal();
                    } else {
                        setContentInjectionMode('serviceworker');
                    }
                });
                return;
            }
            // Because the Service Worker must still run in a PWA app so that it can work offline, we don't actually disable the SW in this context,
            // but it will no longer be intercepting requests for ZIM assets (only requests for the app's own code)
            if (isServiceWorkerAvailable()) {
                serviceWorkerRegistration = null;
            }
            refreshAPIStatus();
            // User has switched to jQuery mode, so no longer needs ASSETS_CACHE
            // We should empty it and turn it off to prevent unnecessary space usage
            if ('caches' in window && isMessageChannelAvailable()) {
                var channel = new MessageChannel();
                if (isServiceWorkerAvailable() && navigator.serviceWorker.controller) {
                    navigator.serviceWorker.controller.postMessage({
                        'action': { 'assetsCache': 'disable' }
                    }, [channel.port2]);
                }
                caches.delete(ASSETS_CACHE);
            }
        } else if (value === 'serviceworker') {
            var protocol = window.location.protocol;
            // Since Firefox 103, the ServiceWorker API is not available any more in Webextensions. See https://hg.mozilla.org/integration/autoland/rev/3a2907ad88e8 and https://bugzilla.mozilla.org/show_bug.cgi?id=1593931
            // Previously, the API was available, but failed to register (which we could trap a few lines below).
            // So we now need to suggest a switch to the PWA if we are inside a Firefox Extension and the ServiceWorker API is unavailable.
            // Even if some older firefox versions do not support ServiceWorkers at all (versions 42, 43, 45ESR, 52ESR, 60ESR and 68ESR, based on https://caniuse.com/serviceworkers). In this case, the PWA will not work either.
            if (protocol === 'moz-extension:' && !isServiceWorkerAvailable()) {
                launchMozillaExtensionServiceWorker();
            } else {
                if (!isServiceWorkerAvailable()) {
                    var message =
                        '<p>Unfortunately, your browser does not appear to support ServiceWorker mode, which is now the default for this app.</p>' +
                        '<p>You can continue to use the app in the (now deprecated) JQuery mode, but note that this mode only works well with ' +
                        'ZIM archives that have static content, such as Wikipedia / Wikimedia ZIMs or Stackexchange.</p>' +
                        '<p>If you can, we recommend that you update your browser to a version that supports ServiceWorker mode.</p>';
                    uiUtil.systemAlert(message, 'ServiceWorker API not available', true, 'Cancel', 'Use JQuery mode').then(function (response) {
                        if (params.referrerExtensionURL && response) {
                            var uriParams = '?allowInternetAccess=false&contentInjectionMode=jquery&defaultModeChangeAlertDisplayed=true';
                            window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
                        } else {
                            setContentInjectionMode('jquery');
                        }
                    });
                    return;
                }
                if (!isMessageChannelAvailable()) {
                    uiUtil.systemAlert('The MessageChannel API is not available on your device. Falling back to JQuery mode', 'MessageChannel API not available').then(function () {
                        setContentInjectionMode('jquery');
                    });
                    return;
                }
                if (!isServiceWorkerReady()) {
                    var serviceWorkerStatus = document.getElementById('serviceWorkerStatus');
                    serviceWorkerStatus.textContent = 'ServiceWorker API available : trying to register it...';
                    if (navigator.serviceWorker.controller) {
                        console.log("Active Service Worker found, no need to register");
                        serviceWorkerRegistration = true;
                        // Remove any jQuery hooks from a previous jQuery session
                        $('#articleContent').contents().remove();
                        // Create the MessageChannel and send 'init'
                        initOrKeepAliveServiceWorker();
                        refreshAPIStatus();
                    } else {
                        navigator.serviceWorker.register('../service-worker.js').then(function (reg) {
                            // The ServiceWorker is registered
                            serviceWorkerRegistration = reg;
                            // We need to wait for the ServiceWorker to be activated
                            // before sending the first init message
                            var serviceWorker = reg.installing || reg.waiting || reg.active;
                            serviceWorker.addEventListener('statechange', function(statechangeevent) {
                                if (statechangeevent.target.state === 'activated') {
                                    // Remove any jQuery hooks from a previous jQuery session
                                    $('#articleContent').contents().remove();
                                    // Create the MessageChannel and send the 'init' message to the ServiceWorker
                                    initOrKeepAliveServiceWorker();
                                    // We need to refresh cache status here on first activation because SW was inaccessible till now
                                    // We also initialize the ASSETS_CACHE constant in SW here
                                    refreshCacheStatus();
                                    refreshAPIStatus();
                                }
                            });
                            if (serviceWorker.state === 'activated') {
                                // Even if the ServiceWorker is already activated,
                                // We need to re-create the MessageChannel
                                // and send the 'init' message to the ServiceWorker
                                // in case it has been stopped and lost its context
                                initOrKeepAliveServiceWorker();
                            }
                            refreshCacheStatus();
                            refreshAPIStatus();
                        }).catch(function (err) {
                            if (protocol === 'moz-extension:') {
                                // This is still useful for Firefox<103 extensions, where the ServiceWorker API is available, but fails to register
                                launchMozillaExtensionServiceWorker();
                            } else {
                                console.error('Error while registering serviceWorker', err);
                                refreshAPIStatus();
                                var message = "The ServiceWorker could not be properly registered. Switching back to jQuery mode. Error message : " + err;
                                if (protocol === 'file:') {
                                    message += "<br/><br/>You seem to be opening kiwix-js with the file:// protocol. You should open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)";
                                }
                                uiUtil.systemAlert(message, "Failed to register ServiceWorker").then(function () {
                                    setContentInjectionMode('jquery');
                                    // We need to wait for the previous dialogue box to unload fully before attempting to display another
                                    setTimeout(function () {
                                        params.defaultModeChangeAlertDisplayed = false;
                                        settingsStore.removeItem('defaultModeChangeAlertDisplayed');
                                        checkAndDisplayInjectionModeChangeAlert();
                                    }, 1200);
                                });
                            }
                        });
                    }
                } else {
                    // We need to reactivate Service Worker
                    initOrKeepAliveServiceWorker();
                }
            }
            // User has switched to ServiceWorker mode, so no longer needs the memory cache
            // We should empty it to ensure good memory management
            resetCssCache();
        }
        $('input:radio[name=contentInjectionMode]').prop('checked', false);
        $('input:radio[name=contentInjectionMode]').filter('[value="' + value + '"]').prop('checked', true);
        // Save the value in the Settings Store, so that to be able to keep it after a reload/restart
        settingsStore.setItem('contentInjectionMode', value, Infinity);
        refreshCacheStatus();
        refreshAPIStatus();
        // Set the visibility of WebP workaround after change of content injection mode
        uiUtil.determineCanvasElementsWorkaround();
    }

    /**
     * Tells if the ServiceWorker API is available
     * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker
     * @returns {Boolean}
     */
    function isServiceWorkerAvailable() {
        return ('serviceWorker' in navigator);
    }

    /**
     * Tells if the MessageChannel API is available
     * https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel
     * @returns {Boolean}
     */
    function isMessageChannelAvailable() {
        try{
            var dummyMessageChannel = new MessageChannel();
            if (dummyMessageChannel) return true;
        }
        catch (e){
            return false;
        }
        return false;
    }

    /**
     * Tells if the ServiceWorker is registered, and ready to capture HTTP requests
     * and inject content in articles.
     * @returns {Boolean}
     */
    function isServiceWorkerReady() {
        // Return true if the serviceWorkerRegistration is not null and not undefined
        return (serviceWorkerRegistration);
    }

    function launchMozillaExtensionServiceWorker () {
        // DEV: See explanation below for why we access localStorage directly here
        var PWASuccessfullyLaunched = localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success';
        var allowInternetAccess = settingsStore.getItem('allowInternetAccess') === 'true';
        var message = params.defaultModeChangeAlertDisplayed ? '<p>To enable the Service Worker, we ' :
            ('<p>We shall attempt to switch you to ServiceWorker mode (this is now the default). ' +
            'It supports more types of ZIM archives and is much more robust.</p><p>We ');
        message += 'need one-time access to our secure server so that the app can re-launch as a Progressive Web App (PWA). ' +
            'If available, the PWA will work offline, but will auto-update periodically when online as per the ' + 
            'Service Worker spec.</p><p>You can switch back any time by returning to JQuery mode.</p>' +
            '<p>WARNING: This will attempt to access the following server:<br/>' + params.PWAServer + '</p>';
        var launchPWA = function () {
            uiUtil.spinnerDisplay(false);
            var uriParams = '?contentInjectionMode=serviceworker&allowInternetAccess=true';
            uriParams += '&referrerExtensionURL=' + encodeURIComponent(window.location.href.replace(/\/www\/index.html.*$/i, ''));
            if (!PWASuccessfullyLaunched || !allowInternetAccess) {
                // Add any further params that should only be passed when the user is intentionally switching to SW mode
                uriParams += '&appTheme=' + params.appTheme;
                uriParams += '&showUIAnimations=' + params.showUIAnimations;
            }
            settingsStore.setItem('contentInjectionMode', 'serviceworker', Infinity);
            // This is needed so that we get passthrough on subsequent launches
            settingsStore.setItem('allowInternetAccess', true, Infinity);
            // Signal failure of PWA until it has successfully launched (in init.js it will be changed to 'success')
            // DEV: We write directly to localStorage instead of using settingsStore here because we need 100% certainty
            // regarding the location of the key to be able to retrieve it in init.js before settingsStore is initialized
            localStorage.setItem(params.keyPrefix + 'PWA_launch', 'fail');
            window.location.href = params.PWAServer + 'www/index.html' + uriParams;
            'Beam me up, Scotty!';
        };
        var checkPWAIsOnline = function () {
            uiUtil.spinnerDisplay(true, 'Checking server access...');
            uiUtil.checkServerIsAccessible(params.PWAServer + 'www/img/icons/kiwix-32.png', launchPWA, function () {
                uiUtil.spinnerDisplay(false);
                uiUtil.systemAlert('The server is not currently accessible! ' +
                    '<br/><br/>(Kiwix needs one-time access to the server to cache the PWA).' +
                    '<br/>Please try again when you have a stable Internet connection.', 'Error!').then(function () {
                        settingsStore.setItem('allowInternetAccess', false, Infinity);
                        setContentInjectionMode('jquery');
                    });
            });
        };
        if (settingsStore.getItem('allowInternetAccess') === 'true') {
            if (PWASuccessfullyLaunched) {
                launchPWA();
            } else {
                uiUtil.systemAlert('The last attempt to launch the PWA appears to have failed.<br/><br/>Do you wish to try again?', 'Confirmation to try again PWA', true).then(function (response) {
                    if (response) {
                        checkPWAIsOnline();
                    } else {
                        settingsStore.setItem('allowInternetAccess', false, Infinity);
                        setContentInjectionMode('jquery');
                    }
                })
            }
        } else {
            uiUtil.systemAlert(message, 'Allow Internet access', true).then(function (response) {
                if (response) {
                    checkPWAIsOnline();
                } else {
                    // User cancelled, so wants to stay in JQuery mode
                    setContentInjectionMode('jquery');
                    settingsStore.setItem('allowInternetAccess', false, Infinity);
                    // We should not bother user with the default mode change alert again
                    params.defaultModeChangeAlertDisplayed = true;
                    settingsStore.setItem('defaultModeChangeAlertDisplayed', true, Infinity)
                }
            });
        }
    }

    /**
     *
     * @type Array.<StorageFirefoxOS>
     */
    var storages = [];
    function searchForArchivesInPreferencesOrStorage() {
        // First see if the list of archives is stored in the Settings Store
        var listOfArchivesFromSettingsStore = settingsStore.getItem("listOfArchives");
        if (listOfArchivesFromSettingsStore !== null && listOfArchivesFromSettingsStore !== undefined && listOfArchivesFromSettingsStore !== "") {
            var directories = listOfArchivesFromSettingsStore.split('|');
            populateDropDownListOfArchives(directories);
        } else {
            searchForArchivesInStorage();
        }
    }
    function searchForArchivesInStorage() {
        // If DeviceStorage is available, we look for archives in it
        document.getElementById('btnConfigure').click();
        document.getElementById('scanningForArchives').style.display = '';
        zimArchiveLoader.scanForArchives(storages, populateDropDownListOfArchives, function () {
            // callbackError function is called in case of an error
            uiUtil.systemAlert(message, label).then(populateDropDownListOfArchives(null));
        });
    }

    if ($.isFunction(navigator.getDeviceStorages)) {
        // The method getDeviceStorages is available (FxOS>=1.1)
        storages = $.map(navigator.getDeviceStorages("sdcard"), function(s) {
            return new abstractFilesystemAccess.StorageFirefoxOS(s);
        });
    }

    if (storages !== null && storages.length > 0) {
        // Make a fake first access to device storage, in order to ask the user for confirmation if necessary.
        // This way, it is only done once at this moment, instead of being done several times in callbacks
        // After that, we can start looking for archives
        storages[0].get("fake-file-to-read").then(searchForArchivesInPreferencesOrStorage,
                                                  searchForArchivesInPreferencesOrStorage);
    } else {
        // If DeviceStorage is not available, we display the file select components
        displayFileSelect();
        if (document.getElementById('archiveFiles').files && document.getElementById('archiveFiles').files.length>0) {
            // Archive files are already selected,
            setLocalArchiveFromFileSelect();
        } else {
            document.getElementById('btnConfigure').click();
        }
    }

    // Display the article when the user goes back in the browser history
    window.onpopstate = function(event) {
        if (event.state) {
            var title = event.state.title;
            var titleSearch = event.state.titleSearch;
            document.getElementById('prefix').value = '';
            document.getElementById('welcomeText').style.display = 'none';
            document.getElementById('searchingArticles').style.display = 'none';
            $('.navbar-collapse').collapse('hide');
            document.getElementById('configuration').style.display = 'none';
            document.getElementById('articleListWithHeader').style.display = 'none';
            $('#articleContent').contents().empty();

            if (title && !(""===title)) {
                goToArticle(title);
            } else if (titleSearch && titleSearch !== '') {
                document.getElementById('prefix').value = titleSearch;
                if (titleSearch !== appstate.search.prefix) {
                    searchDirEntriesFromPrefix(titleSearch);
                } else {
                    document.getElementById('prefix').focus();
                }
            }
        }
    };

    /**
     * Populate the drop-down list of archives with the given list
     * @param {Array.<String>} archiveDirectories
     */
    function populateDropDownListOfArchives(archiveDirectories) {
        document.getElementById('scanningForArchives').style.display = 'none';
        document.getElementById('chooseArchiveFromLocalStorage').style.display = '';
        var comboArchiveList = document.getElementById('archiveList');
        comboArchiveList.options.length = 0;
        for (var i = 0; i < archiveDirectories.length; i++) {
            var archiveDirectory = archiveDirectories[i];
            if (archiveDirectory === '/') {
                uiUtil.systemAlert('It looks like you have put some archive files at the root of your sdcard (or internal storage). Please move them in a subdirectory', 'Error: invalid archive files location');
            } else {
                comboArchiveList.options[i] = new Option(archiveDirectory, archiveDirectory);
            }
        }
        // Store the list of archives in the Settings Store, to avoid rescanning at each start
        settingsStore.setItem('listOfArchives', archiveDirectories.join('|'), Infinity);

        $('#archiveList').on('change', setLocalArchiveFromArchiveList);
        if (comboArchiveList.options.length > 0) {
            var lastSelectedArchive = settingsStore.getItem('lastSelectedArchive');
            if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== '') {
                // Attempt to select the corresponding item in the list, if it exists
                if ($("#archiveList option[value='"+lastSelectedArchive+"']").length > 0) {
                    document.getElementById('archiveList').value = lastSelectedArchive;
                }
            }
            // Set the localArchive as the last selected (or the first one if it has never been selected)
            setLocalArchiveFromArchiveList();
        } else {
            uiUtil.systemAlert('Welcome to Kiwix! This application needs at least a ZIM file in your SD-card (or internal storage). Please download one and put it on the device (see About section). Also check that your device is not connected to a computer through USB device storage (which often locks the SD-card content)', 'Welcome')
            .then(function () {
                document.getElementById('btnAbout').click();
                var isAndroid = (navigator.userAgent.indexOf('Android') !== -1);
                if (isAndroid) {
                    uiUtil.systemAlert("You seem to be using an Android device with DeviceStorage API. That must be a quite old Firefox version because this API has been removed in 2016. Be aware that there was a bug on Firefox, that prevents finding Wikipedia archives in a SD-card (at least on some devices). Please put the archive in the internal storage if the application can't find it.", 'Warning');
                }
            });
        }
    }

    /**
     * Sets the localArchive from the selected archive in the drop-down list
     */
    function setLocalArchiveFromArchiveList() {
        var archiveDirectory = document.getElementById('archiveList').value;
        if (archiveDirectory && archiveDirectory.length > 0) {
            // Now, try to find which DeviceStorage has been selected by the user
            // It is the prefix of the archive directory
            var regexpStorageName = /^\/([^/]+)\//;
            var regexpResults = regexpStorageName.exec(archiveDirectory);
            var selectedStorage = null;
            if (regexpResults && regexpResults.length>0) {
                var selectedStorageName = regexpResults[1];
                for (var i=0; i<storages.length; i++) {
                    var storage = storages[i];
                    if (selectedStorageName === storage.storageName) {
                        // We found the selected storage
                        selectedStorage = storage;
                    }
                }
                if (selectedStorage === null) {
                    uiUtil.systemAlert('Unable to find which device storage corresponds to directory ' + archiveDirectory, 'Error: no matching storage');
                }
            } else {
                // This happens when the archiveDirectory is not prefixed by the name of the storage
                // (in the Simulator, or with FxOs 1.0, or probably on devices that only have one device storage)
                // In this case, we use the first storage of the list (there should be only one)
                if (storages.length === 1) {
                    selectedStorage = storages[0];
                } else {
                    uiUtil.systemAlert('Something weird happened with the DeviceStorage API : found a directory without prefix : '
                    + archiveDirectory + ', but there were ' + storages.length
                    + ' storages found with getDeviceStorages instead of 1', 'Error: unprefixed directory');
                }
            }
            resetCssCache();
            selectedArchive = zimArchiveLoader.loadArchiveFromDeviceStorage(selectedStorage, archiveDirectory, function () {
                settingsStore.setItem('lastSelectedArchive', archiveDirectory, Infinity);
                // The archive is set : go back to home page to start searching
                document.getElementById('btnHome').click();
            }, function (message, label) {
                // callbackError which is called in case of an error
                uiUtil.systemAlert(message, label);
            });

        }
    }

    /**
     * Resets the CSS Cache (used only in jQuery mode)
     */
    function resetCssCache() {
        // Reset the cssCache. Must be done when archive changes.
        if (cssCache) {
            cssCache = new Map();
        }
    }

    /**
     * Displays the zone to select files from the archive
     */
    function displayFileSelect() {
        document.getElementById('openLocalFiles').style.display = 'block';
        // Set the main drop zone
        if (!params.disableDragAndDrop) {
            configDropZone.addEventListener('dragover', handleGlobalDragover);
            configDropZone.addEventListener('dragleave', function() {
                configDropZone.style.border = '';
            });
            // Also set a global drop zone (allows us to ensure Config is always displayed for the file drop)
            globalDropZone.addEventListener('dragover', function(e) {
                e.preventDefault();
                if (configDropZone.style.display === 'none') document.getElementById('btnConfigure').click();
                e.dataTransfer.dropEffect = 'link';
            });
            globalDropZone.addEventListener('drop', handleFileDrop);
        }
        // This handles use of the file picker
        document.getElementById('archiveFiles').addEventListener('change', setLocalArchiveFromFileSelect);
    }

    function handleGlobalDragover(e) {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'link';
        configDropZone.style.border = '3px dotted red';
    }

    function handleIframeDragover(e) {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'link';
        document.getElementById('btnConfigure').click();
    }

    function handleIframeDrop(e) {
        e.stopPropagation();
        e.preventDefault();
        return;
    }

    function handleFileDrop(packet) {
        packet.stopPropagation();
        packet.preventDefault();
        configDropZone.style.border = '';
        var files = packet.dataTransfer.files;
        document.getElementById('openLocalFiles').style.display = 'none';
        document.getElementById('downloadInstruction').style.display = 'none';
        document.getElementById('selectorsDisplay').style.display = 'inline';
        setLocalArchiveFromFileList(files);
        // This clears the display of any previously picked archive in the file selector
        document.getElementById('archiveFiles').value = null;
    }

    // Add event listener to link which allows user to show file selectors
    document.getElementById('selectorsDisplayLink').addEventListener('click', function(e) {
        e.preventDefault();
        document.getElementById('openLocalFiles').style.display = 'block';
        document.getElementById('selectorsDisplay').style.display = 'none';
    });

    function setLocalArchiveFromFileList(files) {
        // Check for usable file types
        for (var i = files.length; i--;) {
            // DEV: you can support other file types by adding (e.g.) '|dat|idx' after 'zim\w{0,2}'
            if (!/\.(?:zim\w{0,2})$/i.test(files[i].name)) {
                uiUtil.systemAlert('One or more files does not appear to be a ZIM file!', 'Invalid File Format');
                return;
            }
        }
        resetCssCache();
        selectedArchive = zimArchiveLoader.loadArchiveFromFiles(files, function () {
            // The archive is set : go back to home page to start searching
            document.getElementById('btnHome').click();
            document.getElementById('downloadInstruction').style.display = 'none';
        }, function (message, label) {
            // callbackError which is called in case of an error
            uiUtil.systemAlert(message, label);
        });
    }

    /**
     * Sets the localArchive from the File selects populated by user
     */
    function setLocalArchiveFromFileSelect() {
        setLocalArchiveFromFileList(document.getElementById('archiveFiles').files);
    }

    /**
     * Reads a remote archive with given URL, and returns the response in a Promise.
     * This function is used by setRemoteArchives below, for UI tests
     *
     * @param {String} url The URL of the archive to read
     * @returns {Promise<Blob>} A promise for the requested file (blob)
     */
    function readRemoteArchive(url) {
        return new Promise(function (resolve, reject) {
            var request = new XMLHttpRequest();
            request.open("GET", url);
            request.responseType = "blob";
            request.onreadystatechange = function () {
                if (request.readyState === XMLHttpRequest.DONE) {
                    if (request.status >= 200 && request.status < 300 || request.status === 0) {
                        // Hack to make this look similar to a file
                        request.response.name = url;
                        resolve(request.response);
                    } else {
                        reject("HTTP status " + request.status + " when reading " + url);
                    }
                }
            };
            request.onabort = request.onerror = reject;
            request.send();
        });
    }

    /**
     * This is used in the testing interface to inject remote archives
     * @returns {Promise<Array>} A Promise for an array of archives
     */
    window.setRemoteArchives = function () {
        var readRequests = [];
        Array.prototype.slice.call(arguments).forEach(function (arg) {
            readRequests.push(readRemoteArchive(arg));
        });
        return Promise.all(readRequests).then(function (arrayOfArchives) {
            setLocalArchiveFromFileList(arrayOfArchives);
        }).catch(function (e) {
            console.error('Unable to load remote archive(s)', e);
        });
    };

    /**
     * Handle key input in the prefix input zone
     * @param {Event} evt The event data to handle
     */
    function onKeyUpPrefix() {
        // Use a timeout, so that very quick typing does not cause a lot of overhead
        // It is also necessary for the words suggestions to work inside Firefox OS
        if (window.timeoutKeyUpPrefix) {
            window.clearTimeout(window.timeoutKeyUpPrefix);
        }
        window.timeoutKeyUpPrefix = window.setTimeout(function () {
            var prefix = document.getElementById('prefix').value;
            if (prefix && prefix.length > 0 && prefix !== appstate.search.prefix) {
                document.getElementById('searchArticles').click();
            }
        }, 500);
    }

    /**
     * Search the index for DirEntries with title that start with the given prefix (implemented
     * with a binary search inside the index file)
     * @param {String} prefix The string that must appear at the start of any title searched for
     */
    function searchDirEntriesFromPrefix(prefix) {
        if (selectedArchive !== null && selectedArchive.isReady()) {
            // Cancel the old search (zimArchive search object will receive this change)
            appstate.search.status = 'cancelled';
            // Initiate a new search object and point appstate.search to it (the zimArchive search object will continue to point to the old object)
            // DEV: Technical explanation: the appstate.search is a pointer to an underlying object assigned in memory, and we are here defining a new object
            // in memory {'prefix': prefix, 'status': 'init', .....}, and pointing appstate.search to it; the old search object that was passed to selectedArchive
            // (zimArchive.js) continues to exist in the scope of the functions initiated by the previous search until all Promises have returned
            appstate.search = {'prefix': prefix, 'status': 'init', 'type': '', 'size': params.maxSearchResultsSize};
            var activeContent = document.getElementById('activeContent');
            if (activeContent) activeContent.style.display = 'none';
            selectedArchive.findDirEntriesWithPrefix(appstate.search, populateListOfArticles);
        } else {
            document.getElementById('searchingArticles').style.display = 'none';
            // We have to remove the focus from the search field,
            // so that the keyboard does not stay above the message
            document.getElementById('searchArticles').focus();
            uiUtil.systemAlert('Archive not set : please select an archive', 'No archive selected').then(function () {
                document.getElementById('btnConfigure').click();
            });
        }
    }

    /**
     * Display the list of articles with the given array of DirEntry
     * @param {Array} dirEntryArray The array of dirEntries returned from the binary search
     * @param {Object} reportingSearch The reporting search object
     */
    function populateListOfArticles(dirEntryArray, reportingSearch) {
        // Do not allow cancelled searches to report
        if (reportingSearch.status === 'cancelled') return;
        var stillSearching = reportingSearch.status === 'interim';
        var articleListHeaderMessageDiv = document.getElementById('articleListHeaderMessage');
        var nbDirEntry = dirEntryArray ? dirEntryArray.length : 0;

        var message;
        if (stillSearching) {
            message = 'Searching [' + reportingSearch.type + ']... found: ' + nbDirEntry;
        } else if (nbDirEntry >= params.maxSearchResultsSize) {
            message = 'First ' + params.maxSearchResultsSize + ' articles found (refine your search).';
        } else {
            message = 'Finished. ' + (nbDirEntry ? nbDirEntry : 'No') + ' articles found' + (
                reportingSearch.type === 'basic' ? ': try fewer words for full search.' : '.'
            );
        }

        articleListHeaderMessageDiv.textContent = message;

        var articleListDiv = document.getElementById('articleList');
        var articleListDivHtml = '';
        var listLength = dirEntryArray.length < params.maxSearchResultsSize ? dirEntryArray.length : params.maxSearchResultsSize;
        for (var i = 0; i < listLength; i++) {
            var dirEntry = dirEntryArray[i];
            // NB We use encodeURIComponent rather than encodeURI here because we know that any question marks in the title are not querystrings,
            // and should be encoded [kiwix-js #806]. DEV: be very careful if you edit the dirEntryId attribute below, because the contents must be
            // inside double quotes (in the final HTML string), given that dirEntryStringId may contain bare apostrophes
            // Info: encodeURIComponent encodes all characters except  A-Z a-z 0-9 - _ . ! ~ * ' ( )
            var dirEntryStringId = encodeURIComponent(dirEntry.toStringId());
            articleListDivHtml += '<a href="#" dirEntryId="' + dirEntryStringId +
                '" class="list-group-item">' + dirEntry.getTitleOrUrl() + '</a>';
        }

        // innerHTML required for this line
        articleListDiv.innerHTML = articleListDivHtml;
        // We have to use mousedown below instead of click as otherwise the prefix blur event fires first
        // and prevents this event from firing; note that touch also triggers mousedown
        $('#articleList a').on('mousedown', function (e) {
            // Cancel search immediately
            appstate.search.status = 'cancelled';
            handleTitleClick(e);
            return false;
        });
        if (!stillSearching) document.getElementById('searchingArticles').style.display = 'none';
        document.getElementById('articleListWithHeader').style.display = '';
    }

    /**
     * Handles the click on the title of an article in search results
     * @param {Event} event The click event to handle
     * @returns {Boolean} Always returns false for JQuery event handling
     */
    function handleTitleClick(event) {
        var dirEntryId = decodeURIComponent(event.target.getAttribute('dirEntryId'));
        findDirEntryFromDirEntryIdAndLaunchArticleRead(dirEntryId);
        return false;
    }

    /**
     * Creates an instance of DirEntry from given dirEntryId (including resolving redirects),
     * and call the function to read the corresponding article
     * @param {String} dirEntryId The stringified Directory Entry to parse and launch
     */
    function findDirEntryFromDirEntryIdAndLaunchArticleRead(dirEntryId) {
        if (selectedArchive.isReady()) {
            var dirEntry = selectedArchive.parseDirEntryId(dirEntryId);
            // Remove focus from search field to hide keyboard and to allow navigation keys to be used
            document.getElementById('articleContent').contentWindow.focus();
            document.getElementById('searchingArticles').style.display = '';
            if (dirEntry.isRedirect()) {
                selectedArchive.resolveRedirect(dirEntry, readArticle);
            } else {
                params.isLandingPage = false;
                readArticle(dirEntry);
            }
        } else {
            uiUtil.systemAlert("Data files not set", "Archive not ready");
        }
    }

    /**
     * Check whether the given URL from given dirEntry equals the expectedArticleURLToBeDisplayed
     * @param {DirEntry} dirEntry The directory entry of the article to read
     */
    function isDirEntryExpectedToBeDisplayed(dirEntry) {
        var curArticleURL = dirEntry.namespace + "/" + dirEntry.url;

        if (expectedArticleURLToBeDisplayed !== curArticleURL) {
            console.debug("url of current article :" + curArticleURL + ", does not match the expected url :" +
            expectedArticleURLToBeDisplayed);
            return false;
        }
        return true;
    }

    /**
     * Read the article corresponding to the given dirEntry
     * @param {DirEntry} dirEntry The directory entry of the article to read
     */
    function readArticle(dirEntry) {
        // Reset search prefix to allow users to search the same string again if they want to
        appstate.search.prefix = '';
        // Only update for expectedArticleURLToBeDisplayed.
        expectedArticleURLToBeDisplayed = dirEntry.namespace + "/" + dirEntry.url;
        // We must remove focus from UI elements in order to deselect whichever one was clicked (in both jQuery and SW modes),
        // but we should not do this when opening the landing page (or else one of the Unit Tests fails, at least on Chrome 58)
        if (!params.isLandingPage) document.getElementById('articleContent').contentWindow.focus();

        if (params.contentInjectionMode === 'serviceworker') {
            // In ServiceWorker mode, we simply set the iframe src.
            // (reading the backend is handled by the ServiceWorker itself)

            // We will need the encoded URL on article load so that we can set the iframe's src correctly,
            // but we must not encode the '/' character or else relative links may fail [kiwix-js #498]
            var encodedUrl = dirEntry.url.replace(/[^/]+/g, function (matchedSubstring) {
                return encodeURIComponent(matchedSubstring);
            });
            var iframeArticleContent = document.getElementById('articleContent');
            iframeArticleContent.onload = function () {
                // The content is fully loaded by the browser : we can hide the spinner
                document.getElementById('cachingAssets').textContent = 'Caching assets...';
                document.getElementById('cachingAssets').style.display = 'none';
                document.getElementById('searchingArticles').style.display = 'none';
                // Set the requested appTheme
                uiUtil.applyAppTheme(params.appTheme);
                // Display the iframe content
                document.getElementById('articleContent').style.display = '';
                // Deflect drag-and-drop of ZIM file on the iframe to Config
                if (!params.disableDragAndDrop) {
                    var doc = iframeArticleContent.contentDocument ? iframeArticleContent.contentDocument.documentElement : null;
                    var docBody = doc ? doc.getElementsByTagName('body') : null;
                    docBody = docBody ? docBody[0] : null;
                    if (docBody) {
                        docBody.addEventListener('dragover', handleIframeDragover);
                        docBody.addEventListener('drop', handleIframeDrop);
                    }
                }
                resizeIFrame();

                if (iframeArticleContent.contentWindow) {
                    // Configure home key press to focus #prefix only if the feature is in active state
                    if (params.useHomeKeyToFocusSearchBar)
                        iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
                    if (params.openExternalLinksInNewTabs) {
                        // Add event listener to iframe window to check for links to external resources
                        iframeArticleContent.contentWindow.addEventListener('click', function (event) {
                            // Find the closest enclosing A tag (if any)
                            var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target);
                            if (clickedAnchor) {
                                var href = clickedAnchor.getAttribute('href');
                                // We assume that, if an absolute http(s) link is hardcoded inside an HTML string,
                                // it means it's a link to an external website.
                                // We also do it for ftp even if it's not supported any more by recent browsers...
                                if (/^(?:http|ftp)/i.test(href)) {
                                    uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor);
                                }
                                if (/\.pdf$/i.test(href)) {
                                    // Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab
                                    event.preventDefault();
                                    window.open(clickedAnchor.href, '_blank');
                                }
                            }
                        });
                    }
                    // Reset UI when the article is unloaded
                    iframeArticleContent.contentWindow.onunload = function () {
                        // remove eventListener to avoid memory leaks
                        iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
                        var articleList = document.getElementById('articleList');
                        var articleListHeaderMessage =  document.getElementById('articleListHeaderMessage');
                        while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
                        while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
                        document.getElementById('articleListWithHeader').style.display = 'none';
                        document.getElementById('prefix').value = '';
                        document.getElementById('searchingArticles').style.display = '';
                    };
                }
            };

            if(! isDirEntryExpectedToBeDisplayed(dirEntry)){
                return;
            }
            
            // We put the ZIM filename as a prefix in the URL, so that browser caches are separate for each ZIM file
            iframeArticleContent.src = "../" + selectedArchive._file.name + "/" + dirEntry.namespace + "/" + encodedUrl;
        } else {
            // In jQuery mode, we read the article content in the backend and manually insert it in the iframe
            if (dirEntry.isRedirect()) {
                selectedArchive.resolveRedirect(dirEntry, readArticle);
            } else {
                // Line below was inserted to prevent the spinner being hidden, possibly by an async function, when pressing the Random button in quick succession
                // TODO: Investigate whether it is really an async issue or whether there is a rogue .hide() statement in the chain
                document.getElementById('searchingArticles').style.display = '';
                selectedArchive.readUtf8File(dirEntry, displayArticleContentInIframe);
            }
        }
    }

    /**
     * Function that handles a message of the messageChannel.
     * It tries to read the content in the backend, and sends it back to the ServiceWorker
     *
     * @param {Event} event The event object of the message channel
     */
    function handleMessageChannelMessage(event) {
        if (event.data.error) {
            console.error("Error in MessageChannel", event.data.error);
            reject(event.data.error);
        } else {
            // We received a message from the ServiceWorker
            if (event.data.action === "askForContent") {
                // The ServiceWorker asks for some content
                var title = event.data.title;
                var messagePort = event.ports[0];
                var readFile = function (dirEntry) {
                    if (dirEntry === null) {
                        console.error("Title " + title + " not found in archive.");
                        messagePort.postMessage({ 'action': 'giveContent', 'title': title, 'content': '' });
                    } else if (dirEntry.isRedirect()) {
                        selectedArchive.resolveRedirect(dirEntry, function (resolvedDirEntry) {
                            var redirectURL = resolvedDirEntry.namespace + "/" + resolvedDirEntry.url;
                            // Ask the ServiceWorker to send an HTTP redirect to the browser.
                            // We could send the final content directly, but it is necessary to let the browser know in which directory it ends up.
                            // Else, if the redirect URL is in a different directory than the original URL,
                            // the relative links in the HTML content would fail. See #312
                            messagePort.postMessage({ 'action': 'sendRedirect', 'title': title, 'redirectUrl': redirectURL });
                        });
                    } else {
                        // Let's read the content in the ZIM file
                        selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
                            var mimetype = fileDirEntry.getMimetype();
                            // Let's send the content to the ServiceWorker
                            var message = { 'action': 'giveContent', 'title': title, 'content': content.buffer, 'mimetype': mimetype };
                            messagePort.postMessage(message, [content.buffer]);
                        });
                    }
                };
                selectedArchive.getDirEntryByPath(title).then(readFile).catch(function () {
                    messagePort.postMessage({ 'action': 'giveContent', 'title': title, 'content': new Uint8Array() });
                });
            } else {
                console.error("Invalid message received", event.data);
            }
        }
    }

    // Compile some regular expressions needed to modify links
    // Pattern to find a ZIM URL (with its namespace) - see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces
    var regexpZIMUrlWithNamespace = /^[./]*([-ABCIJMUVWX]\/.+)$/;
    // The case-insensitive regex below finds images, scripts, stylesheets and tracks with ZIM-type metadata and image namespaces.
    // It first searches for <img, <script, <link, etc., then scans forward to find, on a word boundary, either src=["'] or href=["']
    // (ignoring any extra whitespace), and it then tests the path of the URL with a non-capturing negative lookahead (?!...) that excludes
    // absolute URIs with protocols that conform to RFC 3986 (e.g. 'http:', 'data:'). It then captures the whole of the URL up until either
    // the opening delimiter (" or ', which is capture group \3) or a querystring or hash character (? or #). When the regex is used
    // below, it will be further processed to calculate the ZIM URL from the relative path. This regex can cope with legitimate single
    // quote marks (') in the URL.
    var regexpTagsWithZimUrl = /(<(?:img|script|link|track)\b[^>]*?\s)(?:src|href)(\s*=\s*(["']))(?![a-z][a-z0-9+.-]+:)(.+?)(?=\3|\?|#)/ig;
    // Regex below tests the html of an article for active content [kiwix-js #466]
    // It inspects every <script> block in the html and matches in the following cases: 1) the script loads a UI application called app.js,
    // init.js, or other common scripts found in unsupported ZIMs; 2) the script block has inline content that does not contain
    // "importScript()", "toggleOpenSection" or an "articleId" assignment (these strings are used widely in our fully supported wikimedia ZIMs,
    // so they are excluded); 3) the script block is not of type "math" (these are MathJax markup scripts used extensively in Stackexchange
    // ZIMs). Note that the regex will match ReactJS <script type="text/html"> markup, which is common in unsupported packaged UIs, e.g. PhET ZIMs.
    var regexpActiveContent = /<script\b(?:(?![^>]+src\b)|(?=[^>]+src\b=["'][^"']*?\b(?:app|init|l1[08]9)\.js))(?![^<]+(?:importScript\(\)|toggleOpenSection|articleId\s?=\s?['"]|window.NREUM))(?![^>]+type\s*=\s*["'](?:math\/|[^"']*?math))/i;
    // DEV: The regex below matches ZIM links (anchor hrefs) that should have the html5 "donwnload" attribute added to
    // the link. This is currently the case for epub and pdf files in Project Gutenberg ZIMs -- add any further types you need
    // to support to this regex. The "zip" has been added here as an example of how to support further filetypes
    var regexpDownloadLinks = /^.*?\.epub($|\?)|^.*?\.pdf($|\?)|^.*?\.zip($|\?)/i;

    // A string to hold any anchor parameter in clicked ZIM URLs (as we must strip these to find the article in the ZIM)
    var anchorParameter;
    
    /**
     * Display the the given HTML article in the web page,
     * and convert links to javascript calls
     * NB : in some error cases, the given title can be null, and the htmlArticle contains the error message
     * @param {DirEntry} dirEntry
     * @param {String} htmlArticle
     */
    function displayArticleContentInIframe(dirEntry, htmlArticle) {
        if(! isDirEntryExpectedToBeDisplayed(dirEntry)){
            return;
        }
        // Display Bootstrap warning alert if the landing page contains active content
        if (!params.hideActiveContentWarning && params.isLandingPage) {
            if (regexpActiveContent.test(htmlArticle)) {
                // Exempted scripts: active content warning will not be displayed if any listed script is in the html [kiwix-js #889]
                if (!/<script\b[^'"]+['"][^'"]*?mooc\.js/i.test(htmlArticle)) {
                    uiUtil.displayActiveContentWarning();
                }
            }
        }

        // Calculate the current article's ZIM baseUrl to use when processing relative links
        var baseUrl = dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '');

        // Replaces ZIM-style URLs of img, script, link and media tags with a data-kiwixurl to prevent 404 errors [kiwix-js #272 #376]
        // This replacement also processes the URL relative to the page's ZIM URL so that we can find the ZIM URL of the asset
        // with the correct namespace (this works for old-style -,I,J namespaces and for new-style C namespace)
        htmlArticle = htmlArticle.replace(regexpTagsWithZimUrl, function(match, blockStart, equals, quote, relAssetUrl) {
            var assetZIMUrl = uiUtil.deriveZimUrlFromRelativeUrl(relAssetUrl, baseUrl);
            // DEV: Note that deriveZimUrlFromRelativeUrl produces a *decoded* URL (and incidentally would remove any URI component
            // if we had captured it). We therefore re-encode the URI with encodeURI (which does not encode forward slashes) instead
            // of encodeURIComponent.
            return blockStart + 'data-kiwixurl' + equals + encodeURI(assetZIMUrl);
        });
        // We also need to process data:image/webp if the browser needs the WebPMachine
        if (webpMachine) htmlArticle = htmlArticle.replace(/(<img\b[^>]*?\s)src(\s*=\s*["'])(?=data:image\/webp)([^"']+)/ig, '$1data-kiwixurl$2$3');

        // Extract any css classes from the html tag (they will be stripped when injected in iframe with .innerHTML)
        var htmlCSS = htmlArticle.match(/<html[^>]*class\s*=\s*["']\s*([^"']+)/i);
        // Normalize classList and convert to array
        htmlCSS = htmlCSS ? htmlCSS[1].replace(/\s+/g, ' ').split(' ') : [];

        // Tell jQuery we're removing the iframe document: clears jQuery cache and prevents memory leaks [kiwix-js #361]
        $('#articleContent').contents().remove();

        // Hide any alert box that was activated in uiUtil.displayFileDownloadAlert function
        var downloadAlert = document.getElementById('downloadAlert');
        if (downloadAlert) downloadAlert.style.display = 'none';

        var iframeArticleContent = document.getElementById('articleContent');

        iframeArticleContent.onload = function() {
            iframeArticleContent.onload = function(){};
            var articleList = document.getElementById('articleList');
            var articleListHeaderMessage =  document.getElementById('articleListHeaderMessage');
            while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
            while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
            document.getElementById('articleListWithHeader').style.display = 'none';
            document.getElementById('prefix').value = '';

            var iframeContentDocument = iframeArticleContent.contentDocument;
            if (!iframeContentDocument && window.location.protocol === 'file:') {
                uiUtil.systemAlert("You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons."
                                    + "<br/><br/>The easiest way to run it is to download and run it as a browser extension (from the vendor store)."
                                    + "<br/><br/>Else you can open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)"
                                    + "<br/><br/>Another option is to force your browser to accept that (but you'll open a security breach) : on Chrome, you can start it with --allow-file-access-from-files command-line argument; on Firefox, you can set privacy.file_unique_origin to false in about:config");
                return;
            }

            // Inject the new article's HTML into the iframe
            var articleContent = iframeContentDocument.documentElement;
            // innerHTML required in this line
            articleContent.innerHTML = htmlArticle;

            var docBody = articleContent.getElementsByTagName('body');
            docBody = docBody ? docBody[0] : null;
            if (docBody) {
                // Add any missing classes stripped from the <html> tag
                if (htmlCSS) htmlCSS.forEach(function (cl) {
                    docBody.classList.add(cl);
                });
                // Deflect drag-and-drop of ZIM file on the iframe to Config
                docBody.addEventListener('dragover', handleIframeDragover);
                docBody.addEventListener('drop', handleIframeDrop);
            }

            // Set the requested appTheme
            uiUtil.applyAppTheme(params.appTheme);
            // Allow back/forward in browser history
            pushBrowserHistoryState(dirEntry.namespace + "/" + dirEntry.url);

            parseAnchorsJQuery();
            loadImagesJQuery();
            // JavaScript is currently disabled, so we need to make the browser interpret noscript tags
            // NB : if javascript is properly handled in jQuery mode in the future, this call should be removed
            // and noscript tags should be ignored
            loadNoScriptTags();
            //loadJavaScriptJQuery();
            loadCSSJQuery();
            insertMediaBlobsJQuery();
            // Jump to any anchor parameter
            if (anchorParameter) {
                var target = iframeContentDocument.getElementById(anchorParameter);
                if (target) target.scrollIntoView();
                anchorParameter = '';
            }
            if (iframeArticleContent.contentWindow) {
                // Configure home key press to focus #prefix only if the feature is in active state
                if (params.useHomeKeyToFocusSearchBar)
                    iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
                // when unloaded remove eventListener to avoid memory leaks
                iframeArticleContent.contentWindow.onunload = function () {
                    iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
                };
            }
        };

        // Load the blank article to clear the iframe (NB iframe onload event runs *after* this)
        iframeArticleContent.src = "article.html";

        function parseAnchorsJQuery() {
            var currentProtocol = location.protocol;
            var currentHost = location.host;
            // Percent-encode dirEntry.url and add regex escape character \ to the RegExp special characters - see https://www.regular-expressions.info/characters.html;
            // NB dirEntry.url can also contain path separator / in some ZIMs (Stackexchange). } and ] do not need to be escaped as they have no meaning on their own.
            var escapedUrl = encodeURIComponent(dirEntry.url).replace(/([\\$^.|?*+/()[{])/g, '\\$1');
            // Pattern to match a local anchor in an href even if prefixed by escaped url; will also match # on its own
            // Note that we exclude any # with a semicolon between it and the end of the string, to avoid accidentally matching e.g. &#39;
            var regexpLocalAnchorHref = new RegExp('^(?:#|' + escapedUrl + '#)([^#;]*$)');
            var iframe = iframeArticleContent.contentDocument;
            Array.prototype.slice.call(iframe.querySelectorAll('a, area')).forEach(function (anchor) {
                // Attempts to access any properties of 'this' with malformed URLs causes app crash in Edge/UWP [kiwix-js #430]
                try {
                    var href = anchor.href;
                } catch (err) {
                    console.error('Malformed href caused error:' + err.message);
                    return;
                }
                href = anchor.getAttribute('href');
                if (href === null || href === undefined || /^javascript:/i.test(anchor.protocol)) return;
                var anchorTarget = href.match(regexpLocalAnchorHref);
                if (href.length === 0) {
                    // It's a link with an empty href, pointing to the current page: do nothing.
                } else if (anchorTarget) {
                    // It's a local anchor link : remove escapedUrl if any (see above)
                    anchor.setAttribute('href', '#' + anchorTarget[1]);
                } else if ((anchor.protocol !== currentProtocol ||
                    anchor.host !== currentHost) && params.openExternalLinksInNewTabs) {
                    // It's an external URL : we should open it in a new tab
                    anchor.addEventListener('click', function(event) {
                        // Find the closest enclosing A tag
                        var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target);
                        uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor);
                    });
                } else {
                    // It's a link to an article or file in the ZIM
                    var uriComponent = uiUtil.removeUrlParameters(href);
                    var contentType;
                    var downloadAttrValue;
                    // Some file types need to be downloaded rather than displayed (e.g. *.epub)
                    // The HTML download attribute can be Boolean or a string representing the specified filename for saving the file
                    // For Boolean values, getAttribute can return any of the following: download="" download="download" download="true"
                    // So we need to test hasAttribute first: see https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute
                    // However, we cannot rely on the download attribute having been set, so we also need to test for known download file types
                    var isDownloadableLink = anchor.hasAttribute('download') || regexpDownloadLinks.test(href);
                    if (isDownloadableLink) {
                        downloadAttrValue = anchor.getAttribute('download');
                        // Normalize the value to a true Boolean or a filename string or true if there is no download attribute
                        downloadAttrValue = /^(download|true|\s*)$/i.test(downloadAttrValue) || downloadAttrValue || true;
                        contentType = anchor.getAttribute('type');
                    }
                    // Add an onclick event to extract this article or file from the ZIM
                    // instead of following the link
                    anchor.addEventListener('click', function (e) {
                        anchorParameter = href.match(/#([^#;]+)$/);
                        anchorParameter = anchorParameter ? anchorParameter[1] : '';
                        var zimUrl = uiUtil.deriveZimUrlFromRelativeUrl(uriComponent, baseUrl);
                        goToArticle(zimUrl, downloadAttrValue, contentType);
                        e.preventDefault();
                    });
                }
            });
        }

        function loadImagesJQuery() {
            // Make an array from the images that need to be processed
            var images = Array.prototype.slice.call(iframeArticleContent.contentDocument.querySelectorAll('img[data-kiwixurl]'));
            // This ensures cancellation of image extraction if the user navigates away from the page before extraction has finished
            images.owner = expectedArticleURLToBeDisplayed;
            // DEV: This self-invoking function is recursive, calling itself only when an image has been fully processed into a
            // blob: or data: URI (or returns an error). This ensures that images are processed sequentially from the top of the
            // DOM, making for a better user experience (because images above the fold are extracted first)
            (function extractImage() {
                if (!images.length || images.busy || images.owner !== expectedArticleURLToBeDisplayed) return;
                images.busy = true;
                // Extract the image at the top of the images array and remove it from the array
                var image = images.shift();
                var imageUrl = image.getAttribute('data-kiwixurl');
                // Decode any WebP images that are encoded as dataURIs
                if (/^data:image\/webp/i.test(imageUrl)) {
                    uiUtil.feedNodeWithDataURI(image, 'src', imageUrl, 'image/webp');
                    images.busy = false;
                    extractImage();
                    return;
                }
                var url = decodeURIComponent(imageUrl);
                selectedArchive.getDirEntryByPath(url).then(function (dirEntry) {
                    selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
                        var mimetype = dirEntry.getMimetype();
                        uiUtil.feedNodeWithDataURI(image, 'src', content, mimetype, function() {
                            images.busy = false;
                            extractImage();
                        });
                    });
                }).catch(function (e) {
                    console.error("could not find DirEntry for image:" + url, e);
                    images.busy = false;
                    extractImage();
                });
            })();
        }

        function loadNoScriptTags() {
            // For each noscript tag, we replace it with its content, so that the browser interprets it
            $('#articleContent').contents().find('noscript').replaceWith(function () {
                // When javascript is enabled, browsers interpret the content of noscript tags as text
                // (see https://html.spec.whatwg.org/multipage/scripting.html#the-noscript-element)
                // So we can read this content with .textContent
                return this.textContent;
            });
        }

        function loadCSSJQuery() {
            // Ensure all sections are open for clients that lack JavaScript support, or that have some restrictive CSP [kiwix-js #355].
            // This is needed only for some versions of ZIM files generated by mwoffliner (at least in early 2018), where the article sections are closed by default on small screens.
            // These sections can be opened by clicking on them, but this is done with some javascript.
            // The code below is a workaround we still need for compatibility with ZIM files generated by mwoffliner in 2018.
            // A better fix has been made for more recent ZIM files, with the use of noscript tags : see https://github.com/openzim/mwoffliner/issues/324
            var iframe = iframeArticleContent.contentDocument;
            var collapsedBlocks = iframe.querySelectorAll('.collapsible-block:not(.open-block), .collapsible-heading:not(.open-block)');
            // Using decrementing loop to optimize performance : see https://stackoverflow.com/questions/3520688
            for (var i = collapsedBlocks.length; i--;) {
                collapsedBlocks[i].classList.add('open-block');
            }
            var cssCount = 0;
            var cssFulfilled = 0;
            Array.prototype.slice.call(iframe.querySelectorAll('link[data-kiwixurl]')).forEach(function (link) {
                cssCount++;
                var linkUrl = link.getAttribute('data-kiwixurl');
                var url = decodeURIComponent(uiUtil.removeUrlParameters(linkUrl));
                if (cssCache.has(url)) {
                    var nodeContent = cssCache.get(url);
                    if (/stylesheet/i.test(link.rel)) uiUtil.replaceCSSLinkWithInlineCSS(link, nodeContent);
                    else uiUtil.feedNodeWithDataURI(link, 'href', nodeContent, link.type || 'image');
                    cssFulfilled++;
                } else {
                    if (params.assetsCache) document.getElementById('cachingAssets').style.display = '';
                    selectedArchive.getDirEntryByPath(url).then(function (dirEntry) {
                        if (!dirEntry) {
                            cssCache.set(url, ''); // Prevent repeated lookups of this unfindable asset
                            throw 'DirEntry ' + typeof dirEntry;
                        }
                        var mimetype = dirEntry.getMimetype();
                        var readFile = /^text\//i.test(mimetype) ? selectedArchive.readUtf8File : selectedArchive.readBinaryFile;
                        return readFile(dirEntry, function (fileDirEntry, content) {
                            var fullUrl = fileDirEntry.namespace + "/" + fileDirEntry.url;
                            if (params.assetsCache) cssCache.set(fullUrl, content);
                            if (/text\/css/i.test(mimetype)) uiUtil.replaceCSSLinkWithInlineCSS(link, content);
                            else uiUtil.feedNodeWithDataURI(link, 'href', content, mimetype);
                            cssFulfilled++;
                            renderIfCSSFulfilled(fileDirEntry.url);
                        });
                    }).catch(function (e) {
                        console.error("Could not find DirEntry for link element: " + url, e);
                        cssCount--;
                        renderIfCSSFulfilled();
                    });
                }
            });
            renderIfCSSFulfilled();

            // Some pages are extremely heavy to render, so we prevent rendering by keeping the iframe hidden
            // until all CSS content is available [kiwix-js #381]
            function renderIfCSSFulfilled(title) {
                if (cssFulfilled >= cssCount) {
                    document.getElementById('cachingAssets').textContent = 'Caching assets...';
                    document.getElementById('cachingAssets').style.display = 'none';
                    document.getElementById('searchingArticles').style.display = 'none';
                    document.getElementById('articleContent').style.display = '';
                    // We have to resize here for devices with On Screen Keyboards when loading from the article search list
                    resizeIFrame();
                } else {
                    updateCacheStatus(title);
                }
            }
        }

       /**
        * Code below is currently non-functional in jQuery mode, but provides an outline of how JS scripts could
        * be attached to the DOM. Users who want JS support should switch to ServiceWorker mode if avaialable on
        * their browser/OS. There is an experimental implementation of JS support in jQuery mode in the branch
        * <kiwix-js/javaScript-support>.
        */
        // function loadJavaScriptJQuery() {
        //     $('#articleContent').contents().find('script[data-kiwixurl]').each(function() {
        //         var script = $(this);
        //         var scriptUrl = script.attr("data-kiwixurl");
        //         // TODO check that the type of the script is text/javascript or application/javascript
        //         var title = uiUtil.removeUrlParameters(scriptUrl);
        //         selectedArchive.getDirEntryByPath(title).then(function(dirEntry) {
        //             if (dirEntry === null) {
        //                 console.log("Error: js file not found: " + title);
        //             } else {
        //                 selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
        //                     // TODO : JavaScript support not yet functional [kiwix-js #152]
        //                     uiUtil.feedNodeWithDataURI(script, 'src', content, 'text/javascript');
        //                 });
        //             }
        //         }).catch(function (e) {
        //             console.error("could not find DirEntry for javascript : " + title, e);
        //         });
        //     });
        // }

        function insertMediaBlobsJQuery() {
            var iframe = iframeArticleContent.contentDocument;
            Array.prototype.slice.call(iframe.querySelectorAll('video, audio, source, track'))
            .forEach(function(mediaSource) {
                var source = mediaSource.getAttribute('src');
                source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, baseUrl) : null;
                // We have to exempt text tracks from using deriveZimUrlFromRelativeurl due to a bug in Firefox [kiwix-js #496]
                source = source ? source : decodeURIComponent(mediaSource.dataset.kiwixurl);
                if (!source || !regexpZIMUrlWithNamespace.test(source)) {
                    if (source) console.error('No usable media source was found for: ' + source);
                    return;
                }
                var mediaElement = /audio|video/i.test(mediaSource.tagName) ? mediaSource : mediaSource.parentElement;
                // If the "controls" property is missing, we need to add it to ensure jQuery-only users can operate the video. See kiwix-js #760.
                if (/audio|video/i.test(mediaElement.tagName) && !mediaElement.hasAttribute('controls')) mediaElement.setAttribute('controls', '');
                selectedArchive.getDirEntryByPath(source).then(function(dirEntry) {
                    return selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, mediaArray) {
                        var mimeType = mediaSource.type ? mediaSource.type : dirEntry.getMimetype();
                        var blob = new Blob([mediaArray], { type: mimeType });
                        mediaSource.src = URL.createObjectURL(blob);
                        // In Firefox and Chromium it is necessary to re-register the inserted media source
                        // but do not reload for text tracks (closed captions / subtitles)
                        if (/track/i.test(mediaSource.tagName)) return;
                        mediaElement.load();
                    });
                });
            });
        }
    }

    /**
     * Displays a message to the user that a style or other asset is being cached
     * @param {String} title The title of the file to display in the caching message block
     */
    function updateCacheStatus(title) {
        if (params.assetsCache && /\.css$|\.js$/i.test(title)) {
            var cacheBlock = document.getElementById('cachingAssets');
            cacheBlock.style.display = 'block';
            title = title.replace(/[^/]+\//g, '').substring(0,18);
            cacheBlock.textContent = 'Caching ' + title + '...';
        }
    }

    /**
     * Changes the URL of the browser page, so that the user might go back to it
     *
     * @param {String} title
     * @param {String} titleSearch
     */
    function pushBrowserHistoryState(title, titleSearch) {
        var stateObj = {};
        var urlParameters;
        var stateLabel;
        if (title && !(""===title)) {
            // Prevents creating a double history for the same page
            if (history.state && history.state.title === title) return;
            stateObj.title = title;
            urlParameters = "?title=" + title;
            stateLabel = "Wikipedia Article : " + title;
        } else if (titleSearch && !(""===titleSearch)) {
            stateObj.titleSearch = titleSearch;
            urlParameters = "?titleSearch=" + titleSearch;
            stateLabel = "Wikipedia search : " + titleSearch;
        } else {
            return;
        }
        window.history.pushState(stateObj, stateLabel, urlParameters);
    }


    /**
     * Extracts the content of the given article pathname, or a downloadable file, from the ZIM
     *
     * @param {String} path The pathname (namespace + filename) to the article or file to be extracted
     * @param {Boolean|String} download A Bolean value that will trigger download of title, or the filename that should
     *     be used to save the file in local FS (in HTML5 spec, a string value for the download attribute is optional)
     * @param {String} contentType The mimetype of the downloadable file, if known
     */
    function goToArticle(path, download, contentType) {
        document.getElementById('searchingArticles').style.display = '';
        selectedArchive.getDirEntryByPath(path).then(function(dirEntry) {
            var mimetype = contentType || dirEntry ? dirEntry.getMimetype() : '';
            if (dirEntry === null || dirEntry === undefined) {
                document.getElementById('searchingArticles').style.display = 'none';
                uiUtil.systemAlert("Article with url " + path + " not found in the archive", "Error: article not found");
            } else if (download || /\/(epub|pdf|zip|.*opendocument|.*officedocument|tiff|mp4|webm|mpeg|mp3|octet-stream)\b/i.test(mimetype)) {
                download = true;
                selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
                    uiUtil.displayFileDownloadAlert(path, download, mimetype, content);
                });
            } else {
                params.isLandingPage = false;
                var activeContent = document.getElementById('activeContent');
                if (activeContent) activeContent.style.display = 'none';
                readArticle(dirEntry);
            }
        }).catch(function(e) { uiUtil.systemAlert("Error reading article with url " + path + " : " + e, "Error while reading article"); });
    }

    function goToRandomArticle() {
        if (selectedArchive !== null && selectedArchive.isReady()) {
            document.getElementById('searchingArticles').style.display = '';
            selectedArchive.getRandomDirEntry(function (dirEntry) {
                if (dirEntry === null || dirEntry === undefined) {
                    document.getElementById('searchingArticles').style.display = 'none';
                    uiUtil.systemAlert('Error finding random article', 'Error finding article');
                } else {
                    // We fall back to the old A namespace to support old ZIM files without a text/html MIME type for articles
                    // DEV: If articlePtrPos is defined in zimFile, then we are using a v1 article-only title listing. By definition,
                    // all dirEntries in an article-only listing must be articles.
                    if (selectedArchive._file.articlePtrPos || dirEntry.getMimetype() === 'text/html' || dirEntry.namespace === 'A') {
                        params.isLandingPage = false;
                        var activeContent = document.getElementById('activeContent');
                        if (activeContent) activeContent.style.display = 'none';
                        document.getElementById('searchingArticles').style.display = '';
                        readArticle(dirEntry);
                    } else {
                        // If the random title search did not end up on an article,
                        // we try again, until we find one
                        goToRandomArticle();
                    }
                }
            });
        } else {
            // Showing the relevant error message and redirecting to config page for adding the ZIM file
            uiUtil.systemAlert('Archive not set : please select an archive', 'No archive selected').then(function () {
                document.getElementById('btnConfigure').click();
            });
        }
    }

    function goToMainArticle() {
        document.getElementById('searchingArticles').style.display = '';
        selectedArchive.getMainPageDirEntry(function(dirEntry) {
            if (dirEntry === null || dirEntry === undefined) {
                console.error("Error finding main article.");
                document.getElementById('searchingArticles').style.display = 'none';
                document.getElementById('welcomeText').style.display = '';
            } else {
                // For now, this code doesn't support reading Zimit archives without error, so we warn the user and suggest some solutions
                if (selectedArchive._file.zimType === 'zimit') {
                    uiUtil.systemAlert('<p>You are attempting to open a Zimit-style archive, which is currently unsupported in this app.</p>' +
                        '<p>There is experimental support for this kind of archive in the Kiwix JS PWA. Go to: ' +
                        '<a href="https://pwa.kiwix.org" target="_blank">https://pwa.kiwix.org</a>.</p>' +
                        '<p>Alternatively, you can use Kiwix Serve to serve this archive to your browser from localhost. ' + 
                        'Kiwix Serve is included with <a href="https://www.kiwix.org/en/download/" target="_blank">Kiwix Desktop</a>.</p>',
                        'Unsupported archive type!'
                    );
                    document.getElementById('searchingArticles').style.display = 'none';
                    document.getElementById('welcomeText').style.display = '';
                } else {
                    // DEV: see comment above under goToRandomArticle()
                    if (dirEntry.redirect || dirEntry.getMimetype() === 'text/html' || dirEntry.namespace === 'A') {
                        params.isLandingPage = true;
                        readArticle(dirEntry);
                    } else {
                        console.error("The main page of this archive does not seem to be an article");
                        document.getElementById('searchingArticles').style.display = 'none';
                        document.getElementById('welcomeText').style.display = '';
                    }
                }
            }
        });
    }

});
