/*! * app.js : The main Kiwix User Interface implementation * This file handles the interaction between the Kiwix JS back end and the user * * Copyright 2013-2024 Mossroy, Jaifroid and contributors * Licence 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 Licence as published by * the Free Software Foundation, either version 3 of the Licence, 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 Licence for more details. * * You should have received a copy of the GNU General Public Licence * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ 'use strict'; // The global parameters object is defined in init.js /* global params, webpMachine */ // import styles from '../css/app.css' assert { type: "css" }; // import bootstrap from '../css/bootstrap.min.css' assert { type: "css" }; import '../../node_modules/@fortawesome/fontawesome-free/js/all.js'; import zimArchiveLoader from './lib/zimArchiveLoader.js'; import uiUtil from './lib/uiUtil.js'; import popovers from './lib/popovers.js'; import settingsStore from './lib/settingsStore.js'; import abstractFilesystemAccess from './lib/abstractFilesystemAccess.js'; import translateUI from './lib/translateUI.js'; import kiwixLibrary from './lib/kiwixLibrary.js'; if (params.abort) { // If the app was loaded only to pass a message from the remote code, then we exit immediately throw new Error('Managed error: exiting local extension code.') } /** * 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'; /** * A global object for storing app state * * @type Object */ var appstate = {}; /** * @type ZIMArchive | null */ var selectedArchive = null; // 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 // Placeholders for the article container, the article window, and the search-article area const articleContainer = document.getElementById('articleContent'); const articleWindow = articleContainer.contentWindow; const region = document.getElementById('search-article'); switchHomeKeyToFocusSearchBar(); // 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 an 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|chrome)-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); } if (!/^chrome-extension:/i.test(window.location.protocol)) { document.getElementById('serviceWorkerLocal').style.display = 'none'; document.getElementById('serviceWorkerLocalDescription').style.display = 'none'; } // At launch, we set the correct content injection mode setContentInjectionMode(params.contentInjectionMode); // Define frequently used UI elements const globalDropZone = document.getElementById('search-article'); const folderSelect = document.getElementById('folderSelect'); const archiveFiles = document.getElementById('archiveFiles'); // Unique identifier of the article expected to be displayed appstate.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 () { const headerStyles = getComputedStyle(document.getElementById('top')); const library = document.getElementById('library'); const libraryContent = document.getElementById('libraryContent'); const liHomeNav = document.getElementById('liHomeNav'); const nestedFrame = libraryContent.contentWindow.document.getElementById('libraryIframe'); // There is a race condition with the slide animations, so we have to wait more than 300ms setTimeout(function () { uiUtil.showSlidingUIElements(); if (library.style.display !== 'none') { // We are in Library, so we set the height of the library iframes to the window height minus the header height const headerHeight = parseFloat(headerStyles.height) + parseFloat(headerStyles.marginBottom); libraryContent.style.height = window.innerHeight + 'px'; nestedFrame.style.height = window.innerHeight - headerHeight + 'px'; region.style.overflowY = 'hidden'; } else if (!liHomeNav.classList.contains('active')) { // We are not in Home, so we reset the region height region.style.height = 'auto'; region.style.overflowY = 'auto'; } else { // Get header height *including* its bottom margin const headerHeight = parseFloat(headerStyles.height) + parseFloat(headerStyles.marginBottom); articleContainer.style.height = window.innerHeight - headerHeight + 'px'; // Hide the scrollbar of Configure / About region.style.overflowY = 'hidden'; } // IE cannot retrieve computed headerStyles till the next paint, so we wait a few ticks even if UI animations are disabled }, params.showUIAnimations ? 400 : 100); // Get the contentWindow of the iframe to operate on var thisArticleWindow = articleWindow; if (articleWindow.document && articleWindow.document.getElementById('replay_iframe')) { thisArticleWindow = articleContainer.contentWindow.document.getElementById('replay_iframe').contentWindow; } // Remove and add the scroll event listener to the new article window // Note that IE11 doesn't support wheel or touch events on the iframe, but it does support keydown and scroll thisArticleWindow.removeEventListener('scroll', uiUtil.scroller); thisArticleWindow.removeEventListener('touchstart', uiUtil.scroller); thisArticleWindow.removeEventListener('touchend', uiUtil.scroller); thisArticleWindow.removeEventListener('wheel', uiUtil.scroller); thisArticleWindow.removeEventListener('keydown', uiUtil.scroller); if (params.slideAway) { thisArticleWindow.addEventListener('scroll', uiUtil.scroller); thisArticleWindow.addEventListener('touchstart', uiUtil.scroller); thisArticleWindow.addEventListener('touchend', uiUtil.scroller); thisArticleWindow.addEventListener('wheel', uiUtil.scroller); thisArticleWindow.addEventListener('keydown', uiUtil.scroller); } } document.addEventListener('DOMContentLoaded', function () { getDefaultLanguageAndTranslateApp(); resizeIFrame(); abstractFilesystemAccess.loadPreviousZimFile(); }); window.addEventListener('resize', resizeIFrame); // Define behavior of HTML elements var searchArticlesFocused = false; const searchArticle = document.getElementById('searchArticles') searchArticle.addEventListener('click', function () { var prefix = document.getElementById('prefix').value; // Do not initiate the same search if it is already in progress if (prefix !== '' && 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); const footerHeight = document.getElementById('footer').getBoundingClientRect().height; region.style.height = window.innerHeight - footerHeight + 'px'; region.style.overflowY = 'auto'; // Initiate the search searchDirEntriesFromPrefix(prefix); var navbarCollapse = document.querySelector('.navbar-collapse'); navbarCollapse.classList.remove('show'); document.getElementById('prefix').focus(); // This flag is set to true in the mousedown event below searchArticlesFocused = false; }); searchArticle.addEventListener('mousedown', function () { // We set the flag so that the blur event of #prefix can know that the searchArticles button has been clicked searchArticlesFocused = true; }); document.getElementById('formArticleSearch').addEventListener('submit', function () { document.getElementById('searchArticles').click(); }); function getDefaultLanguageAndTranslateApp () { var defaultBrowserLanguage = uiUtil.getBrowserLanguage(); // DEV: Be sure to add supported language codes here // TODO: Add a supported languages object elsewhere and use it here if (!params.overrideBrowserLanguage) { if (/^en|es|fr$/.test(defaultBrowserLanguage.base)) { console.log('Supported default browser language is: ' + defaultBrowserLanguage.base + ' (' + defaultBrowserLanguage.locale + ')'); } else { console.warn('Unsupported browser language! ' + defaultBrowserLanguage.base + ' (' + defaultBrowserLanguage.locale + ')'); console.warn('Reverting to English'); defaultBrowserLanguage.base = 'en'; defaultBrowserLanguage.name = 'GB'; params.overrideBrowserLanguage = 'en'; } } else { console.log('User-selected language is: ' + params.overrideBrowserLanguage); } // Use the override language if set, or else use the browser default var languageCode = params.overrideBrowserLanguage || defaultBrowserLanguage.base; translateUI.translateApp(languageCode) .catch(function (err) { if (languageCode !== 'en') { var message = '

We cannot load the translation strings for language code ' + languageCode + ''; // if (/^file:\/\//.test(window.location.href)) { // message += ' because you are accessing Kiwix from the file system. Try using a web server instead'; // } message += '.

Falling back to English...

'; if (err) message += '

The error message was:

