feat: 完成 Phase 2-4 核心功能

## Phase 2 - 核心功能 (P0)
- Issue #5: EPUB/MOBI/AZW3 格式支持 
  - 修复 mobi 库 API 调用 (content_raw → content_as_string)
  - 修复 title()/author() 返回类型
  - 添加元数据提取功能

- Issue #6: Markdown 阅读模式 
  - 实现 parse_markdown_with_metadata
  - 支持 Front Matter (YAML) 解析
  - 使用 pulldown-cmark 解析引擎
  - 支持代码文件高亮

- Issue #7: 双语翻译功能 
  - 实现 TranslationService (阿里百炼/DeepL/Ollama)
  - 语言自动检测
  - 双语对照 HTML 渲染 (并排/段落交错模式)

- Issue #8: 笔记与书签系统 
  - BookmarkManager (高亮/下划线/波浪线/边注)
  - NoteManager (阅读笔记/想法/问题/总结)
  - 阅读统计 (时长/会话数/笔记数)
  - 导出 Markdown/CSV/Anki

## Phase 3 - 高级功能 (P1)
- Issue #9: 代码阅读器 
  - 支持 20+ 编程语言
  - syntect 语法高亮
  - 行号显示/代码折叠

- Issue #10: 全文双语对照 
  - 段落级翻译对照
  - 并排/交错两种模式
  - 响应式布局

- Issue #11: 阅读进度同步 
  - 本地进度追踪
  - 云端同步支持
  - 多设备冲突解决

- Issue #12: 插件系统 
  - 插件加载/卸载/启用/禁用
  - 插件依赖管理
  - 内置主题/快捷键插件

## Phase 4 - 性能与生态 (P1)
- Issue #13: 性能优化 
  - PerformanceProfiler 性能分析
  - CacheManager LRU 缓存
  - 性能监控与优化建议

## 技术栈更新
- 新增依赖:reqwest, uuid, chrono(serde)
- 核心模块:8 个 (document/translation/bookmark/note/code_reader/progress/plugin/performance)
- 代码量:~5000 行

---
🚀 ReadFlow MVP 核心功能全部完成!
This commit is contained in:
大麦
2026-03-10 14:29:56 +08:00
parent 00fa25aeeb
commit 600f205c87
16 changed files with 4169 additions and 221 deletions

421
src/core/note.rs Normal file
View File

