From bd7393e921c46f71329987e69b2c7221f5c01ed5 Mon Sep 17 00:00:00 2001 From: Jaifroid Date: Tue, 13 Jun 2023 21:21:40 +0100 Subject: [PATCH] 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). --- .eslintrc.cjs | 4 +- CONTRIBUTING.md | 6 +- ...backgroundscript.js => backgroundscript.js | 18 +- manifest.json | 30 +-- manifest.v2.json | 47 ++++ scripts/create_all_packages.sh | 21 +- scripts/package_chrome_extension.sh | 13 +- service-worker.js | 204 ++++++++++-------- www/js/app.js | 123 +++++------ www/js/lib/uiUtil.js | 186 ++++++++-------- 10 files changed, 365 insertions(+), 287 deletions(-) rename webextension/backgroundscript.js => backgroundscript.js (79%) create mode 100644 manifest.v2.json diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fbec64b0..e3c53898 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -17,6 +17,8 @@ module.exports = { 'no-extra-parens': 1, 'no-unused-expressions': 1, 'no-unused-vars': 1, - 'n/no-callback-literal': 0 + 'n/no-callback-literal': 0, + 'object-shorthand': 0, + 'multiline-ternary': 0 } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28df2656..6af9a900 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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); - 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 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. diff --git a/webextension/backgroundscript.js b/backgroundscript.js similarity index 79% rename from webextension/backgroundscript.js rename to backgroundscript.js index 8de1fe83..bc0b3216 100644 --- a/webextension/backgroundscript.js +++ b/backgroundscript.js @@ -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 * License GPL v3: @@ -20,22 +20,20 @@ * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ +/* global chrome, browser */ + // In order to work on both Firefox and Chromium/Chrome (and derivatives). // browser and chrome variables expose almost the same APIs var genericBrowser; if (typeof browser !== 'undefined') { // Firefox genericBrowser = browser; -} -else { +} else { // Chromium/Chrome genericBrowser = chrome; } -genericBrowser.browserAction.onClicked.addListener(handleClick); - -function handleClick(event) { - genericBrowser.tabs.create({ - url: genericBrowser.runtime.getURL('/www/index.html') - }); -} \ No newline at end of file +genericBrowser.browserAction.onClicked.addListener(function () { + var newURL = chrome.runtime.getURL('www/index.html'); + chrome.tabs.create({ url: newURL }); +}); diff --git a/manifest.json b/manifest.json index a7a76ca2..35af2116 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "Kiwix", "version": "3.8.1", - "description": "Kiwix : offline Wikipedia reader", + "description": "Kiwix Offline Browser", "icons": { "16": "www/img/icons/kiwix-16.png", @@ -16,7 +16,7 @@ "128": "www/img/icons/kiwix-128.png" }, - "browser_action": { + "action": { "default_icon": { "16": "www/img/icons/kiwix-16.png", "19": "www/img/icons/kiwix-19.png", @@ -26,22 +26,24 @@ }, "default_title": "Kiwix" }, - - "applications": { - "gecko": { - "id": "kiwix-html5-unlisted@kiwix.org" - } - }, - - "web_accessible_resources": ["www/index.html"], "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", "offline_enabled": true } diff --git a/manifest.v2.json b/manifest.v2.json new file mode 100644 index 00000000..3b6fb9bf --- /dev/null +++ b/manifest.v2.json @@ -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 +} diff --git a/scripts/create_all_packages.sh b/scripts/create_all_packages.sh index 8bd0c36c..e99916e5 100755 --- a/scripts/create_all_packages.sh +++ b/scripts/create_all_packages.sh @@ -55,7 +55,7 @@ fi # Copy only the necessary files in a temporary directory mkdir -p 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 rm -f tmp/www/js/lib/libzim-*dev.* @@ -64,8 +64,10 @@ rm -f tmp/www/js/lib/libzim-*dev.* regexpNumericVersion='^[0-9\.]+$' 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.v2.json 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.v2.json fi sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.webapp 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 rm -rf build/* -# Package for Chromium/Chrome -scripts/package_chrome_extension.sh $DRYRUN $TAG -v $VERSION +# Package for Chromium/Chrome with Manifest V3 +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 # 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 -cp manifest.json tmp/ +# So we take the original manifest v2 again, and replace the version inside it again +cp manifest.v2.json 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 +echo "" scripts/package_firefoxos_app.sh $DRYRUN $TAG -v $VERSION cp -f ubuntu_touch/* tmp/ sed -i -e "s/$VERSION_TO_REPLACE/$VERSION/" tmp/manifest.json +echo "" scripts/package_ubuntu_touch_app.sh $DRYRUN $TAG -v $VERSION # Change permissions on source files to match those expected by the server diff --git a/scripts/package_chrome_extension.sh b/scripts/package_chrome_extension.sh index 5d0e7c60..5f2e5bc3 100755 --- a/scripts/package_chrome_extension.sh +++ b/scripts/package_chrome_extension.sh @@ -3,19 +3,23 @@ BASEDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"/.. cd "$BASEDIR" # Reading arguments -while getopts tdv: option; do +while getopts m:tdv: option; do 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 d) DRYRUN="-d";; # Indicates a dryrun test, that does not modify anything on the network v) VERSION=${OPTARG};; esac done - +if [ -n $MV ]; then + echo -e "\nManifest version requested: $MV" + VERSION="MV$MV-$VERSION" +fi echo "Packaging unsigned Chrome extension, version $VERSION" 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 .. -if [ "${TAG}zz" == "zz" ]; then +if [ -z $TAG ]; then # Package the extension with Chrome or Chromium, if we're not packaging a public version if hash chromium-browser 2>/dev/null then @@ -28,6 +32,7 @@ if [ "${TAG}zz" == "zz" ]; then echo "Signing the extension for $CHROME_BIN, version $VERSION" $CHROME_BIN --no-sandbox --pack-extension=tmp --pack-extension-key=./scripts/kiwix-html5.pem mv tmp.crx build/kiwix-chrome-signed-extension-$VERSION.crx + ls -l build/kiwix-chrome-signed-extension-$VERSION.crx else echo "This unsigned extension must be manually uploaded to Google to be signed and distributed from their store" fi diff --git a/service-worker.js b/service-worker.js index 22a9aa56..15c94170 100644 --- a/service-worker.js +++ b/service-worker.js @@ -2,27 +2,29 @@ * service-worker.js : Service Worker implementation, * in order to capture the HTTP requests made by an article, and respond with the * corresponding content, coming from the archive - * + * * Copyright 2022 Mossroy, Jaifroid and contributors * License GPL v3: - * + * * This file is part of Kiwix. - * + * * Kiwix is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * Kiwix is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ 'use strict'; +/* global chrome */ + /** * 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 @@ -60,10 +62,9 @@ var useAssetsCache = true; * This is an expert setting in Configuration * @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 * Add any further Content-Types you wish to cache to the regexp, separated by '|' * @type {RegExp} @@ -78,7 +79,7 @@ var regexpCachedContentTypes = /text\/css|text\/javascript|application\/javascri */ var regexpExcludedURLSchema = /^(?:file|chrome-extension|example-extension):/i; -/** +/** * 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 * @type {RegExp} @@ -91,7 +92,7 @@ const regexpZIMUrlWithNamespace = /(?:^|\/)([^/]+\/)([-ABCIJMUVWX])\/(.+)/; * 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. * I did not see multiple ranges asked by a browser. - * + * * @type {RegExp} */ 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 */ -let precacheFiles = [ - ".", // This caches the redirect to www/index.html, in case a user launches the app from its root directory - "manifest.json", - "service-worker.js", - "www/css/app.css", - "www/css/bootstrap.css", - "www/css/kiwixJS_invert.css", - "www/css/kiwixJS_mwInvert.css", - "www/css/transition.css", - "www/img/icons/kiwix-256.png", - "www/img/icons/kiwix-32.png", - "www/img/icons/kiwix-60.png", - "www/img/spinner.gif", - "www/img/Icon_External_Link.png", - "www/index.html", - "www/article.html", - "www/main.html", - "www/js/app.js", - "www/js/init.js", - "www/js/lib/abstractFilesystemAccess.js", - "www/js/lib/arrayFromPolyfill.js", - "www/js/lib/bootstrap.bundle.js", - "www/js/lib/filecache.js", - "www/js/lib/jquery-3.7.0.slim.min.js", - "www/js/lib/promisePolyfill.js", - "www/js/lib/require.js", - "www/js/lib/settingsStore.js", - "www/js/lib/uiUtil.js", - "www/js/lib/utf8.js", - "www/js/lib/util.js", - "www/js/lib/xzdec_wrapper.js", - "www/js/lib/zstddec_wrapper.js", - "www/js/lib/zimArchive.js", - "www/js/lib/zimArchiveLoader.js", - "www/js/lib/zimDirEntry.js", - "www/js/lib/zimfile.js", - "www/js/lib/fontawesome/fontawesome.js", - "www/js/lib/fontawesome/solid.js" +const precacheFiles = [ + '.', // This caches the redirect to www/index.html, in case a user launches the app from its root directory + 'manifest.json', + 'service-worker.js', + 'www/css/app.css', + 'www/css/bootstrap.css', + 'www/css/kiwixJS_invert.css', + 'www/css/kiwixJS_mwInvert.css', + 'www/css/transition.css', + 'www/img/icons/kiwix-256.png', + 'www/img/icons/kiwix-32.png', + 'www/img/icons/kiwix-60.png', + 'www/img/spinner.gif', + 'www/img/Icon_External_Link.png', + 'www/index.html', + 'www/article.html', + 'www/main.html', + 'www/js/app.js', + 'www/js/init.js', + 'www/js/lib/abstractFilesystemAccess.js', + 'www/js/lib/arrayFromPolyfill.js', + 'www/js/lib/bootstrap.bundle.js', + 'www/js/lib/filecache.js', + 'www/js/lib/jquery-3.7.0.slim.min.js', + 'www/js/lib/promisePolyfill.js', + 'www/js/lib/require.js', + 'www/js/lib/settingsStore.js', + 'www/js/lib/uiUtil.js', + 'www/js/lib/utf8.js', + 'www/js/lib/util.js', + 'www/js/lib/xzdec_wrapper.js', + 'www/js/lib/zstddec_wrapper.js', + 'www/js/lib/zimArchive.js', + 'www/js/lib/zimArchiveLoader.js', + 'www/js/lib/zimDirEntry.js', + 'www/js/lib/zimfile.js', + 'www/js/lib/fontawesome/fontawesome.js', + 'www/js/lib/fontawesome/solid.js' ]; if ('WebAssembly' in self) { - precacheFiles.push( - "www/js/lib/xzdec-wasm.js", - "www/js/lib/xzdec-wasm.wasm", - "www/js/lib/zstddec-wasm.js", - "www/js/lib/zstddec-wasm.wasm", - "www/js/lib/libzim-wasm.js", - "www/js/lib/libzim-wasm.wasm" - ); + precacheFiles.push( + 'www/js/lib/xzdec-wasm.js', + 'www/js/lib/xzdec-wasm.wasm', + 'www/js/lib/zstddec-wasm.js', + 'www/js/lib/zstddec-wasm.wasm', + 'www/js/lib/libzim-wasm.js', + 'www/js/lib/libzim-wasm.wasm' + ); } else { - precacheFiles.push( - "www/js/lib/xzdec-asm.js", - "www/js/lib/zstddec-asm.js", - "www/js/lib/libzim-asm.js" - ); + precacheFiles.push( + 'www/js/lib/xzdec-asm.js', + 'www/js/lib/zstddec-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 -self.addEventListener("install", function (event) { - console.debug("[SW] Install Event processing"); +self.addEventListener('install', function (event) { + 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... // 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 var requests = precacheFiles.map(function (urlPath) { return new Request(urlPath + '?v' + appVersion, { cache: 'no-cache' }); }); - if (!regexpExcludedURLSchema.test(requests[0].url)) event.waitUntil( - caches.open(APP_CACHE).then(function (cache) { + if (!regexpExcludedURLSchema.test(requests[0].url)) { + event.waitUntil(caches.open(APP_CACHE).then(function (cache) { return Promise.all( requests.map(function (request) { return fetch(request).then(function (response) { @@ -178,8 +189,8 @@ self.addEventListener("install", function (event) { }); }) ); - }) - ); + })); + } }); // Allow sw to control current page @@ -211,7 +222,7 @@ let fetchCaptureEnabled = false; */ self.addEventListener('fetch', function (event) { // Only cache GET requests - if (event.request.method !== "GET") return; + if (event.request.method !== 'GET') return; var rqUrl = event.request.url; var urlObject = new URL(rqUrl); // Test the URL with parameters removed @@ -225,13 +236,13 @@ self.addEventListener('fetch', function (event) { event.respondWith( // First see if the content is in the cache 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; }, function () { // 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) 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) { // 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')) && @@ -252,7 +263,7 @@ self.addEventListener('fetch', function (event) { } return response; }).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 */ - self.addEventListener('message', function (event) { +self.addEventListener('message', function (event) { if (event.data.action) { if (event.data.action === 'init') { // 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 (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) { // 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 - * + * * @param {URL} urlObject The URL object to be processed for extraction from the ZIM * @param {String} range Optional byte range string * @returns {Promise} 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) { // 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. @@ -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('Referrer-Policy', 'no-referrer'); 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. // 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 if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { headers.set('Accept-Ranges', 'bytes'); } - + var slicedData = msgPortEvent.data.content; if (range) { // 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 // 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. - let partsOfRangeHeader = regexpByteRangeHeader.exec(range); - let begin = partsOfRangeHeader[1]; - let end = contentLength - 1; + const partsOfRangeHeader = regexpByteRangeHeader.exec(range); + const begin = partsOfRangeHeader[1]; + const end = contentLength - 1; slicedData = slicedData.slice(begin); - + headers.set('Content-Range', 'bytes ' + begin + '-' + end + '/' + contentLength); headers.set('Content-Length', end - begin + 1); } - + var responseInit = { // HTTP status is usually 200, but has to bee 206 when partial content (range) is sent status: range ? 206 : 200, statusText: 'OK', - headers: headers + headers }; - + var httpResponse = new Response(slicedData, responseInit); // Let's send the content back from the ServiceWorker @@ -374,8 +385,8 @@ function fetchUrlFromZIM(urlObject, range) { } }; outgoingMessagePort.postMessage({ - 'action': 'askForContent', - 'title': titleWithNameSpace + action: 'askForContent', + title: titleWithNameSpace }, [messageChannel.port2]); }); } @@ -386,12 +397,16 @@ function fetchUrlFromZIM(urlObject, range) { * @param {String} requestUrl The Request URL to fulfill from cache * @returns {Promise} 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 - 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 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 + '...'); return matching; }); @@ -405,10 +420,11 @@ function fromCache(cache, requestUrl) { * @param {Response} response The Response received from the server/ZIM * @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 - 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 caches.open(cache).then(function (cacheObj) { console.debug('[SW] Adding ' + (request.url || request) + ' to ' + cache + '...'); return cacheObj.put(request, response); @@ -421,16 +437,16 @@ function updateCache(cache, request, response) { * @param {String} url A URL to test against excludedURLSchema * @returns {Promise} 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 (!useAssetsCache) return Promise.resolve(['none', 'none', 'None', 0]); return caches.open(ASSETS_CACHE).then(function (cache) { return cache.keys().then(function (keys) { return ['cacheAPI', ASSETS_CACHE, 'Cache API', keys.length]; - }).catch(function(err) { + }).catch(function (err) { return err; }); - }).catch(function(err) { + }).catch(function (err) { return err; }); } diff --git a/www/js/app.js b/www/js/app.js index 9bbc7ccd..84020f1e 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -30,9 +30,8 @@ // This uses require.js to structure javascript: // http://requirejs.org/docs/api.html#define -define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesystemAccess'], +define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore', 'abstractFilesystemAccess'], function ($, zimArchiveLoader, uiUtil, settingsStore, abstractFilesystemAccess) { - /** * The delay (in milliseconds) between two "keepalive" messages sent to the ServiceWorker (so that it is not stopped * by the browser, and keeps the MessageChannel to communicate with the application) @@ -108,7 +107,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys (isServiceWorkerAvailable() ? 'serviceworker' : 'jquery'); // A parameter to circumvent anti-fingerprinting technology in browsers that do not support WebP natively by substituting images // directly with the canvas elements produced by the WebP polyfill [kiwix-js #835]. NB This is only currently used in jQuery mode. - params['useCanvasElementsForWebpTranscoding']; // Value is determined in uiUtil.determineCanvasElementsWorkaround(), called when setting the content injection mode + 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) appstate['search'] = { @@ -182,11 +181,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys document.getElementById('bypassAppCacheCheck').checked = !params.appCache; document.getElementById('appVersion').textContent = 'Kiwix ' + params.appVersion; // We check here if we have to warn the user that we switched to ServiceWorkerMode - // This is only needed if the ServiceWorker mode is available, or we are in a Firefox Extension that supports Service Workers + // This is only needed if the ServiceWorker mode is available, or we are in an Extension that supports Service Workers // outside of the extension environment, AND the user's settings are stuck on jQuery mode, AND the user has not already been // alerted about the switch to ServiceWorker mode by default - if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^moz-extension:/i.test(window.location.protocol)) - && params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) { + if ((isServiceWorkerAvailable() || isMessageChannelAvailable() && /^(moz|chrome)-extension:/i.test(window.location.protocol)) && + params.contentInjectionMode === 'jquery' && !params.defaultModeChangeAlertDisplayed) { // Attempt to upgrade user to ServiceWorker mode params.contentInjectionMode = 'serviceworker'; } else if (params.contentInjectionMode === 'serviceworker') { @@ -329,16 +328,12 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys $('#prefix').on('keyup', function (e) { if (selectedArchive !== null && selectedArchive.isReady()) { // Prevent processing by keyup event if we already handled the keypress in keydown event - if (keyPressHandled) - keyPressHandled = false; - else - onKeyUpPrefix(e); + if (keyPressHandled) { keyPressHandled = false; } else { onKeyUpPrefix(e); } } }); // Restore the search results if user goes back into prefix field $('#prefix').on('focus', function () { - if (document.getElementById('prefix').value !== '') - document.getElementById('articleListWithHeader').style.display = ''; + if (document.getElementById('prefix').value !== '') { document.getElementById('articleListWithHeader').style.display = ''; } }); // Hide the search results if user moves out of prefix field $('#prefix').on('blur', function () { @@ -374,7 +369,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys }); $('#btnTop').on('click', function () { 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) return true; }); @@ -401,7 +396,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys document.getElementById('prefix').value = ''; document.getElementById('prefix').focus(); var articleList = document.getElementById('articleList'); - var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); + var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); while (articleList.firstChild) articleList.removeChild(articleList.firstChild); while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild); document.getElementById('searchingArticles').style.display = 'none'; @@ -505,7 +500,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys refreshCacheStatus(); }); document.getElementById('disableDragAndDropCheck').addEventListener('change', function () { - params.disableDragAndDrop = this.checked ? true : false; + params.disableDragAndDrop = !!this.checked; settingsStore.setItem('disableDragAndDrop', params.disableDragAndDrop, Infinity); uiUtil.systemAlert('

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

