diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15af1aa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.xpi diff --git a/background.js b/background.js index 5cc289a..e28d45f 100644 --- a/background.js +++ b/background.js @@ -8,6 +8,22 @@ let contextReadyPromise = null; let contextReadyResolver = null; let preferWindowContext = true; +function forwardProgressToClient(pending, message) { + if (!pending || pending.tabId == null) { + return; + } + const payload = { + type: "download-progress", + requestId: message.requestId, + payload: message.payload + }; + if (pending.frameId != null) { + chrome.tabs.sendMessage(pending.tabId, payload, { frameId: pending.frameId }).catch(() => {}); + } else { + chrome.tabs.sendMessage(pending.tabId, payload).catch(() => {}); + } +} + function createReadyPromise() { contextReadyPromise = new Promise((resolve) => { contextReadyResolver = resolve; @@ -147,7 +163,7 @@ function shouldRetryInWindow(error, pending) { return message.toLowerCase().includes("domparser"); } -async function startDownloadTask(bookId, options, sendResponse, attempt = 0) { +async function startDownloadTask(bookId, options, sendResponse, attempt = 0, clientInfo = {}) { try { await ensureDocumentContext(); } catch (error) { @@ -157,7 +173,31 @@ async function startDownloadTask(bookId, options, sendResponse, attempt = 0) { const requestId = crypto.randomUUID(); const contextMode = executionContext.mode ?? (preferWindowContext ? "window" : "offscreen"); - pendingResponses.set(requestId, { sendResponse, bookId, options, attempt, contextMode }); + const tabId = typeof clientInfo.tabId === "number" ? clientInfo.tabId : null; + const frameId = typeof clientInfo.frameId === "number" ? clientInfo.frameId : null; + + const pendingEntry = { + sendResponse, + bookId, + options, + attempt, + contextMode, + tabId, + frameId, + clientInfo + }; + + pendingResponses.set(requestId, pendingEntry); + + if (tabId != null) { + forwardProgressToClient(pendingEntry, { + requestId, + payload: { + stage: "starting", + bookId + } + }); + } try { await chrome.runtime.sendMessage({ @@ -189,7 +229,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { kindle: Boolean(message.kindle) }; - startDownloadTask(bookId, options, sendResponse); + const clientInfo = { + tabId: sender?.tab?.id ?? null, + frameId: typeof sender?.frameId === "number" ? sender.frameId : null + }; + + startDownloadTask(bookId, options, sendResponse, 0, clientInfo); return true; } @@ -212,7 +257,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { .catch(() => {}) .finally(() => { resetContextState(); - startDownloadTask(pending.bookId, pending.options, pending.sendResponse, pending.attempt + 1); + startDownloadTask( + pending.bookId, + pending.options, + pending.sendResponse, + pending.attempt + 1, + pending.clientInfo || {} + ); }); return false; } @@ -223,5 +274,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return false; } + if (message?.type === "offscreen-download-progress") { + const { requestId } = message; + const pending = requestId ? pendingResponses.get(requestId) : null; + if (pending) { + forwardProgressToClient(pending, message); + } + return false; + } + return undefined; }); diff --git a/content.js b/content.js index 4a1594e..b1a1862 100644 --- a/content.js +++ b/content.js @@ -103,30 +103,68 @@ return button; } - function handleDownload(button, bookId) { + function setButtonState(button, state, message) { + if (!button) { + return; + } + switch (state) { + case "busy": + button.disabled = true; + button.style.opacity = "0.7"; + break; + case "idle": + button.disabled = false; + button.style.opacity = ""; + break; + case "error": + button.disabled = false; + button.style.opacity = ""; + break; + default: + break; + } + if (message) { + button.textContent = message; + } + } + + function setProgressMessage(wrapper, message) { + if (!wrapper) { + return; + } + let status = wrapper.querySelector(".reilly-progress-status"); + if (!status) { + return; + } + status.textContent = message; + } + + function handleDownload(button, bookId, wrapper, frameId) { if (!bookId) { return; } const originalText = button.textContent; - button.disabled = true; - button.style.opacity = "0.7"; - button.textContent = "Downloading…"; + setButtonState(button, "busy", "Downloading…"); + setProgressMessage(wrapper, "Starting download..."); - chrome.runtime.sendMessage({ type: "downloadBook", bookId }, (response) => { + chrome.runtime.sendMessage({ type: "downloadBook", bookId, frameId }, (response) => { const error = chrome.runtime.lastError; if (error) { console.error("SafariBooks Downloader:", error); - button.textContent = "Failed (extension error)"; + setButtonState(button, "error", "Failed (extension error)"); + setProgressMessage(wrapper, "Failed: extension error."); } else if (response?.ok) { - button.textContent = "Download started"; + setButtonState(button, "busy", "Download started"); + setProgressMessage(wrapper, "Packaging and saving EPUB..."); } else { - button.textContent = response?.error ? `Failed: ${response.error}` : "Download failed"; + const errorMessage = response?.error ? `Failed: ${response.error}` : "Download failed"; + setButtonState(button, "error", errorMessage); + setProgressMessage(wrapper, errorMessage); } setTimeout(() => { button.textContent = originalText; - button.disabled = false; - button.style.opacity = ""; + setButtonState(button, "idle"); }, response?.ok ? 2000 : 4000); }); } @@ -154,10 +192,6 @@ return; } - const button = createButton(); - button.dataset.bookId = bookId; - button.addEventListener("click", () => handleDownload(button, bookId)); - const wrapperId = `${BUTTON_ID}-wrapper`; let wrapper = document.getElementById(wrapperId); if (!wrapper) { @@ -167,18 +201,133 @@ wrapper.style.alignItems = "center"; wrapper.style.gap = "12px"; wrapper.style.marginLeft = "12px"; + + const button = createButton(); + button.dataset.bookId = bookId; + button.addEventListener("click", () => handleDownload(button, bookId, wrapper)); + wrapper.appendChild(button); + const progressContainer = document.createElement("span"); + progressContainer.className = "reilly-progress"; + progressContainer.style.display = "inline-flex"; + progressContainer.style.flexDirection = "column"; + progressContainer.style.background = "#e2e8f0"; + progressContainer.style.borderRadius = "4px"; + progressContainer.style.padding = "6px 8px"; + progressContainer.style.minWidth = "160px"; + progressContainer.style.border = "1px solid #cbd5e1"; + progressContainer.style.fontSize = "0.75rem"; + progressContainer.style.color = "#1e293b"; + progressContainer.style.lineHeight = "1.4"; + + const progressLabel = document.createElement("span"); + progressLabel.textContent = "Progress"; + progressLabel.style.fontSize = "0.7rem"; + progressLabel.style.textTransform = "uppercase"; + progressLabel.style.fontWeight = "700"; + progressLabel.style.color = "#475569"; + progressLabel.style.letterSpacing = "0.05em"; + + const progressStatus = document.createElement("span"); + progressStatus.className = "reilly-progress-status"; + progressStatus.textContent = "Waiting"; + + progressContainer.appendChild(progressLabel); + progressContainer.appendChild(progressStatus); + wrapper.appendChild(progressContainer); if (titleElement.parentElement) { titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling); } else { titleElement.insertAdjacentElement("afterend", wrapper); } - } else if (!wrapper.contains(button)) { - wrapper.appendChild(button); + } else { + const button = wrapper.querySelector(`#${BUTTON_ID}`) || createButton(); + button.dataset.bookId = bookId; + if (!wrapper.contains(button)) { + wrapper.appendChild(button); + } } } + chrome.runtime.onMessage.addListener((message) => { + if (!message || message.type !== "download-progress") { + return; + } + const wrapper = document.getElementById(`${BUTTON_ID}-wrapper`); + if (!wrapper) { + return; + } + const button = wrapper.querySelector(`#${BUTTON_ID}`); + const { payload = {} } = message; + if (!payload.stage) { + return; + } + switch (payload.stage) { + case "starting": + setButtonState(button, "busy", "Preparing..."); + setProgressMessage(wrapper, "Preparing download..."); + break; + case "session-check": + setProgressMessage(wrapper, "Verifying session..."); + break; + case "session-ok": + setProgressMessage(wrapper, "Session verified."); + break; + case "metadata": + setProgressMessage(wrapper, payload.title ? `Metadata: ${payload.title}` : "Metadata loaded."); + break; + case "chapters-discovered": + setProgressMessage( + wrapper, + `Chapters: ${payload.total ?? "?"}${payload.total === 1 ? "" : " chapters"}` + ); + break; + case "chapter-start": + setProgressMessage( + wrapper, + `Chapter ${payload.index}/${payload.total}: ${payload.title || ""}` + ); + break; + case "images-start": + setProgressMessage( + wrapper, + `Images: 0/${payload.total} (max ${payload.concurrency})` + ); + break; + case "images-progress": + setProgressMessage( + wrapper, + `Images: ${payload.completed}/${payload.total} (${payload.elapsedSeconds ?? 0}s)` + ); + break; + case "images-complete": + setProgressMessage(wrapper, `Images complete (${payload.completed})`); + break; + case "css-images-start": + setProgressMessage(wrapper, `CSS assets: ${payload.total}`); + break; + case "packaging-complete": + setProgressMessage(wrapper, `Packaging done (${payload.durationMs}ms)`); + break; + case "download-start": + setProgressMessage(wrapper, "Saving EPUB..."); + break; + case "complete": + setButtonState(button, "idle", "Download Complete"); + setProgressMessage(wrapper, "Download complete." ); + setTimeout(() => { + if (button) { + button.textContent = "Download EPUB"; + } + setProgressMessage(wrapper, "Waiting"); + }, 4000); + break; + default: + break; + } + }); + function observeForTitle() { if (observerInstance) { return; diff --git a/firefox/background.js b/firefox/background.js index af97fbb..7c4d0e6 100644 --- a/firefox/background.js +++ b/firefox/background.js @@ -2,27 +2,94 @@ import { SafariBooksDownloader } from "./safaribooks/downloader.js"; import { collectSessionCookies } from "./safaribooks/cookies.js"; const runtime = typeof browser !== "undefined" ? browser : chrome; +const pendingDownloads = new Map(); const downloader = new SafariBooksDownloader((message) => { console.log(`[BackgroundDownloader] ${message}`); +}, (payload) => { + const entry = payload?.bookId ? pendingDownloads.get(payload.bookId) : null; + if (!entry) { + return; + } + sendProgress(entry, payload); }); -runtime.runtime.onMessage.addListener(async (message) => { - if (message?.type !== "downloadBook") { +function sendProgress(entry, payload) { + if (!entry?.tabId) { + return; + } + const progressMessage = { + type: "download-progress", + payload + }; + + try { + if (typeof browser !== "undefined" && browser.tabs?.sendMessage) { + browser.tabs.sendMessage(entry.tabId, progressMessage).catch(() => {}); + } else { + chrome.tabs.sendMessage(entry.tabId, progressMessage, () => { + const error = chrome.runtime.lastError; + if (error) { + console.debug("Progress send error", error.message); + } + }); + } + } catch (error) { + console.debug("Unable to dispatch progress message", error); + } +} + +runtime.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || message.type !== "downloadBook") { return undefined; } - const { bookId, theme = "none", kindle = false } = message; + const bookId = message.bookId; if (!bookId) { - return { ok: false, error: "Missing book ID." }; + sendResponse({ ok: false, error: "Missing book ID." }); + return false; } + if (pendingDownloads.has(bookId)) { + sendResponse({ ok: false, error: "Download already in progress." }); + return false; + } + + const entry = { + bookId, + options: { + theme: message.theme ?? "none", + kindle: Boolean(message.kindle) + }, + tabId: sender?.tab?.id ?? null, + sendResponse + }; + + pendingDownloads.set(bookId, entry); + startDownload(entry).catch((error) => { + console.error("[BackgroundDownloader] unexpected failure", error); + finalizeDownload(entry, { ok: false, error: error.message || String(error) }); + }); + + return true; +}); + +async function startDownload(entry) { try { await collectSessionCookies(); - await downloader.download(bookId, { theme, kindle }); - return { ok: true }; + await downloader.download(entry.bookId, entry.options); + finalizeDownload(entry, { ok: true }); } catch (error) { console.error("[BackgroundDownloader] Download failed:", error); - return { ok: false, error: error.message }; + finalizeDownload(entry, { ok: false, error: error.message || String(error) }); } -}); +} + +function finalizeDownload(entry, response) { + pendingDownloads.delete(entry.bookId); + try { + entry.sendResponse?.(response); + } catch (error) { + console.debug("sendResponse failed", error); + } +} diff --git a/firefox/content.js b/firefox/content.js index edbd50d..62ff278 100644 --- a/firefox/content.js +++ b/firefox/content.js @@ -4,8 +4,6 @@ let observerInstance = null; let ensureIntervalId = null; let initialized = false; - const runtime = typeof browser !== "undefined" ? browser : chrome; - const rawChrome = typeof chrome !== "undefined" ? chrome : null; const TITLE_SELECTORS = [ '[data-test="book-header-title"]', @@ -105,56 +103,96 @@ return button; } - function sendRuntimeMessage(message) { - try { - const result = runtime.runtime.sendMessage(message); - if (result && typeof result.then === "function") { - return result; - } - } catch (error) { - return Promise.reject(error); + function setButtonState(button, state, message) { + if (!button) { + return; + } + switch (state) { + case "busy": + button.disabled = true; + button.style.opacity = "0.7"; + break; + case "idle": + button.disabled = false; + button.style.opacity = ""; + break; + case "error": + button.disabled = false; + button.style.opacity = ""; + break; + default: + break; + } + if (message) { + button.textContent = message; } - - return new Promise((resolve, reject) => { - runtime.runtime.sendMessage(message, (response) => { - const err = rawChrome?.runtime?.lastError; - if (err) { - reject(new Error(err.message)); - } else { - resolve(response); - } - }); - }); } - function handleDownload(button, bookId) { + function setProgressMessage(wrapper, message) { + if (!wrapper) { + return; + } + let status = wrapper.querySelector(".reilly-progress-status"); + if (!status) { + return; + } + status.textContent = message; + } + + function handleDownload(button, bookId, wrapper, frameId) { if (!bookId) { return; } const originalText = button.textContent; - button.disabled = true; - button.style.opacity = "0.7"; - button.textContent = "Downloading…"; + setButtonState(button, "busy", "Downloading…"); + setProgressMessage(wrapper, "Starting download..."); - sendRuntimeMessage({ type: "downloadBook", bookId }) - .then((response) => { - if (response?.ok) { - button.textContent = "Download started"; - } else { - button.textContent = response?.error ? `Failed: ${response.error}` : "Download failed"; - } - }) - .catch((error) => { + const runtime = typeof browser !== "undefined" ? browser : chrome; + const message = { type: "downloadBook", bookId, frameId }; + const sendPromise = runtime.runtime.sendMessage(message); + + const handleResponse = (response) => { + if (response?.ok) { + setButtonState(button, "busy", "Download started"); + setProgressMessage(wrapper, "Packaging and saving EPUB..."); + } else { + const errorMessage = response?.error ? `Failed: ${response.error}` : "Download failed"; + setButtonState(button, "error", errorMessage); + setProgressMessage(wrapper, errorMessage); + } + + setTimeout(() => { + button.textContent = originalText; + setButtonState(button, "idle"); + }, response?.ok ? 2000 : 4000); + }; + + if (sendPromise && typeof sendPromise.then === "function") { + sendPromise.then(handleResponse).catch((error) => { console.error("SafariBooks Downloader:", error); - button.textContent = "Failed (extension error)"; - }) - .finally(() => { + setButtonState(button, "error", "Failed (extension error)"); + setProgressMessage(wrapper, "Failed: extension error."); setTimeout(() => { button.textContent = originalText; - button.disabled = false; - button.style.opacity = ""; - }, 2000); + setButtonState(button, "idle"); + }, 4000); }); + } else { + chrome.runtime.sendMessage(message, (response) => { + const error = chrome.runtime.lastError; + if (error) { + console.error("SafariBooks Downloader:", error); + setButtonState(button, "error", "Failed (extension error)"); + setProgressMessage(wrapper, "Failed: extension error."); + setTimeout(() => { + button.textContent = originalText; + setButtonState(button, "idle"); + }, 4000); + return; + } + handleResponse(response); + }); + } } function injectButton() { @@ -180,10 +218,6 @@ return; } - const button = createButton(); - button.dataset.bookId = bookId; - button.addEventListener("click", () => handleDownload(button, bookId)); - const wrapperId = `${BUTTON_ID}-wrapper`; let wrapper = document.getElementById(wrapperId); if (!wrapper) { @@ -193,18 +227,133 @@ wrapper.style.alignItems = "center"; wrapper.style.gap = "12px"; wrapper.style.marginLeft = "12px"; + + const button = createButton(); + button.dataset.bookId = bookId; + button.addEventListener("click", () => handleDownload(button, bookId, wrapper)); + wrapper.appendChild(button); + const progressContainer = document.createElement("span"); + progressContainer.className = "reilly-progress"; + progressContainer.style.display = "inline-flex"; + progressContainer.style.flexDirection = "column"; + progressContainer.style.background = "#e2e8f0"; + progressContainer.style.borderRadius = "4px"; + progressContainer.style.padding = "6px 8px"; + progressContainer.style.minWidth = "160px"; + progressContainer.style.border = "1px solid #cbd5e1"; + progressContainer.style.fontSize = "0.75rem"; + progressContainer.style.color = "#1e293b"; + progressContainer.style.lineHeight = "1.4"; + + const progressLabel = document.createElement("span"); + progressLabel.textContent = "Progress"; + progressLabel.style.fontSize = "0.7rem"; + progressLabel.style.textTransform = "uppercase"; + progressLabel.style.fontWeight = "700"; + progressLabel.style.color = "#475569"; + progressLabel.style.letterSpacing = "0.05em"; + + const progressStatus = document.createElement("span"); + progressStatus.className = "reilly-progress-status"; + progressStatus.textContent = "Waiting"; + + progressContainer.appendChild(progressLabel); + progressContainer.appendChild(progressStatus); + wrapper.appendChild(progressContainer); if (titleElement.parentElement) { titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling); } else { titleElement.insertAdjacentElement("afterend", wrapper); } - } else if (!wrapper.contains(button)) { - wrapper.appendChild(button); + } else { + const button = wrapper.querySelector(`#${BUTTON_ID}`) || createButton(); + button.dataset.bookId = bookId; + if (!wrapper.contains(button)) { + wrapper.appendChild(button); + } } } + chrome.runtime.onMessage.addListener((message) => { + if (!message || message.type !== "download-progress") { + return; + } + const wrapper = document.getElementById(`${BUTTON_ID}-wrapper`); + if (!wrapper) { + return; + } + const button = wrapper.querySelector(`#${BUTTON_ID}`); + const { payload = {} } = message; + if (!payload.stage) { + return; + } + switch (payload.stage) { + case "starting": + setButtonState(button, "busy", "Preparing..."); + setProgressMessage(wrapper, "Preparing download..."); + break; + case "session-check": + setProgressMessage(wrapper, "Verifying session..."); + break; + case "session-ok": + setProgressMessage(wrapper, "Session verified."); + break; + case "metadata": + setProgressMessage(wrapper, payload.title ? `Metadata: ${payload.title}` : "Metadata loaded."); + break; + case "chapters-discovered": + setProgressMessage( + wrapper, + `Chapters: ${payload.total ?? "?"}${payload.total === 1 ? "" : " chapters"}` + ); + break; + case "chapter-start": + setProgressMessage( + wrapper, + `Chapter ${payload.index}/${payload.total}: ${payload.title || ""}` + ); + break; + case "images-start": + setProgressMessage( + wrapper, + `Images: 0/${payload.total} (max ${payload.concurrency})` + ); + break; + case "images-progress": + setProgressMessage( + wrapper, + `Images: ${payload.completed}/${payload.total} (${payload.elapsedSeconds ?? 0}s)` + ); + break; + case "images-complete": + setProgressMessage(wrapper, `Images complete (${payload.completed})`); + break; + case "css-images-start": + setProgressMessage(wrapper, `CSS assets: ${payload.total}`); + break; + case "packaging-complete": + setProgressMessage(wrapper, `Packaging done (${payload.durationMs}ms)`); + break; + case "download-start": + setProgressMessage(wrapper, "Saving EPUB..."); + break; + case "complete": + setButtonState(button, "idle", "Download Complete"); + setProgressMessage(wrapper, "Download complete." ); + setTimeout(() => { + if (button) { + button.textContent = "Download EPUB"; + } + setProgressMessage(wrapper, "Waiting"); + }, 4000); + break; + default: + break; + } + }); + function observeForTitle() { if (observerInstance) { return; diff --git a/firefox/index.css b/firefox/index.css index 680e7a6..c503267 100644 --- a/firefox/index.css +++ b/firefox/index.css @@ -75,9 +75,6 @@ button { font-size: 0.95rem; font-weight: 600; cursor: pointer; -} - -button { background: #3a7bd5; color: #fff; } diff --git a/firefox/safaribooks/downloader.js b/firefox/safaribooks/downloader.js index e22453d..b6f8824 100644 --- a/firefox/safaribooks/downloader.js +++ b/firefox/safaribooks/downloader.js @@ -33,6 +33,10 @@ const KINDLE_CSS = "#sbo-rt-content table,#sbo-rt-content pre{overflow-x:unset!important;overflow:unset!important;" + "overflow-y:unset!important;white-space:pre-wrap!important;}"; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0))); +} + function formatTwoDigits(value) { return value.toString().padStart(2, "0"); } @@ -75,11 +79,12 @@ function toAbsoluteUrl(link, base) { } export class SafariBooksDownloader { - constructor(log = console.log) { + constructor(log = console.log, onProgress) { this.log = (message) => { const timestamp = new Date().toISOString(); log(`[${timestamp}] ${message}`); }; + this.onProgress = typeof onProgress === "function" ? onProgress : null; this.reset(); } @@ -110,6 +115,19 @@ export class SafariBooksDownloader { this.imageDownloads = new Map(); // relative path -> absolute url this.cssImageDownloads = new Map(); // path within Styles -> absolute url this.imagesFromLinks = new Set(); + + this.reportProgress("reset", { message: "Preparing download..." }); + } + + reportProgress(stage, data = {}) { + if (typeof this.onProgress !== "function") { + return; + } + try { + this.onProgress({ stage, bookId: this.bookId, ...data }); + } catch (error) { + console.warn("SafariBooksDownloader progress callback error", error); + } } getReferrerUrl() { @@ -141,7 +159,7 @@ export class SafariBooksDownloader { async applyRateLimit() { const waitTime = this.nextRequestTime - Date.now(); if (waitTime > 0) { - await new Promise((resolve) => setTimeout(resolve, waitTime)); + await sleep(waitTime); } } @@ -179,8 +197,8 @@ export class SafariBooksDownloader { computeRetryDelay(response, attempt, options = {}) { const status = response?.status ?? 0; - const base = options.baseRetryDelay ?? 750; - const cap = options.maxRetryDelay ?? 8000; + const base = options.baseRetryDelay ?? 200; + const cap = options.maxRetryDelay ?? 2000; const growth = Math.min(cap, base * Math.pow(2, attempt)); const jitter = Math.random() * 250; if (status === 429 || status === 503) { @@ -284,11 +302,18 @@ export class SafariBooksDownloader { this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`); updateRequestContext({ referer: this.getReferrerUrl() }); + this.reportProgress("starting", { bookId: this.bookId }); this.log(`Verifying session by loading profile page...`); + this.reportProgress("session-check"); await this.checkLogin(); + this.reportProgress("session-ok"); this.log(`Retrieving book metadata for ${bookId}...`); this.bookInfo = await this.fetchBookInfo(bookId); + this.reportProgress("metadata", { + title: this.bookInfo.title, + authors: (this.bookInfo.authors || []).map((author) => author.name).filter(Boolean) + }); this.baseUrl = this.bookInfo.web_url || this.bookInfo.url; this.updateReferrer(this.bookInfo.web_url || this.bookInfo.url); updateRequestContext({ referer: this.getReferrerUrl() }); @@ -298,24 +323,70 @@ export class SafariBooksDownloader { this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId); this.log(`Fetching table of chapters...`); + this.reportProgress("chapters-fetch"); this.bookChapters = await this.fetchChapters(this.bookInfo.chapters); this.log(`Chapters discovered: ${this.bookChapters.length}`); + this.reportProgress("chapters-discovered", { total: this.bookChapters.length }); await this.processChapters(); + this.reportProgress("chapters-complete", { total: this.chapterDocuments.length }); + const cssSourceCount = this.cssSources.length; + this.log( + cssSourceCount + ? `Fetching ${cssSourceCount} stylesheet source${cssSourceCount === 1 ? "" : "s"}...` + : "No external stylesheet sources to fetch." + ); + this.reportProgress("styles-start", { total: cssSourceCount }); await this.fetchCssSources(); + this.reportProgress("styles-complete", { total: this.cssContentMap.size }); + + const fontCount = this.fontDownloads.size; + this.log( + fontCount + ? `Fetching ${fontCount} font asset${fontCount === 1 ? "" : "s"}...` + : "No font assets to fetch." + ); + this.reportProgress("fonts-start", { total: fontCount }); await this.fetchFonts(); + this.reportProgress("fonts-complete", { total: this.fontDownloads.size }); + + const totalImages = Array.from(this.imageDownloads.values()).filter((value) => value).length; + const pendingImages = Array.from(this.imageDownloads.values()).filter((value) => !value?.data).length; + this.log( + pendingImages + ? `Fetching ${pendingImages} image asset${pendingImages === 1 ? "" : "s"} (registered: ${totalImages})...` + : "No image assets to fetch." + ); await this.fetchImages(); + + const cssImageTotal = this.cssImageDownloads.size; + this.log( + cssImageTotal + ? `Fetching ${cssImageTotal} CSS-linked asset${cssImageTotal === 1 ? "" : "s"}...` + : "No CSS-linked assets to fetch." + ); + this.reportProgress("css-images-start", { total: cssImageTotal }); await this.fetchCssImages(); + this.reportProgress("css-images-complete", { total: cssImageTotal }); const zip = await this.assembleEpub(); - const blob = new Blob([zip.generate()], { type: "application/epub+zip" }); + this.log("Packaging EPUB zip contents..."); + const zipStart = Date.now(); + const zipData = zip.generate(); + const zipDuration = Date.now() - zipStart; + this.log(`EPUB archive generated in ${zipDuration}ms. Preparing download...`); + this.reportProgress("packaging-complete", { durationMs: zipDuration }); + + const blob = new Blob([zipData], { type: "application/epub+zip" }); const url = URL.createObjectURL(blob); const filename = `${this.cleanTitle}/${this.outputFileName}.epub`; + this.reportProgress("download-start", { filename }); await chrome.downloads.download({ url, filename, saveAs: false }); this.log(`EPUB saved as ${filename}`); + this.reportProgress("complete", { filename }); } async checkLogin() { @@ -434,7 +505,7 @@ export class SafariBooksDownloader { : normalized.split("/").slice(-1)[0]; let trimmed = relCandidate.replace(/^\/+/, ""); for (const dir of imageDirs) { - if (trimmed.startsWith(dir)) { + if (trimmed.toLowerCase().startsWith(dir)) { trimmed = trimmed.slice(dir.length); break; } @@ -475,6 +546,17 @@ export class SafariBooksDownloader { const chapter = this.bookChapters[index]; const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml"); const assets = chapter.related_assets || {}; + const chapterTitle = chapter.title || `Chapter ${index + 1}`; + + this.log( + `Loading chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle}` + ); + const chapterFetchStart = Date.now(); + this.reportProgress("chapter-start", { + index: index + 1, + total: this.bookChapters.length, + title: chapterTitle + }); if (Array.isArray(assets.images)) { assets.images.forEach((img) => { @@ -496,8 +578,35 @@ export class SafariBooksDownloader { } const htmlRoot = await this.loadChapterDocument(chapter.content_url); + const fetchDurationMs = Date.now() - chapterFetchStart; + this.log( + `Fetched chapter ${index + 1}/${this.bookChapters.length} in ${fetchDurationMs}ms` + ); + this.reportProgress("chapter-fetched", { + index: index + 1, + total: this.bookChapters.length, + title: chapterTitle, + durationMs: fetchDurationMs + }); + const chapterParseStart = Date.now(); const firstPage = index === 0; - const { cssMarkup, xhtml, detectedCover } = this.parseHtml(htmlRoot, chapterStyles, firstPage); + const { cssMarkup, xhtml, detectedCover, pageCount } = this.parseHtml( + htmlRoot, + chapterStyles, + firstPage + ); + const parseDurationMs = Date.now() - chapterParseStart; + const pageCountLabel = typeof pageCount === "number" ? pageCount : "unknown"; + this.log( + `Processed chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle} (pages detected: ${pageCountLabel}) in ${parseDurationMs}ms` + ); + this.reportProgress("chapter-processed", { + index: index + 1, + total: this.bookChapters.length, + title: chapterTitle, + durationMs: parseDurationMs, + pages: pageCountLabel + }); if (detectedCover && !this.coverPath) { const coverRegistered = this.registerImage(detectedCover); if (coverRegistered) { @@ -595,6 +704,28 @@ export class SafariBooksDownloader { detectedCover = this.detectCoverImage(bookContent); } + const pageSelectors = [ + "[data-type='page']", + "[data-role='page']", + "[data-page-number]", + "[data-page]", + "[role='doc-pagebreak']", + "[epub\\:type='pagebreak']", + ".sbo-page" + ]; + const pageElements = new Set(); + for (const selector of pageSelectors) { + try { + bookContent.querySelectorAll(selector).forEach((element) => pageElements.add(element)); + } catch (_) { + // ignore selectors not supported in this document + } + } + if (pageElements.size === 0) { + bookContent.querySelectorAll(".page").forEach((element) => pageElements.add(element)); + } + const pageCount = pageElements.size > 0 ? pageElements.size : null; + const serializer = new XMLSerializer(); const xhtml = serializer.serializeToString(bookContent); const documentHtml = buildXhtml( @@ -608,7 +739,8 @@ export class SafariBooksDownloader { return { cssMarkup: pageCssBlocks.join("\n"), xhtml: documentHtml, - detectedCover + detectedCover, + pageCount }; } @@ -730,12 +862,60 @@ export class SafariBooksDownloader { } async fetchImages() { - for (const [relative, url] of this.imageDownloads.entries()) { - const result = await this.fetchImageWithFallback(relative, url); - if (result) { - this.imageDownloads.set(relative, result); + const pendingEntries = Array.from(this.imageDownloads.entries()).filter(([, entry]) => { + if (!entry) { + return true; } + return !entry.data; + }); + + if (!pendingEntries.length) { + this.reportProgress("images-complete", { total: 0, completed: 0 }); + return; } + + const total = pendingEntries.length; + const maxConcurrency = this.options?.imageConcurrency ?? 10; + const concurrency = Math.min(Math.max(1, maxConcurrency), total); + const startTime = Date.now(); + let completed = 0; + + this.reportProgress("images-start", { total, concurrency }); + + const worker = async () => { + while (pendingEntries.length) { + const nextEntry = pendingEntries.pop(); + if (!nextEntry) { + return; + } + const [relative, value] = nextEntry; + const primaryUrl = typeof value === "string" ? value : value?.sourceUrl; + if (!primaryUrl) { + completed += 1; + continue; + } + const result = await this.fetchImageWithFallback(relative, primaryUrl); + if (result) { + this.imageDownloads.set(relative, result); + } + completed += 1; + if (completed === total || completed % 25 === 0) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + this.log( + `Fetched ${completed}/${total} image assets (${elapsedSeconds}s elapsed)` + ); + this.reportProgress("images-progress", { + completed, + total, + elapsedSeconds + }); + } + } + }; + + await Promise.all(Array.from({ length: concurrency }).map(() => worker())); + const totalDurationMs = Date.now() - startTime; + this.reportProgress("images-complete", { total, completed: total, durationMs: totalDurationMs }); } async fetchImageWithFallback(relative, primaryUrl) { diff --git a/index.css b/index.css index 680e7a6..21d8fc0 100644 --- a/index.css +++ b/index.css @@ -63,7 +63,7 @@ select { font-size: 0.9rem; } -.buttons { +.actions { display: flex; } @@ -75,9 +75,6 @@ button { font-size: 0.95rem; font-weight: 600; cursor: pointer; -} - -button { background: #3a7bd5; color: #fff; } diff --git a/index.html b/index.html index 06cde58..5b2107a 100644 --- a/index.html +++ b/index.html @@ -30,7 +30,7 @@ Add Kindle compatibility tweaks -
+
diff --git a/index.js b/index.js index d2187d4..6eb79f9 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,6 @@ function appendLog(message) { logView.textContent += `[${timestamp}] ${message}\n`; logView.scrollTop = logView.scrollHeight; } - const downloader = new SafariBooksDownloader(appendLog); function setDisabled(disabled) { diff --git a/offscreen.js b/offscreen.js index e097a4d..284749e 100644 --- a/offscreen.js +++ b/offscreen.js @@ -3,8 +3,20 @@ import { collectSessionCookies } from "./safaribooks/cookies.js"; const downloader = new SafariBooksDownloader((message) => { console.info(`[SafariBooksDownloader] ${message}`); +}, (payload) => { + try { + chrome.runtime.sendMessage({ + type: "offscreen-download-progress", + requestId: currentRequestId, + payload + }); + } catch (_) { + // ignore communication errors + } }); +let currentRequestId = null; + async function handleOffscreenDownload(message) { const { requestId, bookId, options = {} } = message; if (!bookId) { @@ -18,6 +30,7 @@ async function handleOffscreenDownload(message) { } try { + currentRequestId = requestId; await collectSessionCookies(); await downloader.download(bookId, { theme: options.theme ?? "none", @@ -36,6 +49,7 @@ async function handleOffscreenDownload(message) { error: error.message }); } + currentRequestId = null; } chrome.runtime.onMessage.addListener((message) => { diff --git a/safaribooks/downloader.js b/safaribooks/downloader.js index 7041a06..b6f8824 100644 --- a/safaribooks/downloader.js +++ b/safaribooks/downloader.js @@ -79,11 +79,12 @@ function toAbsoluteUrl(link, base) { } export class SafariBooksDownloader { - constructor(log = console.log) { + constructor(log = console.log, onProgress) { this.log = (message) => { const timestamp = new Date().toISOString(); log(`[${timestamp}] ${message}`); }; + this.onProgress = typeof onProgress === "function" ? onProgress : null; this.reset(); } @@ -114,6 +115,19 @@ export class SafariBooksDownloader { this.imageDownloads = new Map(); // relative path -> absolute url this.cssImageDownloads = new Map(); // path within Styles -> absolute url this.imagesFromLinks = new Set(); + + this.reportProgress("reset", { message: "Preparing download..." }); + } + + reportProgress(stage, data = {}) { + if (typeof this.onProgress !== "function") { + return; + } + try { + this.onProgress({ stage, bookId: this.bookId, ...data }); + } catch (error) { + console.warn("SafariBooksDownloader progress callback error", error); + } } getReferrerUrl() { @@ -183,8 +197,8 @@ export class SafariBooksDownloader { computeRetryDelay(response, attempt, options = {}) { const status = response?.status ?? 0; - const base = options.baseRetryDelay ?? 750; - const cap = options.maxRetryDelay ?? 8000; + const base = options.baseRetryDelay ?? 200; + const cap = options.maxRetryDelay ?? 2000; const growth = Math.min(cap, base * Math.pow(2, attempt)); const jitter = Math.random() * 250; if (status === 429 || status === 503) { @@ -288,11 +302,18 @@ export class SafariBooksDownloader { this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`); updateRequestContext({ referer: this.getReferrerUrl() }); + this.reportProgress("starting", { bookId: this.bookId }); this.log(`Verifying session by loading profile page...`); + this.reportProgress("session-check"); await this.checkLogin(); + this.reportProgress("session-ok"); this.log(`Retrieving book metadata for ${bookId}...`); this.bookInfo = await this.fetchBookInfo(bookId); + this.reportProgress("metadata", { + title: this.bookInfo.title, + authors: (this.bookInfo.authors || []).map((author) => author.name).filter(Boolean) + }); this.baseUrl = this.bookInfo.web_url || this.bookInfo.url; this.updateReferrer(this.bookInfo.web_url || this.bookInfo.url); updateRequestContext({ referer: this.getReferrerUrl() }); @@ -302,24 +323,70 @@ export class SafariBooksDownloader { this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId); this.log(`Fetching table of chapters...`); + this.reportProgress("chapters-fetch"); this.bookChapters = await this.fetchChapters(this.bookInfo.chapters); this.log(`Chapters discovered: ${this.bookChapters.length}`); + this.reportProgress("chapters-discovered", { total: this.bookChapters.length }); await this.processChapters(); + this.reportProgress("chapters-complete", { total: this.chapterDocuments.length }); + const cssSourceCount = this.cssSources.length; + this.log( + cssSourceCount + ? `Fetching ${cssSourceCount} stylesheet source${cssSourceCount === 1 ? "" : "s"}...` + : "No external stylesheet sources to fetch." + ); + this.reportProgress("styles-start", { total: cssSourceCount }); await this.fetchCssSources(); + this.reportProgress("styles-complete", { total: this.cssContentMap.size }); + + const fontCount = this.fontDownloads.size; + this.log( + fontCount + ? `Fetching ${fontCount} font asset${fontCount === 1 ? "" : "s"}...` + : "No font assets to fetch." + ); + this.reportProgress("fonts-start", { total: fontCount }); await this.fetchFonts(); + this.reportProgress("fonts-complete", { total: this.fontDownloads.size }); + + const totalImages = Array.from(this.imageDownloads.values()).filter((value) => value).length; + const pendingImages = Array.from(this.imageDownloads.values()).filter((value) => !value?.data).length; + this.log( + pendingImages + ? `Fetching ${pendingImages} image asset${pendingImages === 1 ? "" : "s"} (registered: ${totalImages})...` + : "No image assets to fetch." + ); await this.fetchImages(); + + const cssImageTotal = this.cssImageDownloads.size; + this.log( + cssImageTotal + ? `Fetching ${cssImageTotal} CSS-linked asset${cssImageTotal === 1 ? "" : "s"}...` + : "No CSS-linked assets to fetch." + ); + this.reportProgress("css-images-start", { total: cssImageTotal }); await this.fetchCssImages(); + this.reportProgress("css-images-complete", { total: cssImageTotal }); const zip = await this.assembleEpub(); - const blob = new Blob([zip.generate()], { type: "application/epub+zip" }); + this.log("Packaging EPUB zip contents..."); + const zipStart = Date.now(); + const zipData = zip.generate(); + const zipDuration = Date.now() - zipStart; + this.log(`EPUB archive generated in ${zipDuration}ms. Preparing download...`); + this.reportProgress("packaging-complete", { durationMs: zipDuration }); + + const blob = new Blob([zipData], { type: "application/epub+zip" }); const url = URL.createObjectURL(blob); const filename = `${this.cleanTitle}/${this.outputFileName}.epub`; + this.reportProgress("download-start", { filename }); await chrome.downloads.download({ url, filename, saveAs: false }); this.log(`EPUB saved as ${filename}`); + this.reportProgress("complete", { filename }); } async checkLogin() { @@ -438,7 +505,7 @@ export class SafariBooksDownloader { : normalized.split("/").slice(-1)[0]; let trimmed = relCandidate.replace(/^\/+/, ""); for (const dir of imageDirs) { - if (trimmed.startsWith(dir)) { + if (trimmed.toLowerCase().startsWith(dir)) { trimmed = trimmed.slice(dir.length); break; } @@ -479,6 +546,17 @@ export class SafariBooksDownloader { const chapter = this.bookChapters[index]; const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml"); const assets = chapter.related_assets || {}; + const chapterTitle = chapter.title || `Chapter ${index + 1}`; + + this.log( + `Loading chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle}` + ); + const chapterFetchStart = Date.now(); + this.reportProgress("chapter-start", { + index: index + 1, + total: this.bookChapters.length, + title: chapterTitle + }); if (Array.isArray(assets.images)) { assets.images.forEach((img) => { @@ -500,8 +578,35 @@ export class SafariBooksDownloader { } const htmlRoot = await this.loadChapterDocument(chapter.content_url); + const fetchDurationMs = Date.now() - chapterFetchStart; + this.log( + `Fetched chapter ${index + 1}/${this.bookChapters.length} in ${fetchDurationMs}ms` + ); + this.reportProgress("chapter-fetched", { + index: index + 1, + total: this.bookChapters.length, + title: chapterTitle, + durationMs: fetchDurationMs + }); + const chapterParseStart = Date.now(); const firstPage = index === 0; - const { cssMarkup, xhtml, detectedCover } = this.parseHtml(htmlRoot, chapterStyles, firstPage); + const { cssMarkup, xhtml, detectedCover, pageCount } = this.parseHtml( + htmlRoot, + chapterStyles, + firstPage + ); + const parseDurationMs = Date.now() - chapterParseStart; + const pageCountLabel = typeof pageCount === "number" ? pageCount : "unknown"; + this.log( + `Processed chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle} (pages detected: ${pageCountLabel}) in ${parseDurationMs}ms` + ); + this.reportProgress("chapter-processed", { + index: index + 1, + total: this.bookChapters.length, + title: chapterTitle, + durationMs: parseDurationMs, + pages: pageCountLabel + }); if (detectedCover && !this.coverPath) { const coverRegistered = this.registerImage(detectedCover); if (coverRegistered) { @@ -599,6 +704,28 @@ export class SafariBooksDownloader { detectedCover = this.detectCoverImage(bookContent); } + const pageSelectors = [ + "[data-type='page']", + "[data-role='page']", + "[data-page-number]", + "[data-page]", + "[role='doc-pagebreak']", + "[epub\\:type='pagebreak']", + ".sbo-page" + ]; + const pageElements = new Set(); + for (const selector of pageSelectors) { + try { + bookContent.querySelectorAll(selector).forEach((element) => pageElements.add(element)); + } catch (_) { + // ignore selectors not supported in this document + } + } + if (pageElements.size === 0) { + bookContent.querySelectorAll(".page").forEach((element) => pageElements.add(element)); + } + const pageCount = pageElements.size > 0 ? pageElements.size : null; + const serializer = new XMLSerializer(); const xhtml = serializer.serializeToString(bookContent); const documentHtml = buildXhtml( @@ -612,7 +739,8 @@ export class SafariBooksDownloader { return { cssMarkup: pageCssBlocks.join("\n"), xhtml: documentHtml, - detectedCover + detectedCover, + pageCount }; } @@ -734,12 +862,60 @@ export class SafariBooksDownloader { } async fetchImages() { - for (const [relative, url] of this.imageDownloads.entries()) { - const result = await this.fetchImageWithFallback(relative, url); - if (result) { - this.imageDownloads.set(relative, result); + const pendingEntries = Array.from(this.imageDownloads.entries()).filter(([, entry]) => { + if (!entry) { + return true; } + return !entry.data; + }); + + if (!pendingEntries.length) { + this.reportProgress("images-complete", { total: 0, completed: 0 }); + return; } + + const total = pendingEntries.length; + const maxConcurrency = this.options?.imageConcurrency ?? 10; + const concurrency = Math.min(Math.max(1, maxConcurrency), total); + const startTime = Date.now(); + let completed = 0; + + this.reportProgress("images-start", { total, concurrency }); + + const worker = async () => { + while (pendingEntries.length) { + const nextEntry = pendingEntries.pop(); + if (!nextEntry) { + return; + } + const [relative, value] = nextEntry; + const primaryUrl = typeof value === "string" ? value : value?.sourceUrl; + if (!primaryUrl) { + completed += 1; + continue; + } + const result = await this.fetchImageWithFallback(relative, primaryUrl); + if (result) { + this.imageDownloads.set(relative, result); + } + completed += 1; + if (completed === total || completed % 25 === 0) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + this.log( + `Fetched ${completed}/${total} image assets (${elapsedSeconds}s elapsed)` + ); + this.reportProgress("images-progress", { + completed, + total, + elapsedSeconds + }); + } + } + }; + + await Promise.all(Array.from({ length: concurrency }).map(() => worker())); + const totalDurationMs = Date.now() - startTime; + this.reportProgress("images-complete", { total, completed: total, durationMs: totalDurationMs }); } async fetchImageWithFallback(relative, primaryUrl) {