Files
readflow/src/core/note.rs
大麦 600f205c87 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 核心功能全部完成!
2026-03-10 14:29:56 +08:00

422 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 笔记模块
//!
//! 支持阅读笔记、时间统计、导出等功能
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);
}
}