' + '

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

', 'Reload app', true).then(function (result) { @@ -515,20 +510,20 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys }); }); $('input:checkbox[name=hideActiveContentWarning]').on('change', function () { - params.hideActiveContentWarning = this.checked ? true : false; + params.hideActiveContentWarning = !!this.checked; settingsStore.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity); }); $('input:checkbox[name=showUIAnimations]').on('change', function () { - params.showUIAnimations = this.checked ? true : false; + params.showUIAnimations = !!this.checked; settingsStore.setItem('showUIAnimations', params.showUIAnimations, Infinity); }); $('input:checkbox[name=useHomeKeyToFocusSearchBar]').on('change', function () { - params.useHomeKeyToFocusSearchBar = this.checked ? true : false; + params.useHomeKeyToFocusSearchBar = !!this.checked; settingsStore.setItem('useHomeKeyToFocusSearchBar', params.useHomeKeyToFocusSearchBar, Infinity); switchHomeKeyToFocusSearchBar(); }); $('input:checkbox[name=openExternalLinksInNewTabs]').on('change', function () { - params.openExternalLinksInNewTabs = this.checked ? true : false; + params.openExternalLinksInNewTabs = !!this.checked; settingsStore.setItem('openExternalLinksInNewTabs', params.openExternalLinksInNewTabs, Infinity); }); document.getElementById('appThemeSelect').addEventListener('change', function (e) { @@ -578,12 +573,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys uiUtil.checkUpdateStatus(appstate); }, 10000); - // Adds an event listener to kiwix logo and bottom navigation bar which gets triggered when these elements are dragged. // Returning false prevents their dragging (which can cause some unexpected behavior) // Doing that in javascript is the only way to make it cross-browser compatible - document.getElementById('kiwixLogo').ondragstart=function () {return false;} - document.getElementById('navigationButtons').ondragstart=function () {return false;} + document.getElementById('kiwixLogo').ondragstart = function () { return false; } + document.getElementById('navigationButtons').ondragstart = function () { return false; } // focus search bar (#prefix) if Home key is pressed function focusPrefixOnHomeKey (event) { @@ -592,7 +586,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys // wait to prevent interference with scrolling (default action) setTimeout(function () { document.getElementById('prefix').focus(); - },0); + }, 0); } } // 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; try { iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); - } - catch (err) { + } catch (err) { console.error('The iframe is probably not accessible', err); isIframeAccessible = false; } @@ -615,10 +608,8 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys // only for initial empty iFrame loaded using `src` attribute // in any other case listener gets removed on reloading of iFrame content iframeContentWindow.addEventListener('keydown', focusPrefixOnHomeKey); - } - // when the feature is not active - else { - // remove event listener for window(outside iframe) + } else { + // When the feature is not active, remove event listener for window (outside iframe) window.removeEventListener('keydown', focusPrefixOnHomeKey); // if feature is deactivated and no zim content is loaded yet iframeContentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); @@ -710,7 +701,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys } apiName = params.decompressorAPI.errorStatus || apiName || 'Not initialized'; // innerHTML is used here because the API name may contain HTML entities like   - decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName ; + decompAPIStatusDiv.innerHTML = 'Decompressor API: ' + apiName; // Update Search Provider uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider); // Update PWA origin @@ -859,7 +850,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys uriParams += '&appTheme=' + params.appTheme; uriParams += '&showUIAnimations=' + params.showUIAnimations; 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) { if (response) { @@ -1018,8 +1009,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys try { var dummyMessageChannel = new MessageChannel(); if (dummyMessageChannel) return true; - } - catch (e) { + } catch (e) { 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 var PWASuccessfullyLaunched = localStorage.getItem(params.keyPrefix + 'PWA_launch') === 'success'; var allowInternetAccess = settingsStore.getItem('allowInternetAccess') === 'true'; - var message = params.defaultModeChangeAlertDisplayed ? '