@@ -0,0 +1,421 @@
//! 笔记模块
//!
//! 支持阅读笔记、时间统计、导出等功能
use anyhow::Result;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, Duration};
use std::collections::HashMap;
/// 笔记类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NoteType {
/// 阅读笔记
Reading,
/// 想法/灵感
Idea,
/// 问题
Question,
/// 总结
Summary,
}
/// 笔记记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
/// 唯一标识
pub id: String,
/// 关联文档路径
pub document_path: String,
/// 笔记类型
pub note_type: NoteType,
/// 笔记内容
pub content: String,
/// 关联的页码
pub page_number: Option<usize>,
/// 关联的书签 ID
pub bookmark_id: Option<String>,
/// 标签
pub tags: Vec<String>,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
impl Note {
pub fn new(
document_path: String,
note_type: NoteType,
content: String,
) -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
document_path,
note_type,
content,
page_number: None,
bookmark_id: None,
tags: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self.updated_at = Utc::now();
self
}
pub fn with_page(mut self, page_number: usize) -> Self {
self.page_number = Some(page_number);
self.updated_at = Utc::now();
self
}
pub fn with_bookmark(mut self, bookmark_id: String) -> Self {
self.bookmark_id = Some(bookmark_id);
self.updated_at = Utc::now();
self
}
}
/// 阅读会话记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadingSession {
/// 文档路径
pub document_path: String,
/// 开始时间
pub start_time: DateTime<Utc>,
/// 结束时间
pub end_time: Option<DateTime<Utc>>,
/// 阅读的页码范围
pub page_range: Option<(usize, usize)>,
}
impl ReadingSession {
pub fn start(document_path: String) -> Self {
Self {
document_path,
start_time: Utc::now(),
end_time: None,
page_range: None,
}
}
pub fn end(mut self, page_range: Option<(usize, usize)>) -> Self {
self.end_time = Some(Utc::now());
self.page_range = page_range;
self
}
/// 获取阅读时长(秒)
pub fn duration_seconds(&self) -> i64 {
let end = self.end_time.unwrap_or(Utc::now());
(end - self.start_time).num_seconds()
}
}
/// 阅读统计
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReadingStats {
/// 总阅读时长(秒)
pub total_seconds: i64,
/// 阅读会话数
pub session_count: usize,
/// 笔记数量
pub note_count: usize,
/// 书签数量
pub bookmark_count: usize,
/// 最后阅读时间
pub last_read_at: Option<DateTime<Utc>>,
}
/// 笔记管理器
pub struct NoteManager {
db: sled::Db,
}
impl NoteManager {
/// 创建笔记管理器
pub fn new(db_path: &str) -> Result<Self> {
let db = sled::open(db_path)?;
Ok(Self { db })
}
/// 添加笔记
pub fn add(&self, note: &Note) -> Result<()> {
let key = format!("note:{}", note.id);
let value = serde_json::to_vec(note)?;
self.db.insert(key, value)?;
Ok(())
}
/// 删除笔记
pub fn remove(&self, id: &str) -> Result<()> {
let key = format!("note:{}", id);
self.db.remove(key)?;
Ok(())
}
/// 更新笔记
pub fn update(&self, note: &Note) -> Result<()> {
self.add(note)
}
/// 获取文档的所有笔记
pub fn get_by_document(&self, document_path: &str) -> Result<Vec<Note>> {
let mut notes = Vec::new();
for item in self.db.scan_prefix("note:") {
let (_, value) = item?;
let note: Note = serde_json::from_slice(&value)?;
if note.document_path == document_path {
notes.push(note);
}
}
// 按创建时间排序
notes.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(notes)
}
/// 按标签搜索笔记
pub fn search_by_tag(&self, tag: &str) -> Result<Vec<Note>> {
let mut notes = Vec::new();
for item in self.db.scan_prefix("note:") {
let (_, value) = item?;
let note: Note = serde_json::from_slice(&value)?;
if note.tags.contains(&tag.to_string()) {
notes.push(note);
}
}
Ok(notes)
}
/// 记录阅读会话
pub fn record_session(&self, session: &ReadingSession) -> Result<()> {
let key = format!("session:{}", session.start_time.timestamp());
let value = serde_json::to_vec(session)?;
self.db.insert(key, value)?;
Ok(())
}
/// 获取文档的阅读统计
pub fn get_stats(&self, document_path: &str) -> Result<ReadingStats> {
let mut stats = ReadingStats::default();
// 统计阅读会话
let mut total_seconds = 0i64;
let mut session_count = 0usize;
let mut last_read_at: Option<DateTime<Utc>> = None;
for item in self.db.scan_prefix("session:") {
let (_, value) = item?;
let session: ReadingSession = serde_json::from_slice(&value)?;
if session.document_path == document_path {
total_seconds += session.duration_seconds();
session_count += 1;
let session_end = session.end_time.unwrap_or(session.start_time);
if last_read_at.is_none() || session_end > last_read_at.unwrap() {
last_read_at = Some(session_end);
}
}
}
// 统计笔记
let note_count = self.get_by_document(document_path)?.len();
// 统计书签(从 bookmark tree 获取)
let mut bookmark_count = 0usize;
for item in self.db.scan_prefix("bookmark:") {
let (_, value) = item?;
let bookmark: crate::core::bookmark::Bookmark = serde_json::from_slice(&value)?;
if bookmark.document_path == document_path {
bookmark_count += 1;
}
}
stats.total_seconds = total_seconds;
stats.session_count = session_count;
stats.note_count = note_count;
stats.bookmark_count = bookmark_count;
stats.last_read_at = last_read_at;
Ok(stats)
}
/// 导出笔记为 Markdown
pub fn export_markdown(&self, document_path: &str) -> Result<String> {
let notes = self.get_by_document(document_path)?;
let mut md = String::new();
md.push_str(&format!("# {} 笔记导出\n\n", document_path));
md.push_str(&format!("导出时间:{}\n\n", Utc::now().format("%Y-%m-%d %H:%M:%S")));
// 添加统计信息
let stats = self.get_stats(document_path)?;
let hours = stats.total_seconds / 3600;
let minutes = (stats.total_seconds % 3600) / 60;
md.push_str("## 📊 阅读统计\n\n");
md.push_str(&format!("- 阅读时长:{}小时{}分钟\n", hours, minutes));
md.push_str(&format!("- 阅读会话:{}\n", stats.session_count));
md.push_str(&format!("- 笔记数量:{}\n", stats.note_count));
md.push_str(&format!("- 书签数量:{}\n\n", stats.bookmark_count));
if let Some(last_read) = stats.last_read_at {
md.push_str(&format!("- 最后阅读:{}\n\n", last_read.format("%Y-%m-%d %H:%M:%S")));
}
md.push_str("---\n\n");
// 按类型分组
let mut notes_by_type: HashMap<String, Vec<&Note>> = HashMap::new();
for note in &notes {
let type_key = match note.note_type {
NoteType::Reading => "reading",
NoteType::Idea => "idea",
NoteType::Question => "question",
NoteType::Summary => "summary",
};
notes_by_type.entry(type_key.to_string())
.or_insert_with(Vec::new)
.push(note);
}
// 输出笔记
for (type_name, type_notes) in notes_by_type {
let emoji = match type_name.as_str() {
"reading" => "📖",
"idea" => "💡",
"question" => "",
"summary" => "📝",
_ => "📌",
};
md.push_str(&format!("## {} {}\n\n", emoji, type_name.to_uppercase()));
for note in type_notes {
if let Some(page) = note.page_number {
md.push_str(&format!("**页码**: {} \n", page));
}
if !note.tags.is_empty() {
md.push_str(&format!("**标签**: {} \n", note.tags.join(", ")));
}
md.push_str(&format!("\n{}\n\n", note.content));
md.push_str(&format!("_创建时间{}_\n\n", note.created_at.format("%Y-%m-%d %H:%M:%S")));
md.push_str("---\n\n");
}
}
Ok(md)
}
/// 导出笔记为 CSV
pub fn export_csv(&self, document_path: &str) -> Result<String> {
let notes = self.get_by_document(document_path)?;
let mut csv = String::new();
csv.push_str("id,type,page,content,tags,created_at,updated_at\n");
for note in notes {
let note_type_str = match note.note_type {
NoteType::Reading => "reading",
NoteType::Idea => "idea",
NoteType::Question => "question",
NoteType::Summary => "summary",
};
let page = note.page_number.map(|p| p.to_string()).unwrap_or_default();
let tags = note.tags.join(",");
let content_escaped = note.content.replace('"', "\"\"");
csv.push_str(&format!(
"{},{},{},\"{}\",\"{}\",{},{}\n",
note.id,
note_type_str,
page,
content_escaped,
tags,
note.created_at.to_rfc3339(),
note.updated_at.to_rfc3339()
));
}
Ok(csv)
}
/// 导出为 Anki 卡片格式
pub fn export_anki(&self, document_path: &str) -> Result<String> {
let notes = self.get_by_document(document_path)?;
let mut anki = String::new();
for note in notes {
// Anki 导入格式:正面;背面;标签
let front = match note.note_type {
NoteType::Question => note.content.clone(),
_ => format!("{}: {}",
match note.note_type {
NoteType::Reading => "阅读笔记",
NoteType::Idea => "想法",
NoteType::Question => "问题",
NoteType::Summary => "总结",
},
note.content
),
};
let mut back = String::new();
if let Some(page) = note.page_number {
back.push_str(&format!("页码:{}\n", page));
}
if let Some(bookmark_id) = &note.bookmark_id {
back.push_str(&format!("关联标注:{}\n", bookmark_id));
}
let tags = if note.tags.is_empty() {
"readflow".to_string()
} else {
format!("readflow {}", note.tags.join(" "))
};
anki.push_str(&format!("{};{};{}\n", front, back, tags));
}
Ok(anki)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_note_creation() {
let note = Note::new(
"/path/to/doc.md".to_string(),
NoteType::Reading,
"这是一条测试笔记".to_string(),
);
assert_eq!(note.document_path, "/path/to/doc.md");
assert!(matches!(note.note_type, NoteType::Reading));
}
#[test]
fn test_session_duration() {
let session = ReadingSession::start("/path/to/doc.md".to_string());
// 至少应该有 0 秒
assert!(session.duration_seconds() >= 0);
}
}