Skip to content

Added global appstate object to track article and asset Loading #1310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
53 changes: 47 additions & 6 deletions www/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@
/**
* A global object for storing app state
*
* @type Object
*/
var appstate = {};
* @type Object */
const appstate = {
isLoadingArticle: false,
isLoadingAsset: false,
loadingMessage: ''
};

/**
* @type ZIMArchive | null
Expand Down Expand Up @@ -126,6 +129,32 @@
uiUtil.applyAppTheme(params.appTheme);
}

// When article starts Loading, update the appstate to show that its loading
// It will add a message to let user's know what's happening

function startLoadingArticle (message) {
appstate.isLoadingArticle = true;
appstate.loadingMessage = message;
uiUtil.spinnerDisplay(true, message);
}
function stopLoadingArticle () {
appstate.isLoadingArticle = false;
appstate.loadingMessage = '';
uiUtil.spinnerDisplay(false);
}

// Similarly for assests
function startLoadingAsset (message) {
appstate.isLoadingAsset = true;
appstate.loadingMessage = message;
uiUtil.spinnerDisplay(true, message);
}
function stopLoadingAsset () {
appstate.isLoadingAsset = false;
appstate.loadingMessage = '';
uiUtil.spinnerDisplay(false);
}

/**
* Resize the IFrame height, so that it fills the whole available height in the window
*/
Expand Down Expand Up @@ -274,56 +303,56 @@
const prefixElement = document.getElementById('prefix');
// Handle keyboard events in the prefix (article search) field
var keyPressHandled = false;
prefixElement.addEventListener('keydown', function (e) {
// If user presses Escape...
// IE11 returns "Esc" and the other browsers "Escape"; regex below matches both
if (/^Esc/.test(e.key)) {
// Hide the article list
e.preventDefault();
e.stopPropagation();
document.getElementById('articleListWithHeader').style.display = 'none';
document.getElementById('articleContent').focus();
keyPressHandled = true;
}
// Arrow-key selection code adapted from https://stackoverflow.com/a/14747926/9727685
// IE11 produces "Down" instead of "ArrowDown" and "Up" instead of "ArrowUp"
if (/^((Arrow)?Down|(Arrow)?Up|Enter)$/.test(e.key)) {
// User pressed Down arrow or Up arrow or Enter
e.preventDefault();
e.stopPropagation();
// This is needed to prevent processing in the keyup event : https://stackoverflow.com/questions/9951274
keyPressHandled = true;
var activeElement = document.querySelector('#articleList .hover') || document.querySelector('#articleList a');
if (!activeElement) return;
// If user presses Enter, read the dirEntry
if (/Enter/.test(e.key)) {
if (activeElement.classList.contains('hover')) {
var dirEntryId = activeElement.getAttribute('dirEntryId');
findDirEntryFromDirEntryIdAndLaunchArticleRead(decodeURIComponent(dirEntryId));
return;
}
}
// If user presses ArrowDown...
// (NB selection is limited to five possibilities by regex above)
if (/Down/.test(e.key)) {
if (activeElement.classList.contains('hover')) {
activeElement.classList.remove('hover');
activeElement = activeElement.nextElementSibling || activeElement;
var nextElement = activeElement.nextElementSibling || activeElement;
if (!uiUtil.isElementInView(nextElement, true)) nextElement.scrollIntoView(false);
}
}
// If user presses ArrowUp...
if (/Up/.test(e.key)) {
activeElement.classList.remove('hover');
activeElement = activeElement.previousElementSibling || activeElement;
var previousElement = activeElement.previousElementSibling || activeElement;
if (!uiUtil.isElementInView(previousElement, true)) previousElement.scrollIntoView();
if (previousElement === activeElement) document.getElementById('top').scrollIntoView();
}
activeElement.classList.add('hover');
}
});

Check notice on line 355 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L306-L355

