Chapter Saver X

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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.");
});

})();