From 88bd5dfe7c6243bf3fdaa7e2fec2432625087acd Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Thu, 20 Nov 2025 19:17:27 +0800 Subject: [PATCH 01/36] Add comment system, highlights view, and markdown support Features added: - Personal comments (no AI, editable/deletable) - Highlights view page for each book - Markdown rendering for AI responses - Multiple highlights support with Range API - Cover image display in library - Web upload functionality Improvements: - Fixed highlight rendering for multiple items - Color-coded highlights (yellow=AI, green=comments) - Filter highlights by type - Clean up unnecessary files and debug code - Updated documentation --- .env.example | 6 + .gitignore | 27 ++ .vscode/settings.json | 2 + README.md | 148 ++++++- ai_service.py | 80 ++++ backup.bat | 28 ++ books/.gitkeep | 2 + check_database.py | 94 +++++ database.py | 208 ++++++++++ pyproject.toml | 2 + reader3.py | 108 ++++- server.py | 326 ++++++++++++++- templates/highlights.html | 198 +++++++++ templates/library.html | 256 +++++++++++- templates/reader.html | 855 +++++++++++++++++++++++++++++++++++--- uv.lock | 50 +++ 16 files changed, 2286 insertions(+), 104 deletions(-) create mode 100644 .env.example create mode 100644 .vscode/settings.json create mode 100644 ai_service.py create mode 100644 backup.bat create mode 100644 books/.gitkeep create mode 100644 check_database.py create mode 100644 database.py create mode 100644 templates/highlights.html diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..21f56f3a --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# DeepSeek API Configuration (recommended) +OPENAI_API_KEY=your_api_key_here +OPENAI_BASE_URL=https://api.deepseek.com +OPENAI_MODEL=deepseek-chat + +# Get your key from: https://platform.deepseek.com/api_keys diff --git a/.gitignore b/.gitignore index 9e1d25d4..a763aef4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,30 @@ wheels/ # Custom *_data/ *.epub + +# Books directory (but keep the folder structure) +books/* +!books/.gitkeep + +# Temp directory for uploads +temp/ + +# AI Features & Data +.env +reader_data.db +test.db + +# Backup files +backups/ +*.db.backup + +# Export files +reader_data_*.json +highlights_*.csv +ai_analyses_*.csv +report_*.txt + +# OS files +.DS_Store +Thumbs.db +desktop.ini diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/README.md b/README.md index 5d868d7b..5bf1033e 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,155 @@ -# reader 3 +# Reader3 - EPUB Reader with AI Analysis -![reader3](reader3.png) +A lightweight, self-hosted EPUB reader with integrated AI analysis capabilities. -A lightweight, self-hosted EPUB reader that lets you read through EPUB books one chapter at a time. This makes it very easy to copy paste the contents of a chapter to an LLM, to read along. Basically - get epub books (e.g. [Project Gutenberg](https://www.gutenberg.org/) has many), open them up in this reader, copy paste text around to your favorite LLM, and read together and along. +## Features -This project was 90% vibe coded just to illustrate how one can very easily [read books together with LLMs](https://x.com/karpathy/status/1990577951671509438). I'm not going to support it in any way, it's provided here as is for other people's inspiration and I don't intend to improve it. Code is ephemeral now and libraries are over, ask your LLM to change it in whatever way you like. +- 📚 **EPUB Reading** - Clean three-column layout (TOC, Content, AI Panel) +- 🤖 **AI Analysis** - Right-click on text for fact-checking or discussion (DeepSeek) +- � **Paersonal Comments** - Add your own notes without AI (no API cost) +- 💾 **Manual Save** - Choose what to save to avoid clutter +- ✨ **Visual Highlights** - Saved analyses automatically highlighted with icons (📋 💡 💬) +- 📝 **Highlights View** - See all your notes and analyses for each book in one page +- 🎨 **Markdown Support** - AI responses render with proper formatting +- 🗂️ **Organized Storage** - All books in `books/` directory, data in SQLite +- 🌐 **Web Upload** - Upload EPUB files directly from browser +- 🖼️ **Cover Images** - Automatic cover extraction and display -## Usage +## Quick Start + +### 1. Configure API Key + +Edit `.env` file: +```bash +OPENAI_API_KEY=your_deepseek_key +OPENAI_BASE_URL=https://api.deepseek.com +OPENAI_MODEL=deepseek-chat +``` -The project uses [uv](https://docs.astral.sh/uv/). So for example, download [Dracula EPUB3](https://www.gutenberg.org/ebooks/345) to this directory as `dracula.epub`, then: +Get your key from: https://platform.deepseek.com/api_keys +### 2. Add Books + +**Option A: Upload via Web Interface (Easiest)** +1. Start server: `uv run server.py` +2. Open http://127.0.0.1:8123 +3. Click the "+" card +4. Select EPUB file +5. Wait for automatic processing + +**Option B: Command Line** ```bash -uv run reader3.py dracula.epub +uv run reader3.py your_book.epub ``` -This creates the directory `dracula_data`, which registers the book to your local library. We can then run the server: +### 3. Start Server ```bash uv run server.py ``` -And visit [localhost:8123](http://localhost:8123/) to see your current Library. You can easily add more books, or delete them from your library by deleting the folder. It's not supposed to be complicated or complex. +### 4. Read and Analyze + +1. Open http://127.0.0.1:8123 +2. Select a book +3. Right-click on text → Choose analysis type +4. Review AI response in side panel +5. Save if important +6. Highlights appear on next visit! + +## Usage + +### AI Analysis +- Select text → Right-click → Choose: + - **📋 Fact Check** - Verify facts and get context + - **💡 Discussion** - Deep analysis and insights + - **💬 Add Comment** - Your personal notes (no AI) +- View response in right panel +- Click "Save" for important insights + +### Highlights +- **Yellow highlights** (📋 💡) - AI analyses +- **Green highlights** (💬) - Your comments +- Click any highlight to view/edit +- Comments are editable and deletable + +### View All Highlights +- Click ⋮ menu on any book → "📝 View Highlights" +- See all your notes and analyses in one page +- Filter by type (Fact Check, Discussion, Comment) +- Jump directly to any chapter + +## Project Structure + +``` +reader3/ +├── reader3.py # EPUB processor +├── server.py # Web server +├── database.py # SQLite operations +├── ai_service.py # AI integration +├── books/ # All book data here +│ └── book_name_data/ +│ ├── book.pkl +│ └── images/ +├── templates/ # HTML templates +├── reader_data.db # SQLite database +└── .env # API configuration +``` + +## Data Management + +### View Your Highlights +- Click ⋮ menu on any book → "📝 View Highlights" +- See all notes, comments, and analyses in one page +- Filter by type and jump to chapters + +### View Database (Advanced) +```bash +uv run check_database.py +``` + +### Backup +```bash +# Double-click: backup.bat +# Or manually: +copy reader_data.db backups\reader_data_backup.db +``` + +## Tools + +- `check_database.py` - View raw database contents (advanced) +- `backup.bat` - Quick database backup + +## Why DeepSeek? + +- ✅ Cost-effective (¥1/M tokens input, ¥2/M output) +- ✅ Excellent Chinese language support +- ✅ Fast response in China +- ✅ OpenAI-compatible API + +## Troubleshooting + +### API Key Error +1. Check `.env` file exists and has correct key +2. Run `uv run test_env.py` to verify +3. Restart server + +### No Highlights Showing +1. Check browser console (F12) for errors +2. Verify data exists: `uv run check_database.py` +3. Refresh page + +### Server Won't Start +1. Check if port 8123 is available +2. Verify `.env` configuration +3. Run `uv run debug_server.py` for details + + ## License -MIT \ No newline at end of file +MIT + +--- + +**Note**: This project is designed to be simple and hackable. Ask your LLM to modify it however you like! diff --git a/ai_service.py b/ai_service.py new file mode 100644 index 00000000..6cdd356a --- /dev/null +++ b/ai_service.py @@ -0,0 +1,80 @@ +""" +AI service for fact-checking and discussion. +""" +import os +import httpx +from typing import Optional + + +class AIService: + """Handles AI API calls.""" + + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): + self.api_key = api_key or os.getenv("OPENAI_API_KEY") + self.base_url = base_url or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + + if not self.api_key: + raise ValueError("API key not provided. Set OPENAI_API_KEY environment variable.") + + async def fact_check(self, text: str, context: str = "") -> str: + """Fact-check the selected text.""" + prompt = f"""请对以下文本进行事实核查。如果有历史事实、数据或陈述,请验证其准确性并提供相关背景信息。 + +选中的文本: +{text} + +上下文: +{context} + +请提供: +1. 主要事实陈述的准确性评估 +2. 相关的历史背景或补充信息 +3. 如有错误或争议,请指出并说明""" + + return await self._call_api(prompt) + + async def discuss(self, text: str, context: str = "") -> str: + """Generate discussion points about the selected text.""" + prompt = f"""请对以下文本进行深入分析和讨论。 + +选中的文本: +{text} + +上下文: +{context} + +请提供: +1. 文本的核心观点和论证 +2. 可能的不同解读角度 +3. 值得思考的问题 +4. 与其他观点或理论的联系""" + + return await self._call_api(prompt) + + async def _call_api(self, prompt: str) -> str: + """Make API call to OpenAI-compatible endpoint.""" + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + }, + json={ + "model": self.model, + "messages": [ + {"role": "user", "content": prompt} + ], + "temperature": 0.7 + } + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except httpx.HTTPError as e: + return f"API调用失败: {str(e)}" + except Exception as e: + return f"处理失败: {str(e)}" diff --git a/backup.bat b/backup.bat new file mode 100644 index 00000000..4677b453 --- /dev/null +++ b/backup.bat @@ -0,0 +1,28 @@ +@echo off +echo ======================================== +echo 备份 Reader3 数据库 +echo ======================================== +echo. + +REM 创建backups文件夹 +if not exist backups mkdir backups + +REM 生成带时间戳的文件名 +set datetime=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2% +set datetime=%datetime: =0% + +REM 备份数据库 +copy reader_data.db backups\reader_data_%datetime%.db + +echo. +echo ✓ 备份完成! +echo 文件: backups\reader_data_%datetime%.db +echo. + +REM 显示backups文件夹内容 +echo 现有备份: +dir /b backups\*.db + +echo. +echo ======================================== +pause diff --git a/books/.gitkeep b/books/.gitkeep new file mode 100644 index 00000000..da8ae5d5 --- /dev/null +++ b/books/.gitkeep @@ -0,0 +1,2 @@ +# This file keeps the books directory in git +# All book data will be stored here diff --git a/check_database.py b/check_database.py new file mode 100644 index 00000000..cb60f06d --- /dev/null +++ b/check_database.py @@ -0,0 +1,94 @@ +"""查看数据库内容""" +import sqlite3 +from datetime import datetime + +db_path = "reader_data.db" + +print("=" * 60) +print("数据库内容检查") +print("=" * 60) +print(f"\n数据库位置: {db_path}") +print() + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 检查highlights表 +print("📚 Highlights (高亮) 表:") +print("-" * 60) +cursor.execute("SELECT COUNT(*) FROM highlights") +count = cursor.fetchone()[0] +print(f"总记录数: {count}") + +if count > 0: + cursor.execute(""" + SELECT id, book_id, chapter_index, + substr(selected_text, 1, 50) as text_preview, + created_at + FROM highlights + ORDER BY created_at DESC + LIMIT 5 + """) + + print("\n最近的5条记录:") + for row in cursor.fetchall(): + print(f"\nID: {row[0]}") + print(f" 书籍: {row[1]}") + print(f" 章节: {row[2]}") + print(f" 文本: {row[3]}...") + print(f" 时间: {row[4]}") + +print("\n" + "=" * 60) + +# 检查ai_analyses表 +print("🤖 AI Analyses (AI分析) 表:") +print("-" * 60) +cursor.execute("SELECT COUNT(*) FROM ai_analyses") +count = cursor.fetchone()[0] +print(f"总记录数: {count}") + +if count > 0: + cursor.execute(""" + SELECT id, highlight_id, analysis_type, + substr(prompt, 1, 50) as prompt_preview, + substr(response, 1, 100) as response_preview, + created_at + FROM ai_analyses + ORDER BY created_at DESC + LIMIT 5 + """) + + print("\n最近的5条记录:") + for row in cursor.fetchall(): + print(f"\nID: {row[0]}") + print(f" 关联高亮ID: {row[1]}") + print(f" 分析类型: {row[2]}") + print(f" 提示: {row[3]}...") + print(f" 响应: {row[4]}...") + print(f" 时间: {row[5]}") + +print("\n" + "=" * 60) + +# 统计信息 +print("📊 统计信息:") +print("-" * 60) + +cursor.execute(""" + SELECT analysis_type, COUNT(*) + FROM ai_analyses + GROUP BY analysis_type +""") +stats = cursor.fetchall() + +if stats: + print("\n按分析类型统计:") + for row in stats: + print(f" {row[0]}: {row[1]} 条") +else: + print(" 暂无数据") + +conn.close() + +print("\n" + "=" * 60) +print("✓ 检查完成") +print("=" * 60) diff --git a/database.py b/database.py new file mode 100644 index 00000000..25bc7300 --- /dev/null +++ b/database.py @@ -0,0 +1,208 @@ +""" +Database models for storing highlights and AI interactions. +""" +import sqlite3 +import json +from datetime import datetime +from typing import List, Dict, Optional +from dataclasses import dataclass, asdict + + +@dataclass +class Highlight: + """User highlight with position info.""" + id: Optional[int] = None + book_id: str = "" + chapter_index: int = 0 + selected_text: str = "" + context_before: str = "" + context_after: str = "" + created_at: str = "" + + +@dataclass +class AIAnalysis: + """AI analysis result (fact-check or discussion).""" + id: Optional[int] = None + highlight_id: int = 0 + analysis_type: str = "" # 'fact_check' or 'discussion' + prompt: str = "" + response: str = "" + created_at: str = "" + + +class Database: + """Simple SQLite database for storing highlights and AI analyses.""" + + def __init__(self, db_path: str = "reader_data.db"): + self.db_path = db_path + self.init_db() + + def init_db(self): + """Create tables if they don't exist.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS highlights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id TEXT NOT NULL, + chapter_index INTEGER NOT NULL, + selected_text TEXT NOT NULL, + context_before TEXT, + context_after TEXT, + created_at TEXT NOT NULL + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ai_analyses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + highlight_id INTEGER NOT NULL, + analysis_type TEXT NOT NULL, + prompt TEXT NOT NULL, + response TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (highlight_id) REFERENCES highlights (id) + ) + """) + + conn.commit() + conn.close() + + def save_highlight(self, highlight: Highlight) -> int: + """Save a highlight and return its ID.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO highlights (book_id, chapter_index, selected_text, + context_before, context_after, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, ( + highlight.book_id, + highlight.chapter_index, + highlight.selected_text, + highlight.context_before, + highlight.context_after, + highlight.created_at or datetime.now().isoformat() + )) + + highlight_id = cursor.lastrowid + conn.commit() + conn.close() + + return highlight_id + + def save_analysis(self, analysis: AIAnalysis) -> int: + """Save an AI analysis and return its ID.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO ai_analyses (highlight_id, analysis_type, prompt, response, created_at) + VALUES (?, ?, ?, ?, ?) + """, ( + analysis.highlight_id, + analysis.analysis_type, + analysis.prompt, + analysis.response, + analysis.created_at or datetime.now().isoformat() + )) + + analysis_id = cursor.lastrowid + conn.commit() + conn.close() + + return analysis_id + + def get_highlights_for_chapter(self, book_id: str, chapter_index: int) -> List[Dict]: + """Get all highlights for a specific chapter.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM highlights + WHERE book_id = ? AND chapter_index = ? + ORDER BY created_at DESC + """, (book_id, chapter_index)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_all_highlights_for_book(self, book_id: str) -> List[Dict]: + """Get all highlights for a book (all chapters).""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM highlights + WHERE book_id = ? + ORDER BY created_at DESC + """, (book_id,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_analyses_for_highlight(self, highlight_id: int) -> List[Dict]: + """Get all AI analyses for a highlight.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM ai_analyses + WHERE highlight_id = ? + ORDER BY created_at DESC + """, (highlight_id,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def update_analysis(self, analysis_id: int, response: str): + """Update an existing analysis response (for editing comments).""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(""" + UPDATE ai_analyses + SET response = ? + WHERE id = ? + """, (response, analysis_id)) + + conn.commit() + conn.close() + + def delete_analysis(self, analysis_id: int): + """Delete an analysis and its highlight if no other analyses exist.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Get the highlight_id before deleting + cursor.execute("SELECT highlight_id FROM ai_analyses WHERE id = ?", (analysis_id,)) + result = cursor.fetchone() + + if result: + highlight_id = result[0] + + # Delete the analysis + cursor.execute("DELETE FROM ai_analyses WHERE id = ?", (analysis_id,)) + + # Check if there are other analyses for this highlight + cursor.execute("SELECT COUNT(*) FROM ai_analyses WHERE highlight_id = ?", (highlight_id,)) + count = cursor.fetchone()[0] + + # If no other analyses, delete the highlight too + if count == 0: + cursor.execute("DELETE FROM highlights WHERE id = ?", (highlight_id,)) + + conn.commit() + conn.close() diff --git a/pyproject.toml b/pyproject.toml index 31e61793..6480fee4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,6 @@ dependencies = [ "fastapi>=0.121.2", "jinja2>=3.1.6", "uvicorn>=0.38.0", + "httpx>=0.27.0", + "python-multipart>=0.0.6", ] diff --git a/reader3.py b/reader3.py index d0b9d3f9..c9eb7b1d 100644 --- a/reader3.py +++ b/reader3.py @@ -64,6 +64,7 @@ class Book: # Meta info source_file: str processed_at: str + cover_image: Optional[str] = None # Cover image filename version: str = "3.0" @@ -187,9 +188,38 @@ def process_epub(epub_path: str, output_dir: str) -> Book: images_dir = os.path.join(output_dir, 'images') os.makedirs(images_dir, exist_ok=True) - # 4. Extract Images & Build Map + # 4. Extract Images & Build Map (including cover) print("Extracting images...") image_map = {} # Key: internal_path, Value: local_relative_path + cover_image = None + + # Try to find cover image from metadata + cover_item = None + + # Method 1: Check for ITEM_COVER type + for item in book.get_items(): + if item.get_type() == ebooklib.ITEM_COVER: + cover_item = item + print(f"Found cover (type COVER): {item.get_name()}") + break + + # Method 2: Look for images with 'cover' in the name + if not cover_item: + for item in book.get_items(): + if item.get_type() == ebooklib.ITEM_IMAGE: + name_lower = item.get_name().lower() + if 'cover' in name_lower or 'cvi' in name_lower: + cover_item = item + print(f"Found cover (by name): {item.get_name()}") + break + + # Method 3: Use first image as fallback + if not cover_item: + for item in book.get_items(): + if item.get_type() == ebooklib.ITEM_IMAGE: + cover_item = item + print(f"Using first image as cover: {item.get_name()}") + break for item in book.get_items(): if item.get_type() == ebooklib.ITEM_IMAGE: @@ -208,6 +238,10 @@ def process_epub(epub_path: str, output_dir: str) -> Book: rel_path = f"images/{safe_fname}" image_map[item.get_name()] = rel_path image_map[original_fname] = rel_path + + # Check if this is the cover image + if cover_item and item.get_name() == cover_item.get_name(): + cover_image = safe_fname # 5. Process TOC print("Parsing Table of Contents...") @@ -277,7 +311,8 @@ def process_epub(epub_path: str, output_dir: str) -> Book: toc=toc_structure, images=image_map, source_file=os.path.basename(epub_path), - processed_at=datetime.now().isoformat() + processed_at=datetime.now().isoformat(), + cover_image=cover_image ) return final_book @@ -292,6 +327,26 @@ def save_to_pickle(book: Book, output_dir: str): # --- CLI --- +def sanitize_folder_name(name: str) -> str: + """ + Sanitize folder name while preserving Unicode characters (including Chinese). + Only removes characters that are invalid for Windows/Unix filesystems. + """ + # Characters not allowed in Windows filenames + invalid_chars = '<>:"/\\|?*' + for char in invalid_chars: + name = name.replace(char, '_') + + # Remove leading/trailing spaces and dots + name = name.strip('. ') + + # Limit length to avoid path issues (Windows has 260 char limit) + if len(name) > 100: + name = name[:100] + + return name + + if __name__ == "__main__": import sys @@ -301,13 +356,48 @@ def save_to_pickle(book: Book, output_dir: str): epub_file = sys.argv[1] assert os.path.exists(epub_file), "File not found." - out_dir = os.path.splitext(epub_file)[0] + "_data" + + # Create books directory if it doesn't exist + books_dir = "books" + os.makedirs(books_dir, exist_ok=True) + + # First, do a quick metadata extraction to get the real title + print(f"Reading metadata from {epub_file}...") + temp_book = epub.read_epub(epub_file) + temp_metadata = extract_metadata_robust(temp_book) + + # Use the actual book title for folder name (supports Chinese!) + book_title = temp_metadata.title or os.path.splitext(os.path.basename(epub_file))[0] + safe_title = sanitize_folder_name(book_title) + out_dir = os.path.join(books_dir, safe_title + "_data") + + # If folder exists, add a number suffix + if os.path.exists(out_dir): + counter = 1 + while os.path.exists(f"{out_dir}_{counter}"): + counter += 1 + out_dir = f"{out_dir}_{counter}" + + print(f"Output directory: {out_dir}") book_obj = process_epub(epub_file, out_dir) save_to_pickle(book_obj, out_dir) - print("\n--- Summary ---") - print(f"Title: {book_obj.metadata.title}") - print(f"Authors: {', '.join(book_obj.metadata.authors)}") - print(f"Physical Files (Spine): {len(book_obj.spine)}") - print(f"TOC Root Items: {len(book_obj.toc)}") - print(f"Images extracted: {len(book_obj.images)}") + + # Use safe printing to avoid Unicode errors on Windows + try: + print("\n--- Summary ---") + print(f"Title: {book_obj.metadata.title}") + print(f"Authors: {', '.join(book_obj.metadata.authors)}") + print(f"Physical Files (Spine): {len(book_obj.spine)}") + print(f"TOC Root Items: {len(book_obj.toc)}") + print(f"Images extracted: {len(book_obj.images)}") + print(f"\nBook data saved to: {out_dir}") + except UnicodeEncodeError: + # Fallback for Windows console encoding issues + print("\n--- Summary ---") + print(f"Title: [Unicode title]") + print(f"Authors: [Unicode authors]") + print(f"Physical Files (Spine): {len(book_obj.spine)}") + print(f"TOC Root Items: {len(book_obj.toc)}") + print(f"Images extracted: {len(book_obj.images)}") + print(f"\nBook data saved to: {out_dir}") diff --git a/server.py b/server.py index 9c870dc6..08c19645 100644 --- a/server.py +++ b/server.py @@ -2,19 +2,74 @@ import pickle from functools import lru_cache from typing import Optional +from datetime import datetime +from pathlib import Path -from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import HTMLResponse, FileResponse +from fastapi import FastAPI, Request, HTTPException, UploadFile, File +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +import shutil +import subprocess from reader3 import Book, BookMetadata, ChapterContent, TOCEntry +from database import Database, Highlight, AIAnalysis +from ai_service import AIService + +# Load .env file at startup +def load_env(): + """Load environment variables from .env file.""" + env_path = Path(".env") + if env_path.exists(): + print("Loading .env file...") + with open(env_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + print(f"✓ Loaded API configuration: {os.getenv('OPENAI_BASE_URL', 'Not set')}") + else: + print("⚠ Warning: .env file not found. AI features will not work.") + +load_env() app = FastAPI() templates = Jinja2Templates(directory="templates") +# Initialize database and AI service +db = Database() +ai_service = None # Will be initialized on first use + +def get_ai_service(): + """Lazy initialization of AI service.""" + global ai_service + if ai_service is None: + try: + ai_service = AIService() + except ValueError as e: + print(f"Warning: {e}") + return ai_service + + +# Request models +class HighlightRequest(BaseModel): + book_id: str + chapter_index: int + selected_text: str + context_before: str = "" + context_after: str = "" + + +class AIRequest(BaseModel): + highlight_id: int + analysis_type: str # 'fact_check' or 'discussion' + selected_text: str + context: str = "" + # Where are the book folders located? -BOOKS_DIR = "." +BOOKS_DIR = "books" @lru_cache(maxsize=10) def load_book_cached(folder_name: str) -> Optional[Book]: @@ -39,20 +94,32 @@ async def library_view(request: Request): """Lists all available processed books.""" books = [] - # Scan directory for folders ending in '_data' that have a book.pkl - if os.path.exists(BOOKS_DIR): - for item in os.listdir(BOOKS_DIR): - if item.endswith("_data") and os.path.isdir(item): - # Try to load it to get the title - book = load_book_cached(item) - if book: - books.append({ - "id": item, - "title": book.metadata.title, - "author": ", ".join(book.metadata.authors), - "chapters": len(book.spine) - }) + # Create books directory if it doesn't exist + os.makedirs(BOOKS_DIR, exist_ok=True) + # Scan directory for folders ending in '_data' that have a book.pkl + for item in os.listdir(BOOKS_DIR): + item_path = os.path.join(BOOKS_DIR, item) + # Check if it's a directory and ends with '_data' (including _data_1, _data_2, etc.) + if os.path.isdir(item_path) and "_data" in item: + # Try to load it to get the title + book = load_book_cached(item) + if book: + # Extract folder suffix if it exists (e.g., "_1", "_2") + folder_suffix = None + # Check if there's a number after _data + if "_data_" in item: + suffix_num = item.split("_data_")[-1] + folder_suffix = f"Copy {suffix_num}" + + books.append({ + "id": item, + "title": book.metadata.title, + "author": ", ".join(book.metadata.authors), + "chapters": len(book.spine), + "folder_suffix": folder_suffix, + "cover": book.cover_image if hasattr(book, 'cover_image') else None + }) return templates.TemplateResponse("library.html", {"request": request, "books": books}) @app.get("/read/{book_id}", response_class=HTMLResponse) @@ -104,6 +171,233 @@ async def serve_image(book_id: str, image_name: str): return FileResponse(img_path) + +# AI-related endpoints + +@app.post("/api/highlight") +async def create_highlight(req: HighlightRequest): + """Save a user highlight.""" + highlight = Highlight( + book_id=req.book_id, + chapter_index=req.chapter_index, + selected_text=req.selected_text, + context_before=req.context_before, + context_after=req.context_after, + created_at=datetime.now().isoformat() + ) + + highlight_id = db.save_highlight(highlight) + return {"highlight_id": highlight_id, "status": "success"} + + +@app.post("/api/ai/analyze") +async def analyze_text(req: AIRequest): + """Perform AI analysis (fact-check or discussion) without saving.""" + service = get_ai_service() + if not service: + raise HTTPException(status_code=500, detail="AI service not configured. Please set OPENAI_API_KEY.") + + # Call appropriate AI function + if req.analysis_type == "fact_check": + response = await service.fact_check(req.selected_text, req.context) + elif req.analysis_type == "discussion": + response = await service.discuss(req.selected_text, req.context) + else: + raise HTTPException(status_code=400, detail="Invalid analysis type") + + return { + "response": response, + "status": "success" + } + + +class SaveAnalysisRequest(BaseModel): + highlight_id: int + analysis_type: str + prompt: str + response: str + + +@app.post("/api/ai/save") +async def save_analysis(req: SaveAnalysisRequest): + """Save AI analysis to database.""" + analysis = AIAnalysis( + highlight_id=req.highlight_id, + analysis_type=req.analysis_type, + prompt=req.prompt, + response=req.response, + created_at=datetime.now().isoformat() + ) + + analysis_id = db.save_analysis(analysis) + + return { + "analysis_id": analysis_id, + "status": "success" + } + + +@app.get("/api/highlights/{book_id}/{chapter_index}") +async def get_highlights(book_id: str, chapter_index: int): + """Get all highlights for a chapter.""" + highlights = db.get_highlights_for_chapter(book_id, chapter_index) + + # Attach analyses to each highlight + for highlight in highlights: + highlight["analyses"] = db.get_analyses_for_highlight(highlight["id"]) + + return {"highlights": highlights} + + +@app.get("/highlights/{book_id}") +async def view_highlights(book_id: str, request: Request): + """View all highlights for a book.""" + try: + # Get all highlights for this book + all_highlights = db.get_all_highlights_for_book(book_id) + + # Attach analyses and flatten + highlights_with_analyses = [] + for highlight in all_highlights: + analyses = db.get_analyses_for_highlight(highlight["id"]) + if analyses: + for analysis in analyses: + highlights_with_analyses.append({ + **highlight, + "analysis_type": analysis["analysis_type"], + "response": analysis["response"], + "analysis_created_at": analysis["created_at"] + }) + else: + # Highlight without analysis + highlights_with_analyses.append({ + **highlight, + "analysis_type": None, + "response": None, + "analysis_created_at": None + }) + + # Sort by creation date (newest first) + highlights_with_analyses.sort(key=lambda x: x["created_at"], reverse=True) + + # Calculate stats + stats = { + "total": len(highlights_with_analyses), + "fact_check": sum(1 for h in highlights_with_analyses if h["analysis_type"] == "fact_check"), + "discussion": sum(1 for h in highlights_with_analyses if h["analysis_type"] == "discussion"), + "comment": sum(1 for h in highlights_with_analyses if h["analysis_type"] == "comment") + } + + # Get book title + book_title = book_id.replace("_data", "").replace("_", " ") + + return templates.TemplateResponse("highlights.html", { + "request": request, + "book_id": book_id, + "book_title": book_title, + "highlights": highlights_with_analyses, + "stats": stats + }) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/ai/update/{analysis_id}") +async def update_analysis(analysis_id: int, req: dict): + """Update an existing analysis (for editing comments).""" + try: + db.update_analysis(analysis_id, req.get("response", "")) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/ai/delete/{analysis_id}") +async def delete_analysis(analysis_id: int): + """Delete an analysis (and its highlight if no other analyses exist).""" + try: + db.delete_analysis(analysis_id) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/delete/{book_id}") +async def delete_book(book_id: str): + """Delete a book folder (but keep database entries).""" + try: + # Security check: ensure book_id doesn't contain path traversal + if ".." in book_id or "/" in book_id or "\\" in book_id: + raise HTTPException(status_code=400, detail="Invalid book ID") + + book_path = os.path.join(BOOKS_DIR, book_id) + + if not os.path.exists(book_path): + raise HTTPException(status_code=404, detail="Book not found") + + # Delete the book folder + shutil.rmtree(book_path) + + # Clear cache for this book + load_book_cached.cache_clear() + + return { + "message": f"Book deleted. Your highlights and analyses are preserved in the database.", + "status": "success" + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/upload") +async def upload_book(file: UploadFile = File(...)): + """Upload and process an EPUB file.""" + # Validate file type + if not file.filename.endswith('.epub'): + raise HTTPException(status_code=400, detail="Only EPUB files are supported") + + try: + # Create temp directory if it doesn't exist + temp_dir = "temp" + os.makedirs(temp_dir, exist_ok=True) + + # Save uploaded file + temp_file_path = os.path.join(temp_dir, file.filename) + with open(temp_file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Process the EPUB file using reader3.py with uv + result = subprocess.run( + ["uv", "run", "reader3.py", temp_file_path], + capture_output=True, + text=True, + timeout=60 + ) + + # Clean up temp file + os.remove(temp_file_path) + + if result.returncode == 0: + # Extract book title from output + book_name = os.path.splitext(file.filename)[0] + return { + "message": f"Successfully processed '{book_name}'", + "status": "success" + } + else: + raise HTTPException( + status_code=500, + detail=f"Failed to process EPUB: {result.stderr}" + ) + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Processing timeout (file too large?)") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + if __name__ == "__main__": import uvicorn print("Starting server at http://127.0.0.1:8123") diff --git a/templates/highlights.html b/templates/highlights.html new file mode 100644 index 00000000..a787546a --- /dev/null +++ b/templates/highlights.html @@ -0,0 +1,198 @@ + + + + + + Highlights - {{ book_title }} + + + +
+
+ ← Back to Library +

