diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c00a10fe..467c3378 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -19,6 +19,7 @@ module.exports = { 'no-unused-vars': 1, 'n/no-callback-literal': 0, 'object-shorthand': 0, + 'one-var': 0, 'multiline-ternary': 0, 'no-extend-native': 0, 'no-global-assign': 0 diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml index 28f07047..b836ed6b 100644 --- a/.github/workflows/build-electron.yml +++ b/.github/workflows/build-electron.yml @@ -53,7 +53,7 @@ jobs: echo "Changing to the dist directory" cd dist && pwd # Get archive name - packagedFile="$(grep -m1 'params\[.packagedFile' www/js/init.js | sed -E 's/^[^"]+"([^"]+\.zim)".+/\1/')" + packagedFile=$(grep -m1 'params\[.packagedFile' www/js/init.js | sed -E "s/^.+'([^']+\.zim)'.+/\1/") # If file doesn't exist in FS, download it if [ ! -f "archives/$packagedFile" ]; then # Generalize the name if cron_launched and download it @@ -144,7 +144,7 @@ jobs: run: | echo "Changing to the dist directory" cd dist && pwd - $packagedFile = (Select-String 'packagedFile' "www\js\init.js" -List) -ireplace '^[^"]+"([^"]+\.zim)".+', '$1' + $packagedFile = (Select-String 'packagedFile' "www\js\init.js" -List) -ireplace "^.+'([^']+\.zim)'.+", '$1' if ($packagedFile -and ! (Test-Path "archives\$packagedFile" -PathType Leaf)) { # File not in archives, so generalize the name (if nightly) and download it Write-Host "`nDownloading https://download.kiwix.org/zim/$packagedFile" @@ -190,7 +190,7 @@ jobs: } # To ensure there is enough disk space, we can delete the archive that is no longer needed rm -r dist/archives - ./scripts/Create-DraftRelease -buildonly -tag_name $INPUT_VERSION_E -portableonly -nobundle -wingetprompt N + ./scripts/Create-DraftRelease -buildonly -tag_name $INPUT_VERSION_E -portableonly -nobundle -wingetprompt N -nobranchcheck } - name: Publish packages if: github.event.inputs.target != 'artefacts' @@ -241,7 +241,7 @@ jobs: run: | echo "Changing to the dist directory" cd dist && pwd - $packagedFile = (Select-String 'packagedFile' "www\js\init.js" -List) -ireplace '^[^"]+"([^"]+\.zim)".+', '$1' + $packagedFile = (Select-String 'packagedFile' "www\js\init.js" -List) -ireplace "^.+'([^']+\.zim)'.+", '$1' if ($packagedFile -and ! (Test-Path "archives\$packagedFile" -PathType Leaf)) { # File not in archives, so generalize the name (if nightly) and download it Write-Host "`nDownloading https://download.kiwix.org/zim/$packagedFile" diff --git a/package.json b/package.json index ff35facc..2fe318d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kiwix-js-electron", "productName": "Kiwix JS Electron", - "version": "2.5.4-E", + "version": "2.5.4-N", "description": "Kiwix JS packaged for the Electron framework", "main": "main.cjs", "type": "module", @@ -88,6 +88,7 @@ "index.html", "CHANGELOG.md", "LICENCE", + "manifest.json", "www/**", "preload.cjs", "main.cjs", @@ -97,10 +98,13 @@ "scripts": { "serve": "vite", "preview": "del-cli dist && npm run build-src && vite preview", - "build": "del-cli dist && npm run build-src && npm run build-min", + "prebuild": "del-cli dist", + "build": "rollup --config --file dist/www/js/bundle.js && rollup --config --file dist/www/js/bundle.min.js --environment BUILD:production", + "prebuild-min": "del-cli dist", "build-min": "rollup --config --file dist/www/js/bundle.min.js --environment BUILD:production", + "prebuild-src": "del-cli dist", "build-src": "rollup --config --file dist/www/js/bundle.js", - "del-dist": "del-cli dist", + "del": "del-cli dist", "start": "electron .", "dist-win": "electron-builder build --win --projectDir dist", "dist-win-nsis": "electron-builder build --win NSIS:x64 NSIS:ia32 --projectDir dist", @@ -154,3 +158,5 @@ "electron-updater": "^6.1.0" } } + + diff --git a/package.json.nwjs b/package.json.nwjs index 4d7afaf7..fe298930 100644 --- a/package.json.nwjs +++ b/package.json.nwjs @@ -16,18 +16,20 @@ "start": "run --x86 --mirror https://dl.nwjs.io/ .", "dist-win-x86": "build --tasks win-x86 --mirror https://dl.nwjs.io/ .", "dist-win-x64": "build --tasks win-x64 --mirror https://dl.nwjs.io/ .", - "dist": "@powershell -NoProfile -ExecutionPolicy Unrestricted -Command ./scripts/Build_NWJS.ps1" + "dist": "@powershell -NoProfile -ExecutionPolicy Unrestricted -Command ./scripts/Build-NWJS.ps1" }, "build-xp": { "nwVersion": "0.14.7", "output": "bld/nwjs/win-x86-xp", "files": [ - "archives/**", "service-worker.js", "index.html", "CHANGELOG.md", "LICENCE", + "manifest.json", "www/**", + "archives/README.md", + "archives/wikip*.*", "!**/*.dev.{js,wasm}" ], "win": { @@ -38,12 +40,14 @@ "nwVersion": "0.72.0", "output": "bld/nwjs/win-x64", "files": [ - "archives/**", "service-worker.js", "index.html", "CHANGELOG.md", "LICENCE", + "manifest.json", "www/**", + "archives/README.md", + "archives/wikip*.*", "!**/*.dev.{js,wasm}" ], "win": { @@ -58,6 +62,7 @@ "index.html", "CHANGELOG.md", "LICENCE", + "manifest.json", "www/**", "archives/README.md", "archives/wikip*.*", diff --git a/scripts/Build-NWJS.ps1 b/scripts/Build-NWJS.ps1 index cd7919c4..8ef0309c 100644 --- a/scripts/Build-NWJS.ps1 +++ b/scripts/Build-NWJS.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param ( - [switch]$only32bit = $false + [switch]$only32bit = $false, + [switch]$usesdk = $false ) $builds = @("win-ia32", "win-xp") if (-Not $only32bit) { @@ -41,17 +42,21 @@ foreach ($build in $builds) { $folderTarget = "$PSScriptRoot\..\dist\bld\nwjs\$build-$version" $target = "$folderTarget\kiwix_js_windows$sep$appBuild" $fullTarget = "$target-$build" + $sdk = "" + if ($usesdk) { + $sdk = "-sdk" + } $ZipFolder = "$PSScriptRoot\..\dist\node_modules\nwjs-builder-phoenix\caches\" - $ZipLocation = $ZipFolder + "nwjs-v$version-$build.zip" + $ZipLocation = $ZipFolder + "nwjs$sdk-v$version-$build.zip" $UnzipLocation = "$ZipLocation-extracted\" - $buildLocation = "$ZipLocation-extracted\nwjs-v$version-$build\" + $buildLocation = "$ZipLocation-extracted\nwjs$sdk-v$version-$build\" if (-Not (Test-Path $ZipFolder -PathType Container)) { mkdir $ZipFolder } if (-Not (Test-Path $buildLocation -PathType Container)) { # We need to download and/or unzip the release, as it is not available if (-Not (Test-Path $ZipLocation -PathType Leaf)) { - $serverFile = "https://dl.nwjs.io/v$version/nwjs-v$version-$build.zip" + $serverFile = "https://dl.nwjs.io/v$version/nwjs$sdk-v$version-$build.zip" "Downloading $serverFile" Invoke-WebRequest -Uri $serverFile -OutFile $ZipLocation } @@ -72,7 +77,7 @@ foreach ($build in $builds) { # Copy latest binary x64 cp $buildLocation\* $fullTarget -Recurse $root = $PSScriptRoot -replace 'scripts.*$', '' - cp $root\dist\package.json, $root\dist\service-worker.js, $root\dist\index.html, $root\CHANGELOG.md, $root\LICENSE, $root\dist\www $fullTarget -Recurse + cp $root\dist\package.json, $root\dist\service-worker.js, $root\dist\index.html, $root\CHANGELOG.md, $root\LICENSE, $root\manifest.json, $root\dist\www $fullTarget -Recurse # Remove unwanted files # del $fullTarget\www\js\lib\libzim-*.dev.* "Copying archive..." @@ -101,3 +106,4 @@ foreach ($build in $builds) { Compress-Archive "$PSScriptRoot\..\dist\bld\nwjs\$build-$version\*" "$PSScriptRoot\..\dist\bld\nwjs\$foldername.zip" -Force "Build $OBuild finished.`n" } + diff --git a/scripts/Create-DraftRelease.ps1 b/scripts/Create-DraftRelease.ps1 index 80d9ed5a..57604be1 100644 --- a/scripts/Create-DraftRelease.ps1 +++ b/scripts/Create-DraftRelease.ps1 @@ -11,7 +11,8 @@ param ( [string]$electronbuild = "", # 'local' or 'cloud' [switch]$portableonly = $false, # If set, only the portable electron build will be built. Implies local electron build. [switch]$updatewinget = $false, - [string]$wingetprompt = "" # Provide an override response (Y/N) to the winget prompt at the end of the script - for automation + [string]$wingetprompt = "", # Provide an override response (Y/N) to the winget prompt at the end of the script - for automation + [switch]$nobranchcheck = $false # If set, will not check that the current branch is correct for the type of app to build ) # DEV: To build Electron packages for all platforms and NWJS for XP and Vista in a single release, use, e.g., "v1.3.0E+N" (Electron + NWJS) # DEV: To build UWP + Electron in a single release (for WikiMed or Wikivoyage), use "v1.3.0+E" (plus Electron) @@ -185,8 +186,12 @@ if ($numeric_tag -ne $file_tag_numeric) { $actual_branch = git rev-parse --abbrev-ref HEAD if ($branch -ne $actual_branch) { Write-Host "`nError! The branch you are on [$actual_branch] does not match the type of app you wish to build [$branch]!" -ForegroundColor Red - Write-Host "Please switch to the correct branch and try again.`n" -ForegroundColor Red - exit 1 + if (-not $nobranchcheck) { + Write-Host "Please switch to the correct branch and try again.`n" -ForegroundColor Red + exit 1 + } else { + Write-Host "Continuing anyway as you have specified the -nobranchcheck option.`n" -ForegroundColor Yellow + } } # Determine type of Electron build if any @@ -290,7 +295,7 @@ if (-Not ($dryrun -or $buildonly)) { if (-Not $nobundle) { "`nBuilding production bundle with rollup..." if (-Not $dryrun) { - & npm run del-dist && npm run build-min + & npm run build-min } else { "[DRYRUN] & npm run build" } @@ -428,7 +433,7 @@ if ($dryrun -or $buildonly -or $release.assets_url -imatch '^https:') { } } # Check for the existence of the requested packaged archive - $packagedFile = (Select-String 'packagedFile' "dist\www\js\init.js" -List) -ireplace '^[^"]+"([^"]+\.zim)".+', '$1' + $packagedFile = (Select-String 'packagedFile' "dist\www\js\init.js" -List) -ireplace "^.+['`"]([^'`"]+\.zim)['`"].+", '$1' if ($packagedFile -and ! (Test-Path "dist\archives\$packagedFile" -PathType Leaf)) { # File not in archives $downloadArchiveChk = Read-Host "`nWe could not find the packaged archive, do you wish to download it? Y/N" diff --git a/service-worker.js b/service-worker.js index 83f1549f..f1e21d8f 100644 --- a/service-worker.js +++ b/service-worker.js @@ -60,7 +60,7 @@ var useAssetsCache = true; * This is an expert setting in Configuration * @type {Boolean} */ - var useAppCache = true; +var useAppCache = true; /** * A Boolean that governs whether images are displayed diff --git a/www/index.html b/www/index.html index 91a74a5c..26a63618 100644 --- a/www/index.html +++ b/www/index.html @@ -742,6 +742,7 @@
+

diff --git a/www/js/app.js b/www/js/app.js index 5bf58056..2bbbabdb 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -55,7 +55,7 @@ const DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER = 30000; */ // The global parameter and app state objects are defined in init.js -/* global params, appstate, nw, electronAPI, Windows, webpMachine */ +/* global params, appstate, nw, electronAPI, Windows, webpMachine, dialog */ // Placeholders for the article container, the article window and the article DOM var articleContainer = document.getElementById('articleContent'); @@ -80,6 +80,9 @@ appstate['search'] = { params['storeType'] = null; params['storeType'] = settingsStore.getBestAvailableStorageAPI(); +// A parameter to determine whether the webkitdirectory API is available +params['webkitdirectory'] = util.webkitdirectorySupported(); + // Placeholder for the alert box header element, so it can be displayed and hidden easily const alertBoxHeader = document.getElementById('alertBoxHeader'); @@ -754,6 +757,11 @@ document.getElementById('btnHome').addEventListener('click', function () { var currentArchive = document.getElementById('currentArchive'); var currentArchiveLink = document.getElementById('currentArchiveLink'); var openCurrentArchive = document.getElementById('openCurrentArchive'); +var archiveFilesLegacy = document.getElementById('archiveFilesLegacy'); +var archiveDirLegacy = document.getElementById('archiveDirLegacy'); +if (!params.webkitdirectory) { + document.getElementById('archiveDirLegacy').style.display = 'none'; +} function setTab (activeBtn) { // Highlight the selected section in the navbar @@ -776,8 +784,8 @@ function setTab (activeBtn) { } else { cssUIThemeGetOrSet(determinedTheme); } - if (typeof Windows === 'undefined' && typeof window.showOpenFilePicker !== 'function' && !window.dialog) { - // If not UWP, File System Access API, or Electron methods, display legacy File Select + if (typeof Windows === 'undefined' && typeof window.showOpenFilePicker !== 'function' && !window.dialog && !params.webkitdirectory) { + // If not UWP, File System Access API, webkitdirectory API or Electron methods, display legacy File Select document.getElementById('archiveFilesDiv').style.display = 'none'; document.getElementById('archivesFound').style.display = 'none'; document.getElementById('instructions').style.display = appstate.selectedArchive ? 'none' : 'block'; @@ -1063,8 +1071,11 @@ function getNativeFSHandle (callback) { } else { // We have failed to load a picked archive via the File System API, but if params.storedFilePath exists, then the archive // was launched with Electron APIs, so we can get the folder that way - if (params.storedFilePath) params.pickedFolder = params.storedFilePath.replace(/[\\/]+[^\\/]+$/, ''); - searchForArchivesInPreferencesOrStorage(); + if (params.storedFile && params.storedFilePath) params.pickedFolder = params.pickedFolder = params.storedFilePath.replace(/[^\\/]+$/, ''); + scanNodeFolderforArchives(params.pickedFolder, function () { + // We now have the list of archives in the dropdown, so we try to select the storedFile + setLocalArchiveFromArchiveList(params.storedFile); + }); } } }); @@ -1081,7 +1092,7 @@ document.getElementById('btnAbout').addEventListener('click', function () { } // Check if we're 'unclicking' the button var searchDiv = document.getElementById('about'); - if (searchDiv.style.display != 'none') { + if (searchDiv.style.display !== 'none') { setTab(); return; } @@ -1122,10 +1133,17 @@ function selectArchive (list) { // Void any previous picked file to prevent it launching if (params.pickedFile && params.pickedFile.name !== selected) { params.pickedFile = ''; + params.storedFile = ''; } - if (!window.fs && window.showOpenFilePicker) { + if (window.showOpenFilePicker) { getNativeFSHandle(function (handle) { if (!handle) { + if (window.fs && params.storedFilePath) { + // Fall back to using the Electron APIs + params.pickedFolder = params.storedFilePath.replace(/[^\\/]+$/, ''); + setLocalArchiveFromArchiveList(selected); + return; + } console.error('No handle was retrieved'); uiUtil.systemAlert('We could not get a handle to the previously picked file or folder!
' + 'This is probably because the contents of the folder have changed. Please try picking it again.'); @@ -1149,8 +1167,27 @@ function selectArchive (list) { }); } }); + } else if (typeof MSApp === 'undefined' && !window.fs && params.webkitdirectory) { + // If we don't have any picked files or directories... + if (!archiveDirLegacy.files.length && !archiveFilesLegacy.files.length) { + appstate.waitForFileSelect = selected; + // No files are set, so we need to ask user to select the file or directory again + if (params.pickedFolder || document.getElementById('archiveList').options.length > 1) { + archiveDirLegacy.click(); + } else { + archiveFilesLegacy.click(); + } + } else { + console.debug('Files are set, attempting to select ' + selected); + params.pickedFile = selected; + if (archiveDirLegacy.files.length) { + params.pickedFolder = archiveDirLegacy.files[0].webkitRelativePath.replace(/\/[^/]*$/, ''); + params.pickedFile = ''; + } + setLocalArchiveFromArchiveList(selected); + } } else { - setLocalArchiveFromArchiveList([selected]); + setLocalArchiveFromArchiveList(selected); } setTimeout(function () { document.getElementById('openLocalFiles').style.display = 'none'; @@ -1161,7 +1198,30 @@ function selectArchive (list) { } // Legacy file picker is used as a fallback when all other pickers are unavailable -document.getElementById('archiveFilesLegacy').addEventListener('change', setLocalArchiveFromFileSelect); +archiveFilesLegacy.addEventListener('change', function (files) { + var filesArray = Array.from(files.target.files); + params.pickedFolder = null; + params.pickedFile = filesArray[0]; + params.storedFile = params.pickedFile.name.replace(/\.zim\w\w$/i, '.zimaa'); + if (params.webkitdirectory) { + settingsStore.setItem('pickedFolder', '', Infinity); + processFilesArray(filesArray); + } + var selected = params.storedFile; + if (appstate.waitForFileSelect) { + selected = appstate.waitForFileSelect; + appstate.waitForFileSelect = null; + // Select the selected file in the dropdown list of archives + document.getElementById('archiveList').value = selected; + console.debug('Files are set, attempting to select ' + selected); + } + if (!window.fs && params.webkitdirectory) { + // populateDropDownListOfArchives([params.pickedFile], true); + setLocalArchiveFromArchiveList(selected); + } else { + setLocalArchiveFromFileList(files.target.files); + } +}); // But in preference, use UWP, File System Access API document.getElementById('archiveFile').addEventListener('click', function () { if (typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') { @@ -1173,9 +1233,45 @@ document.getElementById('archiveFile').addEventListener('click', function () { } else if (window.fs && window.dialog) { // Electron file picker if showOpenFilePicker is not available dialog.openFile(); + } else { + // Legacy file picker + archiveFilesLegacy.click(); } }); -document.getElementById('archiveFiles').addEventListener('click', function () { +// Legacy webkitdirectory file picker is used as a fallback when File System Access API is unavailable +archiveDirLegacy.addEventListener('change', function (files) { + if (files.target.files.length) { + var filesArray = Array.from(files.target.files); + // Supports reading in NWJS/Electron frameworks that have a path property on the File object + var path = filesArray[0] ? filesArray[0].path ? filesArray[0].path : filesArray[0].webkitRelativePath : ''; + params.pickedFile = null; + var oldDir = params.pickedFolder; + params.pickedFolder = path.replace(/[^\\/]*$/, ''); + // If we're picking a different directroy, don't look for the previously picked file in it + if (params.pickedFolder !== oldDir) { + params.storedFile = null; + params.storedFilePath = null; + } + settingsStore.setItem('pickedFolder', params.pickedFolder, Infinity); + if (document.getElementById('archiveList').options.length === 0) { + params.storedFile = null; + } + processFilesArray(filesArray); + var selected = ''; + if (appstate.waitForFileSelect) { + selected = appstate.waitForFileSelect; + appstate.waitForFileSelect = null; + // Select the selected file in the dropdown list of archives + document.getElementById('archiveList').value = selected; + console.debug('Files are set, attempting to select ' + selected); + } + if (selected) setLocalArchiveFromArchiveList(selected); + } else { + appstate.waitForFileSelect = null; + console.log('User cancelled directory picker, or chose a directory with no files'); + } +}); +document.getElementById('archiveFiles').addEventListener('click', function (e) { if (typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') { // UWP FolderPicker pickFolderUWP(); @@ -1185,7 +1281,10 @@ document.getElementById('archiveFiles').addEventListener('click', function () { } else if (window.fs && window.dialog) { // Electron fallback dialog.openDirectory(); -} + } else if (params.webkitdirectory) { + // Legacy webkitdirectory file picker + archiveDirLegacy.click(); + } }); document.getElementById('btnRefresh').addEventListener('click', function () { // Refresh list of archives @@ -1197,6 +1296,8 @@ document.getElementById('btnRefresh').addEventListener('click', function () { scanUWPFolderforArchives(params.pickedFolder) } else if (window.fs) { scanNodeFolderforArchives(params.pickedFolder); + } else if (params.webkitdirectory) { + document.getElementById('archiveFiles').click(); } } else if (typeof window.showOpenFilePicker === 'function' && !params.pickedFile) { getNativeFSHandle(function (fsHandle) { @@ -1804,9 +1905,9 @@ function cssUIThemeGetOrSet (value, getOnly) { var elements; if (value == 'dark') { document.getElementsByTagName('body')[0].classList.add('dark'); - document.getElementById('archiveFilesLegacy').classList.add('dark'); + archiveFilesLegacy.classList.add('dark'); document.getElementById('footer').classList.add('darkfooter'); - document.getElementById('archiveFilesLegacy').classList.remove('btn'); + archiveFilesLegacy.classList.remove('btn'); document.getElementById('findInArticle').classList.add('dark'); document.getElementById('prefix').classList.add('dark'); elements = document.querySelectorAll('.settings'); @@ -1820,8 +1921,8 @@ function cssUIThemeGetOrSet (value, getOnly) { document.getElementsByTagName('body')[0].classList.remove('dark'); document.getElementById('search-article').classList.remove('dark'); document.getElementById('footer').classList.remove('darkfooter'); - document.getElementById('archiveFilesLegacy').classList.remove('dark'); - document.getElementById('archiveFilesLegacy').classList.add('btn'); + archiveFilesLegacy.classList.remove('dark'); + archiveFilesLegacy.classList.add('btn'); document.getElementById('findInArticle').classList.remove('dark'); document.getElementById('prefix').classList.remove('dark'); elements = document.querySelectorAll('.settings'); @@ -2535,7 +2636,7 @@ var storages = []; function searchForArchivesInPreferencesOrStorage () { // First see if the list of archives is stored in the cookie var listOfArchivesFromCookie = settingsStore.getItem('listOfArchives'); - if (listOfArchivesFromCookie !== null && listOfArchivesFromCookie !== undefined && listOfArchivesFromCookie !== '') { + if (listOfArchivesFromCookie) { var directories = listOfArchivesFromCookie.split('|'); populateDropDownListOfArchives(directories); } else { @@ -2591,7 +2692,8 @@ if ($.isFunction(navigator.getDeviceStorages)) { } if (storages !== null && storages.length > 0 || typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined' || - typeof window.fs !== 'undefined' || typeof window.showOpenFilePicker === 'function') { + typeof window.fs !== 'undefined' || typeof window.showOpenFilePicker === 'function' || + params.webkitdirectory) { if (window.fs && !(params.pickedFile || params.pickedFolder)) { // Below we compare the prefix of the files, i.e. the generic filename without date, so we can smoothly deal with upgrades if (params.packagedFile && params.storedFile.replace(/(^[^-]+all).+/, '$1') === params.packagedFile.replace(/(^[^-]+all).+/, '$1')) { @@ -2604,12 +2706,12 @@ if (storages !== null && storages.length > 0 || } else if (/\/archives\//.test(params.storedFilePath) && ~params.storedFilePath.indexOf(params.storedFile)) { // We're in an Electron / NWJS app, and there is a stored file in the archive, but it's not the packaged archive! // Probably there is more than one archive in the archive folder, so we are forced to use .fs code - console.warn('There may be more than one archive in the directory ' + params.storedFilePath.replace(/[^\/]+$/, '')); + console.warn('There may be more than one archive in the directory ' + params.storedFilePath.replace(/[^\\/]+$/, '')); params.pickedFile = params.storedFile; } } if (!params.pickedFile) { - if (params.storedFile && window.showOpenFilePicker) { + if (params.storedFile && (window.showOpenFilePicker)) { // We are in an app with support for File System Access API, so we can't auto-load the file, show file pickers document.getElementById('btnConfigure').click(); } else { @@ -2618,9 +2720,8 @@ if (storages !== null && storages.length > 0 || } else if (typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') { console.log('Loading picked file for UWP app...'); processPickedFileUWP(params.pickedFile); - // } else if (launchArguments && 'launchQueue' in window) { - // // The app was launched with a file - // processNativeFileHandle(params.pickedFile); + } else if (!window.fs && params.webkitdirectory) { + searchForArchivesInPreferencesOrStorage(); } else { // @AUTOLOAD packaged archive in Electron and NWJS packaged apps // We need to read the packaged file using the node File System API (so user doesn't need to pick it on startup) @@ -2676,7 +2777,6 @@ if (storages !== null && storages.length > 0 || uiUtil.systemAlert(message); }, 10); } - }); document.getElementById('hideFileSelectors').style.display = params.showFileSelectors ? 'inline' : 'none'; } @@ -2751,16 +2851,16 @@ function populateDropDownListOfArchives (archiveDirectories, displayOnly) { comboArchiveList.options[i] = new Option(archiveDirectory, archiveDirectory); } } - // Store the list of archives in a cookie, to avoid rescanning at each start + // Store the list of archives in settingsStore, to avoid rescanning at each start settingsStore.setItem('listOfArchives', archiveDirectories.join('|'), Infinity); comboArchiveList.size = comboArchiveList.length > 15 ? 15 : comboArchiveList.length; // Kiwix-Js-Windows #23 - remove dropdown caret if only one archive if (comboArchiveList.length > 1) comboArchiveList.removeAttribute('multiple'); - if (comboArchiveList.length == 1) comboArchiveList.setAttribute('multiple', '1'); + if (comboArchiveList.length === 1) comboArchiveList.setAttribute('multiple', '1'); if (comboArchiveList.options.length > 0) { // If we're doing a rescan, then don't attempt to jump to the last selected archive, but leave selectors open var lastSelectedArchive = params.rescan ? '' : params.storedFile; - if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== '') { + if (lastSelectedArchive) { // || comboArchiveList.options.length == 1 // Either we have previously chosen a file, or there is only one file // Attempt to select the corresponding item in the list, if it exists @@ -2777,24 +2877,26 @@ function populateDropDownListOfArchives (archiveDirectories, displayOnly) { // We can't find lastSelectedArchive in the archive list // Let's first check if this is a Store UWP/PWA that has a different archive package from that last selected // (or from that indicated in init.js) - if (typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined' && - params.packagedFile && settingsStore.getItem('lastSelectedArchive') !== params.packagedFile) { - // We didn't pick this file previously, so select first one in list - params.storedFile = archiveDirectories[0]; - params.fileVersion = ~params.fileVersion.indexOf(params.storedFile.replace(/\.zim\w?\w?$/i, '')) ? params.fileVersion : params.storedFile; - setLocalArchiveFromArchiveList(params.storedFile); - } else { - // It's genuinely no longer available, so let's ask the user to pick it - var message = '

We could not find the archive ' + lastSelectedArchive + '!

Please select its location...

'; - if (typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') - message += '

Note: If you drag-drop an archive into this UWP app, then it will have to be dragged again each time you launch the app. Try double-clicking on the archive instead, or select it using the controls on this page.

'; - if (document.getElementById('configuration').style.display == 'none') { - document.getElementById('btnConfigure').click(); - } - uiUtil.systemAlert(message).then(function () { - displayFileSelect(); - }); + // if (typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined' && + // params.packagedFile && settingsStore.getItem('lastSelectedArchive') !== params.packagedFile) { + // // We didn't pick this file previously, so select first one in list + // params.storedFile = archiveDirectories[0]; + // params.fileVersion = ~params.fileVersion.indexOf(params.storedFile.replace(/\.zim\w?\w?$/i, '')) ? params.fileVersion : params.storedFile; + // setLocalArchiveFromArchiveList(params.storedFile); + // } + // Warn user that the file they wanted is no longer available + var message = '

We could not find the archive ' + lastSelectedArchive + '!

Please select its location...

'; + if (params.webkitdirectory && !window.fs || typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') { + message += '

Note: If you drag-drop ' + (window.showOpenFilePicker ? 'a split' : 'an') + ' archive into this app, then it will have to be dragged again each time you launch the app. Try '; + message += typeof Windows !== 'undefined' ? 'double-clicking on the archive instead, or ' : ''; + message += 'selecting it using the controls on this page.

'; } + if (document.getElementById('configuration').style.display === 'none') { + document.getElementById('btnConfigure').click(); + } + uiUtil.systemAlert(message).then(function () { + displayFileSelect(); + }); } } usage.style.display = 'none'; @@ -2886,64 +2988,14 @@ function setLocalArchiveFromArchiveList (archive) { openCurrentArchive.style.display = 'inline'; return; } else if (params.pickedFolder.kind === 'directory') { - return processNativeDirHandle(params.pickedFolder, function (fileHandles) { - var fileHandle; - var fileset = []; - if (fileHandles) { - for (var i = 0; i < fileHandles.length; i++) { - if (fileHandles[i].name == archive) { - fileHandle = fileHandles[i]; - break; - } - } - if (fileHandle) { - // Deal with split archives - if (/\.zim\w\w$/i.test(fileHandle.name)) { - var genericFileName = fileHandle.name.replace(/(\.zim)\w\w$/i, '$1'); - var testFileName = new RegExp(genericFileName + '\\w\\w$'); - for (i = 0; i < fileHandles.length; i++) { - if (testFileName.test(fileHandles[i].name)) { - // This gets a JS File object from a file handle - fileset.push(fileHandles[i].getFile().then(function (file) { - return file; - })); - } - } - } else { - // Deal with single unslpit archive - fileset.push(fileHandle.getFile().then(function (file) { - return file; - })); - } - if (fileset.length) { - // Wait for all getFile Promises to resolve - Promise.all(fileset).then(function (resolvedFiles) { - setLocalArchiveFromFileList(resolvedFiles); - }); - } else { - console.error('There was an error reading the picked file(s)!'); - } - } else { - console.error('The picked file could not be found in the selected folder!'); - var archiveList = []; - for (i = 0; i < fileHandles.length; i++) { - if (/\.zim(aa)?$/i.test(fileHandles[i].name)) { - archiveList.push(fileHandles[i].name); - } - } - populateDropDownListOfArchives(archiveList); - document.getElementById('btnConfigure').click(); - } - } else { - console.log('There was an error obtaining the file handle(s).'); - } + return processNativeDirHandle(params.pickedFolder, function (files) { + processDirectoryOfFiles(files, archive); }); } openCurrentArchive.style.display = 'none'; }).catch(function () { openCurrentArchive.style.display = 'inline'; }); - return; } else if (window.fs) { if (params.pickedFile) { setLocalArchiveFromFileList([params.pickedFile]); @@ -2957,31 +3009,41 @@ function setLocalArchiveFromArchiveList (archive) { } else { setLocalArchiveFromFileList(selectedFiles); } + }).catch(function (err) { + console.error(err); }); } else { uiUtil.systemAlert('We could not find the location of the file ' + archive + '. This can happen if you dragged and dropped a file into the app. Please use the file or folder pickers instead.'); - if (document.getElementById('configuration').style.display == 'none') + if (document.getElementById('configuration').style.display === 'none') { document.getElementById('btnConfigure').click(); + } displayFileSelect(); } } return; + } else if (params.pickedFolder && params.webkitdirectory && archiveDirLegacy.files.length) { + processDirectoryOfFiles(archiveDirLegacy.files, archive); + return; } else { // Check if user previously picked a specific file rather than a folder if (params.pickedFile && typeof MSApp !== 'undefined') { try { selectedStorage = MSApp.createFileFromStorageFile(params.pickedFile); setLocalArchiveFromFileList([selectedStorage]); - return; } catch (err) { // Probably user has moved or deleted the previously selected file uiUtil.systemAlert('The previously picked archive can no longer be found!'); console.error('Picked archive not found: ' + err); } + return; } else if (params.pickedFile && typeof window.showOpenFilePicker === 'function') { // Native FS API for single file setLocalArchiveFromFileList([params.pickedFile]); return; + } else if (params.pickedFile && params.webkitdirectory) { + // Webkitdirectory API for single file + setLocalArchiveFromFileList(archiveFilesLegacy.files); + return; } } // There was no picked file or folder, so we'll try setting the default localStorage @@ -3056,6 +3118,67 @@ function setLocalArchiveFromArchiveList (archive) { } } +function processDirectoryOfFiles (fileHandles, archive) { + var fileHandle; + var fileset = []; + if (fileHandles) { + for (var i = 0; i < fileHandles.length; i++) { + if (fileHandles[i].name === archive) { + fileHandle = fileHandles[i]; + break; + } + } + if (fileHandle) { + // Deal with split archives + if (/\.zim\w\w$/i.test(fileHandle.name)) { + var genericFileName = fileHandle.name.replace(/(\.zim)\w\w$/i, '$1'); + var testFileName = new RegExp(genericFileName + '\\w\\w$'); + for (i = 0; i < fileHandles.length; i++) { + if (testFileName.test(fileHandles[i].name)) { + if (fileHandles[i].getFile) { + // This gets a JS File object from a file handle + fileset.push(fileHandles[i].getFile().then(function (file) { + return file; + })); + } else { + fileset.push(fileHandles[i]); + } + } + } + } else { + // Deal with single unslpit archive + if (fileHandle.getFile) { + fileset.push(fileHandle.getFile().then(function (file) { + return file; + })); + } else { + fileset.push(fileHandle); + } + } + if (fileset.length) { + // Wait for all getFile Promises to resolve + Promise.all(fileset).then(function (resolvedFiles) { + setLocalArchiveFromFileList(resolvedFiles); + }); + } else { + console.error('There was an error reading the picked file(s)!'); + } + } else { + console.error('The picked file could not be found in the selected folder!'); + var archiveList = []; + for (i = 0; i < fileHandles.length; i++) { + if (/\.zim(aa)?$/i.test(fileHandles[i].name)) { + archiveList.push(fileHandles[i].name); + } + } + populateDropDownListOfArchives(archiveList); + document.getElementById('btnConfigure').click(); + } + } else { + console.log('There was an error obtaining the file handle(s).'); + } +} + if (!params.disableDragAndDrop) { // Define globalDropZone (universal drop area) and configDropZone (highlighting area on Config page) var globalDropZone = document.getElementById('search-article'); @@ -3082,8 +3205,6 @@ function displayFileSelect () { UWPInstructions.style.display = 'block'; } document.getElementById('rescanStorage').style.display = 'none'; - // This handles use of the file picker - document.getElementById('archiveFiles').addEventListener('change', setLocalArchiveFromFileSelect); } function handleGlobalDragover (e) { @@ -3102,7 +3223,6 @@ function handleIframeDragover (e) { function handleIframeDrop (e) { e.stopPropagation(); e.preventDefault(); - return; } function handleFileDrop (packet) { @@ -3110,6 +3230,7 @@ function handleFileDrop (packet) { packet.preventDefault(); configDropZone.style.border = ''; var items = packet.dataTransfer.items; + // When dropping multiple files (e.g. a split archive), we cannot use the File System Access API if (items && items.length === 1 && items[0].kind === 'file' && typeof items[0].getAsFileSystemHandle !== 'undefined') { items[0].getAsFileSystemHandle().then(function (handle) { if (handle.kind === 'file') { @@ -3123,10 +3244,12 @@ function handleFileDrop (packet) { document.getElementById('openLocalFiles').style.display = 'none'; document.getElementById('rescanStorage').style.display = 'block'; document.getElementById('usage').style.display = 'none'; + // We have to void the previous picked folder, because dragged files don't have a folder + // This also prevents a file-not-found alert to the user when picking a new directory + params.pickedFolder = null; + settingsStore.setItem('pickedFolder', '', Infinity); params.rescan = false; setLocalArchiveFromFileList(files); - // This clears the display of any previously picked archive in the file selector - document.getElementById('archiveFilesLegacy').value = ''; } } @@ -3267,9 +3390,11 @@ function processNativeDirHandle (dirHandle, callback) { if (callback) archiveList.push(entry); // Hide all parts of split file except first in UI else if (/\.zim(aa)?$/.test(entry.name)) archiveList.push(entry.name); - if (!params.pickedFolder.path) entry.getFile().then(function (file) { - params.pickedFolder.path = file.path; - }) + if (!params.pickedFolder.path) { + entry.getFile().then(function (file) { + params.pickedFolder.path = file.path; + }); + } } iterateAsyncDirEntryArray(); } else { @@ -3324,7 +3449,15 @@ function scanUWPFolderforArchives (folder) { params.pickedFolder = folder; // Query the folder. var query = folder.createFileQuery(); - query.getFilesAsync().done(processFilesArray); + query.getFilesAsync().done(function (files) { + processFilesArray(files, function (resolvedFiles) { + // If there is only one file in the folder, we should load it + if ((resolvedFiles.length === 1 || params.storedFile) && !params.rescan) { + var fileToLoad = params.storedFile || resolvedFiles[0].name; + setLocalArchiveFromArchiveList(fileToLoad); + } + }); + }); } else { // The picker was dismissed with no selected file console.log('User closed folder picker without picking a file'); @@ -3335,19 +3468,15 @@ function processFilesArray (files, callback) { // Display file list var archiveDisplay = document.getElementById('chooseArchiveFromLocalStorage'); if (files) { - var filteredFiles = []; var archiveList = []; files.forEach(function (file) { - if (/\.zim(aa)?$/i.test(file.fileType) || /\.zim(aa)?$/i.test(file)) { + if (/\.zim(aa)?$/i.test(file.fileType) || /\.zim(aa)?$/i.test(file) || /\.zim(aa)?$/i.test(file.name)) { archiveList.push(file.name || file); } - if (/\.zim(\w\w)?$/i.test(file.fileType) || /\.zim(\w\w)?$/i.test(file)) { - filteredFiles.push(file); - } }); if (archiveList.length) { document.getElementById('noZIMFound').style.display = 'none'; - populateDropDownListOfArchives(archiveList); + populateDropDownListOfArchives(archiveList, true); if (callback) callback(files, archiveList); return; } @@ -3362,8 +3491,9 @@ function processFilesArray (files, callback) { function setLocalArchiveFromFileList (files) { if (!files.length) { - if (document.getElementById('configuration').style.display == 'none') - document.getElementById('btnConfigure').click(); + if (document.getElementById('configuration').style.display == 'none') { + document.getElementById('btnConfigure').click(); + } displayFileSelect(); return; } @@ -3381,6 +3511,10 @@ function setLocalArchiveFromFileList (files) { if (typeof window.fs !== 'undefined' && files[i].path) { files[i].readMode = 'electron'; console.log('File path is: ' + files[i].path); + if (files.length === 1 || params.firstFileIndex) { + params.pickedFile = files[i].path; + settingsStore.setItem('pickedFile', params.pickedFile, Infinity); + } } } // Check that user hasn't picked just part of split ZIM @@ -3436,8 +3570,22 @@ function setLocalArchiveFromFileList (files) { } // The archive is set : go back to home page to start searching params.storedFile = archive._file._files[0].name; + params.storedFilePath = archive._file._files[0].path ? archive._file._files[0].path : ''; settingsStore.setItem('lastSelectedArchive', params.storedFile, Infinity); - settingsStore.setItem('lastSelectedArchivePath', archive._file._files[0].path ? archive._file._files[0].path : '', Infinity); + settingsStore.setItem('lastSelectedArchivePath', params.storedFilePath, Infinity); + // If we have dragged and dropped files into an Electron app, we should have access to the path, so we should store it + if (params.storedFilePath) { + params.pickedFolder = null; + params.pickedFile = params.storedFilePath; + settingsStore.setItem('pickedFolder', '', Infinity); + settingsStore.setItem('pickedFile', params.pickedFile, Infinity); + populateDropDownListOfArchives([params.storedFile], true); + settingsStore.setItem('listOfArchives', encodeURI(params.storedFile), Infinity); + // We have to remove the file handle to prevent it from launching next time + cache.idxDB('delete', 'pickedFSHandle', function () { + console.debug('File handle deleted'); + }); + } var reloadLink = document.getElementById('reloadPackagedArchive'); if (reloadLink) { if (params.packagedFile != params.storedFile) { @@ -3512,6 +3660,8 @@ function loadPackagedArchive () { params.storedFile = params.packagedFile; setLocalArchiveFromFileList(fileObjects); populateDropDownListOfArchives(fileNames, true); + }).catch(function (err) { + console.error(err); }); // createFakeFileObjectNode(params.packagedFile, params.archivePath + '/' + params.packagedFile, processFakeFile); } @@ -3522,7 +3672,14 @@ function loadPackagedArchive () { * Sets the localArchive from the File selects populated by user */ function setLocalArchiveFromFileSelect () { - setLocalArchiveFromFileList(document.getElementById('archiveFilesLegacy').files); + setLocalArchiveFromFileList(archiveFilesLegacy.files); + params.rescan = false; +} +/** + * Sets the localArchive from the directory selected by user + */ +function setLocalArchiveFromDirSelect () { + setLocalArchiveFromFileList(archiveDirLegacy.files); params.rescan = false; } @@ -3575,6 +3732,7 @@ function readNodeDirectoryAndCreateNodeFileObjects (folder, file) { var selectedFileSet = [], selectedFileNamesSet = []; var count = 0; var fileHandle = typeof file === 'string' ? file : file[0]; + // Electron may need to handle the path differently if (folder === params.archivePath && /^file:/i.test(window.location.protocol)) { folder = decodeURIComponent(window.location.href.replace(/www\/[^/?#]+(?:[?#].*)?$/, '') + folder); } diff --git a/www/js/init.js b/www/js/init.js index 0ea20489..68480c87 100644 --- a/www/js/init.js +++ b/www/js/init.js @@ -23,6 +23,8 @@ 'use strict'; +/* global Windows, launchArguments */ + // Set a global error handler to prevent app crashes window.onerror = function (msg, url, line, col, error) { console.error('Error caught in app [' + url + ':' + line + ']:\n' + msg, error); @@ -45,57 +47,58 @@ var params = {}; /** * A global state object - * + * * @type Object */ var appstate = {}; -/******** UPDATE VERSION IN service-worker.js TO MATCH VERSION AND CHECK PWASERVER BELOW!!!!!!! *******/ -params['appVersion'] = "2.5.4"; //DEV: Manually update this version when there is a new release: it is compared to the Settings Store "appVersion" in order to show first-time info, and the cookie is updated in app.js -/******* UPDATE THIS ^^^^^^ IN service worker AND PWA-SERVER BELOW !! ********************/ -params['packagedFile'] = getSetting('packagedFile') || "wikipedia_en_100_mini_2023-06.zim"; //For packaged Kiwix JS (e.g. with Wikivoyage file), set this to the filename (for split files, give the first chunk *.zimaa) and place file(s) in default storage -params['archivePath'] = "archives"; //The directory containing the packaged archive(s) (relative to app's root directory) -params['fileVersion'] = getSetting('fileVersion') || "wikipedia_en_100_mini_2023-06.zim (2 June 2023)"; //This will be displayed in the app - optionally include date of ZIM file + +// ******** UPDATE VERSION IN service-worker.js TO MATCH VERSION AND CHECK PWASERVER BELOW!!!!!!! ******* +params['appVersion'] = '2.5.4'; // DEV: Manually update this version when there is a new release: it is compared to the Settings Store "appVersion" in order to show first-time info, and the cookie is updated in app.js +// ******* UPDATE THIS ^^^^^^ IN service worker AND PWA-SERVER BELOW !! ******************** +params['packagedFile'] = getSetting('packagedFile') || 'wikipedia_en_100_mini_2023-06.zim'; // For packaged Kiwix JS (e.g. with Wikivoyage file), set this to the filename (for split files, give the first chunk *.zimaa) and place file(s) in default storage +params['archivePath'] = 'archives'; // The directory containing the packaged archive(s) (relative to app's root directory) +params['fileVersion'] = getSetting('fileVersion') || 'wikipedia_en_100_mini_2023-06.zim (2 June 2023)'; // This will be displayed in the app - optionally include date of ZIM file // List of known start pages cached in the FS: params['cachedStartPages'] = { 'wikipedia_en_medicine-app_maxi': 'A/Wikipedia:WikiProject_Medicine/Open_Textbook_of_Medicine2', - 'wikipedia_en_medicine_maxi': 'A/Wikipedia:WikiProject_Medicine/Open_Textbook_of_Medicine2', + wikipedia_en_medicine_maxi: 'A/Wikipedia:WikiProject_Medicine/Open_Textbook_of_Medicine2', // 'mdwiki_en_all_maxi': 'A/Wikipedia:WikiProject_Medicine/Open_Textbook_of_Medicine2', - 'wikivoyage_en_all_maxi': 'A/Main_Page' + wikivoyage_en_all_maxi: 'A/Main_Page' }; -params['kiwixDownloadLink'] = "https://download.kiwix.org/zim/"; //Include final slash -params['kiwixHiddenDownloadLink'] = "https://master.download.kiwix.org/zim/"; -/******* DEV: ENSURE SERVERS BELOW ARE LISTED IN package.appxmanifest ************/ -params['PWAServer'] = "https://pwa.kiwix.org/"; // Production server +params['kiwixDownloadLink'] = 'https://download.kiwix.org/zim/'; // Include final slash +params['kiwixHiddenDownloadLink'] = 'https://master.download.kiwix.org/zim/'; +/** ***** DEV: ENSURE SERVERS BELOW ARE LISTED IN package.appxmanifest ************/ +params['PWAServer'] = 'https://pwa.kiwix.org/'; // Production server // params['PWAServer'] = "https://kiwix.github.io/kiwix-js-windows/dist/"; // Test server params['storeType'] = getBestAvailableStorageAPI(); params['appType'] = getAppType(); params['keyPrefix'] = 'kiwixjs-'; // Prefix to use for localStorage keys // Maximum number of article titles to return (range is 5 - 100, default 30) params['maxSearchResultsSize'] = ~~(getSetting('maxSearchResultsSize') || 30); -params['relativeFontSize'] = ~~(getSetting('relativeFontSize') || 100); //Sets the initial font size for articles (as a percentage) - user can adjust using zoom buttons -params['relativeUIFontSize'] = ~~(getSetting('relativeUIFontSize') || 100); //Sets the initial font size for UI (as a percentage) - user can adjust using slider in Config -params['cssSource'] = getSetting('cssSource') || "auto"; //Set default to "auto", "desktop" or "mobile" -params['removePageMaxWidth'] = getSetting('removePageMaxWidth') != null ? getSetting('removePageMaxWidth') : "auto"; //Set default for removing max-width restriction on Wikimedia pages ("auto" = removed in desktop, not in mobile; true = always remove; false = never remove) -params['displayHiddenBlockElements'] = getSetting('displayHiddenBlockElements') !== null ? getSetting('displayHiddenBlockElements') : "auto"; //Set default for displaying hidden block elements ("auto" = displayed in Wikimedia archives in mobile style) -params['openAllSections'] = getSetting('openAllSections') != null ? getSetting('openAllSections') : true; //Set default for opening all sections in ZIMs that have collapsible sections and headings ("auto" = let CSS decide according to screen width; true = always open until clicked by user; false = always closed until clicked by user) -params['cssCache'] = getSetting('cssCache') != null ? getSetting('cssCache') : true; //Set default to true to use cached CSS, false to use Zim only -params['cssTheme'] = getSetting('cssTheme') || 'light'; //Set default to 'auto', 'light', 'dark' or 'invert' to use respective themes for articles -params['cssUITheme'] = getSetting('cssUITheme') || 'light'; //Set default to 'auto', 'light' or 'dark' to use respective themes for UI' +params['relativeFontSize'] = ~~(getSetting('relativeFontSize') || 100); // Sets the initial font size for articles (as a percentage) - user can adjust using zoom buttons +params['relativeUIFontSize'] = ~~(getSetting('relativeUIFontSize') || 100); // Sets the initial font size for UI (as a percentage) - user can adjust using slider in Config +params['cssSource'] = getSetting('cssSource') || 'auto'; // Set default to "auto", "desktop" or "mobile" +params['removePageMaxWidth'] = getSetting('removePageMaxWidth') != null ? getSetting('removePageMaxWidth') : 'auto'; // Set default for removing max-width restriction on Wikimedia pages ("auto" = removed in desktop, not in mobile; true = always remove; false = never remove) +params['displayHiddenBlockElements'] = getSetting('displayHiddenBlockElements') !== null ? getSetting('displayHiddenBlockElements') : 'auto'; // Set default for displaying hidden block elements ("auto" = displayed in Wikimedia archives in mobile style) +params['openAllSections'] = getSetting('openAllSections') != null ? getSetting('openAllSections') : true; // Set default for opening all sections in ZIMs that have collapsible sections and headings ("auto" = let CSS decide according to screen width; true = always open until clicked by user; false = always closed until clicked by user) +params['cssCache'] = getSetting('cssCache') != null ? getSetting('cssCache') : true; // Set default to true to use cached CSS, false to use Zim only +params['cssTheme'] = getSetting('cssTheme') || 'light'; // Set default to 'auto', 'light', 'dark' or 'invert' to use respective themes for articles +params['cssUITheme'] = getSetting('cssUITheme') || 'light'; // Set default to 'auto', 'light' or 'dark' to use respective themes for UI' params['resetDisplayOnResize'] = getSetting('resetDisplayOnResize') == true; // Default for the display reset feature that fixes bugs with secondary displays -params['imageDisplay'] = getSetting('imageDisplay') != null ? getSetting('imageDisplay') : true; //Set default to display images from Zim -params['manipulateImages'] = getSetting('manipulateImages') != null ? getSetting('manipulateImages') : true; //Makes dataURIs by default instead of BLOB URIs for images -params['linkToWikimediaImageFile'] = getSetting('linkToWikimediaImageFile') == true; //Links images to Wikimedia online version if ZIM archive is a Wikipedia archive -params['hideToolbars'] = getSetting('hideToolbars') != null ? getSetting('hideToolbars') : true; //Set default to true (hides both), 'top' (hides top only), or false (no hiding) -params['rememberLastPage'] = getSetting('rememberLastPage') != null ? getSetting('rememberLastPage') : true; //Set default option to remember the last visited page between sessions +params['imageDisplay'] = getSetting('imageDisplay') != null ? getSetting('imageDisplay') : true; // Set default to display images from Zim +params['manipulateImages'] = getSetting('manipulateImages') != null ? getSetting('manipulateImages') : true; // Makes dataURIs by default instead of BLOB URIs for images +params['linkToWikimediaImageFile'] = getSetting('linkToWikimediaImageFile') == true; // Links images to Wikimedia online version if ZIM archive is a Wikipedia archive +params['hideToolbars'] = getSetting('hideToolbars') != null ? getSetting('hideToolbars') : true; // Set default to true (hides both), 'top' (hides top only), or false (no hiding) +params['rememberLastPage'] = getSetting('rememberLastPage') != null ? getSetting('rememberLastPage') : true; // Set default option to remember the last visited page between sessions params['assetsCache'] = getSetting('assetsCache') != null ? getSetting('assetsCache') : true; // Whether to use cache by default or not params['appCache'] = getSetting('appCache') !== false; // Will be true by default unless explicitly set to false -params['useMathJax'] = getSetting('useMathJax') != null ? getSetting('useMathJax') : true; //Set default to true to display math formulae with MathJax, false to use fallback SVG images only -//params['showFileSelectors'] = getCookie('showFileSelectors') != null ? getCookie('showFileSelectors') : false; //Set to true to display hidden file selectors in packaged apps -params['showFileSelectors'] = true; //False will cause file selectors to be hidden on each load of the app (by ignoring cookie) +params['useMathJax'] = getSetting('useMathJax') != null ? getSetting('useMathJax') : true; // Set default to true to display math formulae with MathJax, false to use fallback SVG images only +// params['showFileSelectors'] = getCookie('showFileSelectors') != null ? getCookie('showFileSelectors') : false; //Set to true to display hidden file selectors in packaged apps +params['showFileSelectors'] = true; // False will cause file selectors to be hidden on each load of the app (by ignoring cookie) params['hideActiveContentWarning'] = getSetting('hideActiveContentWarning') != null ? getSetting('hideActiveContentWarning') : false; params['allowHTMLExtraction'] = getSetting('allowHTMLExtraction') == true; -params['alphaChar'] = getSetting('alphaChar') || 'A'; //Set default start of alphabet string (used by the Archive Index) -params['omegaChar'] = getSetting('omegaChar') || 'Z'; //Set default end of alphabet string +params['alphaChar'] = getSetting('alphaChar') || 'A'; // Set default start of alphabet string (used by the Archive Index) +params['omegaChar'] = getSetting('omegaChar') || 'Z'; // Set default end of alphabet string params['contentInjectionMode'] = getSetting('contentInjectionMode') || ((navigator.serviceWorker && !window.nw) ? 'serviceworker' : 'jquery'); // Deafault to SW mode if the browser supports it params['allowInternetAccess'] = getSetting('allowInternetAccess'); // Access disabled unless user specifically asked for it: NB allow this value to be null as we use it later params['openExternalLinksInNewTabs'] = getSetting('openExternalLinksInNewTabs') !== null ? getSetting('openExternalLinksInNewTabs') : true; // Parameter to turn on/off opening external links in new tab @@ -104,7 +107,9 @@ params['windowOpener'] = getSetting('windowOpener'); // 'tab|window|false' A set params['rightClickType'] = getSetting('rightClickType'); // 'single|double|false' A setting that determines whether a single or double right-click is used to open a new window/tab params['navButtonsPos'] = getSetting('navButtonsPos') || 'bottom'; // 'top|bottom' A setting that determines where the back-forward nav buttons appear -//Do not touch these values unless you know what they do! Some are global variables, some are set programmatically +// Do not touch these values unless you know what they do! Some are global variables, some are set programmatically +params['cacheAPI'] = 'kiwixjs-assetsCache'; // Set the global Cache API database or cache name here, and synchronize with Service Worker +params['cacheIDB'] = 'kiwix-assetsCache'; // Set the global IndexedDB database here (Slightly different name to disambiguate) params['imageDisplayMode'] = params.imageDisplay ? 'progressive' : 'manual'; params['storedFile'] = getSetting('lastSelectedArchive'); params.storedFile = launchArguments ? launchArguments.files[0].name : params.storedFile || params['packagedFile'] || ''; @@ -114,16 +119,16 @@ params['storedFilePath'] = getSetting('lastSelectedArchivePath'); params.storedFilePath = params.storedFilePath ? decodeURIComponent(params.storedFilePath) : params.archivePath + '/' + params.packagedFile; params.storedFilePath = launchArguments ? launchArguments.files[0].path || '' : params.storedFilePath; params.originalPackagedFile = params.packagedFile; -params['localStorage'] = ""; -params['pickedFile'] = launchArguments ? launchArguments.files[0] : ""; -params['pickedFolder'] = ""; +params['localStorage'] = ''; +params['pickedFile'] = launchArguments ? launchArguments.files[0] : ''; +params['pickedFolder'] = ''; params['themeChanged'] = params['themeChanged'] || false; params['printIntercept'] = false; params['printInterception'] = false; params['appIsLaunching'] = true; // Allows some routines to tell if the app has just been launched params['PWAInstalled'] = window.matchMedia('(display-mode: standalone)').matches; // Because user may reset the app, we have to test for standalone mode -params['falFileToken'] = "zimfile"; // UWP support -params['falFolderToken'] = "zimfilestore"; // UWP support +params['falFileToken'] = 'zimfile'; // UWP support +params['falFolderToken'] = 'zimfilestore'; // UWP support params.pagesLoaded = 0; // Page counter used to show PWA Install Prompt only after user has played with the app for a while params.localUWPSettings = /UWP/.test(params.appType) ? Windows.Storage.ApplicationData.current.localSettings.values : null; appstate['target'] = 'iframe'; // The target for article loads (this should always be 'iframe' initially, and will only be changed as a result of user action) @@ -218,24 +223,24 @@ document.getElementById('manipulateImagesCheck').checked = params.manipulateImag document.getElementById('removePageMaxWidthCheck').checked = params.removePageMaxWidth === true; // Will be false if false or auto document.getElementById('removePageMaxWidthCheck').indeterminate = params.removePageMaxWidth === 'auto'; document.getElementById('removePageMaxWidthCheck').readOnly = params.removePageMaxWidth === 'auto'; -document.getElementById('pageMaxWidthState').textContent = (params.removePageMaxWidth === "auto" ? "auto" : params.removePageMaxWidth ? "always" : "never"); +document.getElementById('pageMaxWidthState').textContent = (params.removePageMaxWidth === 'auto' ? 'auto' : params.removePageMaxWidth ? 'always' : 'never'); document.getElementById('displayHiddenBlockElementsCheck').checked = params.displayHiddenBlockElements === true; document.getElementById('displayHiddenBlockElementsCheck').indeterminate = params.displayHiddenBlockElements === 'auto'; document.getElementById('displayHiddenBlockElementsCheck').readOnly = params.displayHiddenBlockElements === 'auto'; -document.getElementById('displayHiddenElementsState').textContent = (params.displayHiddenBlockElements === "auto" ? "auto" : params.displayHiddenBlockElements ? "always" : "never"); +document.getElementById('displayHiddenElementsState').textContent = (params.displayHiddenBlockElements === 'auto' ? 'auto' : params.displayHiddenBlockElements ? 'always' : 'never'); document.getElementById('openAllSectionsCheck').checked = params.openAllSections; document.getElementById('linkToWikimediaImageFileCheck').checked = params.linkToWikimediaImageFile; document.getElementById('useOSMCheck').checked = /openstreetmap/.test(params.mapsURI); -document.getElementById('cssUIDarkThemeCheck').checked = params.cssUITheme == "dark"; // Will be true, or false if light or auto -document.getElementById('cssUIDarkThemeCheck').indeterminate = params.cssUITheme == "auto"; -document.getElementById('cssUIDarkThemeCheck').readOnly = params.cssUITheme == "auto"; +document.getElementById('cssUIDarkThemeCheck').checked = params.cssUITheme == 'dark'; // Will be true, or false if light or auto +document.getElementById('cssUIDarkThemeCheck').indeterminate = params.cssUITheme == 'auto'; +document.getElementById('cssUIDarkThemeCheck').readOnly = params.cssUITheme == 'auto'; document.getElementById('cssUIDarkThemeState').innerHTML = params.cssUITheme; document.getElementById('cssWikiDarkThemeCheck').checked = /dark|invert/.test(params.cssTheme); -document.getElementById('cssWikiDarkThemeCheck').indeterminate = params.cssTheme == "auto"; -document.getElementById('cssWikiDarkThemeCheck').readOnly = params.cssTheme == "auto"; +document.getElementById('cssWikiDarkThemeCheck').indeterminate = params.cssTheme == 'auto'; +document.getElementById('cssWikiDarkThemeCheck').readOnly = params.cssTheme == 'auto'; document.getElementById('cssWikiDarkThemeState').innerHTML = params.cssTheme; -document.getElementById('darkInvert').style.display = /dark|invert|darkReader/i.test(params.cssTheme) ? "inline" : "none"; -document.getElementById('darkDarkReader').style.display = params.contentInjectionMode === 'serviceworker' && /dark|invert|darkReader/i.test(params.cssTheme) ? "inline" : "none"; +document.getElementById('darkInvert').style.display = /dark|invert|darkReader/i.test(params.cssTheme) ? 'inline' : 'none'; +document.getElementById('darkDarkReader').style.display = params.contentInjectionMode === 'serviceworker' && /dark|invert|darkReader/i.test(params.cssTheme) ? 'inline' : 'none'; document.getElementById('cssWikiDarkThemeInvertCheck').checked = params.cssTheme == 'invert'; document.getElementById('cssWikiDarkThemeDarkReaderCheck').checked = params.cssTheme == 'darkReader'; document.getElementById('resetDisplayOnResizeCheck').checked = params.resetDisplayOnResize; @@ -248,17 +253,17 @@ document.getElementById('omegaCharTxt').value = params.omegaChar; document.getElementById('titleSearchRange').value = params.maxSearchResultsSize; document.getElementById('titleSearchRangeVal').innerHTML = params.maxSearchResultsSize; document.getElementById('hideToolbarsCheck').checked = params.hideToolbars === true; // Will be false if false or 'top' -document.getElementById('hideToolbarsCheck').indeterminate = params.hideToolbars === "top"; -document.getElementById('hideToolbarsCheck').readOnly = params.hideToolbars === "top"; -document.getElementById('hideToolbarsState').innerHTML = (params.hideToolbars === "top" ? "top" : params.hideToolbars ? "both" : "never"); +document.getElementById('hideToolbarsCheck').indeterminate = params.hideToolbars === 'top'; +document.getElementById('hideToolbarsCheck').readOnly = params.hideToolbars === 'top'; +document.getElementById('hideToolbarsState').innerHTML = (params.hideToolbars === 'top' ? 'top' : params.hideToolbars ? 'both' : 'never'); document.getElementById('openExternalLinksInNewTabsCheck').checked = params.openExternalLinksInNewTabs; document.getElementById('disableDragAndDropCheck').checked = params.disableDragAndDrop; document.getElementById('debugLibzimASMDrop').value = params.debugLibzimASM || ''; if (params.windowOpener === null) { // Setting has never been activated, so determine a sensible default - params.windowOpener = /UWP/.test(params.appType) && params.contentInjectionMode === 'jquery' ? false : + params.windowOpener = /UWP/.test(params.appType) && params.contentInjectionMode === 'jquery' ? false : /iOS/.test(params.appType) ? false : ('MSBlobBuilder' in window || params.PWAInstalled) ? 'window' : // IE11/Edge Legacy/UWP work best in window mode, not in tab mode, as does installed PWA! - /PWA/.test(params.appType) ? 'tab' : false; + /PWA/.test(params.appType) ? 'tab' : false; } if (params.windowOpener) params.allowHTMLExtraction = false; document.getElementById('allowHTMLExtractionCheck').checked = params.allowHTMLExtraction; @@ -302,7 +307,7 @@ if (params.packagedFileStub && params.appVersion !== getSetting('appVersion') && deleteSetting('listOfArchives'); params.localStorageUpgradeNeeded = true; } -if (params.storedFile && typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') { //UWP +if (params.storedFile && typeof Windows !== 'undefined' && typeof Windows.Storage !== 'undefined') { // UWP var futureAccessList = Windows.Storage.AccessCache.StorageApplicationPermissions.futureAccessList; Windows.ApplicationModel.Package.current.installedLocation.getFolderAsync(params.archivePath).done(function (appFolder) { params.localStorage = appFolder; @@ -310,13 +315,13 @@ if (params.storedFile && typeof Windows !== 'undefined' && typeof Windows.Storag futureAccessList.getFolderAsync(params.falFolderToken).done(function (pickedFolder) { params.pickedFolder = params.localStorageUpgradeNeeded ? params.localStorage : pickedFolder; }, function (err) { - console.error("The previously picked folder is no longer accessible: " + err.message); + console.error('The previously picked folder is no longer accessible: ' + err.message); }); } }, function (err) { - console.error("This app doesn't appear to have access to local storage!"); + console.error(new Error("This app doesn't appear to have access to local storage!")); }); - //If we don't already have a picked file (e.g. by launching app with click on a ZIM file), then retrieve it from futureAccessList if possible + // If we don't already have a picked file (e.g. by launching app with click on a ZIM file), then retrieve it from futureAccessList if possible var listOfArchives = getSetting('listOfArchives'); // But don't get the picked file if we already have access to the folder and the file is in it! if (listOfArchives && ~listOfArchives.indexOf(params.storedFile) && params.pickedFolder) { @@ -327,7 +332,7 @@ if (params.storedFile && typeof Windows !== 'undefined' && typeof Windows.Storag futureAccessList.getFileAsync(params.falFileToken).done(function (file) { if (file.name === params.storedFile) params.pickedFile = file; }, function (err) { - console.error("The previously picked file is no longer accessible: " + err.message); + console.error('The previously picked file is no longer accessible: ' + err.message); }); } } @@ -412,7 +417,7 @@ function getSetting(name) { // Use localStorage instead result = localStorage.getItem(params.keyPrefix + name); } - return result === null || result === "undefined" ? null : result === "true" ? true : result === "false" ? false : result; + return result === null || result === 'undefined' ? null : result === 'true' ? true : result === 'false' ? false : result; } function setSetting(name, val) { diff --git a/www/js/lib/cache.js b/www/js/lib/cache.js index e097ef7f..44313829 100644 --- a/www/js/lib/cache.js +++ b/www/js/lib/cache.js @@ -1,71 +1,73 @@ /** * cache.js : Provide a cache for assets from the ZIM archive using indexedDB, localStorage or memory cache - * + * * Copyright 2018 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 */ +/* globals params, caches, assetsCache */ + 'use strict'; import settingsStore from './settingsStore.js'; import uiUtil from './uiUtil.js'; -const CACHEAPI = 'kiwixjs-assetsCache'; // Set the database or cache name here, and synchronize with Service Worker -const CACHEIDB = 'kiwix-assetsCache'; // Slightly different name to disambiguate +const CACHEAPI = params.cacheAPI; // Set the database or cache name here, and synchronize with Service Worker +const CACHEIDB = params.cacheIDB; // Slightly different name to disambiguate var objStore = 'kiwix-assets'; // Name of the object store const APPCACHE = 'kiwix-appCache-' + params.appVersion; // Ensure this is the same as in Service Worker // DEV: Regex below defines the permitted MIME types for the cache; add further types as needed var regexpMimeTypes = /\b(?:javascript|css|ico|html)\b/; -/** +/** * Tests the enviornment's caching capabilities and sets assetsCache.capability to the supported level - * + * * @param {Function} callback Function to indicate that the capability level has been set */ -function test(callback) { +function test (callback) { // Test for indexedDB capability if (typeof assetsCache.capability !== 'undefined') { callback(true); return; } // Set baseline capability - assetsCache.capability = 'memory'; - idxDB('count', function(result) { + assetsCache.capability = 'memory'; + idxDB('count', function (result) { if (result !== false) { assetsCache.capability = 'indexedDB|' + assetsCache.capability; } else { - console.log("inexedDB is not supported"); + console.log('inexedDB is not supported'); } // Test for Cache API - if('caches' in window && /https?:/i.test(window.location.protocol)) { + if ('caches' in window && /https?:/i.test(window.location.protocol)) { assetsCache.capability = 'cacheAPI|' + assetsCache.capability; } else { - console.log('CacheAPI is not supported' + (/https?:/i.test(window.location.protocol) ? '' : - ' with the ' + window.location.protocol + ' protocol')); + console.log('CacheAPI is not supported' + (/https?:/i.test(window.location.protocol) ? '' + : ' with the ' + window.location.protocol + ' protocol')); } // Test for localCache capability (this is a fallback, indexedDB is preferred because it permits more storage) - if (typeof Storage !== "undefined") { + if (typeof Storage !== 'undefined') { try { // If localStorage is really supported, this won't produce an error var item = window.localStorage.length; assetsCache.capability = assetsCache.capability + '|localStorage'; } catch (err) { - console.log("localStorage is not supported"); + console.log('localStorage is not supported'); } } console.log('Setting storage type to ' + assetsCache.capability.match(/^[^|]+/)[0]); @@ -78,54 +80,56 @@ function test(callback) { /** * Counts the numnber of cached assets - * + * * @param {Function} callback which will receive an array containing [cacheType, cacheCount] */ -function count(callback) { - test(function(result) { +function count (callback) { + test(function (result) { var type = null; var description = null; var cacheCount = null; + switch (assetsCache.capability.match(/^[^|]+/)[0]) { - case 'memory': - type = 'memory'; - description = 'Memory'; - cacheCount = assetsCache.size; - break; - case 'localStorage': - type = 'localStorage'; - description = 'LocalStorage'; - cacheCount = localStorage.length; - break; - case 'indexedDB': - type = 'indexedDB'; - description = 'IndexedDB'; - // Sometimes we already have the count as a result of test, so no need to look again - if (typeof result !== 'boolean' && (result === 0 || result > 0)) { - cacheCount = result; - } else { - idxDB('count', function(cacheCount) { - callback({'type': type, 'description': description, 'count': cacheCount}); - }); - } - break; - case 'cacheAPI': - type = 'cacheAPI'; - description = 'CacheAPI'; - caches.open(CACHEAPI).then(function (cache) { - cache.keys().then(function (keys) { - callback({'type': type, 'description': description, 'count': keys.length}); - }); + case 'memory': + type = 'memory'; + description = 'Memory'; + cacheCount = assetsCache.size; + break; + case 'localStorage': + type = 'localStorage'; + description = 'LocalStorage'; + cacheCount = localStorage.length; + break; + case 'indexedDB': + type = 'indexedDB'; + description = 'IndexedDB'; + // Sometimes we already have the count as a result of test, so no need to look again + if (typeof result !== 'boolean' && (result === 0 || result > 0)) { + cacheCount = result; + } else { + idxDB('count', function (cacheCount) { + callback({ type: type, description: description, count: cacheCount }); }); - break; - default: - // User has turned off caching - type = 'none'; - description = 'None'; - cacheCount = 'null'; + } + break; + case 'cacheAPI': + type = 'cacheAPI'; + description = 'CacheAPI'; + caches.open(CACHEAPI).then(function (cache) { + cache.keys().then(function (keys) { + callback({ type: type, description: description, count: keys.length }); + }); + }); + break; + default: + // User has turned off caching + type = 'none'; + description = 'None'; + cacheCount = 'null'; } + if (cacheCount || cacheCount === 0) { - callback({'type': type, 'description': description, 'count': cacheCount}); + callback({ type: type, description: description, count: cacheCount }); } }); // Refresh instructions to Service Worker @@ -133,10 +137,10 @@ function count(callback) { // Create a Message Channel var channel = new MessageChannel(); navigator.serviceWorker.controller.postMessage({ - 'action': { - 'assetsCache': params.assetsCache ? 'enable' : 'disable', - 'appCache': params.appCache ? 'enable' : 'disable', - 'checkCache': window.location.href + action: { + assetsCache: params.assetsCache ? 'enable' : 'disable', + appCache: params.appCache ? 'enable' : 'disable', + checkCache: window.location.href } }, [channel.port2]); } @@ -145,20 +149,20 @@ function count(callback) { /** * Opens an IndexedDB database and adds or retrieves a key-value pair to it, or performs utility commands * on the database - * + * * @param {String} keyOrCommand The key of the value to be written or read, or commands 'clear' (clears objStore), * 'count' (counts number of objects in objStore), 'delete' (deletes a record with key passed in valueOrCallback), - * 'deleteNonCurrent' (deletes all databases that do not match CACHEIDB - but only works in Chromium currently) + * 'deleteNonCurrent' (deletes all databases that do not match CACHEIDB - but only works in Chromium currently) * @param {Variable} valueOrCallback The value to write, or a callback function for read and command transactions - * @param {Function} callback Callback for write transactions only + * @param {Function} callback Callback for write transactions only - mandatory for delete and write transactions */ -function idxDB(keyOrCommand, valueOrCallback, callback) { +function idxDB (keyOrCommand, valueOrCallback, callback) { var value = callback ? valueOrCallback : null; var rtnFn = callback || valueOrCallback; if (typeof window.indexedDB === 'undefined') { rtnFn(false); return; - } + } // Delete all non-curren IdxDB databases (only works in Chromium currently) if (keyOrCommand === 'deleteNonCurrent') { @@ -179,11 +183,11 @@ function idxDB(keyOrCommand, valueOrCallback, callback) { } return; } - + // Open (or create) the database var open = indexedDB.open(CACHEIDB, 1); - open.onerror = function(e) { + open.onerror = function (e) { // Suppress error reporting if testing (older versions of Firefox support indexedDB but cannot use it with // the file:// protocol, so will report an error) if (assetsCache.capability !== 'test') { @@ -191,21 +195,21 @@ function idxDB(keyOrCommand, valueOrCallback, callback) { } rtnFn(false); }; - + // Create the schema - open.onupgradeneeded = function() { + open.onupgradeneeded = function () { var db = open.result; var store = db.createObjectStore(objStore); }; - open.onsuccess = function() { + open.onsuccess = function () { // Start a new transaction var db = open.result; - + // Set the store to readwrite or read only according to presence or not of value variable - var tx = value !== null || keyOrCommand === 'clear' ? db.transaction(objStore, "readwrite") : db.transaction(objStore); + var tx = value !== null || /clear|delete/.test(keyOrCommand) ? db.transaction(objStore, 'readwrite') : db.transaction(objStore); var store = tx.objectStore(objStore); - + var processData; // Process commands if (keyOrCommand === 'clear') { @@ -222,20 +226,20 @@ function idxDB(keyOrCommand, valueOrCallback, callback) { processData = value !== null ? store.put(value, keyOrCommand) : store.get(keyOrCommand); } // Call the callback with the result - processData.onsuccess = function(e) { + processData.onsuccess = function (e) { if (keyOrCommand === 'delete') { rtnFn(true); } else { rtnFn(processData.result); } }; - processData.onerror = function(e){ + processData.onerror = function (e) { console.error('IndexedDB command failed: ' + processData.error); rtnFn(false); }; // Close the db when the transaction is done - tx.oncomplete = function() { + tx.oncomplete = function () { db.close(); }; }; @@ -245,42 +249,42 @@ function idxDB(keyOrCommand, valueOrCallback, callback) { * Opens a CacheAPI cache and adds or retrieves a key-value pair to it, or performs utility commands * on the cache. This interface also allows the use of callbacks inside the Cache Promise API for ease of * interoperability with the interface for idxDB code above. - * + * * @param {String} keyOrCommand The key of the value to be written or read, or commands 'clear' (clears cache), - * 'delete' (deletes a record with key passed in valueOrCallback) + * 'delete' (deletes a record with key passed in valueOrCallback) * @param {Variable} valueOrCallback The value to write, or a callback function for read and command transactions * @param {Function} callback Callback for write transactions only * @param {String} mimetype The MIME type of any content to be stored */ -function cacheAPI(keyOrCommand, valueOrCallback, callback, mimetype) { +function cacheAPI (keyOrCommand, valueOrCallback, callback, mimetype) { var value = callback ? valueOrCallback : null; var rtnFn = callback || valueOrCallback; // Process commands if (keyOrCommand === 'clear') { caches.delete(CACHEAPI).then(rtnFn); } else if (keyOrCommand === 'delete') { - caches.open(CACHEAPI).then(function(cache) { + caches.open(CACHEAPI).then(function (cache) { cache.delete(value).then(rtnFn); }); } else if (value === null) { // Request retrieval of data - caches.open(CACHEAPI).then(function(cache) { - cache.match('../' + keyOrCommand).then(function(response) { + caches.open(CACHEAPI).then(function (cache) { + cache.match('../' + keyOrCommand).then(function (response) { if (!response) { rtnFn(null); } else { - response.text().then(function(data) { + response.text().then(function (data) { rtnFn(data); }); } - }).catch(function(err) { + }).catch(function (err) { console.error('Unable to match assets from Cache API!', err); rtnFn(null); }); }); } else { // Request storing of data in cache - caches.open(CACHEAPI).then(function(cache) { + caches.open(CACHEAPI).then(function (cache) { var contentLength; if (typeof value === 'string') { var m = encodeURIComponent(value).match(/%[89ABab]/g); @@ -300,9 +304,9 @@ function cacheAPI(keyOrCommand, valueOrCallback, callback, mimetype) { headers: headers }; var httpResponse = new Response(value, responseInit); - cache.put('../' + keyOrCommand, httpResponse).then(function() { + cache.put('../' + keyOrCommand, httpResponse).then(function () { rtnFn(true); - }).catch(function(err) { + }).catch(function (err) { console.error('Unable to store assets in Cache API!', err); rtnFn(null); }); @@ -312,20 +316,20 @@ function cacheAPI(keyOrCommand, valueOrCallback, callback, mimetype) { /** * Stores information about the last visited page in a cookie and, if available, in localStorage or indexedDB - * + * * @param {String} zimFile The filename (or name of first file in set) of the ZIM archive * @param {String} article The URL of the article (including namespace) * @param {String} content The content of the page to be stored * @param {Function} callback Callback function to report the outcome of the operation */ -function setArticle(zimFile, article, content, callback) { +function setArticle (zimFile, article, content, callback) { // Prevent storage if user has deselected the option in Configuration if (!params.rememberLastPage) { callback(-1); return; } settingsStore.setItem(zimFile, article, Infinity); - setItem(zimFile, content, 'text/html', function(response) { + setItem(zimFile, content, 'text/html', function (response) { callback(response); }); } @@ -333,12 +337,12 @@ function setArticle(zimFile, article, content, callback) { /** * Retrieves article contents from cache only if the article's key has been stored in settings store * (since checking the store is synchronous, it prevents unnecessary async cache lookups) - * + * * @param {String} zimFile The filename (or name of first file in set) of the ZIM archive * @param {String} article The URL of the article to be retrieved (including namespace) * @param {Function} callback The function to call with the result */ -function getArticle(zimFile, article, callback) { +function getArticle (zimFile, article, callback) { if (settingsStore.getItem(zimFile) === article) { getItem(zimFile, callback); } else { @@ -348,21 +352,21 @@ function getArticle(zimFile, article, callback) { /** * Caches the contents of an asset in memory or local storage - * + * * @param {String} key The database key of the asset to cache * @param {String} contents The file contents to be stored in the cache * @param {String} mimetype The MIME type of the contents * @param {Function} callback Callback function to report outcome of operation * @param {Boolean} isAsset Optional indicator that a file is an asset */ -function setItem(key, contents, mimetype, callback, isAsset) { +function setItem (key, contents, mimetype, callback, isAsset) { // Prevent use of storage if user has deselected the option in Configuration // or if the asset is of the wrong type if (params.assetsCache === false || !regexpMimeTypes.test(mimetype)) { callback(-1); return; } - // Check if we're actually setting an article + // Check if we're actually setting an article var keyArticle = key.match(/([^/]+)\/([AC]\/.+$)/); if (keyArticle && !isAsset && /\bx?html\b/i.test(mimetype) && !/\.(png|gif|jpe?g|css|js|mpe?g|webp|webm|woff2?|eot|mp[43])(\?|$)/i.test(key)) { // We're setting an article, so go to setArticle function setArticle(keyArticle[1], keyArticle[2], contents, callback); @@ -374,11 +378,11 @@ function setItem(key, contents, mimetype, callback, isAsset) { assetsCache.set(key, contents); } if (/^indexedDB/.test(assetsCache.capability)) { - idxDB(key, contents, function(result) { + idxDB(key, contents, function (result) { callback(result); }); } else if (/^cacheAPI/.test(assetsCache.capability)) { - cacheAPI(key, contents, function(result) { + cacheAPI(key, contents, function (result) { callback(result); }, mimetype); } else { @@ -387,19 +391,19 @@ function setItem(key, contents, mimetype, callback, isAsset) { } /** - * Retrieves a ZIM file asset that has been cached with the addItem function + * Retrieves a ZIM file asset that has been cached with the addItem function * either from the memory cache or local storage - * + * * @param {String} key The database key of the asset to retrieve * @param {Function} callback The function to call with the result */ -function getItem(key, callback) { +function getItem (key, callback) { // Only look up assets of the type stored in the cache if (params.assetsCache === false) { callback(false); return; } - // Check if we're actually calling an article + // Check if we're actually calling an article // DEV: With new ZIM types, we can't know we're retrieving an article... // var keyArticle = key.match(/([^/]+)\/(A\/.+$)/); // if (keyArticle) { // We're retrieving an article, so go to getArticle function @@ -414,11 +418,11 @@ function getItem(key, callback) { contents = localStorage.getItem(key); callback(contents); } else if (/^cacheAPI/.test(assetsCache.capability)) { - cacheAPI(key, function(contents) { - callback(contents); + cacheAPI(key, function (contents) { + callback(contents); }); } else if (/^indexedDB/.test(assetsCache.capability)) { - idxDB(key, function(contents) { + idxDB(key, function (contents) { if (typeof contents !== 'undefined') { // Also store in fast memory cache to prevent repaints assetsCache.set(key, contents); @@ -427,20 +431,20 @@ function getItem(key, callback) { }); } else { callback(contents); - } + } } /** * Gets an item from the cache, or extracts it from the ZIM if it is not cached. After extracting * an item from the ZIM, it is added to the cache if it is of the type specified in regexpKeyTypes. - * - * @param {Object} selectedArchive The ZIM archive picked by the user - * @param {String} key The cache key of the item to retrieve + * + * @param {Object} selectedArchive The ZIM archive picked by the user + * @param {String} key The cache key of the item to retrieve * @param {Object} dirEntry If the item's dirEntry has already been looked up, it can optionally be * supplied here (saves a redundant dirEntry lookup) * @returns {Promise} A Promise for the content */ -function getItemFromCacheOrZIM(selectedArchive, key, dirEntry) { +function getItemFromCacheOrZIM (selectedArchive, key, dirEntry) { return new Promise(function (resolve, reject) { // First check if the item is already in the cache var title = key.replace(/^[^/]+\//, ''); @@ -457,33 +461,33 @@ function getItemFromCacheOrZIM(selectedArchive, key, dirEntry) { return; } // Bypass getting dirEntry if we already have it - var getDirEntry = dirEntry ? Promise.resolve() : - selectedArchive.getDirEntryByPath(title); + var getDirEntry = dirEntry ? Promise.resolve() + : selectedArchive.getDirEntryByPath(title); // Read data from ZIM getDirEntry.then(function (resolvedDirEntry) { if (dirEntry) resolvedDirEntry = dirEntry; if (resolvedDirEntry === null) { - console.log("Error: asset file not found: " + title); + console.log('Error: asset file not found: ' + title); resolve(null); } else { var mimetype = resolvedDirEntry.getMimetype(); if (resolvedDirEntry.nullify) { console.debug('Zimit filter prevented access to ' + resolvedDirEntry.url + '. Storing empty contents in cache.'); setItem(key, '', mimetype, function () {}); - resolve (''); + resolve(''); return; } var shortTitle = key.replace(/[^/]+\//g, '').substring(0, 18); // Since there was no result, post UI messages and look up asset in ZIM - if (/\bx?html\b/.test(mimetype) && !resolvedDirEntry.isAsset && + if (/\bx?html\b/.test(mimetype) && !resolvedDirEntry.isAsset && !/\.(png|gif|jpe?g|svg|css|js|mpe?g|webp|webm|woff2?|eot|mp[43])(\?|$)/i.test(resolvedDirEntry.url)) { uiUtil.pollSpinner('Loading ' + shortTitle + '...'); } else if (/(css|javascript|video|vtt)/i.test(mimetype)) { uiUtil.pollSpinner('Getting ' + shortTitle + '...'); } // Set the read function to use according to filetype - var readFile = /\b(?:x?html|css|javascript)\b/i.test(mimetype) ? - selectedArchive.readUtf8File : selectedArchive.readBinaryFile; + var readFile = /\b(?:x?html|css|javascript)\b/i.test(mimetype) + ? selectedArchive.readUtf8File : selectedArchive.readBinaryFile; readFile(resolvedDirEntry, function (fileDirEntry, content) { if (regexpMimeTypes.test(mimetype)) { console.debug('Cache retrieved ' + title + ' from ZIM'); @@ -491,7 +495,7 @@ function getItemFromCacheOrZIM(selectedArchive, key, dirEntry) { content = transform(content, title.replace(/^.*\.([^.]+)$/, '$1')); } // Hide article while it is rendering - if (!fileDirEntry.isAsset && /\bx?html\b/i.test(mimetype) && !/\.(png|gif|jpe?g|svg|css|js|mpe?g|webp|webm|woff2?|eot|mp[34])(\?|$)/i.test(key)) { + if (!fileDirEntry.isAsset && /\bx?html\b/i.test(mimetype) && !/\.(png|gif|jpe?g|svg|css|js|mpe?g|webp|webm|woff2?|eot|mp[34])(\?|$)/i.test(key)) { // Count CSS so we can attempt to show article before JS/images are fully loaded var cssCount = content.match(/<(?:link)[^>]+?href=["']([^"']+)[^>]+>/ig); assetsCache.cssLoading = cssCount ? cssCount.length : 0; @@ -516,7 +520,7 @@ function getItemFromCacheOrZIM(selectedArchive, key, dirEntry) { }); } }).catch(function (e) { - reject("could not find DirEntry for asset : " + title, e); + reject('could not find DirEntry for asset : ' + title, e); }); }); }); @@ -524,15 +528,15 @@ function getItemFromCacheOrZIM(selectedArchive, key, dirEntry) { /** * Clears caches (including cookie) according to the scope represented by the 'items' variable - * - * @param {String} items Either 'lastpages' (last visited pages of various archives) or 'all' + * + * @param {String} items 'lastpages' (last visited pages of various archives), 'all' or 'reset' * @param {Function} callback Callback function to report the number of items cleared */ -function clear(items, callback) { +function clear (items, callback) { if (!/lastpages|all|reset/.test(items)) { if (callback) callback(false); return; - } + } // Delete cookie entries with a key containing '.zim' or '.zimaa' etc. followed by article namespace var itemsCount = 0; var key; @@ -552,10 +556,10 @@ function clear(items, callback) { localStorage.removeItem(key); } if (/indexedDB/.test(capability)) { - idxDB('delete', key, function(){}); + idxDB('delete', key, function () {}); } if (/cacheAPI/.test(capability)) { - cacheAPI('delete', key, function(){}); + cacheAPI('delete', key, function () {}); } itemsCount++; } @@ -566,7 +570,7 @@ function clear(items, callback) { var result; if (/^(memory|indexedDB|cacheAPI)/.test(capability)) { itemsCount += assetsCache.size; - result = "assetsCache"; + result = 'assetsCache'; } // Delete and reinitialize assetsCache assetsCache = new Map(); @@ -578,43 +582,43 @@ function clear(items, callback) { localStorage.clear(); } else { for (var i = localStorage.length; i--;) { - var key = localStorage.key(i); + key = localStorage.key(i); if (/\.zim\w{0,2}/i.test(key)) { localStorage.removeItem(key); itemsCount++; } } } - result = result ? result + " and localStorage" : "localStorage"; + result = result ? result + ' and localStorage' : 'localStorage'; } // Loose test here ensures we clear indexedDB even if it wasn't being used in this session if (/indexedDB/.test(capability)) { - result = result ? result + " and indexedDB" : "indexedDB"; - idxDB('count', function(number) { + result = result ? result + ' and indexedDB' : 'indexedDB'; + idxDB('count', function (number) { itemsCount += number; - idxDB('clear', function() { - result = result ? result + " (" + itemsCount + " items deleted)" : "no assets to delete"; - console.log("cache.clear: " + result); + idxDB('clear', function () { + result = result ? result + ' (' + itemsCount + ' items deleted)' : 'no assets to delete'; + console.log('cache.clear: ' + result); if (!/^cacheAPI/.test(capability) && callback) callback(itemsCount); }); }); } // No need to use loose test here because cacheAPI trumps the others if (/^cacheAPI/.test(capability)) { - result = result ? result + " and cacheAPI" : "cacheAPI"; - count(function(number) { + result = result ? result + ' and cacheAPI' : 'cacheAPI'; + count(function (number) { itemsCount += number[1]; - cacheAPI('clear', function() { - result = result ? result + " (" + itemsCount + " items deleted)" : "no assets to delete"; - console.log("cache.clear: " + result); + cacheAPI('clear', function () { + result = result ? result + ' (' + itemsCount + ' items deleted)' : 'no assets to delete'; + console.log('cache.clear: ' + result); if (callback) callback(itemsCount); }); }); } } if (!/^cacheAPI|indexedDB/.test(capability)) { - result = result ? result + " (" + itemsCount + " items deleted)" : "no assets to delete"; - console.log("cache.clear: " + result); + result = result ? result + ' (' + itemsCount + ' items deleted)' : 'no assets to delete'; + console.log('cache.clear: ' + result); if (callback) callback(itemsCount); } } @@ -624,7 +628,7 @@ function clear(items, callback) { * from the cache entries corresponding to the given zimFile * Function is intended for link or script tags, but could be extended * Returns the substituted html in the callback function (even if no substitutions were made) - * + * * @param {String} html The html string to process * @param {String} tags The html tag or tags ('link|script') containing the asset to replace; * multiple tags must be separated with a pipe @@ -633,13 +637,13 @@ function clear(items, callback) { * @param {Object} selectedArchive The archive selected by the user in app.js * @param {Function} callback The function to call with the substituted html */ -function replaceAssetRefsWithUri(html, tags, attribute, zimFile, selectedArchive, callback) { +function replaceAssetRefsWithUri (html, tags, attribute, zimFile, selectedArchive, callback) { // Creates an array of all link tags that have the given attribute var regexpTagsWithAttribute = new RegExp('<(?:' + tags + ')[^>]+?' + attribute + '=["\']([^"\']+)[^>]+>', 'ig'); var titles = []; var tagArray = regexpTagsWithAttribute.exec(html); while (tagArray !== null) { - titles.push([tagArray[0], + titles.push([tagArray[0], decodeURIComponent(tagArray[1])]); tagArray = regexpTagsWithAttribute.exec(html); } @@ -649,8 +653,8 @@ function replaceAssetRefsWithUri(html, tags, attribute, zimFile, selectedArchive // Iterate through the erray of titles, populating the HTML string with substituted tags containing // a reference to the content from the Cache or from the ZIM assetsCache.busy = titles.length; - titles.forEach(function(title) { - getItemFromCacheOrZIM(selectedArchive, zimFile + '/' + title[1], function(assetContent) { + titles.forEach(function (title) { + getItemFromCacheOrZIM(selectedArchive, zimFile + '/' + title[1], function (assetContent) { assetsCache.busy--; if (assetContent || assetContent === '') { var newAssetTag = uiUtil.createNewAssetElement(title[0], attribute, assetContent); @@ -665,61 +669,60 @@ function replaceAssetRefsWithUri(html, tags, attribute, zimFile, selectedArchive * Provides "Server Side" transformation of textual content "served" to app.js * For performance reasons, this is only hooked into content extracted from the ZIM: the transformed * content will then be cached in its transformed state - * + * * @param {String} string The string to transform * @param {String} filter An optional filter: only transforms which match the filter will be executed * @returns {String} The tranformed content */ -function transform(string, filter) { - switch(filter) { - case 'html': +function transform (string, filter) { + switch (filter) { + case 'html': + // Filter to remove any BOM (causes quirks mode in browser) + string = string.replace(/^[^<]*/, ''); - // Filter to remove any BOM (causes quirks mode in browser) - string = string.replace(/^[^<]*/, ''); + // Filter to open all heading sections + string = string.replace(/(class=["'][^"']*?collapsible-(?:heading|block)(?!\s+open-block))/g, + '$1 open-block'); - // Filter to open all heading sections - string = string.replace(/(class=["'][^"']*?collapsible-(?:heading|block)(?!\s+open-block))/g, - '$1 open-block'); - break; } return string; } /** - * Provide - * - * @param {Object} fileHandle The file handle that we wish to verify with the Native Filesystem API + * Provide method to verify File System Access API permissions + * + * @param {Object} fileHandle The file handle that we wish to verify with the Native Filesystem API * @param {Boolean} withWrite Indicates read only or read/write persmissions * @returns {Promise} A Promise for a Boolean value indicating whether permission has been granted or not */ -function verifyPermission(fileHandle, withWrite) { +function verifyPermission (fileHandle, withWrite) { // if (window.fs) return Promise.resolve(true); // Electron var opts = withWrite ? { mode: 'readwrite' } : {}; - return fileHandle.queryPermission(opts).then(function(permission) { - if (permission === "granted") return true; - return fileHandle.requestPermission(opts).then(function(permission) { + return fileHandle.queryPermission(opts).then(function (permission) { + if (permission === 'granted') return true; + return fileHandle.requestPermission(opts).then(function (permission) { if (permission === 'granted') return true; console.error('Permission for ' + fileHandle.name + ' was not granted: ' + permission); return false; - }).catch(function(error) { + }).catch(function (error) { console.warn('Cannot use previously picked file handle programmatically (this is normal) ' + fileHandle.name, error); }); - }); + }); } -/** - * Wraps a semaphor in a Promise. A function can signal that it is done by setting a sempahor to true, +/** + * Wraps a semaphor in a Promise. A function can signal that it is done by setting a sempahor to true, * if it has first set it to false at the outset of the procedure. Ensure no other functions use the same - * sempahor. The semaphor must be an object key of the app-wide assetsCache object. - * + * sempahor. The semaphor must be an object key of the app-wide assetsCache object. + * * @param {String} semaphor The name of a semaphor key in the assetsCache object * @param {String|Object} value An optional value or object to pass in the resolved promise - * @returns {Promise} A promise that resolves when assetsCache[semaphor] is true + * @returns {Promise} A promise that resolves when assetsCache[semaphor] is true */ -function wait(semaphor, value) { +function wait (semaphor, value) { var p = new Promise(function (resolve) { - setTimeout(function awaitCache() { + setTimeout(function awaitCache () { if (assetsCache[semaphor]) { return resolve(value); } diff --git a/www/js/lib/settingsStore.js b/www/js/lib/settingsStore.js index 23d30484..ed4bee34 100644 --- a/www/js/lib/settingsStore.js +++ b/www/js/lib/settingsStore.js @@ -2,7 +2,6 @@ /* global define, params */ -import cache from './cache.js'; import uiUtil from './uiUtil.js'; var regexpCookieKeysToMigrate = new RegExp([ @@ -102,7 +101,8 @@ function reset(object) { // 3. Clear any IndexedDB entries if (!object || object === 'indexedDB') { if (/indexedDB/.test(assetsCache.capability)) { - cache.clear('reset'); + window.indexedDB.deleteDatabase(params.indexedDB); + console.debug('All IndexedDB entries were deleted...'); } } diff --git a/www/js/lib/util.js b/www/js/lib/util.js index 37c3f0bd..968a8526 100644 --- a/www/js/lib/util.js +++ b/www/js/lib/util.js @@ -20,7 +20,7 @@ * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ -/* global fs */ +/* global fs, params */ 'use strict'; @@ -273,6 +273,14 @@ function dataURItoUint8Array (dataURI) { } } +/** + * Detect whether the browser supports the webkitdirectory attribute on input elements + * @returns {Boolean} True if the webkitdirectory attribute is supported, false otherwise + */ +function webkitdirectorySupported () { + return 'webkitdirectory' in document.createElement('input') && !/iOS|Android/.test(params.appType); +} + /** * Matches the outermost balanced constructs and their contents * even if they have nested balanced constructs within them @@ -729,6 +737,7 @@ export default { leftShift: leftShift, matchOuter: matchOuter, matchInner: matchInner, + webkitdirectorySupported: webkitdirectorySupported, Hilitor: Hilitor, getClosestForward: getClosestForward, getClosestBack: getClosestBack,