diff --git a/service-worker.js b/service-worker.js index 678d225ae..77270520b 100644 --- a/service-worker.js +++ b/service-worker.js @@ -23,117 +23,240 @@ */ 'use strict'; -self.addEventListener('install', function(event) { +/** + * The name of the Cache API cache in which assets defined in regexpCachedContentTypes will be stored + * The value is defined in app.js and will be passed to Service Worker on initialization (to avoid duplication) + * @type {String} + */ +var CACHE_NAME; + +/** + * A global Boolean that governs whether CACHE_NAME will be used + * Caching is on by default but can be turned off by the user in Configuration + * @type {Boolean} + */ +var useCache = true; + +/** + * A regular expression that matches the Content-Types of assets that may be stored in CACHE_NAME + * Add any further Content-Types you wish to cache to the regexp, separated by '|' + * @type {RegExp} + */ +var regexpCachedContentTypes = /text\/css|text\/javascript|application\/javascript/i; + +/** + * A regular expression that excludes listed schemata from caching attempts + * As of 08-2019 the chrome-extension: schema is incompatible with the Cache API + * 'example-extension' is included to show how to add another schema if necessary + * @type {RegExp} + */ +var regexpExcludedURLSchema = /^(?:chrome-extension|example-extension):/i; + +/** + * Pattern for ZIM file namespace: see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces + * In our case, there is also the ZIM file name used as a prefix in the URL + * @type {RegExp} + */ +var regexpZIMUrlWithNamespace = /(?:^|\/)([^\/]+\/)([-ABIJMUVWX])\/(.+)/; + +self.addEventListener('install', function (event) { event.waitUntil(self.skipWaiting()); }); -self.addEventListener('activate', function(event) { +self.addEventListener('activate', function (event) { // "Claiming" the ServiceWorker is necessary to make it work right away, // without the need to reload the page. // See https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim event.waitUntil(self.clients.claim()); }); -var regexpRemoveUrlParameters = new RegExp(/([^?#]+)[?#].*$/); +var outgoingMessagePort = null; +var fetchCaptureEnabled = false; -// This function is duplicated from uiUtil.js -// because using requirejs would force to add the 'fetch' event listener -// after the initial evaluation of this script, which is not supported any more -// in recent versions of the browsers. -// Cf https://bugzilla.mozilla.org/show_bug.cgi?id=1181127 -// TODO : find a way to avoid this duplication +self.addEventListener('fetch', function (event) { + if (fetchCaptureEnabled && + regexpZIMUrlWithNamespace.test(event.request.url) && + event.request.method === "GET") { -/** - * Removes parameters and anchors from a URL - * @param {type} url - * @returns {String} same URL without its parameters and anchors - */ -function removeUrlParameters(url) { - return url.replace(regexpRemoveUrlParameters, "$1"); -} + // The ServiceWorker will handle this request either from CACHE_NAME or from app.js -var outgoingMessagePort = null; -var fetchCaptureEnabled = false; -self.addEventListener('fetch', fetchEventListener); + event.respondWith( + // First see if the content is in the cache + fromCache(event.request).then( + function (response) { + // The response was found in the cache so we respond with it + return response; + }, + function () { + // The response was not found in the cache so we look for it in the ZIM + // and add it to the cache if it is an asset type (css or js) + return fetchRequestFromZIM(event).then(function (response) { + // Add css or js assets to CACHE_NAME (or update their cache entries) unless the URL schema is not supported + if (regexpCachedContentTypes.test(response.headers.get('Content-Type')) && + !regexpExcludedURLSchema.test(event.request.url)) { + event.waitUntil(updateCache(event.request, response.clone())); + } + return response; + }).catch(function (msgPortData, title) { + console.error('Invalid message received from app.js for ' + title, msgPortData); + return msgPortData; + }); + } + ) + ); + } + // If event.respondWith() isn't called because this wasn't a request that we want to handle, + // then the default request/response behavior will automatically be used. +}); self.addEventListener('message', function (event) { - if (event.data.action === 'init') { - // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener - outgoingMessagePort = event.ports[0]; - fetchCaptureEnabled = true; - } - if (event.data.action === 'disable') { - // On 'disable' message, we delete the outgoingMessagePort and disable the fetchEventListener - outgoingMessagePort = null; - fetchCaptureEnabled = false; + if (event.data.action) { + if (event.data.action === 'init') { + // On 'init' message, we initialize the outgoingMessagePort and enable the fetchEventListener + outgoingMessagePort = event.ports[0]; + fetchCaptureEnabled = true; + } else if (event.data.action === 'disable') { + // On 'disable' message, we delete the outgoingMessagePort and disable the fetchEventListener + outgoingMessagePort = null; + fetchCaptureEnabled = false; + } + if (event.data.action.useCache) { + // Turns caching on or off (a string value of 'on' turns it on, any other string turns it off) + useCache = event.data.action.useCache === 'on'; + if (useCache) CACHE_NAME = event.data.cacheName; + console.log('[SW] Caching was turned ' + event.data.action.useCache); + } + if (event.data.action.checkCache) { + // Checks and returns the caching strategy: checkCache key should contain a sample URL string to test + testCacheAndCountAssets(event.data.action.checkCache).then(function (cacheArr) { + event.ports[0].postMessage({ 'type': cacheArr[0], 'description': cacheArr[1], 'count': cacheArr[2] }); + }); + } } }); -// Pattern for ZIM file namespace - see https://wiki.openzim.org/wiki/ZIM_file_format#Namespaces -// In our case, there is also the ZIM file name, used as a prefix in the URL -var regexpZIMUrlWithNamespace = /(?:^|\/)([^\/]+\/)([-ABIJMUVWX])\/(.+)/; +/** + * Handles fetch events that need to be extracted from the ZIM + * + * @param {Event} fetchEvent The fetch event to be processed + * @returns {Promise} A Promise for the Response, or rejects with the invalid message port data + */ +function fetchRequestFromZIM(fetchEvent) { + return new Promise(function (resolve, reject) { + var nameSpace; + var title; + var titleWithNameSpace; + var regexpResult = regexpZIMUrlWithNamespace.exec(fetchEvent.request.url); + var prefix = regexpResult[1]; + nameSpace = regexpResult[2]; + title = regexpResult[3]; -function fetchEventListener(event) { - if (fetchCaptureEnabled) { - if (regexpZIMUrlWithNamespace.test(event.request.url)) { - // The ServiceWorker will handle this request - // Let's ask app.js for that content - event.respondWith(new Promise(function(resolve, reject) { - var nameSpace; - var title; - var titleWithNameSpace; - var regexpResult = regexpZIMUrlWithNamespace.exec(event.request.url); - var prefix = regexpResult[1]; - nameSpace = regexpResult[2]; - title = regexpResult[3]; - - // We need to remove the potential parameters in the URL - title = removeUrlParameters(decodeURIComponent(title)); - - titleWithNameSpace = nameSpace + '/' + title; - - // Let's instanciate a new messageChannel, to allow app.s to give us the content - var messageChannel = new MessageChannel(); - messageChannel.port1.onmessage = function(event) { - if (event.data.action === 'giveContent') { - // Content received from app.js - var contentLength = event.data.content ? event.data.content.byteLength : null; - var contentType = event.data.mimetype; - var headers = new Headers (); - if (contentLength) headers.set('Content-Length', contentLength); - if (contentType) headers.set('Content-Type', contentType); - // Test if the content is a video or audio file - // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") - // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp - if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { - // In case of a video (at least), Chrome and Edge need these HTTP headers else seeking doesn't work - // (even if we always send all the video content, not the requested range, until the backend supports it) - headers.set('Accept-Ranges', 'bytes'); - headers.set('Content-Range', 'bytes 0-' + (contentLength-1) + '/' + contentLength); - } - var responseInit = { - status: 200, - statusText: 'OK', - headers: headers - }; - - var httpResponse = new Response(event.data.content, responseInit); - - // Let's send the content back from the ServiceWorker - resolve(httpResponse); - } - else if (event.data.action === 'sendRedirect') { - resolve(Response.redirect(prefix + event.data.redirectUrl)); - } - else { - console.error('Invalid message received from app.js for ' + titleWithNameSpace, event.data); - reject(event.data); - } + // We need to remove the potential parameters in the URL + title = removeUrlParameters(decodeURIComponent(title)); + + titleWithNameSpace = nameSpace + '/' + title; + + // Let's instantiate a new messageChannel, to allow app.js to give us the content + var messageChannel = new MessageChannel(); + messageChannel.port1.onmessage = function (msgPortEvent) { + if (msgPortEvent.data.action === 'giveContent') { + // Content received from app.js + var contentLength = msgPortEvent.data.content ? msgPortEvent.data.content.byteLength : null; + var contentType = msgPortEvent.data.mimetype; + var headers = new Headers(); + if (contentLength) headers.set('Content-Length', contentLength); + if (contentType) headers.set('Content-Type', contentType); + // Test if the content is a video or audio file + // See kiwix-js #519 and openzim/zimwriterfs #113 for why we test for invalid types like "mp4" or "webm" (without "video/") + // The full list of types produced by zimwriterfs is in https://github.com/openzim/zimwriterfs/blob/master/src/tools.cpp + if (contentLength >= 1 && /^(video|audio)|(^|\/)(mp4|webm|og[gmv]|mpeg)$/i.test(contentType)) { + // In case of a video (at least), Chrome and Edge need these HTTP headers or else seeking doesn't work + // (even if we always send all the video content, not the requested range, until the backend supports it) + headers.set('Accept-Ranges', 'bytes'); + headers.set('Content-Range', 'bytes 0-' + (contentLength - 1) + '/' + contentLength); + } + var responseInit = { + status: 200, + statusText: 'OK', + headers: headers }; - outgoingMessagePort.postMessage({'action': 'askForContent', 'title': titleWithNameSpace}, [messageChannel.port2]); - })); - } - // If event.respondWith() isn't called because this wasn't a request that we want to handle, - // then the default request/response behavior will automatically be used. - } + + var httpResponse = new Response(msgPortEvent.data.content, responseInit); + + // Let's send the content back from the ServiceWorker + resolve(httpResponse); + } else if (msgPortEvent.data.action === 'sendRedirect') { + resolve(Response.redirect(prefix + msgPortEvent.data.redirectUrl)); + } else { + reject(msgPortEvent.data, titleWithNameSpace); + } + }; + outgoingMessagePort.postMessage({ + 'action': 'askForContent', + 'title': titleWithNameSpace + }, [messageChannel.port2]); + }); } + +/** + * Removes parameters and anchors from a URL + * @param {type} url The URL to be processed + * @returns {String} The same URL without its parameters and anchors + */ +function removeUrlParameters(url) { + return url.replace(/([^?#]+)[?#].*$/, '$1'); +} + +/** + * Looks up a Request in CACHE_NAME and returns a Promise for the matched Response + * @param {Request} request The Request to fulfill from CACHE_NAME + * @returns {Promise} A Promise for the cached Response, or rejects with strings 'disabled' or 'no-match' + */ +function fromCache(request) { + // Prevents use of Cache API if user has disabled it + if (!useCache) return Promise.reject('disabled'); + return caches.open(CACHE_NAME).then(function (cache) { + return cache.match(request).then(function (matching) { + if (!matching || matching.status === 404) { + return Promise.reject('no-match'); + } + console.log('[SW] Supplying ' + request.url + ' from ' + CACHE_NAME + '...'); + return matching; + }); + }); +} + +/** + * Stores or updates in CACHE_NAME the given Request/Response pair + * @param {Request} request The original Request object + * @param {Response} response The Response received from the server/ZIM + * @returns {Promise} A Promise for the update action + */ +function updateCache(request, response) { + // Prevents use of Cache API if user has disabled it + if (!useCache) return Promise.resolve(); + return caches.open(CACHE_NAME).then(function (cache) { + console.log('[SW] Adding ' + request.url + ' to ' + CACHE_NAME + '...'); + return cache.put(request, response); + }); +} + +/** + * Tests the caching strategy available to this app and if it is Cache API, count the + * number of assets in CACHE_NAME + * @param {String} url A URL to test against excludedURLSchema + * @returns {Promise} A Promise for an array of format [cacheType, cacheDescription, assetCount] + */ +function testCacheAndCountAssets(url) { + if (regexpExcludedURLSchema.test(url)) return Promise.resolve(['custom', 'Custom', '-']); + if (!useCache) return Promise.resolve(['none', 'None', 0]); + return caches.open(CACHE_NAME).then(function (cache) { + return cache.keys().then(function (keys) { + return ['cacheAPI', 'Cache API', keys.length]; + }).catch(function(err) { + return err; + }); + }).catch(function(err) { + return err; + }); +} \ No newline at end of file diff --git a/www/index.html b/www/index.html index 138ddae60..89f7277df 100644 --- a/www/index.html +++ b/www/index.html @@ -47,8 +47,8 @@
+
+

Performance settings

+
+
Speed up archive access
+
+
+
+
+

Kiwix JS can speed up the display of articles by caching assets:

+ +
+
+ +
+
+
+ +
+
+
+
+
+

Expert settings

diff --git a/www/js/app.js b/www/js/app.js index 4a59cc016..4a103b54c 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -44,6 +44,22 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles */ var DELAY_BETWEEN_KEEPALIVE_SERVICEWORKER = 30000; + /** + * The name of the Cache API cache to use for caching Service Worker requests and responses for certain asset types + * This name will be passed to service-worker.js in messaging to avoid duplication: see comment in service-worker.js + * We need access to this constant in app.js in order to complete utility actions when Service Worker is not initialized + * @type {String} + */ + var CACHE_NAME = 'kiwixjs-assetCache'; + + /** + * Memory cache for CSS styles contained in ZIM: it significantly speeds up subsequent page display + * This cache is used by default in jQuery mode, but can be turned off in Configuration for low-memory devices + * In Service Worker mode, the Cache API will be used instead + * @type {Map} + */ + var cssCache = new Map(); + /** * @type ZIMArchive */ @@ -60,6 +76,8 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles // Set parameters and associated UI elements from cookie params['hideActiveContentWarning'] = cookies.getItem('hideActiveContentWarning') === 'true'; document.getElementById('hideActiveContentWarningCheck').checked = params.hideActiveContentWarning; + // A global parameter that turns caching on or off and deletes the cache (it defaults to true unless explicitly turned off in UI) + params['useCache'] = cookies.getItem('useCache') !== 'false'; // Define globalDropZone (universal drop area) and configDropZone (highlighting area on Config page) var globalDropZone = document.getElementById('search-article'); @@ -175,7 +193,6 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles goToRandomArticle(); $("#welcomeText").hide(); $('#articleListWithHeader').hide(); - $("#searchingArticles").hide(); $('.navbar-collapse').collapse('hide'); }); @@ -245,6 +262,7 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles $('#articleContent').hide(); $('.alert').hide(); refreshAPIStatus(); + refreshCacheStatus(); return false; }); $('#btnAbout').on('click', function(e) { @@ -273,9 +291,26 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles params.hideActiveContentWarning = this.checked ? true : false; cookies.setItem('hideActiveContentWarning', params.hideActiveContentWarning, Infinity); }); + document.getElementById('cachedAssetsModeRadioTrue').addEventListener('change', function (e) { + if (e.target.checked) { + cookies.setItem('useCache', true, Infinity); + params.useCache = true; + refreshCacheStatus(); + } + }); + document.getElementById('cachedAssetsModeRadioFalse').addEventListener('change', function (e) { + if (e.target.checked) { + cookies.setItem('useCache', false, Infinity); + params.useCache = false; + // Delete all caches + resetCssCache(); + if ('caches' in window) caches.delete(CACHE_NAME); + refreshCacheStatus(); + } + }); /** - * Displays of refreshes the API status shown to the user + * Displays or refreshes the API status shown to the user */ function refreshAPIStatus() { var apiStatusPanel = document.getElementById('apiStatusDiv'); @@ -311,7 +346,64 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles apiStatusPanel.classList.add(apiPanelClass); } - + + /** + * Queries Service Worker if possible to determine cache capability and returns an object with cache attributes + * If Service Worker is not available, the attributes of the memory cache are returned instead + * @returns {Promise} A Promise for an object with cache attributes 'type', 'description', and 'count' + */ + function getCacheAttributes() { + return q.Promise(function (resolve, reject) { + if (contentInjectionMode === 'serviceworker') { + // Create a Message Channel + var channel = new MessageChannel(); + // Handler for recieving message reply from service worker + channel.port1.onmessage = function (event) { + var cache = event.data; + if (cache.error) reject(cache.error); + else resolve(cache); + }; + // Ask Service Worker for its cache status and asset count + navigator.serviceWorker.controller.postMessage({ + 'action': { + 'useCache': params.useCache ? 'on' : 'off', + 'checkCache': window.location.href + }, + 'cacheName': CACHE_NAME + }, [channel.port2]); + } else { + // No Service Worker has been established, so we resolve the Promise with cssCache details only + resolve({ + 'type': params.useCache ? 'memory' : 'none', + 'description': params.useCache ? 'Memory' : 'None', + 'count': cssCache.size + }); + } + }); + } + + /** + * Refreshes the UI (Configuration) with the cache attributes obtained from getCacheAttributes() + */ + function refreshCacheStatus() { + // Update radio buttons and checkbox + document.getElementById('cachedAssetsModeRadio' + (params.useCache ? 'True' : 'False')).checked = true; + // Get cache attributes, then update the UI with the obtained data + getCacheAttributes().then(function (cache) { + document.getElementById('cacheUsed').innerHTML = cache.description; + document.getElementById('assetsCount').innerHTML = cache.count; + var cacheSettings = document.getElementById('cacheSettingsDiv'); + var cacheStatusPanel = document.getElementById('cacheStatusPanel'); + [cacheSettings, cacheStatusPanel].forEach(function (card) { + // IE11 cannot remove more than one class from a list at a time + card.classList.remove('card-success'); + card.classList.remove('card-warning'); + if (params.useCache) card.classList.add('card-success'); + else card.classList.add('card-warning'); + }); + }); + } + var contentInjectionMode; var keepAliveServiceWorkerHandle; @@ -354,6 +446,9 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles messageChannel = null; } refreshAPIStatus(); + // User has switched to jQuery mode, so no longer needs CACHE_NAME + // We should empty it to prevent unnecessary space usage + if ('caches' in window) caches.delete(CACHE_NAME); } else if (value === 'serviceworker') { if (!isServiceWorkerAvailable()) { alert("The ServiceWorker API is not available on your device. Falling back to JQuery mode"); @@ -383,6 +478,9 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles // Create the MessageChannel // and send the 'init' message to the ServiceWorker initOrKeepAliveServiceWorker(); + // We need to refresh cache status here on first activation because SW was inaccessible till now + // We also initialize the CACHE_NAME constant in SW here + refreshCacheStatus(); } }); if (serviceWorker.state === 'activated') { @@ -413,12 +511,16 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles contentInjectionMode = value; initOrKeepAliveServiceWorker(); } + // User has switched to Service Worker mode, so no longer needs the memory cache + // We should empty it to ensure good memory management + resetCssCache(); } $('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); + refreshCacheStatus(); } // At launch, we try to set the last content injection mode (stored in a cookie) @@ -432,6 +534,9 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles var serviceWorkerRegistration = null; + // We need to establish the caching capabilities before first page launch + refreshCacheStatus(); + /** * Tells if the ServiceWorker API is available * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker @@ -903,6 +1008,8 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles var iframeArticleContent = document.getElementById('articleContent'); iframeArticleContent.onload = function () { // The content is fully loaded by the browser : we can hide the spinner + $("#cachingAssets").html("Caching assets..."); + $("#cachingAssets").hide(); $("#searchingArticles").hide(); // Display the iframe content $("#articleContent").show(); @@ -1011,10 +1118,6 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles // to support to this regex. The "zip" has been added here as an example of how to support further filetypes var regexpDownloadLinks = /^.*?\.epub($|\?)|^.*?\.pdf($|\?)|^.*?\.zip($|\?)/i; - // Cache for CSS styles contained in ZIM. - // It significantly speeds up subsequent page display. See kiwix-js issue #335 - var cssCache = new Map(); - /** * Display the the given HTML article in the web page, * and convert links to javascript calls @@ -1201,13 +1304,13 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles uiUtil.replaceCSSLinkWithInlineCSS(link, cssContent); cssFulfilled++; } else { - $('#cachingCSS').show(); + if (params.useCache) $('#cachingAssets').show(); selectedArchive.getDirEntryByTitle(title) .then(function (dirEntry) { return selectedArchive.readUtf8File(dirEntry, function (fileDirEntry, content) { var fullUrl = fileDirEntry.namespace + "/" + fileDirEntry.url; - cssCache.set(fullUrl, content); + if (params.useCache) cssCache.set(fullUrl, content); uiUtil.replaceCSSLinkWithInlineCSS(link, content); cssFulfilled++; renderIfCSSFulfilled(fileDirEntry.url); @@ -1226,15 +1329,14 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles // until all CSS content is available [kiwix-js #381] function renderIfCSSFulfilled(title) { if (cssFulfilled >= cssCount) { - $('#cachingCSS').html('Caching styles...'); - $('#cachingCSS').hide(); + $('#cachingAssets').html('Caching assets...'); + $('#cachingAssets').hide(); $('#searchingArticles').hide(); $('#articleContent').show(); // We have to resize here for devices with On Screen Keyboards when loading from the article search list resizeIFrame(); - } else if (title) { - title = title.replace(/[^/]+\//g, '').substring(0,18); - $('#cachingCSS').html('Caching ' + title + '...'); + } else { + updateCacheStatus(title); } } } @@ -1288,6 +1390,19 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles } } + /** + * Displays a message to the user that a style or other asset is being cached + * @param {String} title The title of the file to display in the caching message block + */ + function updateCacheStatus(title) { + if (params.useCache && /\.css$|\.js$/i.test(title)) { + var cacheBlock = document.getElementById('cachingAssets'); + cacheBlock.style.display = 'block'; + title = title.replace(/[^/]+\//g, '').substring(0,18); + cacheBlock.innerHTML = 'Caching ' + title + '...'; + } + } + /** * Changes the URL of the browser page, so that the user might go back to it * @@ -1354,6 +1469,7 @@ define(['jquery', 'zimArchiveLoader', 'util', 'uiUtil', 'cookies','abstractFiles if (dirEntry.namespace === 'A') { params.isLandingPage = false; $('#activeContent').hide(); + $('#searchingArticles').show(); readArticle(dirEntry); } else { // If the random title search did not end up on an article,