Complex Method
// Search for titles as user types characters
prefixElement.addEventListener('keyup', function (e) {
if (selectedArchive !== null && selectedArchive.isReady()) {
Expand Down Expand Up @@ -661,76 +690,76 @@
* Verifies the given archive and switches contentInjectionMode accourdingly
* @param {ZIMArchive} archive The archive that needs verification
* */
async function verifyLoadedArchive (archive) {
// We construct an HTML element to show the user the alert with the metadata contained in it
const metadataLabels = {
name: translateUI.t('dialog-metadata-name') || 'Name: ',
creator: translateUI.t('dialog-metadata-creator') || 'Creator: ',
publisher: translateUI.t('dialog-metadata-publisher') || 'Publisher: ',
scraper: translateUI.t('dialog-metadata-scraper') || 'Scraper: '
}

const verificationBody = document.createElement('div');

// Text & metadata box
const verificationText = document.createElement('p');
verificationText.innerHTML = translateUI.t('dialog-sourceverification-alert') || 'Is this ZIM archive from a trusted source?\n If not, you can still read the ZIM file in Restricted Mode. Closing this window also opens the file in Restricted Mode. This option can be disabled in Expert Settings.';

const metadataBox = document.createElement('div');
metadataBox.id = 'modal-archive-metadata-container';

const verifyName = document.createElement('p');
verifyName.id = 'confirm-archive-name';
verifyName.classList.add('archive-metadata');
verifyName.innerText = metadataLabels.name + (archive.name || '-');

const verifyCreator = document.createElement('p');
verifyCreator.id = 'confirm-archive-creator';
verifyCreator.classList.add('archive-metadata')
verifyCreator.innerText = metadataLabels.creator + (archive.creator || '-');

const verifyPublisher = document.createElement('p');
verifyPublisher.id = 'confirm-archive-publisher';
verifyPublisher.classList.add('archive-metadata');
verifyPublisher.innerText = metadataLabels.publisher + (archive.publisher || '-');

const verifyScraper = document.createElement('p');
verifyScraper.id = 'confirm-archive-scraper';
verifyScraper.classList.add('archive-metadata');
verifyScraper.innerText = metadataLabels.scraper + (archive.scraper || '-');

const verifyWarning = document.createElement('p');
verifyWarning.id = 'modal-archive-metadata-warning';
verifyWarning.innerHTML = translateUI.t('dialog-metadata-warning') || 'Warning: above data can be spoofed!';

metadataBox.append(verifyName, verifyCreator, verifyPublisher, verifyScraper);
verificationBody.append(verificationText, metadataBox, verifyWarning);

const response = await uiUtil.systemAlert(
verificationBody.outerHTML,
translateUI.t('dialog-sourceverification-title') || 'Security alert!',
true,
translateUI.t('dialog-sourceverification-restricted-mode-button') || 'Open in Restricted Mode',
translateUI.t('dialog-sourceverification-trust-button') || 'Trust Source'
);

if (response) {
params.contentInjectionMode = 'serviceworker';
var trustedZimFiles = settingsStore.getItem('trustedZimFiles');
var updatedTrustedZimFiles = trustedZimFiles + archive.file.name + '|';
settingsStore.setItem('trustedZimFiles', updatedTrustedZimFiles, Infinity);
// Change radio buttons accordingly
if (params.serviceWorkerLocal) {
document.getElementById('serviceworkerLocalModeRadio').checked = true;
} else {
document.getElementById('serviceworkerModeRadio').checked = true;
}
} else {
// Switch to Restricted mode
params.contentInjectionMode = 'jquery';
document.getElementById('jqueryModeRadio').checked = true;
}
}

Check notice on line 762 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L693-L762

Complex Method
// switch on/off the feature to use Home Key to focus search bar
function switchHomeKeyToFocusSearchBar () {
var iframeContentWindow = document.getElementById('articleContent').contentWindow;
Expand Down Expand Up @@ -796,71 +825,71 @@
*/
function refreshAPIStatus () {
// We have to delay refreshing the API status until the translation service has been initialized
setTimeout(function () {
var apiStatusPanel = document.getElementById('apiStatusDiv');
apiStatusPanel.classList.remove('card-success', 'card-warning', 'card-danger');
var apiPanelClass = 'card-success';
var messageChannelStatus = document.getElementById('messageChannelStatus');
var serviceWorkerStatus = document.getElementById('serviceWorkerStatus');
if (isMessageChannelAvailable()) {
messageChannelStatus.textContent = translateUI.t('api-messagechannel-available') || 'MessageChannel API available';
messageChannelStatus.classList.remove('apiAvailable', 'apiUnavailable');
messageChannelStatus.classList.add('apiAvailable');
} else {
apiPanelClass = 'card-warning';
messageChannelStatus.textContent = translateUI.t('api-messagechannel-unavailable') || 'MessageChannel API unavailable';
messageChannelStatus.classList.remove('apiAvailable', 'apiUnavailable');
messageChannelStatus.classList.add('apiUnavailable');
}
if (isServiceWorkerAvailable()) {
if (isServiceWorkerReady()) {
serviceWorkerStatus.textContent = translateUI.t('api-serviceworker-available-registered') || 'ServiceWorker API available, and registered';
serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable');
serviceWorkerStatus.classList.add('apiAvailable');
} else {
apiPanelClass = 'card-warning';
serviceWorkerStatus.textContent = translateUI.t('api-serviceworker-available-unregistered') || 'ServiceWorker API available, but not registered';
serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable');
serviceWorkerStatus.classList.add('apiUnavailable');
}
} else {
apiPanelClass = 'card-warning';
serviceWorkerStatus.textContent = translateUI.t('api-serviceworker-unavailable') || 'ServiceWorker API unavailable';
serviceWorkerStatus.classList.remove('apiAvailable', 'apiUnavailable');
serviceWorkerStatus.classList.add('apiUnavailable');
}
// Update Settings Store section of API panel with API name
var settingsStoreStatusDiv = document.getElementById('settingsStoreStatus');
var apiName = params.storeType === 'cookie' ? (translateUI.t('api-cookie') || 'Cookie') : params.storeType === 'local_storage' ? (translateUI.t('api-localstorage') || 'Local Storage') : (translateUI.t('api-none') || 'None');
settingsStoreStatusDiv.textContent = (translateUI.t('api-storage-used-label') || 'Settings Storage API in use:') + ' ' + apiName;
settingsStoreStatusDiv.classList.remove('apiAvailable', 'apiUnavailable');
settingsStoreStatusDiv.classList.add(params.storeType === 'none' ? 'apiUnavailable' : 'apiAvailable');
apiPanelClass = params.storeType === 'none' ? 'card-warning' : apiPanelClass;
// Update Decompressor API section of panel
var decompAPIStatusDiv = document.getElementById('decompressorAPIStatus');
apiName = params.decompressorAPI.assemblerMachineType;
apiPanelClass = params.decompressorAPI.errorStatus ? 'card-danger' : apiName === 'WASM' ? apiPanelClass : 'card-warning';
decompAPIStatusDiv.className = apiName ? params.decompressorAPI.errorStatus ? 'apiBroken' : apiName === 'WASM' ? 'apiAvailable' : 'apiSuboptimal' : 'apiUnavailable';
// Add the last used decompressor, if known, to the apiName
if (apiName && params.decompressorAPI.decompressorLastUsed) {
apiName += ' [ ' + params.decompressorAPI.decompressorLastUsed + ' ]';
}
apiName = params.decompressorAPI.errorStatus || apiName || (translateUI.t('api-error-uninitialized_feminine') || 'Not initialized');
// innerHTML is used here because the API name may contain HTML entities like  
decompAPIStatusDiv.innerHTML = (translateUI.t('api-decompressor-label') || 'Decompressor API:') + ' ' + apiName;
// Update Search Provider
uiUtil.reportSearchProviderToAPIStatusPanel(params.searchProvider);
// Update PWA origin
var pwaOriginStatusDiv = document.getElementById('pwaOriginStatus');
pwaOriginStatusDiv.className = 'apiAvailable';
pwaOriginStatusDiv.innerHTML = (translateUI.t('api-pwa-origin-label') || 'PWA Origin:') + ' ' + window.location.origin;
// Add a warning colour to the API Status Panel if any of the above tests failed
apiStatusPanel.classList.add(apiPanelClass);
// Set visibility of UI elements according to mode
document.getElementById('bypassAppCacheDiv').style.display = params.contentInjectionMode === 'serviceworker' ? 'block' : 'none';
// Check to see whether we need to alert the user that we have switched to ServiceWorker mode by default
if (!params.defaultModeChangeAlertDisplayed) checkAndDisplayInjectionModeChangeAlert();
}, 250);

Check notice on line 892 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L828-L892

Complex Method
}

/**
Expand Down Expand Up @@ -1079,187 +1108,187 @@
*
* @param {String} value The chosen content injection mode : 'jquery' or 'serviceworker'
*/
function setContentInjectionMode (value) {
console.debug('Setting content injection mode to', value);
params.oldInjectionMode = params.serviceWorkerLocal ? 'serviceworkerlocal' : params.contentInjectionMode;
params.serviceWorkerLocal = false;
if (value === 'serviceworkerlocal') {
value = 'serviceworker';
params.serviceWorkerLocal = true;
}
params.contentInjectionMode = value;
params.originalContentInjectionMode = null;
var message = '';
if (value === 'jquery') {
if (!params.appCache) {
uiUtil.systemAlert((translateUI.t('dialog-bypassappcache-conflict-message') || 'You must deselect the "Bypass AppCache" option before switching to Restricted mode!'),
(translateUI.t('dialog-bypassappcache-conflict-title') || 'Deselect "Bypass AppCache"')).then(function () {
setContentInjectionMode('serviceworker');
})
return;
}
if (params.referrerExtensionURL) {
// We are in an extension, and the user may wish to revert to local code
message = translateUI.t('dialog-launchlocal-message') || 'This will switch to using locally packaged code only. Some configuration settings may be lost.<br/><br/>' +
'WARNING: After this, you may not be able to switch back to SW mode without an online connection!';
var launchLocal = function () {
settingsStore.setItem('allowInternetAccess', false, Infinity);
var uriParams = '?allowInternetAccess=false&contentInjectionMode=jquery&hideActiveContentWarning=false';
uriParams += '&appTheme=' + params.appTheme;
uriParams += '&showUIAnimations=' + params.showUIAnimations;
window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
console.log('Beam me down, Scotty!');
};
uiUtil.systemAlert(message, (translateUI.t('dialog-launchlocal-title') || 'Warning!'), true).then(function (response) {
if (response) {
launchLocal();
} else {
setContentInjectionMode('serviceworker');
}
});
return;
}
// Because the Service Worker must still run in a PWA app so that it can work offline, we don't actually disable the SW in this context,
// but it will no longer be intercepting requests for ZIM assets (only requests for the app's own code)
if ('serviceWorker' in navigator) {
serviceWorkerRegistration = null;
}
// User has switched to jQuery mode, so no longer needs ASSETS_CACHE
// We should empty it and turn it off to prevent unnecessary space usage
if ('caches' in window && isMessageChannelAvailable()) {
if (isServiceWorkerAvailable() && navigator.serviceWorker.controller) {
var channel = new MessageChannel();
navigator.serviceWorker.controller.postMessage({
action: { assetsCache: 'disable' }
}, [channel.port2]);
}
caches.delete(ASSETS_CACHE);
}
refreshAPIStatus();
} else if (value === 'serviceworker') {
var protocol = window.location.protocol;
// Since Firefox 103, the ServiceWorker API is not available any more in Webextensions. See https://hg.mozilla.org/integration/autoland/rev/3a2907ad88e8 and https://bugzilla.mozilla.org/show_bug.cgi?id=1593931
// Previously, the API was available, but failed to register (which we could trap a few lines below).
// So we now need to suggest a switch to the PWA if we are inside a Firefox Extension and the ServiceWorker API is unavailable.
// Even if some older firefox versions do not support ServiceWorkers at all (versions 42, 43, 45ESR, 52ESR, 60ESR and 68ESR, based on https://caniuse.com/serviceworkers). In this case, the PWA will not work either.
if (/^(moz|chrome)-extension:/.test(protocol) && !params.serviceWorkerLocal) {
launchBrowserExtensionServiceWorker();
} else {
if (!isServiceWorkerAvailable()) {
message = translateUI.t('dialog-launchpwa-unsupported-message') ||
'<p>Unfortunately, your browser does not appear to support ServiceWorker mode, which is now the default for this app.</p>' +
'<p>You can continue to use the app in Restricted mode, but note that this mode only works well with ' +
'ZIM archives that have static content, such as Wikipedia / Wikimedia ZIMs or Stackexchange.</p>' +
'<p>If you can, we recommend that you update your browser to a version that supports ServiceWorker mode.</p>';
if (!params.noPrompts) {
uiUtil.systemAlert(message, (translateUI.t('dialog-launchpwa-unsupported-title') || 'ServiceWorker API not available'), true, null,
(translateUI.t('dialog-serviceworker-unsupported-fallback') || 'Use Restricted mode')).then(function (response) {
if (params.referrerExtensionURL && response) {
var uriParams = '?allowInternetAccess=false&contentInjectionMode=jquery&defaultModeChangeAlertDisplayed=true';
window.location.href = params.referrerExtensionURL + '/www/index.html' + uriParams;
} else {
setContentInjectionMode(params.oldInjectionMode || 'jquery');
}
});
} else {
setContentInjectionMode(params.oldInjectionMode || 'jquery');
}
return;
}
if (!isMessageChannelAvailable()) {
uiUtil.systemAlert((translateUI.t('dialog-messagechannel-unsupported-message') || 'The MessageChannel API is not available on your device. Falling back to Restricted mode...'),
(translateUI.t('dialog-messagechannel-unsupported-title') || 'MessageChannel API not available')).then(function () {
setContentInjectionMode('jquery');
});
return;
}
if (!isServiceWorkerReady()) {
var serviceWorkerStatus = document.getElementById('serviceWorkerStatus');
serviceWorkerStatus.textContent = 'ServiceWorker API available : trying to register it...';
if (navigator.serviceWorker.controller) {
console.log('Active Service Worker found, no need to register');
serviceWorkerRegistration = true;
// Remove any jQuery hooks from a previous jQuery session
var articleContent = document.getElementById('articleContent');
while (articleContent.firstChild) {
articleContent.removeChild(articleContent.firstChild);
}
// Create the MessageChannel and send 'init'
refreshAPIStatus();
} else {
navigator.serviceWorker.register('../service-worker.js').then(function (reg) {
// The ServiceWorker is registered
serviceWorkerRegistration = reg;
// We need to wait for the ServiceWorker to be activated
// before sending the first init message
var serviceWorker = reg.installing || reg.waiting || reg.active;
serviceWorker.addEventListener('statechange', function (statechangeevent) {
if (statechangeevent.target.state === 'activated') {
// Remove any jQuery hooks from a previous jQuery session
var articleContent = document.getElementById('articleContent');
while (articleContent.firstChild) {
articleContent.removeChild(articleContent.firstChild);
}
// We need to refresh cache status here on first activation because SW was inaccessible till now
// We also initialize the ASSETS_CACHE constant in SW here
refreshCacheStatus();
refreshAPIStatus();
}
});
refreshCacheStatus();
refreshAPIStatus();
}).catch(function (err) {
if (protocol === 'moz-extension:') {
// This is still useful for Firefox<103 extensions, where the ServiceWorker API is available, but fails to register
launchBrowserExtensionServiceWorker();
} else {
console.error('Error while registering serviceWorker', err);
refreshAPIStatus();
var message = (translateUI.t('dialog-serviceworker-registration-failure-message') || 'The Service Worker could not be properly registered. Switching back to Restricted mode... Error message:') + ' ' + err;
if (protocol === 'file:') {
message += (translateUI.t('dialog-serviceworker-registration-failure-fileprotocol') ||
'<br/><br/>You seem to be opening kiwix-js with the file:// protocol. You should open it through a web server: either through a local one (http://localhost/...) or through a remote one (but you need a secure connection: https://webserver.org/...)');
}
appstate.preventAutoReboot = true;
if (!params.noPrompts) {
uiUtil.systemAlert(message, (translateUI.t('dialog-serviceworker-registration-failure-title') || 'Failed to register Service Worker')).then(function () {
setContentInjectionMode('jquery');
// We need to wait for the previous dialogue box to unload fully before attempting to display another
setTimeout(function () {
params.defaultModeChangeAlertDisplayed = false;
settingsStore.removeItem('defaultModeChangeAlertDisplayed');
checkAndDisplayInjectionModeChangeAlert();
}, 1200);
});
}
}
});
}
} else {
// We need to set this variable earlier else the Service Worker does not get reactivated
params.contentInjectionMode = value;
// initOrKeepAliveServiceWorker();
}
}
}
document.querySelectorAll('input[name=contentInjectionMode]').forEach(function (radio) {
radio.checked = false;
});
var trueMode = params.serviceWorkerLocal ? value + 'local' : value;
var radioToCheck = document.querySelector('input[name=contentInjectionMode][value="' + trueMode + '"]');
if (radioToCheck) {
radioToCheck.checked = true;
}
// Save the value in the Settings Store, so that to be able to keep it after a reload/restart
settingsStore.setItem('contentInjectionMode', trueMode, Infinity);
refreshCacheStatus();
refreshAPIStatus();
// Even in JQuery mode, the PWA needs to be able to serve the app in offline mode
setTimeout(initServiceWorkerMessaging, 600);
// Set the visibility of WebP workaround after change of content injection mode
// Note we need a timeout because loading the webpHero script in init.js is asynchronous
setTimeout(uiUtil.determineCanvasElementsWorkaround, 1500);
}

Check notice on line 1291 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L1111-L1291

Complex Method

/**
* Detects whether the ServiceWorker API is available
Expand Down Expand Up @@ -1567,108 +1596,108 @@
/**
* Displays the zone to select files from the archive
*/
function displayFileSelect () {
const isFireFoxOsNativeFileApiAvailable = typeof navigator.getDeviceStorages === 'function';
let isPlatformMobilePhone = false;
if (/Android/i.test(navigator.userAgent)) isPlatformMobilePhone = true;
if (/iphone|ipad|ipod/i.test(navigator.userAgent) || navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) isPlatformMobilePhone = true;

console.debug(`File system api is ${params.isFileSystemApiSupported ? '' : 'not '}supported`);
console.debug(`Webkit directory api ${params.isWebkitDirApiSupported ? '' : 'not '}supported`);
console.debug(`Firefox os native file ${isFireFoxOsNativeFileApiAvailable ? '' : 'not '}support api`)

document.getElementById('openLocalFiles').style.display = 'block';
if ((params.isFileSystemApiSupported || params.isWebkitDirApiSupported) && !isPlatformMobilePhone) {
document.getElementById('chooseArchiveFromLocalStorage').style.display = '';
document.getElementById('folderSelect').style.display = '';
}

// Set the main drop zone
if (!params.disableDragAndDrop) {
// Set a global drop zone, so that whole page is enabled for drag and drop
globalDropZone.addEventListener('dragover', handleGlobalDragover);
globalDropZone.addEventListener('dragleave', handleGlobalDragleave);
globalDropZone.addEventListener('drop', handleFileDrop);
globalDropZone.addEventListener('dragenter', handleGlobalDragenter);
}

if (isFireFoxOsNativeFileApiAvailable) {
useLegacyFilePicker();
return;
}

document.getElementById('archiveList').addEventListener('change', function (e) {
// handle zim selection from dropdown if multiple files are loaded via webkitdirectory or filesystem api
settingsStore.setItem('previousZimFileName', e.target.value, Infinity);
if (params.isFileSystemApiSupported) {
return abstractFilesystemAccess.getSelectedZimFromCache(e.target.value).then(function (files) {
setLocalArchiveFromFileList(files);
}).catch(function (err) {
console.error(err);
return uiUtil.systemAlert(translateUI.t('dialog-fielhandle-fail-message') || 'We were unable to retrieve a file handle for the selected archive. Please pick the file or folder again.',
translateUI.t('dialog-fielhandle-fail-title') || 'Error retrieving archive');
});
} else {
if (webKitFileList === null) {
const element = settingsStore.getItem('zimFilenames').split('|').length === 1 ? 'archiveFiles' : 'archiveFolders';
if ('showPicker' in HTMLInputElement.prototype) {
document.getElementById(element).showPicker();
return;
}
document.getElementById(element).click()
return;
}
const files = abstractFilesystemAccess.getSelectedZimFromWebkitList(webKitFileList, e.target.value);
setLocalArchiveFromFileList(files);
}
});

if (params.isFileSystemApiSupported) {
// Handles Folder selection when showDirectoryPicker is supported
folderSelect.addEventListener('click', async function (e) {
e.preventDefault();
const previousZimFiles = await abstractFilesystemAccess.selectDirectoryFromPickerViaFileSystemApi()
if (previousZimFiles.length === 1) setLocalArchiveFromFileList(previousZimFiles);
});
}
if (params.isWebkitDirApiSupported) {
// Handles Folder selection when webkitdirectory is supported but showDirectoryPicker is not
folderSelect.addEventListener('change', function (e) {
e.preventDefault();
var fileList = e.target.files;
if (fileList) {
var foundFiles = abstractFilesystemAccess.selectDirectoryFromPickerViaWebkit(fileList);
var selectedZimfile = foundFiles.selectedFile;
// This ensures the selected files are stored for use during this session (webKitFileList is a global object)
webKitFileList = foundFiles.files;
// This will load the old file if the selected folder contains the same file
if (selectedZimfile.length !== 0) {
setLocalArchiveFromFileList(selectedZimfile);
}
}
})
}

if (params.isFileSystemApiSupported) {
// Handles File selection when showOpenFilePicker is supported and uses the filesystem api
archiveFiles.addEventListener('click', async function (e) {
e.preventDefault();
const files = await abstractFilesystemAccess.selectFileFromPickerViaFileSystemApi(e);
setLocalArchiveFromFileList(files);
});
} else {
// Fallbacks to simple file input with multi file selection
useLegacyFilePicker();
}
// Add keyboard activation for folder selection
folderSelect.addEventListener('keydown', function (e) {
// We have to include e.keyCode for IE11
if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 32) {
e.preventDefault();
folderSelect.click();
}
});
}

Check notice on line 1700 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L1599-L1700

Complex Method

/**
* Adds a event listener to the file input to handle file selection (if no other file picker is supported)
Expand Down Expand Up @@ -2123,7 +2152,7 @@
// Show the spinner with a loading message
var message = dirEntry.url.match(/(?:^|\/)([^/]{1,13})[^/]*?$/);
message = message ? message[1] + '...' : '...';
uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + message);
startLoadingArticle((translateUI.t('spinner-loading') || 'Loading') + ' ' + message);

if (params.contentInjectionMode === 'serviceworker') {
// In ServiceWorker mode, we simply set the iframe src.
Expand All @@ -2139,12 +2168,16 @@
articleLoader();

if (!isDirEntryExpectedToBeDisplayed(dirEntry)) {
// Stop Loading the Article if the article isn't expected to be shown
stopLoadingArticle();
return;
}

if (selectedArchive.zimType === 'zimit' && !appstate.isReplayWorkerAvailable) {
if (window.location.protocol === 'chrome-extension:') {
// Zimit archives contain content that is blocked in a local Chromium extension (on every page), so we must fall back to jQuery mode
// Stop Loading if isn't supported
stopLoadingArticle();
return handleUnsupportedReplayWorker(dirEntry);
}
var archiveName = selectedArchive.file.name.replace(/\.zim\w{0,2}$/i, '');
Expand All @@ -2158,6 +2191,7 @@
zimitMessageChannel.port1.onmessage = function (event) {
if (event.data.error) {
console.error('Reading Zimit archives in ServiceWorker mode is not supported in this browser', event.data.error);
stopLoadingArticle();
return handleUnsupportedReplayWorker(dirEntry);
} else if (event.data.success) {
// console.debug(event.data.success);
Expand Down Expand Up @@ -2201,6 +2235,7 @@
return selectedArchive.getDirEntryByPath(fileDirEntry.zimitRedirect).then(readArticle);
} else {
displayArticleContentInIframe(fileDirEntry, content);
stopLoadingArticle();
}
});
}
Expand Down Expand Up @@ -2229,70 +2264,70 @@
}

// Add event listener to iframe window to check for links to external resources
function filterClickEvent (event) {
// Find the closest enclosing A tag (if any)
var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target);
// If the anchor has a passthrough property, then we have already checked it is safe, so we can return
if (clickedAnchor && clickedAnchor.passthrough) {
clickedAnchor.passthrough = false;
return;
}
// Remove any Kiwix Popovers that may be hanging around
popovers.removeKiwixPopoverDivs(event.target.ownerDocument);
if (params.contentInjectionMode === 'jquery' || !params.openExternalLinksInNewTabs && !clickedAnchor.newcontainer) return;
if (clickedAnchor) {
// This prevents any popover from being displayed when the user clicks on a link
clickedAnchor.articleisloading = true;
// Check for Zimit links that would normally be handled by the Replay Worker
// DEV: '__WB_pmw' is a function inserted by wombat.js, so this detects links that have been rewritten in zimit2 archives
// however, this misses zimit2 archives where the framework doesn't support wombat.js, so monitor if always processing zimit2 links
// causes any adverse effects @TODO
if (appstate.isReplayWorkerAvailable || '__WB_pmw' in clickedAnchor || selectedArchive.zimType === 'zimit2' &&
articleWindow.location.href.replace(/[#?].*$/, '') !== clickedAnchor.href.replace(/[#?].*$/, '') && !clickedAnchor.hash) {
return handleClickOnReplayLink(event, clickedAnchor);
}
// DEV: The href returned below is the href as written in the HTML, which may be relative
var href = clickedAnchor.getAttribute('href');
// We assume that, if an absolute http(s) link is hardcoded inside an HTML string, it means it's a link to an external website
// (this assumption is only safe for non-Replay archives, but we deal with those separately above: they are routed to handleClickOnReplayLink).
// Additionally, by comparing the protocols, we can filter out protocols such as `mailto:`, `tel:`, `skype:`, etc. (these should open in a new window).
// DEV: The test for a protocol of ':' may no longer be needed. It needs careful testing in all browsers (particularly in Edge Legacy), and if no
// longer triggered, it can be removed.
if (/^http/i.test(href) || clickedAnchor.protocol && clickedAnchor.protocol !== ':' && articleWindow.location.protocol !== clickedAnchor.protocol) {
console.debug('filterClickEvent opening external link in new tab');
clickedAnchor.newcontainer = true;
uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor);
} else if (clickedAnchor.newcontainer || /\.pdf([?#]|$)/i.test(href) && selectedArchive.zimType !== 'zimit') {
// Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab. We also open
// a new tab if the user has explicitly requested it: in this case the anchor will have a property 'newcontainer' (e.g. with popover control)
event.preventDefault();
event.stopPropagation();
console.debug('filterClickEvent opening new window for PDF or requested new container');
clickedAnchor.newcontainer = true;
window.open(clickedAnchor.href, '_blank');
} else if (/\/[-ABCIJMUVWX]\/.+$/.test(clickedAnchor.href)) { // clickedAnchor.href returns the absolute URL, including any namespace
// Show the spinner if it's a ZIM link, but not an anchor
if (!~href.indexOf('#')) {
var message = href.match(/(?:^|\/)([^/]{1,13})[^/]*?$/);
message = message ? message[1] + '...' : '...';
uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + message);
// In case of false positive, ensure spinner is eventually hidden
setTimeout(function () {
uiUtil.spinnerDisplay(false);
}, 4000);
uiUtil.showSlidingUIElements();
}
}
// Reset popup block
setTimeout(function () {
// Anchor may have been unloaded along with the page by the time this runs
// but will still be present if user opened a new tab
if (clickedAnchor) {
clickedAnchor.articleisloading = false;
}
}, 1000);
}
};

Check notice on line 2330 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L2267-L2330

Complex Method

/**
* Postprocessing required after the article contents are loaded
Expand Down Expand Up @@ -2504,78 +2539,78 @@
// Note that true in the fourth argument instructs getDirEntryByPath to follow redirects by looking up the Header
// DEV: CURRENTLY NON-FUNCTION IN KIWIX-JS -- NEEDS FIXING
return selectedArchive.getDirEntryByPath(zimUrl, null, null, true).then(function (dirEntry) {
var processDirEntry = function (dirEntry) {
var pathToArticleDocumentRoot = document.location.href.replace(/www\/index\.html.*$/, selectedArchive.file.name + '/');
var mimetype = dirEntry.getMimetype();
// Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab
// Note that some Replay PDFs have html mimetypes, or can be redirects to PDFs, we need to check the URL as well
if (/pdf/i.test(mimetype) || /\.pdf(?:[#?]|$)/i.test(anchor.href) || /\.pdf(?:[#?]|$)/i.test(dirEntry.url)) {
if (/Android/.test(params.appType) || window.nw) {
// User is on an Android device, where opening a PDF in a new tab is not sufficient to evade the sandbox
// so we need to download the PDF instead
var readAndDownloadBinaryContent = function (zimUrl) {
return selectedArchive.getDirEntryByPath(zimUrl).then(function (dirEntry) {
if (dirEntry) {
selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
var mimetype = fileDirEntry.getMimetype();
uiUtil.displayFileDownloadAlert(zimUrl, true, mimetype, content);
uiUtil.spinnerDisplay(false);
});
} else {
return uiUtil.systemAlert('We could not find a PDF document at ' + zimUrl, 'PDF not found');
}
});
};
// If the document is in fact an html redirect, we need to follow it first till we get the underlying PDF document
if (/\bx?html\b/.test(mimetype)) {
selectedArchive.readUtf8File(dirEntry, function (fileDirEntry, data) {
var redirectURL = data.match(/<meta[^>]*http-equiv="refresh"[^>]*content="[^;]*;url='?([^"']+)/i);
if (redirectURL) {
redirectURL = redirectURL[1];
var contentUrl = pseudoNamespace + redirectURL.replace(/^[^/]+\/\//, '');
return readAndDownloadBinaryContent(contentUrl);
} else {
return readAndDownloadBinaryContent(zimUrl);
}
});
} else {
return readAndDownloadBinaryContent(zimUrl);
}
} else {
window.open(pathToArticleDocumentRoot + zimUrl, params.windowOpener === 'tab' ? '_blank' : dirEntry.title,
params.windowOpener === 'window' ? 'toolbar=0,location=0,menubar=0,width=800,height=600,resizable=1,scrollbars=1' : null);
}
} else {
// Handle middle-clicks and ctrl-clicks
if (ev.ctrlKey || ev.metaKey || ev.button === 1) {
var encodedTitle = encodeURIComponent(dirEntry.getTitleOrUrl());
var articleContainer = window.open(pathToArticleDocumentRoot + zimUrl,
params.windowOpener === 'tab' ? '_blank' : encodedTitle,
params.windowOpener === 'window' ? 'toolbar=0,location=0,menubar=0,width=800,height=600,resizable=1,scrollbars=1' : null
);
// Conditional, because opening a new window can be blocked by the browser
if (articleContainer) {
appstate.target = 'window';
articleContainer.kiwixType = appstate.target;
}
uiUtil.spinnerDisplay(false);
} else {
// Let Replay handle this link
anchor.passthrough = true;
articleContainer = document.getElementById('articleContent');
appstate.target = 'iframe';
articleContainer.kiwixType = appstate.target;
if (selectedArchive.zimType === 'zimit2') {
// Since we know the URL works, normalize the href (this is needed for zimit2 relative links)
// NB We mustn't do this for zimit classic because it breaks wombat rewriting of absolute links!
anchor.href = pathToArticleDocumentRoot + zimUrl;
}
anchor.click();
// Poll spinner with abbreviated title
uiUtil.spinnerDisplay(true, 'Loading ' + dirEntry.getTitleOrUrl().replace(/([^/]+)$/, '$1').substring(0, 18) + '...');
}
}
};

Check notice on line 2613 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L2542-L2613

Complex Method
if (dirEntry) {
processDirEntry(dirEntry);
} else {
Expand Down Expand Up @@ -2914,103 +2949,103 @@
// Note that we exclude any # with a semicolon between it and the end of the string, to avoid accidentally matching e.g. &#39;
var regexpLocalAnchorHref = new RegExp('^(?:#|' + escapedUrl + '#)([^#;]*$)');
var iframe = iframeArticleContent.contentDocument;
Array.prototype.slice.call(iframe.querySelectorAll('a, area')).forEach(function (anchor) {
// Attempts to access any properties of 'this' with malformed URLs causes app crash in Edge/UWP [kiwix-js #430]
try {
var href = anchor.href;
} catch (err) {
console.error('Malformed href caused error:' + err.message);
return;
}
href = anchor.getAttribute('href');
if (href === null || href === undefined || /^javascript:/i.test(anchor.protocol)) return;
var anchorTarget = href.match(regexpLocalAnchorHref);
if (href.length === 0) {
// It's a link with an empty href, pointing to the current page: do nothing.
return;
}
if (anchorTarget) {
// It's a local anchor link : remove escapedUrl if any (see above)
anchor.setAttribute('href', '#' + anchorTarget[1]);
return;
}
if ((anchor.protocol !== currentProtocol ||
anchor.host !== currentHost) && params.openExternalLinksInNewTabs) {
var newHref = href;
if (selectedArchive.zimType === 'zimit') {
// We need to check that the link isn't from a domain contained in the Zimit archive
var zimitDomain = selectedArchive.zimitPrefix.replace(/^\w\/([^/]+).*/, '$1');
newHref = href.replace(anchor.protocol + '//' + zimitDomain + '/', '');
}
if (newHref === href) {
// It's an external URL : we should open it in a new tab
anchor.addEventListener('click', function (event) {
// Find the closest enclosing A tag
var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target);
uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor);
});
return;
} else {
href = dirEntry.namespace + '/' + selectedArchive.zimitPrefix + newHref;
}
}
// It's a link to an article or file in the ZIM
var uriComponent = uiUtil.removeUrlParameters(href);
var contentType;
var downloadAttrValue;
// Some file types need to be downloaded rather than displayed (e.g. *.epub)
// The HTML download attribute can be Boolean or a string representing the specified filename for saving the file
// For Boolean values, getAttribute can return any of the following: download="" download="download" download="true"
// So we need to test hasAttribute first: see https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute
// However, we cannot rely on the download attribute having been set, so we also need to test for known download file types
var isDownloadableLink = anchor.hasAttribute('download') || regexpDownloadLinks.test(href);
if (isDownloadableLink) {
downloadAttrValue = anchor.getAttribute('download');
// Normalize the value to a true Boolean or a filename string or true if there is no download attribute
downloadAttrValue = /^(download|true|\s*)$/i.test(downloadAttrValue) || downloadAttrValue || true;
contentType = anchor.getAttribute('type');
}
// Add an onclick event to extract this article or file from the ZIM
// instead of following the link
anchor.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
// Prevent display of any popovers because we're loading a new article
anchor.articleisloading = true;
anchorParameter = href.match(/#([^#;]+)$/);
anchorParameter = anchorParameter ? anchorParameter[1] : '';
var indexRoot = window.location.pathname.replace(/[^/]+$/, '') + encodeURI(selectedArchive.file.name) + '/';
var zimRoot = indexRoot.replace(/^.+?\/www\//, '/');
var zimUrl = href;
// var zimUrlFullEncoding;
// Some URLs are incorrectly given with spaces at the beginning and end, so we remove these
zimUrl = zimUrl.replace(/^\s+|\s+$/g, '');
if (/zimit/.test(params.zimType)) {
// Deal with root-relative URLs in zimit ZIMs
if (!zimUrl.indexOf(indexRoot)) { // If begins with indexRoot
zimUrl = zimUrl.replace(indexRoot, '').replace('#' + anchorParameter, '');
} else if (!zimUrl.indexOf(zimRoot)) { // If begins with zimRoot
zimUrl = zimUrl.replace(zimRoot, '').replace('#' + anchorParameter, '');
} else if (/^\//.test(zimUrl)) {
zimUrl = zimUrl.replace(/^\//, selectedArchive.zimitPseudoContentNamespace + selectedArchive.zimitPrefix.replace(/^A\//, ''));
} else if (!~zimUrl.indexOf(selectedArchive.zimitPseudoContentNamespace)) { // Doesn't begin with pseudoContentNamespace
// Zimit ZIMs store URLs percent-encoded and with querystring and
// deriveZimUrlFromRelativeUrls strips any querystring and decodes
var zimUrlToTransform = zimUrl;
zimUrl = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(zimUrlToTransform, appstate.baseUrl)) +
href.replace(uriComponent, '').replace('#' + anchorParameter, '');
// zimUrlFullEncoding = encodeURI(uiUtil.deriveZimUrlFromRelativeUrl(zimUrlToTransform, appstate.baseUrl) +
// href.replace(uriComponent, '').replace('#' + anchorParameter, ''));
}
} else {
// It's a relative URL, so we need to calculate the full ZIM URL
zimUrl = uiUtil.deriveZimUrlFromRelativeUrl(uriComponent, appstate.baseUrl);
}
goToArticle(zimUrl, downloadAttrValue, contentType);
// DEV: There is no need to remove the anchor.articleisloading flag because we do not open new tabs for ZIM URLs in Restricted Mode
// so the anchor will be erased form the DOM when the new article is loaded
});
});

