Skip to content

Commit 63cd7bf

Browse files
committed
feat(notes): v0.10.0 笔记能力建设
1 parent 5aabc39 commit 63cd7bf

21 files changed

Lines changed: 2602 additions & 144 deletions

‎app/package-lock.json‎

Lines changed: 1808 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/package.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
"@types/katex": "^0.16.8",
1717
"katex": "^0.18.4",
1818
"react": "^19.1.0",
19-
"react-dom": "^19.1.0"
19+
"react-dom": "^19.1.0",
20+
"react-markdown": "^10.1.0",
21+
"rehype-katex": "^7.0.1",
22+
"remark-gfm": "^4.0.1",
23+
"remark-math": "^6.0.0"
2024
},
2125
"devDependencies": {
2226
"@tauri-apps/cli": "^2",

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ pub fn ai_refine_apply(
263263
session_id: Some(session_id),
264264
rule_version: Some("rule".to_string()),
265265
purify_stats: None,
266+
tags: None,
267+
properties: None,
266268
};
267269
let note = state.db.create_note(&new).map_err(|e| e.to_string())?;
268270
// ② 精修版落库(新版本 ai-refine + 成本 meta)

‎app/src-tauri/src/commands_artifacts.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ pub async fn artifact_to_note(
9999
// v0.7.5(REQ-171):产物→笔记非净化管线产物——无规则版本(None 诚实)
100100
rule_version: None,
101101
purify_stats: None,
102+
tags: None,
103+
properties: None,
102104
})
103105
.map_err(|e| e.to_string())
104106
}

‎app/src-tauri/src/commands_session_batch_tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ fn batch_skips_already_converted_session() {
108108
session_id: Some(id),
109109
rule_version: None,
110110
purify_stats: None,
111+
tags: None,
112+
properties: None,
111113
})
112114
.expect("create note");
113115
// Act

‎app/src-tauri/src/commands_session_note.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ fn convert_to_note(
205205
// REQ-171:规则版本 + 净化统计落库(可追溯"用哪版规则生成")
206206
rule_version: Some(RULE_VERSION.to_string()),
207207
purify_stats: Some(serde_json::to_string(&result.stats).unwrap_or_default()),
208+
tags: None,
209+
properties: None,
208210
};
209211
db.create_note(&new).map_err(|e| e.to_string())
210212
}

‎app/src-tauri/src/concat.rs‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,12 @@ fn dedupe_ocr_points(ocr_blocks: &[OcrBlock]) -> Vec<String> {
118118
points
119119
}
120120

121-
/// 毫秒时间戳格式化为 MM:SS。
121+
/// 毫秒时间戳格式化为 `[[ts:ms]]` Markdown 回链标记(v0.10.0 M4)。
122122
pub(crate) fn format_timestamp(ms: u64) -> String {
123123
let total_seconds = ms / 1000;
124-
format!("{:02}:{:02}", total_seconds / 60, total_seconds % 60)
124+
let min = total_seconds / 60;
125+
let sec = total_seconds % 60;
126+
format!("[⏱ {:02}:{:02}]([[ts:{}]])", min, sec, ms)
125127
}
126128

127129
/// 组装 Markdown 全文。
@@ -193,8 +195,8 @@ mod tests {
193195
let blocks = vec![ocr(Some(3000), "牛顿第二定律", 0.9)];
194196
// Act
195197
let draft = build_note_draft("截图", &[], &blocks);
196-
// Assert
197-
assert_eq!(draft.ocr_points, vec!["[00:03] 牛顿第二定律".to_string()]);
198+
// Assert:画面要点带时间戳回链锚点(v0.10.0 M4 格式)
199+
assert_eq!(draft.ocr_points, vec!["[[⏱ 00:03]([[ts:3000]])] 牛顿第二定律".to_string()]);
198200
assert!(draft.markdown.contains("## 画面要点"));
199201
assert!(!draft.markdown.contains("## 讲述内容"));
200202
}
@@ -260,8 +262,8 @@ mod tests {
260262
#[test]
261263
fn format_timestamp_converts_ms_to_mmss() {
262264
// Act & Assert
263-
assert_eq!(format_timestamp(0), "00:00");
264-
assert_eq!(format_timestamp(63_000), "01:03");
265-
assert_eq!(format_timestamp(3_600_000), "60:00");
265+
assert_eq!(format_timestamp(0), "[⏱ 00:00]([[ts:0]])");
266+
assert_eq!(format_timestamp(63_000), "[⏱ 01:03]([[ts:63000]])");
267+
assert_eq!(format_timestamp(3_600_000), "[⏱ 60:00]([[ts:3600000]])");
266268
}
267269
}

