Compare commits

..
4 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
7 changed files with 1663 additions and 477 deletions
-189
View File
@@ -1,189 +0,0 @@
# Silksoup MTL Automated Post-Editing (APE) Engine & Enhancement Plan
## 1. Overview & Objectives
Enhance Silksoup with an Automated Post-Editing (APE) pipeline designed to repair Machine Translation (MTL) quirks, typos, dropped pronouns, and inconsistent named entities across multi-chapter web novels.
### Key Goals
1. **Context-Aware Consistency**: Maintain character names, genders, speech cadences, and world terms across hundreds of chapters.
2. **Title Deduplication**: Ensure chapter titles appear exactly once as the canonical `<h1>` and are never duplicated into chapter body paragraphs.
3. **Strict Translator Note Isolation**: Detect translator commentary, cultural notes, and glossary inserts and wrap them strictly in semantic `<blockquote><p><strong>TL Note:</strong> ...</p></blockquote>`.
4. **Token & Cost Efficiency**: Exploit DeepSeek's automatic prefix KV-caching (up to 90% input token discount) and entity-filtered chunking.
5. **Dual-File Library Model**: Keep regular and fixed versions distinct (`<slug>.epub` vs. `<slug>-fixed.epub`), displayed in the UI as stacked 0-margin paired entries with dark azure/blue styling for fixed editions.
---
## 2. Context Management Architecture
### A. Global Lore Sheet (`lore_json`)
- **Storage**: Persistent in DB (`novel_lore` table) and cached in-memory per novel.
- **Initialization**: Automatically extracted via LLM from the novel's synopsis and first 2–3 chapters on the first run.
- **Structure**:
```json
{
"characters": [
{
"raw_name": "アルス",
"mtl_variants": ["Arus", "Ars", "Arce"],
"standard_name": "Ars",
"gender": "male",
"role": "Protagonist",
"speech_style": "Casual, direct, slightly sarcastic"
}
],
"terms": {
"魔力回路": "Mana Circuit",
"黒の騎士団": "Black Knights"
},
"rules": {
"keep_honorifics": false,
"pov": "Third person limited"
}
}
```
- **Chunk-Level Filtering**: Before sending each chunk to the LLM, only entities/terms present in the raw chunk text are injected into the active prompt.
### B. Rolling Scene Context (`rolling_summary_json`)
- **Storage**: JSON updated after each chapter completion.
- **Structure**:
```json
{
"recent_chapters": [
{
"chapter_index": 14,
"title": "Chapter 14: The Descent",
"summary": "Ars and Elena entered dungeon level 3. Elena suffered a minor leg injury; they found a temporary safe alcove."
},
{
"chapter_index": 15,
"title": "Chapter 15: Safe Haven",
"summary": "Ars applies basic first aid to Elena. They discuss their next route towards the teleport gate."
}
]
}
```
- **Token Bound**: Kept strictly under 150 tokens per summary to minimize overhead.
---
## 3. Chapter Processing & Text Chunker
### A. Paragraph-Boundary Chunker
- Splits sanitized chapter text at clean paragraph boundaries (`\n\n` or `</p>`).
- Target chunk size: **700–1,000 words** (~1,000–1,300 tokens).
- Preserves all embedded `<img src="...">` and `<hr />` markers.
### B. Single Title Guarantee
- During chunk 1 pre-processing, matching chapter title strings, leading `<h1>`/`<h2>`, or Markdown `# Chapter Title` lines are stripped from the body content.
- `epub_creator.py` injects the single authoritative `<h1>{chapter_name}</h1>` at the top of the XHTML chapter.
### C. Translator Notes Formatting
- Translator notes are identified during APE and output strictly as:
```html
<blockquote class="tl-note">
<p><strong>TL Note:</strong> Explanation or cultural context.</p>
</blockquote>
```
- Styled in `style/nav.css` with a subtle border and indent.
---
## 4. DeepSeek Prompt & Prefix-Caching Optimization
### A. System Prompt Design
To maximize DeepSeek prefix caching, the static system prompt remains byte-for-byte identical across all calls:
```
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 in the raw MTL text.
2. FIDELITY-PRESERVING STYLISTIC POLISHING:
- Eliminate awkward literal idioms, stiff sentence structures, repetitive word choices, and robotic cadence.
- Fix dropped or inverted pronouns based on active characters in the scene context.
- Smooth dialogue to match character voice defined in the Active Glossary.
3. GLOSSARY & ENTITY ENFORCEMENT:
- Always strictly honor the provided Active Glossary. Do not translate proper names literally if a standardized equivalent is specified.
4. TRANSLATOR NOTES:
- Format any translator/editor notes strictly as: <blockquote class="tl-note"><p><strong>TL Note:</strong> ...</p></blockquote>
5. OUTPUT SPECIFICATION:
- Output ONLY the polished chapter prose wrapped in standard <p>...</p> tags.
- Preserve <img> and <hr /> tags.
- Do NOT duplicate the chapter title at the beginning.
- Do NOT include markdown code block wrappers (```html) or introductory conversational filler.
```
### B. Temperature & Tokens
- `temperature`: `0.15` (prevents hallucination and ensures entity fidelity).
- `max_tokens`: `4096` per chunk.
---
## 5. Storage & Database Schema
### Updated Tables (`src/database.py`)
```sql
-- Chapter page with fixed text cache
CREATE TABLE IF NOT EXISTS chapter_page (
url TEXT PRIMARY KEY,
status_code INTEGER,
html_text TEXT,
clean_html_text TEXT,
fixed_html_text TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Global Lore and Context storage
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
);
```
---
## 6. File Naming & Library Pairing System
### A. EPUB Output File Naming
- Regular Scrape: `output/<novel-slug>.epub`
- Fixed Scrape: `output/<novel-slug>-fixed.epub`
### B. Duplicate Guard on Scrape URL
- When a user submits a novel URL in the "Scrape Novel" form:
- If `<novel-slug>-fixed.epub` already exists in `output/`, the job submission is blocked.
- Return notice: `"Novel already exists in library (Fixed version present)."`
### C. Library Stacking Card UI
- In the Library section:
- Regular entries and Fixed entries of the same novel are paired into **sticking entries with 0 margin**:
- **Top Entry (Regular)**: Standard dark surface background (`bg-theme-surface`), with `[Download]`, `[Fix]`, and `[Delete]` buttons.
- **Bottom Entry (Fixed)**: **Dark azure/blue background** (`#0D1C42`, border `#22396F`, text `#FCF1D0`, badge `#38bdf8`), with `[Download]` and `[Delete]` buttons only (no `[Fix]` button).
- If a novel was scraped with the `Fix` radio button enabled from the beginning, it appears directly as the dark azure/blue fixed entry.
---
## 7. Implementation Steps
1. **Database & Schema Updates (`src/database.py`)**:
- Add `fixed_html_text` column and `novel_lore` table.
2. **Prompts & Extraction (`src/prompt.py`)**:
- Add `LORE_EXTRACTION_PROMPT`, `APE_SYSTEM_PROMPT`, `CHAPTER_SUMMARY_PROMPT`.
3. **Core APE Module (`src/ape.py`)**:
- Implement `extract_initial_lore()`, `chunk_chapter_text()`, `run_ape_on_chunk()`, `update_rolling_summary()`, `fix_chapter()`.
4. **Chapter & EPUB Pipeline Integration (`src/chapter.py`, `src/epub_creator.py`)**:
- Support `mode="fix"` across chapter scraping and EPUB compilation.
- Add `blockquote.tl-note` to `_STYLE`.
5. **Backend Job & API Routing (`web/job_manager.py`, `web/app.py`)**:
- Add `/api/jobs` support for `fix` mode.
- Add `/api/epubs/{name}/fix` endpoint to trigger fixing directly from library.
- Add duplicate check for existing fixed EPUBs.
- Group paired EPUB files in `/api/epubs`.
6. **Frontend UI Updates (`web/templates/index.html`)**:
- Add `Fix` radio option in Scrape form.
- Implement stacked 0-margin paired entries in the Library card with dark azure/blue styling for fixed editions.
7. **Verification & Testing**:
- Verify with FastAPI TestClient and mock novel runs.
+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
+62 -23
View File
@@ -14,11 +14,14 @@ from src.database import db_fetch_one, db_fetch_all, init_db
from src.epub_creator import _slugify
from .job_manager import (
jobs,
job_queues,
create_job,
cancel_job,
cancel_all_jobs,
run_job,
get_active_job,
subscribe_job_events,
unsubscribe_job_events,
WorkerBusyError,
)
OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "output")).resolve()
@@ -61,12 +64,27 @@ def _find_novel_url_for_epub(filename: str) -> str | None:
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")
async def start_job(payload: dict):
url = (payload.get("url") or "").strip()
if not url or not (url.startswith("http://") or url.startswith("https://")):
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]:
@@ -94,7 +112,13 @@ async def start_job(payload: dict):
fix = bool(payload.get("fix", False)) or (refresh_mode == "fix")
job_id = create_job(url, refresh_mode=refresh_mode, fix=fix, output_dir=str(OUTPUT_DIR))
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))
return {"job_id": job_id, "status": "queued"}
@@ -109,6 +133,13 @@ async def fix_library_epub(filename: str):
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(
@@ -116,7 +147,13 @@ async def fix_library_epub(filename: str):
detail="Source novel URL not found in cache. Re-scrape the novel to fix.",
)
job_id = create_job(novel_url, refresh_mode="none", fix=True, output_dir=str(OUTPUT_DIR))
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}
@@ -179,28 +216,30 @@ async def job_events(job_id: str):
raise HTTPException(status_code=404, detail="Job not found")
async def event_gen():
q = job_queues.get(job_id)
if q is None:
return
last_idx = 0
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)
q = subscribe_job_events(job_id)
try:
events = list(job.get("events", []))
for evt in events:
yield f"data: {json.dumps(evt)}\n\n"
if evt.get("message") == "__DONE__":
break
except asyncio.TimeoutError:
yield ": keepalive\n\n"
if job["status"] in ("completed", "failed", "cancelled"):
break
return
if job.get("status") in ("completed", "failed", "cancelled"):
yield f"data: {json.dumps({'stage': 'done', 'message': '__DONE__'})}\n\n"
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={
"Cache-Control": "no-cache",
+73 -4
View File
@@ -12,8 +12,61 @@ from src.chapter import clean_chapters, fix_chapters, scrape_chapters
from src.epub_creator import create_epub
jobs: dict[str, dict] = {}
job_queues: dict[str, asyncio.Queue] = {}
subscribers: dict[str, set[asyncio.Queue]] = {}
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):
@@ -30,8 +83,9 @@ def _push(job_id: str, event: dict):
job["total"] = event["total"]
if "message" in event:
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:
q.put_nowait(event)
except asyncio.QueueFull:
@@ -39,7 +93,13 @@ def _push(job_id: str, event: dict):
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]
active_job_id = job_id
jobs[job_id] = {
"id": job_id,
"url": url,
@@ -58,12 +118,13 @@ def create_job(url: str, refresh_mode: str = "none", fix: bool = False, output_d
"created_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()
return job_id
def cancel_job(job_id: str) -> bool:
global active_job_id
job = jobs.get(job_id)
if not job:
return False
@@ -76,19 +137,24 @@ def cancel_job(job_id: str) -> bool:
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": "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
def cancel_all_jobs():
global active_job_id
for job_id, evt in cancel_events.items():
job = jobs.get(job_id)
if job and job.get("status") in ("running", "queued"):
evt.set()
job["status"] = "cancelled"
job["stage"] = "cancelled"
active_job_id = None
async def run_job(job_id: str):
global active_job_id
job = jobs.get(job_id)
if job is None:
return
@@ -181,3 +247,6 @@ async def run_job(job_id: str):
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": "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); }
}
+297 -261
View File
@@ -3,111 +3,54 @@
<head>
<meta charset="UTF-8">
<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/png" href="/static/icon.png">
<link rel="apple-touch-icon" href="/static/icon.png">
<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: #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>
<link rel="stylesheet" href="/static/app.css">
</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">
<header class="border-b border-theme-border bg-theme-surface sticky top-0 z-30">
<div class="max-w-4xl mx-auto px-4 py-3.5 flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="/static/icon.png" alt="Silksoup" class="w-8 h-8 rounded-md object-contain shadow-sm">
<body>
<header class="app-header">
<div class="header-inner">
<div class="brand-wrap">
<img src="/static/icon.png" alt="Silksoup" class="brand-icon">
<div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base leading-none text-theme-text">Silksoup</span>
<span id="headerBadge" class="hidden text-xs font-medium px-2 py-0.5 rounded bg-emerald-950/60 text-emerald-400 border border-emerald-800/80">Active</span>
<div class="brand-title-line">
<span class="brand-title">Silksoup</span>
<span id="headerBadge" class="header-badge hidden">Active</span>
</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 class="flex items-center gap-2.5">
<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">
<div class="header-actions">
<button id="notifyBtn" onclick="toggleNotifications()" type="button" aria-label="Desktop notifications" class="btn-header">
<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="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"/>
</svg>
<span id="notifyText" class="hidden sm:inline">Notify</span>
<span id="notifyText">Notify</span>
</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">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<a href="/api/epubs" target="_blank" rel="noopener" class="btn-header">
<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="8 6 2 12 8 18"/>
</svg>
<span class="hidden sm:inline">API</span>
<span>API</span>
</a>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto w-full px-4 py-6 flex flex-col gap-6 flex-1">
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-theme-text">Scrape Novel</h2>
</div>
<form id="jobForm" class="space-y-4" onsubmit="return false">
<div>
<label for="urlInput" class="block text-sm font-medium text-theme-text mb-1.5">Novel URL</label>
<div class="flex gap-2">
<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">
<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">
<main class="app-main">
<section class="card" aria-labelledby="headingScrape">
<h2 id="headingScrape" class="card-title">Scrape Novel</h2>
<form id="jobForm">
<div class="form-group">
<label for="urlInput" class="form-label">Novel URL</label>
<div class="input-row">
<input id="urlInput" type="url" required placeholder="https://kakuyomu.jp/works/... or novel index URL" class="input-text">
<button type="button" onclick="pasteClipboard()" class="btn-paste">
<svg width="16" height="16" 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"/>
<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>
@@ -115,49 +58,49 @@ tailwind.config = {
</button>
</div>
</div>
<div>
<span class="block text-sm font-medium text-theme-text mb-1.5">Cache Mode</span>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
<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">
<input type="radio" name="refreshMode" value="none" checked class="mt-0.5 text-theme-primary border-theme-border focus:ring-0">
<div class="flex flex-col">
<span class="text-sm font-medium text-theme-text">Full Cache</span>
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML & AI results</span>
<div class="form-group">
<span class="form-label">Cache Mode</span>
<div class="cache-grid" role="radiogroup" aria-label="Cache Mode">
<label class="cache-card">
<input type="radio" name="refreshMode" value="none" checked>
<div>
<div class="cache-card-title">Full Cache</div>
<div class="cache-card-desc">Reuse HTML and AI results</div>
</div>
</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">
<input type="radio" name="refreshMode" value="llm" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0">
<div class="flex flex-col">
<span class="text-sm font-medium text-theme-text">Re-run AI</span>
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML, re-sanitize</span>
<label class="cache-card">
<input type="radio" name="refreshMode" value="llm">
<div>
<div class="cache-card-title">Re-run AI</div>
<div class="cache-card-desc">Reuse HTML, re-sanitize chapters</div>
</div>
</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">
<input type="radio" name="refreshMode" value="all" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0">
<div class="flex flex-col">
<span class="text-sm font-medium text-theme-text">Full Refresh</span>
<span class="text-xs text-theme-muted mt-0.5">Bypass cache & fetch fresh</span>
<label class="cache-card">
<input type="radio" name="refreshMode" value="all">
<div>
<div class="cache-card-title">Full Refresh</div>
<div class="cache-card-desc">Bypass cache and fetch fresh</div>
</div>
</label>
</div>
</div>
<div>
<label class="flex items-start gap-3 p-3.5 rounded-md border border-[#22396F] bg-[#0D1C42]/60 hover:bg-[#0D1C42] cursor-pointer select-none transition">
<input id="fixCheck" type="checkbox" class="mt-0.5 rounded border-[#22396F] bg-[#090e1a] text-sky-400 focus:ring-0">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-[#FCF1D0]">Fix (Automated Post-Editing)</span>
<span class="text-xs font-mono uppercase px-1.5 py-0.2 rounded bg-sky-950 text-sky-300 border border-sky-700/80">APE</span>
<div class="form-group">
<label class="toggle-card">
<input id="fixCheck" type="checkbox">
<div>
<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>
<span class="text-xs text-sky-300/70 mt-0.5">Repair bad MTL, fix pronouns, enforce consistent character names, and format notes</span>
<div class="cache-card-desc">Repair machine translation phrasing, pronoun confusion, and format translator notes</div>
</div>
</label>
</div>
<div class="flex justify-end pt-1">
<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">
<svg id="btnSpinner" class="hidden w-4 h-4 animate-spin text-current" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
<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>
<span id="btnText">Start Scraping</span>
</button>
@@ -165,106 +108,109 @@ tailwind.config = {
</form>
</section>
<section id="activeJob" class="hidden rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span id="statusPulse" class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
<h2 class="text-base font-semibold text-theme-text">Task Monitor</h2>
<section id="activeJob" class="card monitor-section hidden" aria-labelledby="headingMonitor" role="region">
<div class="monitor-header">
<div class="monitor-title-group">
<span id="statusPulse" class="status-dot dot-emerald"></span>
<h2 id="headingMonitor" class="card-title" style="margin-bottom: 0;">Task Monitor</h2>
</div>
<div class="flex items-center gap-2">
<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>
<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>
<div class="monitor-actions">
<button id="cancelJobBtn" type="button" onclick="requestCancelJob()" class="btn-stop">Stop Process</button>
<span id="jobStatusBadge" class="badge-status">idle</span>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between text-sm gap-3">
<div class="flex items-center gap-2 min-w-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="jobMessage" class="text-theme-text truncate font-medium">Starting...</span>
<div>
<div class="stage-row">
<div style="display: flex; align-items: center; gap: 0.5rem; min-width: 0;">
<span id="jobStage" class="stage-pill">init</span>
<span id="jobMessage" class="stage-message">Starting...</span>
</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 class="h-2 rounded bg-theme-subtle border border-theme-border overflow-hidden">
<div id="jobProgress" class="h-full bg-theme-primary transition-all duration-200" style="width: 0%"></div>
<div class="progress-track" role="progressbar" id="progressTrack" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="jobProgress" class="progress-fill"></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>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input id="autoScrollCheck" type="checkbox" checked class="rounded border-theme-border bg-theme-subtle text-theme-primary focus:ring-0">
<div class="log-actions">
<label style="display: inline-flex; align-items: center; gap: 0.375rem; cursor: pointer; user-select: none;">
<input id="autoScrollCheck" type="checkbox" checked style="accent-color: var(--primary);">
<span>Auto-scroll</span>
</label>
<button type="button" onclick="clearLogs()" class="hover:text-theme-text transition">Clear</button>
<button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="hover:text-theme-text transition">Expand</button>
<button type="button" onclick="clearLogs()" class="log-action-btn">Clear</button>
<button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="log-action-btn">Expand</button>
</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">
<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">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<div id="jobResult" class="result-bar hidden">
<a id="downloadLink" href="#" class="btn-primary" style="text-decoration: none;">
<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"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span>Download EPUB</span>
</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>
</section>
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-2">
<h2 class="text-base font-semibold text-theme-text">Library</h2>
<span id="epubCount" class="text-sm text-theme-muted font-mono font-medium"></span>
<section class="card" aria-labelledby="headingLibrary">
<div class="library-header">
<div class="library-title-group">
<h2 id="headingLibrary" class="card-title" style="margin-bottom: 0;">Library</h2>
<span id="epubCount" class="library-counter"></span>
</div>
<div class="flex items-center gap-2">
<div class="relative flex-1 sm:w-64">
<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">
<div class="library-controls">
<div class="search-wrap">
<input id="searchEpub" type="text" oninput="filterEpubs()" placeholder="Filter books..." class="input-text" style="min-height: 2.25rem; font-size: 0.875rem;">
</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 id="epubList" class="space-y-3"></div>
<div id="epubEmpty" class="text-sm text-theme-muted py-8 text-center hidden">
<div id="epubList" class="book-shelf"></div>
<div id="epubEmpty" class="empty-state hidden">
No EPUBs found. Scrape a novel to populate your library.
</div>
</section>
<section class="rounded-lg border border-theme-border bg-theme-surface p-5">
<h3 class="text-sm font-semibold text-theme-text mb-3">Workflow Pipeline</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 text-xs text-theme-muted">
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
<span class="text-sm font-medium text-theme-text block mb-1">1. Ingest & Cache</span>
Fetches novel HTML via anti-bot client into database.
<details class="guide-details">
<summary class="guide-summary">
<span>Workflow Pipeline Guide</span>
<span style="font-size: 0.75rem; color: var(--text-muted);">Details</span>
</summary>
<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 class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
<span class="text-sm font-medium text-theme-text block mb-1">2. Parse Index</span>
<div class="guide-step">
<div class="guide-step-title">2. Parse Index</div>
LLM extracts chronological volumes and chapter URLs.
</div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
<span class="text-sm font-medium text-theme-text block mb-1">3. Sanitize & APE</span>
Fixes bad MTL, typos, entities, and formats notes into blockquotes.
<div class="guide-step">
<div class="guide-step-title">3. Sanitize & APE</div>
Fixes machine translation flaws, entities, and formats notes into blockquotes.
</div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
<span class="text-sm font-medium text-theme-text block mb-1">4. Build EPUB</span>
<div class="guide-step">
<div class="guide-step-title">4. Build EPUB</div>
Downloads images, compresses assets, and compiles book.
</div>
</div>
</section>
</details>
</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">
Silksoup — Novel to EPUB Pipeline
<footer class="app-footer">
Silksoup: Novel to EPUB Pipeline
</footer>
<script>
@@ -276,7 +222,7 @@ let originalTitle = document.title;
function updateNotifyButton() {
if (Notification.permission === 'granted') {
$('#notifyBtn').classList.add('text-emerald-400', 'border-emerald-800');
$('#notifyBtn').classList.add('active');
$('#notifyText').textContent = 'Notifications on';
}
}
@@ -289,9 +235,9 @@ async function toggleNotifications() {
updateNotifyButton();
}
} else if (Notification.permission === 'denied') {
showToast('Notifications are blocked in browser settings', 'error');
showToast('Notifications blocked in browser settings', 'error');
} else {
showToast('Notifications are already enabled', 'info');
showToast('Notifications already enabled', 'info');
}
}
@@ -304,17 +250,16 @@ function notify(title, body) {
function showToast(message, type = 'info') {
const container = $('#toastContainer');
const toast = document.createElement('div');
const borderClass = type === 'error' ? 'border-theme-danger-border bg-theme-danger-bg text-theme-danger' :
type === 'success' ? 'border-emerald-800 bg-emerald-950 text-emerald-200' :
'border-theme-border bg-theme-surface text-theme-text';
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}`;
const typeClass = type === 'error' ? 'toast-error' :
type === 'success' ? 'toast-success' : 'toast-info';
toast.className = `toast ${typeClass}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.remove('opacity-0');
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.add('opacity-0');
toast.classList.remove('show');
setTimeout(() => toast.remove(), 150);
}, 4000);
}
@@ -346,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();
if (!url) {
showToast('Please enter a novel URL', 'error');
@@ -362,6 +315,12 @@ $('#startBtn').addEventListener('click', async () => {
headers: {'Content-Type': 'application/json'},
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) {
const j = await res.json();
throw new Error(j.detail || 'Failed to submit job');
@@ -377,19 +336,60 @@ $('#startBtn').addEventListener('click', async () => {
} finally {
setBtnLoading(false);
}
}
$('#jobForm').addEventListener('submit', (e) => {
e.preventDefault();
submitJob();
});
function showActive() {
function showActive(jobData = null) {
setFormLocked(true);
$('#activeJob').classList.remove('hidden');
$('#headerBadge').classList.remove('hidden');
$('#cancelJobBtn').classList.remove('hidden');
$('#jobLog').textContent = '';
$('#jobProgress').style.width = '0%';
$('#jobCounter').textContent = '0 / 0';
$('#jobStage').textContent = 'queued';
$('#jobStatusBadge').textContent = 'running';
$('#jobResult').classList.add('hidden');
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500';
if (!jobData) {
$('#jobLog').textContent = '';
$('#jobProgress').style.width = '0%';
$('#progressTrack').setAttribute('aria-valuenow', '0');
$('#jobCounter').textContent = '0 / 0';
$('#jobStage').textContent = 'queued';
$('#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' });
}
@@ -402,6 +402,7 @@ async function requestCancelJob() {
const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: 'POST' });
if (res.ok) {
showToast('Process cancellation requested', 'info');
setFormLocked(false);
}
} catch(e) {
showToast('Failed to cancel job', 'error');
@@ -417,6 +418,7 @@ function dismissActiveJob() {
if (evtSource) evtSource.close();
$('#activeJob').classList.add('hidden');
$('#headerBadge').classList.add('hidden');
setFormLocked(false);
document.title = originalTitle;
}
@@ -431,6 +433,7 @@ function connectSSE(jobId) {
evtSource.close();
refreshJob();
loadEpubs();
setFormLocked(false);
return;
}
const pct = evt.total ? Math.round(evt.current / evt.total * 100) : 0;
@@ -438,6 +441,7 @@ function connectSSE(jobId) {
$('#jobMessage').textContent = evt.message || '';
$('#jobCounter').textContent = `${evt.current} / ${evt.total}`;
$('#jobProgress').style.width = pct + '%';
$('#progressTrack').setAttribute('aria-valuenow', String(pct));
if (evt.stage === 'scraping' || evt.stage === 'cleaning' || evt.stage === 'fixing' || evt.stage === 'epub') {
document.title = `(${pct}%) Silksoup`;
@@ -445,23 +449,29 @@ function connectSSE(jobId) {
if (evt.stage === 'failed') {
$('#jobStatusBadge').textContent = 'failed';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-rose-500';
$('#statusPulse').className = 'status-dot dot-rose';
$('#cancelJobBtn').classList.add('hidden');
document.title = `Failed — Silksoup`;
setFormLocked(false);
loadEpubs();
document.title = 'Failed: Silksoup';
showToast(`Job failed: ${evt.message}`, 'error');
}
if (evt.stage === 'cancelled') {
$('#jobStatusBadge').textContent = 'cancelled';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-amber-500';
$('#statusPulse').className = 'status-dot dot-amber';
$('#cancelJobBtn').classList.add('hidden');
document.title = `Silksoup`;
setFormLocked(false);
loadEpubs();
document.title = 'Silksoup';
showToast('Process stopped. Cached chapters remain safe.', 'info');
}
if (evt.stage === 'completed') {
$('#jobStatusBadge').textContent = 'completed';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500';
$('#statusPulse').className = 'status-dot dot-emerald';
$('#cancelJobBtn').classList.add('hidden');
document.title = `Completed — Silksoup`;
setFormLocked(false);
loadEpubs();
document.title = 'Completed: Silksoup';
notify('Silksoup EPUB Ready', evt.message || 'Book compiled successfully.');
showToast('EPUB compiled successfully', 'success');
}
@@ -485,7 +495,7 @@ async function refreshJob() {
const j = await res.json();
$('#jobStatusBadge').textContent = j.status;
if (j.status === 'completed') {
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500';
$('#statusPulse').className = 'status-dot dot-emerald';
$('#cancelJobBtn').classList.add('hidden');
if (j.result_path) {
const name = j.result_path.split(/[\\/]/).pop();
@@ -495,11 +505,11 @@ async function refreshJob() {
}
}
if (j.status === 'cancelled') {
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-amber-500';
$('#statusPulse').className = 'status-dot dot-amber';
$('#cancelJobBtn').classList.add('hidden');
}
if (j.status === 'failed') {
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-rose-500';
$('#statusPulse').className = 'status-dot dot-rose';
$('#cancelJobBtn').classList.add('hidden');
$('#jobLog').textContent += `\n[ERROR] ${j.error || j.message}\n`;
}
@@ -514,13 +524,11 @@ function toggleLogHeight() {
const log = $('#jobLog');
const btn = $('#expandLogBtn');
if (isLogExpanded) {
log.classList.remove('h-96');
log.classList.add('h-40');
log.classList.remove('expanded');
btn.textContent = 'Expand';
isLogExpanded = false;
} else {
log.classList.remove('h-40');
log.classList.add('h-96');
log.classList.add('expanded');
btn.textContent = 'Collapse';
isLogExpanded = true;
}
@@ -545,13 +553,19 @@ function filterEpubs() {
return;
}
const filtered = allEpubs.filter(f => f.name.toLowerCase().includes(query));
renderEpubs(filtered);
renderEpubs(filtered, true);
}
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');
@@ -567,16 +581,18 @@ async function triggerFix(encodedName) {
}
}
function renderEpubs(list) {
function renderEpubs(list, isFiltered = false) {
const container = $('#epubList');
container.innerHTML = '';
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');
return;
}
$('#epubEmpty').classList.add('hidden');
// Group EPUBs by base novel slug
const groups = {};
for (const f of list) {
const key = f.base_key || f.name.replace(/(-fixed)?\.epub$/i, '').trim().toLowerCase();
@@ -590,74 +606,72 @@ function renderEpubs(list) {
for (const key of Object.keys(groups)) {
const g = groups[key];
const groupWrapper = document.createElement('div');
groupWrapper.className = 'flex flex-col';
const hasBoth = g.regular && g.fixed;
const groupCard = document.createElement('div');
groupCard.className = 'book-card';
if (g.regular) {
const f = g.regular;
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
const row = document.createElement('div');
const roundedClass = hasBoth ? 'rounded-t-md border-b-0' : 'rounded-md';
row.className = `flex items-center justify-between gap-3.5 border border-theme-border bg-theme-subtle/50 px-4 py-3 hover:border-theme-border-strong transition ${roundedClass}`;
row.className = 'book-edition-row';
row.innerHTML = `
<div class="flex items-center gap-3 min-w-0 flex-1">
<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">
<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="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-theme-text capitalize" title="${cleanTitle}">${cleanTitle}</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>
<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 class="flex items-center gap-2 shrink-0">
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="text-sm px-3 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="text-sm px-3 py-1.5 rounded-md border border-sky-800 bg-sky-950/40 hover:bg-sky-900/60 text-sky-300 font-medium transition">Fix</button>
<div class="relative inline-block" id="delWrap_${CSS.escape(f.name)}">
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="text-sm px-2.5 py-1.5 rounded-md border border-theme-border hover:border-theme-danger-border text-theme-muted hover:text-theme-danger transition">Delete</button>
<div class="book-actions">
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="btn-book-action btn-book-download">Download</a>
<button type="button" onclick="triggerFix('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-fix">Fix</button>
<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>
`;
groupWrapper.appendChild(row);
groupCard.appendChild(row);
}
if (g.fixed) {
const f = g.fixed;
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
const row = document.createElement('div');
const roundedClass = hasBoth ? 'rounded-b-md' : 'rounded-md';
row.className = `flex items-center justify-between gap-3.5 border border-[#22396F] bg-[#0D1C42] px-4 py-3 text-[#FCF1D0] transition shadow-sm ${roundedClass}`;
row.className = 'book-edition-row fixed-edition';
row.innerHTML = `
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-4 h-4 text-sky-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium text-[#FCF1D0] capitalize" title="${cleanTitle}">${cleanTitle}</span>
<span class="text-xs font-mono uppercase px-1.5 py-0.2 rounded bg-sky-950 text-sky-300 border border-sky-700/80">Fixed</span>
<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="text-xs text-sky-300/70 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>
<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="flex items-center gap-2 shrink-0">
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="text-sm px-3.5 py-1.5 rounded-md bg-[#FCF1D0] hover:bg-white text-[#0D1C42] font-semibold transition shadow-sm">Download</a>
<div class="relative inline-block" id="delWrap_${CSS.escape(f.name)}">
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="text-sm px-2.5 py-1.5 rounded-md border border-[#22396F] hover:border-rose-900/80 text-sky-300/70 hover:text-rose-400 transition">Delete</button>
<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>
`;
groupWrapper.appendChild(row);
groupCard.appendChild(row);
}
container.appendChild(groupWrapper);
container.appendChild(groupCard);
}
}
@@ -666,13 +680,22 @@ function confirmDelete(encodedName) {
const wrap = document.getElementById(`delWrap_${CSS.escape(name)}`);
if (!wrap) return;
wrap.innerHTML = `
<div class="flex items-center gap-1.5 bg-theme-danger-bg border border-theme-danger-border rounded-md p-1">
<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="loadEpubs()" class="text-xs px-2.5 py-1 text-theme-muted hover:text-theme-text">Cancel</button>
<div class="delete-confirm-box">
<button type="button" onclick="executeDelete('${encodeURIComponent(name)}')" class="btn-confirm-delete">Confirm</button>
<button type="button" onclick="loadEpubs()" class="btn-cancel-delete">Cancel</button>
</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) {
try {
const res = await fetch(`/api/epubs/${encodedName}`, { method: 'DELETE' });
@@ -684,36 +707,49 @@ async function executeDelete(encodedName) {
}
}
async function checkStoredJob() {
if (!currentJobId) return;
async function syncActiveJob() {
try {
const res = await fetch(`/api/jobs/${currentJobId}`);
const res = await fetch('/api/jobs/active');
if (res.ok) {
const j = await res.json();
showActive();
$('#jobStage').textContent = j.stage;
$('#jobMessage').textContent = j.message || '';
$('#jobCounter').textContent = `${j.current} / ${j.total}`;
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') {
const data = await res.json();
if (data.active && data.job) {
currentJobId = data.job.id;
localStorage.setItem('silksoup_active_job', currentJobId);
showActive(data.job);
connectSSE(currentJobId);
} else {
refreshJob();
return;
}
} else {
localStorage.removeItem('silksoup_active_job');
}
} catch(e) {
localStorage.removeItem('silksoup_active_job');
} catch(e) {}
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);
}
}
updateNotifyButton();
checkStoredJob();
syncActiveJob();
loadEpubs();
</script>
</body>