/** * app.js : User Interface implementation * This file handles the interaction between the application and the user * * Copyright 2013-2014 Mossroy and contributors * License GPL v3: * * This file is part of Kiwix. * * Kiwix is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Kiwix is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kiwix (file LICENSE-GPLv3.txt). If not, see */ 'use strict'; // This uses require.js to structure javascript: // http://requirejs.org/docs/api.html#define define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFilesystemAccess', 'module'], function($, zimArchiveLoader, util, uiUtil, cookies, abstractFilesystemAccess, module) { /** * Maximum number of articles to display in a search * @type Integer */ var MAX_SEARCH_RESULT_SIZE = module.config().results; //This is set in init.js /** * @type ZIMArchive */ var selectedArchive = null; /** * Resize the IFrame height, so that it fills the whole available height in the window */ function resizeIFrame() { var height = $(window).outerHeight() - $("#top").outerHeight(true) - $("#articleListWithHeader").outerHeight(true) // TODO : this 5 should be dynamically computed, and not hard-coded - 5; $(".articleIFrame").css("height", height + "px"); } $(document).ready(resizeIFrame); $(window).resize(resizeIFrame); // Define behavior of HTML elements $('#searchArticles').on('click', function(e) { pushBrowserHistoryState(null, $('#prefix').val()); searchDirEntriesFromPrefix($('#prefix').val()); $("#welcomeText").hide(); $("#readingArticle").hide(); $("#articleContent").hide(); if ($('#navbarToggle').is(":visible") && $('#liHomeNav').is(':visible')) { $('#navbarToggle').click(); } }); $('#formArticleSearch').on('submit', function(e) { document.getElementById("searchArticles").click(); return false; }); $('#prefix').on('keyup', function(e) { if (selectedArchive !== null && selectedArchive.isReady()) { onKeyUpPrefix(e); } }); $("#btnRandomArticle").on("click", function(e) { $('#prefix').val(""); goToRandomArticle(); $("#welcomeText").hide(); $('#articleList').hide(); $('#articleListHeaderMessage').hide(); $("#readingArticle").hide(); $('#searchingForArticles').hide(); if ($('#navbarToggle').is(":visible") && $('#liHomeNav').is(':visible')) { $('#navbarToggle').click(); } }); $('#btnRescanDeviceStorage').on("click", function(e) { searchForArchivesInStorage(); }); // Bottom bar : $('#btnBack').on('click', function(e) { history.back(); return false; }); $('#btnForward').on('click', function(e) { history.forward(); return false; }); $('#btnHomeBottom').on('click', function(e) { $('#btnHome').click(); return false; }); $('#btnTop').on('click', function(e) { $("#articleContent").contents().scrollTop(0); // We return true, so that the link to #top is still triggered (useful in the About section) return true; }); // Top menu : $('#btnHome').on('click', function(e) { // Highlight the selected section in the navbar $('#liHomeNav').attr("class","active"); $('#liConfigureNav').attr("class",""); $('#liAboutNav').attr("class",""); if ($('#navbarToggle').is(":visible") && $('#liHomeNav').is(':visible')) { $('#navbarToggle').click(); } // Show the selected content in the page $('#about').hide(); $('#configuration').hide(); $('#formArticleSearch').show(); $("#welcomeText").show(); $('#articleList').show(); $('#articleListHeaderMessage').show(); $('#articleContent').show(); // Give the focus to the search field, and clean up the page contents $("#prefix").val(""); $('#prefix').focus(); $("#articleList").empty(); $('#articleListHeaderMessage').empty(); $("#readingArticle").hide(); $("#articleContent").hide(); $("#articleContent").contents().empty(); $('#searchingForArticles').hide(); if (selectedArchive !== null && selectedArchive.isReady()) { $("#welcomeText").hide(); goToMainArticle(); } return false; }); $('#btnConfigure').on('click', function(e) { // Highlight the selected section in the navbar $('#liHomeNav').attr("class",""); $('#liConfigureNav').attr("class","active"); $('#liAboutNav').attr("class",""); if ($('#navbarToggle').is(":visible") && $('#liHomeNav').is(':visible')) { $('#navbarToggle').click(); } // Show the selected content in the page $('#about').hide(); $('#configuration').show(); $('#formArticleSearch').hide(); $("#welcomeText").hide(); $('#articleList').hide(); $('#articleListHeaderMessage').hide(); $("#readingArticle").hide(); $("#articleContent").hide(); $('#articleContent').hide(); $('#searchingForArticles').hide(); refreshAPIStatus(); return false; }); $('#btnAbout').on('click', function(e) { // Highlight the selected section in the navbar $('#liHomeNav').attr("class",""); $('#liConfigureNav').attr("class",""); $('#liAboutNav').attr("class","active"); if ($('#navbarToggle').is(":visible") && $('#liHomeNav').is(':visible')) { $('#navbarToggle').click(); } // Show the selected content in the page $('#about').show(); $('#configuration').hide(); $('#formArticleSearch').hide(); $("#welcomeText").hide(); $('#articleList').hide(); $('#articleListHeaderMessage').hide(); $("#readingArticle").hide(); $("#articleContent").hide(); $('#articleContent').hide(); $('#searchingForArticles').hide(); return false; }); $('input:radio[name=contentInjectionMode]').on('change', function(e) { if (checkWarnServiceWorkerMode(this.value)) { // Do the necessary to enable or disable the Service Worker setContentInjectionMode(this.value); } else { setContentInjectionMode('jquery'); } }); $('input:radio[name=cssInjectionMode]').on('change', function (e) { params['cssSource'] = this.value; }); /** * Displays of refreshes the API status shown to the user */ function refreshAPIStatus() { if (isMessageChannelAvailable()) { $('#messageChannelStatus').html("MessageChannel API available"); $('#messageChannelStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiAvailable"); } else { $('#messageChannelStatus').html("MessageChannel API unavailable"); $('#messageChannelStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiUnavailable"); } if (isServiceWorkerAvailable()) { if (isServiceWorkerReady()) { $('#serviceWorkerStatus').html("ServiceWorker API available, and registered"); $('#serviceWorkerStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiAvailable"); } else { $('#serviceWorkerStatus').html("ServiceWorker API available, but not registered"); $('#serviceWorkerStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiUnavailable"); } } else { $('#serviceWorkerStatus').html("ServiceWorker API unavailable"); $('#serviceWorkerStatus').removeClass("apiAvailable apiUnavailable") .addClass("apiUnavailable"); } } var contentInjectionMode; /** * Sets the given injection mode. * This involves registering (or re-enabling) the Service Worker if necessary * It also refreshes the API status for the user afterwards. * * @param {String} value The chosen content injection mode : 'jquery' or 'serviceworker' */ function setContentInjectionMode(value) { if (value === 'jquery') { if (isServiceWorkerReady()) { // We need to disable the ServiceWorker // Unregistering it does not seem to work as expected : the ServiceWorker // is indeed unregistered but still active... // So we have to disable it manually (even if it's still registered and active) navigator.serviceWorker.controller.postMessage({'action': 'disable'}); messageChannel = null; } refreshAPIStatus(); } else if (value === 'serviceworker') { if (!isServiceWorkerAvailable()) { alert("The ServiceWorker API is not available on your device. Falling back to JQuery mode"); setContentInjectionMode('jquery'); return; } if (!isMessageChannelAvailable()) { alert("The MessageChannel API is not available on your device. Falling back to JQuery mode"); setContentInjectionMode('jquery'); return; } if (!messageChannel) { // Let's create the messageChannel for the 2-way communication // with the Service Worker messageChannel = new MessageChannel(); messageChannel.port1.onmessage = handleMessageChannelMessage; } if (!isServiceWorkerReady()) { $('#serviceWorkerStatus').html("ServiceWorker API available : trying to register it..."); navigator.serviceWorker.register('../service-worker.js').then(function (reg) { console.log('serviceWorker registered', reg); serviceWorkerRegistration = reg; refreshAPIStatus(); // 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') { console.log("try to post an init message to ServiceWorker"); navigator.serviceWorker.controller.postMessage({'action': 'init'}, [messageChannel.port2]); console.log("init message sent to ServiceWorker"); } }); }, function (err) { console.error('error while registering serviceWorker', err); refreshAPIStatus(); }); } else { console.log("try to re-post an init message to ServiceWorker, to re-enable it in case it was disabled"); navigator.serviceWorker.controller.postMessage({'action': 'init'}, [messageChannel.port2]); console.log("init message sent to ServiceWorker"); } } $('input:radio[name=contentInjectionMode]').prop('checked', false); $('input:radio[name=contentInjectionMode]').filter('[value="' + value + '"]').prop('checked', true); contentInjectionMode = value; // Save the value in a cookie, so that to be able to keep it after a reload/restart cookies.setItem('lastContentInjectionMode', value, Infinity); } /** * If the ServiceWorker mode is selected, warn the user before activating it * @param chosenContentInjectionMode The mode that the user has chosen */ function checkWarnServiceWorkerMode(chosenContentInjectionMode) { if (chosenContentInjectionMode === 'serviceworker' && !cookies.hasItem("warnedServiceWorkerMode")) { // The user selected the "serviceworker" mode, which is still unstable // So let's display a warning to the user // If the focus is on the search field, we have to move it, // else the keyboard hides the message if ($("#prefix").is(":focus")) { $("searchArticles").focus(); } if (confirm("The 'Service Worker' mode is still UNSTABLE for now." + " It happens that the application needs to be reinstalled (or the ServiceWorker manually removed)." + " Please confirm with OK that you're ready to face this kind of bugs, or click Cancel to stay in 'jQuery' mode.")) { // We will not display this warning again for one day cookies.setItem("warnedServiceWorkerMode", true, 86400); return true; } else { return false; } } return true; } // At launch, we try to set the last content injection mode (stored in a cookie) var lastContentInjectionMode = cookies.getItem('lastContentInjectionMode'); if (lastContentInjectionMode) { setContentInjectionMode(lastContentInjectionMode); } else { setContentInjectionMode('jquery'); } var serviceWorkerRegistration = null; /** * Tells if the ServiceWorker API is available * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker * @returns {Boolean} */ function isServiceWorkerAvailable() { return ('serviceWorker' in navigator); } /** * Tells if the MessageChannel API is available * https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel * @returns {Boolean} */ function isMessageChannelAvailable() { try{ var dummyMessageChannel = new MessageChannel(); if (dummyMessageChannel) return true; } catch (e){ return false; } return false; } /** * Tells if the ServiceWorker is registered, and ready to capture HTTP requests * and inject content in articles. * @returns {Boolean} */ function isServiceWorkerReady() { // Return true if the serviceWorkerRegistration is not null and not undefined return (serviceWorkerRegistration); } /** * * @type Array. */ var storages = []; function searchForArchivesInPreferencesOrStorage() { // First see if the list of archives is stored in the cookie var listOfArchivesFromCookie = cookies.getItem("listOfArchives"); if (listOfArchivesFromCookie !== null && listOfArchivesFromCookie !== undefined && listOfArchivesFromCookie !== "") { var directories = listOfArchivesFromCookie.split('|'); populateDropDownListOfArchives(directories); } else { searchForArchivesInStorage(); } } function searchForArchivesInStorage() { // If DeviceStorage is available, we look for archives in it $("#btnConfigure").click(); $('#scanningForArchives').show(); zimArchiveLoader.scanForArchives(storages, populateDropDownListOfArchives); } if ($.isFunction(navigator.getDeviceStorages)) { // The method getDeviceStorages is available (FxOS>=1.1) storages = $.map(navigator.getDeviceStorages("sdcard"), function(s) { return new abstractFilesystemAccess.StorageFirefoxOS(s); }); } if (storages !== null && storages.length > 0) { // Make a fake first access to device storage, in order to ask the user for confirmation if necessary. // This way, it is only done once at this moment, instead of being done several times in callbacks // After that, we can start looking for archives storages[0].get("fake-file-to-read").then(searchForArchivesInPreferencesOrStorage, searchForArchivesInPreferencesOrStorage); } else { // If DeviceStorage is not available, we display the file select components displayFileSelect(); if (document.getElementById('archiveFiles').files && document.getElementById('archiveFiles').files.length>0) { // Archive files are already selected, setLocalArchiveFromFileSelect(); } else { $("#btnConfigure").click(); } } // Display the article when the user goes back in the browser history window.onpopstate = function(event) { if (event.state) { var title = event.state.title; var titleSearch = event.state.titleSearch; $('#prefix').val(""); $("#welcomeText").hide(); $("#readingArticle").hide(); if ($('#navbarToggle').is(":visible") && $('#liHomeNav').is(':visible')) { $('#navbarToggle').click(); } $('#searchingForArticles').hide(); $('#configuration').hide(); $('#articleList').hide(); $('#articleListHeaderMessage').hide(); $('#articleContent').contents().empty(); if (title && !(""===title)) { goToArticle(title); } else if (titleSearch && !(""===titleSearch)) { $('#prefix').val(titleSearch); searchDirEntriesFromPrefix($('#prefix').val()); } } }; /** * Populate the drop-down list of archives with the given list * @param {Array.} archiveDirectories */ function populateDropDownListOfArchives(archiveDirectories) { $('#scanningForArchives').hide(); $('#chooseArchiveFromLocalStorage').show(); var comboArchiveList = document.getElementById('archiveList'); comboArchiveList.options.length = 0; for (var i = 0; i < archiveDirectories.length; i++) { var archiveDirectory = archiveDirectories[i]; if (archiveDirectory === "/") { alert("It looks like you have put some archive files at the root of your sdcard (or internal storage). Please move them in a subdirectory"); } else { comboArchiveList.options[i] = new Option(archiveDirectory, archiveDirectory); } } // Store the list of archives in a cookie, to avoid rescanning at each start cookies.setItem("listOfArchives", archiveDirectories.join('|'), Infinity); $('#archiveList').on('change', setLocalArchiveFromArchiveList); if (comboArchiveList.options.length > 0) { var lastSelectedArchive = cookies.getItem("lastSelectedArchive"); if (lastSelectedArchive !== null && lastSelectedArchive !== undefined && lastSelectedArchive !== "") { // Attempt to select the corresponding item in the list, if it exists if ($("#archiveList option[value='"+lastSelectedArchive+"']").length > 0) { $("#archiveList").val(lastSelectedArchive); } } // Set the localArchive as the last selected (or the first one if it has never been selected) setLocalArchiveFromArchiveList(); } else { alert("Welcome to Kiwix! This application needs at least a ZIM file in your SD-card (or internal storage). Please download one and put it on the device (see About section). Also check that your device is not connected to a computer through USB device storage (which often locks the SD-card content)"); $("#btnAbout").click(); var isAndroid = (navigator.userAgent.indexOf("Android") !== -1); if (isAndroid) { alert("You seem to be using an Android device. Be aware that there is a bug on Firefox, that prevents finding Wikipedia archives in a SD-card (at least on some devices. See about section). Please put the archive in the internal storage if the application can't find it."); } } } /** * Sets the localArchive from the selected archive in the drop-down list */ function setLocalArchiveFromArchiveList() { var archiveDirectory = $('#archiveList').val(); if (archiveDirectory && archiveDirectory.length > 0) { // Now, try to find which DeviceStorage has been selected by the user // It is the prefix of the archive directory var regexpStorageName = /^\/([^\/]+)\//; var regexpResults = regexpStorageName.exec(archiveDirectory); var selectedStorage = null; if (regexpResults && regexpResults.length>0) { var selectedStorageName = regexpResults[1]; for (var i=0; i= 200 && request.status < 300) || request.status === 0) { // Hack to make this look similar to a file request.response.name = url; setLocalArchiveFromFileList([request.response]); } } }; request.send(null); }; /** * Handle key input in the prefix input zone * @param {Event} evt */ function onKeyUpPrefix(evt) { // Use a timeout, so that very quick typing does not cause a lot of overhead // It is also necessary for the words suggestions to work inside Firefox OS if(window.timeoutKeyUpPrefix) { window.clearTimeout(window.timeoutKeyUpPrefix); } window.timeoutKeyUpPrefix = window.setTimeout(function() { var prefix = $("#prefix").val(); if (prefix && prefix.length>0) { $('#searchArticles').click(); } } ,500); } /** * Search the index for DirEntries with title that start with the given prefix (implemented * with a binary search inside the index file) * @param {String} prefix */ function searchDirEntriesFromPrefix(prefix) { $('#searchingForArticles').show(); $('#configuration').hide(); $('#articleContent').contents().empty(); if (selectedArchive !== null && selectedArchive.isReady()) { selectedArchive.findDirEntriesWithPrefix(prefix.trim(), MAX_SEARCH_RESULT_SIZE, populateListOfArticles); } else { $('#searchingForArticles').hide(); // We have to remove the focus from the search field, // so that the keyboard does not stay above the message $("#searchArticles").focus(); alert("Archive not set : please select an archive"); $("#btnConfigure").click(); } } /** * Display the list of articles with the given array of DirEntry * @param {Array.} dirEntryArray * @param {Integer} maxArticles */ function populateListOfArticles(dirEntryArray, maxArticles) { var articleListHeaderMessageDiv = $('#articleListHeaderMessage'); var nbDirEntry = 0; if (dirEntryArray) { nbDirEntry = dirEntryArray.length; } var message; if (maxArticles >= 0 && nbDirEntry >= maxArticles) { message = maxArticles + " first articles below (refine your search)."; } else { message = nbDirEntry + " articles found."; } if (nbDirEntry === 0) { message = "No articles found."; } articleListHeaderMessageDiv.html(message); var articleListDiv = $('#articleList'); var articleListDivHtml = ""; for (var i = 0; i < dirEntryArray.length; i++) { var dirEntry = dirEntryArray[i]; articleListDivHtml += "" + dirEntry.title + ""; } articleListDiv.html(articleListDivHtml); $("#articleList a").on("click",handleTitleClick); $('#searchingForArticles').hide(); $('#articleList').show(); $('#articleListHeaderMessage').show(); } /** * Handles the click on the title of an article in search results * @param {Event} event * @returns {Boolean} */ function handleTitleClick(event) { var dirEntryId = event.target.getAttribute("dirEntryId"); $("#articleList").empty(); $('#articleListHeaderMessage').empty(); $("#prefix").val(""); findDirEntryFromDirEntryIdAndLaunchArticleRead(dirEntryId); var dirEntry = selectedArchive.parseDirEntryId(dirEntryId); pushBrowserHistoryState(dirEntry.url); return false; } /** * Creates an instance of DirEntry from given dirEntryId (including resolving redirects), * and call the function to read the corresponding article * @param {String} dirEntryId */ function findDirEntryFromDirEntryIdAndLaunchArticleRead(dirEntryId) { if (selectedArchive.isReady()) { var dirEntry = selectedArchive.parseDirEntryId(dirEntryId); $("#articleName").html(dirEntry.title); $("#readingArticle").show(); $("#articleContent").contents().html(""); if (dirEntry.isRedirect()) { selectedArchive.resolveRedirect(dirEntry, readArticle); } else { readArticle(dirEntry); } } else { alert("Data files not set"); } } /** * Read the article corresponding to the given dirEntry * @param {DirEntry} dirEntry */ function readArticle(dirEntry) { if (dirEntry.isRedirect()) { selectedArchive.resolveRedirect(dirEntry, readArticle); } else { selectedArchive.readArticle(dirEntry, displayArticleInForm); } } var messageChannel; /** * Function that handles a message of the messageChannel. * It tries to read the content in the backend, and sends it back to the ServiceWorker * @param {Event} event */ function handleMessageChannelMessage(event) { if (event.data.error) { console.error("Error in MessageChannel", event.data.error); reject(event.data.error); } else { console.log("the ServiceWorker sent a message on port1", event.data); if (event.data.action === "askForContent") { console.log("we are asked for a content : let's try to answer to this message"); var title = event.data.title; var messagePort = event.ports[0]; var readFile = function(dirEntry) { if (dirEntry === null) { console.error("Title " + title + " not found in archive."); messagePort.postMessage({'action': 'giveContent', 'title' : title, 'content': ''}); } else if (dirEntry.isRedirect()) { selectedArchive.resolveRedirect(dirEntry, readFile); } else { console.log("Reading binary file..."); selectedArchive.readBinaryFile(dirEntry, function(readableTitle, content) { messagePort.postMessage({'action': 'giveContent', 'title' : title, 'content': content}); console.log("content sent to ServiceWorker"); }); } }; selectedArchive.getDirEntryByTitle(title).then(readFile).fail(function() { messagePort.postMessage({'action': 'giveContent', 'title' : title, 'content': new UInt8Array()}); }); } else { console.error("Invalid message received", event.data); } } }; // Compile some regular expressions needed to modify links var regexpImageLink = /^.?\/?[^:]+:(.*)/; var regexpPath = /^(.*\/)[^\/]+$/; // These regular expressions match both relative and absolute URLs // Since late 2014, all ZIM files should use relative URLs var regexpImageUrl = /^(?:\.\.\/|\/)+(I\/.*)$/; var regexpMetadataUrl = /^(?:\.\.\/|\/)+(-\/.*)$/; // This regular expression matches the href of all tags containing rel="stylesheet" in raw HTML var regexpSheetHref = /(]*rel\s*=\s*["']stylesheet)[^>]*href\s*=\s*["'])([^"']+)(["'][^>]*>)/ig; /** * Display the the given HTML article in the web page, * and convert links to javascript calls * NB : in some error cases, the given title can be null, and the htmlArticle contains the error message * @param {DirEntry} dirEntry * @param {String} htmlArticle */ function displayArticleInForm(dirEntry, htmlArticle) { // Display the article inside the web page. //Fast-replace img src with data-kiwixsrc and hide image [kiwix-js #272] htmlArticle = htmlArticle.replace(/(]*\b)src(\s*=)/ig, "$1style=\"display: none;\" onload=\"this.style.display='inline'\" data-kiwixsrc$2"); //Preload stylesheets [kiwix-js @149] //Set up blobArray of promises var cssArray = htmlArticle.match(regexpSheetHref); var blobArray = []; cssSource = params['cssSource']; getBLOB(cssArray); //Extract CSS URLs from given array of links function getBLOB(arr) { if (cssSource == "desktop" && (!arr.join().match(/-\/s\/style\.css/i))) { arr.push(''); //Insert the standard desktop style } for (var i = 0; i < arr.length; i++) { var linkArray = regexpSheetHref.exec(arr[i]); regexpSheetHref.lastIndex = 0; //Reset start position for next loop if (regexpMetadataUrl.test(linkArray[2])) { //It's a CSS file contained in ZIM var zimLink = decodeURIComponent(uiUtil.removeUrlParameters(linkArray[2])); //If this is a standard Wikipedia css use stylesheet cached in the filesystem... if ((cssSource != "zimfile") && (zimLink.match(/-\/s\/style\.css/i) || zimLink.match(/-\/s\/css_modules\/mediawiki\.toc\.css/i) || zimLink.match(/-\/s\/css_modules\/ext\.cite\.styles\.css/i) || zimLink.match(/-\/s\/css_modules\/ext\.timeline\.styles\.css/i) || zimLink.match(/-\/s\/css_modules\/ext\.scribunto\.logs\.css/i) || zimLink.match(/-\/s\/css_modules\/mediawiki\.page\.gallery\.styles\.css/i) || zimLink.match(/-\/s\/css_modules\/ext\.cite\.a11y\.css/i) || zimLink.match(/-\/s\/css_modules\/content\.parsoid\.css/i) || zimLink.match(/-\/s\/css_modules\/inserted_style_mobile\.css/i) || zimLink.match(/-\/s\/css_modules\/mobile\.css/i) || zimLink.match(/-\/s\/css_modules\/skins\.minerva\.base\.reset\|skins\.minerva\.content\.styles\|ext\.cite\.style\|mediawiki\.page\.gallery\.styles\|mobile\.app\.pagestyles\.android\|mediawiki\.skinning\.content\.parsoid\.css/i) )) { if (cssSource == "desktop" && zimLink.match(/minerva|mobile|parsoid/)) { //If user selected desktop style and the ZIM is formatted for mobile... zimLink = "#"; //Void the style } if ((cssSource == "mobile") || (zimLink.match(/minerva/))) { //If user has selected mobile display mode or mobile is built into ZIM, substitute main stylesheet zimLink = zimLink.match(/(-\/s\/style\.css)|(minerva)/i) ? "../-/s/style-mobile.css" : zimLink; } blobArray[i] = zimLink.replace(/\|/ig, "_"); //Replace "|" with "_" (legacy for some stylesheets with pipes in filename) console.log("Matched #" + i + " [" + blobArray[i] + "] from local filesystem"); injectCSS(); } else { //Try to get the stylesheet from the ZIM file var linkURL = zimLink.match(regexpMetadataUrl)[1]; console.log("Attempting to resolve CSS link #" + i + " [" + linkURL + "] from ZIM file..." + (cssSource != "zimfile" ? "\n(Consider adding file #" + i + " to the local filesystem)" : "")); resolveCSS(linkURL, i); //Pass link and index } } else { blobArray[i] = linkArray[2]; //If CSS not in ZIM, store URL in blobArray injectCSS(); //Ensure this is called even if none of CSS links are in ZIM } } } function resolveCSS(title, index) { selectedArchive.getDirEntryByTitle(title).then( function (dirEntry) { selectedArchive.readBinaryFile(dirEntry, function (readableTitle, content) { //var cssContent = util.uintToString(content); //Uncomment this line and break on next to capture cssContent for local filesystem cache var cssBlob = new Blob([content], { type: 'text/css' }, { oneTimeOnly: true }); var newURL = URL.createObjectURL(cssBlob); blobArray[index] = newURL; injectCSS(); //Don't move this: it must run within .then function to pass correct values }); }).fail(function (e) { console.error("could not find DirEntry for CSS : " + title, e); blobArray[index] = title; injectCSS(); }); } function injectCSS() { if (blobArray.length === cssArray.length) { //If all promised values have been obtained for (var i in cssArray) { cssArray[i] = cssArray[i].replace(/(href\s*=\s*["'])([^"']+)/i, "$1" + blobArray[i]); //DEV note: do not attempt to add onload="URL.revokeObjectURL...)": it fires before the //stylesheet changes have been painted and causes a crash... //Consider using oneTimeOnly= true when creating blob instead } htmlArticle = htmlArticle.replace(regexpSheetHref, ""); //Void existing stylesheets var cssArray$ = "\r\n" + cssArray.join("\r\n") + "\r\n"; if (cssSource == "mobile") { //If user has selected mobile display mode, insert extra stylesheets cssArray$ += cssArray$.match(/-\/s\/css_modules\/content\.parsoid\.css/i) ? "" : '\r\n'; cssArray$ += cssArray$.match(/-\/s\/css_modules\/inserted_style_mobile\.css/i) ? "" : '\r\n'; cssArray$ += cssArray$.match(/-\/s\/css_modules\/mobile\.css/i) ? "" : '\r\n'; htmlArticle = htmlArticle.replace(/class\s*=\s*["']\s*thumb\s+tright\s*["']\s*/ig, 'style="float: right; clear: right; margin-left: 1.4em;"'); htmlArticle = htmlArticle.replace(/class\s*=\s*["']\s*thumb\s+tleft\s*["']\s*/ig, 'style="float: left; clear: left; margin-right: 1.4em;"'); htmlArticle = htmlArticle.replace(/class\s*=\s*["']\s*thumbcaption\s*["']\s*/ig, 'style="margin: 0.5em 0 0.5em; font-size: 0.8em; line-height: 1.5; padding: 0 !important; color: #54595d; width: auto !important;"'); //Move info-box below lead htmlArticle = htmlArticle.replace(/(]*infobox)[\s\S]+?<\/table>[^<]*)(]*>(?:(?=([^<]+))\3|<(?!p\b[^>]*>))*?<\/p>)/ig, "$2$1"); htmlArticle = htmlArticle.replace(/(table\s+(?=[^>]*class\s*=\s*["'][^"']*infobox)[^>]*style\s*=\s*["'][^"']+[^;'"]);?\s*["']/ig, '$1; position: relative; border: 1px solid #eaecf0; text-align: left; background-color: #f8f9fa;"'); } if (cssSource == "desktop") { //If user has selected desktop display mode... htmlArticle = htmlArticle.replace(/class\s*=\s*["']\s*mw-body\s*["']\s*/ig, 'style="background-color: white; padding: 1em; border-width: 0px; max-width: 55.8em; margin: 0 auto 0 auto;"'); htmlArticle = htmlArticle.replace(/]+titleHeading[^>]+>\s*<\/h1>\s*/ig, ""); //Void empty header title } htmlArticle = htmlArticle.replace(/\s*(<\/head>)/i, cssArray$ + "$1"); console.log("All CSS resolved"); injectHTML(htmlArticle); //This passes the revised HTML to the image and JS subroutine... } else { //console.log("Waiting for " + (cssArray.length - blobArray.length) + " out of " + cssArray.length + " to resolve...") } } //End of preload stylesheets code function injectHTML(htmlContent) { $("#readingArticle").hide(); $("#articleContent").show(); // Scroll the iframe to its top $("#articleContent").contents().scrollTop(0); $('#articleContent').contents().find('body').html(htmlContent); // If the ServiceWorker is not useable, we need to fallback to parse the DOM // to inject math images, and replace some links with javascript calls if (contentInjectionMode === 'jquery') { // Convert links into javascript calls $('#articleContent').contents().find('body').find('a').each(function () { // Store current link's url var url = $(this).attr("href"); if (url === null || url === undefined) { return; } var lowerCaseUrl = url.toLowerCase(); var cssClass = $(this).attr("class"); if (cssClass === "new") { // It's a link to a missing article : display a message $(this).on('click', function (e) { alert("Missing article in Wikipedia"); return false; }); } else if (url.slice(0, 1) === "#") { // It's an anchor link : do nothing } else if (url.substring(0, 4) === "http") { // It's an external link : open in a new tab $(this).attr("target", "_blank"); } else if (url.match(regexpImageLink) && (util.endsWith(lowerCaseUrl, ".png") || util.endsWith(lowerCaseUrl, ".svg") || util.endsWith(lowerCaseUrl, ".jpg") || util.endsWith(lowerCaseUrl, ".jpeg"))) { // It's a link to a file of Wikipedia : change the URL to the online version and open in a new tab var onlineWikipediaUrl = url.replace(regexpImageLink, "https://" + selectedArchive._language + ".wikipedia.org/wiki/File:$1"); $(this).attr("href", onlineWikipediaUrl); $(this).attr("target", "_blank"); } else { // It's a link to another article // Add an onclick event to go to this article // instead of following the link if (url.substring(0, 2) === "./") { url = url.substring(2); } // Remove the initial slash if it's an absolute URL else if (url.substring(0, 1) === "/") { url = url.substring(1); } $(this).on('click', function (e) { var decodedURL = decodeURIComponent(url); pushBrowserHistoryState(decodedURL); goToArticle(decodedURL); return false; }); } }); // Load images $('#articleContent').contents().find('body').find('img').each(function () { var image = $(this); // It's a standard image contained in the ZIM file // We try to find its name (from an absolute or relative URL) var imageMatch = image.attr('data-kiwixsrc').match(regexpImageUrl); //kiwix-js #272 if (imageMatch) { var title = decodeURIComponent(imageMatch[1]); selectedArchive.getDirEntryByTitle(title).then(function (dirEntry) { selectedArchive.readBinaryFile(dirEntry, function (readableTitle, content) { // TODO : use the complete MIME-type of the image (as read from the ZIM file) uiUtil.feedNodeWithBlob(image, 'src', content, 'image'); }); }).fail(function (e) { console.error("could not find DirEntry for image:" + title, e); }); } }); /*/ Load Javascript content $('#articleContent').contents().find('script').each(function () { var script = $(this); // We try to find its name (from an absolute or relative URL) if (script) { var srcMatch = script.attr("src").match(regexpMetadataUrl) } // TODO check that the type of the script is text/javascript or application/javascript if (srcMatch) { // It's a Javascript file contained in the ZIM file var title = uiUtil.removeUrlParameters(decodeURIComponent(srcMatch[1])); selectedArchive.getDirEntryByTitle(title).then(function (dirEntry) { if (dirEntry === null) console.log("Error: js file not found: " + title); else selectedArchive.readBinaryFile(dirEntry, function (readableTitle, content) { // TODO : I have to disable javascript for now // var jsContent = encodeURIComponent(util.uintToString(content)); //script.attr("src", 'data:text/javascript;charset=UTF-8,' + jsContent); }); }).fail(function (e) { console.error("could not find DirEntry for javascript : " + title, e); }); } });*/ } } } /** * Changes the URL of the browser page, so that the user might go back to it * * @param {String} title * @param {String} titleSearch */ function pushBrowserHistoryState(title, titleSearch) { var stateObj = {}; var urlParameters; var stateLabel; if (title && !(""===title)) { stateObj.title = title; urlParameters = "?title=" + title; stateLabel = "Wikipedia Article : " + title; } else if (titleSearch && !(""===titleSearch)) { stateObj.titleSearch = titleSearch; urlParameters = "?titleSearch=" + titleSearch; stateLabel = "Wikipedia search : " + titleSearch; } else { return; } window.history.pushState(stateObj, stateLabel, urlParameters); } /** * Replace article content with the one of the given title * @param {String} title */ function goToArticle(title) { selectedArchive.getDirEntryByTitle(title).then(function(dirEntry) { if (dirEntry === null || dirEntry === undefined) { $("#readingArticle").hide(); alert("Article with title " + title + " not found in the archive"); } else { $("#articleName").html(title); $("#readingArticle").show(); $('#articleContent').contents().find('body').html(""); readArticle(dirEntry); } }).fail(function() { alert("Error reading article with title " + title); }); } function goToRandomArticle() { selectedArchive.getRandomDirEntry(function(dirEntry) { if (dirEntry === null || dirEntry === undefined) { alert("Error finding random article."); } else { if (dirEntry.namespace === 'A') { $("#articleName").html(dirEntry.title); pushBrowserHistoryState(dirEntry.url); $("#readingArticle").show(); $('#articleContent').contents().find('body').html(""); readArticle(dirEntry); } else { // If the random title search did not end up on an article, // we try again, until we find one goToRandomArticle(); } } }); } function goToMainArticle() { selectedArchive.getMainPageDirEntry(function(dirEntry) { if (dirEntry === null || dirEntry === undefined) { console.error("Error finding main article."); } else { if (dirEntry.namespace === 'A') { $("#articleName").html(dirEntry.title); pushBrowserHistoryState(dirEntry.url); $("#readingArticle").show(); $('#articleContent').contents().find('body').html(""); readArticle(dirEntry); } else { console.error("The main page of this archive does not seem to be an article"); } } }); } });