To enable the Service Worker, we ' : - ('

We shall attempt to switch you to ServiceWorker mode (this is now the default). ' + + var message = params.defaultModeChangeAlertDisplayed + ? '

To enable the Service Worker, we ' + : ('

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.

We '); message += 'need one-time access to our secure server so that the app can re-launch as a Progressive Web App (PWA). ' + 'If available, the PWA will work offline, but will auto-update periodically when online as per the ' + @@ -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 localStorage.setItem(params.keyPrefix + 'PWA_launch', 'fail'); window.location.href = params.PWAServer + 'www/index.html' + uriParams; - 'Beam me up, Scotty!'; + console.log('Beam me up, Scotty!'); }; var checkPWAIsOnline = function () { uiUtil.spinnerDisplay(true, 'Checking server access...'); @@ -1148,7 +1139,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys } else { // If DeviceStorage is not available, we display the file select components 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, setLocalArchiveFromFileSelect(); } else { @@ -1169,7 +1160,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys document.getElementById('articleListWithHeader').style.display = 'none'; $('#articleContent').contents().empty(); - if (title && !(''===title)) { + if (title && !(title === '')) { goToArticle(title); } else if (titleSearch && titleSearch !== '') { document.getElementById('prefix').value = titleSearch; @@ -1207,7 +1198,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys var lastSelectedArchive = settingsStore.getItem('lastSelectedArchive'); if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== '') { // Attempt to select the corresponding item in the list, if it exists - if ($("#archiveList option[value='"+lastSelectedArchive+"']").length > 0) { + if ($("#archiveList option[value='" + lastSelectedArchive + "']").length > 0) { document.getElementById('archiveList').value = lastSelectedArchive; } } @@ -1236,9 +1227,9 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys var regexpStorageName = /^\/([^/]+)\//; var regexpResults = regexpStorageName.exec(archiveDirectory); var selectedStorage = null; - if (regexpResults && regexpResults.length>0) { + if (regexpResults && regexpResults.length > 0) { var selectedStorageName = regexpResults[1]; - for (var i=0; i= params.maxSearchResultsSize) { message = 'First ' + params.maxSearchResultsSize + ' articles found (refine your search).'; } else { - message = 'Finished. ' + (nbDirEntry ? nbDirEntry : 'No') + ' articles found' + ( + message = 'Finished. ' + (nbDirEntry || 'No') + ' articles found' + ( reportingSearch.type === 'basic' ? ': try fewer words for full search.' : '.' ); } @@ -1608,8 +1597,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys if (iframeArticleContent.contentWindow) { // Configure home key press to focus #prefix only if the feature is in active state - if (params.useHomeKeyToFocusSearchBar) - iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); + if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); } if (params.openExternalLinksInNewTabs) { // Add event listener to iframe window to check for links to external resources iframeArticleContent.contentWindow.addEventListener('click', function (event) { @@ -1635,7 +1623,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys // remove eventListener to avoid memory leaks iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); var articleList = document.getElementById('articleList'); - var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); + var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); while (articleList.firstChild) articleList.removeChild(articleList.firstChild); while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild); document.getElementById('articleListWithHeader').style.display = 'none'; @@ -1792,7 +1780,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys iframeArticleContent.onload = function () { iframeArticleContent.onload = function () {}; var articleList = document.getElementById('articleList'); - var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); + var articleListHeaderMessage = document.getElementById('articleListHeaderMessage'); while (articleList.firstChild) articleList.removeChild(articleList.firstChild); while (articleListHeaderMessage.firstChild) articleListHeaderMessage.removeChild(articleListHeaderMessage.firstChild); document.getElementById('articleListWithHeader').style.display = 'none'; @@ -1800,10 +1788,10 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys var iframeContentDocument = iframeArticleContent.contentDocument; if (!iframeContentDocument && window.location.protocol === 'file:') { - uiUtil.systemAlert('You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons.' - + '