' + err + ''; uiUtil.systemAlert(message); document.getElementById('languageSelector').value = 'en'; return translateUI.translateApp('en'); } }); } // Add a listener for the language selection dropdown which will change the language of the app document.getElementById('languageSelector').addEventListener('change', function (e) { var language = e.target.value; if (language === 'other') { uiUtil.systemAlert((translateUI.t('dialog-other-language-message') || 'We are working hard to bring you more languages! If you are interested in helping to translate the interface to your language, please create an issue on our GitHub. Thank you!'), (translateUI.t('configure-language-selector-other') || 'More soon...')).then(function () { document.getElementById('languageSelector').value = params.overrideBrowserLanguage || 'default'; }); } else if (language === 'default') { params.overrideBrowserLanguage = null; settingsStore.removeItem('languageOverride'); } else { params.overrideBrowserLanguage = language; settingsStore.setItem('languageOverride', language, Infinity); } getDefaultLanguageAndTranslateApp(); }); const prefixElement = document.getElementById('prefix'); // Handle keyboard events in the prefix (article search) field var keyPressHandled = false; prefixElement.addEventListener('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 prefixElement.addEventListener('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 prefixElement.addEventListener('focus', function () { if (document.getElementById('prefix').value !== '') { region.style.overflowY = 'auto'; const footerHeight = document.getElementById('footer').getBoundingClientRect().height; region.style.height = window.innerHeight - footerHeight + 'px'; document.getElementById('articleListWithHeader').style.display = ''; } }); // Hide the search results if user moves out of prefix field prefixElement.addEventListener('blur', function () { if (!searchArticlesFocused) { appstate.search.status = 'cancelled'; region.style.overflowY = 'hidden'; region.style.height = 'auto'; uiUtil.spinnerDisplay(false); document.getElementById('articleListWithHeader').style.display = 'none'; } }); document.getElementById('btnRandomArticle').addEventListener('click', function (event) { event.preventDefault(); document.getElementById('prefix').value = ''; goToRandomArticle(); document.getElementById('welcomeText').style.display = 'none'; document.getElementById('articleListWithHeader').style.display = 'none'; var navbarCollapse = document.querySelector('.navbar-collapse'); navbarCollapse.classList.remove('show'); }); document.getElementById('btnRescanDeviceStorage').addEventListener('click', function () { searchForArchivesInStorage(); }); // Bottom bar : document.getElementById('btnBack').addEventListener('click', function (event) { event.preventDefault(); history.back(); }); document.getElementById('btnForward').addEventListener('click', function (event) { event.preventDefault(); history.forward(); }); document.getElementById('btnHomeBottom').addEventListener('click', function (event) { event.preventDefault(); document.getElementById('btnHome').click(); }); document.getElementById('btnTop').addEventListener('click', function (event) { event.preventDefault(); var articleContent = document.getElementById('articleContent'); articleContent.contentWindow.scrollTo({ top: 0, behavior: 'smooth' }); }); // Top menu : document.getElementById('btnHome').addEventListener('click', function (event) { // Highlight the selected section in the navbar event.preventDefault(); document.getElementById('liHomeNav').setAttribute('class', 'active'); document.getElementById('liConfigureNav').setAttribute('class', ''); document.getElementById('liAboutNav').setAttribute('class', ''); var navbarCollapse = document.querySelector('.navbar-collapse'); navbarCollapse.classList.remove('show'); // Show the selected content in the page uiUtil.tabTransitionToSection('home', params.showUIAnimations); // Give the focus to the search field, and clean up the page contents document.getElementById('prefix').value = ''; if (params.useHomeKeyToFocusSearchBar) 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); uiUtil.spinnerDisplay(false); // 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); }); document.getElementById('btnConfigure').addEventListener('click', function (event) { event.preventDefault(); if (uiUtil.fromSection() === 'config') { uiUtil.returnToCurrentPage(); } else { // Highlight the selected section in the navbar document.getElementById('liHomeNav').setAttribute('class', ''); document.getElementById('liConfigureNav').setAttribute('class', 'active'); document.getElementById('liAboutNav').setAttribute('class', ''); var navbarCollapse = document.querySelector('.navbar-collapse'); navbarCollapse.classList.remove('show'); // Show the selected content in the page uiUtil.tabTransitionToSection('config', params.showUIAnimations); refreshAPIStatus(); refreshCacheStatus(); uiUtil.checkUpdateStatus(appstate); // Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms setTimeout(resizeIFrame, 400); } }); document.getElementById('btnAbout').addEventListener('click', function (event) { event.preventDefault(); if (uiUtil.fromSection() === 'about') { uiUtil.returnToCurrentPage(); } else { // Highlight the selected section in the navbar document.getElementById('liHomeNav').setAttribute('class', ''); document.getElementById('liConfigureNav').setAttribute('class', ''); document.getElementById('liAboutNav').setAttribute('class', 'active'); var navbarCollapse = document.querySelector('.navbar-collapse'); navbarCollapse.classList.remove('show'); // Show the selected content in the page uiUtil.tabTransitionToSection('about', params.showUIAnimations); // Use a timeout of 400ms because uiUtil.applyAnimationToSection uses a timeout of 300ms setTimeout(resizeIFrame, 400); } }); document.querySelectorAll('input[name="contentInjectionMode"][type="radio"]').forEach(function (element) { element.addEventListener('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((translateUI.t('dialog-reset-warning-message') || 'This will reset the app to a freshly installed state, deleting all app caches and settings!'), (translateUI.t('dialog-reset-warning-title') || 'WARNING!'), true).then(function (response) { if (response) { settingsStore.reset(); } }) }); document.getElementById('bypassAppCacheCheck').addEventListener('change', function () { if (params.contentInjectionMode !== 'serviceworker') { uiUtil.systemAlert(translateUI.t('dialog-bypassappcachecheck-message') || '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(); }); if (params.useLibzim) document.getElementById('libzimMode').style.display = ''; document.getElementById('libzimModeSelect').addEventListener('change', function (e) { settingsStore.setItem('libzimMode', e.target.value); window.location.reload(); }); document.getElementById('useLibzim').addEventListener('click', function (e) { settingsStore.setItem('useLibzim', !params.useLibzim); window.location.reload(); }); document.getElementById('disableDragAndDropCheck').addEventListener('change', function () { params.disableDragAndDrop = !!this.checked; settingsStore.setItem('disableDragAndDrop', params.disableDragAndDrop, Infinity); uiUtil.systemAlert((translateUI.t('dialog-disabledragdrop-message') || '

We will now attempt to reload the app to apply the new setting.

' + '

(If you cancel, then the setting will only be applied when you next start the app.)

'), (translateUI.t('dialog-disabledragdrop-title') || 'Reload app'), true).then(function (result) { if (result) { window.location.reload(); } }); }); // Handle switching from jQuery to serviceWorker modes. document.getElementById('serviceworkerModeRadio').addEventListener('click', async function () { document.getElementById('enableSourceVerificationCheckBox').style.display = ''; if (selectedArchive.isReady() && !(settingsStore.getItem('trustedZimFiles').includes(selectedArchive.file.name)) && params.sourceVerification) { await verifyLoadedArchive(selectedArchive); } }); document.getElementById('jqueryModeRadio').addEventListener('click', function () { if (this.checked) { document.getElementById('enableSourceVerificationCheckBox').style.display = 'none'; } }); // Handle switching to serviceWorkerLocal mode for chrome-extension document.getElementById('serviceworkerLocalModeRadio').addEventListener('click', async function () { document.getElementById('enableSourceVerificationCheckBox').style.display = ''; if (selectedArchive.isReady() && !(settingsStore.getItem('trustedZimFiles').includes(selectedArchive.file.name)) && params.sourceVerification) { await verifyLoadedArchive(selectedArchive); } }); // Source verification is only makes sense in SW mode as doing the same in jQuery mode is redundant. document.getElementById('enableSourceVerificationCheckBox').style.display = params.contentInjectionMode === ('serviceworker' || 'serviceworkerlocal') ? 'block' : 'none'; document.getElementById('enableSourceVerification').addEventListener('change', function () { params.sourceVerification = this.checked; settingsStore.setItem('sourceVerification', this.checked, Infinity); }); document.querySelectorAll('input[type="checkbox"][name=hideActiveContentWarning]').forEach(function (element) { element.addEventListener('change', function () { params.hideActiveContentWarning = !!this.checked; settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity); }) }); document.getElementById('hideExternalLinkWarningCheck').addEventListener('change', function () { params.hideExternalLinkWarning = this.checked; settingsStore.setItem('hideExternalLinkWarning', params.hideExternalLinkWarning, Infinity); }) document.getElementById('slideAwayCheck').addEventListener('change', function (e) { params.slideAway = e.target.checked; if (typeof navigator.getDeviceStorages === 'function') { // We are in Firefox OS, which may have a bug with this setting turned on - see [kiwix-js #1140] uiUtil.systemAlert(translateUI.t('dialog-slideawaycheck-message') || ('This setting may not work correctly on Firefox OS. ' + 'If you find that some ZIM links become unresponsive, try turning this setting off.'), translateUI.t('dialog-warning') || 'Warning'); } settingsStore.setItem('slideAway', params.slideAway, Infinity); // This has methods to add or remove the event listeners needed resizeIFrame(); }); document.querySelectorAll('input[type="checkbox"][name=showUIAnimations]').forEach(function (element) { element.addEventListener('change', function () { params.showUIAnimations = !!this.checked; settingsStore.setItem('showUIAnimations', params.showUIAnimations, Infinity); }) }); document.getElementById('useHomeKeyToFocusSearchBarCheck').addEventListener('change', function (e) { params.useHomeKeyToFocusSearchBar = e.target.checked; settingsStore.setItem('useHomeKeyToFocusSearchBar', params.useHomeKeyToFocusSearchBar, Infinity); switchHomeKeyToFocusSearchBar(); if (params.useHomeKeyToFocusSearchBar && params.slideAway) { uiUtil.systemAlert(translateUI.t('dialog-focussearchbarcheck-message') || 'Please note that this setting focuses the search bar when you go to a ZIM landing page, disabling sliding away of header and footer on that page (only).', translateUI.t('dialog-warning') || 'Warning'); } }); document.querySelectorAll('input[type="checkbox"][name=openExternalLinksInNewTabs]').forEach(function (element) { element.addEventListener('change', function () { params.openExternalLinksInNewTabs = !!this.checked; settingsStore.setItem('openExternalLinksInNewTabs', params.openExternalLinksInNewTabs, Infinity); }) }); document.getElementById('reopenLastArchiveCheck').addEventListener('change', function (e) { params.reopenLastArchive = e.target.checked; settingsStore.setItem('reopenLastArchive', params.reopenLastArchive, Infinity); }); document.getElementById('appThemeSelect').addEventListener('change', function (e) { params.appTheme = e.target.value; settingsStore.setItem('appTheme', params.appTheme, Infinity); uiUtil.applyAppTheme(params.appTheme); refreshCacheStatus(); }); 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('showPopoverPreviewsCheck').addEventListener('change', function (e) { params.showPopoverPreviews = e.target.checked; settingsStore.setItem('showPopoverPreviews', params.showPopoverPreviews, Infinity); }); // Add event listeners to the About links in Configuration, so that they jump to the linked sections document.querySelectorAll('.aboutLinks').forEach(function (link) { link.addEventListener('click', function () { var anchor = link.getAttribute('href'); 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.querySelector(anchor).scrollIntoView(); }, 600); }); }); // Do update checks 7s after startup setTimeout(function () { console.log('Checking for updates to the PWA...'); uiUtil.checkUpdateStatus(appstate); }, 7000); // 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); } } /** * Verifies the given archive and switches contentInjectionMode accourdingly * @param {ZIMArchive} archive The archive that needs verification * */ async function verifyLoadedArchive (archive) { // We construct an HTML element to show the user the alert with the metadata contained in it const metadataLabels = { name: translateUI.t('dialog-metadata-name') || 'Name: ', creator: translateUI.t('dialog-metadata-creator') || 'Creator: ', publisher: translateUI.t('dialog-metadata-publisher') || 'Publisher: ', scraper: translateUI.t('dialog-metadata-scraper') || 'Scraper: ' } const verificationBody = document.createElement('div'); // Text & metadata box const verificationText = document.createElement('p'); verificationText.innerHTML = translateUI.t('dialog-sourceverification-alert') || 'Is this ZIM archive from a trusted source?\n If not, you can still read the ZIM file in Restricted Mode. Closing this window also opens the file in Restricted Mode. This option can be disabled in Expert Settings.'; const metadataBox = document.createElement('div'); metadataBox.id = 'modal-archive-metadata-container'; const verifyName = document.createElement('p'); verifyName.id = 'confirm-archive-name'; verifyName.classList.add('archive-metadata'); verifyName.innerText = metadataLabels.name + (archive.name || '-'); const verifyCreator = document.createElement('p'); verifyCreator.id = 'confirm-archive-creator'; verifyCreator.classList.add('archive-metadata') verifyCreator.innerText = metadataLabels.creator + (archive.creator || '-'); const verifyPublisher = document.createElement('p'); verifyPublisher.id = 'confirm-archive-publisher'; verifyPublisher.classList.add('archive-metadata'); verifyPublisher.innerText = metadataLabels.publisher + (archive.publisher || '-'); const verifyScraper = document.createElement('p'); verifyScraper.id = 'confirm-archive-scraper'; verifyScraper.classList.add('archive-metadata'); verifyScraper.innerText = metadataLabels.scraper + (archive.scraper || '-'); const verifyWarning = document.createElement('p'); verifyWarning.id = 'modal-archive-metadata-warning'; verifyWarning.innerHTML = translateUI.t('dialog-metadata-warning') || 'Warning: above data can be spoofed!'; metadataBox.append(verifyName, verifyCreator, verifyPublisher, verifyScraper); verificationBody.append(verificationText, metadataBox, verifyWarning); const response = await uiUtil.systemAlert( verificationBody.outerHTML, translateUI.t('dialog-sourceverification-title') || 'Security alert!', true, translateUI.t('dialog-sourceverification-restricted-mode-button') || 'Open in Restricted Mode', translateUI.t('dialog-sourceverification-trust-button') || 'Trust Source' ); if (response) { params.contentInjectionMode = 'serviceworker'; var trustedZimFiles = settingsStore.getItem('trustedZimFiles'); var updatedTrustedZimFiles = trustedZimFiles + archive.file.name + '|'; settingsStore.setItem('trustedZimFiles', updatedTrustedZimFiles, Infinity); // Change radio buttons accordingly if (params.serviceWorkerLocal) { document.getElementById('serviceworkerLocalModeRadio').checked = true; } else { document.getElementById('serviceworkerModeRadio').checked = true; } } else { // Switch to Restricted mode params.contentInjectionMode = 'jquery'; document.getElementById('jqueryModeRadio').checked = true; } } // 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); } else { // When the feature is not active, 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 = [(translateUI.t('dialog-serviceworker-defaultmodechange-message') || '

We have switched you to ServiceWorker mode (this is now the default). ' + 'It supports more types of ZIM archives and is much more robust.

' + '

If you experience problems with this mode, you can switch back to Restricted mode. ' + 'In that case, please report the problems you experienced to us (see About section).

'), (translateUI.t('dialog-serviceworker-defaultmodechange-title') || '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 = [(translateUI.t('dialog-serviceworker-unsupported-message') || '

Unfortunately, your browser does not appear to support ServiceWorker mode, which is now the default for this app.

' + '

You can continue to use the app in Restricted mode, but note that this mode only works well with ' + 'ZIM archives that have static content, such as Wikipedia / Wikimedia ZIMs or Stackexchange.

' + '

If you can, we recommend that you update your browser to a version that supports ServiceWorker mode.

'), (translateUI.t('dialog-serviceworker-unsupported-title') || 'ServiceWorker mode unsupported')]; uiUtil.systemAlert(message[0], message[1], true, null, (translateUI.t('dialog-ok') || '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 () { // We have to delay refreshing the API status until the translation service has been initialized setTimeout(function () { 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 = translateUI.t('api-messagechannel-available') || 'MessageChannel API available'; messageChannelStatus.classList.remove('apiAvailable', 'apiUnavailable'); messageChannelStatus.classList.add('apiAvailable'); } else { apiPanelClass = 'card-warning'; messageChannelStatus.textContent = translateUI.t('api-messagechannel-unavailable') || 'MessageChannel API unavailable'; messageChannelStatus.classList.remove('apiAvailable', 'apiUnavailable'); messageChannelStatus.classList.add('apiUnavailable'); } if (isServiceWorkerAvailable()) { if (isServiceWorkerReady()) { serviceWorkerStatus.textContent = translateUI.t('api-serviceworker-available-registered') || 'ServiceWorker API available, and registered'; serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable'); serviceWorkerStatus.classList.add('apiAvailable'); } else { apiPanelClass = 'card-warning'; serviceWorkerStatus.textContent = translateUI.t('api-serviceworker-available-unregistered') || 'ServiceWorker API available, but not registered'; serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable'); serviceWorkerStatus.classList.add('apiUnavailable'); } } else { apiPanelClass = 'card-warning'; serviceWorkerStatus.textContent = translateUI.t('api-serviceworker-unavailable') || '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' ? (translateUI.t('api-cookie') || 'Cookie') : params.storeType === 'local_storage' ? (translateUI.t('api-localstorage') || 'Local Storage') : (translateUI.t('api-none') || 'None'); settingsStoreStatusDiv.textContent = (translateUI.t('api-storage-used-label') || '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 += ' [ ' + params.decompressorAPI.decompressorLastUsed + ' ]'; } apiName = params.decompressorAPI.errorStatus || apiName || (translateUI.t('api-error-uninitialized_feminine') || 'Not initialized'); // innerHTML is used here because the API name may contain HTML entities like   decompAPIStatusDiv.innerHTML = (translateUI.t('api-decompressor-label') || 'Decompressor API:') + ' ' + apiName; // Update Search Provider uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider); // Update PWA origin var pwaOriginStatusDiv = document.getElementById('pwaOriginStatus'); pwaOriginStatusDiv.className = 'apiAvailable'; pwaOriginStatusDiv.innerHTML = (translateUI.t('api-pwa-origin-label') || '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(); }, 250); } /** * 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} 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 { if (cache.type === 'cacheAPI' && selectedArchive && selectedArchive.zimType === 'zimit' && appstate.isReplayWorkerAvailable) { cache.type = 'replayWorker'; cache.description = 'ReplayWorker'; cache.count = '-'; } 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: selectedArchive ? selectedArchive.cssCache.size : 0 }); } }); } /** * 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; // Change app's background colour if the bypass appCacche setting is enabled, as a visible warning if (params.appCache) { document.documentElement.style.removeProperty('background'); } else { document.documentElement.style.background = /^dark/.test(document.documentElement.dataset.theme) ? '#300000' : 'mistyrose'; } // Hide or show the jqueryCompatibility info document.getElementById('jqueryCompatibility').style.display = params.contentInjectionMode === 'jquery' ? '' : 'none'; // 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 serviceWorkerRegistration = null; /** * Sends an 'init' message to the ServiceWorker and inititalizes the onmessage event * It is called when the Service Worker is first activated, and also when a new archive is loaded * When a message is received, it will provide a MessageChannel port to respond to the ServiceWorker */ function initServiceWorkerMessaging () { if (!(isServiceWorkerAvailable() && isMessageChannelAvailable())) { console.warn('Cannot initiate ServiceWorker messaging, because one or more API is unavailable!'); return; }; // Create a message listener navigator.serviceWorker.onmessage = function (event) { if (event.data.error) { console.error('Error in MessageChannel', event.data.error); throw event.data.error; } else if (event.data.action === 'acknowledge') { // The Service Worker is acknowledging receipt of init message console.log('SW acknowledged init message'); serviceWorkerRegistration = true; refreshAPIStatus(); } else if (event.data.action === 'askForContent') { // The Service Worker is asking for content. Check we have a loaded ZIM in this instance. // DEV: This can happen if there are various instances of the app open in different tabs or windows, and no archive has been selected in this instance. if (!selectedArchive) { console.warn('Message from SW received, but no archive is selected!'); return; } // See below for explanation of this exception const videoException = selectedArchive.zimType === 'zimit' && /\/\/youtubei.*player/.test(event.data.title); // Check that the zimFileId in the messageChannel event data is the same as the one in the currently open archive // Because the SW broadcasts its request to all open tabs or windows, we need to check that the request is for this instance if (event.data.zimFileName !== selectedArchive.file.name && !videoException) { // Do nothing if the request is not for this instance // console.debug('SW request does not match this instance', '[zimFileName:' + event.data.zimFileName + ' !== ' + selectedArchive.file.name + ']'); } else { if (videoException) { // DEV: This is a hack to allow YouTube videos to play in Zimit archives: // Because links are embedded in a nested iframe, the SW cannot identify the top-level window from which to request the ZIM content // Until we find a way to tell where it is coming from, we allow the request through on all controlled clients and try to load the content console.warn('>>> Allowing passthrough of SW request to process Zimit video <<<'); } if (params.useLibzim) handleMessageChannelByLibzim(event); else handleMessageChannelMessage(event); } } else if (event.data.msg_type) { // Messages received from the ReplayWorker if (event.data.msg_type === 'colAdded') { console.debug('ReplayWorker added a collection'); } } else { console.error('Invalid message received', event.data); } }; // Send the init message to the ServiceWorker if (navigator.serviceWorker.controller) { console.log('Initializing SW messaging...'); navigator.serviceWorker.controller.postMessage({ action: 'init' }); } else if (serviceWorkerRegistration) { // If this is the first time we are initiating the SW, allow Promises to complete and assets to be fetched by delaying potential reload console.warn('The Service Worker needs more time to load, or else the app was force-refreshed...'); serviceWorkerRegistration = null; setTimeout(initServiceWorkerMessaging, 3000); } else if (params.contentInjectionMode === 'serviceworker') { console.error('The Service Worker is not controlling the current page! We have to reload.'); // Turn off failsafe, as this is a controlled reboot settingsStore.setItem('lastPageLoad', 'rebooting', Infinity); if (!appstate.preventAutoReboot) window.location.reload(); } else if (/^https/.test(window.location.protocol) && navigator && navigator.serviceWorker && !navigator.serviceWorker.controller) { if (!params.noPrompts) { uiUtil.systemAlert('

No Service Worker is registered, meaning this app will not currently work offline!

Would you like to switch to ServiceWorker mode?

', 'Offline use is disabled!', true).then(function (response) { if (response) { setContentInjectionMode('serviceworker'); if (selectedArchive) { setTimeout(function () { params.themeChanged = true; document.getElementById('btnHome').click(); }, 800); } } }); } } } /** * Function that handles a message of the messageChannel. * This function will deal with the messages if useLibzim is set to true * * @param {Event} event The event object of the message channel */ async function handleMessageChannelByLibzim (event) { // We received a message from the ServiceWorker // The ServiceWorker asks for some content const title = event.data.title; const messagePort = event.ports[0]; try { const ret = await selectedArchive.callLibzimWorker({ action: 'getEntryByPath', path: title }) if (ret === null) { console.error('Title ' + title + ' not found in archive.'); messagePort.postMessage({ action: 'giveContent', title: title, content: '' }); return; } if (ret.mimetype === 'unknown') { // We have a redirect to follow // this is still a bit flawed, as we do not check if it's a redirect or the file doesn't exist // We have no way to know if the file exists or not, so we have to assume it does and its just a redirect const dirEntry = await new Promise((resolve, _reject) => selectedArchive.getMainPageDirEntry((value) => resolve(value))); if (dirEntry.redirect) { const redirect = await new Promise((resolve, _reject) => selectedArchive.resolveRedirect(dirEntry, (v) => resolve(v))); const ret = await selectedArchive.callLibzimWorker({ action: 'getEntryByPath', path: redirect.namespace + '/' + redirect.url }) const message = { action: 'giveContent', title: title, content: ret.content, mimetype: ret.mimetype }; messagePort.postMessage(message); return; } } // Let's send the content to the ServiceWorker const message = { action: 'giveContent', title: title, content: ret.content, mimetype: ret.mimetype }; messagePort.postMessage(message); } catch (error) { const message = { action: 'giveContent', title: title, content: new Uint8Array(), mimetype: '' }; messagePort.postMessage(message); } } /** * 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) { console.debug('Setting content injection mode to', value); params.oldInjectionMode = params.serviceWorkerLocal ? 'serviceworkerlocal' : params.contentInjectionMode; params.serviceWorkerLocal = false; if (value === 'serviceworkerlocal') { value = 'serviceworker'; params.serviceWorkerLocal = true; } params.contentInjectionMode = value; params.originalContentInjectionMode = null; var message = ''; if (value === 'jquery') { if (!params.appCache) { uiUtil.systemAlert((translateUI.t('dialog-bypassappcache-conflict-message') || 'You must deselect the "Bypass AppCache" option before switching to Restricted mode!'), (translateUI.t('dialog-bypassappcache-conflict-title') || '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 message = translateUI.t('dialog-launchlocal-message') || 'This will switch to using locally packaged code only. Some configuration settings may be lost.

' + '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; console.log('Beam me down, Scotty!'); }; uiUtil.systemAlert(message, (translateUI.t('dialog-launchlocal-title') || '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 ('serviceWorker' in navigator) { serviceWorkerRegistration = null; } // 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()) { if (isServiceWorkerAvailable() && navigator.serviceWorker.controller) { var channel = new MessageChannel(); navigator.serviceWorker.controller.postMessage({ action: { assetsCache: 'disable' } }, [channel.port2]); } caches.delete(ASSETS_CACHE); } refreshAPIStatus(); } 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 (/^(moz|chrome)-extension:/.test(protocol) && !params.serviceWorkerLocal) { launchBrowserExtensionServiceWorker(); } else { if (!isServiceWorkerAvailable()) { message = translateUI.t('dialog-launchpwa-unsupported-message') || '

Unfortunately, your browser does not appear to support ServiceWorker mode, which is now the default for this app.

' + '

You can continue to use the app in Restricted mode, but note that this mode only works well with ' + 'ZIM archives that have static content, such as Wikipedia / Wikimedia ZIMs or Stackexchange.

' + '

If you can, we recommend that you update your browser to a version that supports ServiceWorker mode.

'; if (!params.noPrompts) { uiUtil.systemAlert(message, (translateUI.t('dialog-launchpwa-unsupported-title') || 'ServiceWorker API not available'), true, null, (translateUI.t('dialog-serviceworker-unsupported-fallback') || 'Use Restricted 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(params.oldInjectionMode || 'jquery'); } }); } else { setContentInjectionMode(params.oldInjectionMode || 'jquery'); } return; } if (!isMessageChannelAvailable()) { uiUtil.systemAlert((translateUI.t('dialog-messagechannel-unsupported-message') || 'The MessageChannel API is not available on your device. Falling back to Restricted mode...'), (translateUI.t('dialog-messagechannel-unsupported-title') || '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 var articleContent = document.getElementById('articleContent'); while (articleContent.firstChild) { articleContent.removeChild(articleContent.firstChild); } // Create the MessageChannel and send 'init' 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 var articleContent = document.getElementById('articleContent'); while (articleContent.firstChild) { articleContent.removeChild(articleContent.firstChild); } // 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(); } }); 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 launchBrowserExtensionServiceWorker(); } else { console.error('Error while registering serviceWorker', err); refreshAPIStatus(); var message = (translateUI.t('dialog-serviceworker-registration-failure-message') || 'The Service Worker could not be properly registered. Switching back to Restricted mode... Error message:') + ' ' + err; if (protocol === 'file:') { message += (translateUI.t('dialog-serviceworker-registration-failure-fileprotocol') || '

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 a secure connection: https://webserver.org/...)'); } appstate.preventAutoReboot = true; if (!params.noPrompts) { uiUtil.systemAlert(message, (translateUI.t('dialog-serviceworker-registration-failure-title') || 'Failed to register Service Worker')).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 set this variable earlier else the Service Worker does not get reactivated params.contentInjectionMode = value; // initOrKeepAliveServiceWorker(); } } } document.querySelectorAll('input[name=contentInjectionMode]').forEach(function (radio) { radio.checked = false; }); var trueMode = params.serviceWorkerLocal ? value + 'local' : value; var radioToCheck = document.querySelector('input[name=contentInjectionMode][value="' + trueMode + '"]'); if (radioToCheck) { radioToCheck.checked = true; } // Save the value in the Settings Store, so that to be able to keep it after a reload/restart settingsStore.setItem('contentInjectionMode', trueMode, Infinity); refreshCacheStatus(); refreshAPIStatus(); // Even in JQuery mode, the PWA needs to be able to serve the app in offline mode setTimeout(initServiceWorkerMessaging, 600); // Set the visibility of WebP workaround after change of content injection mode // Note we need a timeout because loading the webpHero script in init.js is asynchronous setTimeout(uiUtil.determineCanvasElementsWorkaround, 1500); } /** * Detects whether the ServiceWorker API is available * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker * @returns {Boolean} */ function isServiceWorkerAvailable () { return 'serviceWorker' in navigator; } /** * Detects whether 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 launchBrowserExtensionServiceWorker () { // 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 ? (translateUI.t('dialog-allow-internetaccess-message1') || '

To enable the Service Worker, we') + ' ' : ((translateUI.t('dialog-allow-internetaccess-message2') || '

We shall attempt to switch you to ServiceWorker mode (this is now the default).') + ' ' + (translateUI.t('dialog-allow-internetaccess-message3') || 'It supports more types of ZIM archives and is much more robust.

We') + ' '); message += (translateUI.t('dialog-allow-internetaccess-message4') || '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.

You can switch back any time by returning to Restricted mode.

' + '

WARNING: This will attempt to access the following server:
') + params.PWAServer + '

'; 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; console.log('Beam me up, Scotty!'); }; var checkPWAIsOnline = function () { uiUtil.spinnerDisplay(true, (translateUI.t('dialog-serveraccess-check') || 'Checking server access...')); uiUtil.checkServerIsAccessible(params.PWAServer + 'www/img/icons/kiwix-32.png', launchPWA, function () { uiUtil.spinnerDisplay(false); uiUtil.systemAlert((translateUI.t('dialog-serveraccess-check-failed') || 'The server is not currently accessible! ' + '

(Kiwix needs one-time access to the server to cache the PWA).' + '
Please try again when you have a stable Internet connection.'), (translateUI.t('dialog-error-title') || 'Error!')).then(function () { settingsStore.setItem('allowInternetAccess', false, Infinity); setContentInjectionMode(params.oldInjectionMode || 'jquery'); }); }); }; if (settingsStore.getItem('allowInternetAccess') === 'true') { if (PWASuccessfullyLaunched) { launchPWA(); } else { uiUtil.systemAlert((translateUI.t('dialog-launchpwa-fail-message') || 'The last attempt to launch the PWA appears to have failed.

Do you wish to try again?'), (translateUI.t('dialog-launchpwa-fail-title') || 'Confirmation to retry PWA launch'), true).then(function (response) { if (response) { checkPWAIsOnline(); } else { settingsStore.setItem('allowInternetAccess', false, Infinity); setContentInjectionMode(params.oldInjectionMode || 'jquery'); } }) } } else { uiUtil.systemAlert(message, (translateUI.t('dialog-allow-internetaccess-title') || 'Allow Internet access'), true).then(function (response) { if (response) { checkPWAIsOnline(); } else { // User cancelled, so wants to stay in previous mode (so long as this wasn't SW mode) params.oldInjectionMode = params.oldInjectionMode === 'serviceworker' ? /^chrome-extension:/i.test(window.location.protocol) ? 'serviceworkerlocal' : null : params.oldInjectionMode; setContentInjectionMode(params.oldInjectionMode || '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. */ 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().then(populateDropDownListOfArchives(null)); }); } if (navigator.getDeviceStorages && typeof navigator.getDeviceStorages === 'function') { // The method getDeviceStorages is available (FxOS>=1.1) storages = Array.from(navigator.getDeviceStorages('sdcard')).map(function (s) { return new abstractFilesystemAccess.StorageFirefoxOS(s); }); } // @AUTOLOAD of archives starts here for frameworks or APIs that allow it var willJumpToRemoteExtension = params.contentInjectionMode === 'serviceworker' && navigator.serviceWorker && /^(moz|chrome)-extension/.test(window.location.protocol) && localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success'; // If DeviceStorage is available (Firefox OS), we look for archives in it 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); // If the File System Access API is available, we may be able to autoload the last selected archive in Chromium > 122 // which has persistent permissions } else if (params.reopenLastArchive && window.showOpenFilePicker && params.previousZimFileName) { displayFileSelect(); abstractFilesystemAccess.getSelectedZimFromCache(params.previousZimFileName).then(function (files) { setLocalArchiveFromFileList(files); }).catch(function (err) { console.warn(err); if (!willJumpToRemoteExtension) { document.getElementById('btnConfigure').click(); } }); // If no autoload API is available and we're not about to jump to the remote extension, we display the file select dialog } else if (!willJumpToRemoteExtension) { displayFileSelect(); if (archiveFiles.files && 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'; uiUtil.spinnerDisplay(false); // Replacing $('.navbar-collapse').collapse('hide'); var navbarCollapse = document.querySelector('.navbar-collapse'); navbarCollapse.classList.remove('show'); document.getElementById('configuration').style.display = 'none'; document.getElementById('articleListWithHeader').style.display = 'none'; // Replacing $('#articleContent').contents().empty(); var articleContent = document.getElementById('articleContent'); while (articleContent.firstChild) { articleContent.removeChild(articleContent.firstChild); } 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.} archiveDirectories */ function populateDropDownListOfArchives (archiveDirectories) { document.getElementById('scanningForArchives').style.display = 'none'; document.getElementById('chooseArchiveFromLocalStorage').style.display = ''; document.getElementById('rescanButtonAndText').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((translateUI.t('dialog-invalid-archivelocation-message') || 'It looks like you have put some archive files at the root of your sdcard (or internal storage). Please move them to a subdirectory'), (translateUI.t('dialog-invalid-archivelocation-title') || '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); document.getElementById('archiveList').addEventListener('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 (document.querySelector("#archiveList option[value='" + lastSelectedArchive + "']")) { 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((translateUI.t('dialog-welcome-message') || '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)'), (translateUI.t('dialog-welcome-title') || 'Welcome')).then(function () { document.getElementById('btnAbout').click(); var isAndroid = (navigator.userAgent.indexOf('Android') !== -1); if (isAndroid) { uiUtil.systemAlert(translateUI.t('dialog-old-android') || "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.", translateUI.t('dialog-launchlocal-title') || '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((translateUI.t('dialog-devicestorage-error-message') || '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'); } } settingsStore.setItem('lastSelectedArchive', archiveDirectory, Infinity); zimArchiveLoader.loadArchiveFromDeviceStorage(selectedStorage, archiveDirectory, archiveReadyCallback, 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 if an archive is loaded if (selectedArchive) selectedArchive.cssCache = new Map(); } let webKitFileList = null /** * Displays the zone to select files from the archive */ function displayFileSelect () { const isFireFoxOsNativeFileApiAvailable = typeof navigator.getDeviceStorages === 'function'; let isPlatformMobilePhone = false; if (/Android/i.test(navigator.userAgent)) isPlatformMobilePhone = true; if (/iphone|ipad|ipod/i.test(navigator.userAgent) || navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) isPlatformMobilePhone = true; console.debug(`File system api is ${params.isFileSystemApiSupported ? '' : 'not '}supported`); console.debug(`Webkit directory api ${params.isWebkitDirApiSupported ? '' : 'not '}supported`); console.debug(`Firefox os native file ${isFireFoxOsNativeFileApiAvailable ? '' : 'not '}support api`) document.getElementById('openLocalFiles').style.display = 'block'; if ((params.isFileSystemApiSupported || params.isWebkitDirApiSupported) && !isPlatformMobilePhone) { document.getElementById('chooseArchiveFromLocalStorage').style.display = ''; document.getElementById('folderSelect').style.display = ''; } // Set the main drop zone if (!params.disableDragAndDrop) { // Set a global drop zone, so that whole page is enabled for drag and drop globalDropZone.addEventListener('dragover', handleGlobalDragover); globalDropZone.addEventListener('dragleave', handleGlobalDragleave); globalDropZone.addEventListener('drop', handleFileDrop); globalDropZone.addEventListener('dragenter', handleGlobalDragenter); } if (isFireFoxOsNativeFileApiAvailable) { useLegacyFilePicker(); return; } document.getElementById('archiveList').addEventListener('change', function (e) { // handle zim selection from dropdown if multiple files are loaded via webkitdirectory or filesystem api settingsStore.setItem('previousZimFileName', e.target.value, Infinity); if (params.isFileSystemApiSupported) { return abstractFilesystemAccess.getSelectedZimFromCache(e.target.value).then(function (files) { setLocalArchiveFromFileList(files); }).catch(function (err) { console.error(err); return uiUtil.systemAlert(translateUI.t('dialog-fielhandle-fail-message') || 'We were unable to retrieve a file handle for the selected archive. Please pick the file or folder again.', translateUI.t('dialog-fielhandle-fail-title') || 'Error retrieving archive'); }); } else { if (webKitFileList === null) { const element = settingsStore.getItem('zimFilenames').split('|').length === 1 ? 'archiveFiles' : 'archiveFolders'; if ('showPicker' in HTMLInputElement.prototype) { document.getElementById(element).showPicker(); return; } document.getElementById(element).click() return; } const files = abstractFilesystemAccess.getSelectedZimFromWebkitList(webKitFileList, e.target.value); setLocalArchiveFromFileList(files); } }); if (params.isFileSystemApiSupported) { // Handles Folder selection when showDirectoryPicker is supported folderSelect.addEventListener('click', async function (e) { e.preventDefault(); const previousZimFiles = await abstractFilesystemAccess.selectDirectoryFromPickerViaFileSystemApi() if (previousZimFiles.length === 1) setLocalArchiveFromFileList(previousZimFiles); }); } if (params.isWebkitDirApiSupported) { // Handles Folder selection when webkitdirectory is supported but showDirectoryPicker is not folderSelect.addEventListener('change', function (e) { e.preventDefault(); var fileList = e.target.files; if (fileList) { var foundFiles = abstractFilesystemAccess.selectDirectoryFromPickerViaWebkit(fileList); var selectedZimfile = foundFiles.selectedFile; // This ensures the selected files are stored for use during this session (webKitFileList is a global object) webKitFileList = foundFiles.files; // This will load the old file if the selected folder contains the same file if (selectedZimfile.length !== 0) { setLocalArchiveFromFileList(selectedZimfile); } } }) } if (params.isFileSystemApiSupported) { // Handles File selection when showOpenFilePicker is supported and uses the filesystem api archiveFiles.addEventListener('click', async function (e) { e.preventDefault(); const files = await abstractFilesystemAccess.selectFileFromPickerViaFileSystemApi(e); setLocalArchiveFromFileList(files); }); } else { // Fallbacks to simple file input with multi file selection useLegacyFilePicker(); } // Add keyboard activation for folder selection folderSelect.addEventListener('keydown', function (e) { // We have to include e.keyCode for IE11 if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 32) { e.preventDefault(); folderSelect.click(); } }); } /** * Adds a event listener to the file input to handle file selection (if no other file picker is supported) */ function useLegacyFilePicker () { // Fallbacks to simple file input with multi file selection archiveFiles.addEventListener('change', function (e) { if (params.isWebkitDirApiSupported || params.isFileSystemApiSupported) { const activeFilename = e.target.files[0].name; settingsStore.setItem('zimFilenames', [activeFilename].join('|'), Infinity); abstractFilesystemAccess.updateZimDropdownOptions([activeFilename], activeFilename); } setLocalArchiveFromFileSelect(); }); } // Add keyboard selection for the archiveFiles input document.getElementById('archiveFilesLbl').addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 32) { e.preventDefault(); archiveFiles.click(); } }); /** Drag and Drop handling for ZIM files */ // Keep track of entrance event so we only fire the correct leave event var enteredElement; function handleGlobalDragenter (e) { e.preventDefault(); // Disable pointer-events on children so they don't interfere with dragleave events globalDropZone.classList.add('dragging-over'); enteredElement = e.target; } function handleGlobalDragover (e) { e.preventDefault(); if (hasType(e.dataTransfer.types, 'Files') && !hasInvalidType(e.dataTransfer.types)) { e.dataTransfer.dropEffect = 'link'; globalDropZone.classList.add('dragging-over'); globalDropZone.style.border = '3px dashed red'; document.getElementById('btnConfigure').click(); } } function handleGlobalDragleave (e) { e.preventDefault(); globalDropZone.style.border = ''; if (enteredElement === e.target) { globalDropZone.classList.remove('dragging-over'); // Only return to page if a ZIM is actually loaded if (selectedArchive && selectedArchive.isReady()) { uiUtil.returnToCurrentPage(); } } } function handleIframeDragover (e) { e.preventDefault(); if (hasType(e.dataTransfer.types, 'Files') && !hasInvalidType(e.dataTransfer.types)) { globalDropZone.classList.add('dragging-over'); e.dataTransfer.dropEffect = 'link'; document.getElementById('btnConfigure').click(); } } function handleIframeDrop (e) { e.preventDefault(); e.stopPropagation(); } // Add type check for chromium browsers, since they count images on the same page as files function hasInvalidType (typesList) { for (var i = 0; i < typesList.length; i++) { // Use indexOf() instead of startsWith() for IE11 support. Also, IE11 uses Text instead of text (and so does Opera). // This is not comprehensive, but should cover most cases. if (typesList[i].indexOf('image') === 0 || typesList[i].indexOf('text') === 0 || typesList[i].indexOf('Text') === 0 || typesList[i].indexOf('video') === 0) { return true; } } return false; } // IE11 doesn't support .includes(), so custom function to check for presence of types function hasType (typesList, type) { for (var i = 0; i < typesList.length; i++) { if (typesList[i] === type) { return true; } } return false; } async function handleFileDrop (packet) { packet.stopPropagation(); packet.preventDefault(); globalDropZone.style.border = ''; globalDropZone.classList.remove('dragging-over'); var files = packet.dataTransfer.files; document.getElementById('selectInstructions').style.display = 'none'; document.getElementById('fileSelectionButtonContainer').style.display = 'none'; document.getElementById('downloadInstruction').style.display = 'none'; document.getElementById('selectorsDisplay').style.display = 'inline'; archiveFiles.value = null; // Value will be set to true if a folder is dropped then there will be no need to // call the `setLocalArchiveFromFileList` let loadZim = true; // No previous file will be loaded in case of FileSystemApi if (params.isFileSystemApiSupported) loadZim = await abstractFilesystemAccess.handleFolderOrFileDropViaFileSystemAPI(packet); else if (params.isWebkitDirApiSupported) { const ret = await abstractFilesystemAccess.handleFolderOrFileDropViaWebkit(packet); loadZim = ret.loadZim; webKitFileList = ret.files; } if (loadZim) setLocalArchiveFromFileList(files); } const btnLibrary = document.getElementById('btnLibrary'); btnLibrary.addEventListener('click', function (e) { const libraryContent = document.getElementById('libraryContent'); const libraryIframe = libraryContent.contentWindow.document.getElementById('libraryIframe'); uiUtil.tabTransitionToSection('library', params.showUIAnimations); resizeIFrame(); kiwixLibrary.loadLibrary(libraryIframe); }); // Add keyboard activation for library button btnLibrary.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 32) { e.preventDefault(); btnLibrary.click(); } }); // Add event listener to link which allows user to show file selectors document.getElementById('selectorsDisplayLink').addEventListener('click', function (e) { e.preventDefault(); document.getElementById('selectInstructions').style.display = 'block'; document.getElementById('downloadInstruction').style.display = 'block'; document.getElementById('fileSelectionButtonContainer').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((translateUI.t('dialog-invalid-zim-message') || 'One or more files does not appear to be a ZIM file!'), (translateUI.t('dialog-invalid-zim-title') || 'Invalid file format')); return; } } zimArchiveLoader.loadArchiveFromFiles(files, archiveReadyCallback, function (message, label) { // callbackError which is called in case of an error uiUtil.systemAlert(message, label); }); } /** * Functions to be run immediately after the archive is loaded * * @param {ZIMArchive} archive The ZIM archive */ async function archiveReadyCallback (archive) { selectedArchive = archive; // A css cache significantly speeds up the loading of CSS files (used by default in jQuery mode) selectedArchive.cssCache = new Map(); if (selectedArchive.zimType !== 'zimit') { if (params.originalContentInjectionMode) { params.contentInjectionMode = params.originalContentInjectionMode; params.originalContentInjectionMode = null; } } // This flag will be reset each time a new archive is loaded appstate.wikimediaZimLoaded = /wikipedia|wikivoyage|mdwiki|wiktionary/i.test(archive.file.name); // Set contentInjectionMode to serviceWorker when opening a new archive in case the user switched to Restricted Mode/jquery Mode when opening the previous archive if (params.contentInjectionMode === 'jquery') { params.contentInjectionMode = settingsStore.getItem('contentInjectionMode'); // Change the radio buttons accordingly switch (settingsStore.getItem('contentInjectionMode')) { case 'serviceworker': document.getElementById('serviceworkerModeRadio').checked = true; break; case 'serviceworkerlocal': document.getElementById('serviceworkerLocalModeRadio').checked = true; break; } } if (settingsStore.getItem('trustedZimFiles') === null) { settingsStore.setItem('trustedZimFiles', '', Infinity); } // This is used for testing: if the noPrompts flag is set, we skip the source verification if (params.noPrompts) params.sourceVerification = false; if (params.sourceVerification && (params.contentInjectionMode === 'serviceworker' || params.contentInjectionMode === 'serviceworkerlocal')) { // Check if source of the zim file can be trusted. if (!(settingsStore.getItem('trustedZimFiles').includes(archive.file.name))) { await verifyLoadedArchive(archive); } } // When a new ZIM is loaded, we turn this flag to null, so that we don't get false positive attempts to use the Worker // It will be defined as false or true when the first article is loaded appstate.isReplayWorkerAvailable = null; // Initialize the Service Worker if (params.contentInjectionMode === 'serviceworker') { initServiceWorkerMessaging(); } // The archive is set: go back to home page to start searching document.getElementById('btnHome').click(); document.getElementById('downloadInstruction').style.display = 'none'; } /** * Sets the localArchive from the File selects populated by user */ function setLocalArchiveFromFileSelect () { setLocalArchiveFromFileList(archiveFiles.files); } window.setLocalArchiveFromFileSelect = setLocalArchiveFromFileSelect; /** * 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} 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(new Error('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} 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 { uiUtil.spinnerDisplay(false); // 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(translateUI.t('dialog-archive-notset-message') || 'Archive not set: please select an archive', translateUI.t('dialog-archive-notset-title') || '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 || '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 += '' + dirEntry.getTitleOrUrl() + ''; } // 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 document.querySelectorAll('#articleList a').forEach(function (link) { link.addEventListener('mousedown', function (e) { // Cancel search immediately appstate.search.status = 'cancelled'; handleTitleClick(e); return false; }); }); if (!stillSearching) uiUtil.spinnerDisplay(false); 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 */ function handleTitleClick (event) { event.preventDefault(); // User may have clicked on a child element of the list item if it contains HTML (for example, italics), // so we may need to find the closest list item let target = event.target; if (target.className !== 'list-group-item') { console.warn('User clicked on child element of list item, looking for parent...'); while (target && target.className !== 'list-group-item') { target = target.parentNode; } if (!target) { // No list item found, so we can't do anything console.warn('No list item could be found for clicked event!'); return; } } var dirEntryId = decodeURIComponent(target.getAttribute('dirEntryId')); findDirEntryFromDirEntryIdAndLaunchArticleRead(dirEntryId); } /** * 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(translateUI.t('dialog-file-notset-message') || 'Data files not set', translateUI.t('dialog-file-notset-title') || 'Archive not ready'); } } /** * Check whether the given URL from given dirEntry matches the expected article * @param {DirEntry} dirEntry The directory entry of the article to read */ function isDirEntryExpectedToBeDisplayed (dirEntry) { var curArticleURL = dirEntry.namespace + '/' + dirEntry.url; if (appstate.expectedArticleURLToBeDisplayed !== curArticleURL) { console.debug('url of current article :' + curArticleURL + ', does not match the expected url :' + appstate.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) { if (dirEntry === null || dirEntry === undefined) { console.error('The directory entry for the requested article was not found (null or undefined)'); uiUtil.spinnerDisplay(false); return; } // Reset search prefix to allow users to search the same string again if they want to appstate.search.prefix = ''; // Only update for appstate.expectedArticleURLToBeDisplayed. appstate.expectedArticleURLToBeDisplayed = dirEntry.namespace + '/' + dirEntry.url; // Calculate the current article's ZIM baseUrl to use when processing relative links appstate.baseUrl = encodeURI(dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '')); // 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) articleContainer.contentWindow.focus(); // Show the spinner with a loading message var message = dirEntry.url.match(/(?:^|\/)([^/]{1,13})[^/]*?$/); message = message ? message[1] + '...' : '...'; uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + message); 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); }); // Set up article onload handler articleLoader(); if (!isDirEntryExpectedToBeDisplayed(dirEntry)) { return; } if (selectedArchive.zimType === 'zimit' && !appstate.isReplayWorkerAvailable) { if (window.location.protocol === 'chrome-extension:') { // Zimit archives contain content that is blocked in a local Chromium extension (on every page), so we must fall back to jQuery mode return handleUnsupportedReplayWorker(dirEntry); } var archiveName = selectedArchive.file.name.replace(/\.zim\w{0,2}$/i, ''); var cns = selectedArchive.getContentNamespace(); // Support type 0 and type 1 Zimit archives var replayCns = cns === 'C' ? '/C/A/' : '/A/'; var base = window.location.href.replace(/^(.*?\/)www\/.*$/, '$1'); var prefix = base + selectedArchive.file.name + replayCns; // Open a new message channel to the ServiceWorker var zimitMessageChannel = new MessageChannel(); zimitMessageChannel.port1.onmessage = function (event) { if (event.data.error) { console.error('Reading Zimit archives in ServiceWorker mode is not supported in this browser', event.data.error); return handleUnsupportedReplayWorker(dirEntry); } else if (event.data.success) { // console.debug(event.data.success); appstate.isReplayWorkerAvailable = true; // We put the ZIM filename as a prefix in the URL, so that browser caches are separate for each ZIM file articleContainer.src = '../' + selectedArchive.file.name + '/' + dirEntry.namespace + '/' + encodedUrl; } }; // If we are dealing with a Zimit ZIM, we need to instruct Replay to add the file as a new collection navigator.serviceWorker.controller.postMessage({ msg_type: 'addColl', name: archiveName, prefix: prefix, file: { sourceUrl: 'proxy:' + prefix }, root: true, skipExisting: false, extraConfig: { // prefix: prefix, // If not set, Replay will use the proxy URL (without the 'proxy:' prefix) sourceType: 'kiwix', notFoundPageUrl: './404.html'/*, baseUrl: base + selectedArchive.file.name + '/', baseUrlHashReplay: false */ }, topTemplateUrl: './www/topFrame.html' }, [zimitMessageChannel.port2]); } else { // We put the ZIM filename as a prefix in the URL, so that browser caches are separate for each ZIM file articleContainer.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, function (fileDirEntry, content) { // Because a Zimit landing page will change the dirEntry, we have to check again for a redirect, but not if we already have the correct dirEntry if (fileDirEntry.zimitRedirect && fileDirEntry.namespace + '/' + fileDirEntry.url !== fileDirEntry.zimitRedirect) { return selectedArchive.getDirEntryByPath(fileDirEntry.zimitRedirect).then(readArticle); } else { displayArticleContentInIframe(fileDirEntry, content); } }); } } } /** * Selects the iframe to which to attach the onload event, and attaches it */ function articleLoader () { if (selectedArchive.zimType === 'zimit') { var doc = articleContainer.contentDocument || null; if (doc) { var replayIframe = doc.getElementById('replay_iframe'); if (replayIframe) { replayIframe.onload = function () { articleLoadedSW(replayIframe); }; } } } else { articleContainer.onload = function () { articleLoadedSW(articleContainer); }; } } // Add event listener to iframe window to check for links to external resources function filterClickEvent (event) { // Find the closest enclosing A tag (if any) var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target); // If the anchor has a passthrough property, then we have already checked it is safe, so we can return if (clickedAnchor && clickedAnchor.passthrough) { clickedAnchor.passthrough = false; return; } // Remove any Kiwix Popovers that may be hanging around popovers.removeKiwixPopoverDivs(event.target.ownerDocument); if (params.contentInjectionMode === 'jquery' || !params.openExternalLinksInNewTabs && !clickedAnchor.newcontainer) return; if (clickedAnchor) { // This prevents any popover from being displayed when the user clicks on a link clickedAnchor.articleisloading = true; // Check for Zimit links that would normally be handled by the Replay Worker // DEV: '__WB_pmw' is a function inserted by wombat.js, so this detects links that have been rewritten in zimit2 archives // however, this misses zimit2 archives where the framework doesn't support wombat.js, so monitor if always processing zimit2 links // causes any adverse effects @TODO if (appstate.isReplayWorkerAvailable || '__WB_pmw' in clickedAnchor || selectedArchive.zimType === 'zimit2' && articleWindow.location.href.replace(/[#?].*$/, '') !== clickedAnchor.href.replace(/[#?].*$/, '') && !clickedAnchor.hash) { return handleClickOnReplayLink(event, clickedAnchor); } // DEV: The href returned below is the href as written in the HTML, which may be relative 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 // (this assumption is only safe for non-Replay archives, but we deal with those separately above: they are routed to handleClickOnReplayLink). // Additionally, by comparing the protocols, we can filter out protocols such as `mailto:`, `tel:`, `skype:`, etc. (these should open in a new window). // DEV: The test for a protocol of ':' may no longer be needed. It needs careful testing in all browsers (particularly in Edge Legacy), and if no // longer triggered, it can be removed. if (/^http/i.test(href) || clickedAnchor.protocol && clickedAnchor.protocol !== ':' && articleWindow.location.protocol !== clickedAnchor.protocol) { console.debug('filterClickEvent opening external link in new tab'); clickedAnchor.newcontainer = true; uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor); } else if (clickedAnchor.newcontainer || /\.pdf([?#]|$)/i.test(href) && selectedArchive.zimType !== 'zimit') { // 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. We also open // a new tab if the user has explicitly requested it: in this case the anchor will have a property 'newcontainer' (e.g. with popover control) event.preventDefault(); event.stopPropagation(); console.debug('filterClickEvent opening new window for PDF or requested new container'); clickedAnchor.newcontainer = true; window.open(clickedAnchor.href, '_blank'); } else if (/\/[-ABCIJMUVWX]\/.+$/.test(clickedAnchor.href)) { // clickedAnchor.href returns the absolute URL, including any namespace // Show the spinner if it's a ZIM link, but not an anchor if (!~href.indexOf('#')) { var message = href.match(/(?:^|\/)([^/]{1,13})[^/]*?$/); message = message ? message[1] + '...' : '...'; uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + message); // In case of false positive, ensure spinner is eventually hidden setTimeout(function () { uiUtil.spinnerDisplay(false); }, 4000); uiUtil.showSlidingUIElements(); } } // Reset popup block setTimeout(function () { // Anchor may have been unloaded along with the page by the time this runs // but will still be present if user opened a new tab if (clickedAnchor) { clickedAnchor.articleisloading = false; } }, 1000); } }; /** * Postprocessing required after the article contents are loaded * @param {HTMLIFrameElement} iframeArticleContent The iframe containing the article content */ function articleLoadedSW (iframeArticleContent) { // The content is fully loaded by the browser : we can hide the spinner document.getElementById('cachingAssets').textContent = translateUI.t('spinner-caching-assets') || 'Caching assets...'; document.getElementById('cachingAssets').style.display = 'none'; uiUtil.spinnerDisplay(false); // Set the requested appTheme uiUtil.applyAppTheme(params.appTheme); // Display the iframe content iframeArticleContent.style.display = ''; articleContainer.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.ondragover = handleIframeDragover; docBody.ondrop = handleIframeDrop; } } resizeIFrame(); var iframeWindow = iframeArticleContent.contentWindow; if (iframeWindow) { // Configure home key press to focus #prefix only if the feature is in active state if (params.useHomeKeyToFocusSearchBar) { iframeWindow.onkeydown = focusPrefixOnHomeKey; } // Add event listeners to iframe window to check for links to external resources and for actions that trigger popovers iframeWindow.onclick = filterClickEvent; attachPopoverTriggerEvents(iframeWindow); // If we are in a zimit2 ZIM and params.serviceWorkerLocal is true, and it's a landing page, then we should display a warning if (!params.hideActiveContentWarning && params.isLandingPage && params.zimType === 'zimit2' && params.serviceWorkerLocal) { uiUtil.displayActiveContentWarning('ServiceWorkerLocal'); } // Reset UI when the article is unloaded iframeArticleContent.contentWindow.onunload = function () { iframeArticleContent.loader = false; // 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 = ''; }; } params.isLandingPage = false; }; /** * Attaches popover trigger events to the given window * @param {Window} win The window to which to attach popover trigger events */ function attachPopoverTriggerEvents (win) { const iframeDoc = win.document; // The popover feature requires as a minimum that the browser supports the css matches function // (having this condition prevents very erratic popover placement in IE11, for example, so the feature is disabled for such browsers) if (!iframeDoc || !appstate.wikimediaZimLoaded || !params.showPopoverPreviews || !('matches' in Element.prototype)) { return; } // Attach the popover CSS to the current article document popovers.attachKiwixPopoverCss(iframeDoc); // Add event listeners to the iframe window to check when anchors are hovered, focused or touched win.addEventListener('mouseover', evokePopoverEvents, true); win.addEventListener('focus', evokePopoverEvents, true); // Conditionally add event listeners to support touch events with fallback to pointer events if (window.navigator.maxTouchPoints > 0) { win.addEventListener('touchstart', evokePopoverEvents, true); } else { win.addEventListener('pointerdown', evokePopoverEvents, true); } } // Throttle for the popover event handler to prevent multiple activations with mouse movement let popoverThrottle = false; /** * Conditionally evokes popover events subject to a throttle * @param {Event} event The event produced by the calling action */ function evokePopoverEvents (event) { // Check if the hovered or focused element or its parent is a link if (popoverThrottle) return; popoverThrottle = true; setTimeout(function () { handlePopoverEvents(event); popoverThrottle = false; }, 10); }; /** * Event handler for attaching preview popovers * @param {Event} event The event produced by the mouseover or focus action */ function handlePopoverEvents (ev) { let anchor = ev.target; const iframeDoc = anchor.ownerDocument; if (!iframeDoc) return; const iframeWindow = iframeDoc.defaultView; while (anchor && anchor !== iframeWindow && anchor.nodeName !== 'A') { anchor = anchor.parentNode; } // If we're not hovering a link, then we can exit if (!anchor || anchor.nodeName !== 'A') return; // console.debug(event.type, event.target, a); const suppressContextMenuHandler = function (e) { e.preventDefault(); e.stopPropagation(); }; // Prevent context menu on this anchor element anchor.addEventListener('contextmenu', suppressContextMenuHandler, true); if (/touchstart|pointerdown/.test(ev.type)) { anchor.touched = true; // Used to prevent dismissal of popver on mouseout if initiated by touch } if (anchor.style.userSelect === undefined) { // This prevents selection of the text in a touched link in Safari for iOS and Edge Legacy / UWP anchor.style.webkitUserSelect = 'none'; anchor.style.msUserSelect = 'none'; } // Check if a popover div is currently being hovered const divArray = Array.from(iframeDoc.getElementsByClassName('kiwixtooltip')); const divIsHovered = divArray.some(div => div.matches(':hover')); // Only add a popover to the link if a current popover is not being hovered (prevents popovers showing for links in a popover) if (!divIsHovered) { // Prevent text selection while popover is open in modern browsers anchor.style.userSelect = 'none'; // Resolve the true app theme const isDarkTheme = uiUtil.isDarkTheme(params.appTheme); // Get and populate the popover corresponding to the hovered or focused link popovers.populateKiwixPopoverDiv(ev, anchor, appstate, isDarkTheme, selectedArchive); } const outHandler = function (e) { setTimeout(function () { anchor.popoverisloading = false; if (/blur/.test(e.type) || !anchor.touched) { popovers.removeKiwixPopoverDivs(iframeDoc); anchor.touched = false; } anchor.style.webkitUserSelect = 'auto'; anchor.style.msUserSelect = 'auto'; anchor.style.userSelect = 'auto'; anchor.removeEventListener(e.type, outHandler); anchor.removeEventListener('contextmenu', suppressContextMenuHandler, true); }, 250); }; // Clean up when user stops hovering, lifts pointer, stops touching, or unfocuses (blurs) the link if (/mouseover/.test(ev.type)) { anchor.addEventListener('mouseleave', outHandler); } if (/pointerdown/.test(ev.type)) { anchor.addEventListener('pointerup', outHandler); } if (/touchstart/.test(ev.type)) { anchor.addEventListener('touchend', outHandler); } if (ev.type === 'focus') { anchor.addEventListener('blur', outHandler); } } // Handles a click on a Zimit link that has been processed by Wombat function handleClickOnReplayLink (ev, anchor) { var basePath = window.location.href.replace(/^(.*?\/)www\/.*$/, '$1'); var pathToZim = basePath + selectedArchive.file.name + '/'; var pseudoNamespace = selectedArchive.zimitPseudoContentNamespace; var pseudoDomainPath = (anchor.hostname === window.location.hostname ? selectedArchive.zimitPrefix.replace(/\/$/, '') : anchor.hostname) + anchor.pathname; var containingDocDomainPath = anchor.ownerDocument.location.hostname + anchor.ownerDocument.location.pathname; // Normalize the protocols of the clicked anchor and the document, because some PDFs are served with a protocol of http: instead of https: var normalizedAnchorProtocol = anchor.protocol ? anchor.protocol.replace(/s:/, ':') : ''; var normalizedDocumentProtocol = document.location.protocol.replace(/s:/, ':'); // If the paths are identical, then we are dealing with a link to an anchor in the same document if (pseudoDomainPath === containingDocDomainPath) return; // If it's for a different protocol (e.g. javascript:) we may need to handle that, or if the user has pressed the ctrl or command key, the document // will open in a new window anyway, so we can return. if (normalizedAnchorProtocol && normalizedAnchorProtocol !== normalizedDocumentProtocol) { // DEV: Monitor whether you need to handle /blob:|data:|file:/ as well (probably not, as they would be blocked by the sandbox if loaded into iframe) if (/about:|javascript:/i.test(anchor.protocol) || ev.ctrlKey || ev.metaKey || ev.button === 1) return; // So it's probably a URI scheme or protocol like mailto: that would violate the CSP, so we need to open it explicitly in a new tab ev.preventDefault(); ev.stopPropagation(); console.debug('handleClickOnReplayLink opening custom protocol ' + anchor.protocol + ' in new tab'); uiUtil.warnAndOpenExternalLinkInNewTab(ev, anchor); return; } var zimUrl; // If it starts with the path to the ZIM file, then we are dealing with an untransformed absolute local ZIM link if (!anchor.href.indexOf(pathToZim)) { zimUrl = anchor.href.replace(pathToZim, ''); // If it is the same as the pseudoDomainPath, then we are dealing with an untransformed pseuodo relative link that looks like an absolute https:// link // (this probably only applies to zimit2 without Wombat) } else if (anchor.href.replace(/^[^:]+:\/\//, '') === pseudoDomainPath && /\.zim\/[CA]\//.test(anchor.href)) { zimUrl = anchor.href.replace(/^(?:[^.]|\.(?!zim\/[CA]\/))+\.zim\//, ''); } else { zimUrl = pseudoNamespace + pseudoDomainPath + anchor.search; } // It is necessary to fully decode zimit2, as these archives follow OpenZIM spec if (params.zimType === 'zimit2') { zimUrl = decodeURIComponent(zimUrl); } // We need to test the ZIM link if (zimUrl) { ev.preventDefault(); ev.stopPropagation(); // Note that true in the fourth argument instructs getDirEntryByPath to follow redirects by looking up the Header // DEV: CURRENTLY NON-FUNCTION IN KIWIX-JS -- NEEDS FIXING return selectedArchive.getDirEntryByPath(zimUrl, null, null, true).then(function (dirEntry) { var processDirEntry = function (dirEntry) { var pathToArticleDocumentRoot = document.location.href.replace(/www\/index\.html.*$/, selectedArchive.file.name + '/'); var mimetype = dirEntry.getMimetype(); // 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 // Note that some Replay PDFs have html mimetypes, or can be redirects to PDFs, we need to check the URL as well if (/pdf/i.test(mimetype) || /\.pdf(?:[#?]|$)/i.test(anchor.href) || /\.pdf(?:[#?]|$)/i.test(dirEntry.url)) { if (/Android/.test(params.appType) || window.nw) { // User is on an Android device, where opening a PDF in a new tab is not sufficient to evade the sandbox // so we need to download the PDF instead var readAndDownloadBinaryContent = function (zimUrl) { return selectedArchive.getDirEntryByPath(zimUrl).then(function (dirEntry) { if (dirEntry) { selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) { var mimetype = fileDirEntry.getMimetype(); uiUtil.displayFileDownloadAlert(zimUrl, true, mimetype, content); uiUtil.spinnerDisplay(false); }); } else { return uiUtil.systemAlert('We could not find a PDF document at ' + zimUrl, 'PDF not found'); } }); }; // If the document is in fact an html redirect, we need to follow it first till we get the underlying PDF document if (/\bx?html\b/.test(mimetype)) { selectedArchive.readUtf8File(dirEntry, function (fileDirEntry, data) { var redirectURL = data.match(/]*http-equiv="refresh"[^>]*content="[^;]*;url='?([^"']+)/i); if (redirectURL) { redirectURL = redirectURL[1]; var contentUrl = pseudoNamespace + redirectURL.replace(/^[^/]+\/\//, ''); return readAndDownloadBinaryContent(contentUrl); } else { return readAndDownloadBinaryContent(zimUrl); } }); } else { return readAndDownloadBinaryContent(zimUrl); } } else { window.open(pathToArticleDocumentRoot + zimUrl, params.windowOpener === 'tab' ? '_blank' : dirEntry.title, params.windowOpener === 'window' ? 'toolbar=0,location=0,menubar=0,width=800,height=600,resizable=1,scrollbars=1' : null); } } else { // Handle middle-clicks and ctrl-clicks if (ev.ctrlKey || ev.metaKey || ev.button === 1) { var encodedTitle = encodeURIComponent(dirEntry.getTitleOrUrl()); var articleContainer = window.open(pathToArticleDocumentRoot + zimUrl, params.windowOpener === 'tab' ? '_blank' : encodedTitle, params.windowOpener === 'window' ? 'toolbar=0,location=0,menubar=0,width=800,height=600,resizable=1,scrollbars=1' : null ); // Conditional, because opening a new window can be blocked by the browser if (articleContainer) { appstate.target = 'window'; articleContainer.kiwixType = appstate.target; } uiUtil.spinnerDisplay(false); } else { // Let Replay handle this link anchor.passthrough = true; articleContainer = document.getElementById('articleContent'); appstate.target = 'iframe'; articleContainer.kiwixType = appstate.target; if (selectedArchive.zimType === 'zimit2') { // Since we know the URL works, normalize the href (this is needed for zimit2 relative links) // NB We mustn't do this for zimit classic because it breaks wombat rewriting of absolute links! anchor.href = pathToArticleDocumentRoot + zimUrl; } anchor.click(); // Poll spinner with abbreviated title uiUtil.spinnerDisplay(true, 'Loading ' + dirEntry.getTitleOrUrl().replace(/([^/]+)$/, '$1').substring(0, 18) + '...'); } } }; if (dirEntry) { processDirEntry(dirEntry); } else { // If URL has final slash, we need to try it without the slash if (/\/$/.test(zimUrl)) { zimUrl = zimUrl.replace(/\/$/, ''); return selectedArchive.getDirEntryByPath(zimUrl).then(function (dirEntry) { if (dirEntry) { processDirEntry(dirEntry); } else { // If dirEntry was still not-found, it's probably an external link, so warn user before opening a new tab/window uiUtil.warnAndOpenExternalLinkInNewTab(null, anchor, selectedArchive); } }); } else { // It's probably an external link, so warn user before opening a new tab/window uiUtil.warnAndOpenExternalLinkInNewTab(null, anchor, selectedArchive); } } }).catch(function (err) { console.error('Error getting dirEntry for ' + zimUrl, err); uiUtil.systemAlert('There was an error looking up ' + zimUrl, 'Error reading direcotry entry!'); }); } } function handleUnsupportedReplayWorker (unhandledDirEntry) { appstate.isReplayWorkerAvailable = false; params.originalContentInjectionMode = params.contentInjectionMode; params.contentInjectionMode = 'jquery'; readArticle(unhandledDirEntry); // if (!params.hideActiveContentWarning) uiUtil.displayActiveContentWarning(); return uiUtil.systemAlert(translateUI.t('dialog-unsupported-archivetype-message') || '

You are attempting to open a Zimit-style archive, ' + 'which is not supported by your browser in ServiceWorker(Local) mode.

We have temporarily switched you to Restricted mode ' + 'so you can view static content, but a lot of content is non-functional in this configuration.

', translateUI.t('dialog-unsupported-archivetype-title') || 'Unsupported archive type!'); } /** * 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) { // We received a message from the ServiceWorker // The ServiceWorker asks for some content var title = event.data.title; if (appstate.isReplayWorkerAvailable) { // Zimit ZIMs store assets with the querystring, so we need to add it. ReplayWorker handles encoding. title = title + event.data.search; } else if (params.zimType === 'zimit') { // Zimit classic ZIMs store assets encoded with the querystring, so we need to add it title = encodeURI(title) + event.data.search; } var messagePort = event.ports[0]; var readFile = function (dirEntry) { if (dirEntry === null) { console.warn('Title ' + title.replace(/^(.{1,160}).*/, '$1...') + ' not found in archive.'); // DEV: We send null for the content, so that the ServiceWorker knows that the article was not found (as opposed to being merely empty) messagePort.postMessage({ action: 'giveContent', title: title, content: null, zimType: selectedArchive.zimType }); } 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(); // Show the spinner var shortTitle = dirEntry.getTitleOrUrl().replace(/^.*?([^/]{3,18})[^/]*\/?$/, '$1 ...'); if (!/moved/i.test(shortTitle) && !/image|javascript|warc-headers|jsonp?/.test(mimetype)) { uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + shortTitle); clearTimeout(window.timeout); window.timeout = setTimeout(function () { uiUtil.spinnerDisplay(false); }, 1000); // Test for an HTML or XHTML article: note that some ZIMs have odd MIME type formatting like 'text/html;raw=true', // or simply `html`, so this has to be as generic as possible if (/\bx?html/i.test(mimetype)) { // Calculate the current article's ZIM baseUrl to use when attaching popovers appstate.baseUrl = encodeURI(dirEntry.namespace + '/' + dirEntry.url.replace(/[^/]+$/, '')); appstate.expectedArticleURLToBeDisplayed = dirEntry.namespace + '/' + dirEntry.url; } // Ensure the article onload event gets attached to the right iframe articleLoader(); } // Let's send the content to the ServiceWorker var buffer = content.buffer ? content.buffer : content; var message = { action: 'giveContent', title: title, content: buffer, mimetype: mimetype, zimType: selectedArchive.zimType }; messagePort.postMessage(message); }); } }; selectedArchive.getDirEntryByPath(title).then(readFile).catch(function () { messagePort.postMessage({ action: 'giveContent', title: title, content: new Uint8Array(), zimType: selectedArchive.zimType }); }); } // 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 ]*?\s)(?:src|href)(\s*=\s*(["']))(?![a-z][a-z0-9+.-]+:)(.+?)(\?.*?)?(?=\3|#)([\s\S]*?>)/ig; // Regex below tests the html of an article for active content [kiwix-js #466] // It inspects every