From 5880d3beb6643a006f0de2c812c6037443ee0c55 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 30 Oct 2023 18:25:43 -0400 Subject: [PATCH] [fix] handle sw unsupported, [fix] sw install / upgrade, [add] local file upload / delete --- assets/lib/sw-helper.js | 11 +- assets/offline/index.html | 103 +++++++++- assets/offline/index.js | 405 +++++++++++++++++++++++++++----------- assets/reader/index.js | 67 +++++-- assets/style.css | 26 +++ assets/sw.js | 12 +- templates/document.html | 2 +- 7 files changed, 487 insertions(+), 139 deletions(-) diff --git a/assets/lib/sw-helper.js b/assets/lib/sw-helper.js index 989cfed..254f4ad 100644 --- a/assets/lib/sw-helper.js +++ b/assets/lib/sw-helper.js @@ -12,7 +12,7 @@ const SW = (function () { let swInstance = null; let outstandingMessages = {}; - navigator.serviceWorker.addEventListener("message", ({ data }) => { + navigator.serviceWorker?.addEventListener("message", ({ data }) => { let { id } = data; data = data.data; @@ -25,6 +25,9 @@ const SW = (function () { }); async function install() { + if (!navigator.serviceWorker) + throw new Error("Service Worker Not Supported"); + // Register Service Worker swInstance = await navigator.serviceWorker.register("/sw.js"); swInstance.onupdatefound = (data) => @@ -38,9 +41,11 @@ const SW = (function () { await new Promise((resolve) => { serviceWorker.onstatechange = (data) => { console.log("[SW.install] State Change:", serviceWorker.state); - if (serviceWorker.state == "activated") resolve(); + if (["installed", "activated"].includes(serviceWorker.state)) resolve(); }; - if (serviceWorker.state == "activated") resolve(); + + console.log("[SW.install] Current State:", serviceWorker.state); + if (["installed", "activated"].includes(serviceWorker.state)) resolve(); }); } diff --git a/assets/offline/index.html b/assets/offline/index.html index 5bc24dc..9cb1424 100644 --- a/assets/offline/index.html +++ b/assets/offline/index.html @@ -27,6 +27,8 @@ + + @@ -96,7 +98,7 @@ >
You're Online:
Loading... @@ -175,5 +177,102 @@
+ + + diff --git a/assets/offline/index.js b/assets/offline/index.js index 54ff3ab..e8f4fa0 100644 --- a/assets/offline/index.js +++ b/assets/offline/index.js @@ -1,142 +1,319 @@ -/** - * TODO: - * - Offling / Online Checker - * - Flush oustanding read activity & progress - * - No files cached - * - Upload Files - **/ - -const BASE_ITEM = ` -
-
-
- - - -
-
-
-
-

Title

-

- N/A -

-
-
-
-
-

Author

-

- N/A -

-
-
-
-
-

Progress

-

- 0% -

-
-
-
- -
-
`; - const GET_SW_CACHE = "GET_SW_CACHE"; const DEL_SW_CACHE = "DEL_SW_CACHE"; -async function initOffline() { - updateOnlineIndicator(); +// ----------------------------------------------------------------------- // +// --------------------------- Event Listeners --------------------------- // +// ----------------------------------------------------------------------- // +/** + * Initial load handler. Gets called on DOMContentLoaded. + **/ +async function handleLoad() { + handleOnlineChange(); + + // If SW Redirected if (document.location.pathname !== "/offline") window.history.replaceState(null, null, "/offline"); - // Ensure Installed - await SW.install(); + // Create Upload Listener + let uploadButton = document.querySelector("button"); + uploadButton.addEventListener("click", handleFileAdd); - // Get Service Worker Cache & Local Cache - Override Local - let swCache = await SW.send({ type: GET_SW_CACHE }); - let allCache = await Promise.all( - swCache.map(async (item) => { - let localCache = await IDB.get("PROGRESS-" + item.id); - if (localCache) { - item.progress = localCache.progress; - item.percentage = Math.round(localCache.percentage * 10000) / 100; - } + // Ensure Installed -> Get Cached Items + let swCache = await SW.install() + // Get Service Worker Cache Books + .then(async () => { + let swResponse = await SW.send({ type: GET_SW_CACHE }); + return Promise.all( + // Normalize Cached Results + swResponse.map(async (item) => { + let localCache = await IDB.get("PROGRESS-" + item.id); + if (localCache) { + item.progress = localCache.progress; + item.percentage = Math.round(localCache.percentage * 10000) / 100; + } - return item; + // Additional Values + item.fileURL = "/documents/" + item.id + "/file"; + item.coverURL = "/documents/" + item.id + "/cover"; + item.type = "REMOTE"; + + return item; + }) + ); }) - ); + // Fail Nicely -> Allows Local Feature + .catch((e) => { + console.log("[loadContent] Service Worker Cache Error:", e); + return []; + }); - populateDOM(allCache); + // Get & Normalize Local Books + let localResponse = await IDB.find(/^FILE-.{32}$/, false); + let localCache = await Promise.all(localResponse.map(getLocalProgress)); + + // Populate DOM with Cache & Local Books + populateDOMBooks([...swCache, ...localCache]); } /** - * Populate DOM with cached documents. + * Update DOM to indicate online status. If no argument is passed, we attempt + * to determine online status via `navigator.onLine`. **/ -function populateDOM(data) { - let allDocuments = document.querySelector("#items"); +function handleOnlineChange(isOnline) { + let onlineEl = document.querySelector("#online"); + isOnline = isOnline == undefined ? navigator.onLine : isOnline; + onlineEl.hidden = !isOnline; +} - // Update Loader / No Results Indicator - let loadingEl = document.querySelector("#loading"); - if (data.length == 0) loadingEl.innerText = "No Results"; - else loadingEl.remove(); +/** + * Allow deleting local or remote cached files. Deleting remotely cached files + * does not remove progress. Progress will still be flushed once online. + **/ +async function handleFileDelete(event, item) { + let mainEl = + event.target.parentElement.parentElement.parentElement.parentElement + .parentElement; - data.forEach((item) => { - // Create Main Element - let baseEl = document.createElement("div"); - baseEl.innerHTML = BASE_ITEM; - baseEl = baseEl.firstElementChild; + if (item.type == "LOCAL") { + await IDB.del("FILE-" + item.id); + await IDB.del("FILE-METADATA-" + item.id); + } else if (item.type == "REMOTE") { + let swResp = await SW.send({ type: DEL_SW_CACHE, id: item.id }); + if (swResp != "SUCCESS") + throw new Error("[handleFileDelete] Service Worker Error"); + } - // Get Elements - let coverEl = baseEl.querySelector("a img"); - let [titleEl, authorEl, percentageEl] = baseEl.querySelectorAll("p + p"); - let downloadEl = baseEl.querySelector("svg").parentElement; + console.log("[handleFileDelete] Item Deleted"); - // Set Variables - downloadEl.setAttribute("href", "/documents/" + item.id + "/file"); - coverEl.setAttribute("src", "/documents/" + item.id + "/cover"); - coverEl.parentElement.setAttribute("href", "/reader#id=" + item.id); - titleEl.textContent = item.title; - authorEl.textContent = item.author; - percentageEl.textContent = item.percentage + "%"; - - allDocuments.append(baseEl); - }); + mainEl.remove(); + updateMessage(); } /** * Allow adding file to offline reader. Add to IndexedDB, * and later upload? Add style indicating external file? **/ -function handleFileAdd() {} +async function handleFileAdd() { + const fileInput = document.getElementById("document_file"); + const file = fileInput.files[0]; -function updateOnlineIndicator(isOnline) { - let onlineEl = document.querySelector("#online"); - isOnline = isOnline == undefined ? navigator.onLine : isOnline; - onlineEl.hidden = !isOnline; + if (!file) return console.log("[handleFileAdd] No File"); + + function readFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => resolve(event.target.result); + reader.onerror = (error) => reject(error); + + reader.readAsArrayBuffer(file); + }); + } + + function randomID() { + return "00000000000000000000000000000000".replace(/[018]/g, (c) => + ( + c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) + ).toString(16) + ); + } + + let newID = randomID(); + + readFile(file) + // Store Blob in IDB + .then((fileData) => { + if (!isEpubFile(fileData)) throw new Error("Invalid File Type"); + + return IDB.set( + "FILE-" + newID, + new Blob([fileData], { type: "application/octet-binary" }) + ); + }) + // Process File + .then(() => getLocalProgress("FILE-" + newID)) + // Populate in DOM + .then((item) => populateDOMBooks([item])) + // Hide Add File Button + .then(() => { + let addButtonEl = document.querySelector("#add-file-button"); + addButtonEl.checked = false; + }) + // Logging + .then(() => console.log("[handleFileAdd] File Add Successfully")) + .catch((e) => console.log("[handleFileAdd] File Add Failed:", e)); } -// Initialize -window.addEventListener("DOMContentLoaded", initOffline); -window.addEventListener("online", () => updateOnlineIndicator(true)); -window.addEventListener("offline", () => updateOnlineIndicator(false)); +// Add Event Listeners +window.addEventListener("DOMContentLoaded", handleLoad); +window.addEventListener("online", () => handleOnlineChange(true)); +window.addEventListener("offline", () => handleOnlineChange(false)); + +// ----------------------------------------------------------------------- // +// ------------------------------- Helpers ------------------------------- // +// ----------------------------------------------------------------------- // + +/** + * Update the message element. Called after initial load, on item add or on + * item delete. + **/ +function updateMessage() { + // Update Loader / No Results Indicator + let itemsEl = document.querySelector("#items"); + let messageEl = document.querySelector("#message"); + + if (itemsEl.children.length == 0) { + messageEl.innerText = "No Results"; + messageEl.hidden = false; + } else messageEl.hidden = true; +} + +/** + * Populate DOM with cached documents. + **/ +function populateDOMBooks(data) { + let allDocuments = document.querySelector("#items"); + + // Create Document Items + data.forEach((item) => { + // Create Main Element + let baseEl = document.querySelector("#item-template").cloneNode(true); + baseEl.removeAttribute("id"); + + // Get Elements + let [titleEl, authorEl, percentageEl] = baseEl.querySelectorAll("p + p"); + let [svgDivEl, textEl] = baseEl.querySelector("strong").children; + let coverEl = baseEl.querySelector("a img"); + let downloadEl = baseEl.querySelector("svg").parentElement; + let deleteInputEl = baseEl.querySelector("#delete-button"); + let deleteLabelEl = deleteInputEl.previousElementSibling; + let deleteTextEl = baseEl.querySelector("input + div span"); + + // Set Download Attributes + downloadEl.setAttribute("href", item.fileURL); + downloadEl.setAttribute( + "download", + item.title + " - " + item.author + ".epub" + ); + + // Set Cover Attributes + coverEl.setAttribute("src", item.coverURL); + coverEl.parentElement.setAttribute( + "href", + "/reader#id=" + item.id + "&type=" + item.type + ); + + // Set Additional Metadata Attributes + titleEl.textContent = item.title; + authorEl.textContent = item.author; + percentageEl.textContent = item.percentage + "%"; + + // Set Remote / Local Indicator + let newSvgEl = + item.type == "LOCAL" + ? document.querySelector("#local-svg-template").cloneNode(true) + : document.querySelector("#remote-svg-template").cloneNode(true); + svgDivEl.append(newSvgEl); + textEl.textContent = item.type; + + // Delete Item + deleteInputEl.setAttribute("id", "delete-button-" + item.id); + deleteLabelEl.setAttribute("for", "delete-button-" + item.id); + deleteTextEl.addEventListener("click", (e) => handleFileDelete(e, item)); + deleteTextEl.textContent = + item.type == "LOCAL" ? "Delete Local" : "Delete Cache"; + + allDocuments.append(baseEl); + }); + + updateMessage(); +} + +/** + * Given an item id, generate expected item format from IDB data store. + **/ +async function getLocalProgress(id) { + // Get Metadata (Cover Always Needed) + let fileBlob = await IDB.get(id); + let fileURL = URL.createObjectURL(fileBlob); + let metadata = await getMetadata(fileURL); + + // Attempt Cache + let documentID = id.replace("FILE-", ""); + let documentData = await IDB.get("FILE-METADATA-" + documentID); + if (documentData) + return { ...documentData, fileURL, coverURL: metadata.coverURL }; + + // Create Starting Progress + let newProgress = { + id: documentID, + title: metadata.title, + author: metadata.author, + type: "LOCAL", + percentage: 0, + progress: "", + words: 0, + }; + + // Update Cache + await IDB.set("FILE-METADATA-" + documentID, newProgress); + + // Return Cache + coverURL + return { ...newProgress, fileURL, coverURL: metadata.coverURL }; +} + +/** + * Retrieve the Title, Author, and CoverURL (blob) for a given file. + **/ +async function getMetadata(fileURL) { + let book = ePub(fileURL, { openAs: "epub" }); + console.log({ book }); + let coverURL = (await book.coverUrl()) || "/assets/images/no-cover.jpg"; + let metadata = await book.loaded.metadata; + + let title = + metadata.title && metadata.title != "" ? metadata.title : "Unknown"; + let author = + metadata.creator && metadata.creator != "" ? metadata.creator : "Unknown"; + + book.destroy(); + + return { title, author, coverURL }; +} + +/** + * Validate filetype. We check the headers and validate that they are ZIP. + * After which we validate contents. This isn't 100% effective, but unless + * someone is trying to trick it, it should be fine. + **/ +function isEpubFile(arrayBuffer) { + const view = new DataView(arrayBuffer); + + // Too Small + if (view.byteLength < 4) { + return false; + } + + // Check for the ZIP file signature (PK) + const littleEndianSignature = view.getUint16(0, true); + const bigEndianSignature = view.getUint16(0, false); + + if (littleEndianSignature !== 0x504b && bigEndianSignature !== 0x504b) { + return false; + } + + // Additional Checks (No FP on ZIP) + const textDecoder = new TextDecoder(); + const zipContent = textDecoder.decode(new Uint8Array(arrayBuffer)); + + if ( + zipContent.includes("mimetype") && + zipContent.includes("META-INF/container.xml") + ) { + return true; + } + + return false; +} diff --git a/assets/reader/index.js b/assets/reader/index.js index dca1992..3fe4867 100644 --- a/assets/reader/index.js +++ b/assets/reader/index.js @@ -1,20 +1,26 @@ const THEMES = ["light", "tan", "blue", "gray", "black"]; const THEME_FILE = "/assets/reader/readerThemes.css"; +/** + * Initial load handler. Gets called on DOMContentLoaded. Responsible for + * normalizing the documentData depending on type (REMOTE or LOCAL), and + * populating the metadata of the book into the DOM. + **/ async function initReader() { let documentData; let filePath; + // Get Document ID & Type const urlParams = new URLSearchParams(window.location.hash.slice(1)); const documentID = urlParams.get("id"); - const localID = urlParams.get("local"); + const documentType = urlParams.get("type"); - if (documentID) { + if (documentType == "REMOTE") { // Get Server / Cached Document let progressResp = await fetch("/documents/" + documentID + "/progress"); documentData = await progressResp.json(); - // Update Local Cache + // Update With Local Cache let localCache = await IDB.get("PROGRESS-" + documentID); if (localCache) { documentData.progress = localCache.progress; @@ -22,27 +28,33 @@ async function initReader() { } filePath = "/documents/" + documentID + "/file"; - } else if (localID) { - // Get Local Document - // TODO: - // - IDB FileID - // - IDB Metadata + } else if (documentType == "LOCAL") { + documentData = await IDB.get("FILE-METADATA-" + documentID); + let fileBlob = await IDB.get("FILE-" + documentID); + filePath = URL.createObjectURL(fileBlob); } else { - throw new Error("Invalid"); + throw new Error("Invalid Type"); } - populateMetadata(documentData); + // Update Type + documentData.type = documentType; + + // Populate Metadata & Create Reader window.currentReader = new EBookReader(filePath, documentData); + populateMetadata(documentData); } +/** + * Populates metadata into the DOM. Specifically for the top "drop" down. + **/ function populateMetadata(data) { - let documentLocation = data.id.startsWith("local-") - ? "/offline" - : "/documents/" + data.id; + let documentLocation = + data.type == "LOCAL" ? "/offline" : "/documents/" + data.id; - let documentCoverLocation = data.id.startsWith("local-") - ? "/assets/images/no-cover.jpg" - : "/documents/" + data.id + "/cover"; + let documentCoverLocation = + data.type == "LOCAL" + ? "/assets/images/no-cover.jpg" + : "/documents/" + data.id + "/cover"; let [backEl, coverEl] = document.querySelectorAll("a"); backEl.setAttribute("href", documentLocation); @@ -54,6 +66,11 @@ function populateMetadata(data) { authorEl.innerText = data.author; } +/** + * This is the main reader class. All functionality is wrapped in this class. + * Responsible for handling gesture / clicks, flushing progress & activity, + * storing and processing themes, etc. + **/ class EBookReader { bookState = { currentWord: 0, @@ -734,7 +751,10 @@ class EBookReader { ], }; - // Flush -> Offline Cache IDB + // Local Files + if (this.bookState.type == "LOCAL") return; + + // Remote Flush -> Offline Cache IDB this.flushActivity(activityEvent).catch(async (e) => { console.error("[createActivity] Activity Flush Failed:", { error: e, @@ -790,7 +810,18 @@ class EBookReader { progress: this.bookState.progress, }; - // Flush -> Offline Cache IDB + // Update Local Metadata + if (this.bookState.type == "LOCAL") { + let currentMetadata = await IDB.get("FILE-METADATA-" + this.bookState.id); + return IDB.set("FILE-METADATA-" + this.bookState.id, { + ...currentMetadata, + progress: progressEvent.progress, + percentage: Math.round(progressEvent.percentage * 10000) / 100, + words: this.bookState.words, + }); + } + + // Remote Flush -> Offline Cache IDB this.flushProgress(progressEvent).catch(async (e) => { console.error("[createProgress] Progress Flush Failed:", { error: e, diff --git a/assets/style.css b/assets/style.css index 61191ed..5befff9 100644 --- a/assets/style.css +++ b/assets/style.css @@ -829,6 +829,10 @@ video { height: 8rem; } +.h-4 { + height: 1rem; +} + .h-48 { height: 12rem; } @@ -885,6 +889,10 @@ video { width: 8rem; } +.w-4 { + width: 1rem; +} + .w-40 { width: 10rem; } @@ -1157,6 +1165,14 @@ video { border-bottom-left-radius: 0.25rem; } +.rounded-bl { + border-bottom-left-radius: 0.25rem; +} + +.rounded-tr { + border-top-right-radius: 0.25rem; +} + .border { border-width: 1px; } @@ -1236,6 +1252,11 @@ video { background-color: rgb(0 0 0 / var(--tw-bg-opacity)); } +.bg-blue-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity)); +} + .bg-blue-700 { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity)); @@ -1553,6 +1574,11 @@ video { color: rgb(0 0 0 / var(--tw-text-opacity)); } +.text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); +} + .text-gray-200 { --tw-text-opacity: 1; color: rgb(229 231 235 / var(--tw-text-opacity)); diff --git a/assets/sw.js b/assets/sw.js index 4ba0789..000a360 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -200,7 +200,17 @@ function handleMessage(event) { event.source.postMessage({ id, data: cachedDocuments }); }); } else if (data.type === DEL_SW_CACHE) { - // TODO + let basePath = "/documents/" + data.id; + caches + .open(SW_CACHE_NAME) + .then((cache) => + Promise.all([ + cache.delete(basePath + "/file"), + cache.delete(basePath + "/progress"), + ]) + ) + .then(() => event.source.postMessage({ id, data: "SUCCESS" })) + .catch(() => event.source.postMessage({ id, data: "FAILURE" })); } else { event.source.postMessage({ id, data: { pong: 1 } }); } diff --git a/templates/document.html b/templates/document.html index 28798fb..96b52d9 100644 --- a/templates/document.html +++ b/templates/document.html @@ -17,7 +17,7 @@ {{ if .Data.Filepath }} Read {{ end }}