Check notice on line 3048 in www/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

www/js/app.js#L2952-L3048

Complex Method
attachPopoverTriggerEvents(iframeArticleContent.contentWindow);
}

Expand All @@ -3036,6 +3071,8 @@
}
// Get the image URL
var imageUrl = image.getAttribute('data-kiwixurl');
// Start Loading the image and update appstate
startLoadingAsset((translateUI.t('spinner-loading-image') || 'Loading Image: ') + imageUrl);
// Decode any WebP images that are encoded as dataURIs
if (/^data:image\/webp/i.test(imageUrl)) {
uiUtil.feedNodeWithDataURI(image, 'src', imageUrl, 'image/webp');
Expand All @@ -3048,6 +3085,7 @@
selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
var mimetype = dirEntry.getMimetype();
uiUtil.feedNodeWithDataURI(image, 'src', content, mimetype, function () {
stopLoadingAsset();
images.busy = false;
if (srcsetArr.length) {
// We need to process each image in the srcset
Expand Down Expand Up @@ -3089,6 +3127,7 @@
});
});
}).catch(function (e) {
stopLoadingAsset();
console.error('could not find DirEntry for image:' + url, e);
images.busy = false;
extractImage();
Expand Down Expand Up @@ -3250,17 +3289,18 @@
* @param {String} contentType The mimetype of the downloadable file, if known
*/
function goToArticle (path, download, contentType) {
uiUtil.spinnerDisplay(true);
startLoadingArticle((translateUI.t('spinner-loading') || 'Loading') + ' ' + path);
selectedArchive.getDirEntryByPath(path).then(function (dirEntry) {
var mimetype = contentType || dirEntry ? dirEntry.getMimetype() : '';
if (dirEntry === null || dirEntry === undefined) {
uiUtil.spinnerDisplay(false);
stopLoadingArticle();
uiUtil.systemAlert((translateUI.t('dialog-article-notfound-message') || 'Article with the following URL was not found in the archive:') + ' ' + path,
translateUI.t('dialog-article-notfound-title') || 'Error: article not found');
} else if (download || /\/(epub|pdf|zip|.*opendocument|.*officedocument|tiff|mp4|webm|mpeg|mp3|octet-stream)\b/i.test(mimetype)) {
download = true;
selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
uiUtil.displayFileDownloadAlert(path, download, mimetype, content);
stopLoadingArticle();
});
} else {
params.isLandingPage = false;
Expand All @@ -3269,6 +3309,7 @@
readArticle(dirEntry);
}
}).catch(function (e) {
stopLoadingArticle();
uiUtil.systemAlert((translateUI.t('dialog-article-readerror-message') || 'Error reading article with url:' + ' ' + path + ' : ' + e),
translateUI.t('dialog-article-readerror-title') || 'Error reading article');
});
Expand Down
Loading