Compare commits

...
7 Commits
Author SHA1 Message Date
Sylphian 69448499e8 feat: minor ui changes 2026-10-10 23:21:20 +07:00
Sylphian 78cdf9ecdb feat: singleton process 2026-10-09 15:19:32 +07:00
Sylphian 65c29ec4c0 feat: adjust user interface 2026-10-09 14:59:09 +07:00
Sylphian f4ed8a382a chore: remove plan.md 2026-10-08 23:39:29 +08:00
Sylphian 3b6cda0f3d fix: merge dev into main 2026-10-08 22:35:15 +07:00
Sylphian f5e03da891 feat: fix feature 2026-10-08 22:28:53 +07:00
Sylphian f674476422 feat: remove light theme 2026-10-08 21:54:25 +07:00
11 changed files with 2293 additions and 352 deletions
+236
View File
@@ -0,0 +1,236 @@
import json
import re
from bs4 import BeautifulSoup
from .database import db_execute, db_fetch_one
from .llm import generate_json, generate_text
from .prompt import (
APE_SYSTEM_PROMPT,
CHAPTER_SUMMARY_SYSTEM_PROMPT,
LORE_EXTRACTION_SYSTEM_PROMPT,
)
def get_or_init_novel_lore(novel_url: str, metadata: dict, initial_sample_html: str = "") -> dict:
row = db_fetch_one("SELECT lore_json FROM novel_lore WHERE novel_url = ?", (novel_url,))
if row is not None and row[0]:
try:
return json.loads(row[0])
except Exception:
pass
series_meta = metadata.get("metadata", {})
description = series_meta.get("description") or ""
title = series_meta.get("title") or "Novel"
soup = BeautifulSoup(initial_sample_html, "lxml")
sample_text = soup.get_text(separator=" ", strip=True)[:4000]
input_text = f"Title: {title}\nDescription: {description}\n\nSample Chapter Content:\n{sample_text}"
try:
raw_json = generate_json(LORE_EXTRACTION_SYSTEM_PROMPT, input_text)
lore = json.loads(raw_json)
except Exception:
lore = {
"characters": [],
"terms": {},
"rules": {"keep_honorifics": False, "pov": "Third person limited"},
}
lore_str = json.dumps(lore, ensure_ascii=False)
db_execute(
"INSERT INTO novel_lore (novel_url, lore_json, rolling_summary_json) VALUES (?, ?, ?) ON CONFLICT(novel_url) DO UPDATE SET lore_json=excluded.lore_json, updated_at=CURRENT_TIMESTAMP",
(novel_url, lore_str, json.dumps([])),
)
return lore
def get_rolling_summary(novel_url: str) -> list:
row = db_fetch_one("SELECT rolling_summary_json FROM novel_lore WHERE novel_url = ?", (novel_url,))
if row is not None and row[0]:
try:
return json.loads(row[0])
except Exception:
return []
return []
def update_rolling_summary(novel_url: str, chapter_name: str, chapter_html: str) -> None:
soup = BeautifulSoup(chapter_html, "lxml")
plain_text = soup.get_text(separator=" ", strip=True)
if not plain_text:
return
sample = plain_text[:3500]
try:
summary_text = generate_text(CHAPTER_SUMMARY_SYSTEM_PROMPT, sample).strip()
except Exception:
summary_text = f"Events occurred in {chapter_name}."
existing = get_rolling_summary(novel_url)
existing.append({"title": chapter_name, "summary": summary_text})
recent = existing[-2:]
db_execute(
"INSERT INTO novel_lore (novel_url, rolling_summary_json) VALUES (?, ?) ON CONFLICT(novel_url) DO UPDATE SET rolling_summary_json=excluded.rolling_summary_json, updated_at=CURRENT_TIMESTAMP",
(novel_url, json.dumps(recent, ensure_ascii=False)),
)
def strip_duplicate_chapter_title(html: str, chapter_title: str) -> str:
if not html or not html.strip():
return ""
soup = BeautifulSoup(html, "lxml")
body = soup.body if soup.body else soup
# Clean title for comparison
clean_target = re.sub(r"[^\w\s]", "", chapter_title.lower()).strip()
target_words = set(clean_target.split())
# Check top headings or paragraphs
for tag in list(body.find_all(["h1", "h2", "h3", "p", "div"], recursive=False))[:3]:
tag_text = tag.get_text(strip=True)
clean_tag = re.sub(r"[^\w\s]", "", tag_text.lower()).strip()
tag_words = set(clean_tag.split())
# Exact match or high overlap with chapter title / "chapter X"
if clean_target and (clean_tag == clean_target or (len(target_words) >= 2 and target_words.issubset(tag_words))):
tag.decompose()
continue
if re.match(r"^(chapter|episode|prologue|epilogue)\s*\d*", clean_tag, re.I) and len(clean_tag.split()) <= 4:
tag.decompose()
continue
return body.decode_contents().strip()
def chunk_chapter_text(html: str, target_words: int = 800) -> list[str]:
if not html or not html.strip():
return []
soup = BeautifulSoup(html, "lxml")
body = soup.body if soup.body else soup
elements = list(body.find_all(["p", "hr", "img", "blockquote", "aside"], recursive=False))
if not elements:
# Fallback: split by paragraph or line breaks
paragraphs = [p.strip() for p in html.split("\n") if p.strip()]
chunks = []
curr = []
curr_words = 0
for p in paragraphs:
w_count = len(p.split())
if curr_words + w_count > target_words and curr:
chunks.append("\n".join(curr))
curr = [p]
curr_words = w_count
else:
curr.append(p)
curr_words += w_count
if curr:
chunks.append("\n".join(curr))
return chunks
chunks = []
curr_elements = []
curr_word_count = 0
for el in elements:
text = el.get_text(strip=True)
w_count = len(text.split())
if curr_word_count + w_count > target_words and curr_elements:
chunk_html = "".join(str(e) for e in curr_elements)
chunks.append(chunk_html)
curr_elements = [el]
curr_word_count = w_count
else:
curr_elements.append(el)
curr_word_count += w_count
if curr_elements:
chunk_html = "".join(str(e) for e in curr_elements)
chunks.append(chunk_html)
return chunks
def filter_glossary_for_chunk(global_lore: dict, chunk_text: str) -> dict:
if not global_lore:
return {}
lowered_chunk = chunk_text.lower()
filtered_chars = []
for char in global_lore.get("characters", []):
names = [char.get("standard_name", ""), char.get("raw_name", "")] + char.get("mtl_variants", [])
if any(n and n.lower() in lowered_chunk for n in names):
filtered_chars.append(char)
filtered_terms = {}
for term, trans in global_lore.get("terms", {}).items():
if (term and term.lower() in lowered_chunk) or (trans and trans.lower() in lowered_chunk):
filtered_terms[term] = trans
# If no matches, retain top character or default to keep context active
if not filtered_chars and global_lore.get("characters"):
filtered_chars = global_lore["characters"][:2]
return {
"characters": filtered_chars,
"terms": filtered_terms,
"rules": global_lore.get("rules", {}),
}
def clean_llm_html_output(raw_output: str) -> str:
cleaned = re.sub(r"^```(?:html)?\s*", "", raw_output.strip(), flags=re.I)
cleaned = re.sub(r"\s*```$", "", cleaned)
# Ensure any <aside class="tl-note"> is converted to <blockquote>
cleaned = re.sub(r"<aside\s+class=[\"']tl-note[\"']>(.*?)</aside>", r"<blockquote class=\"tl-note\"><p><strong>TL Note:</strong> \1</p></blockquote>", cleaned, flags=re.DOTALL | re.I)
return cleaned.strip()
def fix_chapter(
raw_cleaned_html: str,
chapter_name: str,
novel_url: str,
global_lore: dict,
) -> str:
# 1. Deduplicate title from start of body
html_without_title = strip_duplicate_chapter_title(raw_cleaned_html, chapter_name)
# 2. Chunk text
chunks = chunk_chapter_text(html_without_title, target_words=800)
if not chunks:
chunks = [html_without_title] if html_without_title else ["<p></p>"]
# 3. Format rolling scene context
rolling = get_rolling_summary(novel_url)
if rolling:
context_lines = [f"- {r.get('title', 'Previous')}: {r.get('summary', '')}" for r in rolling]
scene_context_str = "Recent Scene Context:\n" + "\n".join(context_lines)
else:
scene_context_str = f"Scene Context: Opening / early chapter ({chapter_name})."
fixed_chunks = []
for chunk in chunks:
active_glossary = filter_glossary_for_chunk(global_lore, chunk)
user_prompt = (
f"### Active Glossary & Lore\n```json\n{json.dumps(active_glossary, ensure_ascii=False, indent=2)}\n```\n\n"
f"### Scene Context\n{scene_context_str}\n\n"
f"### Raw MTL Text to Process\n{chunk}"
)
try:
polished = generate_text(APE_SYSTEM_PROMPT, user_prompt)
fixed_chunk = clean_llm_html_output(polished)
except Exception:
# Fallback to original chunk if LLM fails on a segment
fixed_chunk = clean_llm_html_output(chunk)
fixed_chunks.append(fixed_chunk)
final_fixed_html = "\n".join(fixed_chunks)
# 4. Update rolling summary for next chapters
update_rolling_summary(novel_url, chapter_name, final_fixed_html)
return final_fixed_html
+78 -1
View File
@@ -4,6 +4,7 @@ from .parse import robust_fetch
from .clean_html import clean_novel_html from .clean_html import clean_novel_html
from .prompt import HTML_CHAPTER_SANITIZER from .prompt import HTML_CHAPTER_SANITIZER
from .llm import generate_text from .llm import generate_text
from .ape import get_or_init_novel_lore, fix_chapter
def iter_chapters(metadata: dict): def iter_chapters(metadata: dict):
@@ -25,7 +26,7 @@ def cache_chapter_hit(url: str, refresh_mode: str = "none") -> str:
if isinstance(refresh_mode, bool): if isinstance(refresh_mode, bool):
refresh_mode = "all" if refresh_mode else "none" refresh_mode = "all" if refresh_mode else "none"
if refresh_mode != "all": if refresh_mode not in ("all", "all_refresh"):
row = db_fetch_one("SELECT html_text FROM chapter_page WHERE url = ?", (url,)) row = db_fetch_one("SELECT html_text FROM chapter_page WHERE url = ?", (url,))
if row is not None and row[0] is not None: if row is not None and row[0] is not None:
tqdm.write(f"Chapter cache hit: {url}") tqdm.write(f"Chapter cache hit: {url}")
@@ -65,6 +66,36 @@ def cache_clean_hit(url: str, refresh_mode: str = "none") -> str:
return sanitized return sanitized
def cache_fixed_hit(
url: str,
chapter_name: str,
novel_url: str,
global_lore: dict,
refresh_mode: str = "none",
) -> str:
if refresh_mode == "none":
row = db_fetch_one("SELECT fixed_html_text FROM chapter_page WHERE url = ?", (url,))
if row is not None and row[0] is not None:
tqdm.write(f"Fixed HTML cache hit: {url}")
return str(row[0])
# Retrieve clean html first
clean_html = cache_clean_hit(url, refresh_mode="none" if refresh_mode != "all" else "all")
fixed_html = fix_chapter(
raw_cleaned_html=clean_html,
chapter_name=chapter_name,
novel_url=novel_url,
global_lore=global_lore,
)
db_execute(
"UPDATE chapter_page SET fixed_html_text = ? WHERE url = ?",
(fixed_html, url),
)
return fixed_html
def scrape_chapters( def scrape_chapters(
metadata: dict, metadata: dict,
refresh_mode: str = "none", refresh_mode: str = "none",
@@ -117,3 +148,49 @@ def clean_chapters(
tqdm.write(f"[SKIP] {chapter['name']}: {e}") tqdm.write(f"[SKIP] {chapter['name']}: {e}")
if progress_callback: if progress_callback:
progress_callback(stage="cleaning", current=idx, total=total, message=f"[SKIP] {chapter.get('name')}: {e}") progress_callback(stage="cleaning", current=idx, total=total, message=f"[SKIP] {chapter.get('name')}: {e}")
def fix_chapters(
metadata: dict,
refresh_mode: str = "none",
progress_callback=None,
cancel_event=None,
) -> None:
chapters = list(iter_chapters(metadata))
total = len(chapters)
novel_url = metadata.get("novel_url") or ""
# Sample the first chapter for initial lore extraction if needed
first_chapter_sample = ""
if chapters:
try:
first_raw = cache_chapter_hit(chapters[0]["url"], refresh_mode="none")
first_chapter_sample = clean_novel_html(first_raw, base_url=chapters[0]["url"])
except Exception:
pass
global_lore = get_or_init_novel_lore(novel_url, metadata, first_chapter_sample)
if progress_callback is None:
iterator = tqdm(chapters, desc="Fixing MTL chapters", unit="ch")
else:
iterator = chapters
for idx, chapter in enumerate(iterator, start=1):
if cancel_event is not None and cancel_event.is_set():
raise InterruptedError("Fixing was cancelled by user")
try:
cache_fixed_hit(
url=chapter["url"],
chapter_name=chapter.get("name") or f"Chapter {idx}",
novel_url=novel_url,
global_lore=global_lore,
refresh_mode=refresh_mode,
)
if progress_callback:
progress_callback(stage="fixing", current=idx, total=total, message=chapter.get("name") or chapter.get("url") or "")
except InterruptedError:
raise
except Exception as e:
tqdm.write(f"[SKIP] {chapter['name']}: {e}")
if progress_callback:
progress_callback(stage="fixing", current=idx, total=total, message=f"[SKIP] {chapter.get('name')}: {e}")
+29
View File
@@ -83,9 +83,23 @@ def init_db() -> None:
status_code INTEGER, status_code INTEGER,
html_text TEXT, html_text TEXT,
clean_html_text TEXT, clean_html_text TEXT,
fixed_html_text TEXT,
fetched_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP fetched_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
) )
""") """)
cur.execute("""
CREATE TABLE IF NOT EXISTS novel_lore (
novel_url TEXT PRIMARY KEY,
lore_json TEXT,
rolling_summary_json TEXT,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
""")
# Handle existing tables missing columns
try:
cur.execute("ALTER TABLE chapter_page ADD COLUMN IF NOT EXISTS fixed_html_text TEXT;")
except Exception:
pass
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
@@ -108,6 +122,21 @@ def init_db() -> None:
status_code INTEGER, status_code INTEGER,
html_text TEXT, html_text TEXT,
clean_html_text TEXT, clean_html_text TEXT,
fixed_html_text TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS novel_lore (
novel_url TEXT PRIMARY KEY,
lore_json TEXT,
rolling_summary_json TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Migration check for SQLite
cur = conn.cursor()
cur.execute("PRAGMA table_info(chapter_page);")
columns = [row[1] for row in cur.fetchall()]
if "fixed_html_text" not in columns:
conn.execute("ALTER TABLE chapter_page ADD COLUMN fixed_html_text TEXT;")
+34 -12
View File
@@ -42,12 +42,17 @@ hr {
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
margin: 2em 0; margin: 2em 0;
} }
aside.tl-note { aside.tl-note, blockquote.tl-note {
font-size: 90%; font-size: 90%;
margin: 1em 2em; margin: 1.5em 1em;
padding: 0.5em 1em; padding: 0.6em 1.2em;
border-left: 3px solid #ccc; border-left: 3px solid #888;
color: #555; background-color: #f5f5f5;
color: #444;
}
blockquote.tl-note p {
margin: 0;
text-indent: 0;
} }
""" """
@@ -58,7 +63,16 @@ def _slugify(text: str) -> str:
return slug or "novel" return slug or "novel"
def _get_clean_html(url: str) -> str: def _get_chapter_html(url: str, is_fixed: bool = False) -> str:
if is_fixed:
row = db_fetch_one("SELECT fixed_html_text, clean_html_text FROM chapter_page WHERE url = ?", (url,))
if row is not None:
if row[0]:
return str(row[0])
if row[1]:
return str(row[1])
raise RuntimeError(f"No fixed or cleaned HTML cached for {url}. Run fix_chapters first.")
row = db_fetch_one("SELECT clean_html_text FROM chapter_page WHERE url = ?", (url,)) row = db_fetch_one("SELECT clean_html_text FROM chapter_page WHERE url = ?", (url,))
if row is None or row[0] is None: if row is None or row[0] is None:
raise RuntimeError( raise RuntimeError(
@@ -180,16 +194,22 @@ def create_epub(
output_dir: str = "output", output_dir: str = "output",
progress_callback=None, progress_callback=None,
cancel_event=None, cancel_event=None,
is_fixed: bool = False,
) -> str: ) -> str:
series = metadata.get("metadata", {}) series = metadata.get("metadata", {})
title = series.get("title") or "Untitled" title = series.get("title") or "Untitled"
if is_fixed and not title.endswith(" [Fixed]"):
book_title = f"{title} [Fixed]"
else:
book_title = title
author = series.get("author") or "Unknown" author = series.get("author") or "Unknown"
language = series.get("language") or "en" language = series.get("language") or "en"
cover_url = series.get("cover_url") cover_url = series.get("cover_url")
book = epub.EpubBook() book = epub.EpubBook()
book.set_identifier(_slugify(title)) book.set_identifier(_slugify(book_title))
book.set_title(title) book.set_title(book_title)
book.set_language(language) book.set_language(language)
book.add_author(author) book.add_author(author)
@@ -242,13 +262,13 @@ def create_epub(
continue continue
try: try:
clean_html = _get_clean_html(url) chapter_html = _get_chapter_html(url, is_fixed=is_fixed)
except RuntimeError as e: except RuntimeError as e:
tqdm.write(f"[SKIP] {chapter_name}: {e}") tqdm.write(f"[SKIP] {chapter_name}: {e}")
chapter_counter += 1 chapter_counter += 1
continue continue
clean_html = _embed_images(book, clean_html, image_cache, referer=url) chapter_html = _embed_images(book, chapter_html, image_cache, referer=url)
file_name = f"chapter_{chapter_counter}.xhtml" file_name = f"chapter_{chapter_counter}.xhtml"
epub_chapter = epub.EpubHtml( epub_chapter = epub.EpubHtml(
@@ -257,7 +277,7 @@ def create_epub(
lang=language, lang=language,
) )
epub_chapter.add_item(nav_css) epub_chapter.add_item(nav_css)
epub_chapter.content = f"<h1>{chapter_name}</h1>\n{clean_html}" epub_chapter.content = f"<h1>{chapter_name}</h1>\n{chapter_html}"
book.add_item(epub_chapter) book.add_item(epub_chapter)
sections.append(epub_chapter) sections.append(epub_chapter)
@@ -285,7 +305,9 @@ def create_epub(
book.spine = spine_structure book.spine = spine_structure
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"{_slugify(title)}.epub") slug = _slugify(title)
filename = f"{slug}-fixed.epub" if is_fixed else f"{slug}.epub"
output_path = os.path.join(output_dir, filename)
epub.write_epub(output_path, book, {}) epub.write_epub(output_path, book, {})
print(f"Successfully generated: {output_path}") print(f"Successfully generated: {output_path}")
+82 -3
View File
@@ -53,7 +53,7 @@ TAG & FORMATTING RULES:
TRANSLATOR & CONTEXT NOTES: TRANSLATOR & CONTEXT NOTES:
1. KEEP informative translator notes that explain cultural context, puns, lore, or honorifics. Format them cleanly as: 1. KEEP informative translator notes that explain cultural context, puns, lore, or honorifics. Format them cleanly as:
<aside class="tl-note"><strong>Note:</strong> [Context explanation here]</aside> <blockquote class="tl-note"><p><strong>TL Note:</strong> [Context explanation here]</p></blockquote>
2. REMOVE translator banter, jokes, meme bloopers, editor rants, or off-topic conversational commentary. 2. REMOVE translator banter, jokes, meme bloopers, editor rants, or off-topic conversational commentary.
STRICT REMOVAL RULES: STRICT REMOVAL RULES:
@@ -64,5 +64,84 @@ STRICT REMOVAL RULES:
STRICT FIDELITY RULES: STRICT FIDELITY RULES:
1. DO NOT summarize, paraphrase, rewrite, or correct the author's prose or character dialogue. 1. DO NOT summarize, paraphrase, rewrite, or correct the author's prose or character dialogue.
2. DO NOT delete legitimate story paragraphs. 2. DO NOT delete legitimate story paragraphs.
3. Output ONLY the sanitized HTML body content (<p>, <hr />, <img>, <aside>). Do not include <html>, <head>, <body> wrappers, or markdown code fences (```html). 3. Output ONLY the sanitized HTML body content (<p>, <hr />, <img>, <blockquote>). Do not include <html>, <head>, <body> wrappers, or markdown code fences (```html).
""" """
LORE_EXTRACTION_SYSTEM_PROMPT = """
You are an expert light novel entity extraction and lore analysis engine.
YOUR TASK:
Analyze the provided novel description and initial chapters to extract key characters, proper terminology, and narrative rules into a standardized JSON glossary.
EXTRACTION SCHEMA:
{
"characters": [
{
"raw_name": "Original Japanese or Romanized Name",
"mtl_variants": ["Variant1", "Variant2"],
"standard_name": "Standardized English Name",
"gender": "male | female | unknown",
"role": "Protagonist | Ally | Antagonist | Supporting",
"speech_style": "Brief description of dialogue tone"
}
],
"terms": {
"Original Term or MTL Term": "Standardized English Term"
},
"rules": {
"keep_honorifics": false,
"pov": "First-person | Third-person limited | Third-person omniscient"
}
}
RULES:
1. Identify true recurring character names vs generic nouns.
2. Standardize spelling variants (e.g., "Arus", "Arce", "Ars" -> "Ars").
3. Identify faction names, skill/magic names, and location names.
4. Output ONLY valid raw JSON.
"""
APE_SYSTEM_PROMPT = """
You are an expert Japanese-to-English literary translator and copyeditor specializing in light novels and web novels. Your goal is to transform raw Machine Translation (MTL) into fluent, natural, and immersive English narrative prose while maintaining 100% factual accuracy to the source material.
CORE OPERATIONAL RULES:
1. STRICT CONTEXT ALIGNMENT (Zero Hallucination):
- Do NOT invent, add, or alter plot points, actions, dialogue beats, or world-building elements.
- Do NOT insert extra prose, backstory, or commentary that was not present or implied in the raw MTL text.
- If a sentence is completely incomprehensible, reconstruct the most logical meaning using surrounding paragraph context and the provided Glossary/Lore sheet.
2. FIDELITY-PRESERVING STYLISTIC POLISHING:
- Eliminate common MTL artifacts: awkward literal idioms, stiff sentence structures, repetitive word choices, and robotic cadence.
- Fix dropped or inverted pronouns (Japanese subject-dropping often causes MTL to confuse "he/she/they/it"). Cross-reference active characters in the scene to ensure correct pronoun usage.
- Smooth out dialogue to sound natural when spoken aloud, matching character voice and tone as defined in the Glossary.
- Preserve narrative perspective (1st-person POV vs. 3rd-person limited) consistently throughout the scene.
3. GLOSSARY & ENTITY ENFORCEMENT:
- Always strictly honor the provided Active Glossary & Character Sheet.
- Do not translate proper names, places, or technique names literally if an official/standardized English equivalent is specified in the Glossary.
4. TRANSLATOR NOTES:
- Format any informative translator/editor notes strictly as:
<blockquote class="tl-note"><p><strong>TL Note:</strong> [Context explanation]</p></blockquote>
- Strip out off-topic translator jokes, memes, or donation links.
5. OUTPUT SPECIFICATION:
- Output ONLY the polished chapter prose wrapped in standard <p>...</p> tags, with <hr /> for scene breaks and intact <img> tags.
- Do NOT duplicate the chapter title at the beginning.
- Do NOT include markdown code block wrappers (```html) or introductory conversational text.
"""
CHAPTER_SUMMARY_SYSTEM_PROMPT = """
You are an expert story summarization engine.
YOUR TASK:
Read the provided chapter prose and generate an extremely concise 2-sentence summary of the main plot points, current location, active characters, and ending state.
CONSTRAINTS:
- Maximum 150 words / 200 tokens.
- Factual and direct. Focus on character actions and scene transitions.
- Output ONLY the summary text, with no introductory text or labels.
"""
+126
View File
@@ -0,0 +1,126 @@
import pytest
from fastapi.testclient import TestClient
from web.app import app
client = TestClient(app)
def test_index_page_returns_200():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "<!DOCTYPE html>" in html
assert "/static/app.css" in html
assert "Silksoup: Novel to EPUB" in html
def test_dark_mode_only():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert '<html lang="en" class="dark">' in html
assert "themeBtn" not in html
assert "toggleTheme" not in html
def test_no_external_cdn_dependencies():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "cdn.tailwindcss.com" not in html
assert "googleapis" not in html
assert "cloudflare" not in html
def test_no_em_dash_in_template():
response = client.get("/")
assert response.status_code == 200
html = response.text
# Rule R-02 ban
assert "—" not in html
def test_static_app_css_available_and_contains_azure_tokens():
response = client.get("/static/app.css")
assert response.status_code == 200
css = response.text
assert "--bg: #141517" in css
assert "--surface: #1C1E21" in css
assert "--primary: #0284C7" in css
assert "--primary-accent: #38BDF8" in css
assert ":focus-visible" in css
assert "ui-monospace" in css
def test_required_dom_contract_elements_exist():
response = client.get("/")
assert response.status_code == 200
html = response.text
required_ids = [
"jobForm",
"urlInput",
"startBtn",
"btnSpinner",
"btnText",
"fixCheck",
"activeJob",
"cancelJobBtn",
"statusPulse",
"jobStatusBadge",
"jobStage",
"jobMessage",
"jobCounter",
"jobProgress",
"jobLog",
"autoScrollCheck",
"expandLogBtn",
"jobResult",
"downloadLink",
"searchEpub",
"epubCount",
"epubList",
"epubEmpty",
"toastContainer",
"notifyBtn",
]
for element_id in required_ids:
assert f'id="{element_id}"' in html, f"Missing required element id: {element_id}"
def test_aria_accessibility_attributes_present():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'role="progressbar"' in html
assert 'aria-valuenow' in html
assert 'aria-live="polite"' in html
assert 'role="radiogroup"' in html
def test_form_submits_on_submit_event():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "submitJob()" in html
assert "addEventListener('submit'" in html
assert 'type="submit"' in html
def test_guide_details_present():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "<details" in html
assert "Workflow Pipeline Guide" in html
assert "1. Ingest & Cache" in html
assert "4. Build EPUB" in html
def test_api_epubs_endpoint_unmodified():
response = client.get("/api/epubs")
assert response.status_code == 200
data = response.json()
assert "epubs" in data
assert "output_dir" in data
def test_api_jobs_validation_unmodified():
response = client.post("/api/jobs", json={"url": "not-a-valid-url"})
assert response.status_code == 400
def test_api_cancel_missing_job_returns_404():
response = client.post("/api/jobs/nonexistent-id/cancel")
assert response.status_code == 404
def test_api_download_missing_epub_returns_404():
response = client.get("/api/epubs/nonexistent-book.epub")
assert response.status_code == 404
+130
View File
@@ -0,0 +1,130 @@
import asyncio
import pytest
from fastapi.testclient import TestClient
from web.app import app
import web.job_manager as jm
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset_job_manager():
jm.cancel_all_jobs()
jm.jobs.clear()
jm.subscribers.clear()
jm.cancel_events.clear()
jm.active_job_id = None
yield
jm.cancel_all_jobs()
jm.jobs.clear()
jm.subscribers.clear()
jm.cancel_events.clear()
jm.active_job_id = None
def test_active_job_endpoint_returns_idle_when_empty():
res = client.get("/api/jobs/active")
assert res.status_code == 200
data = res.json()
assert data["active"] is False
assert data["job"] is None
def test_active_job_endpoint_returns_running_job():
job_id = jm.create_job("https://example.com/novel-1")
jm.jobs[job_id]["status"] = "running"
jm.jobs[job_id]["stage"] = "scraping"
jm.jobs[job_id]["current"] = 5
jm.jobs[job_id]["total"] = 20
jm.jobs[job_id]["message"] = "Scraping chapter 5"
res = client.get("/api/jobs/active")
assert res.status_code == 200
data = res.json()
assert data["active"] is True
assert data["job"]["id"] == job_id
assert data["job"]["stage"] == "scraping"
assert data["job"]["current"] == 5
assert data["job"]["total"] == 20
assert data["job"]["percentage"] == 25
assert data["job"]["message"] == "Scraping chapter 5"
def test_concurrent_job_submission_returns_409():
job_id = jm.create_job("https://example.com/active-novel")
jm.jobs[job_id]["status"] = "running"
res = client.post("/api/jobs", json={"url": "https://example.com/another-novel"})
assert res.status_code == 409
assert "Another novel conversion is currently in progress" in res.json()["detail"]
def test_fix_job_submission_returns_409_when_busy(tmp_path, monkeypatch):
test_epub = tmp_path / "test.epub"
test_epub.write_text("dummy epub")
monkeypatch.setattr("web.app.OUTPUT_DIR", tmp_path)
monkeypatch.setattr("web.app._find_novel_url_for_epub", lambda f: "https://example.com/test-novel")
job_id = jm.create_job("https://example.com/active-novel")
jm.jobs[job_id]["status"] = "running"
res = client.post("/api/epubs/test.epub/fix")
assert res.status_code == 409
assert "Another novel conversion is currently in progress" in res.json()["detail"]
def test_cancellation_frees_worker_lock():
job_id = jm.create_job("https://example.com/active-novel")
jm.jobs[job_id]["status"] = "running"
assert jm.is_worker_busy() is True
cancel_res = client.post(f"/api/jobs/{job_id}/cancel")
assert cancel_res.status_code == 200
assert cancel_res.json()["status"] == "cancelled"
assert jm.is_worker_busy() is False
active_res = client.get("/api/jobs/active")
assert active_res.status_code == 200
assert active_res.json()["active"] is False
# A subsequent job can now be submitted
new_job_id = jm.create_job("https://example.com/subsequent-novel")
assert new_job_id is not None
assert jm.active_job_id == new_job_id
def test_sse_pubsub_fanout_multiple_subscribers():
job_id = jm.create_job("https://example.com/sse-test")
q1 = jm.subscribe_job_events(job_id)
q2 = jm.subscribe_job_events(job_id)
assert len(jm.subscribers[job_id]) == 2
test_event = {"stage": "scraping", "current": 1, "total": 10, "message": "Chapter 1"}
jm._push(job_id, test_event)
assert not q1.empty()
assert not q2.empty()
assert q1.get_nowait() == test_event
assert q2.get_nowait() == test_event
jm.unsubscribe_job_events(job_id, q1)
assert len(jm.subscribers[job_id]) == 1
jm.unsubscribe_job_events(job_id, q2)
assert job_id not in jm.subscribers
def test_frontend_has_sync_active_job_and_reconnection():
res = client.get("/")
assert res.status_code == 200
html = res.text
assert "syncActiveJob()" in html
assert "/api/jobs/active" in html
assert "setFormLocked(true)" in html
assert "setFormLocked(false)" in html
assert "409" in html
+129 -24
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -9,13 +10,18 @@ from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from src.database import db_fetch_one, db_fetch_all, init_db
from src.epub_creator import _slugify
from .job_manager import ( from .job_manager import (
jobs, jobs,
job_queues,
create_job, create_job,
cancel_job, cancel_job,
cancel_all_jobs, cancel_all_jobs,
run_job, run_job,
get_active_job,
subscribe_job_events,
unsubscribe_job_events,
WorkerBusyError,
) )
OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "output")).resolve() OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "output")).resolve()
@@ -24,6 +30,7 @@ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db()
yield yield
cancel_all_jobs() cancel_all_jobs()
@@ -42,24 +49,115 @@ async def index(request: Request):
return templates.TemplateResponse(request, "index.html") return templates.TemplateResponse(request, "index.html")
def _find_novel_url_for_epub(filename: str) -> str | None:
base_slug = _slugify(re.sub(r"(-fixed)?\.epub$", "", filename, flags=re.I))
rows = db_fetch_all("SELECT url, json_text FROM main_page WHERE json_text IS NOT NULL")
for row in rows:
url, json_str = row[0], row[1]
try:
data = json.loads(json_str)
title = data.get("metadata", {}).get("title") or ""
if _slugify(title) == base_slug:
return url
except Exception:
continue
return None
@app.get("/api/jobs/active")
async def get_active_job_endpoint():
active = get_active_job()
if active is None:
return {"active": False, "job": None}
return {"active": True, "job": active}
@app.post("/api/jobs") @app.post("/api/jobs")
async def start_job(payload: dict): async def start_job(payload: dict):
url = (payload.get("url") or "").strip() url = (payload.get("url") or "").strip()
if not url or not (url.startswith("http://") or url.startswith("https://")): if not url or not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(status_code=400, detail="Valid URL required") raise HTTPException(status_code=400, detail="Valid URL required")
active = get_active_job()
if active is not None:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {active.get('url')}",
)
# Check if a fixed version of this novel already exists in the library
row = db_fetch_one("SELECT json_text FROM main_page WHERE url = ?", (url,))
if row is not None and row[0]:
try:
data = json.loads(row[0])
title = data.get("metadata", {}).get("title") or ""
slug = _slugify(title)
fixed_file = OUTPUT_DIR / f"{slug}-fixed.epub"
if fixed_file.exists():
raise HTTPException(
status_code=409,
detail="Novel already exists in library (Fixed version present).",
)
except HTTPException:
raise
except Exception:
pass
refresh_mode = payload.get("refresh_mode") refresh_mode = payload.get("refresh_mode")
if refresh_mode is None: if refresh_mode is None:
force_refresh = bool(payload.get("force_refresh", False)) force_refresh = bool(payload.get("force_refresh", False))
refresh_mode = "all" if force_refresh else "none" refresh_mode = "all" if force_refresh else "none"
elif refresh_mode not in ("none", "llm", "all"): elif refresh_mode not in ("none", "llm", "all", "fix"):
refresh_mode = "none" refresh_mode = "none"
job_id = create_job(url, refresh_mode=refresh_mode, output_dir=str(OUTPUT_DIR)) fix = bool(payload.get("fix", False)) or (refresh_mode == "fix")
try:
job_id = create_job(url, refresh_mode=refresh_mode, fix=fix, output_dir=str(OUTPUT_DIR))
except WorkerBusyError as e:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {e.active_job.get('url')}",
)
asyncio.create_task(run_job(job_id)) asyncio.create_task(run_job(job_id))
return {"job_id": job_id, "status": "queued"} return {"job_id": job_id, "status": "queued"}
@app.post("/api/epubs/{filename}/fix")
async def fix_library_epub(filename: str):
safe = Path(filename).name
target = OUTPUT_DIR / safe
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="EPUB file not found")
if "-fixed.epub" in safe.lower():
raise HTTPException(status_code=400, detail="This EPUB is already a fixed version.")
active = get_active_job()
if active is not None:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {active.get('url')}",
)
novel_url = _find_novel_url_for_epub(safe)
if not novel_url:
raise HTTPException(
status_code=404,
detail="Source novel URL not found in cache. Re-scrape the novel to fix.",
)
try:
job_id = create_job(novel_url, refresh_mode="none", fix=True, output_dir=str(OUTPUT_DIR))
except WorkerBusyError as e:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {e.active_job.get('url')}",
)
asyncio.create_task(run_job(job_id))
return {"job_id": job_id, "status": "queued", "novel_url": novel_url}
@app.post("/api/jobs/{job_id}/cancel") @app.post("/api/jobs/{job_id}/cancel")
async def stop_job(job_id: str): async def stop_job(job_id: str):
job = jobs.get(job_id) job = jobs.get(job_id)
@@ -118,28 +216,30 @@ async def job_events(job_id: str):
raise HTTPException(status_code=404, detail="Job not found") raise HTTPException(status_code=404, detail="Job not found")
async def event_gen(): async def event_gen():
q = job_queues.get(job_id) q = subscribe_job_events(job_id)
if q is None: try:
return events = list(job.get("events", []))
last_idx = 0 for evt in events:
events = job.get("events", [])
for evt in events[last_idx:]:
yield f"data: {json.dumps(evt)}\n\n"
last_idx += 1
if evt.get("message") == "__DONE__":
return
while True:
if job["status"] in ("completed", "failed", "cancelled") and q.empty():
break
try:
evt = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(evt)}\n\n" yield f"data: {json.dumps(evt)}\n\n"
if evt.get("message") == "__DONE__": if evt.get("message") == "__DONE__":
break return
except asyncio.TimeoutError:
yield ": keepalive\n\n" if job.get("status") in ("completed", "failed", "cancelled"):
if job["status"] in ("completed", "failed", "cancelled"): yield f"data: {json.dumps({'stage': 'done', 'message': '__DONE__'})}\n\n"
break return
while True:
try:
evt = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(evt)}\n\n"
if evt.get("message") == "__DONE__":
break
except asyncio.TimeoutError:
yield ": keepalive\n\n"
if job.get("status") in ("completed", "failed", "cancelled"):
break
finally:
unsubscribe_job_events(job_id, q)
return StreamingResponse(event_gen(), media_type="text/event-stream", headers={ return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
@@ -154,8 +254,13 @@ async def list_epubs():
if OUTPUT_DIR.exists(): if OUTPUT_DIR.exists():
for p in sorted(OUTPUT_DIR.glob("*.epub"), key=lambda x: x.stat().st_mtime, reverse=True): for p in sorted(OUTPUT_DIR.glob("*.epub"), key=lambda x: x.stat().st_mtime, reverse=True):
stat = p.stat() stat = p.stat()
name = p.name
is_fixed = "-fixed.epub" in name.lower() or " [fixed]" in name.lower()
base_key = re.sub(r"(-fixed)?\.epub$", "", name, flags=re.I).strip("-_")
files.append({ files.append({
"name": p.name, "name": name,
"base_key": base_key,
"is_fixed": is_fixed,
"size": stat.st_size, "size": stat.st_size,
"size_human": _human_size(stat.st_size), "size_human": _human_size(stat.st_size),
"mtime": stat.st_mtime, "mtime": stat.st_mtime,
+104 -15
View File
@@ -8,12 +8,65 @@ from src.database import init_db
from src.parse import cache_hit from src.parse import cache_hit
from src.clean_html import clean_novel_html from src.clean_html import clean_novel_html
from src.metadata import extract_metadata_json from src.metadata import extract_metadata_json
from src.chapter import clean_chapters from src.chapter import clean_chapters, fix_chapters, scrape_chapters
from src.epub_creator import create_epub from src.epub_creator import create_epub
jobs: dict[str, dict] = {} jobs: dict[str, dict] = {}
job_queues: dict[str, asyncio.Queue] = {} subscribers: dict[str, set[asyncio.Queue]] = {}
cancel_events: dict[str, threading.Event] = {} cancel_events: dict[str, threading.Event] = {}
active_job_id: str | None = None
class WorkerBusyError(Exception):
def __init__(self, active_job: dict):
self.active_job = active_job
super().__init__("Worker is currently busy with another job")
def get_active_job() -> dict | None:
if active_job_id is None:
return None
job = jobs.get(active_job_id)
if not job or job.get("status") not in ("queued", "running"):
return None
total = job.get("total") or 0
current = job.get("current") or 0
percentage = round(current / total * 100) if total else 0
return {
"id": job["id"],
"url": job["url"],
"refresh_mode": job.get("refresh_mode", "none"),
"fix": job.get("fix", False),
"status": job["status"],
"stage": job["stage"],
"current": current,
"total": total,
"percentage": percentage,
"message": job.get("message", ""),
"created_at": job.get("created_at"),
"result_path": job.get("result_path"),
"error": job.get("error"),
"logs": job.get("logs", [])[-200:],
}
def is_worker_busy() -> bool:
return get_active_job() is not None
def subscribe_job_events(job_id: str) -> asyncio.Queue:
q = asyncio.Queue(maxsize=1000)
if job_id not in subscribers:
subscribers[job_id] = set()
subscribers[job_id].add(q)
return q
def unsubscribe_job_events(job_id: str, q: asyncio.Queue):
if job_id in subscribers:
subscribers[job_id].discard(q)
if not subscribers[job_id]:
subscribers.pop(job_id, None)
def _push(job_id: str, event: dict): def _push(job_id: str, event: dict):
@@ -30,20 +83,28 @@ def _push(job_id: str, event: dict):
job["total"] = event["total"] job["total"] = event["total"]
if "message" in event: if "message" in event:
job["message"] = event["message"] job["message"] = event["message"]
q = job_queues.get(job_id)
if q is not None: queues = subscribers.get(job_id, set())
for q in list(queues):
try: try:
q.put_nowait(event) q.put_nowait(event)
except asyncio.QueueFull: except asyncio.QueueFull:
pass pass
def create_job(url: str, refresh_mode: str = "none", output_dir: str = "output") -> str: def create_job(url: str, refresh_mode: str = "none", fix: bool = False, output_dir: str = "output") -> str:
global active_job_id
active = get_active_job()
if active is not None:
raise WorkerBusyError(active)
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
active_job_id = job_id
jobs[job_id] = { jobs[job_id] = {
"id": job_id, "id": job_id,
"url": url, "url": url,
"refresh_mode": refresh_mode, "refresh_mode": refresh_mode,
"fix": bool(fix),
"output_dir": output_dir, "output_dir": output_dir,
"status": "queued", "status": "queued",
"stage": "queued", "stage": "queued",
@@ -57,12 +118,13 @@ def create_job(url: str, refresh_mode: str = "none", output_dir: str = "output")
"created_at": time.time(), "created_at": time.time(),
"updated_at": time.time(), "updated_at": time.time(),
} }
job_queues[job_id] = asyncio.Queue(maxsize=1000) subscribers[job_id] = set()
cancel_events[job_id] = threading.Event() cancel_events[job_id] = threading.Event()
return job_id return job_id
def cancel_job(job_id: str) -> bool: def cancel_job(job_id: str) -> bool:
global active_job_id
job = jobs.get(job_id) job = jobs.get(job_id)
if not job: if not job:
return False return False
@@ -75,24 +137,30 @@ def cancel_job(job_id: str) -> bool:
job["logs"].append("[CANCEL] Process stopped by user. Cached chapters remain safe.") job["logs"].append("[CANCEL] Process stopped by user. Cached chapters remain safe.")
_push(job_id, {"stage": "cancelled", "current": job.get("current", 0), "total": job.get("total", 0), "message": "Process stopped by user"}) _push(job_id, {"stage": "cancelled", "current": job.get("current", 0), "total": job.get("total", 0), "message": "Process stopped by user"})
_push(job_id, {"stage": "done", "current": job.get("current", 0), "total": job.get("total", 0), "message": "__DONE__"}) _push(job_id, {"stage": "done", "current": job.get("current", 0), "total": job.get("total", 0), "message": "__DONE__"})
if active_job_id == job_id:
active_job_id = None
return True return True
def cancel_all_jobs(): def cancel_all_jobs():
global active_job_id
for job_id, evt in cancel_events.items(): for job_id, evt in cancel_events.items():
job = jobs.get(job_id) job = jobs.get(job_id)
if job and job.get("status") in ("running", "queued"): if job and job.get("status") in ("running", "queued"):
evt.set() evt.set()
job["status"] = "cancelled" job["status"] = "cancelled"
job["stage"] = "cancelled" job["stage"] = "cancelled"
active_job_id = None
async def run_job(job_id: str): async def run_job(job_id: str):
global active_job_id
job = jobs.get(job_id) job = jobs.get(job_id)
if job is None: if job is None:
return return
url = job["url"] url = job["url"]
refresh_mode = job.get("refresh_mode", "none") refresh_mode = job.get("refresh_mode", "none")
is_fix_job = bool(job.get("fix") or refresh_mode == "fix")
output_dir = job["output_dir"] output_dir = job["output_dir"]
cancel_evt = cancel_events.get(job_id) cancel_evt = cancel_events.get(job_id)
job["status"] = "running" job["status"] = "running"
@@ -111,7 +179,7 @@ async def run_job(job_id: str):
_push(job_id, {"stage": "fetching", "current": 0, "total": 1, "message": "Fetching index page..."}) _push(job_id, {"stage": "fetching", "current": 0, "total": 1, "message": "Fetching index page..."})
job["logs"].append(f"Fetching {url}") job["logs"].append(f"Fetching {url}")
html = await asyncio.to_thread(cache_hit, url, refresh_mode) html = await asyncio.to_thread(cache_hit, url, "none" if is_fix_job else refresh_mode)
if cancel_evt and cancel_evt.is_set(): if cancel_evt and cancel_evt.is_set():
raise InterruptedError("Cancelled after fetch") raise InterruptedError("Cancelled after fetch")
@@ -120,7 +188,7 @@ async def run_job(job_id: str):
_push(job_id, {"stage": "metadata", "current": 0, "total": 1, "message": "Extracting metadata via LLM..."}) _push(job_id, {"stage": "metadata", "current": 0, "total": 1, "message": "Extracting metadata via LLM..."})
job["logs"].append("Extracting metadata") job["logs"].append("Extracting metadata")
metadata = await asyncio.to_thread(extract_metadata_json, cleaned, url, refresh_mode) metadata = await asyncio.to_thread(extract_metadata_json, cleaned, url, "none" if is_fix_job else refresh_mode)
metadata["novel_url"] = url metadata["novel_url"] = url
if cancel_evt and cancel_evt.is_set(): if cancel_evt and cancel_evt.is_set():
@@ -128,16 +196,34 @@ async def run_job(job_id: str):
volumes = metadata.get("volumes", []) volumes = metadata.get("volumes", [])
total_chapters = sum(len(v.get("chapters", [])) for v in volumes) total_chapters = sum(len(v.get("chapters", [])) for v in volumes)
_push(job_id, {"stage": "cleaning", "current": 0, "total": total_chapters, "message": f"Cleaning {total_chapters} chapters"})
job["logs"].append(f"Found {len(volumes)} volumes, {total_chapters} chapters")
await asyncio.to_thread(clean_chapters, metadata, refresh_mode, _cb, cancel_evt) if is_fix_job:
_push(job_id, {"stage": "scraping", "current": 0, "total": total_chapters, "message": f"Ensuring {total_chapters} chapters are cached"})
await asyncio.to_thread(scrape_chapters, metadata, refresh_mode if refresh_mode != "fix" else "none", _cb, cancel_evt)
if cancel_evt and cancel_evt.is_set(): if cancel_evt and cancel_evt.is_set():
raise InterruptedError("Cancelled before EPUB building") raise InterruptedError("Cancelled before APE fix")
_push(job_id, {"stage": "epub", "current": 0, "total": total_chapters, "message": "Building EPUB..."}) _push(job_id, {"stage": "fixing", "current": 0, "total": total_chapters, "message": f"Fixing MTL for {total_chapters} chapters"})
output_path = await asyncio.to_thread(create_epub, metadata, output_dir, _cb, cancel_evt) job["logs"].append(f"Starting APE MTL repair on {total_chapters} chapters")
await asyncio.to_thread(fix_chapters, metadata, "none", _cb, cancel_evt)
if cancel_evt and cancel_evt.is_set():
raise InterruptedError("Cancelled before EPUB building")
_push(job_id, {"stage": "epub", "current": 0, "total": total_chapters, "message": "Building Fixed EPUB..."})
output_path = await asyncio.to_thread(create_epub, metadata, output_dir, _cb, cancel_evt, is_fixed=True)
else:
_push(job_id, {"stage": "cleaning", "current": 0, "total": total_chapters, "message": f"Cleaning {total_chapters} chapters"})
job["logs"].append(f"Found {len(volumes)} volumes, {total_chapters} chapters")
await asyncio.to_thread(clean_chapters, metadata, refresh_mode, _cb, cancel_evt)
if cancel_evt and cancel_evt.is_set():
raise InterruptedError("Cancelled before EPUB building")
_push(job_id, {"stage": "epub", "current": 0, "total": total_chapters, "message": "Building EPUB..."})
output_path = await asyncio.to_thread(create_epub, metadata, output_dir, _cb, cancel_evt, is_fixed=False)
job["result_path"] = output_path job["result_path"] = output_path
job["status"] = "completed" job["status"] = "completed"
@@ -161,3 +247,6 @@ async def run_job(job_id: str):
job["logs"].append(f"[ERROR] {e}") job["logs"].append(f"[ERROR] {e}")
_push(job_id, {"stage": "failed", "current": job.get("current", 0), "total": job.get("total", 0), "message": str(e)}) _push(job_id, {"stage": "failed", "current": job.get("current", 0), "total": job.get("total", 0), "message": str(e)})
_push(job_id, {"stage": "done", "current": 0, "total": 0, "message": "__DONE__"}) _push(job_id, {"stage": "done", "current": 0, "total": 0, "message": "__DONE__"})
finally:
if active_job_id == job_id:
active_job_id = None
+975
View File
@@ -0,0 +1,975 @@
:root {
--bg: #141517;
--surface: #1C1E21;
--surface-subtle: #25282D;
--border: #2B2E33;
--border-strong: #3E434A;
--text: #EAEBED;
--text-muted: #909399;
--primary: #0284C7;
--primary-hover: #0369A1;
--primary-fg: #FFFFFF;
--primary-subtle: rgba(2, 132, 199, 0.15);
--primary-accent: #38BDF8;
--btn-blue: #075985;
--btn-blue-hover: #0C4A6E;
--btn-blue-border: #0369A1;
--success: #34D399;
--success-bg: rgba(52, 211, 153, 0.1);
--success-border: #065F46;
--warning: #FBBF24;
--warning-bg: rgba(251, 191, 36, 0.1);
--warning-border: #78350F;
--danger: #FB7185;
--danger-bg: rgba(251, 113, 133, 0.1);
--danger-border: #881337;
--danger-hover: #F43F5E;
--log-bg: #0F1012;
--log-border: #2B2E33;
--log-text: #EAEBED;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-pill: 9999px;
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--transition-fast: 150ms ease;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: var(--primary);
color: var(--primary-fg);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
:focus-visible {
outline: 2px solid var(--primary-accent);
outline-offset: 2px;
}
.app-header {
position: sticky;
top: 0;
z-index: 30;
background-color: var(--surface);
border-bottom: 1px solid var(--border);
}
.header-inner {
max-width: 56rem;
margin: 0 auto;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand-wrap {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: inherit;
}
.brand-icon {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
object-fit: contain;
}
.brand-title-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.brand-title {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.brand-subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
}
.header-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-pill);
background-color: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
}
.header-badge.hidden {
display: none;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-header {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.375rem 0.625rem;
min-height: 2rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
text-decoration: none;
transition: all var(--transition-fast);
}
.btn-header:hover {
color: var(--text);
background-color: var(--surface-subtle);
border-color: var(--border-strong);
}
.btn-header.active {
color: var(--success);
border-color: var(--success);
}
.app-main {
max-width: 56rem;
margin: 0 auto;
width: 100%;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
flex: 1;
}
.card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.75rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.375rem;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-text {
flex: 1;
font-family: inherit;
font-size: 0.9375rem;
background-color: var(--surface-subtle);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 0.875rem;
min-height: 2.75rem;
transition: border-color var(--transition-fast);
}
.input-text::placeholder {
color: var(--text-muted);
opacity: 0.8;
}
.input-text:hover {
border-color: var(--border-strong);
}
.input-text:focus {
border-color: var(--primary);
outline: none;
}
.btn-paste {
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 0.875rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn-paste:hover {
color: var(--text);
border-color: var(--border-strong);
}
.cache-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0.625rem;
}
@media (min-width: 640px) {
.cache-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.cache-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
background-color: var(--surface-subtle);
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
}
.cache-card:hover {
border-color: var(--border-strong);
}
.cache-card:has(input:checked) {
border-color: var(--primary-accent);
background-color: var(--primary-subtle);
}
.cache-card input[type="radio"] {
margin-top: 0.1875rem;
accent-color: var(--primary);
}
.cache-card-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.cache-card-desc {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.125rem;
}
.toggle-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
background-color: var(--surface-subtle);
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
}
.toggle-card:hover {
border-color: var(--border-strong);
}
.toggle-card:has(input:checked) {
border-color: var(--primary-accent);
background-color: var(--primary-subtle);
}
.toggle-card input[type="checkbox"] {
margin-top: 0.1875rem;
accent-color: var(--primary);
}
.badge-ape {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
background-color: var(--primary-subtle);
color: var(--primary-accent);
border: 1px solid var(--primary);
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
}
.btn-primary {
font-family: inherit;
font-size: 0.9375rem;
font-weight: 500;
color: var(--primary-fg);
background-color: var(--btn-blue);
border: 1px solid var(--btn-blue-border);
border-radius: var(--radius-md);
padding: 0.625rem 1.25rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
width: 100%;
}
@media (min-width: 640px) {
.btn-primary {
width: auto;
}
}
.btn-primary:hover:not(:disabled) {
background-color: var(--btn-blue-hover);
border-color: var(--btn-blue);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.monitor-section.hidden {
display: none;
}
.monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.monitor-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--success);
flex-shrink: 0;
}
.status-dot.dot-emerald { background-color: var(--success); }
.status-dot.dot-amber { background-color: var(--warning); }
.status-dot.dot-rose { background-color: var(--danger); }
.monitor-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-stop {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--danger);
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
border-radius: var(--radius-md);
padding: 0.25rem 0.625rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-stop:hover:not(:disabled) {
background-color: var(--danger);
color: #FFFFFF;
}
.btn-stop.hidden {
display: none;
}
.badge-status {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background-color: var(--surface-subtle);
color: var(--text);
}
.stage-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.stage-pill {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
color: var(--text);
flex-shrink: 0;
}
.stage-message {
color: var(--text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stage-counter {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-muted);
flex-shrink: 0;
}
.progress-track {
height: 0.5rem;
border-radius: var(--radius-pill);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-fill {
height: 100%;
background-color: var(--primary);
width: 0%;
transition: width 200ms ease;
}
.log-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 0.375rem;
}
.log-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.log-action-btn {
background: none;
border: none;
font-family: inherit;
font-size: 0.8125rem;
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast);
}
.log-action-btn:hover {
color: var(--text);
}
.log-box {
height: 10rem;
overflow: auto;
border-radius: var(--radius-md);
background-color: var(--log-bg);
border: 1px solid var(--log-border);
padding: 0.75rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--log-text);
white-space: pre-wrap;
line-height: 1.6;
}
.log-box.expanded {
height: 24rem;
}
.result-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.result-bar.hidden {
display: none;
}
.btn-secondary {
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 1rem;
min-height: 2.75rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-secondary:hover {
color: var(--text);
background-color: var(--surface-subtle);
border-color: var(--border-strong);
}
.library-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.library-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.library-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.library-counter {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-muted);
}
.library-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.search-wrap {
position: relative;
flex: 1;
display: flex;
}
.search-wrap .input-text {
width: 100%;
}
.book-shelf {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.book-card {
border: 1px solid var(--border);
background-color: var(--surface-subtle);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--transition-fast);
}
.book-card:hover {
border-color: var(--border-strong);
}
.book-edition-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.book-edition-row + .book-edition-row {
border-top: 1px solid var(--border);
}
.book-edition-row.fixed-edition {
background-color: var(--primary-subtle);
}
.book-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.book-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--text-muted);
flex-shrink: 0;
}
.fixed-edition .book-icon {
color: var(--primary-accent);
}
.book-meta-block {
min-width: 0;
flex: 1;
}
.book-title-line {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.book-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
}
.badge-fixed {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.0625rem 0.375rem;
border-radius: var(--radius-sm);
background-color: var(--primary);
color: var(--primary-fg);
}
.book-specs {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.125rem;
}
.book-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.btn-book-action {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
padding: 0.375rem 0.625rem;
min-height: 2rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.btn-book-download {
color: var(--primary-fg);
background-color: var(--btn-blue);
border: 1px solid var(--btn-blue-border);
}
.btn-book-download:hover {
background-color: var(--btn-blue-hover);
}
.btn-book-fix {
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
}
.btn-book-fix:hover {
color: var(--primary-accent);
border-color: var(--primary-accent);
}
.btn-book-delete {
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
}
.btn-book-delete:hover {
color: var(--danger);
border-color: var(--danger-border);
}
.delete-confirm-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
border-radius: var(--radius-sm);
padding: 0.125rem;
}
.btn-confirm-delete {
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
background-color: var(--danger);
color: #FFFFFF;
border: none;
cursor: pointer;
}
.btn-confirm-delete:hover {
background-color: var(--danger-hover);
}
.btn-cancel-delete {
font-family: inherit;
font-size: 0.75rem;
padding: 0.25rem 0.375rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
}
.btn-cancel-delete:hover {
color: var(--text);
}
.empty-state {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.empty-state.hidden {
display: none;
}
.guide-details {
border: 1px solid var(--border);
background-color: var(--surface);
border-radius: var(--radius-lg);
padding: 0.75rem 1rem;
}
.guide-summary {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
.guide-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0.625rem;
margin-top: 0.75rem;
}
@media (min-width: 640px) {
.guide-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.guide-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.guide-step {
padding: 0.75rem;
border-radius: var(--radius-md);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
font-size: 0.75rem;
color: var(--text-muted);
}
.guide-step-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.app-footer {
border-top: 1px solid var(--border);
padding: 1rem;
text-align: center;
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: auto;
}
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
pointer-events: auto;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background-color: var(--surface);
color: var(--text);
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
gap: 0.625rem;
transition: opacity 150ms ease, transform 150ms ease;
opacity: 0;
transform: translateY(4px);
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.toast-success {
border-color: var(--success);
background-color: var(--surface);
color: var(--success);
}
.toast.toast-error {
border-color: var(--danger);
background-color: var(--surface);
color: var(--danger);
}
.toast.toast-info {
border-color: var(--border-strong);
}
.spinner {
width: 1rem;
height: 1rem;
animation: spin 1s linear infinite;
}
.spinner.hidden {
display: none;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+370 -297
View File
@@ -1,148 +1,56 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Silksoup — Novel to EPUB</title> <title>Silksoup: Novel to EPUB</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="icon" type="image/png" href="/static/icon.png"> <link rel="icon" type="image/png" href="/static/icon.png">
<link rel="apple-touch-icon" href="/static/icon.png"> <link rel="apple-touch-icon" href="/static/icon.png">
<script> <link rel="stylesheet" href="/static/app.css">
(function() {
const saved = localStorage.getItem('silksoup_theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
theme: {
bg: 'var(--bg)',
surface: 'var(--surface)',
subtle: 'var(--surface-subtle)',
border: 'var(--border)',
'border-strong': 'var(--border-strong)',
primary: 'var(--primary)',
'primary-hover': 'var(--primary-hover)',
'primary-fg': 'var(--primary-fg)',
secondary: 'var(--secondary)',
text: 'var(--text)',
muted: 'var(--text-muted)',
danger: 'var(--danger)',
'danger-bg': 'var(--danger-bg)',
'danger-border': 'var(--danger-border)',
}
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif']
}
}
}
}
</script>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-subtle: #f4f4f5;
--border: #e4e4e7;
--border-strong: #d4d4d8;
--primary: #18181b;
--primary-hover: #27272a;
--primary-fg: #ffffff;
--secondary: #71717a;
--text: #09090b;
--text-muted: #71717a;
--danger: #dc2626;
--danger-bg: #fef2f2;
--danger-border: #fecaca;
--log-bg: #09090b;
--log-border: #27272a;
--log-text: #f4f4f5;
}
html.dark {
--bg: #09090b;
--surface: #121215;
--surface-subtle: #1c1c21;
--border: #27272a;
--border-strong: #3f3f46;
--primary: #fafafa;
--primary-hover: #f4f4f5;
--primary-fg: #09090b;
--secondary: #a1a1aa;
--text: #fafafa;
--text-muted: #a1a1aa;
--danger: #f87171;
--danger-bg: #2b1114;
--danger-border: #4c1d24;
--log-bg: #000000;
--log-border: #27272a;
--log-text: #f4f4f5;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
</style>
</head> </head>
<body class="bg-theme-bg text-theme-text min-h-screen flex flex-col font-sans antialiased selection:bg-zinc-800 selection:text-white dark:selection:bg-zinc-200 dark:selection:text-zinc-900"> <body>
<header class="border-b border-theme-border bg-theme-surface sticky top-0 z-30"> <header class="app-header">
<div class="max-w-4xl mx-auto px-4 py-3.5 flex items-center justify-between"> <div class="header-inner">
<div class="flex items-center gap-3"> <div class="brand-wrap">
<img src="/static/icon.png" alt="Silksoup" class="w-8 h-8 rounded-md object-contain shadow-sm"> <img src="/static/icon.png" alt="Silksoup" class="brand-icon">
<div> <div>
<div class="flex items-center gap-2"> <div class="brand-title-line">
<span class="font-semibold text-base leading-none text-theme-text">Silksoup</span> <span class="brand-title">Silksoup</span>
<span id="headerBadge" class="hidden text-xs font-medium px-2 py-0.5 rounded bg-emerald-50 dark:bg-emerald-950/60 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/80">Active</span> <span id="headerBadge" class="header-badge hidden">Active</span>
</div> </div>
<p class="text-sm text-theme-muted mt-0.5">Novel to EPUB pipeline</p> <p class="brand-subtitle">Novel to EPUB pipeline</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2.5"> <div class="header-actions">
<button id="themeToggleBtn" onclick="toggleTheme()" type="button" aria-label="Toggle theme" class="text-sm text-theme-muted hover:text-theme-text px-3 py-1.5 rounded-md border border-theme-border hover:bg-theme-subtle hover:border-theme-border-strong transition flex items-center gap-1.5"> <button id="notifyBtn" onclick="toggleNotifications()" type="button" aria-label="Desktop notifications" class="btn-header">
<span id="themeIcon" class="w-4 h-4 flex items-center justify-center"></span> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<span id="themeText" class="hidden sm:inline">Theme</span>
</button>
<button id="notifyBtn" onclick="toggleNotifications()" type="button" aria-label="Desktop notifications" class="text-sm text-theme-muted hover:text-theme-text px-3 py-1.5 rounded-md border border-theme-border hover:bg-theme-subtle hover:border-theme-border-strong transition flex items-center gap-1.5">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/> <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg> </svg>
<span id="notifyText" class="hidden sm:inline">Notify</span> <span id="notifyText">Notify</span>
</button> </button>
<a href="/api/epubs" target="_blank" class="text-sm text-theme-muted hover:text-theme-text px-3 py-1.5 rounded-md border border-theme-border hover:bg-theme-subtle hover:border-theme-border-strong transition flex items-center gap-1.5"> <a href="/api/epubs" target="_blank" rel="noopener" class="btn-header">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"/> <polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/> <polyline points="8 6 2 12 8 18"/>
</svg> </svg>
<span class="hidden sm:inline">API</span> <span>API</span>
</a> </a>
</div> </div>
</div> </div>
</header> </header>
<main class="max-w-4xl mx-auto w-full px-4 py-6 flex flex-col gap-6 flex-1"> <main class="app-main">
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm"> <section class="card" aria-labelledby="headingScrape">
<div class="flex items-center justify-between mb-3"> <h2 id="headingScrape" class="card-title">Scrape Novel</h2>
<h2 class="text-base font-semibold text-theme-text">Scrape Novel</h2> <form id="jobForm">
</div> <div class="form-group">
<form id="jobForm" class="space-y-4" onsubmit="return false"> <label for="urlInput" class="form-label">Novel URL</label>
<div> <div class="input-row">
<label for="urlInput" class="block text-sm font-medium text-theme-text mb-1.5">Novel URL</label> <input id="urlInput" type="url" required placeholder="https://kakuyomu.jp/works/... or novel index URL" class="input-text">
<div class="flex gap-2"> <button type="button" onclick="pasteClipboard()" class="btn-paste">
<input id="urlInput" type="url" required placeholder="https://kakuyomu.jp/works/... or novel index URL" class="flex-1 rounded-md bg-theme-subtle border border-theme-border hover:border-theme-border-strong focus:border-theme-primary px-3.5 py-2.5 text-base text-theme-text placeholder:text-theme-muted/60 focus:outline-none focus:ring-1 focus:ring-theme-primary transition"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<button type="button" onclick="pasteClipboard()" class="px-3.5 py-2.5 text-sm font-medium rounded-md border border-theme-border text-theme-muted hover:text-theme-text hover:bg-theme-subtle hover:border-theme-border-strong transition shrink-0 flex items-center gap-1.5">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/> <rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
</svg> </svg>
@@ -150,37 +58,49 @@ html.dark {
</button> </button>
</div> </div>
</div> </div>
<div> <div class="form-group">
<span class="block text-sm font-medium text-theme-text mb-1.5">Cache Mode</span> <span class="form-label">Cache Mode</span>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5"> <div class="cache-grid" role="radiogroup" aria-label="Cache Mode">
<label class="flex items-start gap-3 p-3.5 rounded-md border border-theme-border bg-theme-subtle/50 hover:border-theme-border-strong cursor-pointer select-none transition"> <label class="cache-card">
<input type="radio" name="refreshMode" value="none" checked class="mt-0.5 text-theme-primary border-theme-border focus:ring-0"> <input type="radio" name="refreshMode" value="none" checked>
<div class="flex flex-col"> <div>
<span class="text-sm font-medium text-theme-text">Full Cache</span> <div class="cache-card-title">Full Cache</div>
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML and parsed results</span> <div class="cache-card-desc">Reuse HTML and AI results</div>
</div> </div>
</label> </label>
<label class="flex items-start gap-3 p-3.5 rounded-md border border-theme-border bg-theme-subtle/50 hover:border-theme-border-strong cursor-pointer select-none transition"> <label class="cache-card">
<input type="radio" name="refreshMode" value="llm" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0"> <input type="radio" name="refreshMode" value="llm">
<div class="flex flex-col"> <div>
<span class="text-sm font-medium text-theme-text">Re-run AI</span> <div class="cache-card-title">Re-run AI</div>
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML, re-sanitize text</span> <div class="cache-card-desc">Reuse HTML, re-sanitize chapters</div>
</div> </div>
</label> </label>
<label class="flex items-start gap-3 p-3.5 rounded-md border border-theme-border bg-theme-subtle/50 hover:border-theme-border-strong cursor-pointer select-none transition"> <label class="cache-card">
<input type="radio" name="refreshMode" value="all" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0"> <input type="radio" name="refreshMode" value="all">
<div class="flex flex-col"> <div>
<span class="text-sm font-medium text-theme-text">Full Refresh</span> <div class="cache-card-title">Full Refresh</div>
<span class="text-xs text-theme-muted mt-0.5">Bypass cache and fetch from web</span> <div class="cache-card-desc">Bypass cache and fetch fresh</div>
</div> </div>
</label> </label>
</div> </div>
</div> </div>
<div class="flex justify-end pt-1"> <div class="form-group">
<button id="startBtn" type="button" class="w-full sm:w-auto rounded-md bg-theme-primary hover:bg-theme-primary-hover text-theme-primary-fg text-sm font-medium px-5 py-2.5 transition flex items-center justify-center gap-2 shadow-sm"> <label class="toggle-card">
<svg id="btnSpinner" class="hidden w-4 h-4 animate-spin text-current" viewBox="0 0 24 24" fill="none"> <input id="fixCheck" type="checkbox">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <div>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path> <div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="cache-card-title">Polished Edition (Automated Post-Editing)</span>
<span class="badge-ape">APE</span>
</div>
<div class="cache-card-desc">Repair machine translation phrasing, pronoun confusion, and format translator notes</div>
</div>
</label>
</div>
<div class="form-actions">
<button id="startBtn" type="submit" class="btn-primary">
<svg id="btnSpinner" class="spinner hidden" viewBox="0 0 24 24" fill="none">
<circle opacity="0.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path opacity="0.75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg> </svg>
<span id="btnText">Start Scraping</span> <span id="btnText">Start Scraping</span>
</button> </button>
@@ -188,106 +108,109 @@ html.dark {
</form> </form>
</section> </section>
<section id="activeJob" class="hidden rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm space-y-4"> <section id="activeJob" class="card monitor-section hidden" aria-labelledby="headingMonitor" role="region">
<div class="flex items-center justify-between"> <div class="monitor-header">
<div class="flex items-center gap-2"> <div class="monitor-title-group">
<span id="statusPulse" class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span> <span id="statusPulse" class="status-dot dot-emerald"></span>
<h2 class="text-base font-semibold text-theme-text">Task Monitor</h2> <h2 id="headingMonitor" class="card-title" style="margin-bottom: 0;">Task Monitor</h2>
</div> </div>
<div class="flex items-center gap-2"> <div class="monitor-actions">
<button id="cancelJobBtn" type="button" onclick="requestCancelJob()" class="text-sm px-3 py-1 rounded-md border border-theme-danger-border bg-theme-danger-bg text-theme-danger hover:opacity-90 transition font-medium">Stop Process</button> <button id="cancelJobBtn" type="button" onclick="requestCancelJob()" class="btn-stop">Stop Process</button>
<span id="jobStatusBadge" class="text-xs font-mono uppercase px-2.5 py-0.5 rounded border border-theme-border bg-theme-subtle text-theme-text font-medium">idle</span> <span id="jobStatusBadge" class="badge-status">idle</span>
</div> </div>
</div> </div>
<div class="space-y-3"> <div>
<div class="flex items-center justify-between text-sm gap-3"> <div class="stage-row">
<div class="flex items-center gap-2 min-w-0"> <div style="display: flex; align-items: center; gap: 0.5rem; min-width: 0;">
<span id="jobStage" class="px-2 py-0.5 rounded bg-theme-subtle border border-theme-border text-theme-text font-mono text-xs uppercase font-medium">init</span> <span id="jobStage" class="stage-pill">init</span>
<span id="jobMessage" class="text-theme-text truncate font-medium">Starting...</span> <span id="jobMessage" class="stage-message">Starting...</span>
</div> </div>
<span id="jobCounter" class="text-theme-muted font-mono tabular-nums shrink-0 font-medium">0 / 0</span> <span id="jobCounter" class="stage-counter">0 / 0</span>
</div> </div>
<div class="h-2 rounded bg-theme-subtle border border-theme-border overflow-hidden"> <div class="progress-track" role="progressbar" id="progressTrack" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="jobProgress" class="h-full bg-theme-primary transition-all duration-200" style="width: 0%"></div> <div id="jobProgress" class="progress-fill"></div>
</div> </div>
<div class="flex items-center justify-between text-sm text-theme-muted pt-1"> <div class="log-meta-row">
<span>Execution Log</span> <span>Execution Log</span>
<div class="flex items-center gap-3"> <div class="log-actions">
<label class="flex items-center gap-1.5 cursor-pointer select-none"> <label style="display: inline-flex; align-items: center; gap: 0.375rem; cursor: pointer; user-select: none;">
<input id="autoScrollCheck" type="checkbox" checked class="rounded border-theme-border bg-theme-subtle text-theme-primary focus:ring-0"> <input id="autoScrollCheck" type="checkbox" checked style="accent-color: var(--primary);">
<span>Auto-scroll</span> <span>Auto-scroll</span>
</label> </label>
<button type="button" onclick="clearLogs()" class="hover:text-theme-text transition">Clear</button> <button type="button" onclick="clearLogs()" class="log-action-btn">Clear</button>
<button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="hover:text-theme-text transition">Expand</button> <button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="log-action-btn">Expand</button>
</div> </div>
</div> </div>
<div id="jobLog" class="h-44 overflow-auto rounded-md bg-[var(--log-bg)] border border-[var(--log-border)] p-3.5 font-mono text-sm text-[var(--log-text)] whitespace-pre-wrap leading-relaxed"></div> <div id="jobLog" class="log-box" role="log" aria-live="polite"></div>
<div id="jobResult" class="hidden pt-2 flex items-center gap-2"> <div id="jobResult" class="result-bar hidden">
<a id="downloadLink" href="#" class="inline-flex items-center gap-2 rounded-md bg-theme-primary hover:bg-theme-primary-hover text-theme-primary-fg text-sm font-medium px-4 py-2.5 transition shadow-sm"> <a id="downloadLink" href="#" class="btn-primary" style="text-decoration: none;">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/> <polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/> <line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
<span>Download EPUB</span> <span>Download EPUB</span>
</a> </a>
<button type="button" onclick="dismissActiveJob()" class="text-sm text-theme-muted hover:text-theme-text px-3.5 py-2.5 rounded-md border border-theme-border hover:bg-theme-subtle transition">Dismiss</button> <button type="button" onclick="dismissActiveJob()" class="btn-secondary">Dismiss</button>
</div> </div>
</div> </div>
</section> </section>
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm"> <section class="card" aria-labelledby="headingLibrary">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4"> <div class="library-header">
<div class="flex items-center gap-2"> <div class="library-title-group">
<h2 class="text-base font-semibold text-theme-text">Library</h2> <h2 id="headingLibrary" class="card-title" style="margin-bottom: 0;">Library</h2>
<span id="epubCount" class="text-sm text-theme-muted font-mono font-medium"></span> <span id="epubCount" class="library-counter"></span>
</div> </div>
<div class="flex items-center gap-2"> <div class="library-controls">
<div class="relative flex-1 sm:w-64"> <div class="search-wrap">
<input id="searchEpub" type="text" oninput="filterEpubs()" placeholder="Filter books..." class="w-full rounded-md bg-theme-subtle border border-theme-border hover:border-theme-border-strong focus:border-theme-primary px-3.5 py-2 text-sm text-theme-text placeholder:text-theme-muted/60 focus:outline-none focus:ring-1 focus:ring-theme-primary transition"> <input id="searchEpub" type="text" oninput="filterEpubs()" placeholder="Filter books..." class="input-text" style="min-height: 2.25rem; font-size: 0.875rem;">
</div> </div>
<button type="button" onclick="loadEpubs()" class="text-sm px-3.5 py-2 rounded-md border border-theme-border text-theme-muted hover:text-theme-text hover:bg-theme-subtle transition shrink-0">Refresh</button> <button type="button" onclick="loadEpubs()" class="btn-header" style="min-height: 2.25rem;">Refresh</button>
</div> </div>
</div> </div>
<div id="epubList" class="space-y-2.5"></div> <div id="epubList" class="book-shelf"></div>
<div id="epubEmpty" class="text-sm text-theme-muted py-8 text-center hidden"> <div id="epubEmpty" class="empty-state hidden">
No EPUBs found. Scrape a novel to populate your library. No EPUBs found. Scrape a novel to populate your library.
</div> </div>
</section> </section>
<section class="rounded-lg border border-theme-border bg-theme-surface p-5"> <details class="guide-details">
<h3 class="text-sm font-semibold text-theme-text mb-3">Workflow Pipeline</h3> <summary class="guide-summary">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 text-xs text-theme-muted"> <span>Workflow Pipeline Guide</span>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <span style="font-size: 0.75rem; color: var(--text-muted);">Details</span>
<span class="text-sm font-medium text-theme-text block mb-1">1. Ingest & Cache</span> </summary>
Fetches novel HTML via anti-bot client into database. <div class="guide-grid">
<div class="guide-step">
<div class="guide-step-title">1. Ingest & Cache</div>
Fetches novel HTML via anti-bot client into local database.
</div> </div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <div class="guide-step">
<span class="text-sm font-medium text-theme-text block mb-1">2. Parse Index</span> <div class="guide-step-title">2. Parse Index</div>
LLM extracts chronological volumes and chapter URLs. LLM extracts chronological volumes and chapter URLs.
</div> </div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <div class="guide-step">
<span class="text-sm font-medium text-theme-text block mb-1">3. Sanitize Content</span> <div class="guide-step-title">3. Sanitize & APE</div>
Cleans site bloat and structures semantic chapter content. Fixes machine translation flaws, entities, and formats notes into blockquotes.
</div> </div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <div class="guide-step">
<span class="text-sm font-medium text-theme-text block mb-1">4. Build EPUB</span> <div class="guide-step-title">4. Build EPUB</div>
Downloads images, compresses assets, and compiles book. Downloads images, compresses assets, and compiles book.
</div> </div>
</div> </div>
</section> </details>
</main> </main>
<div id="toastContainer" class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div> <div id="toastContainer" class="toast-container" role="region" aria-live="polite"></div>
<footer class="border-t border-theme-border py-4 text-center text-sm text-theme-muted"> <footer class="app-footer">
Silksoup — Novel to EPUB Pipeline Silksoup: Novel to EPUB Pipeline
</footer> </footer>
<script> <script>
@@ -297,24 +220,9 @@ let evtSource = null;
let allEpubs = []; let allEpubs = [];
let originalTitle = document.title; let originalTitle = document.title;
const sunSvg = `<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>`;
const moonSvg = `<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`;
function updateThemeUI() {
const isDark = document.documentElement.classList.contains('dark');
$('#themeIcon').innerHTML = isDark ? sunSvg : moonSvg;
$('#themeText').textContent = isDark ? 'Light' : 'Dark';
}
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('silksoup_theme', isDark ? 'dark' : 'light');
updateThemeUI();
}
function updateNotifyButton() { function updateNotifyButton() {
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
$('#notifyBtn').classList.add('text-emerald-700', 'dark:text-emerald-400', 'border-emerald-300', 'dark:border-emerald-800'); $('#notifyBtn').classList.add('active');
$('#notifyText').textContent = 'Notifications on'; $('#notifyText').textContent = 'Notifications on';
} }
} }
@@ -327,9 +235,9 @@ async function toggleNotifications() {
updateNotifyButton(); updateNotifyButton();
} }
} else if (Notification.permission === 'denied') { } else if (Notification.permission === 'denied') {
showToast('Notifications are blocked in browser settings', 'error'); showToast('Notifications blocked in browser settings', 'error');
} else { } else {
showToast('Notifications are already enabled', 'info'); showToast('Notifications already enabled', 'info');
} }
} }
@@ -342,17 +250,16 @@ function notify(title, body) {
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
const container = $('#toastContainer'); const container = $('#toastContainer');
const toast = document.createElement('div'); const toast = document.createElement('div');
const borderClass = type === 'error' ? 'border-theme-danger-border bg-theme-danger-bg text-theme-danger' : const typeClass = type === 'error' ? 'toast-error' :
type === 'success' ? 'border-emerald-300 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950 text-emerald-900 dark:text-emerald-200' : type === 'success' ? 'toast-success' : 'toast-info';
'border-theme-border bg-theme-surface text-theme-text'; toast.className = `toast ${typeClass}`;
toast.className = `pointer-events-auto rounded-md border px-3.5 py-2.5 text-sm shadow-md flex items-center gap-2.5 transition-opacity duration-150 opacity-0 ${borderClass}`;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
requestAnimationFrame(() => { requestAnimationFrame(() => {
toast.classList.remove('opacity-0'); toast.classList.add('show');
}); });
setTimeout(() => { setTimeout(() => {
toast.classList.add('opacity-0'); toast.classList.remove('show');
setTimeout(() => toast.remove(), 150); setTimeout(() => toast.remove(), 150);
}, 4000); }, 4000);
} }
@@ -384,7 +291,15 @@ function setBtnLoading(loading) {
} }
} }
$('#startBtn').addEventListener('click', async () => { function setFormLocked(locked) {
$('#urlInput').disabled = locked;
document.querySelectorAll('input[name="refreshMode"]').forEach(r => r.disabled = locked);
const fixCheck = $('#fixCheck');
if (fixCheck) fixCheck.disabled = locked;
$('#startBtn').disabled = locked;
}
async function submitJob() {
const url = $('#urlInput').value.trim(); const url = $('#urlInput').value.trim();
if (!url) { if (!url) {
showToast('Please enter a novel URL', 'error'); showToast('Please enter a novel URL', 'error');
@@ -394,11 +309,18 @@ $('#startBtn').addEventListener('click', async () => {
try { try {
const selectedMode = document.querySelector('input[name="refreshMode"]:checked'); const selectedMode = document.querySelector('input[name="refreshMode"]:checked');
const refresh_mode = selectedMode ? selectedMode.value : 'none'; const refresh_mode = selectedMode ? selectedMode.value : 'none';
const fix = $('#fixCheck') ? $('#fixCheck').checked : false;
const res = await fetch('/api/jobs', { const res = await fetch('/api/jobs', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ url, refresh_mode }) body: JSON.stringify({ url, refresh_mode, fix })
}); });
if (res.status === 409) {
const j = await res.json();
showToast(j.detail || 'Another novel conversion is currently in progress', 'error');
await syncActiveJob();
return;
}
if (!res.ok) { if (!res.ok) {
const j = await res.json(); const j = await res.json();
throw new Error(j.detail || 'Failed to submit job'); throw new Error(j.detail || 'Failed to submit job');
@@ -414,19 +336,60 @@ $('#startBtn').addEventListener('click', async () => {
} finally { } finally {
setBtnLoading(false); setBtnLoading(false);
} }
}
$('#jobForm').addEventListener('submit', (e) => {
e.preventDefault();
submitJob();
}); });
function showActive() { function showActive(jobData = null) {
setFormLocked(true);
$('#activeJob').classList.remove('hidden'); $('#activeJob').classList.remove('hidden');
$('#headerBadge').classList.remove('hidden'); $('#headerBadge').classList.remove('hidden');
$('#cancelJobBtn').classList.remove('hidden'); $('#cancelJobBtn').classList.remove('hidden');
$('#jobLog').textContent = ''; if (!jobData) {
$('#jobProgress').style.width = '0%'; $('#jobLog').textContent = '';
$('#jobCounter').textContent = '0 / 0'; $('#jobProgress').style.width = '0%';
$('#jobStage').textContent = 'queued'; $('#progressTrack').setAttribute('aria-valuenow', '0');
$('#jobStatusBadge').textContent = 'running'; $('#jobCounter').textContent = '0 / 0';
$('#jobResult').classList.add('hidden'); $('#jobStage').textContent = 'queued';
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-emerald-500'; $('#jobStatusBadge').textContent = 'running';
$('#jobResult').classList.add('hidden');
$('#statusPulse').className = 'status-dot dot-emerald';
} else {
$('#jobStage').textContent = jobData.stage || 'running';
$('#jobMessage').textContent = jobData.message || '';
$('#jobCounter').textContent = `${jobData.current || 0} / ${jobData.total || 0}`;
const pct = jobData.percentage != null ? jobData.percentage :
(jobData.total ? Math.round((jobData.current / jobData.total) * 100) : 0);
$('#jobProgress').style.width = pct + '%';
$('#progressTrack').setAttribute('aria-valuenow', String(pct));
$('#jobStatusBadge').textContent = jobData.status || 'running';
$('#statusPulse').className = 'status-dot dot-emerald';
if (jobData.logs && jobData.logs.length) {
$('#jobLog').textContent = jobData.logs.join('\n') + '\n';
$('#jobLog').scrollTop = $('#jobLog').scrollHeight;
}
if (jobData.status === 'completed') {
setFormLocked(false);
$('#cancelJobBtn').classList.add('hidden');
if (jobData.result_path) {
const name = jobData.result_path.split(/[\\/]/).pop();
$('#downloadLink').href = `/api/epubs/${encodeURIComponent(name)}`;
$('#downloadLink').querySelector('span').textContent = `Download ${name}`;
$('#jobResult').classList.remove('hidden');
}
} else if (jobData.status === 'cancelled' || jobData.status === 'failed') {
setFormLocked(false);
$('#cancelJobBtn').classList.add('hidden');
if (jobData.status === 'cancelled') {
$('#statusPulse').className = 'status-dot dot-amber';
} else {
$('#statusPulse').className = 'status-dot dot-rose';
}
}
}
$('#activeJob').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); $('#activeJob').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
@@ -439,6 +402,7 @@ async function requestCancelJob() {
const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: 'POST' }); const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: 'POST' });
if (res.ok) { if (res.ok) {
showToast('Process cancellation requested', 'info'); showToast('Process cancellation requested', 'info');
setFormLocked(false);
} }
} catch(e) { } catch(e) {
showToast('Failed to cancel job', 'error'); showToast('Failed to cancel job', 'error');
@@ -454,6 +418,7 @@ function dismissActiveJob() {
if (evtSource) evtSource.close(); if (evtSource) evtSource.close();
$('#activeJob').classList.add('hidden'); $('#activeJob').classList.add('hidden');
$('#headerBadge').classList.add('hidden'); $('#headerBadge').classList.add('hidden');
setFormLocked(false);
document.title = originalTitle; document.title = originalTitle;
} }
@@ -468,6 +433,7 @@ function connectSSE(jobId) {
evtSource.close(); evtSource.close();
refreshJob(); refreshJob();
loadEpubs(); loadEpubs();
setFormLocked(false);
return; return;
} }
const pct = evt.total ? Math.round(evt.current / evt.total * 100) : 0; const pct = evt.total ? Math.round(evt.current / evt.total * 100) : 0;
@@ -475,30 +441,37 @@ function connectSSE(jobId) {
$('#jobMessage').textContent = evt.message || ''; $('#jobMessage').textContent = evt.message || '';
$('#jobCounter').textContent = `${evt.current} / ${evt.total}`; $('#jobCounter').textContent = `${evt.current} / ${evt.total}`;
$('#jobProgress').style.width = pct + '%'; $('#jobProgress').style.width = pct + '%';
$('#progressTrack').setAttribute('aria-valuenow', String(pct));
if (evt.stage === 'scraping' || evt.stage === 'cleaning' || evt.stage === 'epub') { if (evt.stage === 'scraping' || evt.stage === 'cleaning' || evt.stage === 'fixing' || evt.stage === 'epub') {
document.title = `(${pct}%) Silksoup`; document.title = `(${pct}%) Silksoup`;
} }
if (evt.stage === 'failed') { if (evt.stage === 'failed') {
$('#jobStatusBadge').textContent = 'failed'; $('#jobStatusBadge').textContent = 'failed';
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-rose-500'; $('#statusPulse').className = 'status-dot dot-rose';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
document.title = `Failed — Silksoup`; setFormLocked(false);
loadEpubs();
document.title = 'Failed: Silksoup';
showToast(`Job failed: ${evt.message}`, 'error'); showToast(`Job failed: ${evt.message}`, 'error');
} }
if (evt.stage === 'cancelled') { if (evt.stage === 'cancelled') {
$('#jobStatusBadge').textContent = 'cancelled'; $('#jobStatusBadge').textContent = 'cancelled';
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-amber-500'; $('#statusPulse').className = 'status-dot dot-amber';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
document.title = `Silksoup`; setFormLocked(false);
showToast('Scraping stopped. Completed chapters cached.', 'info'); loadEpubs();
document.title = 'Silksoup';
showToast('Process stopped. Cached chapters remain safe.', 'info');
} }
if (evt.stage === 'completed') { if (evt.stage === 'completed') {
$('#jobStatusBadge').textContent = 'completed'; $('#jobStatusBadge').textContent = 'completed';
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-emerald-500'; $('#statusPulse').className = 'status-dot dot-emerald';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
document.title = `Completed — Silksoup`; setFormLocked(false);
loadEpubs();
document.title = 'Completed: Silksoup';
notify('Silksoup EPUB Ready', evt.message || 'Book compiled successfully.'); notify('Silksoup EPUB Ready', evt.message || 'Book compiled successfully.');
showToast('EPUB compiled successfully', 'success'); showToast('EPUB compiled successfully', 'success');
} }
@@ -522,7 +495,7 @@ async function refreshJob() {
const j = await res.json(); const j = await res.json();
$('#jobStatusBadge').textContent = j.status; $('#jobStatusBadge').textContent = j.status;
if (j.status === 'completed') { if (j.status === 'completed') {
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-emerald-500'; $('#statusPulse').className = 'status-dot dot-emerald';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
if (j.result_path) { if (j.result_path) {
const name = j.result_path.split(/[\\/]/).pop(); const name = j.result_path.split(/[\\/]/).pop();
@@ -532,11 +505,11 @@ async function refreshJob() {
} }
} }
if (j.status === 'cancelled') { if (j.status === 'cancelled') {
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-amber-500'; $('#statusPulse').className = 'status-dot dot-amber';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
} }
if (j.status === 'failed') { if (j.status === 'failed') {
$('#statusPulse').className = 'w-2 h-2 rounded-full bg-rose-500'; $('#statusPulse').className = 'status-dot dot-rose';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
$('#jobLog').textContent += `\n[ERROR] ${j.error || j.message}\n`; $('#jobLog').textContent += `\n[ERROR] ${j.error || j.message}\n`;
} }
@@ -551,13 +524,11 @@ function toggleLogHeight() {
const log = $('#jobLog'); const log = $('#jobLog');
const btn = $('#expandLogBtn'); const btn = $('#expandLogBtn');
if (isLogExpanded) { if (isLogExpanded) {
log.classList.remove('h-96'); log.classList.remove('expanded');
log.classList.add('h-40');
btn.textContent = 'Expand'; btn.textContent = 'Expand';
isLogExpanded = false; isLogExpanded = false;
} else { } else {
log.classList.remove('h-40'); log.classList.add('expanded');
log.classList.add('h-96');
btn.textContent = 'Collapse'; btn.textContent = 'Collapse';
isLogExpanded = true; isLogExpanded = true;
} }
@@ -582,44 +553,125 @@ function filterEpubs() {
return; return;
} }
const filtered = allEpubs.filter(f => f.name.toLowerCase().includes(query)); const filtered = allEpubs.filter(f => f.name.toLowerCase().includes(query));
renderEpubs(filtered); renderEpubs(filtered, true);
} }
function renderEpubs(list) { async function triggerFix(encodedName) {
const name = decodeURIComponent(encodedName);
try {
const res = await fetch(`/api/epubs/${encodeURIComponent(name)}/fix`, { method: 'POST' });
if (res.status === 409) {
const j = await res.json();
showToast(j.detail || 'Another novel conversion is currently in progress', 'error');
await syncActiveJob();
return;
}
if (!res.ok) {
const j = await res.json();
throw new Error(j.detail || 'Failed to start fix job');
}
const data = await res.json();
currentJobId = data.job_id;
localStorage.setItem('silksoup_active_job', currentJobId);
showActive();
connectSSE(currentJobId);
showToast('Fix process started for ' + name, 'success');
} catch(e) {
showToast(e.message, 'error');
}
}
function renderEpubs(list, isFiltered = false) {
const container = $('#epubList'); const container = $('#epubList');
container.innerHTML = ''; container.innerHTML = '';
if (!list.length) { if (!list.length) {
$('#epubEmpty').textContent = isFiltered ?
'No books match your search filter.' :
'No EPUBs found. Scrape a novel to populate your library.';
$('#epubEmpty').classList.remove('hidden'); $('#epubEmpty').classList.remove('hidden');
return; return;
} }
$('#epubEmpty').classList.add('hidden'); $('#epubEmpty').classList.add('hidden');
const groups = {};
for (const f of list) { for (const f of list) {
const row = document.createElement('div'); const key = f.base_key || f.name.replace(/(-fixed)?\.epub$/i, '').trim().toLowerCase();
row.className = 'flex items-center justify-between gap-3.5 rounded-md border border-theme-border bg-theme-subtle/50 px-4 py-3 hover:border-theme-border-strong transition'; if (!groups[key]) groups[key] = { regular: null, fixed: null };
if (f.is_fixed) {
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' '); groups[key].fixed = f;
} else {
groups[key].regular = f;
}
}
row.innerHTML = ` for (const key of Object.keys(groups)) {
<div class="flex items-center gap-3 min-w-0 flex-1"> const g = groups[key];
<svg class="w-4 h-4 text-theme-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> const groupCard = document.createElement('div');
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/> groupCard.className = 'book-card';
<path d="M6 6h10"/>
<path d="M6 10h10"/> if (g.regular) {
</svg> const f = g.regular;
<div class="min-w-0 flex-1"> const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
<div class="truncate text-sm font-medium text-theme-text capitalize" title="${cleanTitle}">${cleanTitle}</div> const row = document.createElement('div');
<div class="text-xs text-theme-muted font-mono mt-0.5">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div> row.className = 'book-edition-row';
row.innerHTML = `
<div class="book-info">
<svg class="book-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/>
<path d="M6 6h10"/>
<path d="M6 10h10"/>
</svg>
<div class="book-meta-block">
<div class="book-title-line">
<span class="book-title" title="${cleanTitle}">${cleanTitle}</span>
</div>
<div class="book-specs">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div>
</div>
</div> </div>
</div> <div class="book-actions">
<div class="flex items-center gap-2 shrink-0"> <a href="/api/epubs/${encodeURIComponent(f.name)}" download class="btn-book-action btn-book-download">Download</a>
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="text-sm px-3.5 py-1.5 rounded-md bg-theme-primary hover:bg-theme-primary-hover text-theme-primary-fg font-medium transition shadow-sm">Download</a> <button type="button" onclick="triggerFix('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-fix">Fix</button>
<div class="relative inline-block" id="delWrap_${CSS.escape(f.name)}"> <div class="delete-wrap" id="delWrap_${CSS.escape(f.name)}">
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="text-sm px-3 py-1.5 rounded-md border border-theme-border hover:border-theme-danger-border text-theme-muted hover:text-theme-danger transition">Delete</button> <button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-delete">Delete</button>
</div>
</div> </div>
</div> `;
`; groupCard.appendChild(row);
container.appendChild(row); }
if (g.fixed) {
const f = g.fixed;
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
const row = document.createElement('div');
row.className = 'book-edition-row fixed-edition';
row.innerHTML = `
<div class="book-info">
<svg class="book-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/>
<path d="M6 6h10"/>
<path d="M6 10h10"/>
</svg>
<div class="book-meta-block">
<div class="book-title-line">
<span class="book-title" title="${cleanTitle}">${cleanTitle}</span>
<span class="badge-fixed">Polished Edition</span>
</div>
<div class="book-specs">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div>
</div>
</div>
<div class="book-actions">
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="btn-book-action btn-book-download">Download</a>
<div class="delete-wrap" id="delWrap_${CSS.escape(f.name)}">
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-delete">Delete</button>
</div>
</div>
`;
groupCard.appendChild(row);
}
container.appendChild(groupCard);
} }
} }
@@ -628,13 +680,22 @@ function confirmDelete(encodedName) {
const wrap = document.getElementById(`delWrap_${CSS.escape(name)}`); const wrap = document.getElementById(`delWrap_${CSS.escape(name)}`);
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = ` wrap.innerHTML = `
<div class="flex items-center gap-1.5 bg-theme-danger-bg border border-theme-danger-border rounded-md p-1"> <div class="delete-confirm-box">
<button type="button" onclick="executeDelete('${encodeURIComponent(name)}')" class="text-xs px-2.5 py-1 bg-rose-600 text-white rounded font-medium hover:bg-rose-500">Confirm</button> <button type="button" onclick="executeDelete('${encodeURIComponent(name)}')" class="btn-confirm-delete">Confirm</button>
<button type="button" onclick="loadEpubs()" class="text-xs px-2.5 py-1 text-theme-muted hover:text-theme-text">Cancel</button> <button type="button" onclick="loadEpubs()" class="btn-cancel-delete">Cancel</button>
</div> </div>
`; `;
} }
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const confirmBoxes = document.querySelectorAll('.delete-confirm-box');
if (confirmBoxes.length > 0) {
loadEpubs();
}
}
});
async function executeDelete(encodedName) { async function executeDelete(encodedName) {
try { try {
const res = await fetch(`/api/epubs/${encodedName}`, { method: 'DELETE' }); const res = await fetch(`/api/epubs/${encodedName}`, { method: 'DELETE' });
@@ -646,37 +707,49 @@ async function executeDelete(encodedName) {
} }
} }
async function checkStoredJob() { async function syncActiveJob() {
if (!currentJobId) return;
try { try {
const res = await fetch(`/api/jobs/${currentJobId}`); const res = await fetch('/api/jobs/active');
if (res.ok) { if (res.ok) {
const j = await res.json(); const data = await res.json();
showActive(); if (data.active && data.job) {
$('#jobStage').textContent = j.stage; currentJobId = data.job.id;
$('#jobMessage').textContent = j.message || ''; localStorage.setItem('silksoup_active_job', currentJobId);
$('#jobCounter').textContent = `${j.current} / ${j.total}`; showActive(data.job);
const pct = j.total ? Math.round(j.current / j.total * 100) : 0;
$('#jobProgress').style.width = pct + '%';
if (j.logs) {
$('#jobLog').textContent = j.logs.join('\n') + '\n';
}
if (j.status === 'running' || j.status === 'queued') {
connectSSE(currentJobId); connectSSE(currentJobId);
} else { return;
refreshJob();
} }
} else {
localStorage.removeItem('silksoup_active_job');
} }
} catch(e) { } catch(e) {}
localStorage.removeItem('silksoup_active_job');
if (currentJobId) {
try {
const res = await fetch(`/api/jobs/${currentJobId}`);
if (res.ok) {
const j = await res.json();
if (j.status === 'running' || j.status === 'queued') {
showActive(j);
connectSSE(currentJobId);
} else {
showActive(j);
refreshJob();
setFormLocked(false);
}
} else {
localStorage.removeItem('silksoup_active_job');
setFormLocked(false);
}
} catch(e) {
localStorage.removeItem('silksoup_active_job');
setFormLocked(false);
}
} else {
setFormLocked(false);
} }
} }
updateThemeUI();
updateNotifyButton(); updateNotifyButton();
checkStoredJob(); syncActiveJob();
loadEpubs(); loadEpubs();
</script> </script>
</body> </body>