‎app/src-tauri/src/db.rs‎

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,25 @@ impl Db {
171171
"purify_stats",
172172
"ALTER TABLE notes ADD COLUMN purify_stats TEXT",
173173
)?;
174+
// v0.10.0(REQ-004 增强):notes 表补 tags/properties/pin 列
175+
ensure_column(
176+
&conn,
177+
"notes",
178+
"tags",
179+
"ALTER TABLE notes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'",
180+
)?;
181+
ensure_column(
182+
&conn,
183+
"notes",
184+
"properties",
185+
"ALTER TABLE notes ADD COLUMN properties TEXT",
186+
)?;
187+
ensure_column(
188+
&conn,
189+
"notes",
190+
"pin",
191+
"ALTER TABLE notes ADD COLUMN pin INTEGER NOT NULL DEFAULT 0",
192+
)?;
174193
// 关联查询索引(列表 has_note 子查询 + 笔记页来源跳转共用)
175194
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_session ON notes(session_id)")?;
176195
// v0.7.7(REQ-183):结构图记录表(建表幂等——新库建表/旧库补表)
@@ -187,13 +206,14 @@ impl Db {
187206
/// 新建笔记,返回含 id 与时间戳的完整记录。
188207
pub fn create_note(&self, new: &NewNote) -> Result<Note> {
189208
let now = unix_seconds();
209+
let tags = new.tags.clone().unwrap_or_else(|| "[]".to_string());
190210
let conn = self.conn.lock().expect("db lock poisoned");
191211
conn.execute(
192-
"INSERT INTO notes (title, content, source, session_id, rule_version, purify_stats, created_at, updated_at)
193-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
212+
"INSERT INTO notes (title, content, source, session_id, rule_version, purify_stats, tags, properties, created_at, updated_at)
213+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
194214
params![
195215
new.title, new.content, new.source, new.session_id, new.rule_version,
196-
new.purify_stats, now
216+
new.purify_stats, tags, new.properties, now
197217
],
198218
)?;
199219
let id = conn.last_insert_rowid();
@@ -205,6 +225,9 @@ impl Db {
205225
session_id: new.session_id,
206226
rule_version: new.rule_version.clone(),
207227
purify_stats: new.purify_stats.clone(),
228+
tags,
229+
properties: new.properties.clone(),
230+
pin: 0,
208231
created_at: now,
209232
updated_at: now,
210233
})
@@ -214,7 +237,7 @@ impl Db {
214237
pub fn get_note(&self, id: i64) -> Result<Option<Note>> {
215238
let conn = self.conn.lock().expect("db lock poisoned");
216239
let mut stmt = conn.prepare(
217-
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at FROM notes WHERE id = ?1",
240+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes WHERE id = ?1",
218241
)?;
219242
let mut rows = stmt.query_map(params![id], row_to_note)?;
220243
match rows.next() {
@@ -228,7 +251,7 @@ impl Db {
228251
pub fn list_notes(&self) -> Result<Vec<Note>> {
229252
let conn = self.conn.lock().expect("db lock poisoned");
230253
let mut stmt = conn.prepare(
231-
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at FROM notes ORDER BY updated_at DESC",
254+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes ORDER BY updated_at DESC",
232255
)?;
233256
let rows = stmt.query_map([], row_to_note)?;
234257
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
@@ -260,7 +283,7 @@ impl Db {
260283
let escaped = escape_like(keyword);
261284
let pattern = format!("%{}%", escaped);
262285
let mut stmt = conn.prepare(
263-
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at FROM notes
286+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes
264287
WHERE title LIKE ?1 ESCAPE '\\' OR content LIKE ?1 ESCAPE '\\'
265288
ORDER BY updated_at DESC",
266289
)?;
@@ -275,7 +298,7 @@ impl Db {
275298
pub fn find_note_by_session(&self, session_id: i64) -> Result<Option<Note>> {
276299
let conn = self.conn.lock().expect("db lock poisoned");
277300
let mut stmt = conn.prepare(
278-
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at FROM notes
301+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes
279302
WHERE session_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1",
280303
)?;
281304
let mut rows = stmt.query_map(params![session_id], row_to_note)?;
@@ -285,6 +308,59 @@ impl Db {
285308
None => Ok(None),
286309
}
287310
}
311+
312+
/// 更新笔记标签(v0.10.0;幂等覆盖写入 JSON 数组)。
313+
pub fn update_note_tags(&self, id: i64, tags: &str) -> Result<bool> {
314+
let conn = self.conn.lock().expect("db lock poisoned");
315+
let affected = conn.execute(
316+
"UPDATE notes SET tags = ?1, updated_at = ?2 WHERE id = ?3",
317+
params![tags, unix_seconds(), id],
318+
)?;
319+
Ok(affected > 0)
320+
}
321+
322+
/// 更新笔记固定状态(v0.10.0;pin=0 取消,=1 固定)。
323+
pub fn update_note_pin(&self, id: i64, pin: i64) -> Result<bool> {
324+
let conn = self.conn.lock().expect("db lock poisoned");
325+
let affected = conn.execute(
326+
"UPDATE notes SET pin = ?1, updated_at = ?2 WHERE id = ?3",
327+
params![pin, unix_seconds(), id],
328+
)?;
329+
Ok(affected > 0)
330+
}
331+
332+
/// 列出笔记(支持排序模式;v0.10.0)。
333+
pub fn list_notes_sorted(&self, sort_mode: &crate::types::NoteSortMode) -> Result<Vec<Note>> {
334+
let conn = self.conn.lock().expect("db lock poisoned");
335+
let sql = match sort_mode {
336+
crate::types::NoteSortMode::UpdatedDesc => {
337+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes ORDER BY updated_at DESC"
338+
}
339+
crate::types::NoteSortMode::PinFirst => {
340+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes ORDER BY pin DESC, updated_at DESC"
341+
}
342+
crate::types::NoteSortMode::CreatedDesc => {
343+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes ORDER BY created_at DESC"
344+
}
345+
};
346+
let mut stmt = conn.prepare(sql)?;
347+
let rows = stmt.query_map([], row_to_note)?;
348+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
349+
}
350+
351+
/// 按标签过滤笔记(v0.10.0;JSON 数组包含式匹配)。
352+
pub fn search_notes_by_tag(&self, tag: &str) -> Result<Vec<Note>> {
353+
let conn = self.conn.lock().expect("db lock poisoned");
354+
let escaped = escape_like(tag);
355+
let pattern = format!("%\"{}%\"%", escaped);
356+
let mut stmt = conn.prepare(
357+
"SELECT id, title, content, source, session_id, rule_version, purify_stats, created_at, updated_at, tags, properties, pin FROM notes
358+
WHERE tags LIKE ?1 ESCAPE '\\'
359+
ORDER BY updated_at DESC",
360+
)?;
361+
let rows = stmt.query_map(params![pattern], row_to_note)?;
362+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
363+
}
288364
}
289365

290366
/// 把 rusqlite 行映射为 Note。
@@ -299,6 +375,9 @@ fn row_to_note(row: &rusqlite::Row<'_>) -> rusqlite::Result<Note> {
299375
purify_stats: row.get(6)?,
300376
created_at: row.get(7)?,
301377
updated_at: row.get(8)?,
378+
tags: row.get(9)?,
379+
properties: row.get(10)?,
380+
pin: row.get(11)?,
302381
})
303382
}
304383

@@ -345,7 +424,7 @@ mod tests {
345424
fn create_and_get_note_roundtrip() {
346425
// Arrange
347426
let db = mem_db();
348-
let new = NewNote { title: "物理".into(), content: "# 牛顿\nF=ma".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None };
427+
let new = NewNote { title: "物理".into(), content: "# 牛顿\nF=ma".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None };
349428
// Act
350429
let created = db.create_note(&new).expect("create");
351430
let fetched = db.get_note(created.id).expect("get").expect("exists");
@@ -359,8 +438,8 @@ mod tests {
359438
fn list_orders_by_updated_desc() {
360439
// Arrange
361440
let db = mem_db();
362-
db.create_note(&NewNote { title: "A".into(), content: "a".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
363-
db.create_note(&NewNote { title: "B".into(), content: "b".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
441+
db.create_note(&NewNote { title: "A".into(), content: "a".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
442+
db.create_note(&NewNote { title: "B".into(), content: "b".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
364443
// Act
365444
let notes = db.list_notes().expect("list");
366445
// Assert
@@ -371,7 +450,7 @@ mod tests {
371450
fn update_note_changes_content() {
372451
// Arrange
373452
let db = mem_db();
374-
let created = db.create_note(&NewNote { title: "旧".into(), content: "旧内容".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
453+
let created = db.create_note(&NewNote { title: "旧".into(), content: "旧内容".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
375454
// Act
376455
let ok = db.update_note(created.id, "新标题", "新内容").expect("update");
377456
let fetched = db.get_note(created.id).unwrap().unwrap();
@@ -385,7 +464,7 @@ mod tests {
385464
fn delete_note_removes_row() {
386465
// Arrange
387466
let db = mem_db();
388-
let created = db.create_note(&NewNote { title: "待删".into(), content: "x".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
467+
let created = db.create_note(&NewNote { title: "待删".into(), content: "x".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
389468
// Act
390469
let ok = db.delete_note(created.id).expect("delete");
391470
let fetched = db.get_note(created.id).expect("get");
@@ -398,8 +477,8 @@ mod tests {
398477
fn search_matches_title_and_content() {
399478
// Arrange
400479
let db = mem_db();
401-
db.create_note(&NewNote { title: "化学课".into(), content: "讲分子".into(), source: "classroom".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
402-
db.create_note(&NewNote { title: "随笔".into(), content: "含熵减概念".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
480+
db.create_note(&NewNote { title: "化学课".into(), content: "讲分子".into(), source: "classroom".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
481+
db.create_note(&NewNote { title: "随笔".into(), content: "含熵减概念".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
403482
// Act
404483
let by_title = db.search_notes("化学").expect("search");
405484
let by_content = db.search_notes("熵减").expect("search");
@@ -413,8 +492,8 @@ mod tests {
413492
fn search_escapes_wildcards() {
414493
// Arrange:用户输入含 % 应作为字面量
415494
let db = mem_db();
416-
db.create_note(&NewNote { title: "50%off".into(), content: "促销".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
417-
db.create_note(&NewNote { title: "normal".into(), content: "普通".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None }).unwrap();
495+
db.create_note(&NewNote { title: "50%off".into(), content: "促销".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
496+
db.create_note(&NewNote { title: "normal".into(), content: "普通".into(), source: "manual".into(), session_id: None, rule_version: None, purify_stats: None, tags: None, properties: None }).unwrap();
418497
// Act:搜索字面 "%"
419498
let result = db.search_notes("%off").expect("search");
420499
// Assert:只命中含字面 %off 的,不应命中所有
@@ -509,6 +588,8 @@ mod tests {
509588
session_id: Some(session.id),
510589
rule_version: Some("note-rules-0.7.5".into()),
511590
purify_stats: Some(r#"{"filler":2,"verbal":3}"#.into()),
591+
tags: None,
592+
properties: None,
512593
};
513594
// Act
514595
let created = db.create_note(&new).expect("create");
@@ -534,6 +615,8 @@ mod tests {
534615
session_id: Some(session.id),
535616
rule_version: None,
536617
purify_stats: None,
618+
tags: None,
619+
properties: None,
537620
}).expect("create note");
538621
// Act:删除会话
539622
let deleted = db.delete_session(session.id).expect("delete session");
@@ -561,6 +644,8 @@ mod tests {
561644
session_id: Some(session.id),
562645
rule_version: None,
563646
purify_stats: None,
647+
tags: None,
648+
properties: None,
564649
}).expect("create older");
565650
let newer = db.create_note(&NewNote {
566651
title: "第二版".into(),
@@ -569,6 +654,8 @@ mod tests {
569654
session_id: Some(session.id),
570655
rule_version: None,
571656
purify_stats: None,
657+
tags: None,
658+
properties: None,
572659
}).expect("create newer");
573660
db.create_note(&NewNote {
574661
title: "手动".into(),
@@ -577,6 +664,8 @@ mod tests {
577664
session_id: None,
578665
rule_version: None,
579666
purify_stats: None,
667+
tags: None,
668+
properties: None,
580669
}).expect("create manual");
581670
// Act
582671
let found = db.find_note_by_session(session.id).expect("find");

‎app/src-tauri/src/db_notes_versions_tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ fn create_note(db: &Db, content: &str) -> i64 {
2323
session_id: None,
2424
rule_version: None,
2525
purify_stats: None,
26+
tags: None,
27+
properties: None,
2628
})
2729
.expect("建笔记")
2830
.id

‎app/src-tauri/src/db_sessions_tests.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ fn list_sessions_marks_content_and_note() {
115115
session_id: Some(with_note.id),
116116
rule_version: None,
117117
purify_stats: None,
118+
tags: None,
119+
properties: None,
118120
})
119121
.unwrap();
120122
// Act
@@ -282,6 +284,8 @@ fn session_notes_tables_coexist() {
282284
session_id: None,
283285
rule_version: None,
284286
purify_stats: None,
287+
tags: None,
288+
properties: None,
285289
});
286290
// Assert
287291
assert!(note.is_ok());

0 commit comments

Comments
 (0)