Migrate to extension Manifest v3 for Chromium #755 (#984)

Both MV2 and MV3 are built for Chromium. Only MV2 for Firefox for now (until Service Workers are supported as backgroundscript.js).
This commit is contained in:
Jaifroid 2023-06-13 21:21:40 +01:00 committed by GitHub
parent bdfe50562c
commit bd7393e921
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 365 additions and 287 deletions

View File

@ -17,6 +17,8 @@ module.exports = {
'no-extra-parens': 1, 'no-extra-parens': 1,
'no-unused-expressions': 1, 'no-unused-expressions': 1,
'no-unused-vars': 1, 'no-unused-vars': 1,
'n/no-callback-literal': 0 'n/no-callback-literal': 0,
'object-shorthand': 0,
'multiline-ternary': 0
} }
} }

View File

@ -19,11 +19,11 @@ Please follow these guidelines when contributing:
- be sure to test your fix in both "JQuery" mode and "Service Worker" mode (see Configuration); - be sure to test your fix in both "JQuery" mode and "Service Worker" mode (see Configuration);
- run the Unit tests (see below) in at least the above browsers. - run the Unit tests (see below) in at least the above browsers.
If all the tests are working fine, you can finally test the extension versions, like this: If all the tests are working fine, you can finally test the extension versions. Plese note that we are using Manifest V3 for the Chromium extensions, and Manifest V2
for the Firefox extension, so there are different instructions for the two browser families:
- Remove the '-WIP' from the version key from the manifest.json file present in the root of this repo;
- In Chromium, you can install the extension by loading the root folder with Extensions -> Load Unpacked (with Developer Mode turned ON) -> select the root folder of the repository; - In Chromium, you can install the extension by loading the root folder with Extensions -> Load Unpacked (with Developer Mode turned ON) -> select the root folder of the repository;
- In Firefox, you can load an extension with Manage Your Extensions -> Debug Add-ons -> Load Temporary Add-on, and then pick any file in the repository. - In Firefox, you need to rename manifest.json to manifest.v3.json, and then rename manifest.v2.json to manifest.json. Then you can load the extension with Manage Your Extensions -> Debug Add-ons -> Load Temporary Add-on, and then pick any file in the repository.
If your feature works and tests are passing, make a PR, describe the testing you have done, and ask for a code review. If your feature works and tests are passing, make a PR, describe the testing you have done, and ask for a code review.

View File

@ -1,5 +1,5 @@
/** /**
* backgroundscript.js: Background script for the WebExtension * backgroundscript.js: Background script for the WebExtension Manifest V2
* *
* Copyright 2017 Mossroy and contributors * Copyright 2017 Mossroy and contributors
* License GPL v3: * License GPL v3:
@ -20,22 +20,20 @@
* along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/> * along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/>
*/ */
/* global chrome, browser */
// In order to work on both Firefox and Chromium/Chrome (and derivatives). // In order to work on both Firefox and Chromium/Chrome (and derivatives).
// browser and chrome variables expose almost the same APIs // browser and chrome variables expose almost the same APIs
var genericBrowser; var genericBrowser;
if (typeof browser !== 'undefined') { if (typeof browser !== 'undefined') {
// Firefox // Firefox
genericBrowser = browser; genericBrowser = browser;
} } else {
else {
// Chromium/Chrome // Chromium/Chrome
genericBrowser = chrome; genericBrowser = chrome;
} }
genericBrowser.browserAction.onClicked.addListener(handleClick); genericBrowser.browserAction.onClicked.addListener(function () {
var newURL = chrome.runtime.getURL('www/index.html');
function handleClick(event) { chrome.tabs.create({ url: newURL });
genericBrowser.tabs.create({ });
url: genericBrowser.runtime.getURL('/www/index.html')
});
}

View File

@ -1,9 +1,9 @@
{ {
"manifest_version": 2, "manifest_version": 3,
"name": "Kiwix", "name": "Kiwix",
"version": "3.8.1", "version": "3.8.1",
"description": "Kiwix : offline Wikipedia reader", "description": "Kiwix Offline Browser",
"icons": { "icons": {
"16": "www/img/icons/kiwix-16.png", "16": "www/img/icons/kiwix-16.png",
@ -16,7 +16,7 @@
"128": "www/img/icons/kiwix-128.png" "128": "www/img/icons/kiwix-128.png"
}, },
"browser_action": { "action": {
"default_icon": { "default_icon": {
"16": "www/img/icons/kiwix-16.png", "16": "www/img/icons/kiwix-16.png",
"19": "www/img/icons/kiwix-19.png", "19": "www/img/icons/kiwix-19.png",
@ -26,22 +26,24 @@
}, },
"default_title": "Kiwix" "default_title": "Kiwix"
}, },
"applications": {
"gecko": {
"id": "kiwix-html5-unlisted@kiwix.org"
}
},
"web_accessible_resources": ["www/index.html"],
"background": { "background": {
"scripts": ["webextension/backgroundscript.js"] "service_worker": "service-worker.js"
}, },
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", "permissions": ["storage", "activeTab", "scripting"],
"author": "mossroy", "content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';",
"sandbox": "sandbox allow-scripts allow-downloads allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';"
},
"web_accessible_resources": [{
"resources": ["www/index.html", "www/article.html"],
"matches": ["https://*.kiwix.org/*"]
}],
"author": "Kiwix",
"homepage_url": "https://www.kiwix.org", "homepage_url": "https://www.kiwix.org",
"offline_enabled": true "offline_enabled": true
} }

47
manifest.v2.json Normal file
View File

@ -0,0 +1,47 @@
{
"manifest_version": 2,
"name": "Kiwix",
"version": "3.8.1",
"description": "Kiwix : offline Wikipedia reader",
"icons": {
"16": "www/img/icons/kiwix-16.png",
"19": "www/img/icons/kiwix-19.png",
"32": "www/img/icons/kiwix-32.png",
"38": "www/img/icons/kiwix-38.png",
"48": "www/img/icons/kiwix-48.png",
"64": "www/img/icons/kiwix-64.png",
"90": "www/img/icons/kiwix-90.png",
"128": "www/img/icons/kiwix-128.png"
},
"browser_action": {
"default_icon": {
"16": "www/img/icons/kiwix-16.png",
"19": "www/img/icons/kiwix-19.png",
"32": "www/img/icons/kiwix-32.png",
"38": "www/img/icons/kiwix-38.png",
"64": "www/img/icons/kiwix-64.png"
},
"default_title": "Kiwix"
},
"applications": {
"gecko": {
"id": "kiwix-html5-unlisted@kiwix.org"
}
},
"web_accessible_resources": ["www/index.html"],
"background": {
"scripts": ["backgroundscript.js"]
},
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"author": "mossroy",
"homepage_url": "https://www.kiwix.org",
"offline_enabled": true
}

View File

