/*!
* 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).
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