From 88bd5dfe7c6243bf3fdaa7e2fec2432625087acd Mon Sep 17 00:00:00 2001 From: Taylor Ren Date: Thu, 20 Nov 2025 19:17:27 +0800 Subject: [PATCH 01/14] 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 0000000..21f56f3 --- /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 9e1d25d..a763aef 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 0000000..7a73a41 --- /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 5d868d7..5bf1033 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 0000000..6cdd356 --- /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 0000000..4677b45 --- /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 0000000..da8ae5d --- /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 0000000..cb60f06 --- /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 0000000..25bc730 --- /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 31e6179..6480fee 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 d0b9d3f..c9eb7b1 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 9c870dc..08c1964 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 0000000..a787546 --- /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 e7d094d..9d80f56 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 c012edc..5e980fc 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 e2e2f80..e84c5ac 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/14] 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 da8ae5d..e69de29 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 08c1964..468071e 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 a787546..c277a03 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 9d80f56..7a8aaf2 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 5e980fc..8f15d92 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/14] 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 a763aef..06456d1 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 7a73a41..0000000 --- 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/14] 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 5bf1033..1600a37 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 8f15d92..2da33ba 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/14] 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 21f56f3..2b0fdfe 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 06456d1..03392c4 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 c9eb7b1..11d7ce7 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 468071e..3c37e1f 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 c277a03..4e62414 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 }}