## 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 核心功能全部完成!
422 lines
13 KiB
Rust
422 lines
13 KiB
Rust
//! 笔记模块
|
||
//!
|
||
//! 支持阅读笔记、时间统计、导出等功能
|
||
|
||
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 ¬es {
|
||
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) = ¬e.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);
|
||
}
|
||
}
|