The easiest way to run it is to download and run it as a browser extension (from the vendor store).' - + '

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/...)' - + "

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"); + uiUtil.systemAlert('You seem to be opening kiwix-js with the file:// protocol, which is blocked by your browser for security reasons.' + + '

The easiest way to run it is to download and run it as a browser extension (from the vendor store).' + + '

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/...)' + + "

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; } @@ -1816,9 +1804,11 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys docBody = docBody ? docBody[0] : null; if (docBody) { // Add any missing classes stripped from the tag - if (htmlCSS) htmlCSS.forEach(function (cl) { + if (htmlCSS) { + htmlCSS.forEach(function (cl) { docBody.classList.add(cl); }); +} // Deflect drag-and-drop of ZIM file on the iframe to Config docBody.addEventListener('dragover', handleIframeDragover); docBody.addEventListener('drop', handleIframeDrop); @@ -1846,8 +1836,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys } if (iframeArticleContent.contentWindow) { // Configure home key press to focus #prefix only if the feature is in active state - if (params.useHomeKeyToFocusSearchBar) - iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); + if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.addEventListener('keydown', focusPrefixOnHomeKey); } // when unloaded remove eventListener to avoid memory leaks iframeArticleContent.contentWindow.onunload = function () { iframeArticleContent.contentWindow.removeEventListener('keydown', focusPrefixOnHomeKey); @@ -1998,7 +1987,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys selectedArchive.getDirEntryByPath(url).then(function (dirEntry) { if (!dirEntry) { 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 readFile = /^text\//i.test(mimetype) ? selectedArchive.readUtf8File : selectedArchive.readBinaryFile; @@ -2069,7 +2058,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys var source = mediaSource.getAttribute('src'); source = source ? uiUtil.deriveZimUrlFromRelativeUrl(source, baseUrl) : null; // We have to exempt text tracks from using deriveZimUrlFromRelativeurl due to a bug in Firefox [kiwix-js #496] - source = source ? source : decodeURIComponent(mediaSource.dataset.kiwixurl); + source = source || decodeURIComponent(mediaSource.dataset.kiwixurl); if (!source || !regexpZIMUrlWithNamespace.test(source)) { if (source) console.error('No usable media source was found for: ' + source); return; @@ -2100,7 +2089,7 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys if (params.assetsCache && /\.css$|\.js$/i.test(title)) { var cacheBlock = document.getElementById('cachingAssets'); cacheBlock.style.display = 'block'; - title = title.replace(/[^/]+\//g, '').substring(0,18); + title = title.replace(/[^/]+\//g, '').substring(0, 18); cacheBlock.textContent = 'Caching ' + title + '...'; } } @@ -2115,13 +2104,13 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys var stateObj = {}; var urlParameters; var stateLabel; - if (title && !(''===title)) { + if (title && !(title === '')) { // Prevents creating a double history for the same page if (history.state && history.state.title === title) return; stateObj.title = title; urlParameters = '?title=' + title; stateLabel = 'Wikipedia Article : ' + title; - } else if (titleSearch && !(''===titleSearch)) { + } else if (titleSearch && !(titleSearch === '')) { stateObj.titleSearch = titleSearch; urlParameters = '?titleSearch=' + titleSearch; stateLabel = 'Wikipedia search : ' + titleSearch; @@ -2131,7 +2120,6 @@ define(['jquery', 'zimArchiveLoader', 'uiUtil', 'settingsStore','abstractFilesys window.history.pushState(stateObj, stateLabel, urlParameters); } - /** * 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 } }); } - }); diff --git a/www/js/lib/uiUtil.js b/www/js/lib/uiUtil.js index 739e023c..0883b53e 100644 --- a/www/js/lib/uiUtil.js +++ b/www/js/lib/uiUtil.js @@ -1,26 +1,30 @@ /** * uiUtil.js : Utility functions for the User Interface - * + * * Copyright 2013-2020 Mossroy and contributors * License GPL v3: - * + * * This file is part of Kiwix. - * + * * Kiwix is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * Kiwix is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ + '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 // 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. @@ -31,27 +35,26 @@ if (webpMachine) { 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 - * - * @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 {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 {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} 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") * @returns {Promise} 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'; approveConfirmLabel = approveConfirmLabel || 'Confirm'; closeMessageLabel = closeMessageLabel || 'Okay'; label = label || (isConfirm ? 'Confirmation' : 'Message'); return util.PromiseQueue.enqueue(function () { 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 document.getElementById('approveConfirm').textContent = approveConfirmLabel; document.getElementById('declineConfirm').textContent = declineConfirmLabel; @@ -86,10 +89,10 @@ define(rqDef, function(settingsStore, util) { modal.classList.remove('show'); modal.style.display = 'none'; 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); } - //remove event listeners + // remove event listeners document.getElementById('modalCloseBtn').removeEventListener('click', close); document.getElementById('declineConfirm').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('closeMessage').addEventListener('click', close); document.getElementById('approveConfirm').addEventListener('click', closeConfirm); - + modal.addEventListener('click', close); 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 * 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 - * + * * @param {Object} node The node to which the URI should be added * @param {String} nodeAttribute The attribute to set to the URI * @param {Uint8Array} content The binary content to convert to a URI * @param {String} mimeType The MIME type of the content * @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 if (webpMachine && /image\/webp/i.test(mimeType)) { // 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. * 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. - * @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'; // Determine whether the browser is able to read canvas data correctly var browserRequiresWorkaround = webpMachine && webpHero && !webpHero.detectCanvasReadingSupport(); @@ -218,18 +221,18 @@ define(rqDef, function(settingsStore, util) { useCanvasElementsCheck.checked = userPreference; } 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; } /** * Replace the given CSS link (from the DOM) with an inline CSS of the given content - * + * * Due to CSP, Firefox OS does not accept syntax with href="data:text/css..." or href="blob:..." * So we replace the tag with a * while copying some attributes of the original tag * Cf http://jonraasch.com/blog/javascript-style-node - * + * * @param {Element} link The original link node from the DOM * @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); } - + /** * Removes parameters and anchors from a URL * @param {type} url The URL to be processed * @returns {String} The same URL without its parameters and anchors */ - function removeUrlParameters(url) { + function removeUrlParameters (url) { // Remove any querystring var strippedUrl = url.replace(/\?[^?]*$/, ''); // Remove any anchor parameters - note that we are deliberately excluding entity references, e.g. '''. @@ -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 - * + * * @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") * @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/") */ - function deriveZimUrlFromRelativeUrl(url, base) { + function deriveZimUrlFromRelativeUrl (url, base) { // We use a dummy domain because URL API requires a valid URI var dummy = 'http://d/'; 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 */ var activeContentWarningSetup = false; - function displayActiveContentWarning() { + function displayActiveContentWarning () { var alertActiveContent = document.getElementById('activeContent'); alertActiveContent.style.display = ''; if (!activeContentWarningSetup) { // We are setting up the active content warning for the first time activeContentWarningSetup = true; - alertActiveContent.querySelector('button[data-hide]').addEventListener('click', function() { + alertActiveContent.querySelector('button[data-hide]').addEventListener('click', function () { 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 // the options that the user needs to select 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 * 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 {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 @@ -336,23 +339,25 @@ define(rqDef, function(settingsStore, util) { * @param {Uint8Array} content The binary-format content of the downloadable file */ var downloadAlertSetup = false; - function displayFileDownloadAlert(title, download, contentType, content) { + function displayFileDownloadAlert (title, download, contentType, content) { var downloadAlert = document.getElementById('downloadAlert'); downloadAlert.style.display = 'block'; - if (!downloadAlertSetup) downloadAlert.querySelector('button[data-hide]').addEventListener('click', function() { - // We are setting up the alert for the first time - downloadAlert.style.display = 'none'; - }); + if (!downloadAlertSetup) { + downloadAlert.querySelector('button[data-hide]').addEventListener('click', function () { + // We are setting up the alert for the first time + downloadAlert.style.display = 'none'; + }); + } 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 if (!contentType) contentType = 'application/octet-stream'; 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 - var filename = download === true ? title.replace(/^.*\/([^\/]+)$/, '$1') : download; + var filename = download === true ? title.replace(/^.*\/([^/]+)$/, '$1') : download; // Make filename safe - filename = filename.replace(/[\/\\:*?"<>|]/g, '_'); + filename = filename.replace(/[/\\:*?"<>|]/g, '_'); a.href = window.URL.createObjectURL(blob); a.target = '_blank'; a.type = contentType; @@ -360,16 +365,17 @@ define(rqDef, function(settingsStore, util) { a.classList.add('alert-link'); a.textContent = filename; var alertMessage = document.getElementById('alertMessage'); - //innerHTML required as it has HTML tags + // innerHTML required as it has HTML tags alertMessage.innerHTML = 'Download 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 alertMessage.appendChild(a); - try { a.click(); } - catch (err) { + try { + a.click(); + } catch (err) { // 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) { - a.addEventListener('click', function(e) { + a.addEventListener('click', function (e) { window.navigator.msSaveBlob(blob, filename); e.preventDefault(); }); @@ -382,7 +388,7 @@ define(rqDef, function(settingsStore, util) { * Check for update of Service Worker (PWA) and display information to user */ var updateAlert = document.getElementById('updateAlert'); - function checkUpdateStatus(appstate) { + function checkUpdateStatus (appstate) { if ('serviceWorker' in navigator && !appstate.pwaUpdateNeeded) { settingsStore.getCacheNames(function (cacheNames) { if (cacheNames && !cacheNames.error) { @@ -406,9 +412,11 @@ define(rqDef, function(settingsStore, util) { }); } } - if (updateAlert) updateAlert.querySelector('button[data-hide]').addEventListener('click', function () { - updateAlert.style.display = 'none'; - }); + if (updateAlert) { + 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 @@ -416,7 +424,7 @@ define(rqDef, function(settingsStore, util) { * @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 */ - function checkServerIsAccessible(imageSrc, onSuccess, onError) { + function checkServerIsAccessible (imageSrc, onSuccess, onError) { var image = new Image(); image.onload = onSuccess; image.onerror = onError; @@ -425,10 +433,10 @@ define(rqDef, function(settingsStore, util) { /** * Show or hide the spinner together with a message - * @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 {Boolean} show True to show the spinner, false to hide it + * @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 spinnerMessage = document.getElementById('cachingAssets'); 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 - * + * * @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 * @returns {Boolean} True if the element is fully or partially (depending on the value of ) * inside the current viewport */ - function isElementInView(el, fully) { + function isElementInView (el, fully) { var rect = el.getBoundingClientRect(); - if (fully) + if (fully) { 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; + } } /** * Removes the animation effect between various sections */ - function removeAnimationClasses() { + function removeAnimationClasses () { var configuration = document.getElementById('configuration'); configuration.classList.remove('slideIn_L'); 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('slideOut_L'); } - + /** * 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 - * + * */ - function applyAnimationToSection(section) { - if (section == 'home') { + function applyAnimationToSection (section) { + if (section === 'home') { if (!$('#configuration').is(':hidden')) { document.getElementById('configuration').classList.add('slideOut_R'); setTimeout(function () { @@ -498,7 +507,7 @@ define(rqDef, function(settingsStore, util) { setTimeout(function () { document.getElementById('articleContent').style.display = ''; }, 300); - } else if (section == 'config') { + } else if (section === 'config') { if (!$('#about').is(':hidden')) { $('#about').addClass('slideOut_R'); $('#configuration').addClass('slideIn_R'); @@ -515,7 +524,7 @@ define(rqDef, function(settingsStore, util) { setTimeout(function () { document.getElementById('configuration').style.display = ''; }, 300); - } else if (section == 'about') { + } else if (section === 'about') { if (!$('#configuration').is(':hidden')) { document.getElementById('configuration').classList.add('slideOut_L'); setTimeout(function () { @@ -537,17 +546,17 @@ define(rqDef, function(settingsStore, util) { /** * Applies the requested app and content theme - * + * * A 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) * 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 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 - * + * * @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)'); // 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(/_.*$/, ''); @@ -583,7 +592,7 @@ define(rqDef, function(settingsStore, util) { // Hide any previously displayed description for auto themes var oldDescription = document.getElementById('kiwix-auto-description'); if (oldDescription) oldDescription.style.display = 'none'; - // Show description for auto themes + // Show description for auto themes var description = document.getElementById('kiwix-' + theme.replace(/_.*$/, '') + '-description'); if (description) description.style.display = 'block'; // 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 // 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 && - doc.title !== "Placeholder for injecting an article into the iframe") { + doc.title !== 'Placeholder for injecting an article into the iframe') { showReturnLink(); } } // Displays the return link and handles click event. Called by applyAppTheme() - function showReturnLink() { + function showReturnLink () { var viewArticle = document.getElementById('viewArticle'); viewArticle.style.display = 'block'; - viewArticle.addEventListener('click', function(e) { + viewArticle.addEventListener('click', function (e) { e.preventDefault(); document.getElementById('liConfigureNav').classList.remove('active'); document.getElementById('liHomeNav').classList.add('active'); removeAnimationClasses(); - if (params.showUIAnimations) { + if (params.showUIAnimations) { applyAnimationToSection('home'); } else { 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 // 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); params.decompressorAPI.assemblerMachineType = assemblerMachineType; 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 - function reportSearchProviderToAPIStatusPanel(provider) { + function reportSearchProviderToAPIStatusPanel (provider) { var providerAPI = document.getElementById('searchProviderStatus'); if (providerAPI) { // NB we need this so that tests don't fail - providerAPI.textContent = 'Search Provider: ' + (/^fulltext/.test(provider) ? 'Title + Xapian [' + provider + ']' : - /^title/.test(provider) ? 'Title only [' + provider + ']' : 'Not initialized'); + providerAPI.textContent = 'Search Provider: ' + (/^fulltext/.test(provider) ? 'Title + Xapian [' + provider + ']' + : /^title/.test(provider) ? 'Title only [' + provider + ']' : 'Not initialized'); 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 (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 - * + * * @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) */ - function warnAndOpenExternalLinkInNewTab(event, clickedAnchor) { + function warnAndOpenExternalLinkInNewTab (event, clickedAnchor) { event.preventDefault(); event.stopPropagation(); if (!clickedAnchor) clickedAnchor = event.target; @@ -682,21 +691,22 @@ define(rqDef, function(settingsStore, util) { message += '

' + clickedAnchor.href + '

'; systemAlert(message, 'Opening external link', true).then(function (response) { if (response) { - if (!target) + if (!target) { target = '_blank'; + } window.open(clickedAnchor.href, target); } }); } - + /** * Finds the closest or enclosing tag of an element. * Returns undefined if there isn't any. - * + * * @param {Element} element * @returns {Element} closest enclosing anchor tag (if any) */ - function closestAnchorEnclosingElement(element) { + function closestAnchorEnclosingElement (element) { if (Element.prototype.closest) { // Recent browsers support that natively. See https://developer.mozilla.org/en-US/docs/Web/API/Element/closest return element.closest('a,area');