Chapter Saver X

10/11/2025, 11:46:09 PM

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name        Chapter Saver X
// @namespace   Violentmonkey Scripts
// @match       https://wtr-lab.com/en/novel/*
// @grant       none
// @version     1.0
// @author      -
// @description 10/11/2025, 11:46:09 PM
// ==/UserScript==


(async () => {
  "use strict";

  // --- 0. Replacement logic ---
  const STORAGE_KEY = 'wordReplacerPairsV3';
  const data = (() => {
    const raw = localStorage.getItem(STORAGE_KEY);
    try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
  })();

  function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\",]/g, '\\$&'); }

  function isStartOfSentence(index, fullText) {
    if (index === 0) return true;
    const before = fullText.slice(0, index).replace(/\s+$/, '');
    if (/[\n\r]$/.test(before)) return true;
    if (/[.!?…]["”’')\]]*$/.test(before)) return true;
    if (/["“”'‘(\[]\s*$/.test(before)) return true;
    if (/Chapter\s+\d+:\s*,?\s*$/.test(before)) return true;
    return false;
  }

  function isInsideDialogueAtIndex(htmlText, index) {
    const quoteChars = `"'“”‘’`;
    const clean = htmlText.replace(/<[^>]*>/g, '');
    const leftText = clean.slice(0, index);
    const quoteCount = (leftText.match(new RegExp(`[${quoteChars}]`, 'g')) || []).length;
    return quoteCount % 2 === 1;
  }

  function applyPreserveCapital(orig, replacement) {
    if (!orig) return replacement;
    return (orig[0].toUpperCase() === orig[0]) ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
  }

  function applyReplacements(text, replacements) {
    let replacedText = text;
    const WILDCARD = '@';
    const punctuationRegex = /^[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]|[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]$/;

    for (const entry of replacements) {
      if (!entry.from || !entry.to || !entry.enabled) continue;
      const flags = entry.ignoreCapital ? 'gi' : 'g';
      let base = escapeRegex(entry.from).replace(new RegExp(`\\${WILDCARD}`, 'g'), '.');
      const firstChar = entry.from.charAt(0);
      const lastChar = entry.from.charAt(entry.from.length - 1);
      const skipBoundaries = punctuationRegex.test(firstChar) || punctuationRegex.test(lastChar);
      const patternStr = (entry.allInstances || skipBoundaries) ? base : `(?<=^|[^A-Za-z0-9])${base}(?=[^A-Za-z0-9]|$)`;
      const regex = new RegExp(patternStr, flags);

      let newText = '';
      let lastIndex = 0, match;
      while ((match = regex.exec(replacedText)) !== null) {
        const idx = match.index;
        const insideDialogue = isInsideDialogueAtIndex(replacedText, idx);
        if ((entry.insideDialogueOnly && !insideDialogue) || (entry.outsideDialogueOnly && insideDialogue)) continue;

        newText += replacedText.slice(lastIndex, idx);
        const startSentence = entry.startOfSentence && isStartOfSentence(idx, replacedText);
        let finalReplacement = entry.preserveFirstCapital ? applyPreserveCapital(match[0], entry.to) : entry.to;
        if (startSentence) finalReplacement = finalReplacement.charAt(0).toUpperCase() + finalReplacement.slice(1);

        newText += finalReplacement;
        lastIndex = idx + match[0].length;
      }
      if (lastIndex < replacedText.length) newText += replacedText.slice(lastIndex);
      replacedText = newText;
    }
    return replacedText;
  }

  function applyReplacementsToText(text, seriesIdParam = null) {
    const seriesId = seriesIdParam || (() => {
      const urlMatch = location.href.match(/\/novel\/(\d+)\//i);
      if (urlMatch) return urlMatch[1];
      const crumb = document.querySelector('.breadcrumb li.breadcrumb-item a[href*="/novel/"]');
      if (crumb) { const crumbMatch = crumb.href.match(/\/novel\/(\d+)\//i); if (crumbMatch) return crumbMatch[1]; }
      return null;
    })();

    let replacements = [];
    for (const key in data) {
      if (key === 'global' || (seriesId && key === `series-${seriesId}`) || (seriesIdParam && key === `series-${seriesIdParam}`)) {
        replacements = replacements.concat(data[key].filter(e => e.enabled));
      }
    }
    return replacements.length ? applyReplacements(text, replacements) : text;
  }

  // --- 1. Chapter info ---
  const dom = document;
  const leaves = dom.baseURI.split("/");
  const novelIndex = leaves.indexOf("novel");
  const language = leaves[novelIndex - 1];
  const id = leaves[novelIndex + 1];
  const novelLink = document.querySelector('a[href*="/novel/"]');
  const novelTitle = novelLink ? novelLink.textContent.trim().replace(/[\/\\?%*:|"<>]/g, '-') : leaves[leaves.length - 1].split("?")[0];

  const chaptersResp = await fetch(`https://wtr-lab.com/api/chapters/${id}`, { credentials: "include" });
  const chaptersJson = await chaptersResp.json();
  const chapters = chaptersJson.chapters;

// --- 2. Menu ---
const menu = document.createElement("div");
menu.style.cssText = `
  position: fixed; top: 60px; right: 20px; background: #fff; border-radius: 12px;
  padding: 0; max-height: 80vh; overflow-y: auto; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  display: none; width: 350px;
`;

// --- fixed top bar inside menu ---
menu.innerHTML = `
  <div id="menuHeader" style="
    position: sticky; top: 0; background: #fff; z-index: 10;
    padding: 10px; border-bottom: 1px solid #ddd;
  ">
    <h3 style="margin: 0 0 6px 0;">Select chapters</h3>
    <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
      <label style="flex:1;"><input type="checkbox" id="selectAllChk" checked> All</label>
      <button id="selectFromCurrentBtn">From Current Chapter Onwards</button>
      <button id="jumpToCurrentBtn">Jump to Current Chapter</button>
      <button id="downloadEpubBtn" style="flex-shrink:0;">Download</button>
    </div>
  </div>
  <div id="chaptersList" style="padding:10px;">
    ${chapters.map(ch => `
      <label style="display:block; border-bottom:1px solid #eee; padding:4px 0;">
        <input type="checkbox" checked data-order="${ch.order}">
        ${ch.order}: ${ch.title}
      </label>
    `).join("")}
  </div>
`;
document.body.appendChild(menu);
// --- menu bar "Continue" button for most recent novel ---
const menuContinueBtn = document.createElement("button");
menuContinueBtn.textContent = "Continue From Latest";
menuContinueBtn.style.flexShrink = "0";
document.querySelector("#menuHeader > div").appendChild(menuContinueBtn);

menuContinueBtn.onclick = () => {
  const library = loadLibrary();
  if (!library.length) return alert("Library is empty. No novel to continue.");

  // pick the most recently downloaded novel
  const recent = library.reduce((a,b) => (a.lastDownloaded > b.lastDownloaded ? a : b));
  continueDownload(recent);
};
// --- toggle button ---
const toggleBtn = document.createElement("button");
toggleBtn.textContent = "📚 Chapters";
toggleBtn.style.cssText = `position: fixed; top: 10px; right: 10px; z-index: 999999;`;
toggleBtn.onclick = () => menu.style.display = menu.style.display === "none" ? "block" : "none";
document.body.appendChild(toggleBtn);

  const libraryBtn = document.createElement("button");
libraryBtn.textContent = "Library";
libraryBtn.style.flexShrink = "0";
document.querySelector("#menuHeader > div").appendChild(libraryBtn);

// --- select/deselect all ---
const selectAllChk = document.getElementById("selectAllChk");
selectAllChk.addEventListener("change", () => {
  menu.querySelectorAll("#chaptersList input[type=checkbox]").forEach(cb => cb.checked = selectAllChk.checked);
});

// --- current chapter logic ---
const currentChapterOrder = parseInt(location.pathname.match(/chapter-(\d+)/)?.[1] ?? "1");

// --- select from current onward ---
document.getElementById("selectFromCurrentBtn").onclick = () => {
  menu.querySelectorAll("#chaptersList input[type=checkbox]").forEach(cb => {
    cb.checked = parseInt(cb.dataset.order) >= currentChapterOrder;
  });
  selectAllChk.checked = false;
};

// --- jump to current + highlight ---
document.getElementById("jumpToCurrentBtn").onclick = () => {
  menu.querySelectorAll("#chaptersList label").forEach(lbl => lbl.style.background = "");
  const currentCheckbox = menu.querySelector(`#chaptersList input[data-order="${currentChapterOrder}"]`);
  if (currentCheckbox) {
    currentCheckbox.scrollIntoView({ behavior: "smooth", block: "center" });
    currentCheckbox.parentElement.style.background = "#fffae6";
  }
};
const INTERRUPTED_KEY = "epubDownloadTemp";

function saveTempProgress(entry, chapters, orders) {
  localStorage.setItem(INTERRUPTED_KEY, JSON.stringify({
    id: entry.id,
    title: entry.title,
    coverUrl: entry.coverUrl,
    chapters,
    orders
  }));
}

function loadTempProgress() {
  const raw = localStorage.getItem(INTERRUPTED_KEY);
  return raw ? JSON.parse(raw) : null;
}

function clearTempProgress() {
  localStorage.removeItem(INTERRUPTED_KEY);
}
async function continueDownload(entry) {
  const novelId = entry.id;
  const novelTitle = entry.title;
  const startChapter = entry.latestChapter + 1;
  const totalChapters = entry.totalChapters;

  console.info(`[DOWNLOAD] Continuing ${novelTitle} from chapter ${startChapter}`);

  let coverUrl = entry.coverUrl || findCoverImageUrl(document);
  let successfulChapters = [];
  let successfulOrders = [];

  // If there is temp progress for this novel, load it
  const temp = loadTempProgress();
  if (temp && temp.id === novelId) {
    successfulChapters = temp.chapters;
    successfulOrders = temp.orders;
    console.info(`[DOWNLOAD] Resuming from temp progress: ${successfulOrders.slice(-1)[0]}`);
  }

  for (let ch = startChapter; ch <= totalChapters; ch++) {
    try {
      const html = await fetchChapterContent(ch);
      successfulChapters.push(html);
      successfulOrders.push(ch);

      // Save temp progress after each successful chapter
      saveTempProgress(entry, successfulChapters, successfulOrders);

      // Update library progress
      const library = loadLibrary();
      const existing = library.find(e => e.id === novelId);
      if (existing) {
        existing.totalChapters = totalChapters;
        existing.latestChapter = Math.max(...successfulOrders);
        existing.coverUrl = coverUrl;
        saveLibrary(library);
      }

      console.info(`[CONTINUE] Fetched chapter ${ch}`);
      await new Promise(r => setTimeout(r, 500));
    } catch (err) {
      console.warn(`[CONTINUE] Chapter ${ch} failed:`, err);

      // Show alert that download paused
      securityAlert.textContent = "⚠️ Download paused due to failure. Refresh page to continue.";
      securityAlert.style.display = "block";
      return; // stop downloading further chapters
    }
  }

  // All chapters succeeded, generate final EPUB
  if (successfulChapters.length > 0) {
    await downloadAsEPUB(novelTitle, successfulChapters, successfulOrders);

    // Update library progress
    const library = loadLibrary();
    const existing = library.find(e => e.id === novelId);
    if (existing) {
      existing.latestChapter = Math.max(...successfulOrders);
      existing.coverUrl = coverUrl;
      saveLibrary(library);
    }

    // Clear temp storage
    clearTempProgress();

    renderLibrary();
    console.info(`[DOWNLOAD] Completed ${novelTitle} up to chapter ${totalChapters}`);
  }
}

  // --- library data store key ---
const LIBRARY_KEY = "epubLibraryV1";
function loadLibrary() {
  const raw = localStorage.getItem(LIBRARY_KEY);
  try { return raw ? JSON.parse(raw) : []; } catch { return []; }
}
function saveLibrary(data) { localStorage.setItem(LIBRARY_KEY, JSON.stringify(data)); }

// --- tiny security alert popup ---
const securityAlert = document.createElement("div");
securityAlert.style.cssText = `
  position: fixed; top: 40px; right: 10px; background:#ffcccc; color:#900;
  padding:2px 6px; border-radius:6px; font-size:12px; display:none; z-index:999999;
`;
securityAlert.innerHTML = "⚠️ Security check encountered — download paused. Refresh page if stuck.";
document.body.appendChild(securityAlert);

// --- library UI panel ---
const libraryPanel = document.createElement("div");
libraryPanel.style.cssText = `
  position: fixed; top: 60px; right: 380px; width: 400px; max-height: 80vh;
  overflow-y: auto; background: #fff; border-radius: 12px; padding: 10px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 9999; display:none;
`;
libraryPanel.innerHTML = `
  <h3>EPUB Library</h3>
  <input type="text" id="librarySearch" placeholder="Search by title..." style="width:100%; margin-bottom:10px;"/>
  <select id="librarySort" style="width:100%; margin-bottom:10px;">
    <option value="recent">Most Recent</option>
    <option value="title">Title A-Z</option>
    <option value="latestChapter">Latest Chapter</option>
  </select>
  <div id="libraryList"></div>
`;
document.body.appendChild(libraryPanel);

// --- open library (close menu, show blank panel) ---
libraryBtn.onclick = () => {
  menu.style.display = "none"; // close chapters menu
  libraryPanel.style.display = "block";
};

// --- library panel style & blank contents ---
libraryPanel.style.width = "380px";
libraryPanel.style.height = "80vh";
libraryPanel.style.background = "#fff";
libraryPanel.style.border = "1px solid #ccc";
libraryPanel.style.borderRadius = "8px";
libraryPanel.style.padding = "10px";
libraryPanel.style.overflowY = "auto";
libraryPanel.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
libraryPanel.style.position = "fixed";
libraryPanel.style.top = "60px";
libraryPanel.style.right = "10px";
libraryPanel.style.zIndex = "9999";
libraryPanel.style.display = "none";

// --- add close button at top ---
const libraryCloseBtn = document.createElement("button");
libraryCloseBtn.textContent = "Close ✖";
libraryCloseBtn.style.cssText = "margin-bottom:10px; padding:4px 8px; cursor:pointer;";
libraryCloseBtn.onclick = () => libraryPanel.style.display = "none";
libraryPanel.prepend(libraryCloseBtn);

// --- blank container for items ---
const libraryItemsContainer = document.createElement("div");
libraryItemsContainer.id = "libraryItems";
libraryPanel.appendChild(libraryItemsContainer);

// <-- ADD THIS LINE
renderLibrary();

let tempEPUB = null;

// --- get chapter info from EPUB ---
async function getEPUBChapterInfo(file) {
  await ensureJSZip();
  const zip = await JSZip.loadAsync(file);
  const allFiles = Object.keys(zip.files);

  // Find chapter files: ch<number>.xhtml
  const chapterFiles = allFiles
    .map(f => f.match(/^OEBPS\/ch(\d+)\.xhtml$/i))
    .filter(Boolean)
    .map(m => parseInt(m[1], 10));

  const latestChapter = chapterFiles.length ? Math.max(...chapterFiles) : 0;
  const totalChapters = chapterFiles.length;

  return { latestChapter, totalChapters };
}

// --- EPUB import button ---
const importEPUBBtn = document.createElement("button");
importEPUBBtn.textContent = "Import EPUB";
importEPUBBtn.style.cssText = "margin-bottom:10px; padding:4px 8px; cursor:pointer;";
importEPUBBtn.onclick = () => {
  const inputFile = document.createElement("input");
  inputFile.type = "file";
  inputFile.accept = ".epub";

  inputFile.onchange = async (e) => {
    if (!e.target.files.length) return;
    const file = e.target.files[0];

    // Clean title: remove .epub, WTR-LAB suffix, and leading "Chapter XXX - "
    let title = file.name.replace(/\.epub$/i, "")
                         .replace(/- WTR-LAB.*$/i, "")
                         .replace(/^Chapter\s+\d+\s*[-:]?\s*/i, "")
                         .trim();

    // Load existing library
    const library = loadLibrary();

    // Check if an entry with this clean title exists
    const existingIndex = library.findIndex(e => e.title === title);

    // Get chapter info from EPUB
    const { latestChapter: epubLatest, totalChapters: epubTotal } = await getEPUBChapterInfo(file);

    // If overwriting, preserve totalChapters from old entry
    const totalChapters = existingIndex >= 0 ? library[existingIndex].totalChapters : epubTotal;
    const latestChapter = epubLatest;

    // Remove old entry if exists
    if (existingIndex >= 0) library.splice(existingIndex, 1);

    // Create new entry
    const entry = {
      id: "epub-" + Date.now(),
      title,
      coverUrl: "",
      latestChapter,      // progress from EPUB
      totalChapters,      // preserve old totalChapters if overwriting
      file,
      lastDownloaded: Date.now(),
      inLocalStorage: true // visual indicator
    };

    library.push(entry);
    saveLibrary(library);
    renderLibrary();
  };

  inputFile.click();
};

libraryPanel.prepend(importEPUBBtn);

// --- Render Library ---
function renderLibrary() {
  const listContainer = libraryPanel.querySelector("#libraryItems");
  const library = loadLibrary();

  const sortMode = libraryPanel.querySelector("#librarySort")?.value || "recent";
  let sorted = [...library];

  if (sortMode === "recent") sorted.sort((a,b) => (b.lastDownloaded||0) - (a.lastDownloaded||0));
  else if (sortMode === "title") sorted.sort((a,b) => (a.title||"").localeCompare(b.title||""));
  else if (sortMode === "latestChapter") sorted.sort((a,b) => (Number(b.latestChapter)||0) - (Number(a.latestChapter)||0));

  const searchQuery = libraryPanel.querySelector("#librarySearch")?.value.trim().toLowerCase() || "";
  const filtered = sorted.filter(e => e.title.toLowerCase().includes(searchQuery));

  listContainer.innerHTML = filtered.map(entry => `
    <div style="display:grid;grid-template-columns:60px 1fr 150px;gap:10px;align-items:center;padding:6px;border-bottom:1px solid #eee;">
      <img src="${entry.coverUrl||''}" style="width:60px;height:80px;object-fit:cover;border:1px solid #ccc;" />
      <div>
        <div style="font-weight:bold;">${entry.title}</div>
        <div style="font-size:12px;">Chapters: ${entry.latestChapter||0}/${entry.totalChapters||0}</div>
      </div>
      <div style="display:flex;gap:4px;">
        <button data-id="${entry.id}" class="continueBtn">Continue</button>
        <button data-id="${entry.id}" class="deleteBtn">Delete</button>
      </div>
    </div>
  `).join("");

  // --- Continue button ---
  listContainer.querySelectorAll(".continueBtn").forEach(btn => {
    btn.onclick = async () => {
      const entry = library.find(e => e.id === btn.dataset.id);
      if (!entry) return;

      console.info("[Library] Continuing download for", entry.title);
      const start = entry.latestChapter + 1;

      try {
        const newChapters = await continueDownload(entry, start);

        if (entry.file) {
          const reader = new FileReader();
          reader.onload = async (ev) => {
            const oldBytes = new Uint8Array(ev.target.result);
            const mergedBytes = new Uint8Array([...oldBytes, ...newChapters]);
            const mergedFile = new File([mergedBytes], entry.title + ".epub", { type: "application/epub+zip" });

            entry.file = mergedFile;
            entry.latestChapter += newChapters.length;
            entry.totalChapters = Math.max(entry.totalChapters, entry.latestChapter);

            saveLibrary(library);
            renderLibrary();
            libraryPanel.style.display = "none";
          };
          reader.readAsArrayBuffer(entry.file);
        } else {
          entry.latestChapter += newChapters.length;
          entry.totalChapters = Math.max(entry.totalChapters, entry.latestChapter);
          saveLibrary(library);
          renderLibrary();
          libraryPanel.style.display = "none";
        }

      } catch (err) {
        console.warn("[Library] Download interrupted", err);
        entry.interrupted = true;
        saveLibrary(library);
      }
    };
  });

  // --- Delete button ---
  listContainer.querySelectorAll(".deleteBtn").forEach(btn => {
    btn.onclick = () => {
      const index = library.findIndex(e => e.id === btn.dataset.id);
      if (index >= 0) {
        library.splice(index, 1);
        saveLibrary(library);
        renderLibrary();
      }
    };
  });
}

// --- Add / Update Library Entry ---
function addToLibrary(novelId, novelTitle, coverUrl, totalChapters, latestChapter, file = null) {
  const library = loadLibrary();
  const now = Date.now();
  const existing = library.find(e => e.id === novelId);

  // Normalize title: replace ":" with "-"
  const normalizedTitle = novelTitle.replace(/:/g, "-").trim();

  if (existing) {
    existing.totalChapters = totalChapters;
    existing.latestChapter = latestChapter;
    existing.lastDownloaded = now;
    if (coverUrl) existing.coverUrl = coverUrl;
    if (file) existing.file = file;
    existing.title = normalizedTitle; // ensure overwrite uses normalized title
  } else {
    library.push({
      id: novelId,
      title: normalizedTitle, // store normalized title
      coverUrl: coverUrl || '',
      totalChapters,
      latestChapter,
      lastDownloaded: now,
      file
    });
  }

  saveLibrary(library);
  renderLibrary();
}


// --- helper: get rendered text ---
function getRenderedText(container) {
  return Array.from(container.querySelectorAll("p[data-line], p"))
    .map(p => p.textContent)
    .join("\n")
    .trim();
}


  // --- 3. Fetch chapter content + replace glossary (patched) ---
async function fetchChapterContent(order) {
  const formData = { translate: "ai", language, raw_id: id, chapter_no: order };

  const res = await fetch("https://wtr-lab.com/api/reader/get", {
    method: "POST",
    headers: { "Content-Type": "application/json;charset=UTF-8" },
    body: JSON.stringify(formData),
    credentials: "include"
  });

  let json;
  try {
    json = await res.json();
  } catch {
    console.warn(`Chapter ${order}: Failed to parse JSON`);
    throw new Error("Invalid JSON");
  }

  if (!json?.data?.data?.body) {
    console.warn(`Chapter ${order}: No body in response`, json);
    throw new Error("Missing body");
  }

  const tempDiv = document.createElement("div");
  let imgCounter = 0;

  json.data.data.body.forEach(el => {
    if (el === "[image]") {
      const src = json.data.data?.images?.[imgCounter++] ?? "";
      if (src) {
        const img = document.createElement("img");
        img.src = src;
        tempDiv.appendChild(img);
      }
    } else {
      const pnode = document.createElement("p");
      const wrapper = document.createElement("div");
      wrapper.innerHTML = el;
      pnode.textContent = wrapper.textContent;

      for (let i = 0; i < json?.data?.data?.glossary_data?.terms?.length ?? 0; i++) {
        const term = json.data.data.glossary_data.terms[i][0] ?? `※${i}⛬`;
        pnode.textContent = pnode.textContent.replaceAll(`※${i}⛬`, term);
      }
      tempDiv.appendChild(pnode);
    }
  });

  const rawText = getRenderedText(tempDiv);
  const processedText = applyReplacementsToText(rawText, id);
  return `<h1>${order}: ${json.chapter?.title ?? "Untitled"}</h1><p>${processedText.replace(/\n/g,"<br>")}</p>`;
}

// --- build all content from selected (patched) ---
async function buildAllContentFromSelected() {
  const selectedOrders = [...menu.querySelectorAll("#chaptersList input:checked")].map(cb => cb.dataset.order);
  const allContent = [];

  for (const order of selectedOrders) {
    try {
      const html = await fetchChapterContent(order);
      allContent.push(html);
      await new Promise(r => setTimeout(r, 1000)); // throttle 1s per chapter
    } catch (err) {
      console.error(`Unexpected error fetching chapter ${order}:`, err);
      allContent.push(`<h1>${order}: (unexpected error)</h1>`);
    }
  }

  console.info("[INFO] Finished fetching all chapters. Check console for any failed chapters.");
  return { content: allContent, orders: selectedOrders };
}

  // --- 4. EPUB functions (unchanged from your previous) ---
  async function ensureJSZip() { if (window.JSZip) return window.JSZip; return new Promise((res, rej) => { const s = document.createElement("script"); s.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; s.onload = () => res(window.JSZip); s.onerror = rej; document.head.appendChild(s); }); }
  async function downloadAsEPUB(novelTitle, allContent, chapterOrders) {
    await ensureJSZip();
    const zip = new JSZip();
    zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
    const metaInf = zip.folder("META-INF");
    const oebps = zip.folder("OEBPS");
    metaInf.file("container.xml", `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`);
    const manifestItems = chapterOrders.map(num => `<item id="ch${num}" href="ch${num}.xhtml" media-type="application/xhtml+xml"/>`).join("\n");
    const spineItems = chapterOrders.map(num => `<itemref idref="ch${num}"/>`).join("\n");
    oebps.file("content.opf", `<?xml version="1.0" encoding="utf-8"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>${escapeXml(novelTitle)}</dc:title><dc:language>en</dc:language>  <dc:creator>${escapeXml(novelAuthor || "WTRLAB")}</dc:creator><dc:identifier id="BookId">urn:uuid:${crypto.randomUUID()}</dc:identifier></metadata><manifest>${manifestItems}</manifest><spine>${spineItems}</spine></package>`);
    allContent.forEach((html, idx) => oebps.file(`ch${chapterOrders[idx]}.xhtml`, `<?xml version="1.0" encoding="UTF-8"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter ${chapterOrders[idx]}</title></head><body>${html}</body></html>`));
    const blob = await zip.generateAsync({ type: "blob" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `${sanitizeFilename(novelTitle)}.epub`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  }
  function escapeXml(str) { return (str+"").replace(/[<>&'"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;',"'":'&apos;','"':'&quot;'})[c]); }
  function sanitizeFilename(name) { return (name||"book").replace(/[\/\\?%*:|"<>]/g,"-").slice(0,200); }

  // helper: find cover URL from page (tries several common selectors/meta tags)
function findCoverImageUrl(dom = document) {
  // 1. Try to find any <picture><source srcset> pointing to CDN
  const pictureSources = Array.from(dom.querySelectorAll("picture source[srcset]"))
    .map(s => s.srcset)
    .filter(u => u && u.includes("/cdn/series/"));
  if (pictureSources.length) return pictureSources[0];

  // 2. Fallback: any <img> in .image-wrap or .cover pointing to CDN
  const imgs = Array.from(dom.querySelectorAll(".image-wrap img, .cover img"))
    .map(i => i.src)
    .filter(u => u && u.includes("/cdn/series/") && !u.includes("/placeholder"));
  if (imgs.length) return imgs[0];

  // 3. Next.js JSON fallback
  try {
    const jsonText = dom.querySelector('script#__NEXT_DATA__')?.textContent;
    if (jsonText) {
      const j = JSON.parse(jsonText);
      return j?.props?.pageProps?.series?.cover ||
             j?.props?.pageProps?.novel?.cover ||
             j?.props?.initialState?.series?.cover ||
             null;
    }
  } catch (e) {}

  // 4. If nothing found, return null
  return null;
}

// patched downloadAsEPUB that includes cover image if available
async function downloadAsEPUB(novelTitle, allContent, chapterOrders) {
  await ensureJSZip();

  const zip = new JSZip();
  // must be first & uncompressed
  zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
  const metaInf = zip.folder("META-INF");
  const oebps = zip.folder("OEBPS");
  const imagesFolder = oebps.folder("images");

  metaInf.file(
    "container.xml",
    `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>`
  );

  // **** Attempt to find & embed cover image ****
  let coverHref = null;
  let coverId = "cover-image";
  try {
    const coverUrl = findCoverImageUrl(document);
    if (coverUrl) {
      // normalize URL (absolute)
      const absolute = new URL(coverUrl, location.href).href;
      // fetch binary
      const resp = await fetch(absolute, { credentials: "include" });
      if (resp.ok) {
        const buf = await resp.arrayBuffer();
        // determine extension/content-type
        const ct = resp.headers.get("content-type") || "";
        let ext = "jpg";
        if (ct.includes("png")) ext = "png";
        else if (ct.includes("webp")) ext = "webp";
        else if (ct.includes("jpeg")) ext = "jpg";
        else {
          // try from URL
          const m = absolute.match(/\.(png|jpe?g|webp)(?:$|\?)/i);
          if (m) ext = m[1].toLowerCase().replace("jpeg", "jpg");
        }
        coverHref = `images/cover.${ext}`;
        // add to zip (Uint8Array)
        imagesFolder.file(`cover.${ext}`, new Uint8Array(buf));
      } else {
        console.warn("[EPUB] cover fetch failed:", resp.status);
      }
    } else {
      console.info("[EPUB] No cover URL found on page");
    }
  } catch (err) {
    console.warn("[EPUB] cover embed skipped (error):", err);
    coverHref = null;
  }

  // Derive minimal chapter titles array (try headings in content or fallback)
  const chapterTitles = chapterOrders.map((num, i) => {
    const html = allContent[i] || "";
    const m = html.match(/<h[1-3][^>]*>([^<]+)<\/h[1-3]>/i);
    if (m && m[1]) return m[1].trim();
    try {
      if (typeof chapters !== "undefined" && Array.isArray(chapters)) {
        const found = chapters.find(c => String(c.order) === String(num));
        if (found && found.title) return found.title;
      }
    } catch (e) {}
    return `Chapter ${num}`;
  });

  // nav.xhtml (simple ToC)
  const tocEntries = chapterOrders
    .map((num, i) => `<li><a href="ch${num}.xhtml">${escapeXml(chapterTitles[i])}</a></li>`)
    .join("\n");

  const navXhtml = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
  <head><title>Table of Contents</title></head>
  <body>
    <nav epub:type="toc" id="toc"><h1>Contents</h1><ol>${tocEntries}</ol></nav>
  </body>
</html>`;
  oebps.file("nav.xhtml", navXhtml);

  // manifest entries (include cover image if present)
  const manifestItems = [
    `<item id="nav" href="nav.xhtml" properties="nav" media-type="application/xhtml+xml"/>`,
    ...chapterOrders.map(num => `<item id="ch${num}" href="ch${num}.xhtml" media-type="application/xhtml+xml"/>`)
  ];
  if (coverHref) {
    // detect media-type
    const ext = coverHref.split(".").pop().toLowerCase();
    let mtype = "image/jpeg";
    if (ext === "png") mtype = "image/png";
    else if (ext === "webp") mtype = "image/webp";
    manifestItems.splice(1, 0, `<item id="${coverId}" href="${coverHref}" media-type="${mtype}"/>`);
  }

  const manifestXml = manifestItems.join("\n");
  const spineItems = chapterOrders.map(num => `<itemref idref="ch${num}"/>`).join("\n");

  // content.opf with cover meta if present
  const metaCoverTag = coverHref ? `<meta name="cover" content="${coverId}"/>` : "";
  const opf = `<?xml version="1.0" encoding="utf-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:title>${escapeXml(novelTitle || "Untitled")}</dc:title>
    <dc:language>en</dc:language>
    <dc:identifier id="BookId">urn:uuid:${crypto.randomUUID()}</dc:identifier>
    ${metaCoverTag}
  </metadata>
  <manifest>
    ${manifestXml}
  </manifest>
  <spine>
    ${spineItems}
  </spine>
</package>`;
  oebps.file("content.opf", opf);

  // Add chapters
  allContent.forEach((html, idx) => {
    const order = chapterOrders[idx];
    const title = escapeXml(chapterTitles[idx] || `Chapter ${order}`);
    const safeHtml = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head><title>${title}</title></head>
  <body>${html}</body>
</html>`;
    oebps.file(`ch${order}.xhtml`, safeHtml);
  });

  // Add a simple cover.xhtml page that displays the cover (some readers use it)
  if (coverHref) {
    const coverPage = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head><title>Cover</title></head>
  <body>
    <div style="text-align:center;">
      <img src="${coverHref}" alt="Cover" style="max-width:100%;height:auto;"/>
    </div>
  </body>
</html>`;
    oebps.file("cover.xhtml", coverPage);
    // include cover.xhtml in manifest and place it first in spine if desired
    // (we already added cover image manifest); optionally insert cover.xhtml
    // oebps manifest/spine modifications could be added here if you want cover.xhtml first.
  }

  // generate
  const blob = await zip.generateAsync({ type: "blob" });
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = `${sanitizeFilename(novelTitle || "book")}.epub`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  console.info("[EPUB] Download triggered (with cover if available).");
}


const downloadBtn = document.getElementById("downloadEpubBtn");

// --- helper: get novel title from breadcrumb ---
function getNovelTitleFromBreadcrumb() {
  const a = document.querySelector(".breadcrumb-item.active a");
  if (a && a.textContent.trim()) {
    return a.textContent.trim();
  }
  return "Novel";
}

// --- download / add to library ---
downloadBtn.addEventListener("click", async () => {
  console.info("[DOWNLOAD] Starting chapter download...");

  const header = document.getElementById("menuHeader");
  let indicator = header.querySelector(".download-indicator");
  if (!indicator) {
    indicator = document.createElement("span");
    indicator.className = "download-indicator";
    indicator.style.cssText = `
      display:inline-block; margin-left:10px; padding:2px 6px;
      background:#ffd700; color:#000; border-radius:8px;
      font-size:12px; font-weight:bold;
      animation: blink 1s infinite;
    `;
    indicator.textContent = "Downloading...";
    header.appendChild(indicator);
  }
  indicator.style.display = "inline-block";

  if (!document.getElementById("blinkAnimation")) {
    const style = document.createElement("style");
    style.id = "blinkAnimation";
    style.textContent = `
      @keyframes blink { 0%,50%,100% { opacity: 1; } 25%,75% { opacity: 0.3; } }
    `;
    document.head.appendChild(style);
  }

  const novelTitle = getNovelTitleFromBreadcrumb();
  const novelId = location.pathname.split("/").pop();
  const selectedChapters = Array.from(menu.querySelectorAll("#chaptersList input[type=checkbox]:checked"))
    .map(cb => ({
      order: parseInt(cb.dataset.order),
      title: cb.parentElement.textContent.trim()
    }));

  let coverUrl = "";
  try {
    const imageWrap = document.querySelector("div.image-wrap picture source[srcset]");
    if (imageWrap) coverUrl = imageWrap.srcset;
  } catch (err) { console.warn("[DOWNLOAD] Could not grab cover URL:", err); }

  const totalChapters = chapters.length;
  libraryPanel.style.display = "block";
  menu.style.display = "none";

  // --- Load temp progress if any ---
  const temp = loadTempProgress();
  let successfulChapters = temp && temp.id === novelId ? temp.chapters : [];
  let successfulOrders = temp && temp.id === novelId ? temp.orders : [];

  for (let ch of selectedChapters) {
    if (successfulOrders.includes(ch.order)) continue; // skip already downloaded

    try {
      const chapterContent = await fetchChapterContent(ch.order);
      if (!chapterContent || chapterContent.trim() === "") throw new Error("Empty chapter content");

      successfulChapters.push(chapterContent);
      successfulOrders.push(ch.order);

      // Save temp progress to localStorage
      saveTempProgress({ id: novelId, title: novelTitle, coverUrl }, successfulChapters, successfulOrders);

      addToLibrary(novelId, novelTitle, coverUrl, totalChapters, Math.max(...successfulOrders));

      libraryPanel.scrollTop = libraryPanel.scrollHeight;
      console.info(`[Library] Downloaded chapter ${ch.order}: ${ch.title}`);
    } catch (err) {
      console.error(`[DOWNLOAD] Chapter ${ch.order} failed:`, err);
      securityAlert.textContent = "⚠️ Download paused due to security check. Refresh page to continue.";
      securityAlert.style.display = "block";
      indicator.style.display = "none";
      return; // stop loop, keep temp saved
    }
  }

  // All selected chapters downloaded, finalize EPUB
  await downloadAsEPUB(novelTitle, successfulChapters, successfulOrders);

  // Clear temp storage
  clearTempProgress();

  indicator.style.display = "none";
  console.info("[DOWNLOAD] All chapters downloaded successfully.");
});

})();