diff --git a/firefox/background.html b/firefox/background.html new file mode 100644 index 0000000..20de2b5 --- /dev/null +++ b/firefox/background.html @@ -0,0 +1,10 @@ + + + + + Reilly Downloader Background + + + + + diff --git a/firefox/background.js b/firefox/background.js new file mode 100644 index 0000000..af97fbb --- /dev/null +++ b/firefox/background.js @@ -0,0 +1,28 @@ +import { SafariBooksDownloader } from "./safaribooks/downloader.js"; +import { collectSessionCookies } from "./safaribooks/cookies.js"; + +const runtime = typeof browser !== "undefined" ? browser : chrome; + +const downloader = new SafariBooksDownloader((message) => { + console.log(`[BackgroundDownloader] ${message}`); +}); + +runtime.runtime.onMessage.addListener(async (message) => { + if (message?.type !== "downloadBook") { + return undefined; + } + + const { bookId, theme = "none", kindle = false } = message; + if (!bookId) { + return { ok: false, error: "Missing book ID." }; + } + + try { + await collectSessionCookies(); + await downloader.download(bookId, { theme, kindle }); + return { ok: true }; + } catch (error) { + console.error("[BackgroundDownloader] Download failed:", error); + return { ok: false, error: error.message }; + } +}); diff --git a/firefox/content.js b/firefox/content.js new file mode 100644 index 0000000..edbd50d --- /dev/null +++ b/firefox/content.js @@ -0,0 +1,259 @@ +(function () { + const BUTTON_ID = "reilly-safaribooks-download"; + const BOOK_PATH_PART = "/library/view/"; + 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"]', + '[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 sendRuntimeMessage(message) { + try { + const result = runtime.runtime.sendMessage(message); + if (result && typeof result.then === "function") { + return result; + } + } catch (error) { + return Promise.reject(error); + } + + 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) { + if (!bookId) { + return; + } + const originalText = button.textContent; + button.disabled = true; + button.style.opacity = "0.7"; + button.textContent = "Downloading…"; + + 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) => { + console.error("SafariBooks Downloader:", error); + button.textContent = "Failed (extension error)"; + }) + .finally(() => { + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + button.style.opacity = ""; + }, 2000); + }); + } + + 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 button = createButton(); + button.dataset.bookId = bookId; + button.addEventListener("click", () => handleDownload(button, bookId)); + + 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"; + wrapper.appendChild(button); + + if (titleElement.parentElement) { + titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling); + } else { + titleElement.insertAdjacentElement("afterend", wrapper); + } + } else if (!wrapper.contains(button)) { + wrapper.appendChild(button); + } + } + + 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(); + } +})(); diff --git a/firefox/firefox.zip b/firefox/firefox.zip new file mode 100644 index 0000000..28f7772 Binary files /dev/null and b/firefox/firefox.zip differ diff --git a/firefox/images/icon128.png b/firefox/images/icon128.png new file mode 100644 index 0000000..dd84812 Binary files /dev/null and b/firefox/images/icon128.png differ diff --git a/firefox/images/icon16.png b/firefox/images/icon16.png new file mode 100644 index 0000000..1d7d3ed Binary files /dev/null and b/firefox/images/icon16.png differ diff --git a/firefox/images/icon32.png b/firefox/images/icon32.png new file mode 100644 index 0000000..d062b33 Binary files /dev/null and b/firefox/images/icon32.png differ diff --git a/firefox/images/icon48.png b/firefox/images/icon48.png new file mode 100644 index 0000000..e97be9d Binary files /dev/null and b/firefox/images/icon48.png differ diff --git a/firefox/index.css b/firefox/index.css new file mode 100644 index 0000000..680e7a6 --- /dev/null +++ b/firefox/index.css @@ -0,0 +1,106 @@ +* { + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin: 0; + padding: 0; + background: #f5f6fa; + color: #1e1e1e; + width: 480px; +} + +main { + padding: 16px; +} + +h1 { + font-size: 1.3rem; + margin: 0 0 8px; +} + +h2 { + font-size: 1rem; + margin: 16px 0 8px; +} + +header p { + margin: 0 0 16px; + font-size: 0.85rem; + color: #444; +} + +form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field span { + font-size: 0.85rem; + font-weight: 600; +} + +input[type="text"], +select { + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.95rem; +} + +.checkbox { + flex-direction: row; + align-items: center; + gap: 8px; + font-size: 0.9rem; +} + +.buttons { + display: flex; +} + +button { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 4px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +button { + background: #3a7bd5; + color: #fff; +} + +#start-download:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.log-container { + border-top: 1px solid #dcdfe6; + margin-top: 16px; + padding-top: 8px; +} + +#log { + background: #0f172a; + color: #f8fafc; + padding: 12px; + height: 200px; + overflow-y: auto; + font-family: "Fira Code", "Consolas", "Courier New", monospace; + font-size: 0.8rem; + border-radius: 4px; + white-space: pre-wrap; +} diff --git a/firefox/index.html b/firefox/index.html new file mode 100644 index 0000000..06cde58 --- /dev/null +++ b/firefox/index.html @@ -0,0 +1,45 @@ + + + + + SafariBooks Downloader + + + +
+
+

SafariBooks Downloader

+

Port of safaribooks.py for Chrome. Make sure you are signed in to O'Reilly in this profile.

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

Activity

+

+      
+
+ + + diff --git a/firefox/index.js b/firefox/index.js new file mode 100644 index 0000000..88bc558 --- /dev/null +++ b/firefox/index.js @@ -0,0 +1,107 @@ +import { SafariBooksDownloader } from "./safaribooks/downloader.js"; +import { collectSessionCookies } from "./safaribooks/cookies.js"; + +const runtime = typeof browser !== "undefined" ? browser : chrome; +const rawChrome = typeof chrome !== "undefined" ? chrome : null; + +const form = document.getElementById("download-form"); +const bookIdInput = document.getElementById("book-id"); +const themeSelect = document.getElementById("theme"); +const kindleCheckbox = document.getElementById("kindle"); +const logView = document.getElementById("log"); +const downloadButton = document.getElementById("start-download"); + +function appendLog(message) { + const timestamp = new Date().toLocaleTimeString(); + logView.textContent += `[${timestamp}] ${message}\n`; + logView.scrollTop = logView.scrollHeight; +} + +const downloader = new SafariBooksDownloader(appendLog); + +function setDisabled(disabled) { + downloadButton.disabled = disabled; + bookIdInput.disabled = disabled; + themeSelect.disabled = disabled; + kindleCheckbox.disabled = disabled; +} + +function extractBookIdFromUrl(url) { + try { + const { pathname } = new URL(url); + const segments = pathname.split("/").filter(Boolean).reverse(); + return segments.find((segment) => /^[0-9]{9,}$/.test(segment)) || null; + } catch (_) { + return null; + } +} + +async function detectActiveBook() { + const tabsApi = runtime.tabs; + if (!tabsApi || typeof tabsApi.query !== "function") { + return; + } + + try { + let activeTab; + if (tabsApi.query.length <= 1) { + const tabs = await tabsApi.query({ active: true, currentWindow: true }); + activeTab = Array.isArray(tabs) ? tabs[0] : null; + } else { + activeTab = await new Promise((resolve, reject) => { + tabsApi.query({ active: true, currentWindow: true }, (tabs) => { + const err = rawChrome?.runtime?.lastError; + if (err) { + reject(new Error(err.message)); + } else { + resolve(Array.isArray(tabs) ? tabs[0] : null); + } + }); + }); + } + + if (!activeTab?.url || !activeTab.url.includes("/library/view/")) { + return; + } + + const detectedBookId = extractBookIdFromUrl(activeTab.url); + if (!detectedBookId) { + return; + } + + if (bookIdInput.value !== detectedBookId) { + bookIdInput.value = detectedBookId; + appendLog(`Detected book ID ${detectedBookId} from the active tab.`); + } + } catch (error) { + console.warn("SafariBooks Downloader: unable to detect active book.", error); + } +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + const bookId = bookIdInput.value.trim(); + if (!bookId) { + appendLog("Please enter a book ID."); + return; + } + setDisabled(true); + appendLog("Verifying O'Reilly session..."); + try { + const session = await collectSessionCookies(); + appendLog(`Session OK (cookies detected: ${session.count}).`); + appendLog(`Starting EPUB download for ${bookId}...`); + await downloader.download(bookId, { + theme: themeSelect.value, + kindle: kindleCheckbox.checked + }); + appendLog("Download completed."); + } catch (error) { + console.error(error); + appendLog(`Download failed: ${error.message}`); + } finally { + setDisabled(false); + } +}); + +detectActiveBook(); diff --git a/firefox/manifest.json b/firefox/manifest.json new file mode 100644 index 0000000..9940134 --- /dev/null +++ b/firefox/manifest.json @@ -0,0 +1,50 @@ +{ + "manifest_version": 2, + "name": "Reilly SafariBooks Downloader", + "description": "Download O'Reilly learning content as EPUB files using a port of safaribooks.py.", + "version": "0.1.0", + "browser_specific_settings": { + "gecko": { + "id": "reilly@safaribooks", + "strict_min_version": "115.0" + } + }, + "icons": { + "16": "images/icon16.png", + "32": "images/icon32.png", + "48": "images/icon48.png", + "128": "images/icon128.png" + }, + "background": { + "page": "background.html" + }, + "browser_action": { + "default_title": "SafariBooks Downloader", + "default_popup": "index.html", + "default_icon": { + "16": "images/icon16.png", + "32": "images/icon32.png", + "48": "images/icon48.png" + } + }, + "content_scripts": [ + { + "matches": [ + "https://learning.oreilly.com/library/view/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle" + } + ], + "permissions": [ + "downloads", + "cookies", + "tabs", + "storage", + "https://learning.oreilly.com/*", + "https://www.oreilly.com/*", + "https://api.oreilly.com/*" + ] +} diff --git a/firefox/safaribooks/cookies.js b/firefox/safaribooks/cookies.js new file mode 100644 index 0000000..d5ac3a3 --- /dev/null +++ b/firefox/safaribooks/cookies.js @@ -0,0 +1,64 @@ +const runtime = typeof browser !== "undefined" ? browser : chrome; +const rawChrome = typeof chrome !== "undefined" ? chrome : null; + +const PROFILE_URL = "https://learning.oreilly.com/profile/"; +const COOKIE_DOMAINS = ["learning.oreilly.com", ".oreilly.com", "www.oreilly.com"]; + +function callCookiesGetAll(filter) { + const result = runtime.cookies.getAll(filter); + if (result && typeof result.then === "function") { + return result; + } + + return new Promise((resolve, reject) => { + runtime.cookies.getAll(filter, (cookies) => { + const err = rawChrome?.runtime?.lastError; + if (err) { + reject(new Error(err.message)); + } else { + resolve(cookies); + } + }); + }); +} + +async function gatherCookies() { + const collected = {}; + for (const domain of COOKIE_DOMAINS) { + const cookies = await callCookiesGetAll({ domain }); + cookies.forEach((cookie) => { + collected[cookie.name] = cookie.value; + }); + } + return collected; +} + +export async function collectSessionCookies() { + let response; + try { + response = await fetch(PROFILE_URL, { credentials: "include" }); + } catch (error) { + throw new Error(`Unable to reach O'Reilly profile page: ${error.message}`); + } + + if (response.status === 401 || response.status === 403) { + throw new Error("You must be logged into learning.oreilly.com in this profile before downloading."); + } + + if (!response.ok) { + throw new Error(`Profile check failed (status ${response.status}).`); + } + + const jar = await gatherCookies(); + const count = Object.keys(jar).length; + if (!count) { + throw new Error("No O'Reilly cookies detected. Make sure you visited learning.oreilly.com in this profile."); + } + + return { + status: response.status, + count, + cookies: jar + }; +} + diff --git a/firefox/safaribooks/downloader.js b/firefox/safaribooks/downloader.js new file mode 100644 index 0000000..5516faa --- /dev/null +++ b/firefox/safaribooks/downloader.js @@ -0,0 +1,850 @@ +import { + makeFolderFriendlyName, + makeFileFriendlyName, + escapeXml, + isAbsoluteUrl, + isDocLink, + isImageLink, + makeValidId, + resolveStylesRelativePath, + ensureForwardSlashes, + uniqPush, + dataFromString, + isFontUrl, + isLikelyImageAsset +} from "./utils.js"; +import { SimpleZip } from "./zip.js"; + +const SAFARI_BASE_URL = "https://learning.oreilly.com"; +const API_V2_TEMPLATE = `${SAFARI_BASE_URL}/api/v2/epubs/urn:orm:book:`; +const API_V1_TEMPLATE = `${SAFARI_BASE_URL}/api/v1/book/`; +const PROFILE_URL = `${SAFARI_BASE_URL}/profile/`; +const IMAGES_BASE = "Images/"; + +const THEME_CSS = { + white: ".ucvMode-white{background:#ffffff;color:#101010;}", + sepia: ".ucvMode-sepia{background:#f4ecd8;color:#523f2a;} .ucvMode-sepia a{color:#274060;}", + black: ".ucvMode-black{background:#070707;color:#f4f4f5;} .ucvMode-black a{color:#60a5fa;}" +}; + +const KINDLE_CSS = + "#sbo-rt-content *{word-wrap:break-word!important;word-break:break-word!important;}" + + "#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 downloadWithWebExtensionsApi(options) { + if (typeof browser !== "undefined" && browser?.downloads?.download) { + return browser.downloads.download(options); + } + if (typeof chrome !== "undefined" && chrome?.downloads?.download) { + return new Promise((resolve, reject) => { + chrome.downloads.download(options, (downloadId) => { + const err = chrome.runtime?.lastError; + if (err) { + reject(new Error(err.message)); + } else { + resolve(downloadId); + } + }); + }); + } + return Promise.reject(new Error("Downloads API is not available in this browser.")); +} + +function formatTwoDigits(value) { + return value.toString().padStart(2, "0"); +} + +function buildXhtml(pageCss, innerHtml, themeMode, includeKindleCss, extraThemeCss = "") { + const kindleRule = includeKindleCss ? KINDLE_CSS : ""; + const themeRule = THEME_CSS[themeMode] || ""; + const additional = [themeRule, extraThemeCss].filter(Boolean).join("\n"); + return ( + ` + + + +${pageCss} + + +
${innerHtml}
+` + ); +} + +function isProbablySvgImage(element) { + return element.tagName?.toLowerCase() === "svg"; +} + +function toAbsoluteUrl(link, base) { + try { + return new URL(link, base).toString(); + } catch (_) { + return link; + } +} + +export class SafariBooksDownloader { + constructor(log = console.log) { + this.log = (message) => { + const timestamp = new Date().toISOString(); + log(`[${timestamp}] ${message}`); + }; + this.reset(); + } + + reset() { + this.bookInfo = null; + this.bookId = null; + this.bookChapters = []; + this.chapterDocuments = []; + this.cleanTitle = ""; + this.primaryAuthors = ""; + this.outputFileName = "download"; + this.baseUrl = ""; + this.coverPath = ""; + this.themeMode = "white"; + this.options = { theme: "none", kindle: false }; + + this.cssSources = []; + this.cssFilenameMap = new Map(); + this.cssContentMap = new Map(); + + this.fontDownloads = new Map(); // local path -> absolute url + this.imageDownloads = new Map(); // relative path -> absolute url + this.cssImageDownloads = new Map(); // path within Styles -> absolute url + this.imagesFromLinks = new Set(); + } + + async download(bookId, options = {}) { + this.reset(); + this.bookId = bookId; + this.options = { + theme: options.theme ?? "none", + kindle: Boolean(options.kindle) + }; + this.themeMode = this.options.theme === "none" ? "white" : this.options.theme; + + this.log(`Verifying session by loading profile page...`); + await this.checkLogin(); + + this.log(`Retrieving book metadata for ${bookId}...`); + this.bookInfo = await this.fetchBookInfo(bookId); + this.baseUrl = this.bookInfo.web_url || this.bookInfo.url; + this.cleanTitle = makeFolderFriendlyName(this.bookInfo.title, this.bookId); + const authorNames = (this.bookInfo.authors || []).map((author) => author.name).filter(Boolean); + this.primaryAuthors = authorNames.join(", ") || "Unknown Author"; + this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId); + + this.log(`Fetching table of chapters...`); + this.bookChapters = await this.fetchChapters(this.bookInfo.chapters); + this.log(`Chapters discovered: ${this.bookChapters.length}`); + + await this.processChapters(); + + await this.fetchCssSources(); + await this.fetchFonts(); + await this.fetchImages(); + await this.fetchCssImages(); + + const zip = await this.assembleEpub(); + + const blob = new Blob([zip.generate()], { type: "application/epub+zip" }); + const url = URL.createObjectURL(blob); + + const filename = `${this.cleanTitle}/${this.outputFileName}.epub`; + await downloadWithWebExtensionsApi({ url, filename, saveAs: false }); + this.log(`EPUB saved as ${filename}`); + } + + async checkLogin() { + const response = await fetch(PROFILE_URL, { credentials: "include" }); + if (!response.ok) { + throw new Error(`Authentication failed (status ${response.status}) when accessing ${PROFILE_URL}`); + } + const text = await response.text(); + if (text.includes('user_type":"Expired"')) { + throw new Error("Authentication issue: account subscription expired."); + } + } + + async fetchBookInfo(bookId) { + const infoResponse = await this.fetchJson(`${API_V2_TEMPLATE}${bookId}/`); + const v1Response = await this.fetchJson(`${API_V1_TEMPLATE}${bookId}/`); + const merged = { ...infoResponse }; + + for (const key of ["authors", "subjects", "topics", "rights", "publishers", "web_url"]) { + if (v1Response[key]) { + merged[key] = v1Response[key]; + } + } + if (!merged.url && merged.web_url) { + merged.url = merged.web_url; + } + return merged; + } + + async fetchChapters(initialUrl) { + if (!initialUrl) { + throw new Error("Missing chapter list URL in book metadata."); + } + const chapters = []; + let next = initialUrl; + while (next) { + const data = await this.fetchJson(next); + if (!Array.isArray(data.results)) { + throw new Error("Chapter response missing results array."); + } + chapters.push(...data.results); + next = data.next; + } + return chapters; + } + + async fetchJson(url) { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) { + throw new Error(`Request to ${url} failed with status ${response.status}`); + } + return response.json(); + } + + getChapterFilename(chapter) { + if (chapter.ourn) { + const lastPart = chapter.ourn.split("chapter:").pop(); + return lastPart.split("%2f").pop(); + } + return chapter.filename || `${chapter.title || "chapter"}.xhtml`; + } + + registerCssSource(url) { + const key = ensureForwardSlashes(url); + if (!this.cssFilenameMap.has(key)) { + const index = uniqPush(this.cssSources, key); + const filename = `Style${formatTwoDigits(index)}.css`; + this.cssFilenameMap.set(key, filename); + } + return this.cssFilenameMap.get(key); + } + + registerFont(baseUrl, relativePath) { + const localPath = resolveStylesRelativePath(relativePath); + if (!this.fontDownloads.has(localPath)) { + const absolute = toAbsoluteUrl(relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); + this.fontDownloads.set(localPath, absolute); + } + } + + registerImage(fullUrl) { + if (!fullUrl) { + return null; + } + const absoluteUrl = isAbsoluteUrl(fullUrl) + ? fullUrl + : toAbsoluteUrl(fullUrl, `${this.bookInfo.url.replace(/\/$/, "")}/files/`); + const local = this.localImagePath(absoluteUrl); + if (!local.fileName) { + return null; + } + const relative = local.folder ? `${local.folder}/${local.fileName}` : local.fileName; + if (!this.imageDownloads.has(relative)) { + this.imageDownloads.set(relative, absoluteUrl); + } + return `${IMAGES_BASE}${relative}`; + } + + localImagePath(url) { + const normalized = url.split("?")[0]; + const imageDirs = ["images/", "graphics/", "assets/"]; + if (!this.bookInfo?.url) { + const fileName = normalized.split("/").pop(); + return { fileName, folder: "" }; + } + const bookUrl = this.bookInfo.url.replace(/\/$/, ""); + const components = bookUrl.split("/"); + const bookIdSegment = components.pop(); + const base = `${bookIdSegment}/files/`; + const relCandidate = normalized.includes(base) + ? normalized.split(base).pop() + : normalized.split("/").slice(-1)[0]; + let trimmed = relCandidate.replace(/^\/+/, ""); + for (const dir of imageDirs) { + if (trimmed.startsWith(dir)) { + trimmed = trimmed.slice(dir.length); + break; + } + } + const parts = trimmed.split("/"); + const fileName = parts.pop(); + return { + fileName, + folder: parts.join("/") + }; + } + + rewriteLink(link) { + if (!link || link.startsWith("mailto")) { + return link; + } + if (!isAbsoluteUrl(link)) { + if (!isDocLink(link) && isImageLink(link)) { + const base = `${this.bookInfo.url.replace(/\/$/, "")}/files/`; + const absolute = toAbsoluteUrl(link, base); + const updated = this.registerImage(absolute); + if (updated) { + this.imagesFromLinks.add(absolute); + return updated; + } + } + return link.split("/").pop().replace(".html", ".xhtml"); + } + if (typeof this.bookInfo.url === "string" && link.includes(this.bookInfo.url)) { + const partial = link.replace(this.bookInfo.url, "").replace(/^\/+/, ""); + return this.rewriteLink(partial); + } + return link; + } + + async processChapters() { + for (let index = 0; index < this.bookChapters.length; index++) { + const chapter = this.bookChapters[index]; + const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml"); + const assets = chapter.related_assets || {}; + + if (Array.isArray(assets.images)) { + assets.images.forEach((img) => { + const url = isAbsoluteUrl(img) ? img : `${this.baseUrl}/files/${img}`; + this.registerImage(url); + }); + } + + const chapterStyles = []; + if (Array.isArray(assets.stylesheets)) { + chapterStyles.push( + ...assets.stylesheets.map((cssUrl) => (isAbsoluteUrl(cssUrl) ? cssUrl : `${this.baseUrl}${cssUrl}`)) + ); + } + if (Array.isArray(assets.site_styles)) { + chapterStyles.push( + ...assets.site_styles.map((cssUrl) => (isAbsoluteUrl(cssUrl) ? cssUrl : `${this.baseUrl}${cssUrl}`)) + ); + } + + const htmlRoot = await this.loadChapterDocument(chapter.content_url); + const firstPage = index === 0; + const { cssMarkup, xhtml, detectedCover } = this.parseHtml(htmlRoot, chapterStyles, firstPage); + if (detectedCover && !this.coverPath) { + const coverRegistered = this.registerImage(detectedCover); + if (coverRegistered) { + this.coverPath = coverRegistered; + } + } + this.chapterDocuments.push({ + title: chapter.title || `Chapter ${index + 1}`, + filename, + cssMarkup, + xhtml + }); + } + + if (!this.coverPath && typeof this.bookInfo.cover === "string") { + this.log("Falling back to book metadata cover image."); + const registered = this.registerImage(this.bookInfo.cover); + if (registered) { + this.coverPath = registered; + } + } + + const hasCoverChapter = this.chapterDocuments.some( + (chapter) => + chapter.filename.toLowerCase().includes("cover") || chapter.title.toLowerCase().includes("cover") + ); + if (!hasCoverChapter && this.coverPath) { + const coverMarkup = `
Book cover
`; + const coverDocument = buildXhtml("", coverMarkup, this.themeMode, this.options.kindle); + this.chapterDocuments.unshift({ + title: "Cover", + filename: "default_cover.xhtml", + cssMarkup: "", + xhtml: coverDocument + }); + } + } + + async loadChapterDocument(url) { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) { + throw new Error(`Failed to download chapter at ${url} (status ${response.status})`); + } + const htmlText = await response.text(); + const parser = new DOMParser(); + return parser.parseFromString(htmlText, "text/html"); + } + + parseHtml(rootDocument, chapterStylesheets, firstPage) { + const bookContent = rootDocument.querySelector("#sbo-rt-content"); + if (!bookContent) { + throw new Error("Chapter markup missing #sbo-rt-content node."); + } + + const pageCssBlocks = []; + + for (const cssUrl of chapterStylesheets) { + const filename = this.registerCssSource(cssUrl); + pageCssBlocks.push(``); + } + + const stylesheetLinks = rootDocument.querySelectorAll("link[rel='stylesheet']"); + stylesheetLinks.forEach((link) => { + const href = link.getAttribute("href"); + if (!href) { + return; + } + const cssUrl = href.startsWith("//") ? `https:${href}` : toAbsoluteUrl(href, this.baseUrl); + const filename = this.registerCssSource(cssUrl); + pageCssBlocks.push(``); + }); + + const inlineStyles = rootDocument.querySelectorAll("style"); + inlineStyles.forEach((styleEl) => { + if (styleEl.hasAttribute("data-template")) { + styleEl.textContent = styleEl.getAttribute("data-template"); + styleEl.removeAttribute("data-template"); + } + pageCssBlocks.push(styleEl.outerHTML); + }); + + this.rewriteLinksInNode(bookContent); + this.fixOverconstrainedImages(bookContent); + + let detectedCover = null; + if (firstPage) { + detectedCover = this.detectCoverImage(bookContent); + } + + const serializer = new XMLSerializer(); + const xhtml = serializer.serializeToString(bookContent); + const documentHtml = buildXhtml( + pageCssBlocks.join("\n"), + xhtml, + this.themeMode, + this.options.kindle, + this.options.theme !== "none" ? THEME_CSS[this.options.theme] : "" + ); + + return { + cssMarkup: pageCssBlocks.join("\n"), + xhtml: documentHtml, + detectedCover + }; + } + + rewriteLinksInNode(node) { + const elements = node.querySelectorAll("*"); + elements.forEach((el) => { + for (const attr of Array.from(el.attributes)) { + if (attr.name === "href" || attr.name === "src" || attr.name.endsWith(":href")) { + const rewritten = this.rewriteLink(attr.value); + if (rewritten) { + el.setAttribute(attr.name, rewritten); + } + } + } + }); + } + + fixOverconstrainedImages(node) { + const images = node.querySelectorAll("img"); + images.forEach((img) => { + const style = img.getAttribute("style"); + if (style && (style.includes("width") || style.includes("height"))) { + img.removeAttribute("width"); + img.removeAttribute("height"); + } + }); + const svgImages = node.querySelectorAll("svg image"); + svgImages.forEach((imageEl) => { + const hrefAttr = Array.from(imageEl.attributes).find((attr) => attr.name.endsWith("href")); + if (hrefAttr) { + const img = node.ownerDocument.createElement("img"); + img.setAttribute("src", this.rewriteLink(hrefAttr.value)); + const parent = imageEl.parentElement?.parentElement; + if (parent) { + parent.appendChild(img); + } + } + }); + } + + detectCoverImage(node) { + const selector = + "img[id*='cover' i], img[class*='cover' i], img[name*='cover' i], img[src*='cover' i], img[alt*='cover' i]"; + const coverImg = node.querySelector(selector); + if (coverImg) { + return coverImg.getAttribute("src"); + } + const fallback = node.querySelector("img"); + return fallback ? fallback.getAttribute("src") : null; + } + + async fetchCssSources() { + for (const source of this.cssSources) { + if (this.cssContentMap.has(source)) { + continue; + } + const response = await fetch(source, { credentials: "include" }); + if (!response.ok) { + this.log(`Warning: unable to retrieve CSS from ${source} (status ${response.status})`); + continue; + } + let cssContent = await response.text(); + cssContent = cssContent.replace(/display\s*:\s*none/gi, "visibility: hidden"); + this.cssContentMap.set(source, cssContent); + + this.registerFontsAndCssImages(source, cssContent); + } + } + + registerFontsAndCssImages(sourceUrl, cssContent) { + try { + const url = new URL(sourceUrl); + const base = `${url.origin}${url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1)}`; + const urlRegex = /url\(([^)]+)\)/gi; + let match = urlRegex.exec(cssContent); + while (match) { + let raw = match[1].trim().replace(/^['"]|['"]$/g, ""); + if (raw.startsWith("data:") || raw.startsWith("about:")) { + match = urlRegex.exec(cssContent); + continue; + } + const absolute = toAbsoluteUrl(raw, base); + if (isFontUrl(raw)) { + this.registerFont(base, raw); + } else if (isLikelyImageAsset(raw)) { + const localPath = resolveStylesRelativePath(raw); + if (!this.cssImageDownloads.has(localPath)) { + this.cssImageDownloads.set(localPath, absolute); + } + } + match = urlRegex.exec(cssContent); + } + } catch (_) { + // ignore malformed css urls + } + } + + async fetchFonts() { + for (const [localPath, url] of this.fontDownloads.entries()) { + if (this.cssImageDownloads.has(localPath)) { + continue; + } + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) { + this.log(`Warning: unable to retrieve font ${url} (status ${response.status})`); + continue; + } + const data = new Uint8Array(await response.arrayBuffer()); + this.cssImageDownloads.set(localPath, { data, inline: false, sourceUrl: url }); + } + } + + async fetchImages() { + for (const [relative, url] of this.imageDownloads.entries()) { + const result = await this.fetchImageWithFallback(relative, url); + if (result) { + this.imageDownloads.set(relative, result); + } + } + } + + async fetchImageWithFallback(relative, primaryUrl) { + const attempts = []; + const tried = new Set(); + + const queue = [primaryUrl, ...this.buildImageFallbackUrls(relative, primaryUrl)]; + for (const candidate of queue) { + if (!candidate || tried.has(candidate)) { + continue; + } + tried.add(candidate); + try { + const response = await fetch(candidate, { credentials: "include" }); + if (!response.ok) { + attempts.push(`${candidate} (status ${response.status})`); + continue; + } + const data = new Uint8Array(await response.arrayBuffer()); + if (candidate !== primaryUrl) { + this.log(`Info: fetched image ${relative} via fallback ${candidate}`); + } + return { data, sourceUrl: candidate }; + } catch (error) { + attempts.push(`${candidate} (${error.message})`); + } + } + + if (attempts.length) { + this.log( + `Warning: unable to fetch image ${primaryUrl}. Attempts: ${attempts.join("; ")}` + ); + } + return null; + } + + buildImageFallbackUrls(relative, url) { + const candidates = new Set(); + + if (url.includes("/Images/")) { + candidates.add(url.replace("/Images/", "/images/")); + } + if (url.includes("/images/")) { + candidates.add(url.replace("/images/", "/Images/")); + } + + if (url.includes("/files/Images/")) { + const base = url.replace(/Images\/[^/]*$/i, "Images/"); + ["cover.jpg", "cover.jpeg", "cover.png", "cover-large.jpg"].forEach((file) => { + candidates.add(`${base}${file}`); + }); + } + + const isbnMatch = url.match(/Images\/(\d{10,13})\.(jpg|jpeg|png|gif)$/i); + if (isbnMatch) { + const isbn = isbnMatch[1]; + const sizes = ["600w", "400w", "250w", ""]; + sizes.forEach((size) => { + const trimmedSize = size ? `/${size}` : ""; + candidates.add(`https://learning.oreilly.com/library/cover/${isbn}${trimmedSize}/`); + }); + } + + if (this.bookInfo?.cover && url !== this.bookInfo.cover) { + candidates.add(this.bookInfo.cover); + } + + if (relative && this.bookInfo?.cover_url) { + candidates.add(this.bookInfo.cover_url); + } + + return Array.from(candidates).filter(Boolean); + } + + async fetchCssImages() { + for (const [path, entry] of this.cssImageDownloads.entries()) { + if (entry?.data) { + continue; + } + const response = await fetch(entry, { credentials: "include" }); + if (!response.ok) { + this.log(`Warning: unable to fetch CSS image ${entry} (status ${response.status})`); + continue; + } + const data = new Uint8Array(await response.arrayBuffer()); + this.cssImageDownloads.set(path, { data, sourceUrl: entry }); + } + } + + createManifestAndSpine() { + const manifestEntries = []; + const spineEntries = []; + this.chapterDocuments.forEach((chapter) => { + const itemId = escapeXml(chapter.filename.replace(/\.xhtml$/i, "")); + manifestEntries.push( + `` + ); + spineEntries.push(``); + }); + + for (const [relative, image] of this.imageDownloads.entries()) { + if (!image?.data) { + continue; + } + const ext = relative.split(".").pop().toLowerCase(); + const media = ext.includes("jp") ? "image/jpeg" : `image/${ext}`; + const itemId = escapeXml(`img_${relative.replace(/[\\/]/g, "_").split(".")[0]}`); + manifestEntries.push( + `` + ); + } + + for (const [source, filename] of this.cssFilenameMap.entries()) { + const cssContent = this.cssContentMap.get(source); + if (!cssContent) { + continue; + } + manifestEntries.push( + `` + ); + } + + for (const [path] of this.cssImageDownloads.entries()) { + const ext = path.split(".").pop().toLowerCase(); + if (isFontUrl(path)) { + const mediaType = ext === "otf" ? "font/otf" : ext === "ttf" ? "font/ttf" : `font/${ext}`; + const id = escapeXml(path.replace(/[\\/]/g, "_")); + manifestEntries.push( + `` + ); + } else if (isLikelyImageAsset(path)) { + const media = ext.includes("jp") ? "image/jpeg" : `image/${ext}`; + const id = escapeXml(path.replace(/[\\/]/g, "_")); + manifestEntries.push(``); + } + } + + return { + manifest: manifestEntries.join("\n"), + spine: spineEntries.join("\n") + }; + } + + getCoverId() { + if (!this.coverPath) { + return "cover"; + } + const withoutPrefix = this.coverPath.replace(/^Images\//, ""); + const base = withoutPrefix.includes(".") ? withoutPrefix.split(".")[0] : withoutPrefix; + return `img_${base.replace(/[\\/]/g, "_")}`; + } + + createContentOpf(manifest, spine) { + const authors = (this.bookInfo.authors || []) + .map((author) => `${escapeXml(author.name)}`) + .join("\n"); + const subjects = (this.bookInfo.subjects || []) + .map((subject) => `${escapeXml(subject.name)}`) + .join("\n"); + + const descriptionContainer = this.bookInfo.descriptions || {}; + const description = typeof descriptionContainer === "object" ? descriptionContainer["text/plain"] || "" : ""; + const publishers = (this.bookInfo.publishers || []).map((publisher) => escapeXml(publisher.name)).join(", "); + + return ` + + +${escapeXml(this.bookInfo.title)} +${authors} +${escapeXml(description)} +${subjects} +${publishers} +${escapeXml(this.bookInfo.rights || "")} +en-US +${escapeXml(this.bookInfo.publication_date || "")} +${escapeXml(this.bookInfo.isbn || this.bookId)} + + + + +${manifest} + + +${spine} + + +`; + } + + createTocNavMap(entries, count = { value: 0 }, maxDepth = { value: 0 }) { + if (!Array.isArray(entries)) { + return { navMap: "", maxDepth: maxDepth.value }; + } + let navMap = ""; + for (const entry of entries) { + count.value += 1; + const depth = Number(entry.depth || 0); + if (depth > maxDepth.value) { + maxDepth.value = depth; + } + const href = this.getChapterHref(entry); + const title = escapeXml(entry.title || entry.label || "Chapter"); + const referenceId = entry.reference_id ? escapeXml(makeValidId(entry.reference_id)) : ""; + const fragment = entry.fragment ? entry.fragment : referenceId; + let childrenXml = ""; + if (Array.isArray(entry.children) && entry.children.length) { + const childResult = this.createTocNavMap(entry.children, count, maxDepth); + childrenXml = childResult.navMap; + maxDepth.value = Math.max(maxDepth.value, childResult.maxDepth); + } + navMap += `${title}${childrenXml}`; + } + return { navMap, maxDepth: maxDepth.value }; + } + + getChapterHref(entry) { + if (entry.href) { + return escapeXml(entry.href.replace(".html", ".xhtml")); + } + if (entry.ourn) { + const filename = entry.ourn.split("chapter:").pop().split("%2f").pop(); + return escapeXml(filename.replace(".html", ".xhtml")); + } + return escapeXml(this.chapterDocuments[0]?.filename || "chapter.xhtml"); + } + + createToc() { + const sourceToc = this.bookInfo.table_of_contents || []; + const { navMap, maxDepth } = this.createTocNavMap(sourceToc); + const authors = (this.bookInfo.authors || []).map((author) => author.name).join(", "); + return ` + + + + + + + + +${escapeXml(this.bookInfo.title)} +${escapeXml(authors)} +${navMap} +`; + } + + async assembleEpub() { + const zip = new SimpleZip(); + zip.addFile("mimetype", dataFromString("application/epub+zip")); + zip.addFile("META-INF/container.xml", dataFromString('')); + + const { manifest, spine } = this.createManifestAndSpine(); + const contentOpf = this.createContentOpf(manifest, spine); + zip.addFile("OEBPS/content.opf", dataFromString(contentOpf)); + const toc = this.createToc(); + zip.addFile("OEBPS/toc.ncx", dataFromString(toc)); + + for (const chapter of this.chapterDocuments) { + zip.addFile(`OEBPS/${chapter.filename}`, dataFromString(chapter.xhtml)); + } + + for (const [source, filename] of this.cssFilenameMap.entries()) { + const content = this.cssContentMap.get(source); + if (!content) { + continue; + } + zip.addFile(`OEBPS/Styles/${filename}`, dataFromString(content)); + } + + for (const [path, entry] of this.cssImageDownloads.entries()) { + if (!entry?.data) { + continue; + } + zip.addFile(`OEBPS/${path}`, entry.data); + } + + for (const [relative, entry] of this.imageDownloads.entries()) { + if (!entry?.data) { + continue; + } + zip.addFile(`OEBPS/Images/${relative}`, entry.data); + } + + return zip; + } +} diff --git a/firefox/safaribooks/utils.js b/firefox/safaribooks/utils.js new file mode 100644 index 0000000..e338d47 --- /dev/null +++ b/firefox/safaribooks/utils.js @@ -0,0 +1,122 @@ +const INVALID_DIR_CHARS = /[~#%&*{}\\<>?/`'"|+:]/g; +const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp"]); +const FONT_EXTENSIONS = new Set(["otf", "ttf", "woff", "woff2", "eot"]); + +export function cleanDirectoryName(name) { + let sanitized = name; + if (sanitized.includes(":")) { + if (sanitized.indexOf(":") > 15) { + sanitized = sanitized.split(":")[0]; + } else { + sanitized = sanitized.replace(/:/g, ","); + } + } + sanitized = sanitized.replace(INVALID_DIR_CHARS, "_"); + return sanitized.replace(/\s+/g, " ").trim(); +} + +export function makeFolderFriendlyName(title, bookId) { + const safeTitle = cleanDirectoryName(title); + const prefix = safeTitle.split(",").slice(0, 2).join(","); + return `${prefix || safeTitle} (${bookId})`.trim(); +} + +export function makeFileFriendlyName(...segments) { + const cleaned = segments + .map((segment) => segment ?? "") + .map((segment) => cleanDirectoryName(String(segment))) + .filter((segment) => segment.length > 0); + + if (!cleaned.length) { + return "download"; + } + + return cleaned.join(" - ").replace(/\s+/g, " ").trim(); +} + +export function escapeXml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function isAbsoluteUrl(url) { + try { + const parsed = new URL(url); + return Boolean(parsed.protocol); + } catch (_) { + return false; + } +} + +export function isDocLink(url) { + return [".html", ".xhtml", ".pdf"].some((ext) => url.includes(ext)); +} + +export function isImageLink(url) { + const lowered = url.toLowerCase(); + if (lowered.includes("cover") || lowered.includes("images") || lowered.includes("graphics")) { + return true; + } + const ext = lowered.split(".").pop(); + return IMAGE_EXTENSIONS.has(ext); +} + +export function isFontUrl(url) { + const ext = url.toLowerCase().split(".").pop().split("?")[0]; + return FONT_EXTENSIONS.has(ext); +} + +export function isLikelyImageAsset(url) { + const ext = url.toLowerCase().split(".").pop().split("?")[0]; + return IMAGE_EXTENSIONS.has(ext); +} + +export function makeValidId(value) { + const sanitized = value.replace(/\W|^(?=\d)/g, "_"); + return sanitized[0]?.match(/\d/) ? `_${sanitized}` : sanitized; +} + +export function resolveStylesRelativePath(relativePath) { + const baseParts = ["Styles"]; + const segments = relativePath.split("/").filter(Boolean); + const resolved = []; + for (const segment of segments) { + if (segment === ".") { + continue; + } + if (segment === "..") { + if (resolved.length) { + resolved.pop(); + } else if (baseParts.length) { + baseParts.pop(); + } + continue; + } + resolved.push(segment); + } + return [...baseParts, ...resolved].join("/"); +} + +export function ensureForwardSlashes(path) { + return path.replace(/\\/g, "/"); +} + +export function uniqPush(collection, value) { + if (!collection.includes(value)) { + collection.push(value); + } + return collection.indexOf(value); +} + +export function dataFromString(value) { + const encoder = new TextEncoder(); + return encoder.encode(value); +} + +export function toHex(value, width = 8) { + return value.toString(16).padStart(width, "0"); +} diff --git a/firefox/safaribooks/zip.js b/firefox/safaribooks/zip.js new file mode 100644 index 0000000..0bc744e --- /dev/null +++ b/firefox/safaribooks/zip.js @@ -0,0 +1,151 @@ +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c >>> 0; + } + return table; +})(); + +function crc32(data) { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + const byte = data[i]; + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +const textEncoder = new TextEncoder(); + +export class SimpleZip { + constructor() { + this.files = []; + } + + addFile(path, data) { + if (!(data instanceof Uint8Array)) { + throw new TypeError(`File data for ${path} must be a Uint8Array.`); + } + const cleanPath = path.replace(/\\/g, "/"); + const nameBytes = textEncoder.encode(cleanPath); + const checksum = crc32(data); + this.files.push({ + name: cleanPath, + nameBytes, + data, + crc: checksum, + localHeaderOffset: 0 + }); + } + + generate() { + let size = 0; + for (const file of this.files) { + const localSize = 30 + file.nameBytes.length + file.data.length; + size += localSize; + } + + const centralDirectoryOffset = size; + let centralDirectorySize = 0; + for (const file of this.files) { + centralDirectorySize += 46 + file.nameBytes.length; + } + size += centralDirectorySize + 22; + + const output = new Uint8Array(size); + const view = new DataView(output.buffer); + let offset = 0; + + for (const file of this.files) { + file.localHeaderOffset = offset; + view.setUint32(offset, 0x04034b50, true); + offset += 4; + view.setUint16(offset, 20, true); // version needed to extract + offset += 2; + view.setUint16(offset, 0, true); // general purpose flag + offset += 2; + view.setUint16(offset, 0, true); // compression (store) + offset += 2; + view.setUint16(offset, 0, true); // mod time + offset += 2; + view.setUint16(offset, 0, true); // mod date + offset += 2; + view.setUint32(offset, file.crc, true); + offset += 4; + view.setUint32(offset, file.data.length, true); + offset += 4; + view.setUint32(offset, file.data.length, true); + offset += 4; + view.setUint16(offset, file.nameBytes.length, true); + offset += 2; + view.setUint16(offset, 0, true); // extra length + offset += 2; + output.set(file.nameBytes, offset); + offset += file.nameBytes.length; + output.set(file.data, offset); + offset += file.data.length; + } + + let centralOffset = centralDirectoryOffset; + for (const file of this.files) { + view.setUint32(centralOffset, 0x02014b50, true); + centralOffset += 4; + view.setUint16(centralOffset, 0x0014, true); // version made by + centralOffset += 2; + view.setUint16(centralOffset, 20, true); // version needed to extract + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // general purpose flag + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // compression + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // mod time + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // mod date + centralOffset += 2; + view.setUint32(centralOffset, file.crc, true); + centralOffset += 4; + view.setUint32(centralOffset, file.data.length, true); + centralOffset += 4; + view.setUint32(centralOffset, file.data.length, true); + centralOffset += 4; + view.setUint16(centralOffset, file.nameBytes.length, true); + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // extra field length + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // comment length + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // disk number start + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // internal file attrs + centralOffset += 2; + view.setUint32(centralOffset, 0, true); // external file attrs + centralOffset += 4; + view.setUint32(centralOffset, file.localHeaderOffset, true); + centralOffset += 4; + output.set(file.nameBytes, centralOffset); + centralOffset += file.nameBytes.length; + } + + view.setUint32(centralOffset, 0x06054b50, true); + centralOffset += 4; + view.setUint16(centralOffset, 0, true); // number of this disk + centralOffset += 2; + view.setUint16(centralOffset, 0, true); // disk where central directory starts + centralOffset += 2; + view.setUint16(centralOffset, this.files.length, true); // records on this disk + centralOffset += 2; + view.setUint16(centralOffset, this.files.length, true); // total records + centralOffset += 2; + view.setUint32(centralOffset, centralDirectorySize, true); + centralOffset += 4; + view.setUint32(centralOffset, centralDirectoryOffset, true); + centralOffset += 4; + view.setUint16(centralOffset, 0, true); // comment length + + return output; + } +} diff --git a/scripts/build-xpi.ps1 b/scripts/build-xpi.ps1 new file mode 100644 index 0000000..225e514 --- /dev/null +++ b/scripts/build-xpi.ps1 @@ -0,0 +1,24 @@ +param( + [string]$Source = "firefox", + [string]$Output = "firefox-extension.xpi" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptRoot "..") +$sourcePath = Join-Path $repoRoot $Source +$outputPath = Join-Path $repoRoot $Output + +if (-not (Test-Path -Path $sourcePath -PathType Container)) { + throw "Source folder '$sourcePath' was not found." +} + +if (Test-Path -Path $outputPath -PathType Leaf) { + Remove-Item -Path $outputPath -Force +} + +Compress-Archive -Path (Join-Path $sourcePath "*") -DestinationPath $outputPath -Force + +Write-Host "Created XPI at $outputPath" diff --git a/scripts/build-xpi.sh b/scripts/build-xpi.sh new file mode 100644 index 0000000..3271732 --- /dev/null +++ b/scripts/build-xpi.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_DIR="${1:-firefox}" +OUTPUT_FILE="${2:-firefox-extension.xpi}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SOURCE_PATH="${REPO_ROOT}/${SOURCE_DIR}" +OUTPUT_PATH="${REPO_ROOT}/${OUTPUT_FILE}" + +if [[ ! -d "${SOURCE_PATH}" ]]; then + echo "Source folder '${SOURCE_PATH}' was not found." >&2 + exit 1 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "The 'zip' command is required but was not found in PATH." >&2 + exit 1 +fi + +rm -f "${OUTPUT_PATH}" + +( + cd "${SOURCE_PATH}" + zip -r "${OUTPUT_PATH}" . >/dev/null +) + +echo "Created XPI at ${OUTPUT_PATH}"