From 363be16fd94e09a6980f1eb08c2d024de288e90b Mon Sep 17 00:00:00 2001 From: Fred Chasen Date: Sun, 28 Oct 2018 22:31:24 -0700 Subject: [PATCH] Add Storage --- examples/offline.html | 97 ++++++++++ package-lock.json | 18 +- package.json | 6 +- src/book.js | 77 +++++++- src/managers/default/index.js | 4 +- src/store.js | 349 ++++++++++++++++++++++++++++++++++ src/utils/hook.js | 15 ++ src/utils/path.js | 6 + types/store.d.ts | 26 +++ types/utils/hook.d.ts | 2 + types/utils/request.d.ts | 2 +- 11 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 examples/offline.html create mode 100644 src/store.js create mode 100644 types/store.d.ts diff --git a/examples/offline.html b/examples/offline.html new file mode 100644 index 0000000..ae5043b --- /dev/null +++ b/examples/offline.html @@ -0,0 +1,97 @@ + + + + + + EPUB.js Storage Example + + + + + + + + + + + +
You are offline. Loading from Storage.
+
+ + + + + + diff --git a/package-lock.json b/package-lock.json index 1e3883e..a89789e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "epubjs", - "version": "0.3.74", + "version": "0.3.75", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -49,6 +49,14 @@ "@types/node": "*" } }, + "@types/localforage": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", + "integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=", + "requires": { + "localforage": "*" + } + }, "@types/node": { "version": "10.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.0.tgz", @@ -10804,6 +10812,14 @@ "json5": "^0.5.0" } }, + "localforage": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.2.tgz", + "integrity": "sha1-+kRCYC+Abt0rympUq05lbwMfEhw=", + "requires": { + "lie": "3.1.1" + } + }, "locate-path": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", diff --git a/package.json b/package.json index 3083892..979523e 100644 --- a/package.json +++ b/package.json @@ -75,13 +75,15 @@ "webpack-dev-server": "^2.11.2" }, "dependencies": { + "@types/jszip": "^3.1.4", + "@types/localforage": "0.0.34", "event-emitter": "^0.3.5", "jszip": "^3.1.5", + "localforage": "^1.7.2", "lodash": "^4.17.10", "marks-pane": "^1.0.9", "path-webpack": "0.0.3", "stream-browserify": "^2.0.1", - "xmldom": "^0.1.27", - "@types/jszip": "^3.1.4" + "xmldom": "^0.1.27" } } diff --git a/src/book.js b/src/book.js index eb0c827..9635a2a 100644 --- a/src/book.js +++ b/src/book.js @@ -13,6 +13,7 @@ import Rendition from "./rendition"; import Archive from "./archive"; import request from "./utils/request"; import EpubCFI from "./epubcfi"; +import Store from "./store"; import { EPUBJS_VERSION, EVENTS } from "./utils/constants"; const CONTAINER_PATH = "META-INF/container.xml"; @@ -39,6 +40,7 @@ const INPUT_TYPE = { * @param {string} [options.replacements=none] use base64, blobUrl, or none for replacing assets in archived Epubs * @param {method} [options.canonical] optional function to determine canonical urls for a path * @param {string} [options.openAs] optional string to determine the input type + * @param {string} [options.store=false] cache the contents in local storage, value should be the name of the reader * @returns {Book} * @example new Book("/path/to/book.epub", {}) * @example new Book({ replacements: "blobUrl" }) @@ -60,7 +62,8 @@ class Book { encoding: undefined, replacements: undefined, canonical: undefined, - openAs: undefined + openAs: undefined, + store: undefined }); extend(this.settings, options); @@ -173,6 +176,13 @@ class Book { */ this.archive = undefined; + /** + * @member {Store} storage + * @memberof Book + * @private + */ + this.storage = undefined; + /** * @member {Resources} resources * @memberof Book @@ -202,6 +212,9 @@ class Book { this.packaging = undefined; // this.toc = undefined; + if (this.settings.store) { + this.store(); + } if(url) { this.open(url, this.settings.openAs).catch((error) => { @@ -233,7 +246,7 @@ class Book { } else if (type === INPUT_TYPE.EPUB) { this.archived = true; this.url = new Url("/", ""); - opening = this.request(input, "binary",this.settings.requestCredentials) + opening = this.request(input, "binary", this.settings.requestCredentials) .then(this.openEpub.bind(this)); } else if(type == INPUT_TYPE.OPF) { this.url = new Url(input); @@ -318,13 +331,10 @@ class Book { * @return {Promise} returns a promise with the requested resource */ load(path) { - var resolved; - + var resolved = this.resolve(path); if(this.archived) { - resolved = this.resolve(path); return this.archive.request(resolved); } else { - resolved = this.resolve(path); return this.request(resolved, null, this.settings.requestCredentials, this.settings.requestHeaders); } } @@ -558,6 +568,61 @@ class Book { return this.archive.open(input, encoding); } + /** + * Store the epubs contents + * @private + * @param {binary} input epub data + * @param {string} [encoding] + * @return {Store} + */ + store() { + // Use "blobUrl" or "base64" for replacements + let replacementsSetting = this.settings.replacements && this.settings.replacements !== "none"; + // Save original url + let originalUrl = this.url; + // Save original request method + let requester = this.settings.requestMethod || request.bind(this); + // Create new Store + this.storage = new Store(this.settings.store, requester, this.resolve.bind(this)); + // Replace request method to go through store + this.request = this.storage.request.bind(this.storage); + + this.opened.then(() => { + if (this.archived) { + this.storage.requester = this.archive.request.bind(this.archive); + } + // Substitute hook + let substituteResources = (output, section) => { + section.output = this.resources.substitute(output, section.url); + }; + + // Set to use replacements + this.resources.settings.replacements = replacementsSetting || "blobUrl"; + // Create replacement urls + this.resources.replacements(). + then(() => { + return this.resources.replaceCss(); + }); + + this.storage.on("offline", () => { + // Remove url to use relative resolving for hrefs + this.url = new Url("/", ""); + // Add hook to replace resources in contents + this.spine.hooks.serialize.register(substituteResources); + }); + + this.storage.on("online", () => { + // Restore original url + this.url = originalUrl; + // Remove hook + this.spine.hooks.serialize.deregister(substituteResources); + }); + + }); + + return this.storage; + } + /** * Get the cover url * @return {string} coverUrl diff --git a/src/managers/default/index.js b/src/managers/default/index.js index 8deeeca..2095465 100644 --- a/src/managers/default/index.js +++ b/src/managers/default/index.js @@ -457,7 +457,7 @@ class DefaultViewManager { } } }.bind(this), (err) => { - displaying.reject(err); + return err; }) .then(function(){ this.views.show(); @@ -529,7 +529,7 @@ class DefaultViewManager { } } }.bind(this), (err) => { - displaying.reject(err); + return err; }) .then(function(){ if(this.isPaginated && this.settings.axis === "horizontal") { diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..522c977 --- /dev/null +++ b/src/store.js @@ -0,0 +1,349 @@ +import {defer, isXml, parse} from "./utils/core"; +import httpRequest from "./utils/request"; +import mime from "../libs/mime/mime"; +import Path from "./utils/path"; +import EventEmitter from "event-emitter"; + +/** + * Handles saving and requesting files from local storage + * @class + */ +class Store { + + constructor(name, requester, resolver) { + this.urlCache = {}; + + this.requester = requester || httpRequest; + this.resolver = resolver; + + this.online = true; + + this.checkRequirements(); + + // This should be the name of the application for modals + localforage.config({ + name: name || 'epubjs' + }); + + this.addListeners(); + } + + /** + * Checks to see if localForage exists in global namspace, + * Requires localForage if it isn't there + * @private + */ + checkRequirements(){ + try { + if (typeof localForage === "undefined") { + let localForage = require("localforage"); + } + } catch (e) { + throw new Error("localForage lib not loaded"); + } + } + + /** + * Add online and offline event listeners + * @private + */ + addListeners() { + window.addEventListener('online', this.status.bind(this)); + window.addEventListener('offline', this.status.bind(this)); + } + + /** + * Remove online and offline event listeners + * @private + */ + removeListeners() { + window.removeEventListener('online', this.status.bind(this)); + window.removeEventListener('offline', this.status.bind(this)); + } + + /** + * Update the online / offline status + * @private + */ + status(event) { + let online = navigator.onLine; + this.online = online; + if (online) { + this.emit("online", this); + } else { + this.emit("offline", this); + } + } + + /** + * Add all of a book resources to the store + * @param {Resources} resources book resources + * @param {boolean} [force] force resaving resources + * @return {Promise} store objects + */ + add(resources, force) { + let mapped = resources.resources.map((item) => { + let { href } = item; + let url = this.resolver(href); + let encodedUrl = window.encodeURIComponent(url); + + return localforage.getItem(encodedUrl).then((item) => { + if (!item || force) { + return this.requester(url, "binary") + .then((data) => { + return localforage.setItem(encodedUrl, data); + }); + } else { + return item; + } + }); + + }); + return Promise.all(mapped); + } + + /** + * Request a url from storage + * @param {string} url a url to request from storage + * @param {string} [type] specify the type of the returned result + * @param {boolean} [withCredentials] + * @param {object} [headers] + * @return {Promise} + */ + request(url, type, withCredentials, headers){ + var deferred = new defer(); + var response; + var path = new Path(url); + + if (this.online) { + return this.requester(url, type, withCredentials, headers).then((data) => { + // from network + let encodedUrl = window.encodeURIComponent(url); + + localforage.getItem(encodedUrl).then((result) => { + if (!result) { + this.requester(url, "binary", withCredentials, headers).then((data) => { + localforage.setItem(encodedUrl, data); + }); + } + }); + + return data; + }) + } else { + // If type isn't set, determine it from the file extension + if(!type) { + type = path.extension; + } + + if(type == "blob"){ + response = this.getBlob(url); + } else { + response = this.getText(url); + } + + + return response.then((r) => { + var deferred = new defer(); + var result; + if (r) { + result = this.handleResponse(r, type); + deferred.resolve(result); + } else { + deferred.reject({ + message : "File not found in storage: " + url, + stack : new Error().stack + }); + } + return deferred.promise; + }); + } + + } + + /** + * Handle the response from request + * @private + * @param {any} response + * @param {string} [type] + * @return {any} the parsed result + */ + handleResponse(response, type){ + var r; + + if(type == "json") { + r = JSON.parse(response); + } + else + if(isXml(type)) { + r = parse(response, "text/xml"); + } + else + if(type == "xhtml") { + r = parse(response, "application/xhtml+xml"); + } + else + if(type == "html" || type == "htm") { + r = parse(response, "text/html"); + } else { + r = response; + } + + return r; + } + + /** + * Get a Blob from Storage by Url + * @param {string} url + * @param {string} [mimeType] + * @return {Blob} + */ + getBlob(url, mimeType){ + let encodedUrl = window.encodeURIComponent(url); + + return localforage.getItem(encodedUrl).then(function(uint8array) { + if(!uint8array) return; + + mimeType = mimeType || mime.lookup(url); + + return new Blob([uint8array], {type : mimeType}); + }); + + } + + /** + * Get Text from Storage by Url + * @param {string} url + * @param {string} [mimeType] + * @return {string} + */ + getText(url, mimeType){ + let encodedUrl = window.encodeURIComponent(url); + + mimeType = mimeType || mime.lookup(url); + + return localforage.getItem(encodedUrl).then(function(uint8array) { + var deferred = new defer(); + var reader = new FileReader(); + var blob = new Blob([uint8array], {type : mimeType}); + + if(!blob) return; + + reader.addEventListener("loadend", () => { + deferred.resolve(reader.result); + }); + + reader.readAsText(blob, mimeType); + + return deferred.promise; + }); + } + + /** + * Get a base64 encoded result from Storage by Url + * @param {string} url + * @param {string} [mimeType] + * @return {string} base64 encoded + */ + getBase64(url, mimeType){ + let encodedUrl = window.encodeURIComponent(url); + + mimeType = mimeType || mime.lookup(url); + + return localforage.getItem(encodedUrl).then((uint8array) => { + var deferred = new defer(); + var reader = new FileReader(); + var blob = new Blob([uint8array], {type : mimeType}); + + if(!blob) return; + + reader.addEventListener("loadend", () => { + deferred.resolve(reader.result); + }); + reader.readAsDataURL(blob, mimeType); + + return deferred.promise; + }); + } + + /** + * Create a Url from a stored item + * @param {string} url + * @param {object} [options.base64] use base64 encoding or blob url + * @return {Promise} url promise with Url string + */ + createUrl(url, options){ + var deferred = new defer(); + var _URL = window.URL || window.webkitURL || window.mozURL; + var tempUrl; + var response; + var useBase64 = options && options.base64; + + if(url in this.urlCache) { + deferred.resolve(this.urlCache[url]); + return deferred.promise; + } + + if (useBase64) { + response = this.getBase64(url); + + if (response) { + response.then(function(tempUrl) { + + this.urlCache[url] = tempUrl; + deferred.resolve(tempUrl); + + }.bind(this)); + + } + + } else { + + response = this.getBlob(url); + + if (response) { + response.then(function(blob) { + + tempUrl = _URL.createObjectURL(blob); + this.urlCache[url] = tempUrl; + deferred.resolve(tempUrl); + + }.bind(this)); + + } + } + + + if (!response) { + deferred.reject({ + message : "File not found in storage: " + url, + stack : new Error().stack + }); + } + + return deferred.promise; + } + + /** + * Revoke Temp Url for a achive item + * @param {string} url url of the item in the store + */ + revokeUrl(url){ + var _URL = window.URL || window.webkitURL || window.mozURL; + var fromCache = this.urlCache[url]; + if(fromCache) _URL.revokeObjectURL(fromCache); + } + + destroy() { + var _URL = window.URL || window.webkitURL || window.mozURL; + for (let fromCache in this.urlCache) { + _URL.revokeObjectURL(fromCache); + } + this.urlCache = {}; + this.removeListeners(); + } +} + +EventEmitter(Store.prototype); + +export default Store; diff --git a/src/utils/hook.js b/src/utils/hook.js index e7e8e3d..4df5376 100644 --- a/src/utils/hook.js +++ b/src/utils/hook.js @@ -28,6 +28,21 @@ class Hook { } } + /** + * Removes a function + * @example this.content.deregister(function(){...}); + */ + deregister(func){ + let hook; + for (let i = 0; i < this.hooks.length; i++) { + hook = this.hooks[i]; + if (hook === func) { + this.hooks.splice(i, 1); + break; + } + } + } + /** * Triggers a hook to run all functions * @example this.content.trigger(args).then(function(){...}); diff --git a/src/utils/path.js b/src/utils/path.js index c1b48b8..6a060cb 100644 --- a/src/utils/path.js +++ b/src/utils/path.js @@ -77,6 +77,12 @@ class Path { * @returns {string} relative */ relative (what) { + var isAbsolute = what && (what.indexOf("://") > -1); + + if (isAbsolute) { + return what; + } + return path.relative(this.directory, what); } diff --git a/types/store.d.ts b/types/store.d.ts new file mode 100644 index 0000000..bc41f50 --- /dev/null +++ b/types/store.d.ts @@ -0,0 +1,26 @@ +import localForage = require('localforage'); +import Resources from "./resources"; + +export default class Store { + constructor(); + + add(resources: Resources, force?: boolean): Promise>; + + request(url: string, type?: string, withCredentials?: boolean, headers?: object): Promise; + + getBlob(url: string, mimeType?: string): Promise; + + getText(url: string): Promise; + + getBase64(url: string, mimeType?: string): Promise; + + createUrl(url: string, options: { base64: boolean }): Promise; + + revokeUrl(url: string): void; + + destroy(): void; + + private checkRequirements(): void; + + private handleResponse(response: any, type?: string): Blob | string | JSON | Document | XMLDocument; +} diff --git a/types/utils/hook.d.ts b/types/utils/hook.d.ts index 05834ce..9db1491 100644 --- a/types/utils/hook.d.ts +++ b/types/utils/hook.d.ts @@ -8,6 +8,8 @@ export default class Hook { register(func: Function): void; register(arr: Array): void; + deregister(func: Function): void; + trigger(...args: any[]): Promise; list(): Array; diff --git a/types/utils/request.d.ts b/types/utils/request.d.ts index a18f58c..e6c9653 100644 --- a/types/utils/request.d.ts +++ b/types/utils/request.d.ts @@ -1 +1 @@ -export default function request(url: string, type: string, withCredentials: boolean, headers: object): Promise; +export default function request(url: string, type?: string, withCredentials?: boolean, headers?: object): Promise;