@ -55,7 +55,7 @@ fi
# Copy only the necessary files in a temporary directory # Copy only the necessary files in a temporary directory
mkdir -p tmp mkdir -p tmp
rm -rf tmp/* rm -rf tmp/*
cp -r www webextension manifest.json manifest.webapp LICENSE-GPLv3.txt service-worker.js README.md tmp/ cp -r www manifest.json manifest.v2.json manifest.webapp LICENSE-GPLv3.txt service-worker.js README.md tmp/
# Remove unwanted files # Remove unwanted files
rm -f tmp/www/js/lib/libzim-*dev.* rm -f tmp/www/js/lib/libzim-*dev.*
@ -64,8 +64,10 @@ rm -f tmp/www/js/lib/libzim-*dev.*
regexpNumericVersion='^[0-9\.]+$' regexpNumericVersion='^[0-9\.]+$'
if [[ $VERSION =~ $regexpNumericVersion ]] ; then if [[ $VERSION =~ $regexpNumericVersion ]] ; then
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.v2.json
else else
sed -i -e "s/$VERSION_TO_REPLACE/$MAJOR_NUMERIC_VERSION/" tmp/manifest.json sed -i -e "s/$VERSION_TO_REPLACE/$MAJOR_NUMERIC_VERSION/" tmp/manifest.json
sed -i -e "s/$VERSION_TO_REPLACE/$MAJOR_NUMERIC_VERSION/" tmp/manifest.v2.json
fi fi
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.webapp sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.webapp
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/service-worker.js sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/service-worker.js
@ -73,17 +75,26 @@ sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/www/js/app.js
mkdir -p build mkdir -p build
rm -rf build/* rm -rf build/*
# Package for Chromium/Chrome # Package for Chromium/Chrome with Manifest V3
scripts/package_chrome_extension.sh $DRYRUN $TAG -v $VERSION scripts/package_chrome_extension.sh -m 3 $DRYRUN $TAG -v $VERSION
# Package for Chromium/Chrome with Manifest V2
cp backgroundscript.js tmp/
rm tmp/manifest.json
mv tmp/manifest.v2.json tmp/manifest.json
scripts/package_chrome_extension.sh -m 2 $DRYRUN $TAG -v $VERSION
# Package for Firefox and Firefox OS # Package for Firefox and Firefox OS
# We have to put a unique version string inside the manifest.json (which Chrome might not have accepted) # We have to put a unique version string inside the manifest.json (which Chrome might not have accepted)
# So we take the original manifest again, and replace the version inside it again # So we take the original manifest v2 again, and replace the version inside it again
cp manifest.json tmp/ cp manifest.v2.json tmp/manifest.json
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION_FOR_MOZILLA_MANIFEST/" tmp/manifest.json sed -i -e "s/$VERSION_TO_REPLACE/$VERSION_FOR_MOZILLA_MANIFEST/" tmp/manifest.json
echo ""
scripts/package_firefox_extension.sh $DRYRUN $TAG -v $VERSION scripts/package_firefox_extension.sh $DRYRUN $TAG -v $VERSION
echo ""
scripts/package_firefoxos_app.sh $DRYRUN $TAG -v $VERSION scripts/package_firefoxos_app.sh $DRYRUN $TAG -v $VERSION
cp -f ubuntu_touch/* tmp/ cp -f ubuntu_touch/* tmp/
sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json
echo ""
scripts/package_ubuntu_touch_app.sh $DRYRUN $TAG -v $VERSION scripts/package_ubuntu_touch_app.sh $DRYRUN $TAG -v $VERSION
# Change permissions on source files to match those expected by the server # Change permissions on source files to match those expected by the server

View File

@ -3,19 +3,23 @@ BASEDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"/..
cd "$BASEDIR" cd "$BASEDIR"
# Reading arguments # Reading arguments
while getopts tdv: option; do while getopts m:tdv: option; do
case "${option}" in case "${option}" in
m) MV=$OPTARG;; # Optionally indicates the manifest version we're using (2 or 3); if present, the version will be added to filename
t) TAG="-t";; # Indicates that we're releasing a public version from a tag t) TAG="-t";; # Indicates that we're releasing a public version from a tag
d) DRYRUN="-d";; # Indicates a dryrun test, that does not modify anything on the network d) DRYRUN="-d";; # Indicates a dryrun test, that does not modify anything on the network
v) VERSION=${OPTARG};; v) VERSION=${OPTARG};;
esac esac
done done
if [ -n $MV ]; then
echo -e "\nManifest version requested: $MV"
VERSION="MV$MV-$VERSION"
fi
echo "Packaging unsigned Chrome extension, version $VERSION" echo "Packaging unsigned Chrome extension, version $VERSION"
cd tmp cd tmp
zip -r ../build/kiwix-chrome-unsigned-extension-$VERSION.zip www webextension manifest.json LICENSE-GPLv3.txt service-worker.js README.md zip -r ../build/kiwix-chrome-unsigned-extension-$VERSION.zip www manifest.json LICENSE-GPLv3.txt service-worker.js README.md
cd .. cd ..
if [ "${TAG}zz" == "zz" ]; then if [ -z $TAG ]; then
# Package the extension with Chrome or Chromium, if we're not packaging a public version # Package the extension with Chrome or Chromium, if we're not packaging a public version
if hash chromium-browser 2>/dev/null if hash chromium-browser 2>/dev/null
then then
@ -28,6 +32,7 @@ if [ "${TAG}zz" == "zz" ]; then
echo "Signing the extension for $CHROME_BIN, version $VERSION" echo "Signing the extension for $CHROME_BIN, version $VERSION"
$CHROME_BIN --no-sandbox --pack-extension=tmp --pack-extension-key=./scripts/kiwix-html5.pem $CHROME_BIN --no-sandbox --pack-extension=tmp --pack-extension-key=./scripts/kiwix-html5.pem
mv tmp.crx build/kiwix-chrome-signed-extension-$VERSION.crx mv tmp.crx build/kiwix-chrome-signed-extension-$VERSION.crx
ls -l build/kiwix-chrome-signed-extension-$VERSION.crx
else else
echo "This unsigned extension must be manually uploaded to Google to be signed and distributed from their store" echo "This unsigned extension must be manually uploaded to Google to be signed and distributed from their store"
fi fi

View File

@ -2,27 +2,29 @@
* service-worker.js : Service Worker implementation, * service-worker.js : Service Worker implementation,
* in order to capture the HTTP requests made by an article, and respond with the * in order to capture the HTTP requests made by an article, and respond with the
* corresponding content, coming from the archive * corresponding content, coming from the archive
* *
* Copyright 2022 Mossroy, Jaifroid and contributors * Copyright 2022 Mossroy, Jaifroid and contributors
* License GPL v3: * License GPL v3:
* *
* This file is part of Kiwix. * This file is part of Kiwix.
* *
* Kiwix is free software: you can redistribute it and/or modify * Kiwix is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* Kiwix is distributed in the hope that it will be useful, * Kiwix is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details. * GNU General Public License for more details.
* *
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/> * along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/>
*/ */
'use strict'; 'use strict';
/* global chrome */
/** /**
* App version number - ENSURE IT MATCHES VALUE IN app.js * App version number - ENSURE IT MATCHES VALUE IN app.js
* DEV: Changing this will cause the browser to recognize that the Service Worker has changed, and it will * DEV: Changing this will cause the browser to recognize that the Service Worker has changed, and it will
@ -60,10 +62,9 @@ var useAssetsCache = true;
* This is an expert setting in Configuration * This is an expert setting in Configuration
* @type {Boolean} * @type {Boolean}
*/ */
var useAppCache = true; var useAppCache = true;
/**
/**
* A regular expression that matches the Content-Types of assets that may be stored in ASSETS_CACHE * A regular expression that matches the Content-Types of assets that may be stored in ASSETS_CACHE
* Add any further Content-Types you wish to cache to the regexp, separated by '|' * Add any further Content-Types you wish to cache to the regexp, separated by '|'
* @type {RegExp} * @type {RegExp}
@ -78,7 +79,7 @@ var regexpCachedContentTypes = /text\/css|text\/javascript|application\/javascri
*/ */
var regexpExcludedURLSchema = /^(?:file|chrome-extension|example-extension):/i; var regexpExcludedURLSchema = /^(?:file|chrome-extension|example-extension):/i;
/** /**
* Pattern for ZIM file namespace: see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces * Pattern for ZIM file namespace: see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces
* In our case, there is also the ZIM file name used as a prefix in the URL * In our case, there is also the ZIM file name used as a prefix in the URL
* @type {RegExp} * @type {RegExp}
@ -91,7 +92,7 @@ const regexpZIMUrlWithNamespace = /(?:^|\/)([^/]+\/)([-ABCIJMUVWX])\/(.+)/;
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
* But, in our case, we send a header to tell the browser we only accept the bytes unit. * But, in our case, we send a header to tell the browser we only accept the bytes unit.
* I did not see multiple ranges asked by a browser. * I did not see multiple ranges asked by a browser.
* *
* @type {RegExp} * @type {RegExp}
*/ */
const regexpByteRangeHeader = /^\s*bytes=(\d+)-/; const regexpByteRangeHeader = /^\s*bytes=(\d+)-/;
@ -99,74 +100,84 @@ const regexpByteRangeHeader = /^\s*bytes=(\d+)-/;
/** /**
* The list of files that the app needs in order to run entirely from offline code * The list of files that the app needs in order to run entirely from offline code
*/ */
let precacheFiles = [ const precacheFiles = [
".", // This caches the redirect to www/index.html, in case a user launches the app from its root directory '.', // This caches the redirect to www/index.html, in case a user launches the app from its root directory
"manifest.json", 'manifest.json',
"service-worker.js", 'service-worker.js',
"www/css/app.css", 'www/css/app.css',
"www/css/bootstrap.css", 'www/css/bootstrap.css',
"www/css/kiwixJS_invert.css", 'www/css/kiwixJS_invert.css',
"www/css/kiwixJS_mwInvert.css", 'www/css/kiwixJS_mwInvert.css',
"www/css/transition.css", 'www/css/transition.css',
"www/img/icons/kiwix-256.png", 'www/img/icons/kiwix-256.png',
"www/img/icons/kiwix-32.png", 'www/img/icons/kiwix-32.png',
"www/img/icons/kiwix-60.png", 'www/img/icons/kiwix-60.png',
"www/img/spinner.gif", 'www/img/spinner.gif',
"www/img/Icon_External_Link.png", 'www/img/Icon_External_Link.png',
"www/index.html", 'www/index.html',
"www/article.html", 'www/article.html',
"www/main.html", 'www/main.html',
"www/js/app.js", 'www/js/app.js',
"www/js/init.js", 'www/js/init.js',
"www/js/lib/abstractFilesystemAccess.js", 'www/js/lib/abstractFilesystemAccess.js',
"www/js/lib/arrayFromPolyfill.js", 'www/js/lib/arrayFromPolyfill.js',
"www/js/lib/bootstrap.bundle.js", 'www/js/lib/bootstrap.bundle.js',
"www/js/lib/filecache.js", 'www/js/lib/filecache.js',
"www/js/lib/jquery-3.7.0.slim.min.js", 'www/js/lib/jquery-3.7.0.slim.min.js',
"www/js/lib/promisePolyfill.js", 'www/js/lib/promisePolyfill.js',
"www/js/lib/require.js", 'www/js/lib/require.js',
"www/js/lib/settingsStore.js", 'www/js/lib/settingsStore.js',
"www/js/lib/uiUtil.js", 'www/js/lib/uiUtil.js',
"www/js/lib/utf8.js", 'www/js/lib/utf8.js',
"www/js/lib/util.js", 'www/js/lib/util.js',
"www/js/lib/xzdec_wrapper.js", 'www/js/lib/xzdec_wrapper.js',
"www/js/lib/zstddec_wrapper.js", 'www/js/lib/zstddec_wrapper.js',
"www/js/lib/zimArchive.js", 'www/js/lib/zimArchive.js',
"www/js/lib/zimArchiveLoader.js", 'www/js/lib/zimArchiveLoader.js',
"www/js/lib/zimDirEntry.js", 'www/js/lib/zimDirEntry.js',
"www/js/lib/zimfile.js", 'www/js/lib/zimfile.js',
"www/js/lib/fontawesome/fontawesome.js", 'www/js/lib/fontawesome/fontawesome.js',
"www/js/lib/fontawesome/solid.js" 'www/js/lib/fontawesome/solid.js'
]; ];
if ('WebAssembly' in self) { if ('WebAssembly' in self) {
precacheFiles.push( precacheFiles.push(
"www/js/lib/xzdec-wasm.js", 'www/js/lib/xzdec-wasm.js',
"www/js/lib/xzdec-wasm.wasm", 'www/js/lib/xzdec-wasm.wasm',
"www/js/lib/zstddec-wasm.js", 'www/js/lib/zstddec-wasm.js',
"www/js/lib/zstddec-wasm.wasm", 'www/js/lib/zstddec-wasm.wasm',
"www/js/lib/libzim-wasm.js", 'www/js/lib/libzim-wasm.js',
"www/js/lib/libzim-wasm.wasm" 'www/js/lib/libzim-wasm.wasm'
); );
} else { } else {
precacheFiles.push( precacheFiles.push(
"www/js/lib/xzdec-asm.js", 'www/js/lib/xzdec-asm.js',
"www/js/lib/zstddec-asm.js", 'www/js/lib/zstddec-asm.js',
"www/js/lib/libzim-asm.js" 'www/js/lib/libzim-asm.js'
); );
}
/**
* If we're in a Chromium extension, add a listener to launch the tab when the icon is clicked
*/
if (typeof chrome !== 'undefined' && chrome.action) {
chrome.action.onClicked.addListener(function () {
var newURL = chrome.runtime.getURL('www/index.html');
chrome.tabs.create({ url: newURL });
});
} }
// Process install event // Process install event
self.addEventListener("install", function (event) { self.addEventListener('install', function (event) {
console.debug("[SW] Install Event processing"); console.debug('[SW] Install Event processing');
// DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate... // DEV: We can't skip waiting because too many params are loaded at an early stage from the old file before the new one can activate...
// self.skipWaiting(); // self.skipWaiting();
// We try to circumvent the browser's cache by adding a header to the Request, and it ensures all files are explicitly versioned // We try to circumvent the browser's cache by adding a header to the Request, and it ensures all files are explicitly versioned
var requests = precacheFiles.map(function (urlPath) { var requests = precacheFiles.map(function (urlPath) {
return new Request(urlPath + '?v' + appVersion, { cache: 'no-cache' }); return new Request(urlPath + '?v' + appVersion, { cache: 'no-cache' });
}); });
if (!regexpExcludedURLSchema.test(requests[0].url)) event.waitUntil( if (!regexpExcludedURLSchema.test(requests[0].url)) {
caches.open(APP_CACHE).then(function (cache) { event.waitUntil(caches.open(APP_CACHE).then(function (cache) {
return Promise.all( return Promise.all(
requests.map(function (request) { requests.map(function (request) {
return fetch(request).then(function (response) { return fetch(request).then(function (response) {
@ -178,8 +189,8 @@ self.addEventListener("install", function (event) {
}); });
}) })
); );
}) }));
); }
}); });
// Allow sw to control current page // Allow sw to control current page
@ -211,7 +222,7 @@ let fetchCaptureEnabled = false;
*/ */
self.addEventListener('fetch', function (event) { self.addEventListener('fetch', function (event) {
// Only cache GET requests // Only cache GET requests
if (event.request.method !== "GET") return; if (event.request.method !== 'GET') return;
var rqUrl = event.request.url; var rqUrl = event.request.url;
var urlObject = new URL(rqUrl); var urlObject = new URL(rqUrl);
// Test the URL with parameters removed // Test the URL with parameters removed
@ -225,13 +236,13 @@ self.addEventListener('fetch', function (event) {
event.respondWith( event.respondWith(
// First see if the content is in the cache // First see if the content is in the cache
fromCache(cache, rqUrl).then(function (response) { fromCache(cache, rqUrl).then(function (response) {
// The response was found in the cache so we respond with it // The response was found in the cache so we respond with it
return response; return response;
}, function () { }, function () {
// The response was not found in the cache so we look for it in the ZIM // The response was not found in the cache so we look for it in the ZIM
// and add it to the cache if it is an asset type (css or js) // and add it to the cache if it is an asset type (css or js)
if (cache === ASSETS_CACHE && regexpZIMUrlWithNamespace.test(strippedUrl)) { if (cache === ASSETS_CACHE && regexpZIMUrlWithNamespace.test(strippedUrl)) {
let range = event.request.headers.get('range'); const range = event.request.headers.get('range');
return fetchUrlFromZIM(urlObject, range).then(function (response) { return fetchUrlFromZIM(urlObject, range).then(function (response) {
// Add css or js assets to ASSETS_CACHE (or update their cache entries) unless the URL schema is not supported // Add css or js assets to ASSETS_CACHE (or update their cache entries) unless the URL schema is not supported
if (regexpCachedContentTypes.test(response.headers.get('Content-Type')) && if (regexpCachedContentTypes.test(response.headers.get('Content-Type')) &&
@ -252,7 +263,7 @@ self.addEventListener('fetch', function (event) {
} }
return response; return response;
}).catch(function (error) { }).catch(function (error) {
console.debug("[SW] Network request failed and no cache.", error); console.debug('[SW] Network request failed and no cache.', error);
}); });
} }
}) })
@ -262,7 +273,7 @@ self.addEventListener('fetch', function (event) {
/** /**
* Handle custom commands sent from app.js * Handle custom commands sent from app.js
*/ */
self.addEventListener('message', function (event) { self.addEventListener('message', function (event) {
if (event.data.action) { if (event.data.action) {
if (event.data.action === 'init') { if (event.data.action === 'init') {
// On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener
@ -287,7 +298,7 @@ self.addEventListener('fetch', function (event) {
if (useAppCache !== oldValue) console.debug('[SW] Use of appCache was switched to: ' + useAppCache); if (useAppCache !== oldValue) console.debug('[SW] Use of appCache was switched to: ' + useAppCache);
} }
if (event.data.action === 'getCacheNames') { if (event.data.action === 'getCacheNames') {
event.ports[0].postMessage({ 'app': APP_CACHE, 'assets': ASSETS_CACHE }); event.ports[0].postMessage({ app: APP_CACHE, assets: ASSETS_CACHE });
} }
if (event.data.action.checkCache) { if (event.data.action.checkCache) {
// Checks and returns the caching strategy: checkCache key should contain a sample URL string to test // Checks and returns the caching strategy: checkCache key should contain a sample URL string to test
@ -300,12 +311,12 @@ self.addEventListener('fetch', function (event) {
/** /**
* Handles URLs that need to be extracted from the ZIM archive * Handles URLs that need to be extracted from the ZIM archive
* *
* @param {URL} urlObject The URL object to be processed for extraction from the ZIM * @param {URL} urlObject The URL object to be processed for extraction from the ZIM
* @param {String} range Optional byte range string * @param {String} range Optional byte range string
* @returns {Promise<Response>} A Promise for the Response, or rejects with the invalid message port data * @returns {Promise<Response>} A Promise for the Response, or rejects with the invalid message port data
*/ */
function fetchUrlFromZIM(urlObject, range) { function fetchUrlFromZIM (urlObject, range) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
// Note that titles may contain bare question marks or hashes, so we must use only the pathname without any URL parameters. // Note that titles may contain bare question marks or hashes, so we must use only the pathname without any URL parameters.
// Be sure that you haven't encoded any querystring along with the URL. // Be sure that you haven't encoded any querystring along with the URL.
@ -330,14 +341,14 @@ function fetchUrlFromZIM(urlObject, range) {
headers.set('Content-Security-Policy', "default-src 'self' data: blob: about: chrome-extension: https://moz-extension.kiwix.org https://kiwix.github.io 'unsafe-inline' 'unsafe-eval'; sandbox allow-scripts allow-same-origin allow-modals allow-popups allow-forms allow-downloads;"); headers.set('Content-Security-Policy', "default-src 'self' data: blob: about: chrome-extension: https://moz-extension.kiwix.org https://kiwix.github.io 'unsafe-inline' 'unsafe-eval'; sandbox allow-scripts allow-same-origin allow-modals allow-popups allow-forms allow-downloads;");
headers.set('Referrer-Policy', 'no-referrer'); headers.set('Referrer-Policy', 'no-referrer');
if (contentType) headers.set('Content-Type', contentType); if (contentType) headers.set('Content-Type', contentType);
// Test if the content is a video or audio file. In this case, Chrome & Edge need us to support ranges. // Test if the content is a video or audio file. In this case, Chrome & Edge need us to support ranges.
// See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/")
// The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp
if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) {
headers.set('Accept-Ranges', 'bytes'); headers.set('Accept-Ranges', 'bytes');
} }
var slicedData = msgPortEvent.data.content; var slicedData = msgPortEvent.data.content;
if (range) { if (range) {
// The browser asks for a range of bytes (usually for a video or audio stream) // The browser asks for a range of bytes (usually for a video or audio stream)
@ -347,22 +358,22 @@ function fetchUrlFromZIM(urlObject, range) {
// So it's probably better to send all we have: hopefully it will avoid some subsequent requests of // So it's probably better to send all we have: hopefully it will avoid some subsequent requests of
// the browser to get the following chunks (which would trigger some other complete reads in the ZIM file) // the browser to get the following chunks (which would trigger some other complete reads in the ZIM file)
// This might be improved in the future with the libzim wasm backend, that should be able to handle ranges. // This might be improved in the future with the libzim wasm backend, that should be able to handle ranges.
let partsOfRangeHeader = regexpByteRangeHeader.exec(range); const partsOfRangeHeader = regexpByteRangeHeader.exec(range);
let begin = partsOfRangeHeader[1]; const begin = partsOfRangeHeader[1];
let end = contentLength - 1; const end = contentLength - 1;
slicedData = slicedData.slice(begin); slicedData = slicedData.slice(begin);
headers.set('Content-Range', 'bytes ' + begin + '-' + end + '/' + contentLength); headers.set('Content-Range', 'bytes ' + begin + '-' + end + '/' + contentLength);
headers.set('Content-Length', end - begin + 1); headers.set('Content-Length', end - begin + 1);
} }
var responseInit = { var responseInit = {
// HTTP status is usually 200, but has to bee 206 when partial content (range) is sent // HTTP status is usually 200, but has to bee 206 when partial content (range) is sent
status: range ? 206 : 200, status: range ? 206 : 200,
statusText: 'OK', statusText: 'OK',
headers: headers headers
}; };
var httpResponse = new Response(slicedData, responseInit); var httpResponse = new Response(slicedData, responseInit);
// Let's send the content back from the ServiceWorker // Let's send the content back from the ServiceWorker
@ -374,8 +385,8 @@ function fetchUrlFromZIM(urlObject, range) {
} }
}; };
outgoingMessagePort.postMessage({ outgoingMessagePort.postMessage({
'action': 'askForContent', action: 'askForContent',
'title': titleWithNameSpace title: titleWithNameSpace
}, [messageChannel.port2]); }, [messageChannel.port2]);
}); });
} }
@ -386,12 +397,16 @@ function fetchUrlFromZIM(urlObject, range) {
* @param {String} requestUrl The Request URL to fulfill from cache * @param {String} requestUrl The Request URL to fulfill from cache
* @returns {Promise<Response>} A Promise for the cached Response, or rejects with strings 'disabled' or 'no-match' * @returns {Promise<Response>} A Promise for the cached Response, or rejects with strings 'disabled' or 'no-match'
*/ */
function fromCache(cache, requestUrl) { function fromCache (cache, requestUrl) {
// Prevents use of Cache API if user has disabled it // Prevents use of Cache API if user has disabled it
if (!(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) return Promise.reject('disabled'); if (!(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) {
return Promise.reject(new Error('disabled'));
}
return caches.open(cache).then(function (cacheObj) { return caches.open(cache).then(function (cacheObj) {
return cacheObj.match(requestUrl).then(function (matching) { return cacheObj.match(requestUrl).then(function (matching) {
if (!matching || matching.status === 404) return Promise.reject('no-match'); if (!matching || matching.status === 404) {
return Promise.reject(new Error('no-match'));
}
console.debug('[SW] Supplying ' + requestUrl + ' from ' + cache + '...'); console.debug('[SW] Supplying ' + requestUrl + ' from ' + cache + '...');
return matching; return matching;
}); });
@ -405,10 +420,11 @@ function fromCache(cache, requestUrl) {
* @param {Response} response The Response received from the server/ZIM * @param {Response} response The Response received from the server/ZIM
* @returns {Promise} A Promise for the update action * @returns {Promise} A Promise for the update action
*/ */
function updateCache(cache, request, response) { function updateCache (cache, request, response) {
// Prevents use of Cache API if user has disabled it // Prevents use of Cache API if user has disabled it
if (!response.ok || !(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) if (!response.ok || !(useAppCache && cache === APP_CACHE || useAssetsCache && cache === ASSETS_CACHE)) {
return Promise.resolve(); return Promise.resolve();
}
return caches.open(cache).then(function (cacheObj) { return caches.open(cache).then(function (cacheObj) {
console.debug('[SW] Adding ' + (request.url || request) + ' to ' + cache + '...'); console.debug('[SW] Adding ' + (request.url || request) + ' to ' + cache + '...');
return cacheObj.put(request, response); return cacheObj.put(request, response);
@ -421,16 +437,16 @@ function updateCache(cache, request, response) {
* @param {String} url A URL to test against excludedURLSchema * @param {String} url A URL to test against excludedURLSchema
* @returns {Promise<Array>} A Promise for an array of format [cacheType, cacheDescription, assetCount] * @returns {Promise<Array>} A Promise for an array of format [cacheType, cacheDescription, assetCount]
*/ */
function testCacheAndCountAssets(url) { function testCacheAndCountAssets (url) {
if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', 'custom', 'Custom', '-']); if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', 'custom', 'Custom', '-']);
if (!useAssetsCache) return Promise.resolve(['none', 'none', 'None', 0]); if (!useAssetsCache) return Promise.resolve(['none', 'none', 'None', 0]);
return caches.open(ASSETS_CACHE).then(function (cache) { return caches.open(ASSETS_CACHE).then(function (cache) {
return cache.keys().then(function (keys) { return cache.keys().then(function (keys) {
return ['cacheAPI', ASSETS_CACHE, 'Cache API', keys.length]; return ['cacheAPI', ASSETS_CACHE, 'Cache API', keys.length];
}).catch(function(err) { }).catch(function (err) {
return err; return err;
}); });
}).catch(function(err) { }).catch(function (err) {
return err; return err;
}); });
} }

View File

@ -30,9 +30,8 @@
// This uses require.js to structure javascript: // This uses require.js to structure javascript:
// http://requirejs.org/docs/api.html#define // http://requirejs.org/docs/api.html#define
define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesystemAccess'], define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore', 'abstractFilesystemAccess'],
function ($, zimArchiveLoader, uiUtil, settingsStore, abstractFilesystemAccess) { function ($, zimArchiveLoader, uiUtil, settingsStore, abstractFilesystemAccess) {
/** /**
* The delay (in milliseconds) between two "keepalive" messages sent to the ServiceWorker (so that it is not stopped * The delay (in milliseconds) between two "keepalive" messages sent to the ServiceWorker (so that it is not stopped
* by the browser, and keeps the MessageChannel to communicate with the application) * by the browser, and keeps the MessageChannel to communicate with the application)
@ -108,7 +107,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
(isServiceWorkerAvailable() ? 'serviceworker' : 'jquery'); (isServiceWorkerAvailable() ? 'serviceworker' : 'jquery');
// A parameter to circumvent anti-fingerprinting technology in browsers that do not support WebP natively by substituting images // A parameter to circumvent anti-fingerprinting technology in browsers that do not support WebP natively by substituting images
// directly with the canvas elements produced by the WebP polyfill [kiwix-js #835]. NB This is only currently used in jQuery mode. // directly with the canvas elements produced by the WebP polyfill [kiwix-js #835]. NB This is only currently used in jQuery mode.
params['useCanvasElementsForWebpTranscoding']; // Value is determined in uiUtil.determineCanvasElementsWorkaround(), called when setting the content injection mode params['useCanvasElementsForWebpTranscoding'] = null; // Value is determined in uiUtil.determineCanvasElementsWorkaround(), called when setting the content injection mode
// An object to hold the current search and its state (allows cancellation of search across modules) // An object to hold the current search and its state (allows cancellation of search across modules)
appstate['search'] = { appstate['search'] = {
@ -182,11 +181,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
document.getElementById('bypassAppCacheCheck').checked = !params.appCache; document.getElementById('bypassAppCacheCheck').checked = !params.appCache;
document.getElementById('appVersion').textContent = 'Kiwix ' + params.appVersion; document.getElementById('appVersion').textContent = 'Kiwix ' + params.appVersion;
// We check here if we have to warn the user that we switched to ServiceWorkerMode // We check here if we have to warn the user that we switched to ServiceWorkerMode
// This is only needed if the ServiceWorker mode is available, or we are in a Firefox Extension that supports Service Workers // 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 // 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 // alerted about the switch to ServiceWorker mode by default
if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^moz-extension:/i.test(window.location.protocol)) if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^(moz|chrome)-extension:/i.test(window.location.protocol)) &&
&& params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) { params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) {
// Attempt to upgrade user to ServiceWorker mode // Attempt to upgrade user to ServiceWorker mode
params.contentInjectionMode = 'serviceworker'; params.contentInjectionMode = 'serviceworker';
} else if (params.contentInjectionMode === 'serviceworker') { } else if (params.contentInjectionMode === 'serviceworker') {
@ -329,16 +328,12 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
$('#prefix').on('keyup', function (e) { $('#prefix').on('keyup', function (e) {
if (selectedArchive !== null && selectedArchive.isReady()) { if (selectedArchive !== null && selectedArchive.isReady()) {
// Prevent processing by keyup event if we already handled the keypress in keydown event // Prevent processing by keyup event if we already handled the keypress in keydown event
if (keyPressHandled) if (keyPressHandled) { keyPressHandled = false; } else { onKeyUpPrefix(e); }
keyPressHandled = false;
else
onKeyUpPrefix(e);
} }
}); });
// Restore the search results if user goes back into prefix field // Restore the search results if user goes back into prefix field
$('#prefix').on('focus', function () { $('#prefix').on('focus', function () {
if (document.getElementById('prefix').value !== '') if (document.getElementById('prefix').value !== '') { document.getElementById('articleListWithHeader').style.display = ''; }
document.getElementById('articleListWithHeader').style.display = '';
}); });
// Hide the search results if user moves out of prefix field // Hide the search results if user moves out of prefix field
$('#prefix').on('blur', function () { $('#prefix').on('blur', function () {
@ -374,7 +369,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}); });
$('#btnTop').on('click', function () { $('#btnTop').on('click', function () {
var articleContent = document.getElementById('articleContent'); var articleContent = document.getElementById('articleContent');
articleContent.contentWindow.scrollTo({top: 0, behavior: 'smooth'}); articleContent.contentWindow.scrollTo({ top: 0, behavior: 'smooth' });
// We return true, so that the link to #top is still triggered (useful in the About section) // We return true, so that the link to #top is still triggered (useful in the About section)
return true; return true;
}); });
@ -401,7 +396,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
document.getElementById('prefix').value = ''; document.getElementById('prefix').value = '';
document.getElementById('prefix').focus(); document.getElementById('prefix').focus();
var articleList = document.getElementById('articleList'); var articleList = document.getElementById('articleList');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
while (articleList.firstChild) articleList.removeChild(articleList.firstChild); while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild); while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
document.getElementById('searchingArticles').style.display = 'none'; document.getElementById('searchingArticles').style.display = 'none';
@ -505,7 +500,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
refreshCacheStatus(); refreshCacheStatus();
}); });
document.getElementById('disableDragAndDropCheck').addEventListener('change', function () { document.getElementById('disableDragAndDropCheck').addEventListener('change', function () {
params.disableDragAndDrop = this.checked ? true : false; params.disableDragAndDrop = !!this.checked;
settingsStore.setItem('disableDragAndDrop', params.disableDragAndDrop, Infinity); settingsStore.setItem('disableDragAndDrop', params.disableDragAndDrop, Infinity);
uiUtil.systemAlert('<p>We will now attempt to reload the app to apply the new setting.</p>' + uiUtil.systemAlert('<p>We will now attempt to reload the app to apply the new setting.</p>' +
'<p>(If you cancel, then the setting will only be applied when you next start the app.)</p>', 'Reload app', true).then(function (result) { '<p>(If you cancel, then the setting will only be applied when you next start the app.)</p>', 'Reload app', true).then(function (result) {
@ -515,20 +510,20 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
}); });
}); });
$('input:checkbox[name=hideActiveContentWarning]').on('change', function () { $('input:checkbox[name=hideActiveContentWarning]').on('change', function () {
params.hideActiveContentWarning = this.checked ? true : false; params.hideActiveContentWarning = !!this.checked;
settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity); settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity);
}); });
$('input:checkbox[name=showUIAnimations]').on('change', function () { $('input:checkbox[name=showUIAnimations]').on('change', function () {
params.showUIAnimations = this.checked ? true : false; params.showUIAnimations = !!this.checked;
settingsStore.setItem('showUIAnimations', params.showUIAnimations, Infinity); settingsStore.setItem('showUIAnimations', params.showUIAnimations, Infinity);
}); });
$('input:checkbox[name=useHomeKeyToFocusSearchBar]').on('change', function () { $('input:checkbox[name=useHomeKeyToFocusSearchBar]').on('change', function () {
params.useHomeKeyToFocusSearchBar = this.checked ? true : false; params.useHomeKeyToFocusSearchBar = !!this.checked;
settingsStore.setItem('useHomeKeyToFocusSearchBar', params.useHomeKeyToFocusSearchBar, Infinity); settingsStore.setItem('useHomeKeyToFocusSearchBar', params.useHomeKeyToFocusSearchBar, Infinity);
switchHomeKeyToFocusSearchBar(); switchHomeKeyToFocusSearchBar();
}); });
$('input:checkbox[name=openExternalLinksInNewTabs]').on('change', function () { $('input:checkbox[name=openExternalLinksInNewTabs]').on('change', function () {
params.openExternalLinksInNewTabs = this.checked ? true : false; params.openExternalLinksInNewTabs = !!this.checked;
settingsStore.setItem('openExternalLinksInNewTabs', params.openExternalLinksInNewTabs, Infinity); settingsStore.setItem('openExternalLinksInNewTabs', params.openExternalLinksInNewTabs, Infinity);
}); });
document.getElementById('appThemeSelect').addEventListener('change', function (e) { document.getElementById('appThemeSelect').addEventListener('change', function (e) {
@ -578,12 +573,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
uiUtil.checkUpdateStatus(appstate); uiUtil.checkUpdateStatus(appstate);
}, 10000); }, 10000);
// Adds an event listener to kiwix logo and bottom navigation bar which gets triggered when these elements are dragged. // 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) // 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 // Doing that in javascript is the only way to make it cross-browser compatible
document.getElementById('kiwixLogo').ondragstart=function () {return false;} document.getElementById('kiwixLogo').ondragstart = function () { return false; }
document.getElementById('navigationButtons').ondragstart=function () {return false;} document.getElementById('navigationButtons').ondragstart = function () { return false; }
// focus search bar (#prefix) if Home key is pressed // focus search bar (#prefix) if Home key is pressed
function focusPrefixOnHomeKey (event) { function focusPrefixOnHomeKey (event) {
@ -592,7 +586,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// wait to prevent interference with scrolling (default action) // wait to prevent interference with scrolling (default action)
setTimeout(function () { setTimeout(function () {
document.getElementById('prefix').focus(); document.getElementById('prefix').focus();
},0); }, 0);
} }
} }
// switch on/off the feature to use Home Key to focus search bar // switch on/off the feature to use Home Key to focus search bar
@ -602,8 +596,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var isIframeAccessible = true; var isIframeAccessible = true;
try { try {
iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
} } catch (err) {
catch (err) {
console.error('The iframe is probably not accessible', err); console.error('The iframe is probably not accessible', err);
isIframeAccessible = false; isIframeAccessible = false;
} }
@ -615,10 +608,8 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// only for initial empty iFrame loaded using `src` attribute // only for initial empty iFrame loaded using `src` attribute
// in any other case listener gets removed on reloading of iFrame content // in any other case listener gets removed on reloading of iFrame content
iframeContentWindow.addEventListener('keydown', focusPrefixOnHomeKey); iframeContentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
} } else {
// when the feature is not active // When the feature is not active, remove event listener for window (outside iframe)
else {
// remove event listener for window(outside iframe)
window.removeEventListener('keydown', focusPrefixOnHomeKey); window.removeEventListener('keydown', focusPrefixOnHomeKey);
// if feature is deactivated and no zim content is loaded yet // if feature is deactivated and no zim content is loaded yet
iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
@ -710,7 +701,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} }
apiName = params.decompressorAPI.errorStatus || apiName || 'Not initialized'; apiName = params.decompressorAPI.errorStatus || apiName || 'Not initialized';
// innerHTML is used here because the API name may contain HTML entities like &nbsp; // innerHTML is used here because the API name may contain HTML entities like &nbsp;
decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName ; decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName;
// Update Search Provider // Update Search Provider
uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider); uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider);
// Update PWA origin // Update PWA origin
@ -859,7 +850,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
uriParams += '&appTheme=' + params.appTheme; uriParams += '&appTheme=' + params.appTheme;
uriParams += '&showUIAnimations=' + params.showUIAnimations; uriParams += '&showUIAnimations=' + params.showUIAnimations;
window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams; window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
'Beam me down, Scotty!'; console.log('Beam me down, Scotty!');
}; };
uiUtil.systemAlert(message, 'Warning!', true).then(function (response) { uiUtil.systemAlert(message, 'Warning!', true).then(function (response) {
if (response) { if (response) {
@ -1018,8 +1009,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
try { try {
var dummyMessageChannel = new MessageChannel(); var dummyMessageChannel = new MessageChannel();
if (dummyMessageChannel) return true; if (dummyMessageChannel) return true;
} } catch (e) {
catch (e) {
return false; return false;
} }
return false; return false;
@ -1039,8 +1029,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// DEV: See explanation below for why we access localStorage directly here // DEV: See explanation below for why we access localStorage directly here
var PWASuccessfullyLaunched = localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success'; var PWASuccessfullyLaunched = localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success';
var allowInternetAccess = settingsStore.getItem('allowInternetAccess') === 'true'; var allowInternetAccess = settingsStore.getItem('allowInternetAccess') === 'true';
var message = params.defaultModeChangeAlertDisplayed ? '<p>To enable the Service Worker, we ' : var message = params.defaultModeChangeAlertDisplayed
('<p>We shall attempt to switch you to ServiceWorker mode (this is now the default). ' + ? '<p>To enable the Service Worker, we '
: ('<p>We shall attempt to switch you to ServiceWorker mode (this is now the default). ' +
'It supports more types of ZIM archives and is much more robust.</p><p>We '); 'It supports more types of ZIM archives and is much more robust.</p><p>We ');
message += 'need one-time access to our secure server so that the app can re-launch as a Progressive Web App (PWA). ' + message += 'need one-time access to our secure server so that the app can re-launch as a Progressive Web App (PWA). ' +
'If available, the PWA will work offline, but will auto-update periodically when online as per the ' + 'If available, the PWA will work offline, but will auto-update periodically when online as per the ' +
@ -1063,7 +1054,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// regarding the location of the key to be able to retrieve it in init.js before settingsStore is initialized // 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'); localStorage.setItem(params.keyPrefix + 'PWA_launch', 'fail');
window.location.href = params.PWAServer + 'www/index.html' + uriParams; window.location.href = params.PWAServer + 'www/index.html' + uriParams;
'Beam me up, Scotty!'; console.log('Beam me up, Scotty!');
}; };
var checkPWAIsOnline = function () { var checkPWAIsOnline = function () {
uiUtil.spinnerDisplay(true, 'Checking server access...'); uiUtil.spinnerDisplay(true, 'Checking server access...');
@ -1148,7 +1139,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} else { } else {
// If DeviceStorage is not available, we display the file select components // If DeviceStorage is not available, we display the file select components
displayFileSelect(); displayFileSelect();
if (document.getElementById('archiveFiles').files && document.getElementById('archiveFiles').files.length>0) { if (document.getElementById('archiveFiles').files && document.getElementById('archiveFiles').files.length > 0) {
// Archive files are already selected, // Archive files are already selected,
setLocalArchiveFromFileSelect(); setLocalArchiveFromFileSelect();
} else { } else {
@ -1169,7 +1160,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
document.getElementById('articleListWithHeader').style.display = 'none'; document.getElementById('articleListWithHeader').style.display = 'none';
$('#articleContent').contents().empty(); $('#articleContent').contents().empty();
if (title && !(''===title)) { if (title && !(title === '')) {
goToArticle(title); goToArticle(title);
} else if (titleSearch && titleSearch !== '') { } else if (titleSearch && titleSearch !== '') {
document.getElementById('prefix').value = titleSearch; document.getElementById('prefix').value = titleSearch;
@ -1207,7 +1198,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var lastSelectedArchive = settingsStore.getItem('lastSelectedArchive'); var lastSelectedArchive = settingsStore.getItem('lastSelectedArchive');
if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== '') { if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== '') {
// Attempt to select the corresponding item in the list, if it exists // Attempt to select the corresponding item in the list, if it exists
if ($("#archiveList option[value='"+lastSelectedArchive+"']").length > 0) { if ($("#archiveList option[value='" + lastSelectedArchive + "']").length > 0) {
document.getElementById('archiveList').value = lastSelectedArchive; document.getElementById('archiveList').value = lastSelectedArchive;
} }
} }
@ -1236,9 +1227,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var regexpStorageName = /^\/([^/]+)\//; var regexpStorageName = /^\/([^/]+)\//;
var regexpResults = regexpStorageName.exec(archiveDirectory); var regexpResults = regexpStorageName.exec(archiveDirectory);
var selectedStorage = null; var selectedStorage = null;
if (regexpResults && regexpResults.length>0) { if (regexpResults && regexpResults.length > 0) {
var selectedStorageName = regexpResults[1]; var selectedStorageName = regexpResults[1];
for (var i=0; i<storages.length; i++) { for (var i = 0; i < storages.length; i++) {
var storage = storages[i]; var storage = storages[i];
if (selectedStorageName === storage.storageName) { if (selectedStorageName === storage.storageName) {
// We found the selected storage // We found the selected storage
@ -1255,9 +1246,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
if (storages.length === 1) { if (storages.length === 1) {
selectedStorage = storages[0]; selectedStorage = storages[0];
} else { } else {
uiUtil.systemAlert('Something weird happened with the DeviceStorage API : found a directory without prefix : ' uiUtil.systemAlert('Something weird happened with the DeviceStorage API : found a directory without prefix : ' +
+ archiveDirectory + ', but there were ' + storages.length archiveDirectory + ', but there were ' + storages.length +
+ ' storages found with getDeviceStorages instead of 1', 'Error: unprefixed directory'); ' storages found with getDeviceStorages instead of 1', 'Error: unprefixed directory');
} }
} }
resetCssCache(); resetCssCache();
@ -1269,7 +1260,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// callbackError which is called in case of an error // callbackError which is called in case of an error
uiUtil.systemAlert(message, label); uiUtil.systemAlert(message, label);
}); });
} }
} }
@ -1321,7 +1311,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
function handleIframeDrop (e) { function handleIframeDrop (e) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} }
function handleFileDrop (packet) { function handleFileDrop (packet) {
@ -1390,7 +1379,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
request.response.name = url; request.response.name = url;
resolve(request.response); resolve(request.response);
} else { } else {
reject('HTTP status ' + request.status + ' when reading ' + url); reject(new Error('HTTP status ' + request.status + ' when reading ' + url));
} }
} }
}; };
@ -1444,9 +1433,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
appstate.search.status = 'cancelled'; 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) // 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 // 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 // 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 // (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}; appstate.search = { prefix: prefix, status: 'init', type: '', size: params.maxSearchResultsSize };
var activeContent = document.getElementById('activeContent'); var activeContent = document.getElementById('activeContent');
if (activeContent) activeContent.style.display = 'none'; if (activeContent) activeContent.style.display = 'none';
selectedArchive.findDirEntriesWithPrefix(appstate.search, populateListOfArticles); selectedArchive.findDirEntriesWithPrefix(appstate.search, populateListOfArticles);
@ -1479,7 +1468,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} else if (nbDirEntry >= params.maxSearchResultsSize) { } else if (nbDirEntry >= params.maxSearchResultsSize) {
message = 'First ' + params.maxSearchResultsSize + ' articles found (refine your search).'; message = 'First ' + params.maxSearchResultsSize + ' articles found (refine your search).';
} else { } else {
message = 'Finished. ' + (nbDirEntry ? nbDirEntry : 'No') + ' articles found' + ( message = 'Finished. ' + (nbDirEntry || 'No') + ' articles found' + (
reportingSearch.type === 'basic' ? ': try fewer words for full search.' : '.' reportingSearch.type === 'basic' ? ': try fewer words for full search.' : '.'
); );
} }
@ -1608,8 +1597,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
if (iframeArticleContent.contentWindow) { if (iframeArticleContent.contentWindow) {
// Configure home key press to focus #prefix only if the feature is in active state // Configure home key press to focus #prefix only if the feature is in active state
if (params.useHomeKeyToFocusSearchBar) if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); }
iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
if (params.openExternalLinksInNewTabs) { if (params.openExternalLinksInNewTabs) {
// Add event listener to iframe window to check for links to external resources // Add event listener to iframe window to check for links to external resources
iframeArticleContent.contentWindow.addEventListener('click', function (event) { iframeArticleContent.contentWindow.addEventListener('click', function (event) {
@ -1635,7 +1623,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
// remove eventListener to avoid memory leaks // remove eventListener to avoid memory leaks
iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
var articleList = document.getElementById('articleList'); var articleList = document.getElementById('articleList');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
while (articleList.firstChild) articleList.removeChild(articleList.firstChild); while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild); while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
document.getElementById('articleListWithHeader').style.display = 'none'; document.getElementById('articleListWithHeader').style.display = 'none';
@ -1792,7 +1780,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
iframeArticleContent.onload = function () { iframeArticleContent.onload = function () {
iframeArticleContent.onload = function () {}; iframeArticleContent.onload = function () {};
var articleList = document.getElementById('articleList'); var articleList = document.getElementById('articleList');
var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); var articleListHeaderMessage = document.getElementById('articleListHeaderMessage');
while (articleList.firstChild) articleList.removeChild(articleList.firstChild); while (articleList.firstChild) articleList.removeChild(articleList.firstChild);
while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild); while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild);
document.getElementById('articleListWithHeader').style.display = 'none'; document.getElementById('articleListWithHeader').style.display = 'none';
@ -1800,10 +1788,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var iframeContentDocument = iframeArticleContent.contentDocument; var iframeContentDocument = iframeArticleContent.contentDocument;
if (!iframeContentDocument && window.location.protocol === 'file:') { if (!iframeContentDocument && window.location.protocol === 'file:') {
uiUtil.systemAlert('You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons.' uiUtil.systemAlert('You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons.' +
+ '<br/><br/>The easiest way to run it is to download and run it as a browser extension (from the vendor store).' '<br/><br/>The easiest way to run it is to download and run it as a browser extension (from the vendor store).' +
+ '<br/><br/>Else you can open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)' '<br/><br/>Else you can open it through a web server : either through a local one (http://localhost/...) or through a remote one (but you need SSL : https://webserver/...)' +
+ "<br/><br/>Another option is to force your browser to accept that (but you'll open a security breach) : on Chrome, you can start it with --allow-file-access-from-files command-line argument; on Firefox, you can set privacy.file_unique_origin to false in about:config"); "<br/><br/>Another option is to force your browser to accept that (but you'll open a security breach) : on Chrome, you can start it with --allow-file-access-from-files command-line argument; on Firefox, you can set privacy.file_unique_origin to false in about:config");
return; return;
} }
@ -1816,9 +1804,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
docBody = docBody ? docBody[0] : null; docBody = docBody ? docBody[0] : null;
if (docBody) { if (docBody) {
// Add any missing classes stripped from the <html> tag // Add any missing classes stripped from the <html> tag
if (htmlCSS) htmlCSS.forEach(function (cl) { if (htmlCSS) {
htmlCSS.forEach(function (cl) {
docBody.classList.add(cl); docBody.classList.add(cl);
}); });
}
// Deflect drag-and-drop of ZIM file on the iframe to Config // Deflect drag-and-drop of ZIM file on the iframe to Config
docBody.addEventListener('dragover', handleIframeDragover); docBody.addEventListener('dragover', handleIframeDragover);
docBody.addEventListener('drop', handleIframeDrop); docBody.addEventListener('drop', handleIframeDrop);
@ -1846,8 +1836,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} }
if (iframeArticleContent.contentWindow) { if (iframeArticleContent.contentWindow) {
// Configure home key press to focus #prefix only if the feature is in active state // Configure home key press to focus #prefix only if the feature is in active state
if (params.useHomeKeyToFocusSearchBar) if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); }
iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey);
// when unloaded remove eventListener to avoid memory leaks // when unloaded remove eventListener to avoid memory leaks
iframeArticleContent.contentWindow.onunload = function () { iframeArticleContent.contentWindow.onunload = function () {
iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey);
@ -1998,7 +1987,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
selectedArchive.getDirEntryByPath(url).then(function (dirEntry) { selectedArchive.getDirEntryByPath(url).then(function (dirEntry) {
if (!dirEntry) { if (!dirEntry) {
cssCache.set(url, ''); // Prevent repeated lookups of this unfindable asset cssCache.set(url, ''); // Prevent repeated lookups of this unfindable asset
throw 'DirEntry ' + typeof dirEntry; throw new Error('DirEntry ' + typeof dirEntry);
} }
var mimetype = dirEntry.getMimetype(); var mimetype = dirEntry.getMimetype();
var readFile = /^text\//i.test(mimetype) ? selectedArchive.readUtf8File : selectedArchive.readBinaryFile; var readFile = /^text\//i.test(mimetype) ? selectedArchive.readUtf8File : selectedArchive.readBinaryFile;
@ -2069,7 +2058,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var source = mediaSource.getAttribute('src'); var source = mediaSource.getAttribute('src');
source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, baseUrl) : null; source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, baseUrl) : null;
// We have to exempt text tracks from using deriveZimUrlFromRelativeurl due to a bug in Firefox [kiwix-js #496] // We have to exempt text tracks from using deriveZimUrlFromRelativeurl due to a bug in Firefox [kiwix-js #496]
source = source ? source : decodeURIComponent(mediaSource.dataset.kiwixurl); source = source || decodeURIComponent(mediaSource.dataset.kiwixurl);
if (!source || !regexpZIMUrlWithNamespace.test(source)) { if (!source || !regexpZIMUrlWithNamespace.test(source)) {
if (source) console.error('No usable media source was found for: ' + source); if (source) console.error('No usable media source was found for: ' + source);
return; return;
@ -2100,7 +2089,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
if (params.assetsCache && /\.css$|\.js$/i.test(title)) { if (params.assetsCache && /\.css$|\.js$/i.test(title)) {
var cacheBlock = document.getElementById('cachingAssets'); var cacheBlock = document.getElementById('cachingAssets');
cacheBlock.style.display = 'block'; cacheBlock.style.display = 'block';
title = title.replace(/[^/]+\//g, '').substring(0,18); title = title.replace(/[^/]+\//g, '').substring(0, 18);
cacheBlock.textContent = 'Caching ' + title + '...'; cacheBlock.textContent = 'Caching ' + title + '...';
} }
} }
@ -2115,13 +2104,13 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
var stateObj = {}; var stateObj = {};
var urlParameters; var urlParameters;
var stateLabel; var stateLabel;
if (title && !(''===title)) { if (title && !(title === '')) {
// Prevents creating a double history for the same page // Prevents creating a double history for the same page
if (history.state && history.state.title === title) return; if (history.state && history.state.title === title) return;
stateObj.title = title; stateObj.title = title;
urlParameters = '?title=' + title; urlParameters = '?title=' + title;
stateLabel = 'Wikipedia Article : ' + title; stateLabel = 'Wikipedia Article : ' + title;
} else if (titleSearch && !(''===titleSearch)) { } else if (titleSearch && !(titleSearch === '')) {
stateObj.titleSearch = titleSearch; stateObj.titleSearch = titleSearch;
urlParameters = '?titleSearch=' + titleSearch; urlParameters = '?titleSearch=' + titleSearch;
stateLabel = 'Wikipedia search : ' + titleSearch; stateLabel = 'Wikipedia search : ' + titleSearch;
@ -2131,7 +2120,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
window.history.pushState(stateObj, stateLabel, urlParameters); window.history.pushState(stateObj, stateLabel, urlParameters);
} }
/** /**
* Extracts the content of the given article pathname, or a downloadable file, from the ZIM * Extracts the content of the given article pathname, or a downloadable file, from the ZIM
* *
@ -2226,5 +2214,4 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys
} }
}); });
} }
}); });

View File

@ -1,26 +1,30 @@
/** /**
* uiUtil.js : Utility functions for the User Interface * uiUtil.js : Utility functions for the User Interface
* *
* Copyright 2013-2020 Mossroy and contributors * Copyright 2013-2020 Mossroy and contributors
* License GPL v3: * License GPL v3:
* *
* This file is part of Kiwix. * This file is part of Kiwix.
* *
* Kiwix is free software: you can redistribute it and/or modify * Kiwix is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* Kiwix is distributed in the hope that it will be useful, * Kiwix is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details. * GNU General Public License for more details.
* *
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/> * along with Kiwix (file LICENSE-GPLv3.txt). If not, see <http://www.gnu.org/licenses/>
*/ */
'use strict'; 'use strict';
/* eslint-disable no-global-assign */
/* global $, define, webpMachine, webpHero, params */
// DEV: Put your RequireJS definition in the rqDef array below, and any function exports in the function parenthesis of the define statement // DEV: Put your RequireJS definition in the rqDef array below, and any function exports in the function parenthesis of the define statement
// We need to do it this way in order to load WebP polyfills conditionally. The WebP polyfills are only needed by a few old browsers, so loading them // We need to do it this way in order to load WebP polyfills conditionally. The WebP polyfills are only needed by a few old browsers, so loading them
// only if needed saves approximately 1MB of memory. // only if needed saves approximately 1MB of memory.
@ -31,27 +35,26 @@ if (webpMachine) {
rqDef.push('webpHeroBundle'); rqDef.push('webpHeroBundle');
} }
define(rqDef, function(settingsStore, util) { define(rqDef, function (settingsStore, util) {
/** /**
* Displays a Bootstrap alert or confirm dialog box depending on the options provided * Displays a Bootstrap alert or confirm dialog box depending on the options provided
* *
* @param {String} message The alert message(can be formatted using HTML) to display in the body of the modal. * @param {String} message The alert message(can be formatted using HTML) to display in the body of the modal.
* @param {String} label The modal's label or title which appears in the header (optional, Default = "Confirmation" or "Message") * @param {String} label The modal's label or title which appears in the header (optional, Default = "Confirmation" or "Message")
* @param {Boolean} isConfirm If true, the modal will be a confirm dialog box, otherwise it will be a simple alert message * @param {Boolean} isConfirm If true, the modal will be a confirm dialog box, otherwise it will be a simple alert message
* @param {String} declineConfirmLabel The text to display on the decline confirmation button (optional, Default = "Cancel") * @param {String} declineConfirmLabel The text to display on the decline confirmation button (optional, Default = "Cancel")
* @param {String} approveConfirmLabel The text to display on the approve confirmation button (optional, Default = "Confirm") * @param {String} approveConfirmLabel The text to display on the approve confirmation button (optional, Default = "Confirm")
* @param {String} closeMessageLabel The text to display on the close alert message button (optional, Default = "Okay") * @param {String} closeMessageLabel The text to display on the close alert message button (optional, Default = "Okay")
* @returns {Promise<Boolean>} A promise which resolves to true if the user clicked Confirm, false if the user clicked Cancel/Okay, backdrop or the cross(x) button * @returns {Promise<Boolean>} A promise which resolves to true if the user clicked Confirm, false if the user clicked Cancel/Okay, backdrop or the cross(x) button
*/ */
function systemAlert(message, label, isConfirm, declineConfirmLabel, approveConfirmLabel, closeMessageLabel) { function systemAlert (message, label, isConfirm, declineConfirmLabel, approveConfirmLabel, closeMessageLabel) {
declineConfirmLabel = declineConfirmLabel || 'Cancel'; declineConfirmLabel = declineConfirmLabel || 'Cancel';
approveConfirmLabel = approveConfirmLabel || 'Confirm'; approveConfirmLabel = approveConfirmLabel || 'Confirm';
closeMessageLabel = closeMessageLabel || 'Okay'; closeMessageLabel = closeMessageLabel || 'Okay';
label = label || (isConfirm ? 'Confirmation' : 'Message'); label = label || (isConfirm ? 'Confirmation' : 'Message');
return util.PromiseQueue.enqueue(function () { return util.PromiseQueue.enqueue(function () {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
if (!message) reject('Missing body message'); if (!message) reject(new Error('Missing body message'));
// Set the text to the modal and its buttons // Set the text to the modal and its buttons
document.getElementById('approveConfirm').textContent = approveConfirmLabel; document.getElementById('approveConfirm').textContent = approveConfirmLabel;
document.getElementById('declineConfirm').textContent = declineConfirmLabel; document.getElementById('declineConfirm').textContent = declineConfirmLabel;
@ -86,10 +89,10 @@ define(rqDef, function(settingsStore, util) {
modal.classList.remove('show'); modal.classList.remove('show');
modal.style.display = 'none'; modal.style.display = 'none';
backdrop.classList.remove('show'); backdrop.classList.remove('show');
if(Array.from(document.body.children).indexOf(backdrop)>=0){ if (Array.from(document.body.children).indexOf(backdrop) >= 0) {
document.body.removeChild(backdrop); document.body.removeChild(backdrop);
} }
//remove event listeners // remove event listeners
document.getElementById('modalCloseBtn').removeEventListener('click', close); document.getElementById('modalCloseBtn').removeEventListener('click', close);
document.getElementById('declineConfirm').removeEventListener('click', close); document.getElementById('declineConfirm').removeEventListener('click', close);
document.getElementById('closeMessage').removeEventListener('click', close); document.getElementById('closeMessage').removeEventListener('click', close);
@ -132,7 +135,7 @@ define(rqDef, function(settingsStore, util) {
document.getElementById('declineConfirm').addEventListener('click', close); document.getElementById('declineConfirm').addEventListener('click', close);
document.getElementById('closeMessage').addEventListener('click', close); document.getElementById('closeMessage').addEventListener('click', close);
document.getElementById('approveConfirm').addEventListener('click', closeConfirm); document.getElementById('approveConfirm').addEventListener('click', closeConfirm);
modal.addEventListener('click', close); modal.addEventListener('click', close);
document.getElementsByClassName('modal-dialog')[0].addEventListener('click', stopOutsideModalClick); document.getElementsByClassName('modal-dialog')[0].addEventListener('click', stopOutsideModalClick);
@ -142,20 +145,20 @@ define(rqDef, function(settingsStore, util) {
}); });
}); });
} }
/** /**
* Creates a data: URI from the given content * Creates a data: URI from the given content
* The given attribute of the DOM node (nodeAttribute) is then set to this URI * The given attribute of the DOM node (nodeAttribute) is then set to this URI
* *
* This is used to inject images (and other dependencies) into the article DOM * This is used to inject images (and other dependencies) into the article DOM
* *
* @param {Object} node The node to which the URI should be added * @param {Object} node The node to which the URI should be added
* @param {String} nodeAttribute The attribute to set to the URI * @param {String} nodeAttribute The attribute to set to the URI
* @param {Uint8Array} content The binary content to convert to a URI * @param {Uint8Array} content The binary content to convert to a URI
* @param {String} mimeType The MIME type of the content * @param {String} mimeType The MIME type of the content
* @param {Function} callback An optional function to call to start processing the next item * @param {Function} callback An optional function to call to start processing the next item
*/ */
function feedNodeWithDataURI(node, nodeAttribute, content, mimeType, callback) { function feedNodeWithDataURI (node, nodeAttribute, content, mimeType, callback) {
// Decode WebP data if the browser does not support WebP and the mimeType is webp // Decode WebP data if the browser does not support WebP and the mimeType is webp
if (webpMachine && /image\/webp/i.test(mimeType)) { if (webpMachine && /image\/webp/i.test(mimeType)) {
// If we're dealing with a dataURI, first convert to Uint8Array // If we're dealing with a dataURI, first convert to Uint8Array
@ -200,9 +203,9 @@ define(rqDef, function(settingsStore, util) {
* Determines whether the Canvas Elements Workaround for decoding WebP images is needed, and sets UI accordingly. * Determines whether the Canvas Elements Workaround for decoding WebP images is needed, and sets UI accordingly.
* This also sets a global app parameter (useCanvasElementsForWebpTranscoding) that determines whether the workaround will be used in jQuery mode. * This also sets a global app parameter (useCanvasElementsForWebpTranscoding) that determines whether the workaround will be used in jQuery mode.
* Note that the workaround will never be used in Service Worker mode, but we still need to determine it in case the user switches modes. * Note that the workaround will never be used in Service Worker mode, but we still need to determine it in case the user switches modes.
* @returns {Boolean} A value to indicate the browser's capability (whether it requires the workaround or not) * @returns {Boolean} A value to indicate the browser's capability (whether it requires the workaround or not)
*/ */
function determineCanvasElementsWorkaround() { function determineCanvasElementsWorkaround () {
var userPreference = settingsStore.getItem('useCanvasElementsForWebpTranscoding') !== 'false'; var userPreference = settingsStore.getItem('useCanvasElementsForWebpTranscoding') !== 'false';
// Determine whether the browser is able to read canvas data correctly // Determine whether the browser is able to read canvas data correctly
var browserRequiresWorkaround = webpMachine && webpHero && !webpHero.detectCanvasReadingSupport(); var browserRequiresWorkaround = webpMachine && webpHero && !webpHero.detectCanvasReadingSupport();
@ -218,18 +221,18 @@ define(rqDef, function(settingsStore, util) {
useCanvasElementsCheck.checked = userPreference; useCanvasElementsCheck.checked = userPreference;
} }
params.useCanvasElementsForWebpTranscoding = browserRequiresWorkaround ? userPreference : false; params.useCanvasElementsForWebpTranscoding = browserRequiresWorkaround ? userPreference : false;
// Return the determined browser capability (which may be different from the user's preference) in case caller wants this // Return the determined browser capability (which may be different from the user's preference) in case caller wants this
return browserRequiresWorkaround; return browserRequiresWorkaround;
} }
/** /**
* Replace the given CSS link (from the DOM) with an inline CSS of the given content * Replace the given CSS link (from the DOM) with an inline CSS of the given content
* *
* Due to CSP, Firefox OS does not accept <link> syntax with href="data:text/css..." or href="blob:..." * Due to CSP, Firefox OS does not accept <link> syntax with href="data:text/css..." or href="blob:..."
* So we replace the tag with a <style type="text/css">...</style> * So we replace the tag with a <style type="text/css">...</style>
* while copying some attributes of the original tag * while copying some attributes of the original tag
* Cf http://jonraasch.com/blog/javascript-style-node * Cf http://jonraasch.com/blog/javascript-style-node
* *
* @param {Element} link The original link node from the DOM * @param {Element} link The original link node from the DOM
* @param {String} cssContent The content to insert as an inline stylesheet * @param {String} cssContent The content to insert as an inline stylesheet
*/ */
@ -251,13 +254,13 @@ define(rqDef, function(settingsStore, util) {
} }
link.parentNode.replaceChild(cssElement, link); link.parentNode.replaceChild(cssElement, link);
} }
/** /**
* Removes parameters and anchors from a URL * Removes parameters and anchors from a URL
* @param {type} url The URL to be processed * @param {type} url The URL to be processed
* @returns {String} The same URL without its parameters and anchors * @returns {String} The same URL without its parameters and anchors
*/ */
function removeUrlParameters(url) { function removeUrlParameters (url) {
// Remove any querystring // Remove any querystring
var strippedUrl = url.replace(/\?[^?]*$/, ''); var strippedUrl = url.replace(/\?[^?]*$/, '');
// Remove any anchor parameters - note that we are deliberately excluding entity references, e.g. '&#39;'. // Remove any anchor parameters - note that we are deliberately excluding entity references, e.g. '&#39;'.
@ -267,13 +270,13 @@ define(rqDef, function(settingsStore, util) {
/** /**
* Derives the URL.pathname from a relative or semi-relative URL using the given base ZIM URL * Derives the URL.pathname from a relative or semi-relative URL using the given base ZIM URL
* *
* @param {String} url The (URI-encoded) URL to convert (e.g. "Einstein", "../Einstein", * @param {String} url The (URI-encoded) URL to convert (e.g. "Einstein", "../Einstein",
* "../../I/im%C3%A1gen.png", "-/s/style.css", "/A/Einstein.html", "../static/bootstrap/css/bootstrap.min.css") * "../../I/im%C3%A1gen.png", "-/s/style.css", "/A/Einstein.html", "../static/bootstrap/css/bootstrap.min.css")
* @param {String} base The base ZIM URL of the currently loaded article (e.g. "A/", "A/subdir1/subdir2/", "C/Singapore/") * @param {String} base The base ZIM URL of the currently loaded article (e.g. "A/", "A/subdir1/subdir2/", "C/Singapore/")
* @returns {String} The derived ZIM URL in decoded form (e.g. "A/Einstein", "I/imágen.png", "C/") * @returns {String} The derived ZIM URL in decoded form (e.g. "A/Einstein", "I/imágen.png", "C/")
*/ */
function deriveZimUrlFromRelativeUrl(url, base) { function deriveZimUrlFromRelativeUrl (url, base) {
// We use a dummy domain because URL API requires a valid URI // We use a dummy domain because URL API requires a valid URI
var dummy = 'http://d/'; var dummy = 'http://d/';
var deriveZimUrl = function (url, base) { var deriveZimUrl = function (url, base) {
@ -294,16 +297,16 @@ define(rqDef, function(settingsStore, util) {
* Displays a Bootstrap warning alert with information about how to access content in a ZIM with unsupported active UI * Displays a Bootstrap warning alert with information about how to access content in a ZIM with unsupported active UI
*/ */
var activeContentWarningSetup = false; var activeContentWarningSetup = false;
function displayActiveContentWarning() { function displayActiveContentWarning () {
var alertActiveContent = document.getElementById('activeContent'); var alertActiveContent = document.getElementById('activeContent');
alertActiveContent.style.display = ''; alertActiveContent.style.display = '';
if (!activeContentWarningSetup) { if (!activeContentWarningSetup) {
// We are setting up the active content warning for the first time // We are setting up the active content warning for the first time
activeContentWarningSetup = true; activeContentWarningSetup = true;
alertActiveContent.querySelector('button[data-hide]').addEventListener('click', function() { alertActiveContent.querySelector('button[data-hide]').addEventListener('click', function () {
alertActiveContent.style.display = 'none'; alertActiveContent.style.display = 'none';
}); });
['swModeLink', 'stop'].forEach(function(id) { ['swModeLink', 'stop'].forEach(function (id) {
// Define event listeners for both hyperlinks in alert box: these take the user to the Config tab and highlight // Define event listeners for both hyperlinks in alert box: these take the user to the Config tab and highlight
// the options that the user needs to select // the options that the user needs to select
document.getElementById(id).addEventListener('click', function () { document.getElementById(id).addEventListener('click', function () {
@ -328,7 +331,7 @@ define(rqDef, function(settingsStore, util) {
/** /**
* Displays a Bootstrap alert box at the foot of the page to enable saving the content of the given title to the device's filesystem * Displays a Bootstrap alert box at the foot of the page to enable saving the content of the given title to the device's filesystem
* and initiates download/save process if this is supported by the OS or Browser * and initiates download/save process if this is supported by the OS or Browser
* *
* @param {String} title The path and filename to the file to be extracted * @param {String} title The path and filename to the file to be extracted
* @param {Boolean|String} download A Bolean value that will trigger download of title, or the filename that should * @param {Boolean|String} download A Bolean value that will trigger download of title, or the filename that should
* be used to save the file in local FS * be used to save the file in local FS
@ -336,23 +339,25 @@ define(rqDef, function(settingsStore, util) {
* @param {Uint8Array} content The binary-format content of the downloadable file * @param {Uint8Array} content The binary-format content of the downloadable file
*/ */
var downloadAlertSetup = false; var downloadAlertSetup = false;
function displayFileDownloadAlert(title, download, contentType, content) { function displayFileDownloadAlert (title, download, contentType, content) {
var downloadAlert = document.getElementById('downloadAlert'); var downloadAlert = document.getElementById('downloadAlert');
downloadAlert.style.display = 'block'; downloadAlert.style.display = 'block';
if (!downloadAlertSetup) downloadAlert.querySelector('button[data-hide]').addEventListener('click', function() { if (!downloadAlertSetup) {
// We are setting up the alert for the first time downloadAlert.querySelector('button[data-hide]').addEventListener('click', function () {
downloadAlert.style.display = 'none'; // We are setting up the alert for the first time
}); downloadAlert.style.display = 'none';
});
}
downloadAlertSetup = true; downloadAlertSetup = true;
// Download code adapted from https://stackoverflow.com/a/19230668/9727685 // Download code adapted from https://stackoverflow.com/a/19230668/9727685
// Set default contentType if none was provided // Set default contentType if none was provided
if (!contentType) contentType = 'application/octet-stream'; if (!contentType) contentType = 'application/octet-stream';
var a = document.createElement('a'); var a = document.createElement('a');
var blob = new Blob([content], { 'type': contentType }); var blob = new Blob([content], { type: contentType });
// If the filename to use for saving has not been specified, construct it from title // If the filename to use for saving has not been specified, construct it from title
var filename = download === true ? title.replace(/^.*\/([^\/]+)$/, '$1') : download; var filename = download === true ? title.replace(/^.*\/([^/]+)$/, '$1') : download;
// Make filename safe // Make filename safe
filename = filename.replace(/[\/\\:*?"<>|]/g, '_'); filename = filename.replace(/[/\\:*?"<>|]/g, '_');
a.href = window.URL.createObjectURL(blob); a.href = window.URL.createObjectURL(blob);
a.target = '_blank'; a.target = '_blank';
a.type = contentType; a.type = contentType;
@ -360,16 +365,17 @@ define(rqDef, function(settingsStore, util) {
a.classList.add('alert-link'); a.classList.add('alert-link');
a.textContent = filename; a.textContent = filename;
var alertMessage = document.getElementById('alertMessage'); var alertMessage = document.getElementById('alertMessage');
//innerHTML required as it has HTML tags // innerHTML required as it has HTML tags
alertMessage.innerHTML = '<strong>Download</strong> If the download does not start, please tap the following link: '; alertMessage.innerHTML = '<strong>Download</strong> If the download does not start, please tap the following link: ';
// We have to add the anchor to a UI element for Firefox to be able to click it programmatically: see https://stackoverflow.com/a/27280611/9727685 // We have to add the anchor to a UI element for Firefox to be able to click it programmatically: see https://stackoverflow.com/a/27280611/9727685
alertMessage.appendChild(a); alertMessage.appendChild(a);
try { a.click(); } try {
catch (err) { a.click();
} catch (err) {
// If the click fails, user may be able to download by manually clicking the link // If the click fails, user may be able to download by manually clicking the link
// But for IE11 we need to force use of the saveBlob method with the onclick event // But for IE11 we need to force use of the saveBlob method with the onclick event
if (window.navigator && window.navigator.msSaveBlob) { if (window.navigator && window.navigator.msSaveBlob) {
a.addEventListener('click', function(e) { a.addEventListener('click', function (e) {
window.navigator.msSaveBlob(blob, filename); window.navigator.msSaveBlob(blob, filename);
e.preventDefault(); e.preventDefault();
}); });
@ -382,7 +388,7 @@ define(rqDef, function(settingsStore, util) {
* Check for update of Service Worker (PWA) and display information to user * Check for update of Service Worker (PWA) and display information to user
*/ */
var updateAlert = document.getElementById('updateAlert'); var updateAlert = document.getElementById('updateAlert');
function checkUpdateStatus(appstate) { function checkUpdateStatus (appstate) {
if ('serviceWorker' in navigator && !appstate.pwaUpdateNeeded) { if ('serviceWorker' in navigator && !appstate.pwaUpdateNeeded) {
settingsStore.getCacheNames(function (cacheNames) { settingsStore.getCacheNames(function (cacheNames) {
if (cacheNames && !cacheNames.error) { if (cacheNames && !cacheNames.error) {
@ -406,9 +412,11 @@ define(rqDef, function(settingsStore, util) {
}); });
} }
} }
if (updateAlert) updateAlert.querySelector('button[data-hide]').addEventListener('click', function () { if (updateAlert) {
updateAlert.style.display = 'none'; updateAlert.querySelector('button[data-hide]').addEventListener('click', function () {
}); updateAlert.style.display = 'none';
});
}
/** /**
* Checks if a server is accessible by attempting to load a test image from the server * Checks if a server is accessible by attempting to load a test image from the server
@ -416,7 +424,7 @@ define(rqDef, function(settingsStore, util) {
* @param {any} onSuccess A function to call if the image can be loaded * @param {any} onSuccess A function to call if the image can be loaded
* @param {any} onError A function to call if the image cannot be loaded * @param {any} onError A function to call if the image cannot be loaded
*/ */
function checkServerIsAccessible(imageSrc, onSuccess, onError) { function checkServerIsAccessible (imageSrc, onSuccess, onError) {
var image = new Image(); var image = new Image();
image.onload = onSuccess; image.onload = onSuccess;
image.onerror = onError; image.onerror = onError;
@ -425,10 +433,10 @@ define(rqDef, function(settingsStore, util) {
/** /**
* Show or hide the spinner together with a message * Show or hide the spinner together with a message
* @param {Boolean} show True to show the spinner, false to hide it * @param {Boolean} show True to show the spinner, false to hide it
* @param {String} message A message to display, or hide the message if null * @param {String} message A message to display, or hide the message if null
*/ */
function spinnerDisplay(show, message) { function spinnerDisplay (show, message) {
var searchingArticles = document.getElementById('searchingArticles'); var searchingArticles = document.getElementById('searchingArticles');
var spinnerMessage = document.getElementById('cachingAssets'); var spinnerMessage = document.getElementById('cachingAssets');
if (show) searchingArticles.style.display = 'block'; if (show) searchingArticles.style.display = 'block';
@ -444,25 +452,26 @@ define(rqDef, function(settingsStore, util) {
/** /**
* Checks whether an element is partially or fully inside the current viewport * Checks whether an element is partially or fully inside the current viewport
* *
* @param {Element} el The DOM element for which to check visibility * @param {Element} el The DOM element for which to check visibility
* @param {Boolean} fully If true, checks that the entire element is inside the viewport; * @param {Boolean} fully If true, checks that the entire element is inside the viewport;
* if false, checks whether any part of the element is inside the viewport * if false, checks whether any part of the element is inside the viewport
* @returns {Boolean} True if the element is fully or partially (depending on the value of <fully>) * @returns {Boolean} True if the element is fully or partially (depending on the value of <fully>)
* inside the current viewport * inside the current viewport
*/ */
function isElementInView(el, fully) { function isElementInView (el, fully) {
var rect = el.getBoundingClientRect(); var rect = el.getBoundingClientRect();
if (fully) if (fully) {
return rect.top > 0 && rect.bottom < window.innerHeight && rect.left > 0 && rect.right < window.innerWidth; return rect.top > 0 && rect.bottom < window.innerHeight && rect.left > 0 && rect.right < window.innerWidth;
else } else {
return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0; return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
}
} }
/** /**
* Removes the animation effect between various sections * Removes the animation effect between various sections
*/ */
function removeAnimationClasses() { function removeAnimationClasses () {
var configuration = document.getElementById('configuration'); var configuration = document.getElementById('configuration');
configuration.classList.remove('slideIn_L'); configuration.classList.remove('slideIn_L');
configuration.classList.remove('slideIn_R'); configuration.classList.remove('slideIn_R');
@ -473,15 +482,15 @@ define(rqDef, function(settingsStore, util) {
document.getElementById('articleContent').classList.remove('slideIn_R'); document.getElementById('articleContent').classList.remove('slideIn_R');
document.getElementById('articleContent').classList.remove('slideOut_L'); document.getElementById('articleContent').classList.remove('slideOut_L');
} }
/** /**
* Adds the slide animation between different sections * Adds the slide animation between different sections
* *
* @param {String} section It takes the name of the section to which the animation is to be added * @param {String} section It takes the name of the section to which the animation is to be added
* *
*/ */
function applyAnimationToSection(section) { function applyAnimationToSection (section) {
if (section == 'home') { if (section === 'home') {
if (!$('#configuration').is(':hidden')) { if (!$('#configuration').is(':hidden')) {
document.getElementById('configuration').classList.add('slideOut_R'); document.getElementById('configuration').classList.add('slideOut_R');
setTimeout(function () { setTimeout(function () {
@ -498,7 +507,7 @@ define(rqDef, function(settingsStore, util) {
setTimeout(function () { setTimeout(function () {
document.getElementById('articleContent').style.display = ''; document.getElementById('articleContent').style.display = '';
}, 300); }, 300);
} else if (section == 'config') { } else if (section === 'config') {
if (!$('#about').is(':hidden')) { if (!$('#about').is(':hidden')) {
$('#about').addClass('slideOut_R'); $('#about').addClass('slideOut_R');
$('#configuration').addClass('slideIn_R'); $('#configuration').addClass('slideIn_R');
@ -515,7 +524,7 @@ define(rqDef, function(settingsStore, util) {
setTimeout(function () { setTimeout(function () {
document.getElementById('configuration').style.display = ''; document.getElementById('configuration').style.display = '';
}, 300); }, 300);
} else if (section == 'about') { } else if (section === 'about') {
if (!$('#configuration').is(':hidden')) { if (!$('#configuration').is(':hidden')) {
document.getElementById('configuration').classList.add('slideOut_L'); document.getElementById('configuration').classList.add('slideOut_L');
setTimeout(function () { setTimeout(function () {
@ -537,17 +546,17 @@ define(rqDef, function(settingsStore, util) {
/** /**
* Applies the requested app and content theme * Applies the requested app and content theme
* *
* A <theme> string consists of two parts, the appTheme (theme to apply to the app shell only), and an optional * A <theme> string consists of two parts, the appTheme (theme to apply to the app shell only), and an optional
* contentTheme beginning with an underscore: e.g. 'dark_invert' = 'dark' (appTheme) + '_invert' (contentTheme) * contentTheme beginning with an underscore: e.g. 'dark_invert' = 'dark' (appTheme) + '_invert' (contentTheme)
* Current themes are: light, dark, dark_invert, dark_mwInvert but code below is written for extensibility * Current themes are: light, dark, dark_invert, dark_mwInvert but code below is written for extensibility
* For each appTheme (except the default 'light'), a corresponding set of rules must be present in app.css * For each appTheme (except the default 'light'), a corresponding set of rules must be present in app.css
* For each contentTheme, a stylesheet must be provided in www/css that is named 'kiwixJS' + contentTheme * For each contentTheme, a stylesheet must be provided in www/css that is named 'kiwixJS' + contentTheme
* A rule may additionally be needed in app.css for full implementation of contentTheme * A rule may additionally be needed in app.css for full implementation of contentTheme
* *
* @param {String} theme The theme to apply (light|dark[_invert|_mwInvert]|auto[_invert|_mwInvert]) * @param {String} theme The theme to apply (light|dark[_invert|_mwInvert]|auto[_invert|_mwInvert])
*/ */
function applyAppTheme(theme) { function applyAppTheme (theme) {
var darkPreference = window.matchMedia('(prefers-color-scheme:dark)'); var darkPreference = window.matchMedia('(prefers-color-scheme:dark)');
// Resolve the app theme from the matchMedia preference (for auto themes) or from the theme string // Resolve the app theme from the matchMedia preference (for auto themes) or from the theme string
var appTheme = /^auto/.test(theme) ? darkPreference.matches ? 'dark' : 'light' : theme.replace(/_.*$/, ''); var appTheme = /^auto/.test(theme) ? darkPreference.matches ? 'dark' : 'light' : theme.replace(/_.*$/, '');
@ -583,7 +592,7 @@ define(rqDef, function(settingsStore, util) {
// Hide any previously displayed description for auto themes // Hide any previously displayed description for auto themes
var oldDescription = document.getElementById('kiwix-auto-description'); var oldDescription = document.getElementById('kiwix-auto-description');
if (oldDescription) oldDescription.style.display = 'none'; if (oldDescription) oldDescription.style.display = 'none';
// Show description for auto themes // Show description for auto themes
var description = document.getElementById('kiwix-' + theme.replace(/_.*$/, '') + '-description'); var description = document.getElementById('kiwix-' + theme.replace(/_.*$/, '') + '-description');
if (description) description.style.display = 'block'; if (description) description.style.display = 'block';
// If there is no ContentTheme or we are applying a different ContentTheme, remove any previously applied ContentTheme // If there is no ContentTheme or we are applying a different ContentTheme, remove any previously applied ContentTheme
@ -613,21 +622,21 @@ define(rqDef, function(settingsStore, util) {
// If we are in Config and a real document has been loaded already, expose return link so user can see the result of the change // If we are in Config and a real document has been loaded already, expose return link so user can see the result of the change
// DEV: The Placeholder string below matches the dummy article.html that is loaded before any articles are loaded // DEV: The Placeholder string below matches the dummy article.html that is loaded before any articles are loaded
if (document.getElementById('liConfigureNav').classList.contains('active') && doc && if (document.getElementById('liConfigureNav').classList.contains('active') && doc &&
doc.title !== "Placeholder for injecting an article into the iframe") { doc.title !== 'Placeholder for injecting an article into the iframe') {
showReturnLink(); showReturnLink();
} }
} }
// Displays the return link and handles click event. Called by applyAppTheme() // Displays the return link and handles click event. Called by applyAppTheme()
function showReturnLink() { function showReturnLink () {
var viewArticle = document.getElementById('viewArticle'); var viewArticle = document.getElementById('viewArticle');
viewArticle.style.display = 'block'; viewArticle.style.display = 'block';
viewArticle.addEventListener('click', function(e) { viewArticle.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
document.getElementById('liConfigureNav').classList.remove('active'); document.getElementById('liConfigureNav').classList.remove('active');
document.getElementById('liHomeNav').classList.add('active'); document.getElementById('liHomeNav').classList.add('active');
removeAnimationClasses(); removeAnimationClasses();
if (params.showUIAnimations) { if (params.showUIAnimations) {
applyAnimationToSection('home'); applyAnimationToSection('home');
} else { } else {
document.getElementById('configuration').style.display = 'none'; document.getElementById('configuration').style.display = 'none';
@ -641,7 +650,7 @@ define(rqDef, function(settingsStore, util) {
// Reports an error in loading one of the ASM or WASM machines to the UI API Status Panel // Reports an error in loading one of the ASM or WASM machines to the UI API Status Panel
// This can't be done in app.js because the error occurs after the API panel is first displayed // This can't be done in app.js because the error occurs after the API panel is first displayed
function reportAssemblerErrorToAPIStatusPanel(decoderType, error, assemblerMachineType) { function reportAssemblerErrorToAPIStatusPanel (decoderType, error, assemblerMachineType) {
console.error('Could not instantiate any ' + decoderType + ' decoder!', error); console.error('Could not instantiate any ' + decoderType + ' decoder!', error);
params.decompressorAPI.assemblerMachineType = assemblerMachineType; params.decompressorAPI.assemblerMachineType = assemblerMachineType;
params.decompressorAPI.errorStatus = 'Error loading ' + decoderType + ' decompressor!'; params.decompressorAPI.errorStatus = 'Error loading ' + decoderType + ' decompressor!';
@ -652,25 +661,25 @@ define(rqDef, function(settingsStore, util) {
} }
// Reports the search provider to the API Status Panel // Reports the search provider to the API Status Panel
function reportSearchProviderToAPIStatusPanel(provider) { function reportSearchProviderToAPIStatusPanel (provider) {
var providerAPI = document.getElementById('searchProviderStatus'); var providerAPI = document.getElementById('searchProviderStatus');
if (providerAPI) { // NB we need this so that tests don't fail if (providerAPI) { // NB we need this so that tests don't fail
providerAPI.textContent = 'Search Provider: ' + (/^fulltext/.test(provider) ? 'Title + Xapian [' + provider + ']' : providerAPI.textContent = 'Search Provider: ' + (/^fulltext/.test(provider) ? 'Title + Xapian [' + provider + ']'
/^title/.test(provider) ? 'Title only [' + provider + ']' : 'Not initialized'); : /^title/.test(provider) ? 'Title only [' + provider + ']' : 'Not initialized');
providerAPI.className = /^fulltext/.test(provider) ? 'apiAvailable' : !/ERROR/.test(provider) ? 'apiUnavailable' : 'apiBroken'; providerAPI.className = /^fulltext/.test(provider) ? 'apiAvailable' : !/ERROR/.test(provider) ? 'apiUnavailable' : 'apiBroken';
} }
} }
// If global variable webpMachine is true (set in init.js), then we need to initialize the WebP Polyfill // If global variable webpMachine is true (set in init.js), then we need to initialize the WebP Polyfill
if (webpMachine) webpMachine = new webpHero.WebpMachine({useCanvasElements: true}); if (webpMachine) webpMachine = new webpHero.WebpMachine({ useCanvasElements: true });
/** /**
* Warn the user that he/she clicked on an external link, and open it in a new tab * Warn the user that he/she clicked on an external link, and open it in a new tab
* *
* @param {Event} event the click event (on an anchor) to handle * @param {Event} event the click event (on an anchor) to handle
* @param {Element} clickedAnchor the DOM anchor that has been clicked (optional, defaults to event.target) * @param {Element} clickedAnchor the DOM anchor that has been clicked (optional, defaults to event.target)
*/ */
function warnAndOpenExternalLinkInNewTab(event, clickedAnchor) { function warnAndOpenExternalLinkInNewTab (event, clickedAnchor) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!clickedAnchor) clickedAnchor = event.target; if (!clickedAnchor) clickedAnchor = event.target;
@ -682,21 +691,22 @@ define(rqDef, function(settingsStore, util) {
message += '</p><p style="word-break:break-all;">' + clickedAnchor.href + '</p>'; message += '</p><p style="word-break:break-all;">' + clickedAnchor.href + '</p>';
systemAlert(message, 'Opening external link', true).then(function (response) { systemAlert(message, 'Opening external link', true).then(function (response) {
if (response) { if (response) {
if (!target) if (!target) {
target = '_blank'; target = '_blank';
}
window.open(clickedAnchor.href, target); window.open(clickedAnchor.href, target);
} }
}); });
} }
/** /**
* Finds the closest <a> or <area> enclosing tag of an element. * Finds the closest <a> or <area> enclosing tag of an element.
* Returns undefined if there isn't any. * Returns undefined if there isn't any.
* *
* @param {Element} element * @param {Element} element
* @returns {Element} closest enclosing anchor tag (if any) * @returns {Element} closest enclosing anchor tag (if any)
*/ */
function closestAnchorEnclosingElement(element) { function closestAnchorEnclosingElement (element) {
if (Element.prototype.closest) { if (Element.prototype.closest) {
// Recent browsers support that natively. See https://developer.mozilla.org/en-US/docs/Web/API/Element/closest // Recent browsers support that natively. See https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
return element.closest('a,area'); return element.closest('a,area');