Commit ·
432adc8
0
Parent(s):
chore: project foundation — reader UI, modal, PDF parsing, bookmarking, voice samples
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- .gitignore +10 -0
- app.py +27 -0
- data/library.db +0 -0
- novel_reader/__init__.py +5 -0
- novel_reader/config.py +16 -0
- novel_reader/ingestion.py +52 -0
- novel_reader/models.py +28 -0
- novel_reader/parsers.py +433 -0
- novel_reader/storage.py +387 -0
- novel_reader/ui.py +1474 -0
- requirements.txt +5 -0
- voice_samples/Aiden/Aiden_angry_high.wav +3 -0
- voice_samples/Aiden/Aiden_angry_low.wav +3 -0
- voice_samples/Aiden/Aiden_angry_med.wav +3 -0
- voice_samples/Aiden/Aiden_fearful_high.wav +3 -0
- voice_samples/Aiden/Aiden_fearful_low.wav +3 -0
- voice_samples/Aiden/Aiden_fearful_med.wav +3 -0
- voice_samples/Aiden/Aiden_happy_high.wav +3 -0
- voice_samples/Aiden/Aiden_happy_low.wav +3 -0
- voice_samples/Aiden/Aiden_happy_med.wav +3 -0
- voice_samples/Aiden/Aiden_mysterious_low.wav +3 -0
- voice_samples/Aiden/Aiden_neutral.wav +3 -0
- voice_samples/Aiden/Aiden_sad_high.wav +3 -0
- voice_samples/Aiden/Aiden_sad_low.wav +3 -0
- voice_samples/Aiden/Aiden_sad_med.wav +3 -0
- voice_samples/Aiden/Aiden_serious_high.wav +3 -0
- voice_samples/Aiden/Aiden_serious_low.wav +3 -0
- voice_samples/Aiden/Aiden_warm_high.wav +3 -0
- voice_samples/Aiden/Aiden_warm_low.wav +3 -0
- voice_samples/Aiden/Aiden_whisper_high.wav +3 -0
- voice_samples/Aiden/Aiden_whisper_low.wav +3 -0
- voice_samples/Dylan/Dylan_angry_high.wav +3 -0
- voice_samples/Dylan/Dylan_angry_low.wav +3 -0
- voice_samples/Dylan/Dylan_angry_med.wav +3 -0
- voice_samples/Dylan/Dylan_fearful_high.wav +3 -0
- voice_samples/Dylan/Dylan_fearful_low.wav +3 -0
- voice_samples/Dylan/Dylan_fearful_med.wav +3 -0
- voice_samples/Dylan/Dylan_happy_high.wav +3 -0
- voice_samples/Dylan/Dylan_happy_low.wav +3 -0
- voice_samples/Dylan/Dylan_happy_med.wav +3 -0
- voice_samples/Dylan/Dylan_mysterious_low.wav +3 -0
- voice_samples/Dylan/Dylan_neutral.wav +3 -0
- voice_samples/Dylan/Dylan_sad_high.wav +3 -0
- voice_samples/Dylan/Dylan_sad_low.wav +3 -0
- voice_samples/Dylan/Dylan_sad_med.wav +3 -0
- voice_samples/Dylan/Dylan_serious_high.wav +3 -0
- voice_samples/Dylan/Dylan_serious_low.wav +3 -0
- voice_samples/Dylan/Dylan_warm_high.wav +3 -0
- voice_samples/Dylan/Dylan_warm_low.wav +3 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
myenv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.mypy_cache/
|
| 8 |
+
data/reader.sqlite3
|
| 9 |
+
data/novels/
|
| 10 |
+
*.log
|
app.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import uvicorn
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.responses import RedirectResponse
|
| 5 |
+
|
| 6 |
+
from novel_reader.ui import CSS, READER_CSS, READER_JS, THEME_JS, build_dashboard_app, build_reader_app
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
app = FastAPI()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@app.get("/")
|
| 13 |
+
def root():
|
| 14 |
+
return RedirectResponse("/dashboard/")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@app.head("/")
|
| 18 |
+
def root_head():
|
| 19 |
+
return RedirectResponse("/dashboard/")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
gr.mount_gradio_app(app, build_dashboard_app().queue(default_concurrency_limit=4), path="/dashboard", css=CSS, js=THEME_JS, theme=gr.themes.Base())
|
| 23 |
+
gr.mount_gradio_app(app, build_reader_app().queue(default_concurrency_limit=4), path="/reader", css=READER_CSS, js=READER_JS, theme=gr.themes.Base())
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if __name__ == "__main__":
|
| 27 |
+
uvicorn.run(app, host="127.0.0.1", port=8060)
|
data/library.db
ADDED
|
File without changes
|
novel_reader/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Novel Reader app package."""
|
| 2 |
+
|
| 3 |
+
__all__ = ["__version__"]
|
| 4 |
+
|
| 5 |
+
__version__ = "0.1.0"
|
novel_reader/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 7 |
+
DATA_DIR = BASE_DIR / "data"
|
| 8 |
+
NOVELS_DIR = DATA_DIR / "novels"
|
| 9 |
+
DATABASE_PATH = DATA_DIR / "reader.sqlite3"
|
| 10 |
+
|
| 11 |
+
SUPPORTED_FORMATS = {".txt", ".epub", ".pdf"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def ensure_app_dirs() -> None:
|
| 15 |
+
DATA_DIR.mkdir(exist_ok=True)
|
| 16 |
+
NOVELS_DIR.mkdir(exist_ok=True)
|
novel_reader/ingestion.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .config import SUPPORTED_FORMATS
|
| 7 |
+
from .parsers import extract_cover_info, parse_book
|
| 8 |
+
from .storage import LibraryStore
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class IngestionPipeline:
|
| 12 |
+
"""Base ingestion pipeline; AI/RAG stages will slot in after parsing."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, store: LibraryStore) -> None:
|
| 15 |
+
self.store = store
|
| 16 |
+
|
| 17 |
+
def ingest(self, upload_path: str) -> int:
|
| 18 |
+
path = Path(upload_path)
|
| 19 |
+
if path.suffix.lower() not in SUPPORTED_FORMATS:
|
| 20 |
+
raise ValueError(f"Unsupported format. Use: {', '.join(sorted(SUPPORTED_FORMATS))}")
|
| 21 |
+
novel_id = self.store.create_novel_record(path)
|
| 22 |
+
thread = threading.Thread(target=self._run_job, args=(novel_id,), daemon=True)
|
| 23 |
+
thread.start()
|
| 24 |
+
return novel_id
|
| 25 |
+
|
| 26 |
+
def _run_job(self, novel_id: int) -> None:
|
| 27 |
+
novel = self.store.get_novel(novel_id)
|
| 28 |
+
if not novel:
|
| 29 |
+
return
|
| 30 |
+
try:
|
| 31 |
+
self.store.set_status(novel_id, "running", "Parsing book text")
|
| 32 |
+
source_path = Path(novel["stored_path"])
|
| 33 |
+
parsed = parse_book(source_path)
|
| 34 |
+
self.store.save_parsed_book(novel_id, parsed)
|
| 35 |
+
|
| 36 |
+
# Extract cover image and extra metadata
|
| 37 |
+
file_size = source_path.stat().st_size if source_path.exists() else 0
|
| 38 |
+
try:
|
| 39 |
+
cover_info = extract_cover_info(source_path)
|
| 40 |
+
if cover_info.cover_b64:
|
| 41 |
+
self.store.save_cover_image(novel_id, cover_info.cover_b64)
|
| 42 |
+
self.store.save_extra_metadata(
|
| 43 |
+
novel_id,
|
| 44 |
+
series=cover_info.series,
|
| 45 |
+
genres=cover_info.genres,
|
| 46 |
+
file_size=file_size,
|
| 47 |
+
)
|
| 48 |
+
except Exception:
|
| 49 |
+
# Non-fatal: cover extraction failures should not break ingestion
|
| 50 |
+
self.store.save_extra_metadata(novel_id, file_size=file_size)
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
self.store.set_status(novel_id, "error", str(exc))
|
novel_reader/models.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class ParsedSection:
|
| 8 |
+
index: int
|
| 9 |
+
title: str
|
| 10 |
+
text: str
|
| 11 |
+
source_locator: str
|
| 12 |
+
html: str = ""
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class ParsedBook:
|
| 17 |
+
title: str
|
| 18 |
+
author: str
|
| 19 |
+
file_format: str
|
| 20 |
+
sections: list[ParsedSection]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class BookCoverInfo:
|
| 25 |
+
"""Holds the extracted cover image and extra book metadata."""
|
| 26 |
+
cover_b64: str = "" # data-URI or empty string
|
| 27 |
+
series: str = ""
|
| 28 |
+
genres: str = ""
|
novel_reader/parsers.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import posixpath
|
| 5 |
+
import re
|
| 6 |
+
from html import unescape, escape as _escape
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from urllib.parse import unquote, urlsplit
|
| 9 |
+
|
| 10 |
+
from bs4 import BeautifulSoup
|
| 11 |
+
|
| 12 |
+
from .models import ParsedBook, ParsedSection, BookCoverInfo
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
CHAPTER_RE = re.compile(
|
| 16 |
+
r"^\s*((chapter|book|part)\s+([ivxlcdm]+|\d+|one|two|three|four|five|six|seven|eight|nine|ten)\b.*|prologue\b.*|epilogue\b.*)\s*$",
|
| 17 |
+
re.IGNORECASE,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def extract_cover_info(path: Path) -> BookCoverInfo:
|
| 22 |
+
"""Extract cover image (base64 data-URI) and extra metadata for a book file."""
|
| 23 |
+
suffix = path.suffix.lower()
|
| 24 |
+
if suffix == ".epub":
|
| 25 |
+
return _epub_cover_info(path)
|
| 26 |
+
if suffix == ".pdf":
|
| 27 |
+
return _pdf_cover_info(path)
|
| 28 |
+
return _txt_cover_info(path)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _epub_cover_info(path: Path) -> BookCoverInfo:
|
| 32 |
+
"""Extract cover image + series/genres from an EPUB."""
|
| 33 |
+
try:
|
| 34 |
+
from ebooklib import ITEM_IMAGE, epub
|
| 35 |
+
book = epub.read_epub(str(path))
|
| 36 |
+
|
| 37 |
+
# --- cover image ---
|
| 38 |
+
cover_b64 = ""
|
| 39 |
+
# Try common cover identifiers
|
| 40 |
+
for item in book.get_items():
|
| 41 |
+
if item.get_type() == ITEM_IMAGE:
|
| 42 |
+
name_lower = (item.get_name() or "").lower()
|
| 43 |
+
item_id_lower = (item.id or "").lower()
|
| 44 |
+
if any(k in name_lower or k in item_id_lower for k in ("cover", "title")):
|
| 45 |
+
mt = item.media_type or "image/jpeg"
|
| 46 |
+
cover_b64 = f"data:{mt};base64,{base64.b64encode(item.get_content()).decode()}"
|
| 47 |
+
break
|
| 48 |
+
# Fallback: first image item
|
| 49 |
+
if not cover_b64:
|
| 50 |
+
for item in book.get_items_of_type(ITEM_IMAGE):
|
| 51 |
+
mt = item.media_type or "image/jpeg"
|
| 52 |
+
cover_b64 = f"data:{mt};base64,{base64.b64encode(item.get_content()).decode()}"
|
| 53 |
+
break
|
| 54 |
+
|
| 55 |
+
# --- series metadata ---
|
| 56 |
+
series = ""
|
| 57 |
+
try:
|
| 58 |
+
belongs = book.get_metadata("OPF", "belongs-to-collection")
|
| 59 |
+
if belongs:
|
| 60 |
+
series = str(belongs[0][0]).strip()
|
| 61 |
+
except Exception:
|
| 62 |
+
pass
|
| 63 |
+
if not series:
|
| 64 |
+
try:
|
| 65 |
+
series_meta = book.get_metadata("DC", "relation")
|
| 66 |
+
if series_meta:
|
| 67 |
+
series = str(series_meta[0][0]).strip()
|
| 68 |
+
except Exception:
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
# --- genres/subjects ---
|
| 72 |
+
genres = ""
|
| 73 |
+
try:
|
| 74 |
+
subjects = book.get_metadata("DC", "subject")
|
| 75 |
+
if subjects:
|
| 76 |
+
genres = ", ".join(str(s[0]).strip() for s in subjects[:5])
|
| 77 |
+
except Exception:
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
return BookCoverInfo(cover_b64=cover_b64, series=series, genres=genres)
|
| 81 |
+
except Exception:
|
| 82 |
+
return BookCoverInfo()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _pdf_cover_info(path: Path) -> BookCoverInfo:
|
| 86 |
+
"""Render first PDF page as a JPEG cover thumbnail."""
|
| 87 |
+
try:
|
| 88 |
+
import fitz
|
| 89 |
+
doc = fitz.open(str(path))
|
| 90 |
+
page = doc.load_page(0)
|
| 91 |
+
# Render at 2x scale for a decent thumbnail
|
| 92 |
+
mat = fitz.Matrix(1.5, 1.5)
|
| 93 |
+
pix = page.get_pixmap(matrix=mat)
|
| 94 |
+
img_bytes = pix.tobytes("jpeg")
|
| 95 |
+
doc.close()
|
| 96 |
+
cover_b64 = f"data:image/jpeg;base64,{base64.b64encode(img_bytes).decode()}"
|
| 97 |
+
return BookCoverInfo(cover_b64=cover_b64)
|
| 98 |
+
except Exception:
|
| 99 |
+
return BookCoverInfo()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _txt_cover_info(path: Path) -> BookCoverInfo:
|
| 103 |
+
"""Generate a styled SVG placeholder cover for text files."""
|
| 104 |
+
# We return an empty string so the UI generates a CSS-based cover
|
| 105 |
+
return BookCoverInfo()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def parse_book(path: Path) -> ParsedBook:
|
| 109 |
+
suffix = path.suffix.lower()
|
| 110 |
+
if suffix == ".txt":
|
| 111 |
+
return parse_txt(path)
|
| 112 |
+
if suffix == ".epub":
|
| 113 |
+
return parse_epub(path)
|
| 114 |
+
if suffix == ".pdf":
|
| 115 |
+
return parse_pdf(path)
|
| 116 |
+
raise ValueError(f"Unsupported book format: {suffix}")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def parse_txt(path: Path) -> ParsedBook:
|
| 120 |
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
| 121 |
+
text = _normalize_text(raw)
|
| 122 |
+
lines = text.splitlines()
|
| 123 |
+
chapter_starts = [i for i, line in enumerate(lines) if CHAPTER_RE.match(line)]
|
| 124 |
+
|
| 125 |
+
sections: list[ParsedSection] = []
|
| 126 |
+
if chapter_starts:
|
| 127 |
+
starts = chapter_starts + [len(lines)]
|
| 128 |
+
preface = "\n".join(lines[: chapter_starts[0]]).strip()
|
| 129 |
+
if preface:
|
| 130 |
+
sections.append(ParsedSection(0, "Opening", preface, "txt:opening"))
|
| 131 |
+
for pos, start in enumerate(chapter_starts):
|
| 132 |
+
end = starts[pos + 1]
|
| 133 |
+
title = lines[start].strip() or f"Chapter {pos + 1}"
|
| 134 |
+
body = "\n".join(lines[start + 1 : end]).strip()
|
| 135 |
+
if body:
|
| 136 |
+
sections.append(ParsedSection(len(sections), title, body, f"txt:{start + 1}"))
|
| 137 |
+
else:
|
| 138 |
+
chunks = _chunk_text(text, max_chars=6500)
|
| 139 |
+
sections = [
|
| 140 |
+
ParsedSection(index=i, title=f"Section {i + 1}", text=chunk, source_locator=f"txt:section:{i + 1}")
|
| 141 |
+
for i, chunk in enumerate(chunks)
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
return ParsedBook(_clean_title(path.stem), "Unknown author", "txt", sections or [_empty_section()])
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _build_toc_map(toc_items, toc_map=None) -> dict[str, str]:
|
| 148 |
+
if toc_map is None:
|
| 149 |
+
toc_map = {}
|
| 150 |
+
for item in toc_items:
|
| 151 |
+
if isinstance(item, (list, tuple)):
|
| 152 |
+
_build_toc_map(item, toc_map)
|
| 153 |
+
else:
|
| 154 |
+
href = getattr(item, "href", "")
|
| 155 |
+
title = getattr(item, "title", "")
|
| 156 |
+
if href and title:
|
| 157 |
+
path_only = href.split("#")[0]
|
| 158 |
+
path_only = path_only.replace("\\", "/")
|
| 159 |
+
# Store full paths and basenames
|
| 160 |
+
if path_only not in toc_map:
|
| 161 |
+
toc_map[path_only] = title.strip()
|
| 162 |
+
base = posixpath.basename(path_only)
|
| 163 |
+
if base not in toc_map:
|
| 164 |
+
toc_map[base] = title.strip()
|
| 165 |
+
return toc_map
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def parse_epub(path: Path) -> ParsedBook:
|
| 169 |
+
from ebooklib import ITEM_DOCUMENT, ITEM_IMAGE
|
| 170 |
+
from ebooklib import epub
|
| 171 |
+
|
| 172 |
+
book = epub.read_epub(str(path))
|
| 173 |
+
title = _metadata_value(book, "title") or _clean_title(path.stem)
|
| 174 |
+
author = _metadata_value(book, "creator") or "Unknown author"
|
| 175 |
+
images = _epub_images(book, ITEM_IMAGE)
|
| 176 |
+
|
| 177 |
+
toc_map = {}
|
| 178 |
+
if book.toc:
|
| 179 |
+
_build_toc_map(book.toc, toc_map)
|
| 180 |
+
|
| 181 |
+
# Let's perform a dual-pass load. Pass 1 attempts high-precision TOC-only document inclusion.
|
| 182 |
+
# If that yields 0 sections (or very few), Pass 2 loads all spine documents to avoid content loss.
|
| 183 |
+
sections: list[ParsedSection] = []
|
| 184 |
+
|
| 185 |
+
for pass_num in (1, 2):
|
| 186 |
+
if sections:
|
| 187 |
+
break
|
| 188 |
+
use_toc_filtering = (pass_num == 1) and (len(toc_map) > 0)
|
| 189 |
+
|
| 190 |
+
for item in _epub_documents(book, ITEM_DOCUMENT):
|
| 191 |
+
raw_name = item.get_name() or ""
|
| 192 |
+
clean_name = raw_name.replace("\\", "/")
|
| 193 |
+
base_name = posixpath.basename(clean_name)
|
| 194 |
+
|
| 195 |
+
# Match against TOC
|
| 196 |
+
toc_title = ""
|
| 197 |
+
if clean_name in toc_map:
|
| 198 |
+
toc_title = toc_map[clean_name]
|
| 199 |
+
elif base_name in toc_map:
|
| 200 |
+
toc_title = toc_map[base_name]
|
| 201 |
+
|
| 202 |
+
# If TOC filtering is active and this spine document isn't in TOC, skip it
|
| 203 |
+
if use_toc_filtering and not toc_title:
|
| 204 |
+
continue
|
| 205 |
+
|
| 206 |
+
soup = BeautifulSoup(item.get_content(), "html.parser")
|
| 207 |
+
for tag in soup(["script", "style", "nav"]):
|
| 208 |
+
tag.decompose()
|
| 209 |
+
body = soup.body or soup
|
| 210 |
+
html = _clean_epub_html(body, item.get_name(), images)
|
| 211 |
+
|
| 212 |
+
# Determine title: Use TOC title first if found, otherwise extract/clean
|
| 213 |
+
if toc_title:
|
| 214 |
+
section_title = toc_title
|
| 215 |
+
else:
|
| 216 |
+
# Fallback to headers
|
| 217 |
+
title_tag = soup.find(["h1", "h2", "h3", "h4", "h5", "h6"])
|
| 218 |
+
section_title = title_tag.get_text(" ", strip=True) if title_tag else ""
|
| 219 |
+
if not section_title:
|
| 220 |
+
head_title = soup.find("title")
|
| 221 |
+
if head_title:
|
| 222 |
+
section_title = head_title.get_text(" ", strip=True)
|
| 223 |
+
|
| 224 |
+
if not section_title or "/" in section_title or section_title.endswith((".html", ".xhtml", ".xml", ".htm")):
|
| 225 |
+
basename = posixpath.basename((section_title or raw_name).replace("\\", "/"))
|
| 226 |
+
num_match = re.search(r"split_(\d+)", basename)
|
| 227 |
+
if num_match:
|
| 228 |
+
section_title = f"Chapter {int(num_match.group(1)) + 1}"
|
| 229 |
+
else:
|
| 230 |
+
num_match_generic = re.search(r"(\d+)", basename)
|
| 231 |
+
if num_match_generic:
|
| 232 |
+
section_title = f"Chapter {int(num_match_generic.group(1))}"
|
| 233 |
+
else:
|
| 234 |
+
stem = posixpath.splitext(basename)[0]
|
| 235 |
+
stem = stem.replace("_", " ").replace("-", " ").title()
|
| 236 |
+
section_title = stem or f"Chapter {len(sections) + 1}"
|
| 237 |
+
|
| 238 |
+
if section_title.lower() == title.lower():
|
| 239 |
+
section_title = f"Chapter {len(sections) + 1}"
|
| 240 |
+
|
| 241 |
+
text = _normalize_text(body.get_text("\n"))
|
| 242 |
+
if text or "<img" in html or "<hr" in html:
|
| 243 |
+
sections.append(
|
| 244 |
+
ParsedSection(
|
| 245 |
+
index=len(sections),
|
| 246 |
+
title=section_title.strip() or f"Chapter {len(sections) + 1}",
|
| 247 |
+
text=text or section_title or "Illustration",
|
| 248 |
+
source_locator=f"epub:{raw_name}",
|
| 249 |
+
html=html,
|
| 250 |
+
)
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
if not sections:
|
| 254 |
+
sections = [_empty_section()]
|
| 255 |
+
return ParsedBook(unescape(title), unescape(author), "epub", sections)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _epub_documents(book, item_document) -> list:
|
| 259 |
+
documents = []
|
| 260 |
+
seen = set()
|
| 261 |
+
for spine_item in book.spine:
|
| 262 |
+
item_id = spine_item[0] if isinstance(spine_item, tuple) else spine_item
|
| 263 |
+
item = book.get_item_with_id(item_id)
|
| 264 |
+
if item and item.get_type() == item_document:
|
| 265 |
+
documents.append(item)
|
| 266 |
+
seen.add(item.get_name())
|
| 267 |
+
for item in book.get_items_of_type(item_document):
|
| 268 |
+
if item.get_name() not in seen:
|
| 269 |
+
documents.append(item)
|
| 270 |
+
return documents
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _epub_images(book, item_image) -> dict[str, str]:
|
| 274 |
+
images = {}
|
| 275 |
+
for item in book.get_items_of_type(item_image):
|
| 276 |
+
media_type = item.media_type or "image/png"
|
| 277 |
+
data = base64.b64encode(item.get_content()).decode("ascii")
|
| 278 |
+
images[item.get_name()] = f"data:{media_type};base64,{data}"
|
| 279 |
+
images[posixpath.basename(item.get_name())] = images[item.get_name()]
|
| 280 |
+
return images
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _clean_epub_html(body, document_name: str, images: dict[str, str]) -> str:
|
| 284 |
+
allowed = {
|
| 285 |
+
"a", "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3", "h4",
|
| 286 |
+
"hr", "i", "img", "li", "ol", "p", "pre", "section", "span", "strong", "u", "ul",
|
| 287 |
+
}
|
| 288 |
+
for tag in body.find_all(True):
|
| 289 |
+
if tag.name not in allowed:
|
| 290 |
+
tag.unwrap()
|
| 291 |
+
continue
|
| 292 |
+
if tag.name == "img":
|
| 293 |
+
src = _resolve_epub_src(document_name, tag.get("src") or "", images)
|
| 294 |
+
if not src:
|
| 295 |
+
tag.decompose()
|
| 296 |
+
continue
|
| 297 |
+
tag.attrs = {"src": src, "alt": tag.get("alt", "")}
|
| 298 |
+
elif tag.name == "a":
|
| 299 |
+
tag.attrs = {}
|
| 300 |
+
else:
|
| 301 |
+
tag.attrs = {}
|
| 302 |
+
return "".join(str(child) for child in body.contents).strip()
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _resolve_epub_src(document_name: str, src: str, images: dict[str, str]) -> str:
|
| 306 |
+
clean = unquote(urlsplit(src).path)
|
| 307 |
+
if not clean:
|
| 308 |
+
return ""
|
| 309 |
+
candidates = [
|
| 310 |
+
clean,
|
| 311 |
+
clean.lstrip("/"),
|
| 312 |
+
posixpath.normpath(posixpath.join(posixpath.dirname(document_name), clean)),
|
| 313 |
+
posixpath.basename(clean),
|
| 314 |
+
]
|
| 315 |
+
for candidate in candidates:
|
| 316 |
+
if candidate in images:
|
| 317 |
+
return images[candidate]
|
| 318 |
+
return ""
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def parse_pdf(path: Path) -> ParsedBook:
|
| 322 |
+
import fitz
|
| 323 |
+
import base64
|
| 324 |
+
|
| 325 |
+
document = fitz.open(path)
|
| 326 |
+
sections: list[ParsedSection] = []
|
| 327 |
+
page_count = document.page_count
|
| 328 |
+
|
| 329 |
+
for page_index in range(page_count):
|
| 330 |
+
try:
|
| 331 |
+
page = document.load_page(page_index)
|
| 332 |
+
rect = page.rect
|
| 333 |
+
w, h = rect.width, rect.height
|
| 334 |
+
|
| 335 |
+
# Render page at 150 DPI for high visual fidelity
|
| 336 |
+
pix = page.get_pixmap(dpi=150)
|
| 337 |
+
img_bytes = pix.tobytes("jpeg")
|
| 338 |
+
b64_str = base64.b64encode(img_bytes).decode("utf-8")
|
| 339 |
+
|
| 340 |
+
# Extract plain text of the page for search/TTS
|
| 341 |
+
plain_text = page.get_text("text")
|
| 342 |
+
|
| 343 |
+
# Extract text blocks with coordinates for interactive selection overlay
|
| 344 |
+
blocks = page.get_text("blocks")
|
| 345 |
+
text_layers = []
|
| 346 |
+
for b in blocks:
|
| 347 |
+
x0, y0, x1, y1, text, block_no, block_type = b
|
| 348 |
+
if block_type == 0: # Text block
|
| 349 |
+
escaped_text = _escape(text).replace("\n", "<br>")
|
| 350 |
+
bw = x1 - x0
|
| 351 |
+
bh = y1 - y0
|
| 352 |
+
# Standard responsive font sizing relative to block height
|
| 353 |
+
fs = max(7, min(36, bh * 0.85))
|
| 354 |
+
text_layers.append(f"""
|
| 355 |
+
<div class="pdf-text-layer" style="position:absolute; left:{x0}px; top:{y0}px; width:{bw}px; height:{bh}px; font-size:{fs}px;">
|
| 356 |
+
{escaped_text}
|
| 357 |
+
</div>
|
| 358 |
+
""")
|
| 359 |
+
|
| 360 |
+
text_overlay_html = "".join(text_layers)
|
| 361 |
+
|
| 362 |
+
# Embed native high-fidelity rendering with a transparent interactive text selection overlay
|
| 363 |
+
html_content = f"""
|
| 364 |
+
<div class="pdf-page-container" style="position:relative; width:{w}px; height:{h}px; margin:0 auto; user-select:text;">
|
| 365 |
+
<img src="data:image/jpeg;base64,{b64_str}" class="pdf-page-img" alt="Page {page_index + 1}" style="width:100%; height:100%; display:block; pointer-events:none;" />
|
| 366 |
+
<div class="pdf-text-overlay" style="position:absolute; left:0; top:0; width:100%; height:100%; pointer-events:auto; overflow:hidden;">
|
| 367 |
+
{text_overlay_html}
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
"""
|
| 371 |
+
|
| 372 |
+
sections.append(
|
| 373 |
+
ParsedSection(
|
| 374 |
+
index=page_index,
|
| 375 |
+
title=f"Page {page_index + 1}",
|
| 376 |
+
text=plain_text,
|
| 377 |
+
html=html_content,
|
| 378 |
+
source_locator=f"pdf:page:{page_index + 1}",
|
| 379 |
+
)
|
| 380 |
+
)
|
| 381 |
+
except Exception as e:
|
| 382 |
+
# Fallback for errors
|
| 383 |
+
sections.append(
|
| 384 |
+
ParsedSection(
|
| 385 |
+
index=page_index,
|
| 386 |
+
title=f"Page {page_index + 1}",
|
| 387 |
+
text=f"[Error rendering page {page_index + 1}: {e}]",
|
| 388 |
+
html=f"<div class='pdf-page-error'><p>Error rendering page {page_index + 1}</p></div>",
|
| 389 |
+
source_locator=f"pdf:page:{page_index + 1}",
|
| 390 |
+
)
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
document.close()
|
| 394 |
+
return ParsedBook(_clean_title(path.stem), "Unknown author", "pdf", sections or [_empty_section()])
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _metadata_value(book, key: str) -> str:
|
| 398 |
+
values = book.get_metadata("DC", key)
|
| 399 |
+
if not values:
|
| 400 |
+
return ""
|
| 401 |
+
return str(values[0][0]).strip()
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _normalize_text(text: str) -> str:
|
| 405 |
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
| 406 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 407 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 408 |
+
return text.strip()
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _chunk_text(text: str, max_chars: int) -> list[str]:
|
| 412 |
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
| 413 |
+
chunks: list[str] = []
|
| 414 |
+
current: list[str] = []
|
| 415 |
+
current_size = 0
|
| 416 |
+
for paragraph in paragraphs:
|
| 417 |
+
if current and current_size + len(paragraph) > max_chars:
|
| 418 |
+
chunks.append("\n\n".join(current))
|
| 419 |
+
current = []
|
| 420 |
+
current_size = 0
|
| 421 |
+
current.append(paragraph)
|
| 422 |
+
current_size += len(paragraph)
|
| 423 |
+
if current:
|
| 424 |
+
chunks.append("\n\n".join(current))
|
| 425 |
+
return chunks or [text[:max_chars]]
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def _clean_title(value: str) -> str:
|
| 429 |
+
return value.replace("_", " ").replace("-", " ").strip().title() or "Untitled"
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _empty_section() -> ParsedSection:
|
| 433 |
+
return ParsedSection(0, "Empty Book", "No readable text was found in this file.", "empty")
|
novel_reader/storage.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import shutil
|
| 4 |
+
import sqlite3
|
| 5 |
+
import uuid
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Iterator
|
| 10 |
+
|
| 11 |
+
from .config import DATABASE_PATH, NOVELS_DIR, ensure_app_dirs
|
| 12 |
+
from .models import ParsedBook
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def utc_now() -> str:
|
| 16 |
+
return datetime.now(timezone.utc).isoformat()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class LibraryStore:
|
| 20 |
+
def __init__(self, db_path: Path = DATABASE_PATH) -> None:
|
| 21 |
+
ensure_app_dirs()
|
| 22 |
+
self.db_path = db_path
|
| 23 |
+
self.init_db()
|
| 24 |
+
|
| 25 |
+
@contextmanager
|
| 26 |
+
def connect(self) -> Iterator[sqlite3.Connection]:
|
| 27 |
+
conn = sqlite3.connect(self.db_path)
|
| 28 |
+
conn.row_factory = sqlite3.Row
|
| 29 |
+
try:
|
| 30 |
+
yield conn
|
| 31 |
+
conn.commit()
|
| 32 |
+
finally:
|
| 33 |
+
conn.close()
|
| 34 |
+
|
| 35 |
+
def init_db(self) -> None:
|
| 36 |
+
with self.connect() as conn:
|
| 37 |
+
conn.executescript(
|
| 38 |
+
"""
|
| 39 |
+
CREATE TABLE IF NOT EXISTS novels (
|
| 40 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 41 |
+
uuid TEXT NOT NULL UNIQUE,
|
| 42 |
+
title TEXT NOT NULL,
|
| 43 |
+
author TEXT NOT NULL DEFAULT 'Unknown author',
|
| 44 |
+
file_format TEXT NOT NULL,
|
| 45 |
+
original_filename TEXT NOT NULL,
|
| 46 |
+
stored_path TEXT NOT NULL,
|
| 47 |
+
rag_path TEXT NOT NULL,
|
| 48 |
+
status TEXT NOT NULL DEFAULT 'queued',
|
| 49 |
+
status_message TEXT NOT NULL DEFAULT '',
|
| 50 |
+
archived INTEGER NOT NULL DEFAULT 0,
|
| 51 |
+
completed_at TEXT,
|
| 52 |
+
created_at TEXT NOT NULL,
|
| 53 |
+
updated_at TEXT NOT NULL
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
CREATE TABLE IF NOT EXISTS sections (
|
| 57 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 58 |
+
novel_id INTEGER NOT NULL REFERENCES novels(id) ON DELETE CASCADE,
|
| 59 |
+
section_index INTEGER NOT NULL,
|
| 60 |
+
title TEXT NOT NULL,
|
| 61 |
+
text TEXT NOT NULL,
|
| 62 |
+
html TEXT NOT NULL DEFAULT '',
|
| 63 |
+
source_locator TEXT NOT NULL,
|
| 64 |
+
UNIQUE(novel_id, section_index)
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
CREATE TABLE IF NOT EXISTS progress (
|
| 68 |
+
novel_id INTEGER PRIMARY KEY REFERENCES novels(id) ON DELETE CASCADE,
|
| 69 |
+
section_index INTEGER NOT NULL DEFAULT 0,
|
| 70 |
+
scroll_hint REAL NOT NULL DEFAULT 0,
|
| 71 |
+
updated_at TEXT NOT NULL
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
CREATE TABLE IF NOT EXISTS bookmarks (
|
| 75 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 76 |
+
novel_id INTEGER NOT NULL REFERENCES novels(id) ON DELETE CASCADE,
|
| 77 |
+
section_index INTEGER NOT NULL,
|
| 78 |
+
label TEXT NOT NULL,
|
| 79 |
+
note TEXT NOT NULL DEFAULT '',
|
| 80 |
+
created_at TEXT NOT NULL
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
CREATE TABLE IF NOT EXISTS highlights (
|
| 84 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 85 |
+
novel_id INTEGER NOT NULL REFERENCES novels(id) ON DELETE CASCADE,
|
| 86 |
+
section_index INTEGER NOT NULL,
|
| 87 |
+
quote TEXT NOT NULL,
|
| 88 |
+
note TEXT NOT NULL DEFAULT '',
|
| 89 |
+
color TEXT NOT NULL DEFAULT 'gold',
|
| 90 |
+
created_at TEXT NOT NULL
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
CREATE TABLE IF NOT EXISTS dictionary_lookups (
|
| 94 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 95 |
+
novel_id INTEGER REFERENCES novels(id) ON DELETE SET NULL,
|
| 96 |
+
section_index INTEGER NOT NULL DEFAULT 0,
|
| 97 |
+
query TEXT NOT NULL,
|
| 98 |
+
search_url TEXT NOT NULL,
|
| 99 |
+
created_at TEXT NOT NULL
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
"""
|
| 103 |
+
)
|
| 104 |
+
columns = {row["name"] for row in conn.execute("PRAGMA table_info(sections)")}
|
| 105 |
+
if "html" not in columns:
|
| 106 |
+
conn.execute("ALTER TABLE sections ADD COLUMN html TEXT NOT NULL DEFAULT ''")
|
| 107 |
+
novel_columns = {row["name"] for row in conn.execute("PRAGMA table_info(novels)")}
|
| 108 |
+
if "archived" not in novel_columns:
|
| 109 |
+
conn.execute("ALTER TABLE novels ADD COLUMN archived INTEGER NOT NULL DEFAULT 0")
|
| 110 |
+
if "completed_at" not in novel_columns:
|
| 111 |
+
conn.execute("ALTER TABLE novels ADD COLUMN completed_at TEXT")
|
| 112 |
+
if "cover_image" not in novel_columns:
|
| 113 |
+
conn.execute("ALTER TABLE novels ADD COLUMN cover_image TEXT NOT NULL DEFAULT ''")
|
| 114 |
+
if "series" not in novel_columns:
|
| 115 |
+
conn.execute("ALTER TABLE novels ADD COLUMN series TEXT NOT NULL DEFAULT ''")
|
| 116 |
+
if "genres" not in novel_columns:
|
| 117 |
+
conn.execute("ALTER TABLE novels ADD COLUMN genres TEXT NOT NULL DEFAULT ''")
|
| 118 |
+
if "file_size" not in novel_columns:
|
| 119 |
+
conn.execute("ALTER TABLE novels ADD COLUMN file_size INTEGER NOT NULL DEFAULT 0")
|
| 120 |
+
if "starred" not in novel_columns:
|
| 121 |
+
conn.execute("ALTER TABLE novels ADD COLUMN starred INTEGER NOT NULL DEFAULT 0")
|
| 122 |
+
|
| 123 |
+
def create_novel_record(self, source_path: Path) -> int:
|
| 124 |
+
book_uuid = uuid.uuid4().hex
|
| 125 |
+
novel_dir = NOVELS_DIR / book_uuid
|
| 126 |
+
uploads_dir = novel_dir / "source"
|
| 127 |
+
rag_dir = novel_dir / "rag"
|
| 128 |
+
uploads_dir.mkdir(parents=True, exist_ok=True)
|
| 129 |
+
rag_dir.mkdir(parents=True, exist_ok=True)
|
| 130 |
+
stored_path = uploads_dir / source_path.name
|
| 131 |
+
shutil.copy2(source_path, stored_path)
|
| 132 |
+
now = utc_now()
|
| 133 |
+
with self.connect() as conn:
|
| 134 |
+
cursor = conn.execute(
|
| 135 |
+
"""
|
| 136 |
+
INSERT INTO novels
|
| 137 |
+
(uuid, title, author, file_format, original_filename, stored_path, rag_path, status, status_message, created_at, updated_at)
|
| 138 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 'Waiting to parse book', ?, ?)
|
| 139 |
+
""",
|
| 140 |
+
(
|
| 141 |
+
book_uuid,
|
| 142 |
+
source_path.stem,
|
| 143 |
+
"Unknown author",
|
| 144 |
+
source_path.suffix.lower().lstrip("."),
|
| 145 |
+
source_path.name,
|
| 146 |
+
str(stored_path),
|
| 147 |
+
str(rag_dir),
|
| 148 |
+
now,
|
| 149 |
+
now,
|
| 150 |
+
),
|
| 151 |
+
)
|
| 152 |
+
return int(cursor.lastrowid)
|
| 153 |
+
|
| 154 |
+
def set_status(self, novel_id: int, status: str, message: str = "") -> None:
|
| 155 |
+
with self.connect() as conn:
|
| 156 |
+
conn.execute(
|
| 157 |
+
"UPDATE novels SET status = ?, status_message = ?, updated_at = ? WHERE id = ?",
|
| 158 |
+
(status, message, utc_now(), novel_id),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def save_parsed_book(self, novel_id: int, parsed: ParsedBook) -> None:
|
| 162 |
+
now = utc_now()
|
| 163 |
+
with self.connect() as conn:
|
| 164 |
+
conn.execute("DELETE FROM sections WHERE novel_id = ?", (novel_id,))
|
| 165 |
+
conn.executemany(
|
| 166 |
+
"""
|
| 167 |
+
INSERT INTO sections (novel_id, section_index, title, text, html, source_locator)
|
| 168 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 169 |
+
""",
|
| 170 |
+
[
|
| 171 |
+
(novel_id, section.index, section.title, section.text, section.html, section.source_locator)
|
| 172 |
+
for section in parsed.sections
|
| 173 |
+
],
|
| 174 |
+
)
|
| 175 |
+
conn.execute(
|
| 176 |
+
"""
|
| 177 |
+
UPDATE novels
|
| 178 |
+
SET title = ?, author = ?, file_format = ?, status = 'complete',
|
| 179 |
+
status_message = ?, updated_at = ?
|
| 180 |
+
WHERE id = ?
|
| 181 |
+
""",
|
| 182 |
+
(parsed.title, parsed.author, parsed.file_format, f"Ready: {len(parsed.sections)} sections", now, novel_id),
|
| 183 |
+
)
|
| 184 |
+
conn.execute(
|
| 185 |
+
"""
|
| 186 |
+
INSERT INTO progress (novel_id, section_index, updated_at)
|
| 187 |
+
VALUES (?, 0, ?)
|
| 188 |
+
ON CONFLICT(novel_id) DO NOTHING
|
| 189 |
+
""",
|
| 190 |
+
(novel_id, now),
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
def list_novels(self, include_archived: bool = False) -> list[dict[str, Any]]:
|
| 194 |
+
where = "" if include_archived else "WHERE n.archived = 0"
|
| 195 |
+
with self.connect() as conn:
|
| 196 |
+
rows = conn.execute(
|
| 197 |
+
f"""
|
| 198 |
+
SELECT n.*, COALESCE(p.section_index, 0) AS progress_section,
|
| 199 |
+
(SELECT COUNT(*) FROM sections s WHERE s.novel_id = n.id) AS section_count,
|
| 200 |
+
(SELECT s.text FROM sections s WHERE s.novel_id = n.id ORDER BY s.section_index LIMIT 1) AS first_section_text
|
| 201 |
+
FROM novels n
|
| 202 |
+
LEFT JOIN progress p ON p.novel_id = n.id
|
| 203 |
+
{where}
|
| 204 |
+
ORDER BY n.updated_at DESC
|
| 205 |
+
"""
|
| 206 |
+
).fetchall()
|
| 207 |
+
return [dict(row) for row in rows]
|
| 208 |
+
|
| 209 |
+
def get_novel(self, novel_id: int) -> dict[str, Any] | None:
|
| 210 |
+
with self.connect() as conn:
|
| 211 |
+
row = conn.execute(
|
| 212 |
+
"""
|
| 213 |
+
SELECT n.*, COALESCE(p.section_index, 0) AS progress_section,
|
| 214 |
+
(SELECT COUNT(*) FROM sections s WHERE s.novel_id = n.id) AS section_count,
|
| 215 |
+
(SELECT s.text FROM sections s WHERE s.novel_id = n.id ORDER BY s.section_index LIMIT 1) AS first_section_text
|
| 216 |
+
FROM novels n
|
| 217 |
+
LEFT JOIN progress p ON p.novel_id = n.id
|
| 218 |
+
WHERE n.id = ?
|
| 219 |
+
""",
|
| 220 |
+
(novel_id,),
|
| 221 |
+
).fetchone()
|
| 222 |
+
return dict(row) if row else None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def get_section(self, novel_id: int, section_index: int) -> dict[str, Any] | None:
|
| 226 |
+
with self.connect() as conn:
|
| 227 |
+
row = conn.execute(
|
| 228 |
+
"""
|
| 229 |
+
SELECT s.*, n.title AS novel_title, n.author, n.status
|
| 230 |
+
FROM sections s
|
| 231 |
+
JOIN novels n ON n.id = s.novel_id
|
| 232 |
+
WHERE s.novel_id = ? AND s.section_index = ?
|
| 233 |
+
""",
|
| 234 |
+
(novel_id, section_index),
|
| 235 |
+
).fetchone()
|
| 236 |
+
return dict(row) if row else None
|
| 237 |
+
|
| 238 |
+
def list_sections(self, novel_id: int) -> list[dict[str, Any]]:
|
| 239 |
+
with self.connect() as conn:
|
| 240 |
+
rows = conn.execute(
|
| 241 |
+
"""
|
| 242 |
+
SELECT section_index, title
|
| 243 |
+
FROM sections
|
| 244 |
+
WHERE novel_id = ?
|
| 245 |
+
ORDER BY section_index
|
| 246 |
+
""",
|
| 247 |
+
(novel_id,),
|
| 248 |
+
).fetchall()
|
| 249 |
+
return [dict(row) for row in rows]
|
| 250 |
+
|
| 251 |
+
def section_count(self, novel_id: int) -> int:
|
| 252 |
+
with self.connect() as conn:
|
| 253 |
+
return int(conn.execute("SELECT COUNT(*) FROM sections WHERE novel_id = ?", (novel_id,)).fetchone()[0])
|
| 254 |
+
|
| 255 |
+
def first_section_text(self, novel_id: int) -> str:
|
| 256 |
+
with self.connect() as conn:
|
| 257 |
+
row = conn.execute(
|
| 258 |
+
"SELECT text FROM sections WHERE novel_id = ? ORDER BY section_index LIMIT 1",
|
| 259 |
+
(novel_id,),
|
| 260 |
+
).fetchone()
|
| 261 |
+
return str(row["text"]) if row else ""
|
| 262 |
+
|
| 263 |
+
def save_cover_image(self, novel_id: int, cover_b64: str) -> None:
|
| 264 |
+
with self.connect() as conn:
|
| 265 |
+
conn.execute(
|
| 266 |
+
"UPDATE novels SET cover_image = ?, updated_at = ? WHERE id = ?",
|
| 267 |
+
(cover_b64, utc_now(), novel_id),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
def save_extra_metadata(self, novel_id: int, series: str = "", genres: str = "", file_size: int = 0) -> None:
|
| 271 |
+
with self.connect() as conn:
|
| 272 |
+
conn.execute(
|
| 273 |
+
"UPDATE novels SET series = ?, genres = ?, file_size = ?, updated_at = ? WHERE id = ?",
|
| 274 |
+
(series, genres, file_size, utc_now(), novel_id),
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
def archive_novel(self, novel_id: int) -> None:
|
| 278 |
+
now = utc_now()
|
| 279 |
+
with self.connect() as conn:
|
| 280 |
+
conn.execute(
|
| 281 |
+
"UPDATE novels SET archived = 1, completed_at = ?, updated_at = ? WHERE id = ?",
|
| 282 |
+
(now, now, novel_id),
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
def toggle_starred(self, novel_id: int) -> None:
|
| 286 |
+
with self.connect() as conn:
|
| 287 |
+
conn.execute(
|
| 288 |
+
"UPDATE novels SET starred = 1 - starred, updated_at = ? WHERE id = ?",
|
| 289 |
+
(utc_now(), novel_id),
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
def toggle_archived(self, novel_id: int) -> None:
|
| 293 |
+
now = utc_now()
|
| 294 |
+
with self.connect() as conn:
|
| 295 |
+
row = conn.execute("SELECT archived FROM novels WHERE id = ?", (novel_id,)).fetchone()
|
| 296 |
+
if row:
|
| 297 |
+
new_val = 1 - row["archived"]
|
| 298 |
+
completed_at = now if new_val == 1 else None
|
| 299 |
+
conn.execute(
|
| 300 |
+
"UPDATE novels SET archived = ?, completed_at = ?, updated_at = ? WHERE id = ?",
|
| 301 |
+
(new_val, completed_at, now, novel_id),
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def delete_novel(self, novel_id: int) -> None:
|
| 306 |
+
novel = self.get_novel(novel_id)
|
| 307 |
+
with self.connect() as conn:
|
| 308 |
+
conn.execute("DELETE FROM dictionary_lookups WHERE novel_id = ?", (novel_id,))
|
| 309 |
+
conn.execute("DELETE FROM highlights WHERE novel_id = ?", (novel_id,))
|
| 310 |
+
conn.execute("DELETE FROM bookmarks WHERE novel_id = ?", (novel_id,))
|
| 311 |
+
conn.execute("DELETE FROM progress WHERE novel_id = ?", (novel_id,))
|
| 312 |
+
conn.execute("DELETE FROM sections WHERE novel_id = ?", (novel_id,))
|
| 313 |
+
conn.execute("DELETE FROM novels WHERE id = ?", (novel_id,))
|
| 314 |
+
if novel:
|
| 315 |
+
novel_dir = Path(str(novel["stored_path"])).parent.parent
|
| 316 |
+
if novel_dir.exists() and novel_dir.parent == NOVELS_DIR:
|
| 317 |
+
shutil.rmtree(novel_dir)
|
| 318 |
+
|
| 319 |
+
def update_progress(self, novel_id: int, section_index: int) -> None:
|
| 320 |
+
now = utc_now()
|
| 321 |
+
with self.connect() as conn:
|
| 322 |
+
conn.execute(
|
| 323 |
+
"""
|
| 324 |
+
INSERT INTO progress (novel_id, section_index, updated_at)
|
| 325 |
+
VALUES (?, ?, ?)
|
| 326 |
+
ON CONFLICT(novel_id) DO UPDATE SET section_index = excluded.section_index, updated_at = excluded.updated_at
|
| 327 |
+
""",
|
| 328 |
+
(novel_id, section_index, now),
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
def add_bookmark(self, novel_id: int, section_index: int, label: str, note: str = "") -> None:
|
| 332 |
+
with self.connect() as conn:
|
| 333 |
+
conn.execute(
|
| 334 |
+
"INSERT INTO bookmarks (novel_id, section_index, label, note, created_at) VALUES (?, ?, ?, ?, ?)",
|
| 335 |
+
(novel_id, section_index, label, note, utc_now()),
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
def add_highlight(self, novel_id: int, section_index: int, quote: str, note: str = "", color: str = "gold") -> None:
|
| 339 |
+
with self.connect() as conn:
|
| 340 |
+
conn.execute(
|
| 341 |
+
"INSERT INTO highlights (novel_id, section_index, quote, note, color, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
| 342 |
+
(novel_id, section_index, quote, note, color, utc_now()),
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
def add_dictionary_lookup(self, novel_id: int | None, section_index: int, query: str, search_url: str) -> None:
|
| 346 |
+
with self.connect() as conn:
|
| 347 |
+
conn.execute(
|
| 348 |
+
"INSERT INTO dictionary_lookups (novel_id, section_index, query, search_url, created_at) VALUES (?, ?, ?, ?, ?)",
|
| 349 |
+
(novel_id, section_index, query, search_url, utc_now()),
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def sidebar_items(self, novel_id: int | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
| 353 |
+
if not novel_id:
|
| 354 |
+
return [], [], []
|
| 355 |
+
with self.connect() as conn:
|
| 356 |
+
bookmarks = [dict(row) for row in conn.execute(
|
| 357 |
+
"SELECT * FROM bookmarks WHERE novel_id = ? ORDER BY created_at DESC LIMIT 20", (novel_id,)
|
| 358 |
+
)]
|
| 359 |
+
highlights = [dict(row) for row in conn.execute(
|
| 360 |
+
"SELECT * FROM highlights WHERE novel_id = ? ORDER BY created_at DESC LIMIT 20", (novel_id,)
|
| 361 |
+
)]
|
| 362 |
+
lookups = [dict(row) for row in conn.execute(
|
| 363 |
+
"SELECT * FROM dictionary_lookups WHERE novel_id = ? ORDER BY created_at DESC LIMIT 20", (novel_id,)
|
| 364 |
+
)]
|
| 365 |
+
return bookmarks, highlights, lookups
|
| 366 |
+
|
| 367 |
+
def list_all_bookmarks(self, novel_id: int) -> list[dict[str, Any]]:
|
| 368 |
+
with self.connect() as conn:
|
| 369 |
+
return [dict(row) for row in conn.execute(
|
| 370 |
+
"SELECT * FROM bookmarks WHERE novel_id = ? ORDER BY section_index ASC, created_at ASC", (novel_id,)
|
| 371 |
+
)]
|
| 372 |
+
|
| 373 |
+
def get_section_bookmarks(self, novel_id: int | None, section_index: int) -> list[str]:
|
| 374 |
+
if not novel_id:
|
| 375 |
+
return []
|
| 376 |
+
with self.connect() as conn:
|
| 377 |
+
return [row["label"] for row in conn.execute(
|
| 378 |
+
"SELECT label FROM bookmarks WHERE novel_id = ? AND section_index = ?", (novel_id, section_index)
|
| 379 |
+
)]
|
| 380 |
+
|
| 381 |
+
def get_section_lookups(self, novel_id: int | None, section_index: int) -> list[str]:
|
| 382 |
+
if not novel_id:
|
| 383 |
+
return []
|
| 384 |
+
with self.connect() as conn:
|
| 385 |
+
return [row["query"] for row in conn.execute(
|
| 386 |
+
"SELECT query FROM dictionary_lookups WHERE novel_id = ? AND section_index = ?", (novel_id, section_index)
|
| 387 |
+
)]
|
novel_reader/ui.py
ADDED
|
@@ -0,0 +1,1474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import posixpath
|
| 4 |
+
import re
|
| 5 |
+
from urllib.parse import quote_plus
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
|
| 9 |
+
from .ingestion import IngestionPipeline
|
| 10 |
+
from .storage import LibraryStore
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
store = LibraryStore()
|
| 14 |
+
pipeline = IngestionPipeline(store)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_dashboard_app() -> gr.Blocks:
|
| 18 |
+
with gr.Blocks(title="Dashboard") as demo:
|
| 19 |
+
selected_id = gr.State(None)
|
| 20 |
+
library = gr.HTML()
|
| 21 |
+
detail = gr.HTML()
|
| 22 |
+
with gr.Row(elem_classes=["hidden"]):
|
| 23 |
+
select = gr.Button("Select", elem_id="action-select-novel")
|
| 24 |
+
clear = gr.Button("Clear", elem_id="action-clear-select")
|
| 25 |
+
star = gr.Button("Star", elem_id="action-star")
|
| 26 |
+
archive = gr.Button("Archive", elem_id="action-archive")
|
| 27 |
+
delete = gr.Button("Delete", elem_id="action-delete")
|
| 28 |
+
upload = gr.File(label="Add TXT, EPUB, or PDF", file_types=[".txt", ".epub", ".pdf"])
|
| 29 |
+
refresh = gr.Button("Refresh")
|
| 30 |
+
msg = gr.HTML()
|
| 31 |
+
|
| 32 |
+
demo.load(_dashboard_load, outputs=[library, detail, selected_id, msg])
|
| 33 |
+
refresh.click(_dashboard_refresh, outputs=[library, detail, selected_id, msg])
|
| 34 |
+
upload.upload(_upload, upload, [library, detail, selected_id, msg])
|
| 35 |
+
select.click(_dashboard_select, outputs=[library, detail, selected_id, msg])
|
| 36 |
+
clear.click(_dashboard_clear, outputs=[library, detail, selected_id, msg])
|
| 37 |
+
star.click(_toggle_star, selected_id, [library, detail, selected_id, msg])
|
| 38 |
+
archive.click(_toggle_archive, selected_id, [library, detail, selected_id, msg])
|
| 39 |
+
delete.click(_delete, selected_id, [library, detail, selected_id, msg])
|
| 40 |
+
return demo
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def build_reader_app() -> gr.Blocks:
|
| 44 |
+
with gr.Blocks(title="Reader") as demo:
|
| 45 |
+
state = gr.State(_state())
|
| 46 |
+
with gr.Row(elem_classes=["reader-grid"]):
|
| 47 |
+
with gr.Column(scale=1, min_width=230, elem_classes=["chapter-rail"]):
|
| 48 |
+
gr.HTML("<a class='rail-link' href='/dashboard/'>Dashboard</a>")
|
| 49 |
+
chapters = gr.Radio(label="Chapters", choices=[], interactive=True, elem_classes=["chapters"])
|
| 50 |
+
with gr.Column(scale=5, min_width=560, elem_classes=["reader-body"]):
|
| 51 |
+
top = gr.HTML()
|
| 52 |
+
page = gr.HTML(_empty_page())
|
| 53 |
+
settings_panel = gr.HTML(elem_id="settings-popup-panel")
|
| 54 |
+
msg = gr.HTML()
|
| 55 |
+
with gr.Row(elem_classes=["hidden"]):
|
| 56 |
+
prev = gr.Button("prev", elem_id="nr-prev")
|
| 57 |
+
next_ = gr.Button("next", elem_id="nr-next")
|
| 58 |
+
back = gr.Button("back", elem_id="nr-back")
|
| 59 |
+
forward = gr.Button("forward", elem_id="nr-forward")
|
| 60 |
+
font_up = gr.Button("font up", elem_id="nr-font-up")
|
| 61 |
+
font_down = gr.Button("font down", elem_id="nr-font-down")
|
| 62 |
+
sepia = gr.Button("sepia", elem_id="nr-sepia")
|
| 63 |
+
light = gr.Button("light", elem_id="nr-light")
|
| 64 |
+
dark = gr.Button("dark", elem_id="nr-dark")
|
| 65 |
+
speak = gr.Button("speak", elem_id="nr-speak")
|
| 66 |
+
selected = gr.Textbox(elem_id="nr-selected-text", container=False, show_label=False)
|
| 67 |
+
bookmark = gr.Button("bookmark", elem_id="nr-bookmark")
|
| 68 |
+
define = gr.Button("define", elem_id="nr-define")
|
| 69 |
+
speak_selected = gr.Button("speak selected", elem_id="nr-speak-selected")
|
| 70 |
+
target_jump = gr.Textbox(elem_id="nr-target-jump", container=False, show_label=False)
|
| 71 |
+
do_jump = gr.Button("jump", elem_id="nr-do-jump")
|
| 72 |
+
|
| 73 |
+
demo.load(_reader_load, outputs=[page, top, chapters, settings_panel, msg, state])
|
| 74 |
+
chapters.change(_jump, [chapters, state], [page, top, chapters, settings_panel, msg, state])
|
| 75 |
+
prev.click(_move, [state, gr.State(-1)], [page, top, chapters, settings_panel, msg, state])
|
| 76 |
+
next_.click(_move, [state, gr.State(1)], [page, top, chapters, settings_panel, msg, state])
|
| 77 |
+
back.click(_history, [state, gr.State("back")], [page, top, chapters, settings_panel, msg, state])
|
| 78 |
+
forward.click(_history, [state, gr.State("forward")], [page, top, chapters, settings_panel, msg, state])
|
| 79 |
+
font_up.click(_font, [state, gr.State(1)], [page, top, chapters, settings_panel, msg, state])
|
| 80 |
+
font_down.click(_font, [state, gr.State(-1)], [page, top, chapters, settings_panel, msg, state])
|
| 81 |
+
sepia.click(_theme, [state, gr.State("sepia")], [page, top, chapters, settings_panel, msg, state])
|
| 82 |
+
light.click(_theme, [state, gr.State("light")], [page, top, chapters, settings_panel, msg, state])
|
| 83 |
+
dark.click(_theme, [state, gr.State("dark")], [page, top, chapters, settings_panel, msg, state])
|
| 84 |
+
speak.click(_speak, [state, gr.State("")], msg)
|
| 85 |
+
speak_selected.click(_speak, [state, selected], msg)
|
| 86 |
+
bookmark.click(_bookmark, [state, selected], [page, top, chapters, settings_panel, msg, state])
|
| 87 |
+
define.click(_define, [state, selected], [page, top, chapters, settings_panel, msg, state])
|
| 88 |
+
do_jump.click(_jump, [target_jump, state], [page, top, chapters, settings_panel, msg, state])
|
| 89 |
+
return demo
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _dashboard_load(request: gr.Request):
|
| 93 |
+
novel_id = _request_id(request)
|
| 94 |
+
return _dashboard_outputs(novel_id)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _dashboard_refresh():
|
| 98 |
+
return _dashboard_outputs(None, "Dashboard refreshed.")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _upload(file):
|
| 102 |
+
if not file:
|
| 103 |
+
return _dashboard_outputs(None, "Choose a file first.", "warn")
|
| 104 |
+
try:
|
| 105 |
+
pipeline.ingest(file.name)
|
| 106 |
+
return _dashboard_outputs(None, "Added. Parsing continues in the background.")
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
return _dashboard_outputs(None, f"Upload failed: {exc}", "warn")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _toggle_star(novel_id):
|
| 112 |
+
novel_id = _int(novel_id)
|
| 113 |
+
if novel_id:
|
| 114 |
+
store.toggle_starred(novel_id)
|
| 115 |
+
return _dashboard_outputs(novel_id, "Star status updated.")
|
| 116 |
+
return _dashboard_outputs(None, "Choose a book first.", "warn")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _toggle_archive(novel_id):
|
| 120 |
+
novel_id = _int(novel_id)
|
| 121 |
+
if novel_id:
|
| 122 |
+
store.toggle_archived(novel_id)
|
| 123 |
+
return _dashboard_outputs(novel_id, "Read status updated.")
|
| 124 |
+
return _dashboard_outputs(None, "Choose a book first.", "warn")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _delete(novel_id):
|
| 128 |
+
novel_id = _int(novel_id)
|
| 129 |
+
if novel_id:
|
| 130 |
+
store.delete_novel(novel_id)
|
| 131 |
+
return _dashboard_outputs(None, "Book deleted.")
|
| 132 |
+
return _dashboard_outputs(None, "Choose a book first.", "warn")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _dashboard_select(request: gr.Request):
|
| 136 |
+
novel_id = _request_id(request)
|
| 137 |
+
return _dashboard_outputs(novel_id)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _dashboard_clear():
|
| 141 |
+
return _dashboard_outputs(None)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _dashboard_outputs(novel_id: int | None, message: str = "", tone: str = "ok"):
|
| 146 |
+
novel = store.get_novel(novel_id) if novel_id else None
|
| 147 |
+
return (
|
| 148 |
+
_dashboard(novel_id),
|
| 149 |
+
_detail(novel),
|
| 150 |
+
novel["id"] if novel else None,
|
| 151 |
+
_notice(message, tone),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _dashboard(selected_id: int | None = None) -> str:
|
| 156 |
+
active = [n for n in store.list_novels(include_archived=True) if not n.get("archived")]
|
| 157 |
+
archived = [n for n in store.list_novels(include_archived=True) if n.get("archived")]
|
| 158 |
+
return f"""
|
| 159 |
+
<main class="dashboard">
|
| 160 |
+
<header>
|
| 161 |
+
<div>
|
| 162 |
+
<p>Novel Reader</p>
|
| 163 |
+
<h1>Dashboard</h1>
|
| 164 |
+
<small>{len(active)} active · {len(archived)} archived</small>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="dashboard-theme-selector">
|
| 167 |
+
<button data-theme-set="sepia" class="theme-btn sepia-btn">Sepia</button>
|
| 168 |
+
<button data-theme-set="light" class="theme-btn light-btn">Light</button>
|
| 169 |
+
<button data-theme-set="dark" class="theme-btn dark-btn">Dark</button>
|
| 170 |
+
</div>
|
| 171 |
+
</header>
|
| 172 |
+
<h2>Library</h2>
|
| 173 |
+
<section class="cards">{''.join(_card(n, selected_id) for n in active) or "<div class='empty'>No books yet. Add a novel to begin.</div>"}</section>
|
| 174 |
+
<h2>Archive</h2>
|
| 175 |
+
<section class="cards archive">{''.join(_card(n, selected_id) for n in archived) or "<div class='empty'>Completed books will appear here.</div>"}</section>
|
| 176 |
+
</main>
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _get_cover_html(novel: dict) -> str:
|
| 181 |
+
cover_image = novel.get("cover_image")
|
| 182 |
+
if cover_image:
|
| 183 |
+
return f'<img class="book-cover-img" src="{cover_image}" alt="Cover" />'
|
| 184 |
+
|
| 185 |
+
# Beautiful text fallback cover
|
| 186 |
+
first_text = novel.get("first_section_text") or ""
|
| 187 |
+
# Strip HTML tags just in case
|
| 188 |
+
first_text = re.sub(r'<[^>]*>', '', first_text)
|
| 189 |
+
preview = first_text.strip()[:140]
|
| 190 |
+
if len(first_text) > 140:
|
| 191 |
+
preview += "..."
|
| 192 |
+
if not preview:
|
| 193 |
+
preview = "No text content available."
|
| 194 |
+
|
| 195 |
+
return f"""
|
| 196 |
+
<div class="book-cover-fallback">
|
| 197 |
+
<div class="fallback-header">{_escape(novel['file_format'].upper())}</div>
|
| 198 |
+
<div class="fallback-title">{_escape(novel['title'])}</div>
|
| 199 |
+
<div class="fallback-divider"></div>
|
| 200 |
+
<div class="fallback-body">{_escape(preview)}</div>
|
| 201 |
+
<div class="fallback-footer">{_escape(novel['author'])}</div>
|
| 202 |
+
</div>
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _card(novel: dict, selected_id: int | None) -> str:
|
| 207 |
+
count = int(novel.get("section_count") or 0)
|
| 208 |
+
done = int(novel.get("progress_section") or 0)
|
| 209 |
+
progress = int(((done + 1) / count) * 100) if count else 0
|
| 210 |
+
selected = " selected" if novel["id"] == selected_id else ""
|
| 211 |
+
|
| 212 |
+
cover_html = _get_cover_html(novel)
|
| 213 |
+
|
| 214 |
+
star_badge = ""
|
| 215 |
+
if novel.get("starred"):
|
| 216 |
+
star_badge = '<div class="star-badge" title="Starred">★</div>'
|
| 217 |
+
|
| 218 |
+
return f"""
|
| 219 |
+
<a class="book-card{selected}" href="/dashboard?novel_id={novel['id']}">
|
| 220 |
+
<div class="card-cover-container">
|
| 221 |
+
{cover_html}
|
| 222 |
+
{star_badge}
|
| 223 |
+
</div>
|
| 224 |
+
<div class="card-info">
|
| 225 |
+
<span class="card-format">{_escape(novel['file_format'].upper())}</span>
|
| 226 |
+
<strong class="card-title">{_escape(novel['title'])}</strong>
|
| 227 |
+
<small class="card-author">{_escape(novel['author'])}</small>
|
| 228 |
+
<div class="card-progress"><b style="width:{progress}%"></b></div>
|
| 229 |
+
<span class="card-progress-text">{progress}% read</span>
|
| 230 |
+
</div>
|
| 231 |
+
</a>
|
| 232 |
+
"""
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _detail(novel: dict | None) -> str:
|
| 236 |
+
if not novel:
|
| 237 |
+
return ""
|
| 238 |
+
|
| 239 |
+
novel_id = novel["id"]
|
| 240 |
+
starred = bool(novel.get("starred"))
|
| 241 |
+
archived = bool(novel.get("archived"))
|
| 242 |
+
|
| 243 |
+
# SVG icons for logo buttons
|
| 244 |
+
star_icon = """<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>"""
|
| 245 |
+
if starred:
|
| 246 |
+
star_icon = """<svg class="icon-svg filled" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>"""
|
| 247 |
+
|
| 248 |
+
archive_icon = """<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>"""
|
| 249 |
+
if archived:
|
| 250 |
+
archive_icon = """<svg class="icon-svg filled" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>"""
|
| 251 |
+
|
| 252 |
+
delete_icon = """<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>"""
|
| 253 |
+
|
| 254 |
+
continue_icon = """<svg class="icon-svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>"""
|
| 255 |
+
|
| 256 |
+
meta_items = []
|
| 257 |
+
|
| 258 |
+
author = novel.get("author")
|
| 259 |
+
if author and author != "Unknown author":
|
| 260 |
+
meta_items.append(f"""
|
| 261 |
+
<div class="meta-row">
|
| 262 |
+
<span class="meta-label">Author</span>
|
| 263 |
+
<span class="meta-value">{_escape(author)}</span>
|
| 264 |
+
</div>
|
| 265 |
+
""")
|
| 266 |
+
|
| 267 |
+
series = novel.get("series")
|
| 268 |
+
if series:
|
| 269 |
+
meta_items.append(f"""
|
| 270 |
+
<div class="meta-row">
|
| 271 |
+
<span class="meta-label">Series</span>
|
| 272 |
+
<span class="meta-value">{_escape(series)}</span>
|
| 273 |
+
</div>
|
| 274 |
+
""")
|
| 275 |
+
|
| 276 |
+
genres = novel.get("genres")
|
| 277 |
+
if genres:
|
| 278 |
+
meta_items.append(f"""
|
| 279 |
+
<div class="meta-row">
|
| 280 |
+
<span class="meta-label">Genres</span>
|
| 281 |
+
<span class="meta-value">{_escape(genres)}</span>
|
| 282 |
+
</div>
|
| 283 |
+
""")
|
| 284 |
+
|
| 285 |
+
count = int(novel.get("section_count") or 0)
|
| 286 |
+
done = int(novel.get("progress_section") or 0)
|
| 287 |
+
percent = int(((done + 1) / count) * 100) if count else 0
|
| 288 |
+
meta_items.append(f"""
|
| 289 |
+
<div class="meta-row">
|
| 290 |
+
<span class="meta-label">Progress</span>
|
| 291 |
+
<span class="meta-value">{percent}% ({done + 1} / {count} sections)</span>
|
| 292 |
+
</div>
|
| 293 |
+
""")
|
| 294 |
+
|
| 295 |
+
meta_items.append(f"""
|
| 296 |
+
<div class="meta-row">
|
| 297 |
+
<span class="meta-label">Last Read</span>
|
| 298 |
+
<span class="meta-value">{_format_date(novel.get("updated_at"))}</span>
|
| 299 |
+
</div>
|
| 300 |
+
""")
|
| 301 |
+
|
| 302 |
+
file_size = novel.get("file_size") or 0
|
| 303 |
+
if file_size:
|
| 304 |
+
meta_items.append(f"""
|
| 305 |
+
<div class="meta-row">
|
| 306 |
+
<span class="meta-label">Size</span>
|
| 307 |
+
<span class="meta-value">{_format_size(file_size)}</span>
|
| 308 |
+
</div>
|
| 309 |
+
""")
|
| 310 |
+
|
| 311 |
+
metadata_html = "".join(meta_items)
|
| 312 |
+
cover_html = _get_cover_html(novel)
|
| 313 |
+
|
| 314 |
+
starred_class = "active" if starred else ""
|
| 315 |
+
archived_class = "active" if archived else ""
|
| 316 |
+
read_label = "Read Again" if archived else "Continue Reading"
|
| 317 |
+
|
| 318 |
+
return f"""
|
| 319 |
+
<div id="detail-modal" class="modal-overlay show" onclick="if(event.target === this) closeModal();">
|
| 320 |
+
<div class="modal-content">
|
| 321 |
+
<button class="modal-close" onclick="closeModal();" title="Close modal">×</button>
|
| 322 |
+
|
| 323 |
+
<div class="modal-cover-wrapper">
|
| 324 |
+
{cover_html}
|
| 325 |
+
</div>
|
| 326 |
+
|
| 327 |
+
<h1 class="modal-title">{_escape(novel['title'])}</h1>
|
| 328 |
+
|
| 329 |
+
<div class="modal-actions">
|
| 330 |
+
<!-- Star button -->
|
| 331 |
+
<button class="action-btn star-btn {starred_class}" onclick="document.getElementById('action-star').click();" title="Toggle Star">
|
| 332 |
+
{star_icon}
|
| 333 |
+
</button>
|
| 334 |
+
|
| 335 |
+
<!-- Have read button -->
|
| 336 |
+
<button class="action-btn archive-btn {archived_class}" onclick="document.getElementById('action-archive').click();" title="Toggle Read Status">
|
| 337 |
+
{archive_icon}
|
| 338 |
+
</button>
|
| 339 |
+
|
| 340 |
+
<!-- Delete button -->
|
| 341 |
+
<button class="action-btn delete-btn" onclick="if(confirm('Are you sure you want to delete this book?')) document.getElementById('action-delete').click();" title="Delete Book">
|
| 342 |
+
{delete_icon}
|
| 343 |
+
</button>
|
| 344 |
+
|
| 345 |
+
<!-- Continue reading button -->
|
| 346 |
+
<a class="action-btn continue-btn" href="/reader/?novel_id={novel_id}" title="{read_label}">
|
| 347 |
+
{continue_icon}
|
| 348 |
+
</a>
|
| 349 |
+
</div>
|
| 350 |
+
|
| 351 |
+
<div class="modal-divider"></div>
|
| 352 |
+
|
| 353 |
+
<div class="modal-metadata">
|
| 354 |
+
{metadata_html}
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
"""
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _format_size(bytes_val: int) -> str:
|
| 362 |
+
if not bytes_val:
|
| 363 |
+
return "Unknown size"
|
| 364 |
+
val = float(bytes_val)
|
| 365 |
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
| 366 |
+
if val < 1024:
|
| 367 |
+
return f"{val:.1f} {unit}"
|
| 368 |
+
val /= 1024
|
| 369 |
+
return f"{val:.1f} TB"
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _format_date(iso_str: str | None) -> str:
|
| 373 |
+
if not iso_str:
|
| 374 |
+
return "Never"
|
| 375 |
+
try:
|
| 376 |
+
parts = iso_str.split("T")
|
| 377 |
+
date_part = parts[0]
|
| 378 |
+
time_part = parts[1][:5] if len(parts) > 1 else ""
|
| 379 |
+
return f"{date_part} {time_part}".strip()
|
| 380 |
+
except Exception:
|
| 381 |
+
return str(iso_str)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _reader_load(request: gr.Request):
|
| 386 |
+
novel_id = _request_id(request)
|
| 387 |
+
if not novel_id:
|
| 388 |
+
return _empty_page(), "", gr.update(choices=[]), "", _notice("Open a book from the dashboard.", "warn"), _state()
|
| 389 |
+
novel = store.get_novel(novel_id)
|
| 390 |
+
if not novel:
|
| 391 |
+
return _empty_page(), "", gr.update(choices=[]), "", _notice("Book not found.", "warn"), _state()
|
| 392 |
+
state = {**_state(), "novel_id": novel_id, "section": int(novel.get("progress_section") or 0)}
|
| 393 |
+
return _render(state)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _state() -> dict:
|
| 397 |
+
return {"novel_id": None, "section": 0, "history": [], "future": [], "theme": "sepia", "font": 22}
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _move(state, delta):
|
| 401 |
+
novel_id = state.get("novel_id")
|
| 402 |
+
if not novel_id:
|
| 403 |
+
return _render(state, "Open a book first.")
|
| 404 |
+
count = store.section_count(novel_id)
|
| 405 |
+
current = int(state["section"])
|
| 406 |
+
target = max(0, min(count - 1, current + int(delta)))
|
| 407 |
+
if target != current:
|
| 408 |
+
state = {**state, "section": target, "history": state["history"] + [current], "future": []}
|
| 409 |
+
return _render(state, save_progress=True)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _jump(section, state):
|
| 413 |
+
target = _int(section)
|
| 414 |
+
if target is None:
|
| 415 |
+
return _render(state)
|
| 416 |
+
current = int(state["section"])
|
| 417 |
+
if target != current:
|
| 418 |
+
state = {**state, "section": target, "history": state["history"] + [current], "future": []}
|
| 419 |
+
return _render(state, save_progress=True)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _history(state, direction):
|
| 423 |
+
history, future = list(state["history"]), list(state["future"])
|
| 424 |
+
current = int(state["section"])
|
| 425 |
+
if direction == "back" and history:
|
| 426 |
+
state = {**state, "section": history.pop(), "history": history, "future": [current] + future}
|
| 427 |
+
if direction == "forward" and future:
|
| 428 |
+
state = {**state, "section": future.pop(0), "history": history + [current], "future": future}
|
| 429 |
+
return _render(state, save_progress=True)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _font(state, delta):
|
| 433 |
+
novel_id = state.get("novel_id")
|
| 434 |
+
novel = store.get_novel(novel_id) if novel_id else None
|
| 435 |
+
is_pdf = novel and novel.get("file_format") == "pdf"
|
| 436 |
+
|
| 437 |
+
if is_pdf:
|
| 438 |
+
current_zoom = int(state.get("zoom", 100))
|
| 439 |
+
zoom_change = int(delta) * 10
|
| 440 |
+
new_zoom = max(50, min(200, current_zoom + zoom_change))
|
| 441 |
+
return _render({**state, "zoom": new_zoom})
|
| 442 |
+
else:
|
| 443 |
+
return _render({**state, "font": max(16, min(32, int(state["font"]) + int(delta)))})
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _theme(state, theme):
|
| 447 |
+
return _render({**state, "theme": theme})
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _bookmark(state, text):
|
| 451 |
+
text = (text or "").strip()
|
| 452 |
+
novel_id = state.get("novel_id")
|
| 453 |
+
if novel_id and text:
|
| 454 |
+
store.add_bookmark(novel_id, int(state["section"]), text)
|
| 455 |
+
return _render(state, "Bookmarked.")
|
| 456 |
+
return _render(state, "Select text first.")
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def _define(state, text):
|
| 460 |
+
text = (text or "").strip()
|
| 461 |
+
novel_id = state.get("novel_id")
|
| 462 |
+
if novel_id and text:
|
| 463 |
+
store.add_dictionary_lookup(novel_id, int(state["section"]), text, _meaning_url(text))
|
| 464 |
+
return _render(state, f"Defined: {text}")
|
| 465 |
+
return _render(state, "Select word first.")
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def _speak(state, text):
|
| 469 |
+
detail = f": {_escape((text or '').strip()[:48])}" if (text or "").strip() else ""
|
| 470 |
+
return _notice(f"Speak queued for future TTS{detail}.")
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _render(state, message: str = "", save_progress: bool = False):
|
| 474 |
+
novel_id = state.get("novel_id")
|
| 475 |
+
if not novel_id:
|
| 476 |
+
return _empty_page(), "", gr.update(choices=[]), "", _notice(message, "warn") if message else "", state
|
| 477 |
+
novel = store.get_novel(novel_id)
|
| 478 |
+
if not novel:
|
| 479 |
+
return _empty_page(), "", gr.update(choices=[]), "", _notice("Book not found.", "warn"), state
|
| 480 |
+
if novel["status"] != "complete":
|
| 481 |
+
return _waiting_page(novel, state), _top(novel, None, state), _chapter_update(novel_id, state), _settings_panel(novel_id, state), _notice(novel["status_message"]), state
|
| 482 |
+
|
| 483 |
+
count = store.section_count(novel_id)
|
| 484 |
+
state = {**state, "section": max(0, min(count - 1, int(state["section"])))}
|
| 485 |
+
if save_progress:
|
| 486 |
+
store.update_progress(novel_id, int(state["section"]))
|
| 487 |
+
section = store.get_section(novel_id, int(state["section"]))
|
| 488 |
+
return _page(section, count, state), _top(novel, section, state), _chapter_update(novel_id, state), _settings_panel(novel_id, state), _notice(message), state
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _clean_chapter_title(title: str, index: int) -> str:
|
| 492 |
+
if not title:
|
| 493 |
+
return f"Chapter {index + 1}"
|
| 494 |
+
title = title.strip()
|
| 495 |
+
# Check if it looks like a filepath or ends with file extensions
|
| 496 |
+
if "/" in title or "\\" in title or title.lower().endswith((".html", ".xhtml", ".xml", ".htm")):
|
| 497 |
+
basename = posixpath.basename(title.replace("\\", "/"))
|
| 498 |
+
# split_003 -> Chapter 4
|
| 499 |
+
num_match = re.search(r"split_(\d+)", basename)
|
| 500 |
+
if num_match:
|
| 501 |
+
return f"Chapter {int(num_match.group(1)) + 1}"
|
| 502 |
+
# generic number search, e.g. chapter_04 -> Chapter 4
|
| 503 |
+
num_match_generic = re.search(r"(\d+)", basename)
|
| 504 |
+
if num_match_generic:
|
| 505 |
+
return f"Chapter {int(num_match_generic.group(1))}"
|
| 506 |
+
# stem title fallback
|
| 507 |
+
stem = posixpath.splitext(basename)[0]
|
| 508 |
+
stem = stem.replace("_", " ").replace("-", " ").title()
|
| 509 |
+
return stem or f"Chapter {index + 1}"
|
| 510 |
+
return title
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def _top(novel: dict, section: dict | None, state: dict) -> str:
|
| 514 |
+
count = store.section_count(novel["id"]) if novel and novel["status"] == "complete" else 0
|
| 515 |
+
progress = f"{section['section_index'] + 1}/{count}" if section else ""
|
| 516 |
+
title = _clean_chapter_title(section["title"], section["section_index"]) if section else novel["title"]
|
| 517 |
+
return f"""
|
| 518 |
+
<header class="top theme-{state['theme']}">
|
| 519 |
+
<div><small>{_escape(novel['title'])}</small><strong>{_escape(title)}</strong></div>
|
| 520 |
+
<nav>
|
| 521 |
+
<a href="/dashboard/">Dashboard</a>
|
| 522 |
+
<button data-click="nr-back">Back</button><button data-click="nr-prev">Prev</button>
|
| 523 |
+
<span>{_escape(progress)}</span>
|
| 524 |
+
<button data-click="nr-next">Next</button><button data-click="nr-forward">Forward</button>
|
| 525 |
+
<button class="settings-trigger" title="Settings">
|
| 526 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: block;">
|
| 527 |
+
<circle cx="12" cy="12" r="3"></circle>
|
| 528 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
| 529 |
+
</svg>
|
| 530 |
+
</button>
|
| 531 |
+
</nav>
|
| 532 |
+
</header>
|
| 533 |
+
"""
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def _chapter_update(novel_id: int, state: dict):
|
| 537 |
+
choices = [(_clean_chapter_title(s["title"], s["section_index"]), s["section_index"]) for s in store.list_sections(novel_id)]
|
| 538 |
+
return gr.update(choices=choices, value=int(state["section"]))
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _highlight_phrases(html: str, phrases: list[str], css_class: str) -> str:
|
| 542 |
+
if not phrases:
|
| 543 |
+
return html
|
| 544 |
+
phrases = sorted(list(set(phrases)), key=len, reverse=True)
|
| 545 |
+
parts = re.split(r'(<[^>]+>)', html)
|
| 546 |
+
for i in range(len(parts)):
|
| 547 |
+
if parts[i].startswith('<') and parts[i].endswith('>'):
|
| 548 |
+
continue
|
| 549 |
+
for phrase in phrases:
|
| 550 |
+
if not phrase.strip():
|
| 551 |
+
continue
|
| 552 |
+
escaped = re.escape(phrase)
|
| 553 |
+
replacement = f'<span class="{css_class}" data-phrase="{_escape(phrase)}">{phrase}</span>'
|
| 554 |
+
parts[i] = re.sub(escaped, replacement, parts[i])
|
| 555 |
+
return "".join(parts)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _page(section: dict | None, count: int, state: dict) -> str:
|
| 559 |
+
if not section:
|
| 560 |
+
return _empty_page()
|
| 561 |
+
progress = int(((section["section_index"] + 1) / max(count, 1)) * 100)
|
| 562 |
+
content = section["html"] or _paragraphs(section["text"])
|
| 563 |
+
|
| 564 |
+
novel_id = state.get("novel_id")
|
| 565 |
+
if novel_id:
|
| 566 |
+
bookmarks = store.get_section_bookmarks(novel_id, section["section_index"])
|
| 567 |
+
lookups = store.get_section_lookups(novel_id, section["section_index"])
|
| 568 |
+
content = _highlight_phrases(content, bookmarks, "bookmark-dotted")
|
| 569 |
+
content = _highlight_phrases(content, lookups, "define-dotted")
|
| 570 |
+
|
| 571 |
+
zoom_val = state.get("zoom", 100) / 100.0
|
| 572 |
+
return f"""
|
| 573 |
+
<article class="page theme-{state['theme']}" style="--font:{state['font']}px; --pdf-zoom:{zoom_val}">
|
| 574 |
+
<div class="progress"><span style="width:{progress}%"></span></div>
|
| 575 |
+
<div class="text">{content}</div>
|
| 576 |
+
</article>
|
| 577 |
+
"""
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def _waiting_page(novel: dict, state: dict) -> str:
|
| 581 |
+
return f"<article class='page theme-{state['theme']}'><div class='empty'><h2>{_escape(novel['title'])}</h2><p>{_escape(novel['status_message'])}</p></div></article>"
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _empty_page() -> str:
|
| 585 |
+
return "<article class='page theme-sepia'><div class='empty'><h2>Open a book from your dashboard.</h2></div></article>"
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def _settings_panel(novel_id: int | None, state: dict) -> str:
|
| 589 |
+
if not novel_id:
|
| 590 |
+
return ""
|
| 591 |
+
bookmarks = store.list_all_bookmarks(novel_id)
|
| 592 |
+
bookmarks_html = ""
|
| 593 |
+
for b in bookmarks:
|
| 594 |
+
section_title = f"Ch {b['section_index'] + 1}"
|
| 595 |
+
preview = _escape(b['label'][:45])
|
| 596 |
+
if len(b['label']) > 45:
|
| 597 |
+
preview += "..."
|
| 598 |
+
bookmarks_html += f"""
|
| 599 |
+
<div class="bookmark-item" data-goto-section="{b['section_index']}" data-goto-text="{_escape(b['label'])}">
|
| 600 |
+
<span class="b-sec">{section_title}</span>
|
| 601 |
+
<span class="b-text">"{preview}"</span>
|
| 602 |
+
</div>
|
| 603 |
+
"""
|
| 604 |
+
if not bookmarks:
|
| 605 |
+
bookmarks_html = "<p class='no-bookmarks'>No bookmarks in this book.</p>"
|
| 606 |
+
|
| 607 |
+
novel = store.get_novel(novel_id)
|
| 608 |
+
is_pdf = novel and novel.get("file_format") == "pdf"
|
| 609 |
+
|
| 610 |
+
theme = state.get("theme", "sepia")
|
| 611 |
+
font = state.get("font", 22)
|
| 612 |
+
active_sepia = "active" if theme == "sepia" else ""
|
| 613 |
+
active_light = "active" if theme == "light" else ""
|
| 614 |
+
active_dark = "active" if theme == "dark" else ""
|
| 615 |
+
|
| 616 |
+
font_group_html = ""
|
| 617 |
+
if is_pdf:
|
| 618 |
+
zoom = state.get("zoom", 100)
|
| 619 |
+
font_group_html = f"""
|
| 620 |
+
<div class="settings-group">
|
| 621 |
+
<h4>Zoom</h4>
|
| 622 |
+
<div class="settings-row font-adjust">
|
| 623 |
+
<button class="settings-action" data-click="nr-font-down">Zoom-</button>
|
| 624 |
+
<span class="font-display">{zoom}%</span>
|
| 625 |
+
<button class="settings-action" data-click="nr-font-up">Zoom+</button>
|
| 626 |
+
</div>
|
| 627 |
+
</div>
|
| 628 |
+
"""
|
| 629 |
+
else:
|
| 630 |
+
font = state.get("font", 22)
|
| 631 |
+
font_group_html = f"""
|
| 632 |
+
<div class="settings-group">
|
| 633 |
+
<h4>Font Size</h4>
|
| 634 |
+
<div class="settings-row font-adjust">
|
| 635 |
+
<button class="settings-action" data-click="nr-font-down">A-</button>
|
| 636 |
+
<span class="font-display">{font}px</span>
|
| 637 |
+
<button class="settings-action" data-click="nr-font-up">A+</button>
|
| 638 |
+
</div>
|
| 639 |
+
</div>
|
| 640 |
+
"""
|
| 641 |
+
|
| 642 |
+
return f"""
|
| 643 |
+
<div class="settings-popover theme-{theme}">
|
| 644 |
+
<!-- Screen 1: Main Settings -->
|
| 645 |
+
<div class="settings-main-screen">
|
| 646 |
+
{font_group_html}
|
| 647 |
+
<div class="settings-group">
|
| 648 |
+
<h4>Theme</h4>
|
| 649 |
+
<div class="settings-row themes">
|
| 650 |
+
<button class="theme-btn sepia {active_sepia}" data-theme-set="sepia">Sepia</button>
|
| 651 |
+
<button class="theme-btn light {active_light}" data-theme-set="light">Light</button>
|
| 652 |
+
<button class="theme-btn dark {active_dark}" data-theme-set="dark">Dark</button>
|
| 653 |
+
</div>
|
| 654 |
+
</div>
|
| 655 |
+
<div class="settings-group" style="border-bottom: none; padding-bottom: 0; margin-bottom: 0;">
|
| 656 |
+
<button class="theme-btn view-bookmarks-btn" style="display: flex; justify-content: space-between; align-items: center; width: 100% !important; background: var(--blue) !important; color: #ffffff !important; border-color: var(--blue) !important;">
|
| 657 |
+
<span>View Bookmarks ({len(bookmarks)})</span>
|
| 658 |
+
<span style="font-weight: bold; margin-left: 8px;">→</span>
|
| 659 |
+
</button>
|
| 660 |
+
</div>
|
| 661 |
+
</div>
|
| 662 |
+
|
| 663 |
+
<!-- Screen 2: Bookmarks Screen -->
|
| 664 |
+
<div class="settings-bookmarks-screen" style="display: none;">
|
| 665 |
+
<div class="settings-group" style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px; border-bottom: 1px dashed var(--line); padding-bottom: 8px;">
|
| 666 |
+
<button class="back-to-settings-btn" style="background: transparent; border: none; color: var(--blue); cursor: pointer; font-size: 16px; padding: 0 4px 0 0; font-weight: bold; display: inline-flex; align-items: center;">←</button>
|
| 667 |
+
<h4 style="margin: 0 !important; color: var(--muted) !important; font-size: 11px !important; text-transform: uppercase !important; letter-spacing: 0.5px !important;">Bookmarks ({len(bookmarks)})</h4>
|
| 668 |
+
</div>
|
| 669 |
+
<div class="bookmarks-list">
|
| 670 |
+
{bookmarks_html}
|
| 671 |
+
</div>
|
| 672 |
+
</div>
|
| 673 |
+
</div>
|
| 674 |
+
"""
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def _note(title: str, rows: list[dict], key: str) -> str:
|
| 678 |
+
body = "".join(f"<p><b>{r['section_index'] + 1}</b>{_escape(str(r.get(key, ''))[:120])}</p>" for r in rows[:8]) or "<p>Nothing yet.</p>"
|
| 679 |
+
return f"<div><h3>{title}</h3>{body}</div>"
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def _paragraphs(text: str) -> str:
|
| 683 |
+
return "".join(f"<p>{_escape(p).replace(chr(10), '<br>')}</p>" for p in text.split("\n\n") if p.strip())
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
def _request_id(request: gr.Request) -> int | None:
|
| 687 |
+
params = dict(request.query_params) if request else {}
|
| 688 |
+
return _int(params.get("novel_id"))
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def _notice(text: str, tone: str = "ok") -> str:
|
| 692 |
+
return f"<p class='notice {tone}'>{_escape(text)}</p>" if text else ""
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def _meaning_url(text: str) -> str:
|
| 696 |
+
return f"https://www.google.com/search?q={quote_plus(text + ' meaning')}"
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _int(value):
|
| 700 |
+
try:
|
| 701 |
+
return int(value)
|
| 702 |
+
except (TypeError, ValueError):
|
| 703 |
+
return None
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
def _escape(value) -> str:
|
| 707 |
+
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
READER_JS = r"""
|
| 711 |
+
// Apply saved theme immediately so there's no flash
|
| 712 |
+
(function() {
|
| 713 |
+
var t = localStorage.getItem('nr-theme') || 'sepia';
|
| 714 |
+
document.documentElement.setAttribute('data-theme', t);
|
| 715 |
+
})();
|
| 716 |
+
|
| 717 |
+
const q = (sel, root = document) => root.querySelector(sel);
|
| 718 |
+
const setBox = (id, value) => {
|
| 719 |
+
const field = q(`#${id} textarea, #${id} input`);
|
| 720 |
+
if (field) { field.value = value; field.dispatchEvent(new Event("input", { bubbles: true })); }
|
| 721 |
+
};
|
| 722 |
+
|
| 723 |
+
// Fixed Gradio 4 element-level click interceptor mapping
|
| 724 |
+
const click = (id) => {
|
| 725 |
+
const el = q(`#${id}`);
|
| 726 |
+
if (!el) return;
|
| 727 |
+
if (el.tagName === "BUTTON") {
|
| 728 |
+
el.click();
|
| 729 |
+
} else {
|
| 730 |
+
el.querySelector("button")?.click();
|
| 731 |
+
}
|
| 732 |
+
};
|
| 733 |
+
|
| 734 |
+
const selectionText = () => {
|
| 735 |
+
const sel = getSelection();
|
| 736 |
+
const text = sel && !sel.isCollapsed ? sel.toString().trim().replace(/\s+/g, " ") : "";
|
| 737 |
+
const reader = q(".text");
|
| 738 |
+
return text && reader?.contains(sel.anchorNode) && reader.contains(sel.focusNode) ? text : "";
|
| 739 |
+
};
|
| 740 |
+
|
| 741 |
+
// Theme ids that map to data-click -> localStorage key
|
| 742 |
+
const THEMES = { 'nr-sepia': 'sepia', 'nr-light': 'light', 'nr-dark': 'dark' };
|
| 743 |
+
|
| 744 |
+
function applyTheme(name) {
|
| 745 |
+
localStorage.setItem('nr-theme', name);
|
| 746 |
+
document.documentElement.setAttribute('data-theme', name);
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
function checkAndScrollBookmark() {
|
| 750 |
+
if (!window.__gotoBookmarkText) return;
|
| 751 |
+
const phrase = window.__gotoBookmarkText;
|
| 752 |
+
const el = Array.from(document.querySelectorAll(".bookmark-dotted")).find(
|
| 753 |
+
span => span.dataset.phrase === phrase || span.textContent.trim() === phrase
|
| 754 |
+
);
|
| 755 |
+
if (el) {
|
| 756 |
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 757 |
+
el.style.backgroundColor = 'rgba(255, 215, 0, 0.4)';
|
| 758 |
+
setTimeout(() => { el.style.backgroundColor = 'transparent'; }, 2000);
|
| 759 |
+
window.__gotoBookmarkText = null;
|
| 760 |
+
}
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
// Seamlessly preserve settings popup state across Svelte server re-renders
|
| 764 |
+
function syncSettingsPopupState() {
|
| 765 |
+
const panel = q(".settings-popover");
|
| 766 |
+
if (!panel) return;
|
| 767 |
+
if (window.__settingsPanelOpen) {
|
| 768 |
+
if (!panel.classList.contains("show")) {
|
| 769 |
+
panel.classList.add("show");
|
| 770 |
+
const trigger = q(".settings-trigger");
|
| 771 |
+
if (trigger) {
|
| 772 |
+
const rect = trigger.getBoundingClientRect();
|
| 773 |
+
panel.style.top = `${rect.bottom + 8}px`; // Fixed position
|
| 774 |
+
panel.style.left = `${Math.max(10, Math.min(innerWidth - 320, rect.left + rect.width - 290))}px`;
|
| 775 |
+
}
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
// Toggle main settings screen vs bookmarks list sub-screen
|
| 779 |
+
const mainScreen = q(".settings-main-screen", panel);
|
| 780 |
+
const bScreen = q(".settings-bookmarks-screen", panel);
|
| 781 |
+
if (mainScreen && bScreen) {
|
| 782 |
+
if (window.__settingsPanelBookmarksScreen) {
|
| 783 |
+
mainScreen.style.display = "none";
|
| 784 |
+
bScreen.style.display = "block";
|
| 785 |
+
} else {
|
| 786 |
+
mainScreen.style.display = "block";
|
| 787 |
+
bScreen.style.display = "none";
|
| 788 |
+
}
|
| 789 |
+
}
|
| 790 |
+
} else {
|
| 791 |
+
panel.classList.remove("show");
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
function bootReader() {
|
| 796 |
+
if (window.__readerBooted) return;
|
| 797 |
+
window.__readerBooted = true;
|
| 798 |
+
|
| 799 |
+
const bar = document.createElement("div");
|
| 800 |
+
bar.id = "select-bar";
|
| 801 |
+
document.body.appendChild(bar);
|
| 802 |
+
|
| 803 |
+
// Prevent selection clearing when clicking buttons in selection bar
|
| 804 |
+
bar.addEventListener("mousedown", (e) => {
|
| 805 |
+
e.preventDefault();
|
| 806 |
+
});
|
| 807 |
+
|
| 808 |
+
// Set up instant MutationObserver on settings popup container to prevent any flicker during Svelte DOM updates
|
| 809 |
+
const observer = new MutationObserver(() => {
|
| 810 |
+
syncSettingsPopupState();
|
| 811 |
+
});
|
| 812 |
+
const container = q("#settings-popup-panel") || document.body;
|
| 813 |
+
observer.observe(container, { childList: true, subtree: true });
|
| 814 |
+
|
| 815 |
+
// Poll for bookmark targets and keep settings open state synchronized
|
| 816 |
+
setInterval(checkAndScrollBookmark, 300);
|
| 817 |
+
setInterval(syncSettingsPopupState, 50);
|
| 818 |
+
|
| 819 |
+
document.addEventListener("click", (event) => {
|
| 820 |
+
// Intercept bookmarks clicks inside the settings popup
|
| 821 |
+
const bookmarkItem = event.target.closest(".bookmark-item");
|
| 822 |
+
if (bookmarkItem) {
|
| 823 |
+
const section = bookmarkItem.dataset.gotoSection;
|
| 824 |
+
const text = bookmarkItem.dataset.gotoText;
|
| 825 |
+
window.__gotoBookmarkText = text;
|
| 826 |
+
setBox("nr-target-jump", section);
|
| 827 |
+
click("nr-do-jump");
|
| 828 |
+
return;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
// Toggle settings popup visibility & positioning
|
| 832 |
+
const trigger = event.target.closest(".settings-trigger");
|
| 833 |
+
const panel = q(".settings-popover");
|
| 834 |
+
if (trigger) {
|
| 835 |
+
event.preventDefault();
|
| 836 |
+
if (panel) {
|
| 837 |
+
panel.classList.toggle("show");
|
| 838 |
+
window.__settingsPanelOpen = panel.classList.contains("show");
|
| 839 |
+
if (window.__settingsPanelOpen) {
|
| 840 |
+
const rect = trigger.getBoundingClientRect();
|
| 841 |
+
panel.style.top = `${rect.bottom + 8}px`; // Fixed position
|
| 842 |
+
panel.style.left = `${Math.max(10, Math.min(innerWidth - 320, rect.left + rect.width - 290))}px`;
|
| 843 |
+
}
|
| 844 |
+
}
|
| 845 |
+
return;
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
// Sub-screen transition triggers inside settings panel
|
| 849 |
+
if (event.target.closest(".view-bookmarks-btn")) {
|
| 850 |
+
event.preventDefault();
|
| 851 |
+
window.__settingsPanelBookmarksScreen = true;
|
| 852 |
+
syncSettingsPopupState();
|
| 853 |
+
return;
|
| 854 |
+
}
|
| 855 |
+
if (event.target.closest(".back-to-settings-btn")) {
|
| 856 |
+
event.preventDefault();
|
| 857 |
+
window.__settingsPanelBookmarksScreen = false;
|
| 858 |
+
syncSettingsPopupState();
|
| 859 |
+
return;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
// Double-guard settings open state when clicking elements inside settings popover
|
| 863 |
+
if (event.target.closest(".settings-popover")) {
|
| 864 |
+
window.__settingsPanelOpen = true;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
// Clicking outside closes the settings popup
|
| 868 |
+
if (panel && !event.target.closest(".settings-popover") && !event.target.closest(".settings-trigger")) {
|
| 869 |
+
// ONLY close if the click target is still in the document body (prevents closing on detached Svelte re-renders)
|
| 870 |
+
if (document.body.contains(event.target)) {
|
| 871 |
+
panel.classList.remove("show");
|
| 872 |
+
window.__settingsPanelOpen = false;
|
| 873 |
+
}
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
const proxy = event.target.closest("[data-click]");
|
| 877 |
+
if (proxy) {
|
| 878 |
+
const themeKey = THEMES[proxy.dataset.click];
|
| 879 |
+
if (themeKey) applyTheme(themeKey);
|
| 880 |
+
click(proxy.dataset.click);
|
| 881 |
+
return;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
const themeSet = event.target.closest("[data-theme-set]");
|
| 885 |
+
if (themeSet) {
|
| 886 |
+
const theme = themeSet.dataset.themeSet;
|
| 887 |
+
applyTheme(theme);
|
| 888 |
+
click(`nr-${theme}`);
|
| 889 |
+
return;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
const action = event.target.closest("#select-bar button");
|
| 893 |
+
if (action) {
|
| 894 |
+
const text = selectionText();
|
| 895 |
+
setBox("nr-selected-text", text);
|
| 896 |
+
if (action.dataset.a === "define" && text) {
|
| 897 |
+
window.open(`https://www.google.com/search?q=${encodeURIComponent(text + " meaning")}`, "_blank", "noopener");
|
| 898 |
+
click("nr-define");
|
| 899 |
+
} else if (action.dataset.a === "bookmark" && text) {
|
| 900 |
+
click("nr-bookmark");
|
| 901 |
+
} else if (action.dataset.a === "speak-selected" && text) {
|
| 902 |
+
click("nr-speak-selected");
|
| 903 |
+
}
|
| 904 |
+
bar.classList.remove("show");
|
| 905 |
+
return;
|
| 906 |
+
}
|
| 907 |
+
});
|
| 908 |
+
|
| 909 |
+
document.addEventListener("mouseup", (e) => {
|
| 910 |
+
if (e.target.closest("#select-bar")) return; // ignore mouseup when interacting with the select bar itself!
|
| 911 |
+
const text = selectionText();
|
| 912 |
+
if (!text) return bar.classList.remove("show");
|
| 913 |
+
|
| 914 |
+
// Dynamic selection popover button filters
|
| 915 |
+
const hasSpace = /\s/.test(text);
|
| 916 |
+
let buttonsHtml = `<button data-a='bookmark'>Bookmark</button>`;
|
| 917 |
+
if (!hasSpace && text.length > 0) {
|
| 918 |
+
buttonsHtml += `<button data-a='define'>Define</button>`;
|
| 919 |
+
}
|
| 920 |
+
buttonsHtml += `<button data-a='speak-selected'>Speak</button>`;
|
| 921 |
+
bar.innerHTML = buttonsHtml;
|
| 922 |
+
|
| 923 |
+
const rect = getSelection().getRangeAt(0).getBoundingClientRect();
|
| 924 |
+
bar.style.left = `${Math.max(10, Math.min(innerWidth - 260, rect.left + rect.width / 2 - 120))}px`;
|
| 925 |
+
bar.style.top = `${Math.max(10, scrollY + rect.top - 46)}px`;
|
| 926 |
+
bar.classList.add("show");
|
| 927 |
+
});
|
| 928 |
+
|
| 929 |
+
// Force Svelte layout reflows so observers position dynamic chapters perfectly at 100% zoom
|
| 930 |
+
[50, 150, 300, 600, 1200, 2500, 4000].forEach(delay => {
|
| 931 |
+
setTimeout(() => {
|
| 932 |
+
window.dispatchEvent(new Event('resize'));
|
| 933 |
+
const rail = q('.chapter-rail');
|
| 934 |
+
if (rail) {
|
| 935 |
+
const d = rail.style.display;
|
| 936 |
+
rail.style.display = 'none';
|
| 937 |
+
rail.offsetHeight;
|
| 938 |
+
rail.style.display = d;
|
| 939 |
+
}
|
| 940 |
+
}, delay);
|
| 941 |
+
});
|
| 942 |
+
}
|
| 943 |
+
bootReader();
|
| 944 |
+
"""
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
# JS injected on both pages: reads localStorage theme and applies data-theme to <html>
|
| 948 |
+
THEME_JS = """
|
| 949 |
+
(function() {
|
| 950 |
+
var t = localStorage.getItem('nr-theme') || 'sepia';
|
| 951 |
+
document.documentElement.setAttribute('data-theme', t);
|
| 952 |
+
|
| 953 |
+
document.addEventListener("click", function(e) {
|
| 954 |
+
var card = e.target.closest(".book-card");
|
| 955 |
+
if (card) {
|
| 956 |
+
e.preventDefault();
|
| 957 |
+
var href = card.getAttribute("href");
|
| 958 |
+
window.history.pushState(null, "", href);
|
| 959 |
+
var btn = document.getElementById("action-select-novel");
|
| 960 |
+
if (btn) {
|
| 961 |
+
btn.click();
|
| 962 |
+
}
|
| 963 |
+
return;
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
var btn = e.target.closest("[data-theme-set]");
|
| 967 |
+
if (btn) {
|
| 968 |
+
var theme = btn.getAttribute("data-theme-set");
|
| 969 |
+
localStorage.setItem('nr-theme', theme);
|
| 970 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 971 |
+
}
|
| 972 |
+
});
|
| 973 |
+
})();
|
| 974 |
+
|
| 975 |
+
function closeModal() {
|
| 976 |
+
var modal = document.getElementById('detail-modal');
|
| 977 |
+
if (modal) {
|
| 978 |
+
modal.classList.remove('show');
|
| 979 |
+
}
|
| 980 |
+
window.history.pushState(null, "", "/dashboard/");
|
| 981 |
+
var btn = document.getElementById("action-clear-select");
|
| 982 |
+
if (btn) {
|
| 983 |
+
btn.click();
|
| 984 |
+
}
|
| 985 |
+
}
|
| 986 |
+
"""
|
| 987 |
+
|
| 988 |
+
CSS = """
|
| 989 |
+
/* ── Unified theme tokens ────────────────────────────────────────── */
|
| 990 |
+
:root,
|
| 991 |
+
html[data-theme=sepia] {
|
| 992 |
+
--bg:#f4ead0; --paper:#f7edcf; --ink:#2b2118; --muted:#756854;
|
| 993 |
+
--line:rgba(43,33,24,.18); --card-bg:rgba(244,235,208,.72);
|
| 994 |
+
--card-hover:rgba(244,235,208,.96); --rail-bg:#2e2416;
|
| 995 |
+
--rail-fg:#e8ddc8; --rail-border:rgba(255,255,255,.10);
|
| 996 |
+
--blue:#2f80ed; --warn:#b84a18;
|
| 997 |
+
--reader-bg:#f4ead0; --reader-ink:#2b2118;
|
| 998 |
+
--reader-muted:#756854; --reader-line:rgba(43,33,24,.18);
|
| 999 |
+
/* Gradio var overrides */
|
| 1000 |
+
--body-background-fill:#f4ead0; --body-text-color:#2b2118;
|
| 1001 |
+
--background-fill-primary:#f7edcf; --background-fill-secondary:#f0e3be;
|
| 1002 |
+
--block-background-fill:transparent; --block-border-color:rgba(43,33,24,.18);
|
| 1003 |
+
--border-color-primary:rgba(43,33,24,.18);
|
| 1004 |
+
--neutral-50:#f7edcf; --neutral-100:#f0e3be; --neutral-200:rgba(43,33,24,.18);
|
| 1005 |
+
--neutral-300:#a89880; --neutral-400:#756854; --neutral-500:#4a3828;
|
| 1006 |
+
--neutral-600:#2b2118; --neutral-700:#2b2118; --neutral-800:#2b2118; --neutral-900:#1a140f;
|
| 1007 |
+
--button-secondary-background-fill:#e8ddc8; --button-secondary-text-color:#2b2118;
|
| 1008 |
+
--button-secondary-text-color-hover:#2b2118;
|
| 1009 |
+
--input-background-fill:#f0e3be; --checkbox-label-text-color:#2b2118;
|
| 1010 |
+
--upload-text-color:#4a3828; --prose-text-color:#2b2118;
|
| 1011 |
+
}
|
| 1012 |
+
html[data-theme=light] {
|
| 1013 |
+
--bg:#f5f5f0; --paper:#ffffff; --ink:#1a1a1a; --muted:#5a5a5a;
|
| 1014 |
+
--line:rgba(0,0,0,.13); --card-bg:rgba(255,255,255,.80);
|
| 1015 |
+
--card-hover:rgba(255,255,255,.98); --rail-bg:#1e1e1e;
|
| 1016 |
+
--rail-fg:#e8e8e8; --rail-border:rgba(255,255,255,.10);
|
| 1017 |
+
--blue:#1a6ed8; --warn:#c0340d;
|
| 1018 |
+
--reader-bg:#f5f5f0; --reader-ink:#1a1a1a;
|
| 1019 |
+
--reader-muted:#5a5a5a; --reader-line:rgba(0,0,0,.13);
|
| 1020 |
+
/* Gradio var overrides */
|
| 1021 |
+
--body-background-fill:#f5f5f0; --body-text-color:#1a1a1a;
|
| 1022 |
+
--background-fill-primary:#ffffff; --background-fill-secondary:#f0f0eb;
|
| 1023 |
+
--block-background-fill:transparent; --block-border-color:rgba(0,0,0,.13);
|
| 1024 |
+
--border-color-primary:rgba(0,0,0,.13);
|
| 1025 |
+
--neutral-50:#ffffff; --neutral-100:#f0f0eb; --neutral-200:rgba(0,0,0,.13);
|
| 1026 |
+
--neutral-300:#999999; --neutral-400:#5a5a5a; --neutral-500:#3a3a3a;
|
| 1027 |
+
--neutral-600:#1a1a1a; --neutral-700:#1a1a1a; --neutral-800:#1a1a1a; --neutral-900:#000000;
|
| 1028 |
+
--button-secondary-background-fill:#e4e4e0; --button-secondary-text-color:#1a1a1a;
|
| 1029 |
+
--button-secondary-text-color-hover:#1a1a1a;
|
| 1030 |
+
--input-background-fill:#ececec; --checkbox-label-text-color:#1a1a1a;
|
| 1031 |
+
--upload-text-color:#3a3a3a; --prose-text-color:#1a1a1a;
|
| 1032 |
+
}
|
| 1033 |
+
html[data-theme=dark] {
|
| 1034 |
+
--bg:#141414; --paper:#1e1e1e; --ink:#e8e2d8; --muted:#9a9188;
|
| 1035 |
+
--line:rgba(232,226,216,.14); --card-bg:rgba(38,34,28,.80);
|
| 1036 |
+
--card-hover:rgba(50,45,38,.95); --rail-bg:#0d0d0d;
|
| 1037 |
+
--rail-fg:#d4cec4; --rail-border:rgba(255,255,255,.08);
|
| 1038 |
+
--blue:#5ba3f5; --warn:#f0956a;
|
| 1039 |
+
--reader-bg:#141414; --reader-ink:#e8e2d8;
|
| 1040 |
+
--reader-muted:#9a9188; --reader-line:rgba(232,226,216,.14);
|
| 1041 |
+
/* Gradio var overrides */
|
| 1042 |
+
--body-background-fill:#141414; --body-text-color:#e8e2d8;
|
| 1043 |
+
--background-fill-primary:#1e1e1e; --background-fill-secondary:#1a1a1a;
|
| 1044 |
+
--block-background-fill:transparent; --block-border-color:rgba(232,226,216,.14);
|
| 1045 |
+
--border-color-primary:rgba(232,226,216,.14); --neutral-100:#e8e2d8;
|
| 1046 |
+
--neutral-200:rgba(232,226,216,.14); --neutral-800:#e8e2d8;
|
| 1047 |
+
--button-secondary-background-fill:#2a2520; --button-secondary-text-color:#e8e2d8;
|
| 1048 |
+
--input-background-fill:#252220; --checkbox-label-text-color:#e8e2d8;
|
| 1049 |
+
}
|
| 1050 |
+
/* ── Base ─────────────────────────────────────────────────────────── */
|
| 1051 |
+
body, .gradio-container { margin:0!important; padding:0!important; max-width:none!important; background:var(--bg)!important; color:var(--ink)!important; font-family:Inter,system-ui,sans-serif; transition:background .25s,color .25s; }
|
| 1052 |
+
/* Override Gradio base-theme text-color resets on common elements */
|
| 1053 |
+
.gradio-container p, .gradio-container span, .gradio-container h1, .gradio-container h2, .gradio-container h3, .gradio-container h4, .gradio-container a, .gradio-container label, .gradio-container dt, .gradio-container dd, .gradio-container li, .gradio-container summary, .gradio-container small, .gradio-container strong, .gradio-container em { color:inherit; }
|
| 1054 |
+
/* Override Gradio widget backgrounds so they adapt to theme */
|
| 1055 |
+
.gradio-container .block { background:transparent!important; border-color:var(--line)!important; }
|
| 1056 |
+
/* File upload: force all text inside to be readable — Gradio uses neutral-400/500 which are light-gray by default */
|
| 1057 |
+
.gradio-container .upload-container, .gradio-container .file-upload, .gradio-container .upload-btn { background:var(--card-bg)!important; border-color:var(--line)!important; color:var(--ink)!important; }
|
| 1058 |
+
.gradio-container .upload-container *, .gradio-container .file-upload * { color:var(--ink)!important; }
|
| 1059 |
+
/* upload-area .wrap blocks (NOT chapter-rail .wrap) */
|
| 1060 |
+
.gradio-container .upload-container .wrap, .gradio-container .file-upload .wrap { background:var(--card-bg)!important; border-color:var(--line)!important; color:var(--ink)!important; }
|
| 1061 |
+
.gradio-container .upload-container .wrap .icon-wrap, .gradio-container .upload-container .wrap .title, .gradio-container .upload-container .wrap p, .gradio-container .upload-container .wrap span,
|
| 1062 |
+
.gradio-container .file-upload .wrap .icon-wrap, .gradio-container .file-upload .wrap .title, .gradio-container .file-upload .wrap p, .gradio-container .file-upload .wrap span { color:var(--ink)!important; }
|
| 1063 |
+
.gradio-container button.primary { background:var(--blue)!important; color:#fff!important; border:none!important; }
|
| 1064 |
+
.gradio-container button.secondary { background:var(--card-bg)!important; color:var(--ink)!important; border:1px solid var(--line)!important; }
|
| 1065 |
+
/* Gradio svelte components use these classes for upload hint text */
|
| 1066 |
+
.gradio-container .icon-wrap svg, .gradio-container .upload-icon { color:var(--muted)!important; opacity:0.7; }
|
| 1067 |
+
.gradio-container .file-name, .gradio-container .file-size, .gradio-container .or, .gradio-container .subtitle { color:var(--muted)!important; }
|
| 1068 |
+
/* Hide Gradio default footer entirely to prevent standard Gradio settings from interfering with our persistent theme system */
|
| 1069 |
+
footer, .footer, .gradio-button.theme-toggle, button.theme-toggle, .settings-btn, .show-api { display:none!important; }
|
| 1070 |
+
/* ── Dashboard ────────────────────────────────────────────────────── */
|
| 1071 |
+
.dashboard { width:min(1120px,calc(100vw - 48px)); margin:auto; padding:48px 0; }
|
| 1072 |
+
.dashboard header { display:flex; justify-content:space-between; align-items:flex-end; border-bottom:1px solid var(--line); padding-bottom:20px; margin-bottom:28px; }
|
| 1073 |
+
.dashboard header p, .dashboard h1, .dashboard small { margin:0; }
|
| 1074 |
+
.dashboard header p { color:var(--muted); text-transform:uppercase; font-size:12px; }
|
| 1075 |
+
.dashboard h1 { font:400 clamp(44px,7vw,82px)/1 Georgia,serif; color:var(--ink); }
|
| 1076 |
+
.dashboard-theme-selector { display:flex; gap:8px; align-items:center; }
|
| 1077 |
+
.dashboard-theme-selector button { border:1px solid var(--line)!important; background:transparent!important; border-radius:6px!important; color:var(--ink)!important; box-shadow:none!important; text-decoration:none; padding:7px 14px; cursor:pointer; font-size:13px; font-weight:500; transition:all .2s; }
|
| 1078 |
+
.dashboard-theme-selector button:hover { background:var(--line)!important; }
|
| 1079 |
+
.dashboard h2 { margin:36px 0 12px; font-size:14px; color:var(--muted); text-transform:uppercase; }
|
| 1080 |
+
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:24px 18px; }
|
| 1081 |
+
|
| 1082 |
+
/* Book Card */
|
| 1083 |
+
.book-card { display:flex; flex-direction:column; text-decoration:none; border:1px solid var(--line); border-radius:12px; background:var(--card-bg); color:var(--ink)!important; overflow:hidden; transition:all 0.25s cubic-bezier(0.4, 0, 0.2, 1); box-shadow:0 2px 8px rgba(0,0,0,0.04); height: 100%; position: relative; }
|
| 1084 |
+
.book-card:hover, .book-card.selected { background:var(--card-hover); transform:translateY(-4px); box-shadow:0 8px 24px rgba(0,0,0,0.12); border-color: var(--blue); }
|
| 1085 |
+
|
| 1086 |
+
.card-cover-container { width:100%; aspect-ratio:3/4; overflow:hidden; background:var(--paper); border-bottom:1px solid var(--line); position:relative; display:flex; align-items:center; justify-content:center; }
|
| 1087 |
+
.book-cover-img { width:100%; height:100%; object-fit:cover; transition: transform 0.3s ease; }
|
| 1088 |
+
.book-card:hover .book-cover-img { transform: scale(1.03); }
|
| 1089 |
+
|
| 1090 |
+
/* Star badge inside card cover */
|
| 1091 |
+
.star-badge { position:absolute; top:8px; right:8px; background:rgba(242, 201, 76, 0.95); color:#2b2118; width:26px; height:26px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:bold; box-shadow:0 2px 6px rgba(0,0,0,0.15); z-index: 5; }
|
| 1092 |
+
|
| 1093 |
+
/* Fallback Text Cover */
|
| 1094 |
+
.book-cover-fallback { width:100%; height:100%; padding:16px; display:flex; flex-direction:column; justify-content:space-between; box-sizing:border-box; background:var(--paper); font-family:Georgia, serif; color:var(--ink); overflow:hidden; position:relative; }
|
| 1095 |
+
.book-cover-fallback::before { content:''; position:absolute; top:0; left:0; right:0; bottom:0; background:linear-gradient(90deg, rgba(0,0,0,0.04) 0%, rgba(255,255,255,0.06) 1.5%, rgba(0,0,0,0.02) 3%, transparent 4%, rgba(0,0,0,0.03) 98%, rgba(0,0,0,0.08) 100%); pointer-events:none; }
|
| 1096 |
+
.fallback-header { font-size:10px; text-transform:uppercase; letter-spacing:1px; color:var(--muted); text-align:center; }
|
| 1097 |
+
.fallback-title { font-size:16px; font-weight:bold; text-align:center; margin:8px 0 4px; line-height:1.2; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; max-height: 3.6em; }
|
| 1098 |
+
.fallback-divider { width:30px; height:1px; background:var(--muted); margin:4px auto; opacity:0.5; }
|
| 1099 |
+
.fallback-body { font-size:10px; line-height:1.4; color:var(--muted); text-align:justify; opacity:0.85; overflow:hidden; display:-webkit-box; -webkit-line-clamp:6; -webkit-box-orient:vertical; height: 8.4em; }
|
| 1100 |
+
.fallback-footer { font-size:10px; font-style:italic; text-align:center; color:var(--muted); margin-top: auto; }
|
| 1101 |
+
|
| 1102 |
+
.card-info { padding:14px; display:flex; flex-direction:column; gap:4px; flex-grow:1; }
|
| 1103 |
+
.card-format { color:var(--blue); font-size:10px; font-weight:bold; text-transform:uppercase; letter-spacing:0.5px; }
|
| 1104 |
+
.card-title { font:500 16px/1.25 Georgia,serif; color:var(--ink); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; min-height: 2.5em; }
|
| 1105 |
+
.card-author { color:var(--muted); font-size:12px; display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; overflow:hidden; }
|
| 1106 |
+
.card-progress { height:3px; background:var(--line); overflow:hidden; border-radius:1.5px; margin-top:8px; }
|
| 1107 |
+
.card-progress b { display:block; height:100%; background:var(--blue); }
|
| 1108 |
+
.card-progress-text { font-size:10px; color:var(--muted); align-self:flex-end; margin-top:2px; }
|
| 1109 |
+
|
| 1110 |
+
/* ── Modal Dialog ──────────────────────────────────────────────────── */
|
| 1111 |
+
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0, 0, 0, 0.4); display:none; align-items:center; justify-content:center; z-index:99999; padding:20px; box-sizing:border-box; backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); animation:fadeIn 0.25s ease-out; }
|
| 1112 |
+
.modal-overlay.show { display:flex; }
|
| 1113 |
+
|
| 1114 |
+
.modal-content { background:var(--paper); color:var(--ink); width:100%; max-width:520px; border-radius:20px; box-shadow:0 20px 50px rgba(0,0,0,0.3); border:1px solid var(--line); position:relative; box-sizing:border-box; padding:36px 32px 28px; text-align:center; max-height:90vh; overflow-y:auto; animation:slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
|
| 1115 |
+
|
| 1116 |
+
/* Scrollbar styling for modal */
|
| 1117 |
+
.modal-content::-webkit-scrollbar { width:6px; }
|
| 1118 |
+
.modal-content::-webkit-scrollbar-track { background:transparent; }
|
| 1119 |
+
.modal-content::-webkit-scrollbar-thumb { background:var(--line); border-radius:3px; }
|
| 1120 |
+
|
| 1121 |
+
.modal-close { position:absolute; top:16px; right:16px; width:32px; height:32px; border-radius:50%; border:none; background:rgba(0,0,0,0.03); color:var(--ink); font-size:24px; font-weight:300; display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all 0.2s; z-index:10; }
|
| 1122 |
+
.modal-close:hover { background:var(--line); transform:scale(1.05); }
|
| 1123 |
+
|
| 1124 |
+
.modal-cover-wrapper { width:190px; aspect-ratio:3/4; margin:0 auto 24px; border-radius:12px; overflow:hidden; box-shadow:0 8px 24px rgba(0,0,0,0.15); border:1px solid var(--line); background:var(--bg); }
|
| 1125 |
+
.modal-cover-wrapper .book-cover-fallback { padding: 16px; }
|
| 1126 |
+
.modal-cover-wrapper .fallback-title { font-size: 15px; }
|
| 1127 |
+
.modal-cover-wrapper .fallback-body { font-size: 9.5px; -webkit-line-clamp: 5; height: 7em; }
|
| 1128 |
+
|
| 1129 |
+
.modal-title { font:400 28px/1.25 Georgia,serif; color:var(--ink); margin:0 0 24px; padding:0 10px; }
|
| 1130 |
+
|
| 1131 |
+
.modal-actions { display:flex; justify-content:center; align-items:center; gap:20px; margin:0 auto 28px; }
|
| 1132 |
+
.action-btn { width:54px!important; height:54px!important; border-radius:50%!important; border:1.5px solid var(--muted)!important; background:var(--bg)!important; color:var(--ink)!important; display:flex!important; align-items:center!important; justify-content:center!important; cursor:pointer!important; transition:all 0.2s cubic-bezier(0.4, 0, 0.2, 1)!important; text-decoration:none!important; box-sizing:border-box!important; box-shadow:0 2px 10px rgba(0,0,0,0.05)!important; padding:0!important; }
|
| 1133 |
+
.action-btn:hover { transform:translateY(-2px)!important; box-shadow:0 6px 16px rgba(0,0,0,0.12)!important; border-color:var(--ink)!important; background:var(--paper)!important; }
|
| 1134 |
+
|
| 1135 |
+
/* Force standard action icons to follow the ink color on all internal strokes */
|
| 1136 |
+
.action-btn .icon-svg { width:24px!important; height:24px!important; stroke:var(--ink)!important; color:var(--ink)!important; }
|
| 1137 |
+
.action-btn .icon-svg path,
|
| 1138 |
+
.action-btn .icon-svg circle { stroke:var(--ink)!important; stroke-width:2.2px!important; fill:none!important; }
|
| 1139 |
+
|
| 1140 |
+
/* Star Button States */
|
| 1141 |
+
.star-btn.active .icon-svg,
|
| 1142 |
+
.star-btn.active .icon-svg path { fill:#f2c94c!important; stroke:#f2c94c!important; }
|
| 1143 |
+
.star-btn:hover, .star-btn.active { border-color:#f2c94c!important; }
|
| 1144 |
+
.star-btn:hover .icon-svg,
|
| 1145 |
+
.star-btn:hover .icon-svg path { stroke:#f2c94c!important; }
|
| 1146 |
+
.star-btn.active { background:rgba(242, 201, 76, 0.15)!important; }
|
| 1147 |
+
|
| 1148 |
+
/* Archive Button States */
|
| 1149 |
+
.archive-btn.active .icon-svg,
|
| 1150 |
+
.archive-btn.active .icon-svg path,
|
| 1151 |
+
.archive-btn.active .icon-svg circle { fill:var(--blue)!important; stroke:var(--blue)!important; }
|
| 1152 |
+
.archive-btn:hover, .archive-btn.active { border-color:var(--blue)!important; }
|
| 1153 |
+
.archive-btn:hover .icon-svg,
|
| 1154 |
+
.archive-btn:hover .icon-svg path,
|
| 1155 |
+
.archive-btn:hover .icon-svg circle { stroke:var(--blue)!important; }
|
| 1156 |
+
.archive-btn.active { background:rgba(47, 128, 237, 0.15)!important; }
|
| 1157 |
+
|
| 1158 |
+
/* Delete Button States */
|
| 1159 |
+
.delete-btn:hover { border-color:#eb5757!important; background:rgba(235, 87, 87, 0.15)!important; }
|
| 1160 |
+
.delete-btn:hover .icon-svg,
|
| 1161 |
+
.delete-btn:hover .icon-svg path { stroke:#eb5757!important; }
|
| 1162 |
+
|
| 1163 |
+
/* Continue Button (Play Icon) */
|
| 1164 |
+
.continue-btn { background:var(--blue)!important; border-color:var(--blue)!important; }
|
| 1165 |
+
.continue-btn .icon-svg { stroke:none!important; color:#ffffff!important; }
|
| 1166 |
+
.continue-btn .icon-svg path { fill:#ffffff!important; stroke:none!important; }
|
| 1167 |
+
.continue-btn:hover { background:var(--blue)!important; opacity:0.95!important; }
|
| 1168 |
+
|
| 1169 |
+
.modal-divider { height:1px; background:var(--line); width:100%; margin-bottom:24px; }
|
| 1170 |
+
|
| 1171 |
+
.modal-metadata { display:flex; flex-direction:column; gap:12px; text-align:left; padding:0 6px; }
|
| 1172 |
+
.meta-row { display:flex; justify-content:space-between; align-items:baseline; border-bottom:1px dashed rgba(0,0,0,0.05); padding-bottom:6px; font-size:14px; }
|
| 1173 |
+
html[data-theme=dark] .meta-row { border-bottom-color:rgba(255,255,255,0.05); }
|
| 1174 |
+
.meta-label { color:var(--muted); font-weight:normal; }
|
| 1175 |
+
.meta-value { color:var(--ink); font-weight:500; text-align:right; max-width:70%; word-break:break-word; }
|
| 1176 |
+
|
| 1177 |
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
| 1178 |
+
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
| 1179 |
+
|
| 1180 |
+
/* ── Reader layout ────────────────────────────────────────────────── */
|
| 1181 |
+
@media (min-width: 861px) {
|
| 1182 |
+
.reader-grid { display:flex!important; flex-direction:row!important; align-items:stretch!important; min-height:100vh!important; gap:0!important; }
|
| 1183 |
+
.chapter-rail { display:flex!important; flex-direction:column!important; flex-wrap:nowrap!important; position:sticky!important; top:0!important; height:100vh!important; overflow-y:auto!important; overflow-x:hidden!important; flex:0 0 280px!important; min-width:280px!important; max-width:280px!important; background:var(--rail-bg)!important; color:var(--rail-fg)!important; padding:18px 0!important; border-right:1px solid var(--rail-border)!important; box-sizing:border-box!important; z-index:100!important; }
|
| 1184 |
+
.reader-body { flex:1!important; min-width:0!important; box-sizing:border-box!important; display:flex!important; flex-direction:column!important; }
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
@media (max-width: 860px) {
|
| 1188 |
+
.reader-grid { min-height:100vh; gap:0!important; }
|
| 1189 |
+
.chapter-rail { display:none!important; }
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
/* Force each chapter choice inside the radio group to render on a fresh single line spanning 100% width */
|
| 1193 |
+
.chapters .gr-radio-group {
|
| 1194 |
+
display:flex!important;
|
| 1195 |
+
flex-direction:column!important;
|
| 1196 |
+
align-items:stretch!important;
|
| 1197 |
+
width:100%!important;
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
.chapters label {
|
| 1201 |
+
width:100%!important;
|
| 1202 |
+
flex:1 1 100%!important;
|
| 1203 |
+
display:flex!important;
|
| 1204 |
+
align-items:center!important;
|
| 1205 |
+
box-sizing:border-box!important;
|
| 1206 |
+
margin:0!important;
|
| 1207 |
+
padding:14px 18px!important;
|
| 1208 |
+
border-top:1px solid var(--rail-border)!important;
|
| 1209 |
+
border-radius:0!important;
|
| 1210 |
+
color:var(--rail-fg)!important;
|
| 1211 |
+
background:transparent!important;
|
| 1212 |
+
}
|
| 1213 |
+
.chapters label:has(input:checked) { color:var(--blue)!important; }
|
| 1214 |
+
|
| 1215 |
+
/* Force ALL children of chapter-rail to use rail colors regardless of any other color overrides */
|
| 1216 |
+
.chapter-rail * { color:var(--rail-fg)!important; background:transparent!important; }
|
| 1217 |
+
.chapter-rail .block, .chapter-rail .wrap, .chapter-rail .panel, .chapter-rail fieldset, .chapter-rail .form { border:none!important; box-shadow:none!important; }
|
| 1218 |
+
.chapter-rail span.group-text { color:var(--rail-fg)!important; }
|
| 1219 |
+
.rail-link { display:block; margin:0 18px 18px; color:var(--rail-fg)!important; text-decoration:none; }
|
| 1220 |
+
/* theme-* classes kept on elements for state tracking; all reader vars now flow from html[data-theme] */
|
| 1221 |
+
.top { height:64px; display:flex; align-items:center; justify-content:space-between; gap:18px; padding:0 28px; color:var(--reader-ink)!important; background:var(--reader-bg)!important; border-bottom:1px solid var(--reader-line); }
|
| 1222 |
+
.top small { display:block!important; color:var(--reader-muted)!important; font-size:12px!important; }
|
| 1223 |
+
.top strong { display:block!important; font:400 20px/1.15 Georgia,serif!important; color:var(--reader-ink)!important; }
|
| 1224 |
+
.top nav { display:flex; gap:7px; align-items:center; flex-wrap:wrap; }
|
| 1225 |
+
.top a, .top button { border:1px solid var(--reader-line)!important; background:transparent!important; border-radius:6px!important; color:var(--reader-ink)!important; box-shadow:none!important; text-decoration:none; padding:7px 10px; }
|
| 1226 |
+
.top span { min-width:54px; text-align:center; color:var(--reader-muted); font-size:13px; }
|
| 1227 |
+
.page { min-height:calc(100vh - 64px); padding:64px clamp(26px,8vw,130px); color:var(--reader-ink)!important; background:var(--reader-bg)!important; }
|
| 1228 |
+
.text { max-width:930px; margin:auto; text-align:center; font:var(--font,22px)/1.32 Georgia,serif; color:var(--reader-ink)!important; }
|
| 1229 |
+
/* Force ALL descendants inside .text to use reader-ink — prevents EPUB inline color styles from creating unreadable light text on light backgrounds */
|
| 1230 |
+
.text * { color:var(--reader-ink)!important; }
|
| 1231 |
+
.text p { margin:0 0 .85em; }
|
| 1232 |
+
.text img { display:block; max-width:100%; height:auto; margin:1.2em auto; color:unset!important; }
|
| 1233 |
+
.text hr { width:min(320px,60%); margin:1.6em auto; border:0; border-top:1px solid currentColor; opacity:.38; }
|
| 1234 |
+
.text h1, .text h2, .text h3, .text h4 { font-weight:400; line-height:1.15; margin:1.2em 0 .7em; }
|
| 1235 |
+
.text blockquote { margin:1.2em auto; max-width:760px; opacity:.86; }
|
| 1236 |
+
.text ::selection { background:rgba(47,128,237,.22); }
|
| 1237 |
+
.notes { padding:0 28px 28px; color:var(--reader-ink)!important; background:var(--reader-bg)!important; border-top:1px solid var(--reader-line); }
|
| 1238 |
+
.notes summary { padding:16px 0; color:var(--reader-muted)!important; cursor:pointer; }
|
| 1239 |
+
.notes section { display:grid; grid-template-columns:repeat(3,1fr); gap:18px; }
|
| 1240 |
+
.notes h3 { margin:0 0 8px; color:var(--reader-muted)!important; font-size:12px; text-transform:uppercase; }
|
| 1241 |
+
.notes p { margin:0 0 10px; color:var(--reader-ink)!important; }
|
| 1242 |
+
.notes b { margin-right:8px; color:var(--blue); }
|
| 1243 |
+
.notice { width:fit-content; margin:10px auto; color:var(--blue); font-size:13px; }
|
| 1244 |
+
.notice.warn { color:var(--warn); }
|
| 1245 |
+
.hidden { position:fixed!important; left:-10000px!important; width:1px!important; height:1px!important; overflow:hidden!important; }
|
| 1246 |
+
@media (max-width:860px) { .reader-grid{flex-direction:column}.chapter-rail{display:none!important}.top{height:auto;align-items:flex-start;flex-direction:column;padding:14px}.page{padding:42px 20px}.text{text-align:left}.notes section,.detail dl{grid-template-columns:1fr} }
|
| 1247 |
+
"""
|
| 1248 |
+
|
| 1249 |
+
|
| 1250 |
+
READER_CSS = CSS + """
|
| 1251 |
+
/* select-bar uses reader theme vars so it adapts automatically */
|
| 1252 |
+
#select-bar { position:absolute; z-index:9999; display:none; gap:4px; padding:5px; border-radius:7px; background:var(--reader-bg); border:1px solid var(--reader-line); box-shadow:0 10px 26px rgba(0,0,0,.30); }
|
| 1253 |
+
#select-bar.show { display:flex; }
|
| 1254 |
+
#select-bar button { border:0; border-radius:5px; background:transparent; color:var(--reader-ink); padding:7px 10px; cursor:pointer; font-size:13px; }
|
| 1255 |
+
#select-bar button:hover { background:var(--reader-line); }
|
| 1256 |
+
|
| 1257 |
+
/* Settings Popover Dropdown */
|
| 1258 |
+
.settings-popover {
|
| 1259 |
+
position: fixed !important;
|
| 1260 |
+
z-index: 1000;
|
| 1261 |
+
display: none;
|
| 1262 |
+
width: 300px;
|
| 1263 |
+
background: var(--paper) !important;
|
| 1264 |
+
color: var(--ink) !important;
|
| 1265 |
+
border: 1px solid var(--line) !important;
|
| 1266 |
+
border-radius: 14px !important;
|
| 1267 |
+
box-shadow: 0 12px 36px rgba(0,0,0,0.18) !important;
|
| 1268 |
+
padding: 20px !important;
|
| 1269 |
+
box-sizing: border-box !important;
|
| 1270 |
+
animation: slideUp 0.2s ease-out;
|
| 1271 |
+
}
|
| 1272 |
+
.settings-popover.show {
|
| 1273 |
+
display: block !important;
|
| 1274 |
+
}
|
| 1275 |
+
.settings-group {
|
| 1276 |
+
margin-bottom: 16px;
|
| 1277 |
+
border-bottom: 1px dashed var(--line);
|
| 1278 |
+
padding-bottom: 12px;
|
| 1279 |
+
}
|
| 1280 |
+
.settings-group:last-child {
|
| 1281 |
+
margin-bottom: 0;
|
| 1282 |
+
border-bottom: none;
|
| 1283 |
+
padding-bottom: 0;
|
| 1284 |
+
}
|
| 1285 |
+
.settings-group h4 {
|
| 1286 |
+
margin: 0 0 10px 0 !important;
|
| 1287 |
+
color: var(--muted) !important;
|
| 1288 |
+
font-size: 11px !important;
|
| 1289 |
+
text-transform: uppercase !important;
|
| 1290 |
+
letter-spacing: 0.5px !important;
|
| 1291 |
+
}
|
| 1292 |
+
.settings-row {
|
| 1293 |
+
display: flex;
|
| 1294 |
+
gap: 8px;
|
| 1295 |
+
align-items: center;
|
| 1296 |
+
}
|
| 1297 |
+
.settings-row.themes {
|
| 1298 |
+
display: grid;
|
| 1299 |
+
grid-template-columns: repeat(3, 1fr);
|
| 1300 |
+
gap: 6px;
|
| 1301 |
+
}
|
| 1302 |
+
.font-adjust {
|
| 1303 |
+
justify-content: space-between;
|
| 1304 |
+
}
|
| 1305 |
+
.font-display {
|
| 1306 |
+
font-size: 16px;
|
| 1307 |
+
font-weight: 600;
|
| 1308 |
+
color: var(--ink);
|
| 1309 |
+
}
|
| 1310 |
+
.settings-action, .theme-btn {
|
| 1311 |
+
background: var(--bg) !important;
|
| 1312 |
+
color: var(--ink) !important;
|
| 1313 |
+
border: 1.5px solid var(--line) !important;
|
| 1314 |
+
border-radius: 8px !important;
|
| 1315 |
+
padding: 8px 12px !important;
|
| 1316 |
+
cursor: pointer !important;
|
| 1317 |
+
font-size: 13px !important;
|
| 1318 |
+
font-weight: 500 !important;
|
| 1319 |
+
transition: all 0.15s ease !important;
|
| 1320 |
+
text-align: center !important;
|
| 1321 |
+
width: 100% !important;
|
| 1322 |
+
box-sizing: border-box !important;
|
| 1323 |
+
}
|
| 1324 |
+
.settings-action:hover, .theme-btn:hover {
|
| 1325 |
+
border-color: var(--ink) !important;
|
| 1326 |
+
transform: translateY(-1px);
|
| 1327 |
+
}
|
| 1328 |
+
.theme-btn.active {
|
| 1329 |
+
background: var(--blue) !important;
|
| 1330 |
+
color: #ffffff !important;
|
| 1331 |
+
border-color: var(--blue) !important;
|
| 1332 |
+
}
|
| 1333 |
+
/* Bookmarks list section inside popup */
|
| 1334 |
+
.bookmarks-list {
|
| 1335 |
+
max-height: 180px;
|
| 1336 |
+
overflow-y: auto;
|
| 1337 |
+
display: flex;
|
| 1338 |
+
flex-direction: column;
|
| 1339 |
+
gap: 6px;
|
| 1340 |
+
padding-right: 4px;
|
| 1341 |
+
}
|
| 1342 |
+
.bookmarks-list::-webkit-scrollbar {
|
| 1343 |
+
width: 4px;
|
| 1344 |
+
}
|
| 1345 |
+
.bookmarks-list::-webkit-scrollbar-thumb {
|
| 1346 |
+
background: var(--line);
|
| 1347 |
+
border-radius: 2px;
|
| 1348 |
+
}
|
| 1349 |
+
.bookmark-item {
|
| 1350 |
+
display: flex;
|
| 1351 |
+
flex-direction: column;
|
| 1352 |
+
padding: 8px 10px;
|
| 1353 |
+
background: var(--bg);
|
| 1354 |
+
border: 1.5px solid var(--line);
|
| 1355 |
+
border-radius: 8px;
|
| 1356 |
+
cursor: pointer;
|
| 1357 |
+
transition: all 0.15s ease;
|
| 1358 |
+
}
|
| 1359 |
+
.bookmark-item:hover {
|
| 1360 |
+
border-color: var(--blue);
|
| 1361 |
+
background: var(--paper);
|
| 1362 |
+
transform: translateX(2px);
|
| 1363 |
+
}
|
| 1364 |
+
.bookmark-item .b-sec {
|
| 1365 |
+
font-size: 10px;
|
| 1366 |
+
font-weight: bold;
|
| 1367 |
+
color: var(--blue);
|
| 1368 |
+
text-transform: uppercase;
|
| 1369 |
+
margin-bottom: 2px;
|
| 1370 |
+
}
|
| 1371 |
+
.bookmark-item .b-text {
|
| 1372 |
+
font-size: 12px;
|
| 1373 |
+
font-style: italic;
|
| 1374 |
+
color: var(--ink);
|
| 1375 |
+
white-space: nowrap;
|
| 1376 |
+
overflow: hidden;
|
| 1377 |
+
text-overflow: ellipsis;
|
| 1378 |
+
}
|
| 1379 |
+
.no-bookmarks {
|
| 1380 |
+
font-size: 12px;
|
| 1381 |
+
color: var(--muted);
|
| 1382 |
+
text-align: center;
|
| 1383 |
+
margin: 12px 0 0 0;
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
/* Persistent dotted annotation underlines */
|
| 1387 |
+
.bookmark-dotted {
|
| 1388 |
+
border-bottom: 2.2px dotted var(--blue) !important;
|
| 1389 |
+
background: transparent !important;
|
| 1390 |
+
cursor: pointer !important;
|
| 1391 |
+
transition: background-color 0.3s ease;
|
| 1392 |
+
}
|
| 1393 |
+
.bookmark-dotted:hover {
|
| 1394 |
+
background: rgba(47, 128, 237, 0.12) !important;
|
| 1395 |
+
}
|
| 1396 |
+
.define-dotted {
|
| 1397 |
+
border-bottom: 2.2px dotted #9b51e0 !important; /* purple dotted */
|
| 1398 |
+
background: transparent !important;
|
| 1399 |
+
cursor: pointer !important;
|
| 1400 |
+
transition: background-color 0.3s ease;
|
| 1401 |
+
}
|
| 1402 |
+
.define-dotted:hover {
|
| 1403 |
+
background: rgba(155, 81, 224, 0.12) !important;
|
| 1404 |
+
}
|
| 1405 |
+
.settings-trigger {
|
| 1406 |
+
width: 36px !important;
|
| 1407 |
+
height: 36px !important;
|
| 1408 |
+
border-radius: 50% !important;
|
| 1409 |
+
padding: 0 !important;
|
| 1410 |
+
display: inline-flex !important;
|
| 1411 |
+
align-items: center !important;
|
| 1412 |
+
justify-content: center !important;
|
| 1413 |
+
cursor: pointer !important;
|
| 1414 |
+
border: 1.5px solid var(--reader-line) !important;
|
| 1415 |
+
background: transparent !important;
|
| 1416 |
+
}
|
| 1417 |
+
.settings-trigger svg {
|
| 1418 |
+
display: block;
|
| 1419 |
+
stroke: var(--reader-ink) !important;
|
| 1420 |
+
color: var(--reader-ink) !important;
|
| 1421 |
+
}
|
| 1422 |
+
.settings-trigger svg path,
|
| 1423 |
+
.settings-trigger svg circle {
|
| 1424 |
+
stroke: var(--reader-ink) !important;
|
| 1425 |
+
stroke-width: 2.2px !important;
|
| 1426 |
+
fill: none !important;
|
| 1427 |
+
}
|
| 1428 |
+
.pdf-page-container {
|
| 1429 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
|
| 1430 |
+
border: 1px solid var(--reader-line) !important;
|
| 1431 |
+
border-radius: 8px !important;
|
| 1432 |
+
overflow: hidden !important;
|
| 1433 |
+
background: #ffffff !important;
|
| 1434 |
+
zoom: var(--pdf-zoom, 1) !important;
|
| 1435 |
+
transition: zoom 0.2s ease !important;
|
| 1436 |
+
}
|
| 1437 |
+
.pdf-page-img {
|
| 1438 |
+
transition: filter 0.3s ease !important;
|
| 1439 |
+
}
|
| 1440 |
+
[data-theme="dark"] .pdf-page-img {
|
| 1441 |
+
filter: invert(0.9) hue-rotate(180deg) contrast(0.95) !important;
|
| 1442 |
+
}
|
| 1443 |
+
[data-theme="sepia"] .pdf-page-img {
|
| 1444 |
+
filter: sepia(0.55) contrast(0.95) brightness(0.98) !important;
|
| 1445 |
+
}
|
| 1446 |
+
.pdf-text-layer {
|
| 1447 |
+
color: transparent !important;
|
| 1448 |
+
font-family: system-ui, -apple-system, sans-serif !important;
|
| 1449 |
+
white-space: pre-wrap !important;
|
| 1450 |
+
line-height: 1.15 !important;
|
| 1451 |
+
cursor: text !important;
|
| 1452 |
+
user-select: text !important;
|
| 1453 |
+
-webkit-user-select: text !important;
|
| 1454 |
+
}
|
| 1455 |
+
.pdf-text-layer::selection {
|
| 1456 |
+
background: rgba(0, 120, 215, 0.33) !important;
|
| 1457 |
+
color: transparent !important;
|
| 1458 |
+
}
|
| 1459 |
+
.pdf-text-layer::-moz-selection {
|
| 1460 |
+
background: rgba(0, 120, 215, 0.33) !important;
|
| 1461 |
+
color: transparent !important;
|
| 1462 |
+
}
|
| 1463 |
+
.pdf-page-error {
|
| 1464 |
+
display: flex !important;
|
| 1465 |
+
justify-content: center !important;
|
| 1466 |
+
align-items: center !important;
|
| 1467 |
+
width: 100% !important;
|
| 1468 |
+
padding: 40px !important;
|
| 1469 |
+
background: rgba(235, 94, 85, 0.1) !important;
|
| 1470 |
+
border: 1px dashed #eb5e55 !important;
|
| 1471 |
+
border-radius: 8px !important;
|
| 1472 |
+
color: #eb5e55 !important;
|
| 1473 |
+
}
|
| 1474 |
+
"""
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.8.0
|
| 2 |
+
ebooklib>=0.18
|
| 3 |
+
beautifulsoup4>=4.12
|
| 4 |
+
pymupdf>=1.24
|
| 5 |
+
Pillow>=10.0
|
voice_samples/Aiden/Aiden_angry_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8a088b0f3dff7d5e6769a40897fe46c004f1cde4bf7712f127753eca6d0f2f55
|
| 3 |
+
size 691244
|
voice_samples/Aiden/Aiden_angry_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ec065ac3fa7dfbc14f52ee218821bac8db29526353c676e1516cba0f3a8f2050
|
| 3 |
+
size 668204
|
voice_samples/Aiden/Aiden_angry_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:23ee3284d2d53a134eb7b21da9df3ab136d7ef0367a8ab8c1ad49c56754e6456
|
| 3 |
+
size 629804
|
voice_samples/Aiden/Aiden_fearful_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fbf02ab3a6504438a48ac3f74ca44ed03a6d0221f8d7138b300fca77ae9457ae
|
| 3 |
+
size 764204
|
voice_samples/Aiden/Aiden_fearful_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5740ad971f69c5411d947d953ebaa16f9cb52ff59860e6791d2e8a842085a9fa
|
| 3 |
+
size 633644
|
voice_samples/Aiden/Aiden_fearful_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d7fb3f1033f0657deaef1f350d73f2b12b9214e688892e7f11c03bae04152b8c
|
| 3 |
+
size 748844
|
voice_samples/Aiden/Aiden_happy_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1e6415614108668077361a5e91999cde522399612467dc5d9e3e4a2258a74b33
|
| 3 |
+
size 714284
|
voice_samples/Aiden/Aiden_happy_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b5922fd7eb230578dc1217888b3e5d81a69a47103feaf05ca17a98279b61d59d
|
| 3 |
+
size 645164
|
voice_samples/Aiden/Aiden_happy_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5de4b1dca1f983d55cae84e24d0389716ef3ca7adeee1f9bec765d6dc98771e0
|
| 3 |
+
size 414764
|
voice_samples/Aiden/Aiden_mysterious_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:10390cb57b0bf860e4c5bd4c6121591147e7c15aae24046230f7364bd10a6f12
|
| 3 |
+
size 668204
|
voice_samples/Aiden/Aiden_neutral.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a8513477a7b4bec91e25443df7383dedc94f1ae5949874992dfed8ff3401536
|
| 3 |
+
size 741164
|
voice_samples/Aiden/Aiden_sad_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cc260af84d04b27d7a6a1a0aebf4646734162482b55ac9c7c3919687a61af904
|
| 3 |
+
size 748844
|
voice_samples/Aiden/Aiden_sad_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:46f32b95f1bf08f84ea5e7967fdac42543bf72b34608b7d1a167cb63e0360c5e
|
| 3 |
+
size 660524
|
voice_samples/Aiden/Aiden_sad_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f47c613b8c1f2bc1c329b95438292c414f70560d1bce830b46f2d3fddd2ceaaa
|
| 3 |
+
size 779564
|
voice_samples/Aiden/Aiden_serious_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a690ce17ab7280688e235817df37a36cd8a664b01f2476feffedf16d24fac0a9
|
| 3 |
+
size 710444
|
voice_samples/Aiden/Aiden_serious_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:96dd7863b4aed7d1209c4a30e700d94ed876b0a5f17493a7fc82daf346167e08
|
| 3 |
+
size 545324
|
voice_samples/Aiden/Aiden_warm_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c905111e287bc1476ed1a9e4ac387884cfd4c9065c8920e71f6ed873077ddd12
|
| 3 |
+
size 587564
|
voice_samples/Aiden/Aiden_warm_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a7628542d761f3ce77b9b7379f8ebf7720808270227a85c51eb19470f1d11485
|
| 3 |
+
size 649004
|
voice_samples/Aiden/Aiden_whisper_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c9b8a72eedb625f271fb506343e0168d449bb2cf9c4b342e2eec508b8201f238
|
| 3 |
+
size 633644
|
voice_samples/Aiden/Aiden_whisper_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5c4cd50c4f75aec53b78b425510d5d791c9b52c6343deee95dbcef09441b9d7e
|
| 3 |
+
size 633644
|
voice_samples/Dylan/Dylan_angry_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4e56d7164cec6126afc1ed576f37f2ea2eab0191b0d6374b214d9a41fc5437c6
|
| 3 |
+
size 806444
|
voice_samples/Dylan/Dylan_angry_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3edc107e70036bcf16f039ce28a088375bef131755361d65e19bdee58735261e
|
| 3 |
+
size 817964
|
voice_samples/Dylan/Dylan_angry_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4b6f58c753e656e88cf5efc53f9f442d79448d63bebb0de621560351fb38c537
|
| 3 |
+
size 675884
|
voice_samples/Dylan/Dylan_fearful_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9f2edc56d185eb036732232c7f4a4eba086b95404c9a606afbcbce457a9cf4fc
|
| 3 |
+
size 952364
|
voice_samples/Dylan/Dylan_fearful_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1ec4756b2bc8852a1aa2585f8268cbfe4e78866c7ac5a290f83ae72fe0545370
|
| 3 |
+
size 568364
|
voice_samples/Dylan/Dylan_fearful_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fb69ebf60e05a634dd66625e080e2923a66049dd18639c428813729e0fc9a46e
|
| 3 |
+
size 668204
|
voice_samples/Dylan/Dylan_happy_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1353ec840de7607751c4d682246dbcf8f3b62fca94755bfd99d5411d95a75f24
|
| 3 |
+
size 794924
|
voice_samples/Dylan/Dylan_happy_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a047e04d469be327f0930de697622b0d2a94373fe25a5a000fbdec5ca2657e7c
|
| 3 |
+
size 672044
|
voice_samples/Dylan/Dylan_happy_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1dba770a5c373fcea44c5c9b79608e114950c3c646a53efaa74eadc9bf02b76d
|
| 3 |
+
size 468524
|
voice_samples/Dylan/Dylan_mysterious_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2804d903b17b9aba33dd35664bea2f2072dcde15a7f8ab1d3e67898e9f0b6f1f
|
| 3 |
+
size 733484
|
voice_samples/Dylan/Dylan_neutral.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b3723c6ae0184780b3bd3df0b10258e6e53beb5a57c250cabb9ee62bdb182a78
|
| 3 |
+
size 748844
|
voice_samples/Dylan/Dylan_sad_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a5248013da3702b48d3977d56b81559256f28546049fa63543d00c60b98b484
|
| 3 |
+
size 821804
|
voice_samples/Dylan/Dylan_sad_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:068e5f64e4295eac208a482fc484e8c0564d51f78d94868f8b825578f1f1619b
|
| 3 |
+
size 675884
|
voice_samples/Dylan/Dylan_sad_med.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:afe1fcc1d65dc76307854fe392b861d09080cbf3bd0cf694cb96efac352d274a
|
| 3 |
+
size 752684
|
voice_samples/Dylan/Dylan_serious_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9cd1d73998fe02580c89e64d56119510083b2bc0a9175150c0405734ba9bc3f8
|
| 3 |
+
size 733484
|
voice_samples/Dylan/Dylan_serious_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:985f71e242741bf32463e319ba98ecb64e01a2de818a0451b18b7ef97841b271
|
| 3 |
+
size 614444
|
voice_samples/Dylan/Dylan_warm_high.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:19db9dc946adebb11ce82d700b553ba2ff4f5a1e517ce592003c8f1e7c3077d2
|
| 3 |
+
size 622124
|
voice_samples/Dylan/Dylan_warm_low.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0c5c6ee69071525f06b1038709bb19767a99a057212a5cc43aee0948c3451b32
|
| 3 |
+
size 629804
|