Changelog: - forward download progress from background/offscreen workers to content frames - add progress UI and button state management in Safari Books page script - emit structured progress callbacks and faster image fetching in SafariBooksDownloader - sync Firefox bundle with progress plumbing and concurrency improvements - ignore packaged extension archives Description: Expose progress events through the downloader so the content script can present real-time status, including chapter and asset fetching milestones. Added concurrency controls, page detection, and packaging telemetry, while keeping Firefox build behavior in sync and ignoring generated .xpi bundles.
408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
(function () {
|
|
const BUTTON_ID = "reilly-safaribooks-download";
|
|
const BOOK_PATH_PART = "/library/view/";
|
|
let observerInstance = null;
|
|
let ensureIntervalId = null;
|
|
let initialized = false;
|
|
|
|
const TITLE_SELECTORS = [
|
|
'[data-test="book-header-title"]',
|
|
'[data-testid="book-title"]',
|
|
'[data-testid="content-header-title"]',
|
|
'[data-testid="content-header"] h1',
|
|
'[data-testid="book-detail-header"] h1',
|
|
'[data-ui-test="book-title"]',
|
|
'[role="heading"][aria-level="1"]',
|
|
"main h1",
|
|
"article h1",
|
|
"section h2",
|
|
"article h2",
|
|
"header h1",
|
|
"h1",
|
|
"h2"
|
|
];
|
|
|
|
function extractBookId() {
|
|
const segments = window.location.pathname.split("/").filter(Boolean).reverse();
|
|
const match = segments.find((segment) => /^[0-9]{9,}$/.test(segment));
|
|
return match || null;
|
|
}
|
|
|
|
function isBookPage() {
|
|
return window.location.pathname.includes(BOOK_PATH_PART);
|
|
}
|
|
|
|
function findTitleElement() {
|
|
for (const selector of TITLE_SELECTORS) {
|
|
const element = document.querySelector(selector);
|
|
if (element && element.textContent.trim().length > 0) {
|
|
return element;
|
|
}
|
|
}
|
|
const allHeadings = Array.from(document.querySelectorAll("h1"))
|
|
.concat(Array.from(document.querySelectorAll('[role="heading"][aria-level="1"]')));
|
|
return allHeadings.find((element) => element.textContent.trim().length > 0) || null;
|
|
}
|
|
|
|
function ensureFloatingButton(bookId) {
|
|
const floatingId = `${BUTTON_ID}-floating`;
|
|
let floating = document.getElementById(floatingId);
|
|
if (!floating) {
|
|
floating = document.createElement("button");
|
|
floating.id = floatingId;
|
|
floating.type = "button";
|
|
floating.textContent = "Download EPUB";
|
|
floating.style.position = "fixed";
|
|
floating.style.top = "96px";
|
|
floating.style.right = "24px";
|
|
floating.style.zIndex = "2147483647";
|
|
floating.style.padding = "8px 14px";
|
|
floating.style.borderRadius = "999px";
|
|
floating.style.border = "none";
|
|
floating.style.background = "#3a7bd5";
|
|
floating.style.color = "#fff";
|
|
floating.style.fontSize = "0.95rem";
|
|
floating.style.cursor = "pointer";
|
|
floating.style.boxShadow = "0 10px 25px rgba(58,123,213,0.35)";
|
|
floating.addEventListener("mouseenter", () => {
|
|
floating.style.background = "#255ca0";
|
|
});
|
|
floating.addEventListener("mouseleave", () => {
|
|
floating.style.background = "#3a7bd5";
|
|
});
|
|
document.body.appendChild(floating);
|
|
}
|
|
|
|
floating.dataset.bookId = bookId;
|
|
floating.onclick = () => handleDownload(floating, bookId);
|
|
}
|
|
|
|
function createButton() {
|
|
const button = document.createElement("button");
|
|
button.id = BUTTON_ID;
|
|
button.type = "button";
|
|
button.textContent = "Download EPUB";
|
|
button.style.padding = "6px 12px";
|
|
button.style.borderRadius = "4px";
|
|
button.style.border = "none";
|
|
button.style.background = "#3a7bd5";
|
|
button.style.color = "#fff";
|
|
button.style.fontSize = "0.9rem";
|
|
button.style.cursor = "pointer";
|
|
button.style.display = "inline-flex";
|
|
button.style.alignItems = "center";
|
|
button.style.gap = "4px";
|
|
button.style.boxShadow = "0 1px 2px rgba(0,0,0,0.12)";
|
|
button.style.transition = "background 0.2s ease";
|
|
button.addEventListener("mouseenter", () => {
|
|
button.style.background = "#2f6bbd";
|
|
});
|
|
button.addEventListener("mouseleave", () => {
|
|
button.style.background = "#3a7bd5";
|
|
});
|
|
return button;
|
|
}
|
|
|
|
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;
|
|
setButtonState(button, "busy", "Downloading…");
|
|
setProgressMessage(wrapper, "Starting download...");
|
|
|
|
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);
|
|
setButtonState(button, "error", "Failed (extension error)");
|
|
setProgressMessage(wrapper, "Failed: extension error.");
|
|
setTimeout(() => {
|
|
button.textContent = originalText;
|
|
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() {
|
|
const bookId = extractBookId();
|
|
if (!bookId) {
|
|
return;
|
|
}
|
|
|
|
const titleElement = findTitleElement();
|
|
if (!titleElement) {
|
|
ensureFloatingButton(bookId);
|
|
return;
|
|
}
|
|
|
|
const floating = document.getElementById(`${BUTTON_ID}-floating`);
|
|
if (floating) {
|
|
floating.remove();
|
|
}
|
|
|
|
const existing = document.getElementById(BUTTON_ID);
|
|
if (existing) {
|
|
existing.dataset.bookId = bookId;
|
|
return;
|
|
}
|
|
|
|
const wrapperId = `${BUTTON_ID}-wrapper`;
|
|
let wrapper = document.getElementById(wrapperId);
|
|
if (!wrapper) {
|
|
wrapper = document.createElement("span");
|
|
wrapper.id = wrapperId;
|
|
wrapper.style.display = "inline-flex";
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
observerInstance = new MutationObserver(() => {
|
|
if (!isBookPage()) {
|
|
return;
|
|
}
|
|
injectButton();
|
|
});
|
|
|
|
observerInstance.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
function init() {
|
|
if (!isBookPage()) {
|
|
return;
|
|
}
|
|
console.debug("[SafariBooks Downloader] content script active on", window.location.href);
|
|
if (initialized) {
|
|
injectButton();
|
|
return;
|
|
}
|
|
|
|
initialized = true;
|
|
injectButton();
|
|
observeForTitle();
|
|
window.addEventListener("popstate", injectButton);
|
|
const originalPushState = history.pushState;
|
|
history.pushState = function (...args) {
|
|
originalPushState.apply(this, args);
|
|
setTimeout(injectButton, 50);
|
|
};
|
|
const originalReplaceState = history.replaceState;
|
|
history.replaceState = function (...args) {
|
|
originalReplaceState.apply(this, args);
|
|
setTimeout(injectButton, 50);
|
|
};
|
|
ensureIntervalId = window.setInterval(() => {
|
|
if (isBookPage()) {
|
|
injectButton();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|