parvmittal07 commited on
Commit
432adc8
·
0 Parent(s):

chore: project foundation — reader UI, modal, PDF parsing, bookmarking, voice samples

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. .gitignore +10 -0
  3. app.py +27 -0
  4. data/library.db +0 -0
  5. novel_reader/__init__.py +5 -0
  6. novel_reader/config.py +16 -0
  7. novel_reader/ingestion.py +52 -0
  8. novel_reader/models.py +28 -0
  9. novel_reader/parsers.py +433 -0
  10. novel_reader/storage.py +387 -0
  11. novel_reader/ui.py +1474 -0
  12. requirements.txt +5 -0
  13. voice_samples/Aiden/Aiden_angry_high.wav +3 -0
  14. voice_samples/Aiden/Aiden_angry_low.wav +3 -0
  15. voice_samples/Aiden/Aiden_angry_med.wav +3 -0
  16. voice_samples/Aiden/Aiden_fearful_high.wav +3 -0
  17. voice_samples/Aiden/Aiden_fearful_low.wav +3 -0
  18. voice_samples/Aiden/Aiden_fearful_med.wav +3 -0
  19. voice_samples/Aiden/Aiden_happy_high.wav +3 -0
  20. voice_samples/Aiden/Aiden_happy_low.wav +3 -0
  21. voice_samples/Aiden/Aiden_happy_med.wav +3 -0
  22. voice_samples/Aiden/Aiden_mysterious_low.wav +3 -0
  23. voice_samples/Aiden/Aiden_neutral.wav +3 -0
  24. voice_samples/Aiden/Aiden_sad_high.wav +3 -0
  25. voice_samples/Aiden/Aiden_sad_low.wav +3 -0
  26. voice_samples/Aiden/Aiden_sad_med.wav +3 -0
  27. voice_samples/Aiden/Aiden_serious_high.wav +3 -0
  28. voice_samples/Aiden/Aiden_serious_low.wav +3 -0
  29. voice_samples/Aiden/Aiden_warm_high.wav +3 -0
  30. voice_samples/Aiden/Aiden_warm_low.wav +3 -0
  31. voice_samples/Aiden/Aiden_whisper_high.wav +3 -0
  32. voice_samples/Aiden/Aiden_whisper_low.wav +3 -0
  33. voice_samples/Dylan/Dylan_angry_high.wav +3 -0
  34. voice_samples/Dylan/Dylan_angry_low.wav +3 -0
  35. voice_samples/Dylan/Dylan_angry_med.wav +3 -0
  36. voice_samples/Dylan/Dylan_fearful_high.wav +3 -0
  37. voice_samples/Dylan/Dylan_fearful_low.wav +3 -0
  38. voice_samples/Dylan/Dylan_fearful_med.wav +3 -0
  39. voice_samples/Dylan/Dylan_happy_high.wav +3 -0
  40. voice_samples/Dylan/Dylan_happy_low.wav +3 -0
  41. voice_samples/Dylan/Dylan_happy_med.wav +3 -0
  42. voice_samples/Dylan/Dylan_mysterious_low.wav +3 -0
  43. voice_samples/Dylan/Dylan_neutral.wav +3 -0
  44. voice_samples/Dylan/Dylan_sad_high.wav +3 -0
  45. voice_samples/Dylan/Dylan_sad_low.wav +3 -0
  46. voice_samples/Dylan/Dylan_sad_med.wav +3 -0
  47. voice_samples/Dylan/Dylan_serious_high.wav +3 -0
  48. voice_samples/Dylan/Dylan_serious_low.wav +3 -0
  49. voice_samples/Dylan/Dylan_warm_high.wav +3 -0
  50. 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">&times;</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;">&rarr;</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;">&larr;</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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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