diff --git a/.github/workflows/AutoRelease.yml b/.github/workflows/AutoRelease.yml index 432cfdaff..8c1e4eb9f 100644 --- a/.github/workflows/AutoRelease.yml +++ b/.github/workflows/AutoRelease.yml @@ -4,7 +4,7 @@ name: Auto release developer-build on: push: - branches: [ ExperimentalTabMode ] + branches: [ ExperimentalTabMode ] workflow_dispatch: inputs: version: @@ -21,11 +21,11 @@ jobs: matrix: node-version: [18.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} @@ -34,7 +34,7 @@ jobs: shell: cmd run: | set version=${{ github.event.inputs.version }} - Get-Command "jq.exe" + where jq.exe jq.exe --version echo do some git configuration @@ -49,20 +49,20 @@ jobs: ) echo extract version numbers - jq.exe (.version^|split(\".\")^|map(tonumber)^|.[0]) < plugin/manifest.json > milestone_version.txt - jq.exe (.version^|split(\".\")^|map(tonumber)^|.[1]) < plugin/manifest.json > major_version.txt - jq.exe (.version^|split(\".\")^|map(tonumber)^|.[2]) < plugin/manifest.json > minor_version.txt - jq.exe (.version^|split(\".\")^|map(tonumber)^|.[3]) < plugin/manifest.json > dev_version.txt - + jq.exe "(.version | split(\".\") | map(tonumber) | .[0])" plugin/manifest.json > milestone_version.txt + jq.exe "(.version | split(\".\") | map(tonumber) | .[1])" plugin/manifest.json > major_version.txt + jq.exe "(.version | split(\".\") | map(tonumber) | .[2])" plugin/manifest.json > minor_version.txt + jq.exe "(.version | split(\".\") | map(tonumber) | .[3])" plugin/manifest.json > dev_version.txt + set /p var_mil= old_change_log_version.txt + jq.exe -r "(.version | split(\".\") | map(tonumber) | .[0] = %var_mil% | .[1] = %var_maj% | .[2] = %var_min% | .[3] = 0 | map(tostring) | join(\".\"))" plugin/manifest.json > old_change_log_version.txt set /p var_old_change_log_version= new_version.txt + jq.exe -r "(.version | split(\".\") | map(tonumber) | .[0] = %var_mil% | .[1] = %var_maj% | .[2] = %var_min% | .[3] = %var_dev% | map(tostring) | join(\".\"))" plugin/manifest.json > new_version.txt set /p var_build= new_manifest.json + jq.exe --arg version "%var_build%" ".version = $version" plugin/manifest.json > new_manifest.json echo move new_manifest.json to manifest.json move /y new_manifest.json plugin/manifest.json echo cleanup new_version.txt jq.exe -r . new_version.txt > new_clean_version.txt - + echo npm install/ run - run: npm install - run: npm run lint - + + - name: Install crx3 + shell: bash + run: npm install -g crx3 + + - name: Restore PEM key + shell: bash + run: | + echo "${{ secrets.CRX_PRIVATE_KEY }}" | base64 -d > key.pem + + - name: Build CRX + shell: bash + run: | + set -e + + VERSION=$(cat new_clean_version.txt) + + cd eslint + unzip -o "WebToEpub${VERSION}.zip" -d chrome_unpacked + + crx3 --key=../key.pem chrome_unpacked + + mv chrome_unpacked.crx "../WebToEpub${VERSION}.crx" + rm -rf chrome_unpacked + - name: commit/ push git changes shell: cmd run: | @@ -114,11 +138,11 @@ jobs: git push git rev-parse --verify HEAD > commit_sha.txt - + - name: Release latest developer build shell: cmd run: | - + set version=${{ github.event.inputs.version }} set /p var_build= output.txt echo To install the Addon pick the file suitable for you, >> output.txt - echo * WebToEpub%var_build%.Firefox.zip for Firefox >> output.txt + echo * WebToEpub%var_build%.Firefox.xpi for Firefox >> output.txt echo * WebToEpub%var_build%.Chrome.zip for Chrome >> output.txt echo.>> output.txt echo follow the "How to install from Source (for people who are not developers)" instructions at: https://github.com/dteviot/WebToEpub/tree/ExperimentalTabMode#user-content-installation>> output.txt @@ -176,14 +201,15 @@ jobs: echo **Full Changelog**: https://github.com/dteviot/WebToEpub/compare/%var_old_change_log_version%...developer-build >> output.txt echo delete old developer-build release: - gh release delete developer-build --cleanup-tag + gh release delete developer-build --cleanup-tag --yes >nul 2>&1 echo create developer-build release: gh release create developer-build -p -t "Latest developer build" --target "ExperimentalTabMode" -F output.txt - + echo add files to release. - gh release upload developer-build eslint/WebToEpub%var_build%.Firefox.zip + gh release upload developer-build eslint/WebToEpub%var_build%.Firefox.xpi gh release upload developer-build eslint/WebToEpub%var_build%.Chrome.zip + gh release upload developer-build WebToEpub%var_build%.crx ) env: - GH_TOKEN: ${{ github.token }} \ No newline at end of file + GH_TOKEN: ${{ github.token }} diff --git a/plugin/_locales/en/messages.json b/plugin/_locales/en/messages.json index 8b1e132a4..7966162f3 100644 --- a/plugin/_locales/en/messages.json +++ b/plugin/_locales/en/messages.json @@ -368,8 +368,8 @@ "description": "Show more Metadata options to user" }, "__MSG_label_Manual_Delay_Per_Chapter__": { - "message": "Delay per chapter in ms", - "description": "Delay per chapter in ms" + "message": "Manual Throttle|Delay per chapter", + "description": "Manual Throttle|Delay per chapter" }, "__MSG_label_Manually_Select_Parser__": { "message": "Manually Select Parser:", diff --git a/plugin/css/default.css b/plugin/css/default.css index d9f4db05b..cceb2c665 100644 --- a/plugin/css/default.css +++ b/plugin/css/default.css @@ -107,7 +107,7 @@ button:disabled { } -input[type=text], input[type=url], textarea, select, #manualDelayPerChapterTag { +input[type=text], input[type=url], textarea, select { padding: 4px; border: 1px #CCC solid; border-radius: 3px; @@ -115,7 +115,7 @@ input[type=text], input[type=url], textarea, select, #manualDelayPerChapterTag { min-width: 356px; } -#advancedOptionsSection textarea, #advancedOptionsSection select, #manualDelayPerChapterTag { +#advancedOptionsSection textarea, #advancedOptionsSection select { min-width: inherit !important; } @@ -321,4 +321,4 @@ button { } #sbFilters input[type=text], #sbFilters input[type=url], #sbFilters textarea, #sbFilters select { width: calc(100% - 15px); - } \ No newline at end of file + } diff --git a/plugin/js/EpubMetaInfo.js b/plugin/js/EpubMetaInfo.js index 32203075f..cde3c0074 100644 --- a/plugin/js/EpubMetaInfo.js +++ b/plugin/js/EpubMetaInfo.js @@ -37,124 +37,241 @@ class EpubMetaInfo { } static getDefaultStyleSheet() { - return ""+ + return "" + // Style for svg images. I got this from BTE-Gen epunbs. Works nicely. - "div.svg_outer {\r"+ - " display: block;\r"+ - " margin-bottom: 0;\r"+ - " margin-left: 0;\r"+ - " margin-right: 0;\r"+ - " margin-top: 0;\r"+ - " padding-bottom: 0;\r"+ - " padding-left: 0;\r"+ - " padding-right: 0;\r"+ - " padding-top: 0;\r"+ - " text-align: left;\r"+ - "}\r"+ - "div.svg_inner {\r"+ - " display: block;\r"+ - " text-align: center;\r"+ - "}\r"+ + "div.svg_outer {\r" + + " display: block;\r" + + " margin-bottom: 0;\r" + + " margin-left: 0;\r" + + " margin-right: 0;\r" + + " margin-top: 0;\r" + + " padding-bottom: 0;\r" + + " padding-left: 0;\r" + + " padding-right: 0;\r" + + " padding-top: 0;\r" + + " text-align: left;\r" + + "}\r" + + "div.svg_inner {\r" + + " display: block;\r" + + " text-align: center;\r" + + "}\r" + + + // SVG scaling stability + ".svg_outer svg {\r" + + " display: block;\r" + + " max-width: 100%;\r" + + " height: auto;\r" + + " margin: 0 auto;\r" + + "}\r" + // Centered headings and some margin to make sure it's not too close to the content. - "h1, h2 {\r"+ - " text-align: center;\r"+ - " margin-bottom: 10%;\r"+ - " margin-top: 10%;\r"+ - "}\r"+ - "h3, h4, h5, h6 {\r"+ - " text-align: center;\r"+ - " margin-bottom: 15%;\r"+ - " margin-top: 10%;\r"+ - "}\r"+ + "h1 {\r" + + " font-size: 1.6em;\r" + + " border-bottom: 2px solid #333;\r" + + " padding-bottom: 0.3em;\r" + + "}\r" + + "h2 {\r" + + " font-size: 1.2em;\r" + + " color: #444;\r" + + "}\r" + + "h1, h2 {\r" + + " text-align: center;\r" + + " margin-bottom: 10%;\r" + + " margin-top: 10%;\r" + + "}\r" + + "h3, h4, h5, h6 {\r" + + " text-align: center;\r" + + " margin-bottom: 15%;\r" + + " margin-top: 10%;\r" + + "}\r" + // Style for lists. Calibre sometimes has issues with the placement of lists, this fixes it. - "ol, ul {\r"+ - " padding-left: 8%;\r"+ - "}\r"+ + "ol, ul {\r" + + " padding-left: 8%;\r" + + "}\r" + - "body {\r"+ - " margin: 2%;\r"+ - "}\r"+ + "body {\r" + + " margin: 2%;\r" + + "}\r" + //Breaks extremely long words, screams, wails etc to fit viewer window. - "p {\r"+ - " overflow-wrap: break-word;\r"+ - "}\r"+ + "p {\r" + + " overflow-wrap: break-word;\r" + + " text-indent: 1.5em;\r" + + "}\r" + + + //Add text intent for

tag + "p + p {\r" + + " text-indent: 1.5em;\r" + + "}\r" + + + //First letter of first start paragraph with

tag. + "p:first-letter {\r" + + " text-transform: uppercase;\r" + + "}\r" + + + "p:has(> img:only-child) {\r" + + " text-indent: 0;\r" + + "}\r" + + + "h1 + p::first-letter,\r" + + "h2 + p::first-letter,\r" + + "h3 + p::first-letter,\r" + + "h1 + p *::first-letter,\r" + + "h2 + p *::first-letter,\r" + + "h3 + p *::first-letter {\r" + + " font-size: 2em;\r" + + " font-family: cursive;\r" + + " font-weight: bold;\r" + + " font-style: italic;\r" + + " text-shadow: 0 1px 2px rgba(255, 223, 97, 1), 1px 2px 3px black;\r" + + " text-transform: uppercase;\r" + + "}\r" + + + "h1 + p,\r" + + "h3 + p,\r" + + "h2 + p {\r" + + " text-indent: 1.5em;\r" + + "}\r" + // Prevent texts inside mutliple definition list tags going outside viewer window. // Example https://www.baka-tsuki.org/project/index.php?title=The_Unexplored_Summon_Blood_Sign:Volume2_Opening2 // It looks okay in a browser but in devices with small screen, it's almost unreadable. - "dd, dt, dl {\r"+ - " padding: 0;\r"+ - " margin: 0;\r"+ - "}\r"+ - - "img {\r"+ - " display: block;\r"+ - " min-height: 1em;\r"+ - " max-height: 100%;\r"+ - " max-width: 100%;\r"+ - " padding-bottom: 0;\r"+ - " padding-left: 0;\r"+ - " padding-right: 0;\r"+ - " padding-top: 0;\r"+ - " margin-left: auto;\r"+ - " margin-right: auto;\r"+ - " margin-bottom: 2%;\r"+ - " margin-top: 2%;\r"+ - "}\r"+ + "dd, dt, dl {\r" + + " padding: 0;\r" + + " margin: 0;\r" + + "}\r" + + + "img {\r" + + " display: block;\r" + + " min-height: 1em;\r" + + " max-height: 100%;\r" + + " max-width: 100%;\r" + + " padding-bottom: 0;\r" + + " padding-left: 0;\r" + + " padding-right: 0;\r" + + " padding-top: 0;\r" + + " margin-left: auto;\r" + + " margin-right: auto;\r" + + " margin-bottom: 2%;\r" + + " margin-top: 2%;\r" + + "}\r" + + + // COVER PAGE ENGINE + ".cover-page {\r" + + " display: table;\r" + + " width: 100%;\r" + + " min-height: 100vh;\r" + + " text-align: center;\r" + + "}\r" + + + ".cover-page-inner {\r" + + " display: table-cell;\r" + + " vertical-align: middle;\r" + + " text-align: center;\r" + + " padding: 0 0.8em;\r" + + "}\r" + + + // TITLE UNDER COVER + + ".cover-title {\r" + + " margin-top: 0.75em;\r" + + " margin-bottom: 0;\r" + + " font-size: 1.45em;\r" + + " font-weight: 600;\r" + + " line-height: 1.25;\r" + + " letter-spacing: 0.02em;\r" + + " text-align: center;\r" + + " font-family: \"Times New Roman\", Georgia, serif;\r" + + " word-break: break-word;\r" + + "}\r" + // images embedded in sentances (e.g. Emoji) - "img.inline {\r"+ - " display: inline;\r"+ - " min-height: 1em;\r"+ - " margin-bottom: 0;\r"+ - " margin-top: 0;\r"+ - "}\r"+ - - // differentiate caption text from body text - ".thumbcaption {\r"+ - " display: block;\r"+ - " font-size: 0.9em;\r"+ - " padding-right: 5%;\r"+ - " padding-left: 5%;\r"+ - "}\r"+ - + "img.inline {\r" + + " display: inline;\r" + + " min-height: 1em;\r" + + " margin-bottom: 0;\r" + + " margin-top: 0;\r" + + "}\r" + + + // differentiate caption text from body text + ".thumbcaption {\r" + + " display: block;\r" + + " font-size: 0.9em;\r" + + " padding-right: 5%;\r" + + " padding-left: 5%;\r" + + "}\r" + + // To make hr tags more visible. BT doesn't use them very often but other sites might. - "hr {\r"+ - " color: black;\r"+ - " background-color: black;\r"+ - " height: 2px;\r"+ - "}\r"+ + "hr {\r" + + " border: none;\r" + + " background: black;\r" + + " height: 2px;\r" + + "}\r" + // Styling all links. - "a:link {\r"+ - " text-decoration: none;\r"+ - " color: #0B0080;\r"+ - "}\r"+ - "a:visited {\r"+ - " text-decoration: none;\r"+ - "}\r"+ - "a:hover {\r"+ - " text-decoration: underline;\r"+ - "}\r"+ - "a:active {\r"+ - " text-decoration: underline;\r"+ - "}"+ - - "table {\r"+ - " width: 90%;\r"+ - " border-collapse: collapse;\r"+ - "}\r"+ - "table, th, td {\r"+ - " border: 1px solid black;\r"+ - "}\r"+ + "a:link {\r" + + " text-decoration: none;\r" + + " color: #0B0080;\r" + + "}\r" + + "a:visited {\r" + + " text-decoration: none;\r" + + "}\r" + + "a:hover {\r" + + " text-decoration: underline;\r" + + "}\r" + + "a:active {\r" + + " text-decoration: underline;\r" + + "}\r" + + + // red link for BT non-existent page // + "a.new {\r" + + " color: #ba0000;\r" + + "}\r" + + + // some groups use

 tag for character status with no wrapping, thus breaking the view in ebook //
+        "pre {\r" +
+        "   white-space: pre-wrap;\r" +
+        "}\r" +
+        "th {\r" +
+        "   background: #333;\r" +
+        "   color: #fff;\r" +
+        "   padding: 6px 8px;\r" +
+        "   text-align: left;\r" +
+        "   font-size: 0.85em;\r" +
+        "}\r" +
+        "td {\r" +
+        "   padding: 5px 8px;\r" +
+        "   border-bottom: 1px solid #ddd;\r" +
+        "   vertical-align: top;\r" +
+        "   font-size: 0.88em;\r" +
+        "}\r" +
+
+        "table {\r" +
+        "   width: 90%;\r" +
+        "   border-collapse: collapse;\r" +
+        "   margin-bottom: 1em;\r" +
+        "}\r" +
+        "table, th, td {\r" +
+        "   border: 1px solid black;\r" +
+        "}\r" +
+        "tr:nth-child(odd) td {\r" +
+        "   background: #f9f9f9;\r" +
+        "}\r" +
+        "tr:nth-child(even) td {\r" +
+        "   background: #eef5ff;\r" +
+        "}\r" +
+        "nav a {\r" +
+        "   display: block;\r" +
+        "   margin: 0.2em 0;\r" +
+        "   color: #1a1a6e;\r" +
+        "}\r" +
 
         // Box around author notes
         ".webToEpub-author-note {\r" +
-        "    border: 1px solid black; padding: 0.5em\r" +
-        "}";
+        "   border: 1px solid black;\r" +
+        "   padding: 0.5em;\r" +
+        "}\r";
     }
 
     static getEpubMetaAddInfo(dom, url, allTags) {
@@ -171,7 +288,7 @@ class EpubMetaInfo {
         }
         return metaAddInfo;
     }
-    
+
     static addSubjectNovelupdate(dom, allTags) {
         let selector = "#seriesgenre .genre";
         if (allTags) {
@@ -183,7 +300,7 @@ class EpubMetaInfo {
     static addDescriptionNovelupdate(dom) {
         return dom.querySelector("#editdescription").textContent.replace(/\n+/g, "\n").replace(/\n/g, "\n\n");
     }
-    
+
     static addAuthorNovelupdate(dom) {
         return dom.querySelector("#authtag").textContent;
     }
diff --git a/plugin/js/EpubPacker.js b/plugin/js/EpubPacker.js
index 5d2247cd7..b25ed8075 100644
--- a/plugin/js/EpubPacker.js
+++ b/plugin/js/EpubPacker.js
@@ -1,430 +1,500 @@
-/*
-  Functions for packing an EPUB file
-*/
-"use strict";
-
-/*
-    For our purposes, an EPUB only contains two types of content file: XHTML and image.
-    - The HTML files are in reading order (i.e. Appear in same order as spine and table of contents (ToC))
-    - If an HTML file entry has a "title" element, it will appear in the ToC
-    - Stand-alone images (e.g. Cover) will have an XHTML entry that points to the image.
-    - First image, (if there are any) will be the cover image
-*/
-
-/// identifier for this EPUB.  (i.e. "origin" URL story was downloaded from)
-/// The Title of the story
-/// The writer of the story
-class EpubPacker {
-    constructor(metaInfo, version = EpubPacker.EPUB_VERSION_2) {
-        this.metaInfo = metaInfo;
-        this.version = version;
-
-        this.emptyDocFactory = util.createEmptyXhtmlDoc;
-        let contentType = EpubPacker.XHTML_MIME_TYPE;
-        if (version === EpubPacker.EPUB_VERSION_3) {
-            this.emptyDocFactory = util.createEmptyHtmlDoc;
-            contentType = EpubPacker.HTML_MIME_TYPE;
-        }
-        this.contentValidator = xml => util.isXhtmlInvalid(xml, contentType);
-    }
-
-    static coverImageXhtmlHref() {
-        return "OEBPS/Text/Cover.xhtml";
-    }
-
-    static coverImageXhtmlId() {
-        return "cover";
-    }
-
-    assemble(epubItemSupplier) {
-        let zipFileWriter = new zip.BlobWriter("application/epub+zip");
-        let zipWriter = new zip.ZipWriter(zipFileWriter,{useWebWorkers: false,compressionMethod: 8, extendedTimestamp: false});
-        this.addRequiredFiles(zipWriter);
-        zipWriter.add("OEBPS/content.opf", new zip.TextReader(this.buildContentOpf(epubItemSupplier)));
-        zipWriter.add("OEBPS/toc.ncx", new zip.TextReader(this.buildTableOfContents(epubItemSupplier)));
-        if (this.version === EpubPacker.EPUB_VERSION_3) {
-            zipWriter.add("OEBPS/toc.xhtml", new zip.TextReader(this.buildNavigationDocument(epubItemSupplier)));
-        }
-        this.packContentFiles(zipWriter, epubItemSupplier);
-        zipWriter.add(util.styleSheetFileName(), new zip.TextReader(this.metaInfo.styleSheet));
-        return zipWriter.close();
-    }
-
-    static addExtensionIfMissing(fileName) {
-        let extension = ".epub";
-        return (fileName.endsWith(extension)) ? fileName : fileName + extension;
-    }
-
-    // every EPUB must have a mimetype and a container.xml file
-    addRequiredFiles(zipFile) {
-        zipFile.add("mimetype",  new zip.TextReader("application/epub+zip"),{compressionMethod: 0});
-        zipFile.add("META-INF/container.xml",
-            new zip.TextReader("" +
-            "" +
-                "" +
-                    "" +
-                "" +
-            "")
-        );
-    }
-
-    buildContentOpf(epubItemSupplier) {
-        let ns = "http://www.idpf.org/2007/opf";
-        let opf = document.implementation.createDocument(ns, "package", null);
-        opf.documentElement.setAttributeNS(null, "version", this.version);
-        opf.documentElement.setAttributeNS(null, "unique-identifier", "BookId");
-        this.buildMetaData(opf, epubItemSupplier);
-        this.buildManifest(opf, ns, epubItemSupplier);
-        this.buildSpine(opf, ns, epubItemSupplier);
-        this.buildGuide(opf, ns, epubItemSupplier);
-
-        return util.xmlToString(opf);
-    }
-
-    buildMetaData(opf, epubItemSupplier) {
-        let opf_ns = "http://www.idpf.org/2007/opf";
-        let dc_ns = "http://purl.org/dc/elements/1.1/";
-
-        let metadata = opf.createElementNS(opf_ns, "metadata");
-        metadata.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:dc", dc_ns);
-        metadata.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:opf", opf_ns);
-        opf.documentElement.appendChild(metadata);
-        this.createAndAppendChildNS(metadata, dc_ns, "dc:title", this.metaInfo.title);
-        this.createAndAppendChildNS(metadata, dc_ns, "dc:language", this.metaInfo.language);
-        let datePublished = this.metaInfo.datePublished || this.getDateForMetaData();
-        this.createAndAppendChildNS(metadata, dc_ns, "dc:date", datePublished);
-        if (!util.isNullOrEmpty(this.metaInfo.subject)) {
-            this.createAndAppendChildNS(metadata, dc_ns, "dc:subject", this.metaInfo.subject);
-        }
-        if (!util.isNullOrEmpty(this.metaInfo.description)) {
-            this.createAndAppendChildNS(metadata, dc_ns, "dc:description", this.metaInfo.description);
-        }
-        if (!util.isNullOrEmpty(this.metaInfo.publisher)) {
-            this.createAndAppendChildNS(metadata, dc_ns, "dc:publisher", this.metaInfo.publisher);
-        }
-
-        let author = this.createAndAppendChildNS(metadata, dc_ns, "dc:creator", this.metaInfo.author);
-        this.addMetaProperty(metadata, author, "file-as", "creator", this.metaInfo.getFileAuthorAs());
-        this.addMetaProperty(metadata, author, "role", "creator", "aut");
-
-        if (this.metaInfo.translator !== null) {
-            let translator = this.createAndAppendChildNS(metadata, dc_ns, "dc:contributor", this.metaInfo.translator);
-            this.addMetaProperty(metadata, translator, "file-as", "translator", this.metaInfo.translator);
-            this.addMetaProperty(metadata, translator, "role", "translator", "trl");
-        }
-
-        let idText = (this.version === EpubPacker.EPUB_VERSION_3 ? "uri:" : "") + this.metaInfo.uuid;
-        let identifier = this.createAndAppendChildNS(metadata, dc_ns, "dc:identifier", idText);
-        identifier.setAttributeNS(null, "id", "BookId");
-        if (this.version === EpubPacker.EPUB_VERSION_2) {
-            identifier.setAttributeNS(opf_ns, "opf:scheme", "URI");
-        } else {
-            this.addMetaProperty(metadata, identifier, "identifier-type", "BookId", "URI");
-            let meta = this.createAndAppendChildNS(metadata, opf_ns, "meta");
-            meta.setAttributeNS(null, "property", "dcterms:modified");
-            let dateWithoutMillisecond = this.getDateForMetaData().substring(0, 19) + "Z";
-            meta.textContent = dateWithoutMillisecond;
-        }
-
-        let webToEpubVersion = `[https://github.com/dteviot/WebToEpub] (ver. ${util.extensionVersion()})`;
-        let contributor = this.createAndAppendChildNS(metadata, dc_ns, "dc:contributor", webToEpubVersion);
-        this.addMetaProperty(metadata, contributor, "role", "packingTool", "bkp");
-
-        if (epubItemSupplier.hasCoverImageFile()) {
-            this.appendMetaContent(metadata, opf_ns, "cover", epubItemSupplier.coverImageId());
-        }
-
-        if (this.metaInfo.seriesName !== null) {
-            this.appendMetaContent(metadata, opf_ns, "calibre:series", this.metaInfo.seriesName);
-            this.appendMetaContent(metadata, opf_ns, "calibre:series_index", this.metaInfo.seriesIndex);
-            if (this.version === EpubPacker.EPUB_VERSION_3) {
-                let series = this.createAndAppendChildNS(metadata, opf_ns, "meta");
-                series.setAttributeNS(null, "property", "belongs-to-collection");
-                series.setAttributeNS(null, "id", "series");
-                series.textContent = this.metaInfo.seriesName;
-                this.addMetaProperty(metadata, series, "collection-type", "series", "series");
-                this.addMetaProperty(metadata, series, "group-position", "series", this.metaInfo.seriesIndex);
-            }
-        }
-
-        for (let i of epubItemSupplier.manifestItems()) {
-            let sourceUrl = util.clearIfDataUri(i.sourceUrl);
-            if (sourceUrl) {  // Only add dc:source if we have a valid URL
-                let source = this.createAndAppendChildNS(metadata, dc_ns, "dc:source", sourceUrl);
-                source.setAttributeNS(null, "id", "id." + i.getId());
-            }
-        }
-    }
-
-    addMetaProperty(metadata, element, propName, id, value) {
-        let opf_ns = "http://www.idpf.org/2007/opf";
-        if (this.version === EpubPacker.EPUB_VERSION_3) {
-            element.setAttributeNS(null, "id", id);
-            let meta = this.createAndAppendChildNS(metadata, opf_ns, "meta");
-            meta.setAttributeNS(null, "refines", "#" +id);
-            meta.setAttributeNS(null, "property", propName);
-            meta.textContent = value;
-        } else {
-            element.setAttributeNS(opf_ns, "opf:" + propName, value);
-        }
-    }
-
-    appendMetaContent(parent, opf_ns, name, content) {
-        let meta = this.createAndAppendChildNS(parent, opf_ns, "meta");
-        // Some e-book readers such as the Nook fail to recognize covers if the content
-        // attribute comes before the name attribute. For maximum compatibility move
-        // the name attribute before the content attribute.
-        meta.setAttributeNS(null, "name", name);
-        meta.setAttributeNS(null, "content", content);
-    }
-    
-    buildManifest(opf, ns, epubItemSupplier) {
-        let manifest = this.createAndAppendChildNS(opf.documentElement, ns, "manifest");
-        for (let i of epubItemSupplier.manifestItems()) {
-            let item = this.addManifestItem(manifest, ns, i.getZipHref(), i.getId(), i.getMediaType());
-            this.setSvgPropertyForManifestItem(item, i.hasSvg());
-        }
-
-        this.addManifestItem(manifest, ns, util.styleSheetFileName(), "stylesheet", "text/css");
-        this.addManifestItem(manifest, ns, "OEBPS/toc.ncx", "ncx", "application/x-dtbncx+xml");
-        if (epubItemSupplier.hasCoverImageFile()) {
-            let item = this.addManifestItem(manifest, ns, EpubPacker.coverImageXhtmlHref(), EpubPacker.coverImageXhtmlId(), "application/xhtml+xml");
-            this.setSvgPropertyForManifestItem(item, this.doesCoverHaveSvg(epubItemSupplier));
-        }
-        if (this.version === EpubPacker.EPUB_VERSION_3) {
-            let item = this.addManifestItem(manifest, ns, "OEBPS/toc.xhtml", "nav", "application/xhtml+xml");
-            item.setAttributeNS(null, "properties", "nav");
-        }
-    }
-
-    addManifestItem(manifest, ns, href, id, mediaType) {
-        let item = this.createAndAppendChildNS(manifest, ns, "item");
-        let relativeHref = this.makeRelative(href);
-        if (mediaType === "image/webp") {
-            let userPreferences = main.getUserPreferences();
-            if (!userPreferences?.disableWebpImageFormatError?.value) {
-                let errorMsg = UIText.Warning.warningWebpImage(relativeHref);
-                ErrorLog.log(errorMsg);
-            }
-        }
-        item.setAttributeNS(null, "href", relativeHref);
-        item.setAttributeNS(null, "id", id);
-        item.setAttributeNS(null, "media-type", mediaType);
-        return item;
-    }
-
-    setSvgPropertyForManifestItem(item, hasSvg) {
-        if (hasSvg && (this.version === EpubPacker.EPUB_VERSION_3)) {
-            item.setAttributeNS(null, "properties", "svg");
-        }
-    }
-
-    doesCoverHaveSvg(epubItemSupplier) {
-        let fileContent = epubItemSupplier.makeCoverImageXhtmlFile(util.createEmptyXhtmlDoc);
-        let doc = new DOMParser().parseFromString(fileContent, "application/xml");
-        return (doc.querySelector("svg") != null);
-    }
-
-    buildSpine(opf, ns, epubItemSupplier) {
-        let spine = this.createAndAppendChildNS(opf.documentElement, ns, "spine");
-        spine.setAttributeNS(null, "toc", "ncx");
-        if (epubItemSupplier.hasCoverImageFile()) {
-            this.addSpineItemRef(spine, ns, EpubPacker.coverImageXhtmlId());
-        }
-        for (let item of epubItemSupplier.spineItems()) {
-            this.addSpineItemRef(spine, ns, item.getId());
-        }
-    }
-
-    addSpineItemRef(spine, ns, idref) {
-        this.createAndAppendChildNS(spine, ns, "itemref").setAttributeNS(null, "idref", idref);
-    }
-
-    buildGuide(opf, ns, epubItemSupplier) {
-        if (epubItemSupplier.hasCoverImageFile()) {
-            let guide = this.createAndAppendChildNS(opf.documentElement, ns, "guide");
-            let reference = this.createAndAppendChildNS(guide, ns, "reference");
-            reference.setAttributeNS(null, "href", this.makeRelative(EpubPacker.coverImageXhtmlHref()));
-            reference.setAttributeNS(null, "title", "Cover");
-            reference.setAttributeNS(null, "type", "cover");
-        }
-    }
-
-    buildTableOfContents(epubItemSupplier) {
-        let ns = "http://www.daisy.org/z3986/2005/ncx/";
-        let ncx = document.implementation.createDocument(ns, "ncx", null);
-        ncx.documentElement.setAttribute("version", "2005-1");
-        ncx.documentElement.setAttribute("xml:lang", this.metaInfo.language);
-        let head = this.createAndAppendChildNS(ncx.documentElement, ns, "head");
-        this.buildDocTitle(ncx, ns);
-        let depth = this.buildNavMap(ncx, ns, epubItemSupplier);
-        this.populateHead(head, ns, depth);
-
-        return util.xmlToString(ncx);
-    }
-
-    buildNavigationDocument(epubItemSupplier) {
-        let ns = "http://www.w3.org/1999/xhtml";
-        let navDoc = document.implementation.createDocument(ns, "html", null);
-        navDoc.documentElement.setAttribute("xml:lang", this.metaInfo.language);
-        navDoc.documentElement.setAttribute("xmlns:epub", "http://www.idpf.org/2007/ops");
-        navDoc.documentElement.setAttribute("lang", this.metaInfo.language);
-        let head = this.createAndAppendChildNS(navDoc.documentElement, ns, "head");
-        this.createAndAppendChildNS(head, ns, "title").textContent = "Table of Contents";
-        this.addDocType(navDoc);
-        let body = this.createAndAppendChildNS(navDoc.documentElement, ns, "body");
-        let nav = this.createAndAppendChildNS(body, ns, "nav");
-        nav.setAttribute("epub:type", "toc");
-        nav.setAttribute("id", "toc");
-        this.populateNavElement(nav, ns, epubItemSupplier);
-        return util.xmlToString(navDoc);
-    }
-
-    addDocType(navDoc) {
-        let docTypeNode = navDoc.implementation.createDocumentType("html", "", "");
-        navDoc.insertBefore(docTypeNode, navDoc.childNodes[0]);
-    }
-
-    populateHead(head, ns, depth) {
-        this.appendMetaContent(head, ns, "dtb:uid", (this.version === EpubPacker.EPUB_VERSION_3 ? "uri:" : "") + this.metaInfo.uuid);
-        this.appendMetaContent(head, ns, "dtb:depth", (depth < 2) ? "2" : depth);
-        this.appendMetaContent(head, ns, "dtb:totalPageCount", "0");
-        this.appendMetaContent(head, ns, "dtb:maxPageNumber", "0");
-    }
-
-    buildDocTitle(ncx, ns) {
-        let docTitle = this.createAndAppendChildNS(ncx.documentElement, ns, "docTitle");
-        this.createAndAppendChildNS(docTitle, ns, "text", this.metaInfo.title);
-    }
-
-    populateNavElement(nav, ns, epubItemSupplier) {
-        let rootParent = this.createAndAppendChildNS(nav, ns, "ol");
-        let parents = new NavPointParentElementsStack(rootParent);
-        for (let chapterInfo of epubItemSupplier.chapterInfo()) {
-            let parent = parents.findParentElement(chapterInfo.depth);
-            let nextLevel = this.buildNavListItem(parent, ns, chapterInfo);
-            parents.addElement(chapterInfo.depth, nextLevel);
-        }
-        this.removeEmptyNavLists(rootParent);
-    }
-
-    buildNavListItem(parent, ns, chapterInfo) {
-        let li = this.createAndAppendChildNS(parent, ns, "li");
-        let link = this.createAndAppendChildNS(li, ns, "a");
-        link.href = this.makeRelative(chapterInfo.src);
-        link.textContent = chapterInfo.title;
-        return this.createAndAppendChildNS(li, ns, "ol");
-    }
-
-    removeEmptyNavLists(rootParent) {
-        for (let list of rootParent.querySelectorAll("ol")) {
-            if (list.childElementCount === 0) {
-                list.remove();
-            }
-        }
-    }
-
-    buildNavMap(ncx, ns, epubItemSupplier) {
-        let navMap = this.createAndAppendChildNS(ncx.documentElement, ns, "navMap");
-        let parents = new NavPointParentElementsStack(navMap);
-        let playOrder = 0;
-        let id = 0;
-        let lastChapterSrc = null;
-        for (let chapterInfo of epubItemSupplier.chapterInfo()) {
-            let parent = parents.findParentElement(chapterInfo.depth);
-            if (lastChapterSrc !== chapterInfo.src) {
-                ++playOrder;
-            }
-            let navPoint = this.buildNavPoint(parent, ns, playOrder, ++id, chapterInfo);
-            lastChapterSrc = chapterInfo.src;
-            parents.addElement(chapterInfo.depth, navPoint);
-        }
-        return parents.maxDepth;
-    }
-
-    buildNavPoint(parent, ns, playOrder, id, chapterInfo) {
-        let navPoint = this.createAndAppendChildNS(parent, ns, "navPoint");
-        navPoint.setAttributeNS(null, "id", this.makeId(util.zeroPad(id)));
-        navPoint.setAttributeNS(null, "playOrder", playOrder);
-        let navLabel = this.createAndAppendChildNS(navPoint, ns, "navLabel");
-        this.createAndAppendChildNS(navLabel, ns, "text", chapterInfo.title);
-        this.createAndAppendChildNS(navPoint, ns, "content").setAttributeNS(null, "src", this.makeRelative(chapterInfo.src));
-        return navPoint;
-    }
-
-    packContentFiles(zipWriter, epubItemSupplier) {
-        for (let file of epubItemSupplier.files()) {
-            file.packInEpub(zipWriter, this.emptyDocFactory, this.contentValidator);
-        }
-        if (epubItemSupplier.hasCoverImageFile()) {
-            let fileContent = epubItemSupplier.makeCoverImageXhtmlFile(this.emptyDocFactory, "Cover");
-            zipWriter.add(EpubPacker.coverImageXhtmlHref(), new zip.TextReader(fileContent));
-        }
-    }
-
-    createAndAppendChildNS(element, ns, name, data) {
-        let child = element.ownerDocument.createElementNS(ns, name);
-        if (typeof data  !== "undefined") {
-            child.appendChild(element.ownerDocument.createTextNode(data));
-        }
-        element.appendChild(child);
-        return child;
-    }
-
-    makeId(id) {
-        return "body" + id;
-    }
-    // changes href to be relative to manifest (and toc.ncx)
-    // which are in OEBPS
-    makeRelative(href) {
-        return href.substr(6);
-    }
-
-    /// hook point for unit testing (because we can't control the actual time)
-    /// return time string to put into  element of metadata
-    getDateForMetaData() {
-        return new Date().toISOString();
-    }
-}
-
-EpubPacker.EPUB_VERSION_2 = "2.0";
-EpubPacker.EPUB_VERSION_3 = "3.0";
-EpubPacker.XHTML_MIME_TYPE = "application/xml";
-EpubPacker.HTML_MIME_TYPE = "text/html";
-
-/*
-  Class to make sure we correctly nest the NavPoint elements
-  in the table of contents
-*/
-class NavPointParentElementsStack {
-    constructor(navMap) {
-        this.parents = [];
-        this.parents.push({
-            element: navMap,
-            depth: -1
-        });
-        this.maxDepth = 0;
-    }
-
-    findParentElement(depth) {
-        let index = this.parents.length - 1;
-        while (depth <= this.parents[index].depth) {
-            --index;
-        }
-        return this.parents[index].element;
-    }
-
-    addElement(depth, element) {
-        // discard any elements that are nested >= this one
-        while (depth <= this.parents[this.parents.length - 1].depth) {
-            this.parents.pop();
-        }
-        this.parents.push({
-            element: element,
-            depth: depth
-        });
-        if (this.maxDepth < this.parents.length - 1) {
-            this.maxDepth = this.parents.length - 1;
-        }
-    }
-}
+/*
+  Functions for packing an EPUB file
+*/
+"use strict";
+
+/*
+    For our purposes, an EPUB only contains two types of content file: XHTML and image.
+    - The HTML files are in reading order (i.e. Appear in same order as spine and table of contents (ToC))
+    - If an HTML file entry has a "title" element, it will appear in the ToC
+    - Stand-alone images (e.g. Cover) will have an XHTML entry that points to the image.
+    - First image, (if there are any) will be the cover image
+*/
+
+/// identifier for this EPUB.  (i.e. "origin" URL story was downloaded from)
+/// The Title of the story
+/// The writer of the story
+class EpubPacker {
+    constructor(metaInfo, version = EpubPacker.EPUB_VERSION_2) {
+        this.metaInfo = metaInfo;
+        this.version = version;
+
+        this.emptyDocFactory = util.createEmptyXhtmlDoc;
+        let contentType = EpubPacker.XHTML_MIME_TYPE;
+        if (version === EpubPacker.EPUB_VERSION_3) {
+            this.emptyDocFactory = util.createEmptyHtmlDoc;
+            contentType = EpubPacker.HTML_MIME_TYPE;
+        }
+        this.contentValidator = xml => util.isXhtmlInvalid(xml, contentType);
+    }
+
+    static coverImageXhtmlHref() {
+        return "OEBPS/Text/Cover.xhtml";
+    }
+
+    static coverImageXhtmlId() {
+        return "cover";
+    }
+
+    assemble(epubItemSupplier) {
+        let zipFileWriter = new zip.BlobWriter("application/epub+zip");
+        let zipWriter = new zip.ZipWriter(zipFileWriter, {useWebWorkers: false, compressionMethod: 8, extendedTimestamp: false});
+        this.addRequiredFiles(zipWriter);
+        zipWriter.add("OEBPS/content.opf", new zip.TextReader(this.buildContentOpf(epubItemSupplier)));
+        zipWriter.add("OEBPS/toc.ncx", new zip.TextReader(this.buildTableOfContents(epubItemSupplier)));
+        if (this.version === EpubPacker.EPUB_VERSION_3) {
+            zipWriter.add("OEBPS/toc.xhtml", new zip.TextReader(this.buildNavigationDocument(epubItemSupplier)));
+        }
+        this.packContentFiles(zipWriter, epubItemSupplier);
+        zipWriter.add(util.styleSheetFileName(), new zip.TextReader(this.metaInfo.styleSheet));
+        return zipWriter.close();
+    }
+
+    static addExtensionIfMissing(fileName) {
+        let extension = ".epub";
+        return (fileName.endsWith(extension)) ? fileName : fileName + extension;
+    }
+
+    // every EPUB must have a mimetype and a container.xml file
+    addRequiredFiles(zipFile) {
+        zipFile.add("mimetype", new zip.TextReader("application/epub+zip"),{compressionMethod: 0});
+        zipFile.add("META-INF/container.xml",
+            new zip.TextReader("" +
+            "" +
+                "" +
+                    "" +
+                "" +
+            "")
+        );
+    }
+
+    buildContentOpf(epubItemSupplier) {
+        let ns = "http://www.idpf.org/2007/opf";
+        let opf = document.implementation.createDocument(ns, "package", null);
+        opf.documentElement.setAttributeNS(null, "version", this.version);
+        opf.documentElement.setAttributeNS(null, "unique-identifier", "BookId");
+        this.buildMetaData(opf, epubItemSupplier);
+        this.buildManifest(opf, ns, epubItemSupplier);
+        this.buildSpine(opf, ns, epubItemSupplier);
+        this.buildGuide(opf, ns, epubItemSupplier);
+
+        return util.xmlToString(opf);
+    }
+
+    buildMetaData(opf, epubItemSupplier) {
+        let opf_ns = "http://www.idpf.org/2007/opf";
+        let dc_ns = "http://purl.org/dc/elements/1.1/";
+
+        let metadata = opf.createElementNS(opf_ns, "metadata");
+        metadata.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:dc", dc_ns);
+        metadata.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:opf", opf_ns);
+        opf.documentElement.appendChild(metadata);
+        this.createAndAppendChildNS(metadata, dc_ns, "dc:title", this.metaInfo.title);
+        this.createAndAppendChildNS(metadata, dc_ns, "dc:language", this.metaInfo.language);
+        let datePublished = this.metaInfo.datePublished || this.getDateForMetaData();
+        this.createAndAppendChildNS(metadata, dc_ns, "dc:date", datePublished);
+        // ----- SUBJECTS (support multiple) -----
+        if (!util.isNullOrEmpty(this.metaInfo.subject)) {
+            let subjects = this.metaInfo.subject;
+
+            // if string, split by comma
+            if (!Array.isArray(subjects)) {
+                subjects = subjects.split(",").map(s => s.trim()).filter(Boolean);
+            }
+
+            subjects.forEach(sub => {
+                this.createAndAppendChildNS(metadata, dc_ns, "dc:subject", sub);
+            });
+        }
+
+        // ----- DESCRIPTION (HTML paragraph formatting) -----
+        if (!util.isNullOrEmpty(this.metaInfo.description)) {
+            let desc = this.metaInfo.description;
+
+            // escape XML
+            desc = desc
+                .replace(/&/g, "&")
+                .replace(//g, ">");
+
+            // paragraph formatting
+            let parts = desc.split(/\n\s*\n/);
+            let formatted = parts
+                .map(p => `

${p.trim().replace(/\n/g, "
")}

`) + .join("\n"); + + this.createAndAppendChildNS(metadata, dc_ns, "dc:description", formatted); + } + if (!util.isNullOrEmpty(this.metaInfo.publisher)) { + this.createAndAppendChildNS(metadata, dc_ns, "dc:publisher", this.metaInfo.publisher); + } + + let author = this.createAndAppendChildNS(metadata, dc_ns, "dc:creator", this.metaInfo.author); + this.addMetaProperty(metadata, author, "file-as", "creator", this.metaInfo.getFileAuthorAs()); + this.addMetaProperty(metadata, author, "role", "creator", "aut"); + + if (this.metaInfo.translator !== null) { + let translator = this.createAndAppendChildNS(metadata, dc_ns, "dc:contributor", this.metaInfo.translator); + this.addMetaProperty(metadata, translator, "file-as", "translator", this.metaInfo.translator); + this.addMetaProperty(metadata, translator, "role", "translator", "trl"); + } + + let idText = (this.version === EpubPacker.EPUB_VERSION_3 ? "uri:" : "") + this.metaInfo.uuid; + let identifier = this.createAndAppendChildNS(metadata, dc_ns, "dc:identifier", idText); + identifier.setAttributeNS(null, "id", "BookId"); + if (this.version === EpubPacker.EPUB_VERSION_2) { + identifier.setAttributeNS(opf_ns, "opf:scheme", "URI"); + } else { + this.addMetaProperty(metadata, identifier, "identifier-type", "BookId", "URI"); + let meta = this.createAndAppendChildNS(metadata, opf_ns, "meta"); + meta.setAttributeNS(null, "property", "dcterms:modified"); + let dateWithoutMillisecond = this.getDateForMetaData().substring(0, 19) + "Z"; + meta.textContent = dateWithoutMillisecond; + } + + let webToEpubVersion = `[https://github.com/dteviot/WebToEpub] (ver. ${util.extensionVersion()})`; + let contributor = this.createAndAppendChildNS(metadata, dc_ns, "dc:contributor", webToEpubVersion); + this.addMetaProperty(metadata, contributor, "role", "packingTool", "bkp"); + + if (epubItemSupplier.hasCoverImageFile()) { + this.appendMetaContent(metadata, opf_ns, "cover", epubItemSupplier.coverImageId()); + } + + if (this.metaInfo.seriesName !== null) { + this.appendMetaContent(metadata, opf_ns, "calibre:series", this.metaInfo.seriesName); + this.appendMetaContent(metadata, opf_ns, "calibre:series_index", this.metaInfo.seriesIndex); + if (this.version === EpubPacker.EPUB_VERSION_3) { + let series = this.createAndAppendChildNS(metadata, opf_ns, "meta"); + series.setAttributeNS(null, "property", "belongs-to-collection"); + series.setAttributeNS(null, "id", "series"); + series.textContent = this.metaInfo.seriesName; + this.addMetaProperty(metadata, series, "collection-type", "series", "series"); + this.addMetaProperty(metadata, series, "group-position", "series", this.metaInfo.seriesIndex); + } + } + + for (let i of epubItemSupplier.manifestItems()) { + let sourceUrl = util.clearIfDataUri(i.sourceUrl); + if (sourceUrl) { // Only add dc:source if we have a valid URL + let source = this.createAndAppendChildNS(metadata, dc_ns, "dc:source", sourceUrl); + source.setAttributeNS(null, "id", "id." + i.getId()); + } + } + } + + addMetaProperty(metadata, element, propName, id, value) { + let opf_ns = "http://www.idpf.org/2007/opf"; + if (this.version === EpubPacker.EPUB_VERSION_3) { + element.setAttributeNS(null, "id", id); + let meta = this.createAndAppendChildNS(metadata, opf_ns, "meta"); + meta.setAttributeNS(null, "refines", "#" + id); + meta.setAttributeNS(null, "property", propName); + meta.textContent = value; + } else { + element.setAttributeNS(opf_ns, "opf:" + propName, value); + } + } + + appendMetaContent(parent, opf_ns, name, content) { + let meta = this.createAndAppendChildNS(parent, opf_ns, "meta"); + // Some e-book readers such as the Nook fail to recognize covers if the content + // attribute comes before the name attribute. For maximum compatibility move + // the name attribute before the content attribute. + meta.setAttributeNS(null, "name", name); + meta.setAttributeNS(null, "content", content); + } + + buildManifest(opf, ns, epubItemSupplier) { + let manifest = this.createAndAppendChildNS(opf.documentElement, ns, "manifest"); + for (let i of epubItemSupplier.manifestItems()) { + let item = this.addManifestItem(manifest, ns, i.getZipHref(), i.getId(), i.getMediaType()); + this.setSvgPropertyForManifestItem(item, i.hasSvg()); + } + + this.addManifestItem(manifest, ns, util.styleSheetFileName(), "stylesheet", "text/css"); + this.addManifestItem(manifest, ns, "OEBPS/toc.ncx", "ncx", "application/x-dtbncx+xml"); + if (epubItemSupplier.hasCoverImageFile()) { + let item = this.addManifestItem(manifest, ns, EpubPacker.coverImageXhtmlHref(), EpubPacker.coverImageXhtmlId(), "application/xhtml+xml"); + this.setSvgPropertyForManifestItem(item, this.doesCoverHaveSvg(epubItemSupplier)); + } + if (this.version === EpubPacker.EPUB_VERSION_3) { + let item = this.addManifestItem(manifest, ns, "OEBPS/toc.xhtml", "nav", "application/xhtml+xml"); + item.setAttributeNS(null, "properties", "nav"); + } + } + + addManifestItem(manifest, ns, href, id, mediaType) { + let item = this.createAndAppendChildNS(manifest, ns, "item"); + let relativeHref = this.makeRelative(href); + if (mediaType === "image/webp") { + let userPreferences = main.getUserPreferences(); + if (!userPreferences?.disableWebpImageFormatError?.value) { + let errorMsg = UIText.Warning.warningWebpImage(relativeHref); + ErrorLog.log(errorMsg); + } + } + item.setAttributeNS(null, "href", relativeHref); + item.setAttributeNS(null, "id", id); + item.setAttributeNS(null, "media-type", mediaType); + return item; + } + + setSvgPropertyForManifestItem(item, hasSvg) { + if (hasSvg && (this.version === EpubPacker.EPUB_VERSION_3)) { + item.setAttributeNS(null, "properties", "svg"); + } + } + + doesCoverHaveSvg(epubItemSupplier) { + let fileContent = epubItemSupplier.makeCoverImageXhtmlFile(util.createEmptyXhtmlDoc); + let doc = new DOMParser().parseFromString(fileContent, "application/xml"); + return (doc.querySelector("svg") != null); + } + + buildSpine(opf, ns, epubItemSupplier) { + let spine = this.createAndAppendChildNS(opf.documentElement, ns, "spine"); + spine.setAttributeNS(null, "toc", "ncx"); + if (epubItemSupplier.hasCoverImageFile()) { + this.addSpineItemRef(spine, ns, EpubPacker.coverImageXhtmlId()); + } + for (let item of epubItemSupplier.spineItems()) { + this.addSpineItemRef(spine, ns, item.getId()); + } + } + + addSpineItemRef(spine, ns, idref) { + this.createAndAppendChildNS(spine, ns, "itemref").setAttributeNS(null, "idref", idref); + } + + buildGuide(opf, ns, epubItemSupplier) { + if (epubItemSupplier.hasCoverImageFile()) { + let guide = this.createAndAppendChildNS(opf.documentElement, ns, "guide"); + let reference = this.createAndAppendChildNS(guide, ns, "reference"); + reference.setAttributeNS(null, "href", this.makeRelative(EpubPacker.coverImageXhtmlHref())); + reference.setAttributeNS(null, "title", "Cover"); + reference.setAttributeNS(null, "type", "cover"); + } + } + + buildTableOfContents(epubItemSupplier) { + let ns = "http://www.daisy.org/z3986/2005/ncx/"; + let ncx = document.implementation.createDocument(ns, "ncx", null); + ncx.documentElement.setAttribute("version", "2005-1"); + ncx.documentElement.setAttribute("xml:lang", this.metaInfo.language); + let head = this.createAndAppendChildNS(ncx.documentElement, ns, "head"); + this.buildDocTitle(ncx, ns); + let depth = this.buildNavMap(ncx, ns, epubItemSupplier); + this.populateHead(head, ns, depth); + + return util.xmlToString(ncx); + } + + buildNavigationDocument(epubItemSupplier) { + let ns = "http://www.w3.org/1999/xhtml"; + let navDoc = document.implementation.createDocument(ns, "html", null); + navDoc.documentElement.setAttribute("xml:lang", this.metaInfo.language); + navDoc.documentElement.setAttribute("xmlns:epub", "http://www.idpf.org/2007/ops"); + navDoc.documentElement.setAttribute("lang", this.metaInfo.language); + let head = this.createAndAppendChildNS(navDoc.documentElement, ns, "head"); + this.createAndAppendChildNS(head, ns, "title").textContent = "Table of Contents"; + this.addDocType(navDoc); + let body = this.createAndAppendChildNS(navDoc.documentElement, ns, "body"); + let nav = this.createAndAppendChildNS(body, ns, "nav"); + nav.setAttribute("epub:type", "toc"); + nav.setAttribute("id", "toc"); + this.populateNavElement(nav, ns, epubItemSupplier); + return util.xmlToString(navDoc); + } + + addDocType(navDoc) { + let docTypeNode = navDoc.implementation.createDocumentType("html", "", ""); + navDoc.insertBefore(docTypeNode, navDoc.childNodes[0]); + } + + populateHead(head, ns, depth) { + this.appendMetaContent(head, ns, "dtb:uid", (this.version === EpubPacker.EPUB_VERSION_3 ? "uri:" : "") + this.metaInfo.uuid); + this.appendMetaContent(head, ns, "dtb:depth", (depth < 2) ? "2" : depth); + this.appendMetaContent(head, ns, "dtb:totalPageCount", "0"); + this.appendMetaContent(head, ns, "dtb:maxPageNumber", "0"); + } + + buildDocTitle(ncx, ns) { + let docTitle = this.createAndAppendChildNS(ncx.documentElement, ns, "docTitle"); + this.createAndAppendChildNS(docTitle, ns, "text", this.metaInfo.title); + } + + populateNavElement(nav, ns, epubItemSupplier) { + let rootParent = this.createAndAppendChildNS(nav, ns, "ol"); + let parents = new NavPointParentElementsStack(rootParent); + for (let chapterInfo of epubItemSupplier.chapterInfo()) { + let parent = parents.findParentElement(chapterInfo.depth); + let nextLevel = this.buildNavListItem(parent, ns, chapterInfo); + parents.addElement(chapterInfo.depth, nextLevel); + } + this.removeEmptyNavLists(rootParent); + } + + buildNavListItem(parent, ns, chapterInfo) { + let li = this.createAndAppendChildNS(parent, ns, "li"); + let link = this.createAndAppendChildNS(li, ns, "a"); + link.href = this.makeRelative(chapterInfo.src); + link.textContent = chapterInfo.title; + return this.createAndAppendChildNS(li, ns, "ol"); + } + + removeEmptyNavLists(rootParent) { + for (let list of rootParent.querySelectorAll("ol")) { + if (list.childElementCount === 0) { + list.remove(); + } + } + } + + buildNavMap(ncx, ns, epubItemSupplier) { + let navMap = this.createAndAppendChildNS(ncx.documentElement, ns, "navMap"); + let parents = new NavPointParentElementsStack(navMap); + let playOrder = 0; + let id = 0; + let lastChapterSrc = null; + for (let chapterInfo of epubItemSupplier.chapterInfo()) { + let parent = parents.findParentElement(chapterInfo.depth); + if (lastChapterSrc !== chapterInfo.src) { + ++playOrder; + } + let navPoint = this.buildNavPoint(parent, ns, playOrder, ++id, chapterInfo); + lastChapterSrc = chapterInfo.src; + parents.addElement(chapterInfo.depth, navPoint); + } + return parents.maxDepth; + } + + buildNavPoint(parent, ns, playOrder, id, chapterInfo) { + let navPoint = this.createAndAppendChildNS(parent, ns, "navPoint"); + navPoint.setAttributeNS(null, "id", this.makeId(util.zeroPad(id))); + navPoint.setAttributeNS(null, "playOrder", playOrder); + let navLabel = this.createAndAppendChildNS(navPoint, ns, "navLabel"); + this.createAndAppendChildNS(navLabel, ns, "text", chapterInfo.title); + this.createAndAppendChildNS(navPoint, ns, "content").setAttributeNS(null, "src", this.makeRelative(chapterInfo.src)); + return navPoint; + } + + packContentFiles(zipWriter, epubItemSupplier) { + for (let file of epubItemSupplier.files()) { + file.packInEpub(zipWriter, this.emptyDocFactory, this.contentValidator); + } + if (epubItemSupplier.hasCoverImageFile()) { + let fileContent = + epubItemSupplier.makeCoverImageXhtmlFile(this.emptyDocFactory, "Cover"); + + // inject title under cover (SAFE XHTML VERSION) + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(fileContent, "application/xml"); + + const body = doc.getElementsByTagName("body")[0]; + if (body) { + const xhtmlNS = "http://www.w3.org/1999/xhtml"; + + // outer wrapper (vertical centering engine) + const wrapper = doc.createElementNS(xhtmlNS, "div"); + wrapper.setAttribute("class", "cover-page"); + + // inner wrapper (content box) + const inner = doc.createElementNS(xhtmlNS, "div"); + inner.setAttribute("class", "cover-page-inner"); + + // move original nodes safely + while (body.firstChild) { + inner.appendChild(body.firstChild); + } + + // only add title if it exists + if (this.metaInfo.title) { + const title = doc.createElementNS(xhtmlNS, "h1"); + title.setAttribute("class", "cover-title"); + title.textContent = this.metaInfo.title; + inner.appendChild(title); + } + + wrapper.appendChild(inner); + body.appendChild(wrapper); + + fileContent = new XMLSerializer().serializeToString(doc); + } + } catch (e) { + console.warn("Cover title injection failed:", e); + } + + zipWriter.add( + EpubPacker.coverImageXhtmlHref(), + new zip.TextReader(fileContent) + ); + } + } + + createAndAppendChildNS(element, ns, name, data) { + let child = element.ownerDocument.createElementNS(ns, name); + if (typeof data !== "undefined") { + child.appendChild(element.ownerDocument.createTextNode(data)); + } + element.appendChild(child); + return child; + } + + makeId(id) { + return "body" + id; + } + // changes href to be relative to manifest (and toc.ncx) + // which are in OEBPS + makeRelative(href) { + return href.substr(6); + } + + /// hook point for unit testing (because we can't control the actual time) + /// return time string to put into element of metadata + getDateForMetaData() { + return new Date().toISOString(); + } +} + +EpubPacker.EPUB_VERSION_2 = "2.0"; +EpubPacker.EPUB_VERSION_3 = "3.0"; +EpubPacker.XHTML_MIME_TYPE = "application/xml"; +EpubPacker.HTML_MIME_TYPE = "text/html"; + +/* + Class to make sure we correctly nest the NavPoint elements + in the table of contents +*/ +class NavPointParentElementsStack { + constructor(navMap) { + this.parents = []; + this.parents.push({ + element: navMap, + depth: -1 + }); + this.maxDepth = 0; + } + + findParentElement(depth) { + let index = this.parents.length - 1; + while (depth <= this.parents[index].depth) { + --index; + } + return this.parents[index].element; + } + + addElement(depth, element) { + // discard any elements that are nested >= this one + while (depth <= this.parents[this.parents.length - 1].depth) { + this.parents.pop(); + } + this.parents.push({ + element: element, + depth: depth + }); + if (this.maxDepth < this.parents.length - 1) { + this.maxDepth = this.parents.length - 1; + } + } +} \ No newline at end of file diff --git a/plugin/js/Parser.js b/plugin/js/Parser.js index 04dc691c0..0bc0ee5ae 100644 --- a/plugin/js/Parser.js +++ b/plugin/js/Parser.js @@ -53,8 +53,7 @@ class ParserState { class Parser { constructor(imageCollector) { - this.minimumThrottle = 500; - this.maxSimultanousFetchSize = 1; + this.minimumThrottle = null; this.state = new ParserState(); this.imageCollector = imageCollector || new ImageCollector(); this.userPreferences = null; @@ -622,7 +621,9 @@ class Parser { } groupPagesToFetch(webPages, index) { - return webPages.slice(index, index + this.maxSimultanousFetchSize); + let blockSize = parseInt(this.userPreferences.maxPagesToFetchSimultaneously.value); + blockSize = this.clampSimultanousFetchSize(blockSize); + return webPages.slice(index, index + blockSize); } async fetchWebPageContent(webPage) { @@ -758,6 +759,16 @@ class Parser { return null; } + /** + * limit number of pages to fetch at once + * ignoing user preference. + * Some sites can't handle high load. e.g. Comrademao + * @param {any} fetchSize + */ + clampSimultanousFetchSize(fetchSize) { + return fetchSize; + } + tagAuthorNotes(elements) { for (let e of elements) { e.classList.add("webToEpub-author-note"); @@ -812,7 +823,12 @@ class Parser { getRateLimit() { - let manualDelayPerChapterValue = (!isNaN(parseInt(this.userPreferences.manualDelayPerChapter.value)))?parseInt(this.userPreferences.manualDelayPerChapter.value):this.minimumThrottle; + if (this.userPreferences.manualDelayPerChapter.value == "simulate_reading") + { + return this.userPreferences.manualDelayPerChapter.value; + } + let manualDelayPerChapterValue = parseInt(this.userPreferences.manualDelayPerChapter.value); + if (!this.userPreferences.overrideMinimumDelay.value) { return Math.max(this.minimumThrottle, manualDelayPerChapterValue); @@ -822,6 +838,7 @@ class Parser { async rateLimitDelay() { let manualDelayPerChapterValue = this.getRateLimit(); + manualDelayPerChapterValue = (manualDelayPerChapterValue == "simulate_reading" )? util.randomInteger(420000,900000): manualDelayPerChapterValue; await util.sleep(manualDelayPerChapterValue); } diff --git a/plugin/js/UserPreferences.js b/plugin/js/UserPreferences.js index 0a0477ee8..226a3d8aa 100644 --- a/plugin/js/UserPreferences.js +++ b/plugin/js/UserPreferences.js @@ -86,17 +86,17 @@ class UserPreferences { // eslint-disable-line no-unused-vars // Initialize all preferences explicitly for IDE support // (autocomplete, type inference, no unresolved variable warnings) this.removeDuplicateImages = this.addPreference("removeDuplicateImages", "removeDuplicateImages", false); - this.includeImageSourceUrl = this.addPreference("includeImageSourceUrl", "includeImageSourceUrlCheckboxInput", true); + this.includeImageSourceUrl = this.addPreference("includeImageSourceUrl", "includeImageSourceUrlCheckboxInput", false); this.highestResolutionImages = this.addPreference("highestResolutionImages", "highestResolutionImagesCheckboxInput", true); this.unSuperScriptAlternateTranslations = this.addPreference("unSuperScriptAlternateTranslations", "unSuperScriptCheckboxInput", false); this.styleSheet = this.addPreference("styleSheet", "stylesheetInput", EpubMetaInfo.getDefaultStyleSheet()); this.CustomFilename = this.addPreference("CustomFilename", "CustomFilenameInput", "%Filename%"); this.useSvgForImages = this.addPreference("useSvgForImages", "useSvgForImagesInput", true); this.removeNextAndPreviousChapterHyperlinks = this.addPreference("removeNextAndPreviousChapterHyperlinks", "removeNextAndPreviousChapterHyperlinksInput", true); - this.advancedOptionsVisibleByDefault = this.addPreference("advancedOptionsVisibleByDefault", "advancedOptionsVisibleByDefaultCheckbox", false); - this.noDownloadPopup = this.addPreference("noDownloadPopup", "noDownloadPopupCheckbox", false); - this.writeErrorHistoryToFile = this.addPreference("writeErrorHistoryToFile", "writeErrorHistoryToFileCheckbox", false); - this.createEpub3 = this.addPreference("createEpub3", "createEpub3Checkbox", false); + this.advancedOptionsVisibleByDefault = this.addPreference("advancedOptionsVisibleByDefault", "advancedOptionsVisibleByDefaultCheckbox", true); + this.noDownloadPopup = this.addPreference("noDownloadPopup", "noDownloadPopupCheckbox", true); + this.writeErrorHistoryToFile = this.addPreference("writeErrorHistoryToFile", "writeErrorHistoryToFileCheckbox", true); + this.createEpub3 = this.addPreference("createEpub3", "createEpub3Checkbox", true); this.chaptersPageInChapterList = this.addPreference("chaptersPageInChapterList", "chaptersPageInChapterListCheckbox", false); this.autoSelectBTSeriesPage = this.addPreference("autoSelectBTSeriesPage", "autoParserSelectIncludesBTSeriesPageCheckbox", false); this.removeAuthorNotes = this.addPreference("removeAuthorNotes", "removeAuthorNotesCheckbox", false); @@ -105,7 +105,8 @@ class UserPreferences { // eslint-disable-line no-unused-vars this.selectTranslationAi = this.addPreference("selectTranslationAi", "selectTranslationAiCheckbox", false); this.selectRetryLonger = this.addPreference("selectRetryLonger", "selectRetryLongerCheckbox", false); this.removeTranslated = this.addPreference("removeTranslated", "removeTranslatedCheckbox", false); - this.skipChaptersThatFailFetch = this.addPreference("skipChaptersThatFailFetch", "skipChaptersThatFailFetchCheckbox", false); + this.maxPagesToFetchSimultaneously = this.addPreference("maxPagesToFetchSimultaneously", "maxPagesToFetchSimultaneouslyTag", "32"); + this.skipChaptersThatFailFetch = this.addPreference("skipChaptersThatFailFetch", "skipChaptersThatFailFetchCheckbox", true); this.noContentToError403 = this.addPreference("noContentToError403", "noContentToError403Checkbox", false); this.maxChaptersPerEpub = this.addPreference("maxChaptersPerEpub", "maxChaptersPerEpubTag", "10,000"); this.manualDelayPerChapter = this.addPreference("manualDelayPerChapter", "manualDelayPerChapterTag", "0"); @@ -115,22 +116,22 @@ class UserPreferences { // eslint-disable-line no-unused-vars this.compressImagesAnimated = this.addPreference("compressImagesAnimated", "compressImagesAnimatedCheckbox", true); this.compressImagesJpgCover = this.addPreference("compressImagesJpgCover", "compressImagesJpgCoverCheckbox", false); this.compressImagesType = this.addPreference("compressImagesType", "compressImagesType", "jpg"); - this.compressImagesMaxResolution = this.addPreference("compressImagesMaxResolution", "compressImagesMaxResolutionTag", "1080"); + this.compressImagesMaxResolution = this.addPreference("compressImagesMaxResolution", "compressImagesMaxResolutionTag", "2160"); this.overwriteExistingEpub = this.addPreference("overwriteExistingEpub", "overwriteEpubWhenDuplicateFilenameCheckbox", false); this.themeColor = this.addPreference("themeColor", "themeColorTag", ""); - this.useFullTitle = this.addPreference("useFullTitle", "useFullTitleAsFileNameCheckbox", false); + this.useFullTitle = this.addPreference("useFullTitle", "useFullTitleAsFileNameCheckbox", true); this.removeIllegalCharacterFromFilenameOnDownload = this.addPreference("removeIllegalCharacterFromFilenameOnDownload", "removeIllegalCharacterFromFilenameOnDownloadCheckbox", false); this.addInformationPage = this.addPreference("addInformationPage", "addInformationPageToEpubCheckbox", true); - this.lesstags = this.addPreference("lesstags", "lesstagsCheckbox", true); + this.lesstags = this.addPreference("lesstags", "lesstagsCheckbox", false); this.autosearchmetadata = this.addPreference("autosearchmetadata", "autosearchmetadataCheckbox", false); - this.noAdditionalMetadata = this.addPreference("noAdditionalMetadata", "noAdditionalMetadataCheckbox", true); - this.ShowMoreMetadataOptions = this.addPreference("ShowMoreMetadataOptions", "ShowMoreMetadataOptionsCheckbox", false); - this.LibShowAdvancedOptions = this.addPreference("LibShowAdvancedOptions", "LibShowAdvancedOptionsCheckbox", false); + this.noAdditionalMetadata = this.addPreference("noAdditionalMetadata", "noAdditionalMetadataCheckbox", false); + this.ShowMoreMetadataOptions = this.addPreference("ShowMoreMetadataOptions", "ShowMoreMetadataOptionsCheckbox", true); + this.LibShowAdvancedOptions = this.addPreference("LibShowAdvancedOptions", "LibShowAdvancedOptionsCheckbox", true); this.LibShowCompactView = this.addPreference("LibShowCompactView", "LibShowCompactViewCheckbox", false); - this.LibDownloadEpubAfterUpdate = this.addPreference("LibDownloadEpubAfterUpdate", "LibDownloadEpubAfterUpdateCheckbox", false); + this.LibDownloadEpubAfterUpdate = this.addPreference("LibDownloadEpubAfterUpdate", "LibDownloadEpubAfterUpdateCheckbox", true); this.disableShiftClickAlert = this.addPreference("disableShiftClickAlert", "disableShiftClickAlertCheckbox", false); this.disableImageResError = this.addPreference("disableImageResError", "disableImageResErrorCheckbox", false); - this.disableWebpImageFormatError = this.addPreference("disableWebpImageFormatError", "disableWebpImageFormatErrorCheckbox", false); + this.disableWebpImageFormatError = this.addPreference("disableWebpImageFormatError", "disableWebpImageFormatErrorCheckbox", true); document.getElementById("themeColorTag").addEventListener("change", UserPreferences.SetTheme); } diff --git a/plugin/js/parsers/CreativeNovelsParser.js b/plugin/js/parsers/CreativeNovelsParser.js index a9c5f23c4..a4b24f464 100644 --- a/plugin/js/parsers/CreativeNovelsParser.js +++ b/plugin/js/parsers/CreativeNovelsParser.js @@ -16,7 +16,7 @@ class CreativeNovelsParser extends Parser { removeUnwantedElementsFromContentElement(element) { util.removeElements(element.querySelectorAll("div.team, div.x-donate-1,"+ - " div.navigation, div.navi, header.entry-header")); + " div.navigation, div.navi, header.entry-header, .support-placement")); super.removeUnwantedElementsFromContentElement(element); } diff --git a/plugin/js/parsers/FoxaholicParser.js b/plugin/js/parsers/FoxaholicParser.js index b16ed7bc3..326b17384 100644 --- a/plugin/js/parsers/FoxaholicParser.js +++ b/plugin/js/parsers/FoxaholicParser.js @@ -28,6 +28,11 @@ class FoxaholicParser extends WordpressBaseParser { : title.textContent; } + removeUnwantedElementsFromContentElement(element) { + util.removeChildElementsMatchingSelector(element, "div.foxaholic-publift-manga-chapter, div.chapter-warning"); + super.removeUnwantedElementsFromContentElement(element); + } + findCoverImageUrl(dom) { return util.getFirstImgSrc(dom, "div.summary_image"); } diff --git a/plugin/js/parsers/GrandOldBooks.js b/plugin/js/parsers/GrandOldBooks.js new file mode 100644 index 000000000..156370a8e --- /dev/null +++ b/plugin/js/parsers/GrandOldBooks.js @@ -0,0 +1,335 @@ + +"use strict"; + +class GrandOldBooksParser extends Parser { + constructor() { + super(new ImageCollector()); + this._meta = null; + this._catalogAuthor = null; + this._coverImageUrl = null; + this._glossary = null; + } + + async getChapterUrls(dom) { + const slug = this._slugFromUrl(dom.baseURI); + if (!slug) throw new Error("Cannot extract book slug: " + dom.baseURI); + const BASE = "https://grandoldbooks.com"; + const OPTS = { credentials: "include" }; + + // Try to pull glossary from the starting page's rendered DOM right now + // (React renders it client-side but WebToEpub passes the live tab DOM) + this._glossary = this._extractGlossaryFromDom(dom); + + // Primary: offline API + try { + const r = await fetch(`${BASE}/api/offline/books/${slug}`, OPTS); + if (r.ok) { + const p = await r.json(); + this._meta = p; + if (p.coverImage) this._coverImageUrl = + p.coverImage.startsWith("http") ? p.coverImage : BASE + p.coverImage; + if (!this._glossary && p.bookAnnotations?.glossary) + this._glossary = p.bookAnnotations.glossary; + this._updateUIAsync(); + const chapters = p.chapters.map(ch => ({ + sourceUrl: this._chapterUrl(slug, ch), + title: ch.title, + })); + return this._appendGlossary(chapters, slug, BASE); + } + } catch (e) { /* ignore */ } + + // Fallback: meta.json + catalog.json + try { + const r = await fetch(`${BASE}/data/books/${slug}/meta.json`, OPTS); + if (r.ok) { + const m = await r.json(); + this._meta = m; + if (m.coverImage) this._coverImageUrl = + m.coverImage.startsWith("http") ? m.coverImage : BASE + m.coverImage; + try { + const cr = await fetch(`${BASE}/data/catalog.json`, OPTS); + if (cr.ok) { + const cat = await cr.json(); + const a = (cat.authors ?? []).find(x => x.id === m.authorId); + if (a?.name) this._catalogAuthor = a.name; + } + } catch (e) { /* ignore */ } + // Try public annotations.json + if (!this._glossary) { + try { + const ar = await fetch(`${BASE}/data/books/${slug}/annotations.json`, OPTS); + if (ar.ok) { + const ann = await ar.json(); + this._glossary = ann?.glossary ?? null; + } + } catch (e) { /* ignore */ } + } + this._updateUIAsync(); + const chapters = await this._stubUrls(slug, m.totalChapters ?? 1, BASE); + return this._appendGlossary(chapters, slug, BASE); + } + } catch (e) { /* ignore */ } + + const ndc = this._ndcChapters(dom); + if (ndc?.length) + return ndc.map(ch => ({ sourceUrl: this._chapterUrl(slug, ch), title: ch.title })); + throw new Error("Could not get chapters for " + slug); + } + + // Extract glossary from the live rendered DOM of the books page + // (React renders glossary cards in the same page) + _extractGlossaryFromDom(dom) { + const result = {}; + // Try __NEXT_DATA__ (App Router doesn't expose pageProps, but worth trying) + try { + const s = dom.querySelector("#__NEXT_DATA__"); + const pp = JSON.parse(s?.textContent ?? "null")?.props?.pageProps; + const g = pp?.bookAnnotations?.glossary ?? pp?.annotations?.glossary; + if (g && Object.keys(g).length > 0) return g; + } catch (e) { /* ignore */ } + return Object.keys(result).length ? result : null; + } + + // Add glossary as last chapter using a unique URL (avoids starting-URL dedup) + _appendGlossary(chapters, slug, BASE) { + if (!this._glossary || !Object.keys(this._glossary).length) return chapters; + // ?_gob=glossary ensures WebToEpub treats this as a distinct URL + chapters.push({ + sourceUrl: `${BASE}/books/${slug}?_gob=glossary`, + title: "Glossary", + }); + return chapters; + } + + _buildGlossaryElement(dom) { + const doc = dom.ownerDocument ?? dom; + const BASE = "https://grandoldbooks.com"; + + // Map annotation types → display group (propernoun + footnote → "Places & Terms") + const GROUP = { + character: "Characters", + propernoun: "Places & Terms", + footnote: "Places & Terms", + vocabulary: "Vocabulary", + }; + const GROUP_ORDER = ["Characters", "Places & Terms", "Vocabulary"]; + + // Bucket entries by display group, sorted alphabetically within each + const buckets = {}; + GROUP_ORDER.forEach(g => (buckets[g] = [])); + for (const [key, entry] of Object.entries(this._glossary)) { + const g = GROUP[entry.type] ?? "Places & Terms"; + buckets[g].push([key, entry]); + } + GROUP_ORDER.forEach(g => buckets[g].sort(([a], [b]) => a.localeCompare(b))); + + const art = doc.createElement("article"); + + // ── Title ──────────────────────────────────────────────────── + const h2 = doc.createElement("h2"); + h2.textContent = "Glossary"; + art.appendChild(h2); + + // ── TOC nav ────────────────────────────────────────────────── + const nav = doc.createElement("p"); + nav.textContent = GROUP_ORDER + .map(g => `${g} (${buckets[g].length})`) + .join(" · "); + art.appendChild(nav); + + // ── One section per group ──────────────────────────────────── + for (const group of GROUP_ORDER) { + const entries = buckets[group]; + if (!entries.length) continue; + + const h3 = doc.createElement("h3"); + h3.textContent = `${group} (${entries.length})`; + art.appendChild(h3); + + // Characters with images: render as portrait cards BEFORE the table + const withImg = entries.filter(([, e]) => !!e.image); + if (withImg.length) { + const gallery = doc.createElement("div"); + for (const [key, entry] of withImg) { + const card = doc.createElement("div"); + const img = doc.createElement("img"); + img.src = entry.image.startsWith("http") ? entry.image : BASE + entry.image; + img.alt = key; + card.appendChild(img); + const cap = doc.createElement("p"); + cap.textContent = key; + card.appendChild(cap); + gallery.appendChild(card); + } + art.appendChild(gallery); + } + + // All entries in a table: Term | Category | Definition + const table = doc.createElement("table"); + const thead = doc.createElement("thead"); + const hrow = doc.createElement("tr"); + ["Term", "Category", "Definition"].forEach(t => { + const th = doc.createElement("th"); + th.textContent = t; + hrow.appendChild(th); + }); + thead.appendChild(hrow); + table.appendChild(thead); + + const tbody = doc.createElement("tbody"); + for (const [key, entry] of entries) { + const tr = doc.createElement("tr"); + + const tdTerm = doc.createElement("td"); + tdTerm.textContent = key; + tr.appendChild(tdTerm); + + const tdCat = doc.createElement("td"); + const CAT_LABEL = { character: "Characters", proper_noun: "Places & Terms", + footnote: "Places & Terms", vocabulary: "Vocabulary" }; + tdCat.textContent = CAT_LABEL[entry.type] ?? entry.type; + tr.appendChild(tdCat); + + const tdDef = doc.createElement("td"); + tdDef.textContent = entry.description ?? ""; + tr.appendChild(tdDef); + + tbody.appendChild(tr); + } + table.appendChild(tbody); + art.appendChild(table); + } + return art; + } + + // ── Core methods ────────────────────────────────────────────────── + + findContent(dom) { + // Detect glossary page by query param (survives Next.js rendering) + if (dom.baseURI?.includes("_gob=glossary") && this._glossary) + return this._buildGlossaryElement(dom); + return dom.querySelector(".min-h-screen"); + } + + extractTitleImpl(dom) { + if (dom.baseURI?.includes("_gob=glossary")) return "Glossary"; + return dom.querySelector("h1")?.textContent?.trim() ?? null; + } + + removeUnwantedElementsFromContentElement(element) { + if (element?.tagName === "ARTICLE") return; // skip glossary + util.removeChildElementsMatchingSelector(element, + ".z-30, .z-50, .backdrop-blur-xl, .whitespace-nowrap, .flex.items-center, h3, [class*='audio'], [class*='gate']" + ); + super.removeUnwantedElementsFromContentElement(element); + } + + extractAuthor(dom) { + return dom?.querySelector?.("meta[name=\"author\"]")?.getAttribute?.("content") + ?? this._meta?.authorName ?? this._catalogAuthor + ?? super.extractAuthor(dom); + } + + getInformationEpubItemChildNodes(dom) { + const text = this._meta?.summary ?? + dom?.querySelector?.("meta[property=\"og:description\"]")?.getAttribute?.("content"); + if (!text) return []; + const p = document.createElement("p"); + p.textContent = text; + return [p.outerHTML]; + } + + extractSubject(dom) { + const genre = this._meta?.genre; + if (Array.isArray(genre) && genre.length) return genre.join(", "); + return super.extractSubject?.(dom) ?? ""; + } + + findCoverImageUrl(dom) { + if (this._coverImageUrl) return this._coverImageUrl; + const og = dom?.querySelector?.("meta[property=\"og:image\"]")?.getAttribute?.("content"); + if (og) return og; + const slug = this._slugFromUrl(dom?.baseURI); + return slug ? `https://grandoldbooks.com/data/images/covers/${slug}.webp` : null; + } + + _updateUIAsync() { + const author = this._meta?.authorName ?? this._catalogAuthor; + const desc = this._meta?.summary; + const genre = Array.isArray(this._meta?.genre) ? this._meta.genre.join(", ") : null; + const cover = this._coverImageUrl; + document.querySelectorAll("label").forEach(label => { + const text = label.textContent.trim().toLowerCase(); + const el = label.htmlFor ? document.getElementById(label.htmlFor) + : label.querySelector("input, textarea"); + if (!el) return; + if (author && text.includes("author")) el.value = author; + if (desc && (text.includes("description") || text.includes("synopsis"))) el.value = desc; + if (genre && (text.includes("tag") || text.includes("subject") || text.includes("genre"))) el.value = genre; + if (cover && text.includes("cover")) el.value = cover; + }); + const set = (id, val) => { if (!val) return; const e = document.getElementById(id); if (e) e.value = val; }; + set("authorInput", author); set("epubAuthor", author); + set("descriptionInput", desc); set("epubDescription", desc); + set("tagsInput", genre); set("subjectInput", genre); + set("coverUrl", cover); set("coverImageUrl", cover); + } + + populateUI(dom) { + super.populateUI(dom); + const author = dom?.querySelector?.("meta[name=\"author\"]")?.getAttribute?.("content"); + const ogDesc = dom?.querySelector?.("meta[property=\"og:description\"]")?.getAttribute?.("content"); + const ogImg = dom?.querySelector?.("meta[property=\"og:image\"]")?.getAttribute?.("content"); + if (!this._coverImageUrl && ogImg) this._coverImageUrl = ogImg; + document.querySelectorAll("label").forEach(label => { + const text = label.textContent.trim().toLowerCase(); + const el = label.htmlFor ? document.getElementById(label.htmlFor) + : label.querySelector("input, textarea"); + if (!el) return; + if (author && text.includes("author") && (!el.value || el.value === "")) el.value = author; + if (ogDesc && (text.includes("description") || text.includes("synopsis")) && !el.value) el.value = ogDesc; + if (ogImg && text.includes("cover") && !el.value) el.value = ogImg; + }); + const setIfEmpty = (id, val) => { if (!val) return; const e = document.getElementById(id); if (e && (!e.value || e.value === "")) e.value = val; }; + setIfEmpty("authorInput", author); setIfEmpty("epubAuthor", author); + setIfEmpty("coverUrl", ogImg); setIfEmpty("coverImageUrl", ogImg); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + _slugFromUrl(url) { + const m = (url ?? "").match(/grandoldbooks\.com\/(?:books|read)\/([^/?#]+)/); + return m ? m[1] : null; + } + + _ndcChapters(dom) { + try { + const s = dom.querySelector("#__NEXT_DATA__"); + const pp = JSON.parse(s?.textContent ?? "null")?.props?.pageProps; + return pp?.readerPayload?.chapters ?? pp?.chapters ?? null; + } catch (e) { return null; } + } + + _chapterUrl(bookSlug, ch) { + const base = "https://grandoldbooks.com/read/" + bookSlug; + const partNum = ch.part ?? 1; + const partSlug = this._toSlug(ch.partName ?? "part-" + partNum); + const chapSlug = this._toSlug(ch.slugTitle ?? ch.title); + return `${base}/${partNum}/${partSlug}/${ch.number}/${chapSlug}`; + } + + _toSlug(s) { + return (s ?? "").toLowerCase() + .replace(/['']/g, "-").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + } + + async _stubUrls(slug, total, BASE) { + return Array.from({ length: total }, (_, i) => ({ + sourceUrl: `${BASE}/read/${slug}/${i + 1}`, + title: `Chapter ${i + 1}`, + })); + } +} + +parserFactory.register("grandoldbooks.com", () => new GrandOldBooksParser()); diff --git a/plugin/js/parsers/ManganovParser.js b/plugin/js/parsers/ManganovParser.js index fe5562533..7f45bd3e5 100644 --- a/plugin/js/parsers/ManganovParser.js +++ b/plugin/js/parsers/ManganovParser.js @@ -37,4 +37,8 @@ class ManganovParser extends Parser { getInformationEpubItemChildNodes(dom) { return [...dom.querySelectorAll("section.mb-3.mt-2 p")]; } + + removeUnwantedElementsFromContentElement(content) { + util.removeChildElementsMatchingCss(content, "small.text-muted, p.text-center.mb-0"); + } } diff --git a/plugin/js/parsers/MvlempyrParser.js b/plugin/js/parsers/MvlempyrParser.js index 1d164a198..f66ca5a75 100644 --- a/plugin/js/parsers/MvlempyrParser.js +++ b/plugin/js/parsers/MvlempyrParser.js @@ -6,7 +6,7 @@ parserFactory.register("mvlempyr.io", () => new MvlempyrParser()); class MvlempyrParser extends Parser { constructor() { super(); - this.minimumThrottle = 1000; + this.minimumThrottle = 0; } async getChapterUrls(dom) { @@ -69,10 +69,23 @@ class MvlempyrParser extends Parser { let epubDescription = ([...dom.querySelectorAll("div.synopsis")]); return epubDescription.map(e => e.innerHTML.replace(/

/g, "\n\n").replace(/
/g, "\n")); } - + extractSubject(dom) { - let tags = ([...dom.querySelectorAll("div.genere-tagslist a")]); - let regex = new RegExp("^#"); - return tags.map(e => e.textContent.trim().replace(regex, "")).join(", "); + const regex = /^#/; + + let seen = new Set(); + let result = []; + + [...dom.querySelectorAll("div.genere-tagslist a")] + .map(e => e.textContent.trim().replace(regex, "")) + .forEach(tag => { + let key = tag.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + result.push(tag); + } + }); + + return result.join(", "); } } diff --git a/plugin/js/parsers/NovelfullParser.js b/plugin/js/parsers/NovelfullParser.js index 2027b3767..9ae0b2a72 100644 --- a/plugin/js/parsers/NovelfullParser.js +++ b/plugin/js/parsers/NovelfullParser.js @@ -276,7 +276,7 @@ class NovelbinParser extends NovelfullParser { } removeUnwantedElementsFromContentElement(element) { - util.removeChildElementsMatchingSelector(element, ".unlock-buttons"); + util.removeChildElementsMatchingSelector(element, "div.unlock-buttons text-center"); super.removeUnwantedElementsFromContentElement(element); } -} \ No newline at end of file +} diff --git a/plugin/js/parsers/PandaNovelParser.js b/plugin/js/parsers/PandaNovelParser.js index 71641209b..1fa300446 100644 --- a/plugin/js/parsers/PandaNovelParser.js +++ b/plugin/js/parsers/PandaNovelParser.js @@ -97,7 +97,7 @@ class PandaNovelParser extends Parser { } removeUnwantedElementsFromContentElement(element) { - util.removeChildElementsMatchingSelector(element, ".novel-ins, sub"); + util.removeChildElementsMatchingSelector(element, ".novel-ins, sub, del"); super.removeUnwantedElementsFromContentElement(element); } diff --git a/plugin/js/parsers/ShmtranslationsParser.js b/plugin/js/parsers/ShmtranslationsParser.js index 2bc07f324..d487481dd 100644 --- a/plugin/js/parsers/ShmtranslationsParser.js +++ b/plugin/js/parsers/ShmtranslationsParser.js @@ -22,7 +22,7 @@ class ShmtranslationsParser extends WordpressBaseParser { } findChapterTitle(dom) { - return dom.querySelector(".entry-title"); + return dom.querySelector(".wp-block-post-title"); } static cleanLaterContent(content) { diff --git a/plugin/js/parsers/WordpressBaseParser.js b/plugin/js/parsers/WordpressBaseParser.js index 34854627e..bc96a788c 100644 --- a/plugin/js/parsers/WordpressBaseParser.js +++ b/plugin/js/parsers/WordpressBaseParser.js @@ -4,6 +4,7 @@ "use strict"; parserFactory.register("bakapervert.wordpress.com", () => new WordpressBaseParser()); +parserFactory.register("wujizun.com", () => new WordpressBaseParser()); parserFactory.register("crimsonmagic.me", () => new WordpressBaseParser()); parserFactory.register("shalvationtranslations.wordpress.com", () => new WordpressBaseParser()); parserFactory.register("frostfire10.wordpress.com", () => new WordpressBaseParser()); @@ -60,7 +61,13 @@ class WordpressBaseParser extends Parser { findParentNodeOfChapterLinkToRemoveAt(link) { // "next" and "previous" chapter links may be inside then

tag let toRemove = util.moveIfParent(link, "strong"); - return util.moveIfParent(toRemove, "p"); + return util.moveIfParent(toRemove, "p") || + util.moveIfParent(toRemove, "span"); + } + + removeUnwantedElementsFromContentElement(element) { + util.removeChildElementsMatchingSelector(element, "div.cb_p6_patreon_button, div.jp-relatedposts, .cbxwpbkmarkwrap"); + super.removeUnwantedElementsFromContentElement(element); } static findChapterTitleElement(dom) { diff --git a/plugin/manifest.json b/plugin/manifest.json index 60397a3e9..c933ad72a 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WebToEpub", - "version": "1.0.14.36", + "version": "1.0.14.37", "default_locale": "en", "icons": { "128": "book128.png" diff --git a/plugin/popup.html b/plugin/popup.html index 7f9f07d55..47d8e69d0 100644 --- a/plugin/popup.html +++ b/plugin/popup.html @@ -280,7 +280,7 @@

Instructions

- + __MSG_label_no_additional_metadata__ @@ -288,7 +288,7 @@

Instructions

__MSG_label_auto_search_metadata__ - + __MSG_label_less_tags__ @@ -353,11 +353,11 @@

Instructions

- + __MSG_label_Skip_Images__ - + __MSG_label_Include_URL_of_Images__ @@ -431,7 +431,7 @@

Instructions

- + __MSG_label_No_Download_Popup__ @@ -447,15 +447,15 @@

Instructions

__MSG_label_Disable_Webp_Image_Format_Error__ - + __MSG_label_Write_Error_History_To_File__ - + __MSG_label_Create_Epub_3__ - + __MSG_label_Use_Full_Title_As_File_Name__ @@ -479,8 +479,21 @@

Instructions

__MSG_label_Auto_Parser_Select_Includes_Baka_Tsuki_Series_Page_Parser__ + + + + + __MSG_label_Max_pages_to_fetch_simultaneously__ + - + __MSG_label_Skip_Chapters_That_Fail_Fetch__ @@ -501,12 +514,29 @@

Instructions

__MSG_label_Max_chapters_per_epub__ + + +
- + __MSG_label_Manual_Delay_Per_Chapter__
+ @@ -752,6 +782,7 @@

Instructions

+
__MSG_label_Override_Default_Minimum_Delay__