/** * abstractFilesystemAccess.js: Abstraction layer for file access. * This is currently only implemented for FirefoxOS and Standard browser (using File System Access API), but could be extended to * Cordova, Electron or other ways to directly browse and read files from the * filesystem. * * Copyright 2014-2023 Kiwix developers and Rishabhg71 * Licence GPL v3: * * This file is part of Kiwix. * * Kiwix is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public Licence as published by * the Free Software Foundation, either version 3 of the Licence, or * (at your option) any later version. * * Kiwix is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public Licence for more details. * * You should have received a copy of the GNU General Public Licence * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ 'use strict'; import cache from './cache.js'; import translateUI from './translateUI.js'; import settingsStore from './settingsStore.js'; function StorageFirefoxOS (storage) { this._storage = storage; this.storageName = storage.storageName; } /** * Access the given file. * @param {String} path absolute path to the file * @return {Promise} Promise which is resolved with a HTML5 file object and * rejected with an error message. */ StorageFirefoxOS.prototype.get = function (path) { var that = this; return new Promise(function (resolve, reject) { var request = that._storage.get(path); request.onsuccess = function () { resolve(this.result); }; request.onerror = function () { reject(this.error.name); }; }); }; // We try to match both a standalone ZIM file (.zim) or // the first file of a split ZIM files collection (.zimaa) var regexpZIMFileName = /\.zim(aa)?$/i; /** * Searches for archive files or directories. * @return {Promise} Promise which is resolved with an array of * paths and rejected with an error message. */ StorageFirefoxOS.prototype.scanForArchives = function () { var that = this; return new Promise(function (resolve, reject) { var directories = []; var cursor = that._storage.enumerate(); cursor.onerror = function () { reject(cursor.error); }; cursor.onsuccess = function () { if (!cursor.result) { resolve(directories); return; } var file = cursor.result; if (regexpZIMFileName.test(file.name)) { directories.push(file.name); } cursor.continue(); }; }); }; /** * Browse a path through DeviceStorage API * @param path Path where to look for files * @return {DOMCursor} Cursor of files found in given path */ // eslint-disable-next-line no-unused-vars StorageFirefoxOS.prototype.enumerate = function (path) { return this._storage.enumerate(); }; // refer to this article for easy explanation of File System API https://developer.chrome.com/articles/file-system-access/ /** * @param {Array} files All the File names to be shown in the dropdown * @param {string} selectedFile The name of the file to be selected in the dropdown * @returns {Array} Array of unique filenames (if a split zim is considered a single file) */ function updateZimDropdownOptions (files, selectedFile) { const isFireFoxOsNativeFileApiAvailable = typeof navigator.getDeviceStorages === 'function'; // This will make sure that there is no race around condition when platform is firefox os // as other function will handle the dropdown UI updates if (isFireFoxOsNativeFileApiAvailable) return // do nothing let other function handle it const select = document.getElementById('archiveList'); const options = []; let count = 0; select.innerHTML = ''; if (files.length !== 0) { const placeholderOption = new Option(translateUI.t('configure-select-file-first-option'), ''); placeholderOption.disabled = true; select.appendChild(placeholderOption); }; // Create a new option for each fileName or for the zimaa part of a split archive files.forEach((fileName) => { if (/\.zim(aa)?$/i.test(fileName)) { options.push(new Option(fileName, fileName)); select.appendChild(new Option(fileName, fileName)); count++; } }); select.value = selectedFile; document.getElementById('numberOfFilesCount').style.display = ''; document.getElementById('fileCountDisplay').style.display = ''; document.getElementById('numberOfFilesCount').innerText = count.toString(); document.getElementById('fileCountDisplay').innerText = translateUI.t('configure-select-file-numbers'); } /** * Opens the File System API to select a directory * @returns {Promise>} Previously selected file if available in selected folder */ async function selectDirectoryFromPickerViaFileSystemApi () { const handle = await window.showDirectoryPicker(); const fileNames = []; const previousZimFile = [] const lastZimName = settingsStore.getItem('previousZimFileName') || ''; const lastZimNameWithoutExtension = lastZimName.replace(/\.zim\w?\w?$/i, ''); // Iterate over all files in directory, store an array of ZIM files, and get the previously selectee ZIM file if it exists for await (const entry of handle.values()) { if (entry.kind === 'file' && /\.zim\w?\w?$/i.test(entry.name)) { fileNames.push(entry.name); if (!entry.name.indexOf(lastZimNameWithoutExtension)) { previousZimFile.push(await entry.getFile()); } } } settingsStore.setItem('zimFilenames', fileNames.join('|'), Infinity); updateZimDropdownOptions(fileNames, previousZimFile.length !== 0 ? lastZimName : ''); cache.idxDB('zimFiles', handle, function () { // save file in DB }); return previousZimFile; } /** * Opens the File System API to select a file * @returns {Promise>} The selected file from picker */ async function selectFileFromPickerViaFileSystemApi () { const fileHandles = await window.showOpenFilePicker({ multiple: false }); const [selectedFile] = fileHandles; const file = await selectedFile.getFile(); const filenameList = [selectedFile.name]; settingsStore.setItem('zimFilenames', filenameList.join('|')); cache.idxDB('zimFiles', selectedFile, function () { // file saved in DB updateZimDropdownOptions(filenameList, selectedFile.name); }); return [file]; } /** * Gets the selected zim file from the IndexedDB * @param {string} selectedFilename The name of the file to get back from DB * @returns {Promise>} The selected File Object from cache */ function getSelectedZimFromCache (selectedFilename) { return new Promise((resolve, reject) => { cache.idxDB('zimFiles', async function (fileOrDirHandle) { if (!fileOrDirHandle) { return reject(new Error('No file or directory selected')); } // Request permission if not already granted if ((await fileOrDirHandle.queryPermission()) !== 'granted') { try { await fileOrDirHandle.requestPermission(); } catch (error) { return reject(new Error('Permission denied', error)); } } if (fileOrDirHandle.kind === 'directory') { const files = []; for await (const entry of fileOrDirHandle.values()) { const filenameWithoutExtension = selectedFilename.replace(/\.zim\w?\w?$/i, ''); // const regex = new RegExp(`\\${filenameWithoutExtension}.zim\\w\\w$`, 'i'); if (!entry.name.indexOf(filenameWithoutExtension)) { files.push(await entry.getFile()); } } resolve(files); } else { const file = await fileOrDirHandle.getFile(); resolve([file]); } }); }); } /** * @typedef {Object.} WebkitFileList */ /** * Gets the selected zim file from the WebkitFileList * * @param {WebkitFileList} fileList The WebkitFileList to get the selected file from * @param {string} filename The name of the file to get back from webkitFileList * @returns {Array} The selected Files Object from webkitFileList */ function getSelectedZimFromWebkitList (fileList, filename) { const filenameWithoutExtension = filename.replace(/\.zim\w?\w?$/i, ''); const files = []; for (const file of fileList) { // If the file.name begins with the filenameWithoutExtension, then it matches (may match mutliple split ZIM files) if (!file.name.indexOf(filenameWithoutExtension)) { files.push(file); } } return files; } /** * Loads the Previously loaded zim filename(s) via local storage */ function loadPreviousZimFile () { // If we call `updateZimDropdownOptions` without any delay it will run before the internationalization is initialized // It's a bit hacky but it works and I am not sure if there is any other way ATM setTimeout(() => { if (window.params.isFileSystemApiSupported || window.params.isWebkitDirApiSupported) { const filenames = settingsStore.getItem('zimFilenames'); if (filenames) updateZimDropdownOptions(filenames.split('|'), ''); } }, 200); } /** * Handles selecting a directory from the webkitdirectory picker, setting the UI and storing the selected files * * @param {WebkitFileList} fileList The list returned from the webkitdirectory picker * @returns An object containing all the ZIM files in the directory and the previously selected ZIM fileset if available */ function selectDirectoryFromPickerViaWebkit (fileList) { const zimFiles = []; const filenames = []; const previousZimFile = [] const lastFilename = settingsStore.getItem('previousZimFileName') || ''; const filenameWithoutExtension = lastFilename.replace(/\.zim\w?\w?$/i, ''); for (const file of fileList) { // Only add ZIM files in top directory to the filenames array. To do this, we must exclude ZIM files that contain more than one '/' in their path. // This is because most browsers using this API will return the full path of the file including the directory it is in (but Chromium < 72 will not return the full path, so // in that browser, we will get some files that are in subdirectories). See MDN webkitdirectory documentation for more information. if (/^[^/]+\/[^/]+\.zim\w?\w?$/i.test(file.webkitRelativePath)) { zimFiles.push(file); filenames.push(file.name); // If the file.name begins with the filenameWithoutExtension... if (filenameWithoutExtension && !file.name.indexOf(filenameWithoutExtension)) { previousZimFile.push(file); } } } settingsStore.setItem('zimFilenames', filenames.join('|'), Infinity); updateZimDropdownOptions(filenames, previousZimFile.length ? lastFilename : ''); return { files: zimFiles, selectedFile: previousZimFile }; } /** * Handles the folder drop event via File System API * @param {DragEvent} packet The DragEvent packet * @returns {Promise} Whether the dropped item is a file or directory */ async function handleFolderOrFileDropViaFileSystemAPI (packet) { if (!window.params.isFileSystemApiSupported) return true; // Only runs when browser support File System API const fileInfo = packet.dataTransfer.items[0]; const fileOrDirHandle = await fileInfo.getAsFileSystemHandle(); if (fileOrDirHandle.kind === 'file') { settingsStore.setItem([fileOrDirHandle.name], [fileOrDirHandle.name].join('|'), Infinity); cache.idxDB('zimFiles', fileOrDirHandle, function () { // save file in DB updateZimDropdownOptions([fileOrDirHandle.name], fileOrDirHandle.name); }); settingsStore.setItem('zimFilenames', [fileOrDirHandle.name].join('|'), Infinity); return true; } if (fileOrDirHandle.kind === 'directory') { const fileNames = []; for await (const entry of fileOrDirHandle.values()) { fileNames.push(entry.name); } settingsStore.setItem('zimFilenames', fileNames.join('|'), Infinity); cache.idxDB('zimFiles', fileOrDirHandle, function () { updateZimDropdownOptions(fileNames, ''); // save file in DB }); return false; } } /** * Handles the folder drop event via WebkitGetAsEntry * @param {DragEvent} event The DragEvent packet * @returns {Promise<{loadZim: boolean, files: Array} | void>} Whether the dropped item is a file or directory and FileList */ async function handleFolderOrFileDropViaWebkit (event) { var dt = event.dataTransfer; var entry = dt.items[0].webkitGetAsEntry(); if (entry.isFile) { settingsStore.setItem('zimFilenames', [entry.name].join('|'), Infinity); updateZimDropdownOptions([entry.name], entry.name); return { loadZim: true, files: [entry.file] }; } else if (entry.isDirectory) { var reader = entry.createReader(); const files = await getFilesFromReader(reader); const fileNames = []; files.forEach(function (file) { fileNames.push(file.name) }); settingsStore.setItem('zimFilenames', fileNames.join('|'), Infinity); updateZimDropdownOptions(fileNames, ''); return { loadZim: false, files: files }; } } /** * Gets the files from the FileSystemReader * @param {FileSystemDirectoryReader} reader The FileSystemReader to get files from * @returns {Promise>} The files from the reader */ async function getFilesFromReader (reader) { const files = []; // eslint-disable-next-line no-unused-vars const promise = new Promise(function (resolve, _reject) { reader.readEntries(function (entries) { resolve(entries); }); }); const entries = await promise; for (let index = 0; index < entries.length; index++) { const fileOrDir = entries[index]; if (fileOrDir.isFile) { // eslint-disable-next-line no-unused-vars const filePromise = await new Promise(function (resolve, _reject) { fileOrDir.file(function (file) { resolve(file); }); }); files.push(filePromise); } } return files; } export default { StorageFirefoxOS: StorageFirefoxOS, updateZimDropdownOptions: updateZimDropdownOptions, selectDirectoryFromPickerViaFileSystemApi: selectDirectoryFromPickerViaFileSystemApi, selectFileFromPickerViaFileSystemApi: selectFileFromPickerViaFileSystemApi, getSelectedZimFromCache: getSelectedZimFromCache, loadPreviousZimFile: loadPreviousZimFile, handleFolderOrFileDropViaWebkit: handleFolderOrFileDropViaWebkit, handleFolderOrFileDropViaFileSystemAPI: handleFolderOrFileDropViaFileSystemAPI, getSelectedZimFromWebkitList: getSelectedZimFromWebkitList, selectDirectoryFromPickerViaWebkit: selectDirectoryFromPickerViaWebkit };