{{ book_title }}

+
All your highlights and notes
+
+
+ 📋 + {{ stats.fact_check }} Fact Checks +
+
+ 💡 + {{ stats.discussion }} Discussions +
+
+ 💬 + {{ stats.comment }} Comments +
+
+ 📝 + {{ stats.total }} Total +
+
+
+ + {% if highlights %} +
+ + + + +
+ +
+ {% for item in highlights %} +
+
+
+ {% if item.analysis_type == 'fact_check' %} + 📋 Fact Check + {% elif item.analysis_type == 'discussion' %} + 💡 Discussion + {% elif item.analysis_type == 'comment' %} + 💬 Comment + {% endif %} +
+
{{ item.created_at }}
+
+ +
Chapter {{ item.chapter_index + 1 }}
+ +
"{{ item.selected_text }}"
+ + {% if item.response %} +
+
+ {% if item.analysis_type == 'comment' %} + Your Note: + {% else %} + AI Analysis: + {% endif %} +
+
{{ item.response }}
+
+ {% endif %} + + +
+ {% endfor %} +
+ {% else %} +
+
📚
+
No highlights yet
+
Start reading and highlight interesting passages!
+
+ Start Reading → +
+
+ {% endif %} +
+ + + + + + + diff --git a/templates/library.html b/templates/library.html index e7d094d3..9d80f565 100644 --- a/templates/library.html +++ b/templates/library.html @@ -9,33 +9,267 @@ .container { max-width: 800px; margin: 0 auto; } h1 { color: #333; border-bottom: 2px solid #ddd; padding-bottom: 10px; } .book-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; margin-top: 30px; } - .book-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); transition: transform 0.2s; } + .book-card { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); transition: transform 0.2s; position: relative; display: flex; flex-direction: column; } + .book-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.15); } + .book-cover { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; margin-bottom: 12px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3em; } + .book-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; } + .book-info { flex-grow: 1; margin-bottom: 15px; } .book-title { font-size: 1.2em; font-weight: bold; color: #2c3e50; margin-bottom: 10px; } .book-meta { color: #666; font-size: 0.9em; margin-bottom: 15px; } - .btn { display: inline-block; background: #3498db; color: white; text-decoration: none; padding: 8px 15px; border-radius: 4px; font-size: 0.9em; } + .btn { display: inline-block; background: #3498db; color: white; text-decoration: none; padding: 8px 15px; border-radius: 4px; font-size: 0.9em; border: none; cursor: pointer; text-align: center; } .btn:hover { background: #2980b9; } + .book-actions { display: flex; gap: 8px; align-items: center; margin-top: auto; } + .book-actions .btn { flex: 1; } + + /* Dropdown Menu */ + .menu-btn { background: #e9ecef; color: #333; padding: 8px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: 1.2em; line-height: 1; position: relative; } + .menu-btn:hover { background: #dee2e6; } + .dropdown-menu { position: absolute; bottom: 100%; right: 0; background: white; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 150px; display: none; z-index: 100; margin-bottom: 5px; } + .dropdown-menu.show { display: block; } + .dropdown-item { padding: 10px 15px; cursor: pointer; border-bottom: 1px solid #f0f0f0; font-size: 0.9em; color: #333; } + .dropdown-item:last-child { border-bottom: none; } + .dropdown-item:hover { background: #f8f9fa; } + .dropdown-item.danger { color: #e74c3c; } + .dropdown-item.danger:hover { background: #ffebee; } + + /* Add Book Card */ + .add-book-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); border: 2px dashed #3498db; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; cursor: pointer; transition: all 0.2s; } + .add-book-card:hover { border-color: #2980b9; background: #f8f9fa; transform: translateY(-2px); } + .add-icon { font-size: 4em; color: #3498db; margin-bottom: 10px; } + .add-text { color: #666; font-size: 1em; text-align: center; } + #file-input { display: none; } + + /* Upload Progress */ + .upload-status { margin-top: 20px; padding: 15px; background: #e3f2fd; border-radius: 8px; display: none; } + .upload-status.show { display: block; } + .upload-status.success { background: #e8f5e9; color: #2e7d32; } + .upload-status.error { background: #ffebee; color: #c62828; } + .progress-bar { width: 100%; height: 4px; background: #ddd; border-radius: 2px; margin-top: 10px; overflow: hidden; } + .progress-fill { height: 100%; background: #3498db; width: 0%; transition: width 0.3s; } + + /* Search Bar */ + .search-container { position: relative; margin: 20px 0 30px 0; } + .search-input { width: 100%; padding: 12px 45px 12px 15px; font-size: 1em; border: 2px solid #ddd; border-radius: 8px; transition: border-color 0.2s; } + .search-input:focus { outline: none; border-color: #3498db; } + .search-icon { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); font-size: 1.2em; color: #999; pointer-events: none; } + .book-card.hidden { display: none; } + .no-results { text-align: center; padding: 40px; color: #999; font-size: 1.1em; grid-column: 1 / -1; }

Library

- {% if not books %} -

No processed books found. Run reader3.py on an epub first.

- {% endif %} + +
+ + 🔍 +
+ +
+
+
+
+
+
+ {% for book in books %} -
-
{{ book.title }}
-
- {{ book.author }}
- {{ book.chapters }} sections +
+
+ {% if book.cover %} + {{ book.title }} + {% else %} + 📚 + {% endif %} +
+
+
{{ book.title }}
+
+ {{ book.author }}
+ {{ book.chapters }} sections + {% if book.folder_suffix %} +
{{ book.folder_suffix }} + {% endif %} +
+
+
+ Read Book +
+ + +
- Read Book
{% endfor %} + + +
+
+
+
Add New Book
Click to upload EPUB
+
+
+ + diff --git a/templates/reader.html b/templates/reader.html index c012edca..5e980fc8 100644 --- a/templates/reader.html +++ b/templates/reader.html @@ -5,27 +5,27 @@ {{ book.metadata.title }} - + - +
-
+
{{ current_chapter.content | safe }}
{% if prev_idx is not none %} - ← Previous + ← 上一章 {% else %} - ← Previous + ← 上一章 {% endif %} - Section {{ chapter_index + 1 }} of {{ book.spine|length }} + 第 {{ chapter_index + 1 }} / {{ book.spine|length }} 节 {% if next_idx is not none %} - Next → + 下一章 → {% else %} - Next → + 下一章 → {% endif %}
+ + + +
+ + +
+ + +
+ + +
+
+ 📋 事实核查 +
+
+ 💡 深入讨论 +
+
+ 💬 添加笔记 +
+
+ + + + + + + diff --git a/uv.lock b/uv.lock index e2e2f808..e84c5ac5 100644 --- a/uv.lock +++ b/uv.lock @@ -48,6 +48,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -118,6 +127,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -481,6 +518,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + [[package]] name = "reader3" version = "0.1.0" @@ -489,7 +535,9 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "ebooklib" }, { name = "fastapi" }, + { name = "httpx" }, { name = "jinja2" }, + { name = "python-multipart" }, { name = "uvicorn" }, ] @@ -498,7 +546,9 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.2" }, { name = "ebooklib", specifier = ">=0.20" }, { name = "fastapi", specifier = ">=0.121.2" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "python-multipart", specifier = ">=0.0.6" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] From 84290f0360f888354f8c190e2194bd7f07fedce2 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Fri, 21 Nov 2025 09:29:25 +0800 Subject: [PATCH 02/36] Major UI improvements and new features New Features: - Export highlights to markdown with AI context warning - Drag and drop EPUB upload - Modal popup for internal links (footnotes, author comments) - Keyboard navigation (arrow keys for prev/next chapter) - Sticky top navigation bar - Clickable book covers in library - Delete functionality for all highlight types - Tooltips on highlights showing type UI Improvements: - Color-coded highlights (yellow=fact check, blue=discussion, green=comment) - Removed icon overlays (cleaner text flow) - Professional AI button (replaced robot emoji) - Fixed HTML structure issues - Better panel visibility handling Bug Fixes: - Fixed highlight rendering for multiple items on same page - Fixed context length (only send selected text to AI) - Handle EPUB internal links with filenames - Proper modal closing behavior --- books/.gitkeep | 2 - server.py | 25 +++++- templates/highlights.html | 73 ++++++++++++++++ templates/library.html | 59 ++++++++++--- templates/reader.html | 175 ++++++++++++++++++++++++++++++++------ 5 files changed, 293 insertions(+), 41 deletions(-) diff --git a/books/.gitkeep b/books/.gitkeep index da8ae5d5..e69de29b 100644 --- a/books/.gitkeep +++ b/books/.gitkeep @@ -1,2 +0,0 @@ -# This file keeps the books directory in git -# All book data will be stored here diff --git a/server.py b/server.py index 08c19645..468071ef 100644 --- a/server.py +++ b/server.py @@ -127,9 +127,28 @@ async def redirect_to_first_chapter(book_id: str): """Helper to just go to chapter 0.""" return await read_chapter(book_id=book_id, chapter_index=0) -@app.get("/read/{book_id}/{chapter_index}", response_class=HTMLResponse) -async def read_chapter(request: Request, book_id: str, chapter_index: int): - """The main reader interface.""" +@app.get("/read/{book_id}/{chapter_ref:path}", response_class=HTMLResponse) +async def read_chapter(request: Request, book_id: str, chapter_ref: str): + """The main reader interface. Accepts either chapter index (0, 1, 2) or filename (part0008.html).""" + + # Try to parse as integer first + try: + chapter_index = int(chapter_ref) + except ValueError: + # It's a filename, need to find the corresponding chapter index + book = load_book_cached(book_id) + chapter_index = None + + # Search through spine to find matching filename + for idx, item in enumerate(book.spine): + if item.href == chapter_ref or item.href.endswith(chapter_ref): + chapter_index = idx + break + + if chapter_index is None: + raise HTTPException(status_code=404, detail=f"Chapter file '{chapter_ref}' not found") + + # Now proceed with the chapter_index book = load_book_cached(book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") diff --git a/templates/highlights.html b/templates/highlights.html index a787546a..c277a033 100644 --- a/templates/highlights.html +++ b/templates/highlights.html @@ -73,6 +73,7 @@
← Back to Library +

{{ book_title }}

All your highlights and notes
@@ -176,7 +177,10 @@

{{ book_title }}

}); }); + let currentFilter = 'all'; + function filterHighlights(type) { + currentFilter = type; const items = document.querySelectorAll('.highlight-item'); const buttons = document.querySelectorAll('.filter-btn'); @@ -193,6 +197,75 @@

{{ book_title }}

} }); } + + function exportHighlights() { + const items = document.querySelectorAll('.highlight-item'); + const bookTitle = "{{ book_title }}"; + const exportData = []; + + // Collect visible highlights + items.forEach(item => { + if (item.style.display !== 'none') { + const type = item.dataset.type; + const typeLabel = type === 'fact_check' ? 'Fact Check' : + type === 'discussion' ? 'Discussion' : 'Comment'; + const chapter = item.querySelector('.highlight-chapter').textContent; + const text = item.querySelector('.highlight-text').textContent.replace(/^"|"$/g, ''); + const analysisContent = item.querySelector('.analysis-content'); + const analysis = analysisContent ? analysisContent.textContent.trim() : ''; + + exportData.push({ + type: typeLabel, + chapter: chapter, + text: text, + analysis: analysis + }); + } + }); + + // Create markdown format + let markdown = `# ${bookTitle} - Highlights\n\n`; + markdown += `Exported: ${new Date().toLocaleString()}\n`; + markdown += `Filter: ${currentFilter === 'all' ? 'All' : currentFilter.replace('_', ' ')}\n`; + markdown += `Total: ${exportData.length} highlights\n\n`; + markdown += `---\n\n`; + + exportData.forEach((item, index) => { + markdown += `## ${index + 1}. ${item.type}\n\n`; + markdown += `**${item.chapter}**\n\n`; + markdown += `> ${item.text}\n\n`; + if (item.analysis) { + markdown += `### Analysis:\n\n${item.analysis}\n\n`; + } + markdown += `---\n\n`; + }); + + // Check length and warn if too large + const charCount = markdown.length; + const tokenEstimate = Math.ceil(charCount / 4); // Rough estimate: 1 token ≈ 4 chars + + if (tokenEstimate > 100000) { + if (!confirm(`⚠️ Warning: Export is very large (~${tokenEstimate.toLocaleString()} tokens, ${(charCount/1000).toFixed(1)}K chars).\n\nThis exceeds most AI context limits (e.g., GPT-4: 128K, Claude: 200K).\n\nConsider filtering to reduce size.\n\nContinue export anyway?`)) { + return; + } + } else if (tokenEstimate > 50000) { + if (!confirm(`⚠️ Notice: Export is large (~${tokenEstimate.toLocaleString()} tokens, ${(charCount/1000).toFixed(1)}K chars).\n\nThis may exceed some AI context limits.\n\nContinue export?`)) { + return; + } + } + + // Download file + const blob = new Blob([markdown], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const filename = `${bookTitle.replace(/[^a-z0-9]/gi, '_')}_highlights_${Date.now()}.md`; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } diff --git a/templates/library.html b/templates/library.html index 9d80f565..7a8aaf23 100644 --- a/templates/library.html +++ b/templates/library.html @@ -77,13 +77,15 @@

Library

{% for book in books %}
-
- {% if book.cover %} - {{ book.title }} - {% else %} - 📚 - {% endif %} -
+ +
+ {% if book.cover %} + {{ book.title }} + {% else %} + 📚 + {% endif %} +
+
{{ book.title }}
@@ -99,7 +101,7 @@

Library

@@ -223,9 +225,48 @@

Library

} } + // Drag and drop functionality + const bookGrid = document.querySelector('.book-grid'); + + bookGrid.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + bookGrid.style.opacity = '0.7'; + bookGrid.style.background = '#e3f2fd'; + }); + + bookGrid.addEventListener('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + bookGrid.style.opacity = '1'; + bookGrid.style.background = ''; + }); + + bookGrid.addEventListener('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + bookGrid.style.opacity = '1'; + bookGrid.style.background = ''; + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + if (file.name.endsWith('.epub')) { + uploadFile(file); + } else { + alert('Please drop an EPUB file'); + } + } + }); + async function handleFileUpload(event) { const file = event.target.files[0]; if (!file) return; + + uploadFile(file); + } + + async function uploadFile(file) { const statusDiv = document.getElementById('upload-status'); const statusMessage = document.getElementById('status-message'); @@ -267,8 +308,6 @@

Library

progressFill.style.width = '0%'; } - // Reset file input - event.target.value = ''; } diff --git a/templates/reader.html b/templates/reader.html index 5e980fc8..8f15d921 100644 --- a/templates/reader.html +++ b/templates/reader.html @@ -32,13 +32,14 @@ /* Navigation Footer */ .chapter-nav { display: flex; justify-content: space-between; margin-top: 60px; padding-top: 20px; border-top: 1px solid #eee; font-family: -apple-system, sans-serif; } + .chapter-nav.sticky { position: sticky; top: 0; z-index: 100; background: white; margin-top: 0; padding: 15px 0; border-top: none; border-bottom: 1px solid #eee; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .nav-btn { text-decoration: none; color: #3498db; font-weight: bold; padding: 10px 20px; border: 1px solid #3498db; border-radius: 4px; transition: all 0.2s; } .nav-btn:hover { background: #3498db; color: white; } .nav-btn.disabled { opacity: 0.5; pointer-events: none; border-color: #ccc; color: #ccc; } /* Right Panel - AI Analysis */ #ai-panel { width: 400px; background: #fafbfc; border-left: 1px solid #e9ecef; overflow-y: auto; flex-shrink: 0; display: flex; flex-direction: column; } - #ai-panel.hidden { display: none; } + #ai-panel.hidden { display: none !important; } .panel-header { padding: 20px; border-bottom: 1px solid #e9ecef; background: white; } .panel-title { font-family: -apple-system, sans-serif; font-size: 1.1em; font-weight: bold; color: #333; margin: 0; } @@ -99,33 +100,31 @@ .saved-indicator { color: #28a745; font-size: 0.85em; margin-top: 10px; display: none; } .saved-indicator.show { display: block; } - /* Saved Highlight markers */ + /* Saved Highlight markers - Color coded by type */ .saved-highlight { - background: linear-gradient(180deg, transparent 60%, #ffeb3b 60%); cursor: pointer; - position: relative; transition: background 0.2s; padding: 2px 0; + border-radius: 2px; } - .saved-highlight:hover { - background: linear-gradient(180deg, transparent 60%, #fdd835 60%); - } - .saved-highlight::before { - content: attr(data-analysis-type); - position: absolute; - left: -20px; - top: 0; - font-size: 0.9em; + + /* Fact Check - Yellow */ + .saved-highlight[data-analysis-type="fact_check"] { + background: linear-gradient(180deg, transparent 60%, #ffeb3b 60%); } - .saved-highlight[data-analysis-type="fact_check"]::before { - content: "📋"; + .saved-highlight[data-analysis-type="fact_check"]:hover { + background: linear-gradient(180deg, transparent 60%, #fdd835 60%); } - .saved-highlight[data-analysis-type="discussion"]::before { - content: "💡"; + + /* Discussion - Blue */ + .saved-highlight[data-analysis-type="discussion"] { + background: linear-gradient(180deg, transparent 60%, #90caf9 60%); } - .saved-highlight[data-analysis-type="comment"]::before { - content: "💬"; + .saved-highlight[data-analysis-type="discussion"]:hover { + background: linear-gradient(180deg, transparent 60%, #64b5f6 60%); } + + /* Comment - Green */ .saved-highlight[data-analysis-type="comment"] { background: linear-gradient(180deg, transparent 60%, #a5d6a7 60%); } @@ -160,6 +159,19 @@ color: #333; font-family: -apple-system, sans-serif; } + + /* Modal for internal links (footnotes, comments) */ + .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 2000; align-items: center; justify-content: center; } + .modal-overlay.show { display: flex; } + .modal-content { background: white; max-width: 700px; max-height: 80vh; overflow-y: auto; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); position: relative; margin: 20px; } + .modal-header { padding: 20px; border-bottom: 1px solid #e9ecef; background: #f8f9fa; border-radius: 8px 8px 0 0; } + .modal-title { font-family: -apple-system, sans-serif; font-size: 1.1em; font-weight: bold; color: #333; margin: 0; } + .modal-close { position: absolute; top: 15px; right: 20px; cursor: pointer; color: #999; font-size: 1.5em; line-height: 1; } + .modal-close:hover { color: #333; } + .modal-body { padding: 30px; line-height: 1.8; font-size: 1.05em; color: #212529; } + .modal-body img { max-width: 100%; height: auto; } + .modal-body h1, .modal-body h2, .modal-body h3 { font-family: -apple-system, sans-serif; margin-top: 1.5em; color: #333; } + .modal-body p { margin-bottom: 1.5em; } @@ -192,10 +204,30 @@
+ +
+ {% if prev_idx is not none %} + ← 上一章 + {% else %} + ← 上一章 + {% endif %} + + + 第 {{ chapter_index + 1 }} / {{ book.spine|length }} 节 + + + {% if next_idx is not none %} + 下一章 → + {% else %} + 下一章 → + {% endif %} +
+
{{ current_chapter.content | safe }}
+
{% if prev_idx is not none %} ← 上一章 @@ -231,7 +263,7 @@

AI 分析

-
+
等待分析...
@@ -274,20 +306,33 @@

AI 分析

- 📋 事实核查 + 事实核查
- 💡 深入讨论 + 深入讨论
- 💬 添加笔记 + 添加笔记
- + + + @@ -376,6 +421,15 @@

AI 分析

span.className = 'saved-highlight'; span.setAttribute('data-highlight-id', item.highlight.id); span.setAttribute('data-analysis-type', item.analysisType); + + // Add tooltip based on type + const tooltips = { + 'fact_check': '📋 Fact Check - Click to view', + 'discussion': '💡 Discussion - Click to view', + 'comment': '💬 Your Comment - Click to view/edit' + }; + span.title = tooltips[item.analysisType] || 'Click to view'; + span.onclick = () => showSavedAnalysis(item.highlight); // Extract contents and wrap them @@ -881,13 +935,82 @@

AI 分析

} } - // ESC to close panel + // Keyboard shortcuts document.addEventListener('keydown', function(e) { + // Don't trigger if user is typing in a text field + if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') { + return; + } + if (e.key === 'Escape') { closePanel(); hideContextMenu(); + closeModal(); + } else if (e.key === 'ArrowLeft') { + // Previous chapter - find the first prev button that's not disabled + const prevBtn = document.querySelector('.chapter-nav a.nav-btn:first-child:not(.disabled)'); + if (prevBtn) { + e.preventDefault(); + window.location.href = prevBtn.href; + } + } else if (e.key === 'ArrowRight') { + // Next chapter - find the last next button that's not disabled + const nextBtn = document.querySelector('.chapter-nav a.nav-btn:last-child:not(.disabled)'); + if (nextBtn) { + e.preventDefault(); + window.location.href = nextBtn.href; + } + } + }); + + // Intercept internal links and show in modal + document.getElementById('book-content').addEventListener('click', function(e) { + const link = e.target.closest('a'); + if (link && link.href) { + const url = new URL(link.href); + // Check if it's an internal link to this book + if (url.pathname.includes('/read/')) { + e.preventDefault(); + showLinkModal(url.pathname); + } } }); + + async function showLinkModal(path) { + const modal = document.getElementById('link-modal'); + const modalBody = document.getElementById('modal-body'); + const modalTitle = document.getElementById('modal-title'); + + modal.classList.add('show'); + modalBody.innerHTML = '
Loading...
'; + modalTitle.textContent = 'Reference'; + + try { + const response = await fetch(path); + const html = await response.text(); + + // Parse the HTML to extract just the content + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const content = doc.querySelector('.book-content'); + + if (content) { + modalBody.innerHTML = content.innerHTML; + } else { + modalBody.innerHTML = '

Content not found

'; + } + } catch (error) { + modalBody.innerHTML = '

Error loading content

'; + console.error('Error loading modal content:', error); + } + } + + function closeModal(event) { + // Only close if clicking overlay or close button, not the content + if (!event || event.target.id === 'link-modal' || event.target.classList.contains('modal-close')) { + document.getElementById('link-modal').classList.remove('show'); + } + } From 2c969bb43b8b83ecd18e5d2d8bce22e273c22b44 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Fri, 21 Nov 2025 09:30:56 +0800 Subject: [PATCH 03/36] Remove .vscode from repository and add to .gitignore --- .gitignore | 4 ++++ .vscode/settings.json | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index a763aef4..06456d14 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ wheels/ # Virtual environments .venv +# IDE settings +.vscode/ +.idea/ + # Custom *_data/ *.epub diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7a73a41b..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} \ No newline at end of file From 3d0ff4d1e21d40659d14c359ceb0b0faa8696fa2 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Fri, 21 Nov 2025 09:44:51 +0800 Subject: [PATCH 04/36] Add reading settings and update README New Features: - Reading settings panel with gear icon in navigation - Font family selection (8 options including Chinese fonts) - Font size adjustment (14-24px slider) - Line height adjustment (1.4-2.2 slider) - Settings persist in localStorage across sessions Updated: - README with comprehensive feature list - Organized features by category - Added keyboard shortcuts documentation --- README.md | 64 +++++++++++++-------- templates/reader.html | 127 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 5bf1033e..1600a372 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,29 @@ A lightweight, self-hosted EPUB reader with integrated AI analysis capabilities. ## Features -- 📚 **EPUB Reading** - Clean three-column layout (TOC, Content, AI Panel) +### Reading Experience +- 📚 **Clean Layout** - Three-column design (TOC, Content, AI Panel) +- 📖 **Sticky Navigation** - Top navigation bar stays visible while scrolling +- ⌨️ **Keyboard Shortcuts** - Arrow keys for prev/next chapter, ESC to close panels +- 🔗 **Internal Links** - Footnotes and author comments open in modal popups +- 🎯 **Clickable Covers** - Click book covers to start reading instantly + +### AI & Annotations - 🤖 **AI Analysis** - Right-click on text for fact-checking or discussion (DeepSeek) -- � **Paersonal Comments** - Add your own notes without AI (no API cost) +- � ***Personal Comments** - Add your own notes without AI (no API cost) - 💾 **Manual Save** - Choose what to save to avoid clutter -- ✨ **Visual Highlights** - Saved analyses automatically highlighted with icons (📋 💡 💬) -- 📝 **Highlights View** - See all your notes and analyses for each book in one page +- ✨ **Color-Coded Highlights** - Yellow (fact check), Blue (discussion), Green (comments) +- 🏷️ **Smart Tooltips** - Hover over highlights to see type +- 🗑️ **Edit & Delete** - Manage all your highlights and comments - 🎨 **Markdown Support** - AI responses render with proper formatting -- 🗂️ **Organized Storage** - All books in `books/` directory, data in SQLite -- 🌐 **Web Upload** - Upload EPUB files directly from browser + +### Library & Organization +- 📝 **Highlights View** - See all your notes and analyses for each book +- 📤 **Export to Markdown** - Export highlights with AI context warnings +- 🌐 **Web Upload** - Upload EPUB files via click or drag & drop - 🖼️ **Cover Images** - Automatic cover extraction and display +- 🔍 **Search** - Find books by title or author +- 🗂️ **Organized Storage** - All books in `books/` directory, data in SQLite ## Quick Start @@ -30,12 +43,11 @@ Get your key from: https://platform.deepseek.com/api_keys ### 2. Add Books -**Option A: Upload via Web Interface (Easiest)** +**Option A: Upload via Web Interface (Recommended)** 1. Start server: `uv run server.py` 2. Open http://127.0.0.1:8123 -3. Click the "+" card -4. Select EPUB file -5. Wait for automatic processing +3. Click the "+" card OR drag & drop EPUB file +4. Wait for automatic processing **Option B: Command Line** ```bash @@ -68,17 +80,25 @@ uv run server.py - Click "Save" for important insights ### Highlights -- **Yellow highlights** (📋 💡) - AI analyses -- **Green highlights** (💬) - Your comments -- Click any highlight to view/edit -- Comments are editable and deletable - -### View All Highlights -- Click ⋮ menu on any book → "📝 View Highlights" +- **Yellow** - Fact checks +- **Blue** - Discussions +- **Green** - Your comments +- Hover to see type, click to view/edit +- All highlights are editable and deletable + +### View & Export Highlights +- Click ⋮ menu on any book → "View Highlights" - See all your notes and analyses in one page - Filter by type (Fact Check, Discussion, Comment) +- Export to markdown for AI processing +- Context length warnings for large exports - Jump directly to any chapter +### Keyboard Shortcuts +- **← →** - Navigate between chapters +- **ESC** - Close panels and modals +- Works anywhere except when typing in text fields + ## Project Structure ``` @@ -99,7 +119,7 @@ reader3/ ## Data Management ### View Your Highlights -- Click ⋮ menu on any book → "📝 View Highlights" +- Click ⋮ menu on any book → "View Highlights" - See all notes, comments, and analyses in one page - Filter by type and jump to chapters @@ -131,20 +151,16 @@ copy reader_data.db backups\reader_data_backup.db ### API Key Error 1. Check `.env` file exists and has correct key -2. Run `uv run test_env.py` to verify -3. Restart server +2. Restart server ### No Highlights Showing 1. Check browser console (F12) for errors 2. Verify data exists: `uv run check_database.py` -3. Refresh page +3. Hard refresh (Ctrl+Shift+R) ### Server Won't Start 1. Check if port 8123 is available 2. Verify `.env` configuration -3. Run `uv run debug_server.py` for details - - ## License diff --git a/templates/reader.html b/templates/reader.html index 8f15d921..2da33ba8 100644 --- a/templates/reader.html +++ b/templates/reader.html @@ -95,6 +95,22 @@ .toggle-panel-btn { position: fixed; right: 20px; bottom: 20px; background: #3498db; color: white; border: none; border-radius: 50%; width: 56px; height: 56px; font-size: 1.5em; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 999; transition: all 0.2s; } .toggle-panel-btn:hover { background: #2980b9; transform: scale(1.05); } .toggle-panel-btn.hidden { display: none; } + + /* Settings Button & Dropdown */ + .settings-container { position: relative; display: inline-block; } + .settings-btn { background: none; border: none; color: #666; font-size: 1.2em; cursor: pointer; padding: 8px; margin-left: 15px; transition: color 0.2s; } + .settings-btn:hover { color: #333; } + .settings-dropdown { position: absolute; right: 0; top: 100%; background: white; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 15px; min-width: 280px; display: none; z-index: 1000; margin-top: 5px; } + .settings-dropdown.show { display: block; } + .settings-section { margin-bottom: 15px; } + .settings-section:last-child { margin-bottom: 0; } + .settings-label { font-family: -apple-system, sans-serif; font-size: 0.85em; font-weight: 600; color: #333; margin-bottom: 8px; display: block; } + .settings-options { display: flex; gap: 8px; flex-wrap: wrap; } + .settings-option { padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 0.85em; transition: all 0.2s; font-family: -apple-system, sans-serif; } + .settings-option:hover { background: #f8f9fa; } + .settings-option.active { background: #3498db; color: white; border-color: #3498db; } + .settings-slider { width: 100%; } + .settings-value { font-size: 0.85em; color: #666; margin-top: 5px; text-align: center; } /* Saved indicator */ .saved-indicator { color: #28a745; font-size: 0.85em; margin-top: 10px; display: none; } @@ -221,6 +237,37 @@ {% else %} 下一章 → {% endif %} + +
+ +
+
+ +
+ + + + + + + + +
+
+ +
+ + +
18px
+
+ +
+ + +
1.8
+
+
+
@@ -1011,6 +1058,86 @@ document.getElementById('link-modal').classList.remove('show'); } } + + // Reading settings + function toggleSettings(event) { + event.stopPropagation(); + const dropdown = document.getElementById('settings-dropdown'); + dropdown.classList.toggle('show'); + } + + // Close settings when clicking outside + document.addEventListener('click', function(e) { + if (!e.target.closest('.settings-container')) { + document.getElementById('settings-dropdown').classList.remove('show'); + } + }); + + function setFont(fontFamily) { + document.getElementById('book-content').style.fontFamily = fontFamily; + localStorage.setItem('reader-font', fontFamily); + + // Update active button + document.querySelectorAll('.settings-option[data-font]').forEach(btn => { + btn.classList.remove('active'); + }); + event.target.classList.add('active'); + } + + function setFontSize(size) { + document.getElementById('book-content').style.fontSize = size + 'px'; + document.getElementById('font-size-value').textContent = size + 'px'; + localStorage.setItem('reader-font-size', size); + } + + function setLineHeight(height) { + document.getElementById('book-content').style.lineHeight = height; + document.getElementById('line-height-value').textContent = height; + localStorage.setItem('reader-line-height', height); + } + + // Load saved settings on page load + window.addEventListener('DOMContentLoaded', function() { + const savedFont = localStorage.getItem('reader-font'); + const savedSize = localStorage.getItem('reader-font-size'); + const savedHeight = localStorage.getItem('reader-line-height'); + + if (savedFont) { + document.getElementById('book-content').style.fontFamily = savedFont; + // Update active button + const fontMap = { + 'Georgia, serif': 'georgia', + 'Times New Roman, serif': 'times', + '-apple-system, sans-serif': 'sans', + 'Arial, sans-serif': 'arial', + 'Verdana, sans-serif': 'verdana', + 'Microsoft YaHei, sans-serif': 'yahei', + 'SimSun, serif': 'simsun', + 'Consolas, monospace': 'mono' + }; + const fontType = fontMap[savedFont]; + if (fontType) { + document.querySelectorAll('.settings-option[data-font]').forEach(btn => { + btn.classList.remove('active'); + if (btn.getAttribute('data-font') === fontType) { + btn.classList.add('active'); + } + }); + } + } + + if (savedSize) { + document.getElementById('book-content').style.fontSize = savedSize + 'px'; + document.querySelector('.settings-slider[min="14"]').value = savedSize; + document.getElementById('font-size-value').textContent = savedSize + 'px'; + } + + if (savedHeight) { + document.getElementById('book-content').style.lineHeight = savedHeight; + document.querySelector('.settings-slider[min="1.4"]').value = savedHeight; + document.getElementById('line-height-value').textContent = savedHeight; + } + }); From b9ef4433c75e7a06537200f62990a1436f1d10b9 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Fri, 21 Nov 2025 13:37:44 +0800 Subject: [PATCH 05/36] Major improvements: fix cover images, remove _data suffix, fix highlights display, update branding - Fix image route ordering to properly serve cover images - Remove _data suffix from book folders for cleaner names - Fix highlight rendering for multi-paragraph selections - Apply highlight styles directly to block elements - Add distinct colors for fact_check/discussion/comment highlights - Update app title to 'My Reader with AI' - Add book emoji favicon - Restore .env.example file --- .env.example | 6 +- .gitignore | 1 - reader3.py | 2 +- server.py | 52 ++++++------- templates/highlights.html | 3 +- templates/library.html | 5 +- templates/reader.html | 157 +++++++++++++++++++++++++++++--------- 7 files changed, 157 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 21f56f3a..2b0fdfe4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -# DeepSeek API Configuration (recommended) +# DeepSeek API Configuration +# Get your API key from: https://platform.deepseek.com/api_keys + OPENAI_API_KEY=your_api_key_here OPENAI_BASE_URL=https://api.deepseek.com OPENAI_MODEL=deepseek-chat - -# Get your key from: https://platform.deepseek.com/api_keys diff --git a/.gitignore b/.gitignore index 06456d14..03392c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ wheels/ .idea/ # Custom -*_data/ *.epub # Books directory (but keep the folder structure) diff --git a/reader3.py b/reader3.py index c9eb7b1d..11d7ce7a 100644 --- a/reader3.py +++ b/reader3.py @@ -369,7 +369,7 @@ def sanitize_folder_name(name: str) -> str: # Use the actual book title for folder name (supports Chinese!) book_title = temp_metadata.title or os.path.splitext(os.path.basename(epub_file))[0] safe_title = sanitize_folder_name(book_title) - out_dir = os.path.join(books_dir, safe_title + "_data") + out_dir = os.path.join(books_dir, safe_title) # If folder exists, add a number suffix if os.path.exists(out_dir): diff --git a/server.py b/server.py index 468071ef..3c37e1f5 100644 --- a/server.py +++ b/server.py @@ -97,19 +97,19 @@ async def library_view(request: Request): # Create books directory if it doesn't exist os.makedirs(BOOKS_DIR, exist_ok=True) - # Scan directory for folders ending in '_data' that have a book.pkl + # Scan directory for folders that have a book.pkl for item in os.listdir(BOOKS_DIR): item_path = os.path.join(BOOKS_DIR, item) - # Check if it's a directory and ends with '_data' (including _data_1, _data_2, etc.) - if os.path.isdir(item_path) and "_data" in item: + # Check if it's a directory and has book.pkl + if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, "book.pkl")): # Try to load it to get the title book = load_book_cached(item) if book: # Extract folder suffix if it exists (e.g., "_1", "_2") folder_suffix = None - # Check if there's a number after _data - if "_data_" in item: - suffix_num = item.split("_data_")[-1] + # Check if there's a number suffix + if item.endswith(tuple(f"_{i}" for i in range(1, 100))): + suffix_num = item.split("_")[-1] folder_suffix = f"Copy {suffix_num}" books.append({ @@ -127,6 +127,24 @@ async def redirect_to_first_chapter(book_id: str): """Helper to just go to chapter 0.""" return await read_chapter(book_id=book_id, chapter_index=0) +@app.get("/read/{book_id}/images/{image_name}") +async def serve_image(book_id: str, image_name: str): + """ + Serves images specifically for a book. + The HTML contains . + The browser resolves this to /read/{book_id}/images/pic.jpg. + """ + # Security check: ensure book_id is clean + safe_book_id = os.path.basename(book_id) + safe_image_name = os.path.basename(image_name) + + img_path = os.path.join(BOOKS_DIR, safe_book_id, "images", safe_image_name) + + if not os.path.exists(img_path): + raise HTTPException(status_code=404, detail="Image not found") + + return FileResponse(img_path) + @app.get("/read/{book_id}/{chapter_ref:path}", response_class=HTMLResponse) async def read_chapter(request: Request, book_id: str, chapter_ref: str): """The main reader interface. Accepts either chapter index (0, 1, 2) or filename (part0008.html).""" @@ -172,24 +190,6 @@ async def read_chapter(request: Request, book_id: str, chapter_ref: str): "next_idx": next_idx }) -@app.get("/read/{book_id}/images/{image_name}") -async def serve_image(book_id: str, image_name: str): - """ - Serves images specifically for a book. - The HTML contains . - The browser resolves this to /read/{book_id}/images/pic.jpg. - """ - # Security check: ensure book_id is clean - safe_book_id = os.path.basename(book_id) - safe_image_name = os.path.basename(image_name) - - img_path = os.path.join(BOOKS_DIR, safe_book_id, "images", safe_image_name) - - if not os.path.exists(img_path): - raise HTTPException(status_code=404, detail="Image not found") - - return FileResponse(img_path) - # AI-related endpoints @@ -419,5 +419,5 @@ async def upload_book(file: UploadFile = File(...)): if __name__ == "__main__": import uvicorn - print("Starting server at http://127.0.0.1:8123") - uvicorn.run(app, host="127.0.0.1", port=8123) + print("Starting server at http://0.0.0.0:8123 (accessible externally if firewall/NAT allow)") + uvicorn.run(app, host="0.0.0.0", port=8123) diff --git a/templates/highlights.html b/templates/highlights.html index c277a033..4e624143 100644 --- a/templates/highlights.html +++ b/templates/highlights.html @@ -3,7 +3,8 @@ - Highlights - {{ book_title }} + Highlights - {{ book_title }} - My Reader with AI + + +
← Back to Library @@ -160,6 +225,31 @@

{{ book_title }}

- - + @@ -318,6 +52,12 @@
+
@@ -365,6 +105,19 @@
+ +
+
+ +
+ Off +
+
+
+ On +
+
+
@@ -508,1073 +261,6 @@ - - + From d4faf582ff41f3035e7fc9db2466b99d86147c4c Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Fri, 8 May 2026 09:38:02 +0800 Subject: [PATCH 19/36] Some UI touch up Co-authored-by: Copilot --- server.py | 15 ++++++++++++++- static/css/reader.css | 42 ++++++++++++++++++++++++++++++++++++++++++ templates/reader.html | 4 ++-- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index b19e4899..d8cf1799 100644 --- a/server.py +++ b/server.py @@ -27,6 +27,16 @@ BASE_DIR = Path(__file__).resolve().parent + +def get_asset_version(*relative_paths: str) -> int: + """Return a stable cache-busting token based on asset modification times.""" + mtimes = [] + for relative_path in relative_paths: + asset_path = BASE_DIR / relative_path + if asset_path.exists(): + mtimes.append(asset_path.stat().st_mtime_ns) + return max(mtimes, default=0) + # Load .env file at startup def load_env(): """Load environment variables from .env file.""" @@ -242,6 +252,8 @@ async def read_chapter(request: Request, book_id: str, chapter_ref: str): if progress_data and progress_data['chapter_index'] == chapter_index: saved_scroll = progress_data['scroll_position'] + reader_asset_version = get_asset_version("static/css/reader.css", "static/js/reader.js") + return templates.TemplateResponse("reader.html", { "request": request, "book": book, @@ -251,7 +263,8 @@ async def read_chapter(request: Request, book_id: str, chapter_ref: str): "spine_map": spine_map, "prev_idx": prev_idx, "next_idx": next_idx, - "saved_scroll": saved_scroll + "saved_scroll": saved_scroll, + "asset_version": reader_asset_version, }) diff --git a/static/css/reader.css b/static/css/reader.css index 86187dee..4b055085 100644 --- a/static/css/reader.css +++ b/static/css/reader.css @@ -67,6 +67,27 @@ body.paper-mode #main { background: radial-gradient(circle at top, #f7f3e8 0%, #f1ebdd 55%, #ece4d4 100%); } + body.paper-mode #sidebar { + background: #fbf8f0; + border-right-color: #ddd1bb; + box-shadow: inset -1px 0 0 rgba(104, 81, 45, 0.06); + } + body.paper-mode .nav-header { + color: #5d4a2b; + border-bottom-color: #d9ccb4; + } + body.paper-mode .nav-home { + color: #8c5d2e; + } + body.paper-mode .toc-link { + color: #5f5546; + } + body.paper-mode .toc-link:hover { + color: #2f2416; + } + body.paper-mode .toc-link.active { + color: #9c4f2e; + } body.paper-mode .content-container { max-width: 780px; padding: 70px 64px; @@ -99,6 +120,27 @@ body.dark-mode.paper-mode #main { background: radial-gradient(circle at top, #23211f 0%, #1d1a18 55%, #181513 100%); } + body.dark-mode.paper-mode #sidebar { + background: #26211b; + border-right-color: #43392f; + box-shadow: inset -1px 0 0 rgba(255, 244, 220, 0.04); + } + body.dark-mode.paper-mode .nav-header { + color: #eadfc8; + border-bottom-color: #4a4034; + } + body.dark-mode.paper-mode .nav-home { + color: #d5ab74; + } + body.dark-mode.paper-mode .toc-link { + color: #c7bca7; + } + body.dark-mode.paper-mode .toc-link:hover { + color: #f3ead7; + } + body.dark-mode.paper-mode .toc-link.active { + color: #f0a370; + } body.dark-mode.paper-mode .content-container { background: #26211b; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); diff --git a/templates/reader.html b/templates/reader.html index bae41cbe..9d85b50e 100644 --- a/templates/reader.html +++ b/templates/reader.html @@ -21,7 +21,7 @@ }; - + @@ -261,6 +261,6 @@ - + From d6e54fc62adac7c1896061f096f22907ed630055 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Fri, 8 May 2026 15:49:29 +0800 Subject: [PATCH 20/36] Tidy up. View Highlights will bring reader to the exact location of a highlight. Co-authored-by: Copilot --- database.py | 133 ++++++++++++++++++++++---------------- server.py | 13 ++++ static/js/reader.js | 67 +++++++++++++++---- templates/highlights.html | 12 +++- templates/library.html | 7 +- templates/reader.html | 8 ++- 6 files changed, 170 insertions(+), 70 deletions(-) diff --git a/database.py b/database.py index 5f52c21f..d5f93301 100644 --- a/database.py +++ b/database.py @@ -2,10 +2,9 @@ Database models for storing highlights and AI interactions. """ import sqlite3 -import json from datetime import datetime from typing import List, Dict, Optional -from dataclasses import dataclass, asdict +from dataclasses import dataclass @dataclass @@ -33,16 +32,16 @@ class AIAnalysis: class Database: """Simple SQLite database for storing highlights and AI analyses.""" - + def __init__(self, db_path: str = "reader_data.db"): self.db_path = db_path self.init_db() - + def init_db(self): """Create tables if they don't exist.""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + cursor.execute(""" CREATE TABLE IF NOT EXISTS highlights ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -54,7 +53,7 @@ def init_db(self): created_at TEXT NOT NULL ) """) - + cursor.execute(""" CREATE TABLE IF NOT EXISTS ai_analyses ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -66,7 +65,7 @@ def init_db(self): FOREIGN KEY (highlight_id) REFERENCES highlights (id) ) """) - + cursor.execute(""" CREATE TABLE IF NOT EXISTS reading_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -76,17 +75,17 @@ def init_db(self): last_read_at TEXT NOT NULL ) """) - + conn.commit() conn.close() - + def save_highlight(self, highlight: Highlight) -> int: """Save a highlight and return its ID.""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + cursor.execute(""" - INSERT INTO highlights (book_id, chapter_index, selected_text, + INSERT INTO highlights (book_id, chapter_index, selected_text, context_before, context_after, created_at) VALUES (?, ?, ?, ?, ?, ?) """, ( @@ -97,20 +96,22 @@ def save_highlight(self, highlight: Highlight) -> int: highlight.context_after, highlight.created_at or datetime.now().isoformat() )) - + highlight_id = cursor.lastrowid conn.commit() conn.close() - + return highlight_id - + def save_analysis(self, analysis: AIAnalysis) -> int: """Save an AI analysis and return its ID.""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + cursor.execute(""" - INSERT INTO ai_analyses (highlight_id, analysis_type, prompt, response, created_at) + INSERT INTO ai_analyses ( + highlight_id, analysis_type, prompt, response, created_at + ) VALUES (?, ?, ?, ?, ?) """, ( analysis.highlight_id, @@ -119,133 +120,157 @@ def save_analysis(self, analysis: AIAnalysis) -> int: analysis.response, analysis.created_at or datetime.now().isoformat() )) - + analysis_id = cursor.lastrowid conn.commit() conn.close() - + return analysis_id - - def get_highlights_for_chapter(self, book_id: str, chapter_index: int) -> List[Dict]: + + def get_highlights_for_chapter( + self, book_id: str, chapter_index: int + ) -> List[Dict]: """Get all highlights for a specific chapter.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute(""" - SELECT * FROM highlights + SELECT * FROM highlights WHERE book_id = ? AND chapter_index = ? ORDER BY created_at DESC """, (book_id, chapter_index)) - + rows = cursor.fetchall() conn.close() - + return [dict(row) for row in rows] - + def get_all_highlights_for_book(self, book_id: str) -> List[Dict]: """Get all highlights for a book (all chapters).""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute(""" - SELECT * FROM highlights + SELECT * FROM highlights WHERE book_id = ? ORDER BY created_at DESC """, (book_id,)) - + rows = cursor.fetchall() conn.close() - + return [dict(row) for row in rows] - + def get_analyses_for_highlight(self, highlight_id: int) -> List[Dict]: """Get all AI analyses for a highlight.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute(""" - SELECT * FROM ai_analyses + SELECT * FROM ai_analyses WHERE highlight_id = ? ORDER BY created_at DESC """, (highlight_id,)) - + rows = cursor.fetchall() conn.close() - + return [dict(row) for row in rows] def update_analysis(self, analysis_id: int, response: str): """Update an existing analysis response (for editing comments).""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + cursor.execute(""" - UPDATE ai_analyses + UPDATE ai_analyses SET response = ? WHERE id = ? """, (response, analysis_id)) - + conn.commit() conn.close() - + def delete_analysis(self, analysis_id: int): """Delete an analysis and its highlight if no other analyses exist.""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + # Get the highlight_id before deleting - cursor.execute("SELECT highlight_id FROM ai_analyses WHERE id = ?", (analysis_id,)) + cursor.execute( + "SELECT highlight_id FROM ai_analyses WHERE id = ?", + (analysis_id,), + ) result = cursor.fetchone() - + if result: highlight_id = result[0] - + # Delete the analysis cursor.execute("DELETE FROM ai_analyses WHERE id = ?", (analysis_id,)) - + # Check if there are other analyses for this highlight - cursor.execute("SELECT COUNT(*) FROM ai_analyses WHERE highlight_id = ?", (highlight_id,)) + cursor.execute( + "SELECT COUNT(*) FROM ai_analyses WHERE highlight_id = ?", + (highlight_id,), + ) count = cursor.fetchone()[0] - + # If no other analyses, delete the highlight too if count == 0: cursor.execute("DELETE FROM highlights WHERE id = ?", (highlight_id,)) - + + conn.commit() + conn.close() + + def delete_highlight(self, highlight_id: int): + """Delete a highlight and any analyses attached to it.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute( + "DELETE FROM ai_analyses WHERE highlight_id = ?", + (highlight_id,), + ) + cursor.execute("DELETE FROM highlights WHERE id = ?", (highlight_id,)) + conn.commit() conn.close() - + def save_progress(self, book_id: str, chapter_index: int, scroll_position: int = 0): """Save or update reading progress for a book.""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + cursor.execute(""" - INSERT INTO reading_progress (book_id, chapter_index, scroll_position, last_read_at) + INSERT INTO reading_progress ( + book_id, chapter_index, scroll_position, last_read_at + ) VALUES (?, ?, ?, ?) ON CONFLICT(book_id) DO UPDATE SET chapter_index = excluded.chapter_index, scroll_position = excluded.scroll_position, last_read_at = excluded.last_read_at """, (book_id, chapter_index, scroll_position, datetime.now().isoformat())) - + conn.commit() conn.close() - + def get_progress(self, book_id: str) -> Optional[Dict]: """Get the last read position for a book.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute(""" SELECT chapter_index, scroll_position FROM reading_progress WHERE book_id = ? """, (book_id,)) - + result = cursor.fetchone() conn.close() - + return dict(result) if result else None diff --git a/server.py b/server.py index d8cf1799..d4c20b68 100644 --- a/server.py +++ b/server.py @@ -252,6 +252,8 @@ async def read_chapter(request: Request, book_id: str, chapter_ref: str): if progress_data and progress_data['chapter_index'] == chapter_index: saved_scroll = progress_data['scroll_position'] + target_highlight_id = request.query_params.get("highlight_id", "") + reader_asset_version = get_asset_version("static/css/reader.css", "static/js/reader.js") return templates.TemplateResponse("reader.html", { @@ -264,6 +266,7 @@ async def read_chapter(request: Request, book_id: str, chapter_ref: str): "prev_idx": prev_idx, "next_idx": next_idx, "saved_scroll": saved_scroll, + "target_highlight_id": target_highlight_id, "asset_version": reader_asset_version, }) @@ -296,6 +299,16 @@ async def create_highlight(req: HighlightRequest): return {"highlight_id": highlight_id, "status": "success"} +@app.delete("/api/highlight/{highlight_id}") +async def delete_highlight(highlight_id: int): + """Delete a highlight and any analyses attached to it.""" + try: + db.delete_highlight(highlight_id) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @app.post("/api/ai/analyze") async def analyze_text(req: AIRequest): """Perform AI analysis (fact-check or discussion) without saving.""" diff --git a/static/js/reader.js b/static/js/reader.js index f3c658ea..b61b05ae 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -26,15 +26,25 @@ const BOOK_ID = (readerDataEl && readerDataEl.dataset.bookId) || ''; const CHAPTER_INDEX = Number((readerDataEl && readerDataEl.dataset.chapterIndex) || 0); const SAVED_SCROLL = Number((readerDataEl && readerDataEl.dataset.savedScroll) || 0); + const TARGET_HIGHLIGHT_ID = (readerDataEl && readerDataEl.dataset.targetHighlightId) || ''; let selectedText = ""; let selectedContext = ""; let currentHighlightId = null; let currentAnalysisId = null; let currentAnalysisType = ""; + let currentRawAnalysisResponse = ""; let savedHighlights = []; // Store saved highlights for this chapter let serverProviderOverride = null; // Server-side override from /api/settings let serverDefaultProvider = 'ollama_cloud'; // Server's default provider + function normalizeSavedAnalysisContent(text) { + if (!text) { + return ''; + } + + return text.replace(/^(Using:\s*(?:🏠 Local|☁️ Cloud))(?=\S)/m, '$1\n\n'); + } + function getAISettings() { const mode = localStorage.getItem('ai-mode'); @@ -145,11 +155,24 @@ // Apply highlights to the content applyHighlights(); + + if (TARGET_HIGHLIGHT_ID) { + scrollToTargetHighlight(); + } } catch (error) { console.error('Error loading highlights:', error); } } + function scrollToTargetHighlight() { + const target = document.querySelector(`[data-highlight-id="${TARGET_HIGHLIGHT_ID}"]`); + if (!target) { + return; + } + + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + // Apply highlights using Range-based approach (industry standard) // This is how tools like Hypothesis, Medium, and Google Docs do it function applyHighlights() { @@ -346,6 +369,11 @@ // Show saved analysis when clicking on a highlight function showSavedAnalysis(highlight) { openPanel(); + currentHighlightId = highlight.id; + currentAnalysisId = highlight.analyses && highlight.analyses.length > 0 + ? highlight.analyses[0].id + : null; + selectedText = highlight.selected_text; // Update panel content document.getElementById('panel-selected-text').textContent = highlight.selected_text; @@ -368,11 +396,7 @@ document.getElementById('update-comment-btn').style.display = 'inline-block'; document.getElementById('delete-comment-btn').style.display = 'inline-block'; - // Store IDs for update/delete - currentHighlightId = highlight.id; - currentAnalysisId = analysis.id; currentAnalysisType = 'comment'; - selectedText = highlight.selected_text; } else { // Show AI analysis (fact_check or discussion) @@ -382,12 +406,15 @@ document.getElementById('panel-analysis-type').textContent = analysis.analysis_type === 'fact_check' ? '解释说明' : '深入讨论'; // Render markdown for AI responses - document.getElementById('panel-analysis-content').innerHTML = marked.parse(analysis.response); + document.getElementById('panel-analysis-content').innerHTML = marked.parse( + normalizeSavedAnalysisContent(analysis.response) + ); // Hide save button and show saved indicator document.getElementById('panel-actions').style.display = 'flex'; document.getElementById('comment-actions').style.display = 'none'; document.getElementById('save-btn').style.display = 'none'; + document.getElementById('delete-highlight-btn').style.display = 'inline-block'; document.getElementById('saved-indicator').classList.add('show'); } } else { @@ -398,6 +425,8 @@ document.getElementById('panel-actions').style.display = 'flex'; document.getElementById('comment-actions').style.display = 'none'; document.getElementById('save-btn').style.display = 'none'; + document.getElementById('delete-highlight-btn').style.display = 'inline-block'; + document.getElementById('saved-indicator').classList.add('show'); } } @@ -408,7 +437,7 @@ // Restore scroll position const savedScroll = SAVED_SCROLL; - if (savedScroll > 0) { + if (!TARGET_HIGHLIGHT_ID && savedScroll > 0) { // Try multiple times to ensure content is loaded const mainElement = document.getElementById('main'); let attempts = 0; @@ -541,6 +570,7 @@ // Reset IDs currentHighlightId = null; currentAnalysisId = null; + currentRawAnalysisResponse = ''; // Hide saved indicator document.getElementById('saved-indicator').classList.remove('show'); @@ -607,6 +637,7 @@ const aiData = await aiRes.json(); if (aiData.status === 'success') { + currentRawAnalysisResponse = aiData.response; // Render markdown const providerUsed = aiData.provider_used === 'ollama' ? '🏠 Local' : '☁️ Cloud'; const responseHTML = `
Using: ${providerUsed}
` + @@ -647,7 +678,7 @@ currentHighlightId = highlightData.highlight_id; // Step 2: Save analysis - const analysisContent = document.getElementById('panel-analysis-content').textContent; + const analysisContent = currentRawAnalysisResponse; const saveRes = await fetch('/api/ai/save', { method: 'POST', @@ -699,11 +730,17 @@ // Reset panel state document.getElementById('save-btn').style.display = 'inline-block'; + document.getElementById('delete-highlight-btn').style.display = 'none'; + document.getElementById('delete-highlight-btn').disabled = false; + document.getElementById('delete-highlight-btn').textContent = '删除高亮'; document.getElementById('saved-indicator').classList.remove('show'); document.getElementById('analysis-box').style.display = 'block'; document.getElementById('comment-input-area').style.display = 'none'; document.getElementById('panel-actions').style.display = 'flex'; document.getElementById('comment-actions').style.display = 'none'; + document.getElementById('delete-comment-btn').style.display = 'none'; + document.getElementById('delete-comment-btn').disabled = false; + document.getElementById('delete-comment-btn').textContent = '删除高亮'; } // Comment functions @@ -826,17 +863,25 @@ } } - async function deleteComment() { - if (!confirm('确定要删除这条笔记吗?')) { + async function deleteCurrentHighlight() { + if (!currentHighlightId) { + return; + } + + if (!confirm('确定要删除这条高亮吗?相关笔记和分析也会一起删除。')) { return; } - const deleteBtn = document.getElementById('delete-comment-btn'); + const deleteBtn = document.getElementById( + document.getElementById('comment-actions').style.display === 'flex' + ? 'delete-comment-btn' + : 'delete-highlight-btn' + ); deleteBtn.disabled = true; deleteBtn.textContent = '删除中...'; try { - const response = await fetch(`/api/ai/delete/${currentAnalysisId}`, { + const response = await fetch(`/api/highlight/${currentHighlightId}`, { method: 'DELETE' }); diff --git a/templates/highlights.html b/templates/highlights.html index 38a332e9..88972749 100644 --- a/templates/highlights.html +++ b/templates/highlights.html @@ -204,7 +204,7 @@

{{ book_title }}

{% endif %}
{% endfor %} @@ -255,6 +255,14 @@

{{ book_title }}

breaks: true, gfm: true }); + + function normalizeSavedAnalysisContent(text) { + if (!text) { + return ''; + } + + return text.replace(/^(Using:\s*(?:🏠 Local|☁️ Cloud))(?=\S)/m, '$1\n\n'); + } // Render markdown for AI responses (not comments) document.addEventListener('DOMContentLoaded', function() { @@ -262,7 +270,7 @@

{{ book_title }}

const type = element.getAttribute('data-type'); // Only render markdown for AI responses (fact_check, discussion) if (type === 'fact_check' || type === 'discussion') { - const text = element.textContent; + const text = normalizeSavedAnalysisContent(element.textContent); element.innerHTML = marked.parse(text); } }); diff --git a/templates/library.html b/templates/library.html index 54c9286c..a00da131 100644 --- a/templates/library.html +++ b/templates/library.html @@ -195,7 +195,7 @@

我的AI电子书阅读器

{% if book.progress is not none %}
-
+
{{ book.progress_percent }}% complete
{% endif %} @@ -247,6 +247,11 @@

我的AI电子书阅读器

document.getElementById('theme-icon').textContent = '☀️'; } + document.querySelectorAll('.progress-bar-fill[data-progress-percent]').forEach(function(bar) { + const percent = bar.dataset.progressPercent || '0'; + bar.style.width = `${percent}%`; + }); + loadAISettings(); }); diff --git a/templates/reader.html b/templates/reader.html index 9d85b50e..fbcfc5f9 100644 --- a/templates/reader.html +++ b/templates/reader.html @@ -56,6 +56,7 @@ data-book-id="{{ book_id }}" data-chapter-index="{{ chapter_index }}" data-saved-scroll="{{ saved_scroll }}" + data-target-highlight-id="{{ target_highlight_id }}" data-spine-map='{{ spine_map | tojson }}' style="display:none;">
@@ -207,6 +208,9 @@

AI 分析

+ @@ -219,8 +223,8 @@

AI 分析

- @@ -347,6 +359,32 @@

我的AI电子书阅读器

window.location.href = `/highlights/${bookId}`; } + async function toggleCompleted(bookId, event) { + event.stopPropagation(); + + const card = document.querySelector(`[data-book-id="${bookId}"]`); + const currentlyCompleted = card?.dataset.completed === 'true'; + const nextCompleted = !currentlyCompleted; + + document.getElementById(`menu-${bookId}`).classList.remove('show'); + + try { + const response = await fetch(`/api/books/${encodeURIComponent(bookId)}/completion?completed=${nextCompleted}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.detail || 'Failed to update completion'); + } + + window.location.reload(); + } catch (error) { + alert(`Error: ${error.message}`); + } + } + async function deleteBook(bookId, event) { event.stopPropagation(); From 37799e0fbaab3a576db3d8c3a01e106848293b45 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Sat, 9 May 2026 06:52:26 +0800 Subject: [PATCH 22/36] Slight visual tweaks. Co-authored-by: Copilot --- server.py | 5 +- templates/library.html | 122 ++++++++++++++++++++++++++++++----------- 2 files changed, 92 insertions(+), 35 deletions(-) diff --git a/server.py b/server.py index 44760487..42e2737f 100644 --- a/server.py +++ b/server.py @@ -3,6 +3,7 @@ import os import pickle import re +import sys from functools import lru_cache from typing import Optional from datetime import datetime @@ -558,9 +559,9 @@ async def upload_book(file: UploadFile = File(...)): with open(temp_file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) - # Process the EPUB file using reader3.py with uv + # Process the EPUB file using the current Python interpreter. result = subprocess.run( - ["uv", "run", str(BASE_DIR / "reader3.py"), temp_file_path], + [sys.executable, str(BASE_DIR / "reader3.py"), temp_file_path], check=False, capture_output=True, text=True, diff --git a/templates/library.html b/templates/library.html index 54d9a73c..9f411ff8 100644 --- a/templates/library.html +++ b/templates/library.html @@ -119,7 +119,7 @@ /* Search Bar */ .search-container { position: relative; margin: 20px 0 30px 0; } - .search-input { width: 100%; padding: 12px 45px 12px 15px; font-size: 1em; border: 2px solid #ddd; border-radius: 8px; transition: border-color 0.2s; } + .search-input { width: 100%; box-sizing: border-box; padding: 12px 45px 12px 15px; font-size: 1em; border: 2px solid #ddd; border-radius: 8px; transition: border-color 0.2s; } .search-input:focus { outline: none; border-color: #3498db; } .search-icon { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); font-size: 1.2em; color: #999; pointer-events: none; } .book-card.hidden { display: none; } @@ -127,20 +127,23 @@ /* AI settings */ .ai-settings { margin: 0 0 18px 0; padding: 14px; border: 1px solid #ddd; border-radius: 8px; background: #fff; } - .ai-settings-title { font-size: 0.92em; font-weight: 600; color: #333; margin-bottom: 10px; } .ai-settings-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } + .ai-settings-row + .ai-settings-row { margin-top: 10px; } .ai-settings label { font-size: 0.88em; color: #555; } - .ai-radio-group { display: flex; gap: 16px; align-items: center; } - .ai-radio-option { display: inline-flex; align-items: center; gap: 6px; font-size: 0.9em; } - .ai-radio-option input[type="radio"] { margin: 0; } - .ai-hint { font-size: 0.82em; color: #666; margin-top: 8px; } + .settings-label { min-width: 92px; } + .provider-toggle { display: flex; align-items: center; gap: 8px; } + .toggle-switch { position: relative; width: 50px; height: 24px; background: #ddd; border-radius: 12px; cursor: pointer; transition: background 0.3s; } + .toggle-switch.active { background: #3498db; } + .toggle-switch-slider { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: white; border-radius: 50%; transition: left 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } + .toggle-switch.active .toggle-switch-slider { left: 28px; } + .toggle-label { min-width: 54px; text-align: center; font-size: 0.78em; color: #666; } body.dark-mode .ai-settings { background: #2d2d2d; border-color: #404040; } - body.dark-mode .ai-settings-title { color: #e0e0e0; } body.dark-mode .ai-settings label { color: #c0c0c0; } - body.dark-mode .ai-radio-option { color: #e0e0e0; } - body.dark-mode .ai-hint { color: #9a9a9a; } body.dark-mode .book-word-count { background: #1f3347; color: #90caf9; } + body.dark-mode .toggle-switch { background: #404040; } + body.dark-mode .toggle-switch-slider { background: #2d2d2d; } + body.dark-mode .toggle-label { color: #999; } @@ -151,28 +154,32 @@

我的AI电子书阅读器

-
🔍
-
AI Mode
- -
- - + +
+ Local AI +
+
+
+ Cloud AI +
+
+
+ +
+ All books +
+
+
+ Unfinished
-
Model/endpoint details are handled internally from server configuration.
@@ -278,32 +285,74 @@

我的AI电子书阅读器

} const selectedMode = mode || 'local'; - const target = document.querySelector(`input[name="ai-mode"][value="${selectedMode}"]`); - if (target) { - target.checked = true; - } + updateAIProviderToggle(selectedMode); } - function saveAISettings() { - const selected = document.querySelector('input[name="ai-mode"]:checked'); - const mode = selected ? selected.value : 'local'; + function saveAISettings(mode) { const provider = mode === 'remote' ? 'ollama_cloud' : 'ollama'; localStorage.setItem('ai-mode', mode); localStorage.setItem('ai-provider', provider); } + + function updateAIProviderToggle(mode) { + const toggle = document.getElementById('ai-provider-toggle'); + if (!toggle) { + return; + } + + const isCloud = mode === 'remote'; + toggle.classList.toggle('active', isCloud); + toggle.setAttribute('aria-checked', String(isCloud)); + } + + function toggleAIProviderSetting() { + const toggle = document.getElementById('ai-provider-toggle'); + const nextMode = toggle && toggle.classList.contains('active') ? 'local' : 'remote'; + updateAIProviderToggle(nextMode); + saveAISettings(nextMode); + } + + function updateViewToggle(showUnfinishedOnly) { + const toggle = document.getElementById('view-toggle'); + if (!toggle) { + return; + } + + toggle.classList.toggle('active', showUnfinishedOnly); + toggle.setAttribute('aria-checked', String(showUnfinishedOnly)); + } + + function toggleViewFilter() { + const toggle = document.getElementById('view-toggle'); + const showUnfinishedOnly = !(toggle && toggle.classList.contains('active')); + updateViewToggle(showUnfinishedOnly); + filterBooks(); + } + + function handleToggleKey(event, toggleFn) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleFn(); + } + } function filterBooks() { const searchInput = document.getElementById('search-input'); + const viewToggle = document.getElementById('view-toggle'); const searchTerm = searchInput.value.toLowerCase(); const bookCards = document.querySelectorAll('.book-card:not(.add-book-card)'); + const filterUnfinishedOnly = viewToggle ? viewToggle.classList.contains('active') : false; let visibleCount = 0; bookCards.forEach(card => { const title = card.querySelector('.book-title').textContent.toLowerCase(); const meta = card.querySelector('.book-meta').textContent.toLowerCase(); - - if (title.includes(searchTerm) || meta.includes(searchTerm)) { + const isCompleted = card.dataset.completed === 'true'; + const matchesSearch = title.includes(searchTerm) || meta.includes(searchTerm); + const matchesCompletionFilter = !filterUnfinishedOnly || !isCompleted; + + if (matchesSearch && matchesCompletionFilter) { card.classList.remove('hidden'); visibleCount++; } else { @@ -313,14 +362,21 @@

我的AI电子书阅读器

// Show/hide "no results" message let noResultsMsg = document.getElementById('no-results-msg'); - if (visibleCount === 0 && searchTerm !== '') { + if (visibleCount === 0 && (searchTerm !== '' || filterUnfinishedOnly)) { if (!noResultsMsg) { noResultsMsg = document.createElement('div'); noResultsMsg.id = 'no-results-msg'; noResultsMsg.className = 'no-results'; - noResultsMsg.textContent = 'No books found matching "' + searchInput.value + '"'; document.querySelector('.book-grid').appendChild(noResultsMsg); } + + if (searchTerm !== '' && filterUnfinishedOnly) { + noResultsMsg.textContent = 'No unfinished books found matching "' + searchInput.value + '"'; + } else if (searchTerm !== '') { + noResultsMsg.textContent = 'No books found matching "' + searchInput.value + '"'; + } else { + noResultsMsg.textContent = 'No unfinished books found'; + } } else if (noResultsMsg) { noResultsMsg.remove(); } From 0f0ecdd118f6fdd55592ba85056f94828c22f515 Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Sat, 9 May 2026 07:02:45 +0800 Subject: [PATCH 23/36] Improve library browsing and mixed-language sorting --- pyproject.toml | 1 + server.py | 71 ++++++++++++++++++++++++++++++++++++-- templates/library.html | 78 +++++++++++++++++++++++++++++++----------- uv.lock | 11 ++++++ 4 files changed, 139 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6480fee4..d1d0e38f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,4 +12,5 @@ dependencies = [ "uvicorn>=0.38.0", "httpx>=0.27.0", "python-multipart>=0.0.6", + "pypinyin>=0.54.0", ] diff --git a/server.py b/server.py index 42e2737f..3cfb8ef7 100644 --- a/server.py +++ b/server.py @@ -4,6 +4,7 @@ import pickle import re import sys +from itertools import groupby from functools import lru_cache from typing import Optional from datetime import datetime @@ -23,6 +24,11 @@ from database import Database, Highlight, AIAnalysis from ai_service import AIService +try: + from pypinyin import lazy_pinyin +except ImportError: + lazy_pinyin = None + BookMetadata = reader3_models.BookMetadata ChapterContent = reader3_models.ChapterContent TOCEntry = reader3_models.TOCEntry @@ -31,6 +37,8 @@ LATIN_WORD_RE = re.compile(r"\b\w+\b", re.UNICODE) CJK_CHAR_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]") +LEADING_SYMBOL_RE = re.compile(r"^[^0-9A-Za-z\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+") +LEADING_ARTICLE_RE = re.compile(r"^(the|a|an)\s+", re.IGNORECASE) def get_asset_version(*relative_paths: str) -> int: @@ -61,6 +69,56 @@ def format_word_count(word_count: int) -> str: """Format a word-count estimate for compact display.""" return f"{word_count:,} words" + +def normalize_title_for_sort(title: str) -> str: + """Normalize titles for grouping and sort order.""" + cleaned_title = LEADING_SYMBOL_RE.sub("", (title or "").strip()) + cleaned_title = LEADING_ARTICLE_RE.sub("", cleaned_title) + return cleaned_title or (title or "").strip() or "#" + + +def transliterate_for_sort(text: str) -> str: + """Build a Latin sort key for mixed English and Chinese titles.""" + normalized_title = normalize_title_for_sort(text) + if lazy_pinyin: + transliterated = "".join(lazy_pinyin(normalized_title)) + if transliterated: + return transliterated.casefold() + return normalized_title.casefold() + + +def title_group_key(title: str) -> str: + """Return the visible group key for a title.""" + sort_key = transliterate_for_sort(title) + if sort_key: + first_char = sort_key[0].upper() + if first_char.isalpha(): + return first_char + return "#" + + +def build_grouped_books(books: list[dict]) -> list[dict]: + """Build alphabetical section groups for the library page.""" + sorted_books = sorted( + books, + key=lambda book: ( + book["title_group"] == "#", + book["title_group"], + book["title_sort_key"], + book["title"].casefold(), + ), + ) + + grouped_books = [] + for group_key, items in groupby(sorted_books, key=lambda book: book["title_group"]): + grouped_books.append({ + "key": group_key, + "label": group_key, + "books": list(items), + }) + + return grouped_books + # Load .env file at startup def load_env(): """Load environment variables from .env file.""" @@ -196,6 +254,8 @@ async def library_view(request: Request): progress_percent = 100 estimated_word_count = estimate_book_word_count(book) + title_sort_key = transliterate_for_sort(book.metadata.title) + title_group = title_group_key(book.metadata.title) books.append({ "id": item, @@ -208,9 +268,16 @@ async def library_view(request: Request): "cover": book.cover_image if hasattr(book, 'cover_image') else None, "progress": current_chapter, "progress_percent": progress_percent, - "is_completed": is_completed + "is_completed": is_completed, + "title_sort_key": title_sort_key, + "title_group": title_group, }) - return templates.TemplateResponse("library.html", {"request": request, "books": books}) + grouped_books = build_grouped_books(books) + books = [book for group in grouped_books for book in group["books"]] + return templates.TemplateResponse( + "library.html", + {"request": request, "books": books, "grouped_books": grouped_books}, + ) @app.get("/read/{book_id}", response_class=HTMLResponse) async def redirect_to_last_position(book_id: str): diff --git a/templates/library.html b/templates/library.html index 9f411ff8..33ce3793 100644 --- a/templates/library.html +++ b/templates/library.html @@ -124,9 +124,20 @@ .search-icon { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); font-size: 1.2em; color: #999; pointer-events: none; } .book-card.hidden { display: none; } .no-results { text-align: center; padding: 40px; color: #999; font-size: 1.1em; grid-column: 1 / -1; } + .library-index { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 18px 0; } + .library-index-link { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 34px; padding: 0 10px; border-radius: 999px; background: white; border: 1px solid #ddd; color: #555; text-decoration: none; font-size: 0.88em; font-weight: 600; cursor: pointer; } + .library-index-link:hover { background: #f8f9fa; border-color: #3498db; color: #3498db; } + .library-index-link.active { background: #3498db; border-color: #3498db; color: white; } + .library-index-link.hidden { display: none; } /* AI settings */ + .settings-panel { margin: 0 0 18px 0; } + .settings-panel summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; border: 1px solid #ddd; background: white; color: #555; font-size: 0.9em; font-weight: 600; } + .settings-panel summary::-webkit-details-marker { display: none; } + .settings-panel summary::before { content: '▸'; font-size: 0.85em; transition: transform 0.2s; } + .settings-panel[open] summary::before { transform: rotate(90deg); } .ai-settings { margin: 0 0 18px 0; padding: 14px; border: 1px solid #ddd; border-radius: 8px; background: #fff; } + .settings-panel .ai-settings { margin: 10px 0 0 0; } .ai-settings-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .ai-settings-row + .ai-settings-row { margin-top: 10px; } .ai-settings label { font-size: 0.88em; color: #555; } @@ -141,9 +152,13 @@ body.dark-mode .ai-settings { background: #2d2d2d; border-color: #404040; } body.dark-mode .ai-settings label { color: #c0c0c0; } body.dark-mode .book-word-count { background: #1f3347; color: #90caf9; } + body.dark-mode .settings-panel summary { background: #2d2d2d; border-color: #404040; color: #d0d0d0; } body.dark-mode .toggle-switch { background: #404040; } body.dark-mode .toggle-switch-slider { background: #2d2d2d; } body.dark-mode .toggle-label { color: #999; } + body.dark-mode .library-index-link { background: #2d2d2d; border-color: #404040; color: #d0d0d0; } + body.dark-mode .library-index-link:hover { background: #353535; border-color: #64b5f6; color: #90caf9; } + body.dark-mode .library-index-link.active { background: #64b5f6; border-color: #64b5f6; color: #1a1a1a; } @@ -159,28 +174,40 @@

我的AI电子书阅读器

🔍
-
-
- -
- Local AI -
-
+ {% if grouped_books %} +
+ + {% for section in grouped_books %} + + {% endfor %} +
+ {% endif %} + +
+ Settings +
+
+ +
+ Local AI +
+
+
+ Cloud AI
- Cloud AI
-
-
- -
- All books -
-
+
+ +
+ All books +
+
+
+ Unfinished
- Unfinished
-
+
@@ -192,7 +219,7 @@

我的AI电子书阅读器

{% for book in books %} -
+
{% if book.cover %} @@ -242,6 +269,8 @@

我的AI电子书阅读器

- - + + + diff --git a/templates/library.html b/templates/library.html index 8af66fbd..c1d1667c 100644 --- a/templates/library.html +++ b/templates/library.html @@ -167,23 +167,23 @@ - -
+

我的AI电子书阅读器

- + 🔍
{% if grouped_books %}
- + {% for section in grouped_books %} - + {% endfor %}
{% endif %} @@ -195,7 +195,7 @@

我的AI电子书阅读器

Local AI -
+
Cloud AI @@ -205,7 +205,7 @@

我的AI电子书阅读器

All books -
+
Unfinished @@ -258,11 +258,11 @@

我的AI电子书阅读器

浏览本书
- +
@@ -271,349 +271,15 @@

我的AI电子书阅读器

{% endfor %} -
+
+
Add New Book
Click to upload EPUB
- +
- - + + + diff --git a/templates/reader.html b/templates/reader.html index 2f28471a..56dbda5e 100644 --- a/templates/reader.html +++ b/templates/reader.html @@ -24,6 +24,7 @@ +
@@ -178,7 +180,7 @@
-
+