Skip to content

Commit c78e294

Browse files
committed
feat(tasks): REQ-292 任务行原子层与 task_index 保存钩子
1 parent 562ab55 commit c78e294

7 files changed

Lines changed: 427 additions & 0 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,8 @@ CREATE TABLE IF NOT EXISTS contracts (
503503
// v0.20.2(REQ-268/270):会话精修草稿表(离线第二遍/LLM 校对派生落点——
504504
// 原料 session_segments 不可变,替换文本走草稿表 + 有效轴合成)
505505
crate::db_session_refine::init(conn)?;
506+
// v0.20.3(REQ-292):任务行增量索引表(真相=md 行;聚合查询缓存)
507+
crate::db_task_index::init(conn)?;
506508
Ok(())
507509
}
508510

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use rusqlite::{params, OptionalExtension};
99

1010
use crate::db::{unix_seconds, Db};
1111
use crate::error::Result;
12+
use crate::db_task_index::rebuild_note_tasks;
1213
use crate::kb_index::soft_rebuild_note;
1314
use crate::types::{NewNote, Note};
1415

@@ -35,6 +36,8 @@ impl Db {
3536
// v0.19.0(REQ-258)索引钩子:新建笔记即入派生索引(同事务软失败
3637
// 记录不阻断——失败可见于 kb_index_stats 角标)
3738
soft_rebuild_note(conn, id, &new.content);
39+
// v0.20.3(REQ-292):任务行索引同钩子重扫(真相仍在正文 md 行)
40+
rebuild_note_tasks(conn, id, &new.content);
3841
Ok(Note {
3942
id,
4043
title: new.title.clone(),
@@ -98,6 +101,9 @@ impl Db {
98101
)?;
99102
if affected > 0 && cur.as_deref() != Some(content) {
100103
soft_rebuild_note(conn, id, content);
104+
// v0.20.3(REQ-292):正文变化即重扫任务索引(勾选/编辑/裁决
105+
// 回写全收敛于此——行号漂移由重扫吸收)
106+
rebuild_note_tasks(conn, id, content);
101107
}
102108
Ok(affected > 0)
103109
})

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! 任务行增量索引表(v0.20.3 / REQ-292 行动底座)。
2+
//!
3+
//! @ai-context: 正文 md 任务行为唯一真相(不复制正文)——本表只是聚合查询缓存:
4+
//! 保存钩子(notes 写路径)对正文按行重扫重建(行号漂移由"重扫式
5+
//! 刷新"吸收,不做增量行号补丁);勾选回写仍走既有字符级路径
6+
//! (NoteMarkdown/前端 → update_note → 本表随保存刷新)。
7+
//! @ai-context: disposition/plan_date 为索引列元数据(行动中心裁决写;不写回
8+
//! 正文——正文只承载任务行本身)。行解析纯逻辑见 tasks_core.rs。
9+
//! @ai-context: 建表幂等(db_migrations init_schema 尾链挂);删除笔记由 FK
10+
//! CASCADE 清行。软失败记录(不阻断保存——派生索引职责)。
11+
12+
use rusqlite::{params, Connection};
13+
14+
use crate::db::Db;
15+
use crate::error::Result;
16+
use crate::tasks_core::{parse_task_line, TaskStatus};
17+
18+
/// 任务行索引表(notes 1:N;UNIQUE(note_id, line_no) 幂等重扫)。
19+
pub fn init(conn: &Connection) -> Result<()> {
20+
conn.execute_batch(
21+
"CREATE TABLE IF NOT EXISTS task_index (
22+
id INTEGER PRIMARY KEY AUTOINCREMENT,
23+
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
24+
line_no INTEGER NOT NULL,
25+
task_text TEXT NOT NULL,
26+
status TEXT NOT NULL DEFAULT 'todo',
27+
unrefined INTEGER NOT NULL DEFAULT 0,
28+
plan_date INTEGER,
29+
disposition TEXT,
30+
created_at INTEGER NOT NULL,
31+
updated_at INTEGER NOT NULL,
32+
UNIQUE (note_id, line_no)
33+
);
34+
CREATE INDEX IF NOT EXISTS idx_task_index_note ON task_index(note_id, status);
35+
",
36+
)?;
37+
Ok(())
38+
}
39+
40+
/// 任务行视图(队列展示:笔记上下文 JOIN)。
41+
#[derive(Debug, Clone, serde::Serialize)]
42+
#[serde(rename_all = "camelCase")]
43+
pub struct TaskIndexRow {
44+
pub id: i64,
45+
pub note_id: i64,
46+
pub line_no: i64,
47+
pub task_text: String,
48+
pub status: String,
49+
pub unrefined: bool,
50+
pub plan_date: Option<i64>,
51+
pub disposition: Option<String>,
52+
/// 所属笔记标题(队列行上下文)
53+
pub note_title: String,
54+
pub updated_at: i64,
55+
}
56+
57+
/// 按正文重扫重建某笔记任务索引(保存钩子;conn 已在写事务/闭包内)。
58+
///
59+
/// @ai-context: 全删全插单事务语义(调用方已在 with_conn 闭包内)——
60+
/// 失败打印日志不阻断保存(派生索引可下次刷新,真相仍在正文)。
61+
pub fn rebuild_note_tasks(conn: &Connection, note_id: i64, content: &str) {
62+
if let Err(e) = rebuild_inner(conn, note_id, content) {
63+
eprintln!("[task-index] 笔记 {} 任务索引重建失败(可下次保存自动恢复): {}", note_id, e);
64+
}
65+
}
66+
67+
fn rebuild_inner(conn: &Connection, note_id: i64, content: &str) -> Result<()> {
68+
let now = crate::db::unix_seconds();
69+
conn.execute("DELETE FROM task_index WHERE note_id = ?1", params![note_id])?;
70+
let mut stmt = conn.prepare(
71+
"INSERT INTO task_index (note_id, line_no, task_text, status, unrefined, created_at, updated_at)
72+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
73+
)?;
74+
for (line_no, line) in content.split('\n').enumerate() {
75+
if let Some(p) = parse_task_line(line) {
76+
let status = match p.status {
77+
TaskStatus::Todo => "todo",
78+
TaskStatus::Done => "done",
79+
};
80+
stmt.execute(params![
81+
note_id,
82+
line_no as i64,
83+
p.payload,
84+
status,
85+
p.unrefined as i64,
86+
now
87+
])?;
88+
}
89+
}
90+
Ok(())
91+
}
92+
93+
impl Db {
94+
/// 任务队列查询(行动中心数据源;scope_note_id=单笔记过滤,None=跨组全量)。
95+
pub fn list_task_queue(&self, scope_note_id: Option<i64>) -> Result<Vec<TaskIndexRow>> {
96+
self.with_conn(|conn| {
97+
let rows = match scope_note_id {
98+
Some(nid) => {
99+
let mut stmt = conn.prepare(QUEUE_SQL_SCOPE)?;
100+
let mapped = stmt.query_map(params![nid], row_to_task)?;
101+
mapped.collect::<rusqlite::Result<Vec<_>>>()?
102+
}
103+
None => {
104+
let mut stmt = conn.prepare(QUEUE_SQL)?;
105+
let mapped = stmt.query_map([], row_to_task)?;
106+
mapped.collect::<rusqlite::Result<Vec<_>>>()?
107+
}
108+
};
109+
Ok(rows)
110+
})
111+
}
112+
}
113+
114+
const QUEUE_SQL: &str = "SELECT t.id, t.note_id, t.line_no, t.task_text, t.status, t.unrefined,
115+
t.plan_date, t.disposition, n.title AS note_title, t.updated_at
116+
FROM task_index t JOIN notes n ON n.id = t.note_id
117+
ORDER BY t.status = 'done', t.updated_at DESC, t.id DESC";
118+
const QUEUE_SQL_SCOPE: &str = "SELECT t.id, t.note_id, t.line_no, t.task_text, t.status, t.unrefined,
119+
t.plan_date, t.disposition, n.title AS note_title, t.updated_at
120+
FROM task_index t JOIN notes n ON n.id = t.note_id
121+
WHERE t.note_id = ?1
122+
ORDER BY t.status = 'done', t.updated_at DESC, t.id DESC";
123+
124+
fn row_to_task(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskIndexRow> {
125+
Ok(TaskIndexRow {
126+
id: row.get(0)?,
127+
note_id: row.get(1)?,
128+
line_no: row.get(2)?,
129+
task_text: row.get(3)?,
130+
status: row.get(4)?,
131+
unrefined: row.get::<_, i64>(5)? != 0,
132+
plan_date: row.get(6)?,
133+
disposition: row.get(7)?,
134+
note_title: row.get(8)?,
135+
updated_at: row.get(9)?,
136+
})
137+
}
138+
139+
#[cfg(test)]
140+
#[path = "db_task_index_tests.rs"]
141+
mod tests;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//! task_index 表读写单测(v0.20.3 / REQ-292)。
2+
//!
3+
//! @ai-context: 内存库隔离;断言「保存钩子重扫」幂等语义:重复重建无重复行、
4+
//! 行号漂移由重扫吸收、正文原样(索引零回写——真相唯一)。
5+
6+
use super::*;
7+
use crate::db::Db;
8+
9+
fn create_note_with(db: &Db, content: &str) -> i64 {
10+
db.create_note(&crate::types::NewNote {
11+
title: "任务笔记".to_string(),
12+
content: content.to_string(),
13+
source: "manual".to_string(),
14+
session_id: None,
15+
rule_version: None,
16+
purify_stats: None,
17+
tags: None,
18+
properties: None,
19+
group_id: None,
20+
})
21+
.unwrap()
22+
.id
23+
}
24+
25+
#[test]
26+
fn save_hook_indexes_task_lines() {
27+
// Arrange:新建即建索引(create_note 钩子)
28+
let db = Db::open(":memory:").unwrap();
29+
let id = create_note_with(
30+
&db,
31+
"# 标题\n- [ ] 任务一\n普通段落\n- [x] 已办事项\n- ☑️ 待办 找资料\n",
32+
);
33+
// Act
34+
let rows = db.list_task_queue(Some(id)).unwrap();
35+
// Assert:三行入索引(任务一 todo/已办 done/☑️ unrefined);普通行不入
36+
assert_eq!(rows.len(), 3);
37+
assert!(rows.iter().any(|r| r.task_text == "任务一" && r.status == "todo" && !r.unrefined));
38+
assert!(rows.iter().any(|r| r.task_text == "已办事项" && r.status == "done"));
39+
assert!(rows.iter().any(|r| r.task_text == "找资料" && r.unrefined));
40+
assert_eq!(rows[0].note_title, "任务笔记");
41+
}
42+
43+
#[test]
44+
fn update_hook_rescans_and_absorbs_line_shift() {
45+
let db = Db::open(":memory:").unwrap();
46+
let id = create_note_with(&db, "- [ ] 原任务\n");
47+
// Act:前插两行(行号漂移)+ 勾选原任务 → 保存触发重扫
48+
db.update_note(id, "任务笔记", "新增段落\n\n- [x] 原任务\n").unwrap();
49+
// Assert:重扫吸收漂移(唯一行、内容为勾选后正文)
50+
let rows = db.list_task_queue(Some(id)).unwrap();
51+
assert_eq!(rows.len(), 1);
52+
assert_eq!(rows[0].task_text, "原任务");
53+
assert_eq!(rows[0].status, "done");
54+
// 正文原样(索引零回写——由字符级迁移路径保证,另行断言正文未变)
55+
let note = db.get_note(id).unwrap().unwrap();
56+
assert_eq!(note.content, "新增段落\n\n- [x] 原任务\n");
57+
}
58+
59+
#[test]
60+
fn rebuild_is_idempotent_and_delete_cleans() {
61+
let db = Db::open(":memory:").unwrap();
62+
let id = create_note_with(&db, "- [ ] 甲\n- [ ] 乙\n");
63+
// 更新同内容(触发重扫两次路径)——不重复
64+
db.update_note(id, "任务笔记", "- [ ] 甲\n- [ ] 乙\n").unwrap();
65+
let rows = db.list_task_queue(Some(id)).unwrap();
66+
assert_eq!(rows.len(), 2, "重扫式刷新幂等无重复");
67+
// 删除笔记 → FK 级联清行(无孤儿)
68+
db.delete_note(id).unwrap();
69+
assert!(db.list_task_queue(Some(id)).unwrap().is_empty());
70+
// 跨组全量查询(None)不含已删行
71+
assert!(db.list_task_queue(None).unwrap().is_empty());
72+
}
73+
74+
#[test]
75+
fn content_without_tasks_indexes_empty() {
76+
let db = Db::open(":memory:").unwrap();
77+
let id = create_note_with(&db, "纯文本笔记,无任务行。\n");
78+
let rows = db.list_task_queue(Some(id)).unwrap();
79+
assert!(rows.is_empty());
80+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ mod ai_chat_stream;
7373
mod ai_chat_client;
7474
mod commands_ai_chat;
7575
mod asr;
76+
mod tasks_core;
77+
mod db_task_index;
7678
mod asr_clean;
7779
mod asr_confusion;
7880
mod asr_dedupe;

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//! 任务行回写原子层(v0.20.3 / REQ-292 行动底座)。
2+
//!
3+
//! @ai-context: markdown 任务行为唯一真相(v1 不引入新符号)——解析/迁移/行替换
4+
//! 全部收敛到本模块纯函数(前端勾选、行动中心裁决、未来通知多入口
5+
//! 共用);字符级迁移(NoteMarkdown 先例:勾选=只改 [ ]→[x],正文
6+
//! 其余字节不动——可逆、可 diff、不破坏用户排版)。
7+
//! @ai-context: 识别形态:GFM `- [ ]`/`- [x]`(todo/done)+ 产物遗留行
8+
//! `- ☑️ 待办`(unrefined——行动中心「待提炼」区消费,裁决后
9+
//! 提炼为标准任务行)。行号以换行切分为准(0 基)。
10+
//! @ai-context: 纯逻辑无 IO——解析/迁移/替换全可单测(tasks_core_tests.rs)。
11+
12+
/// 任务状态(任务行标记侧)。
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub enum TaskStatus {
15+
Todo,
16+
Done,
17+
}
18+
19+
/// 解析出的任务行信息。
20+
#[derive(Debug, Clone, PartialEq, Eq)]
21+
pub struct ParsedTaskLine {
22+
pub status: TaskStatus,
23+
/// 是否产物遗留行(`- ☑️ 待办` 型——待提炼为任务行)
24+
pub unrefined: bool,
25+
/// 载荷文本(去前缀/勾选框后的正文;trim)
26+
pub payload: String,
27+
}
28+
29+
/// 行首列表符号之一(-/*/+ 或有序 `1.`);返回 (前缀跳过量, 符号后内容)。
30+
fn split_marker(line: &str) -> Option<(usize, &str)> {
31+
let trimmed = line.trim_start();
32+
let lead = line.len() - trimmed.len();
33+
for p in ["- ", "* ", "+ "] {
34+
if let Some(rest) = trimmed.strip_prefix(p) {
35+
return Some((lead + p.len(), rest));
36+
}
37+
}
38+
// 有序列表:数字 + . / ) + 空格
39+
let bytes = trimmed.as_bytes();
40+
let mut i = 0usize;
41+
while i < bytes.len() && bytes[i].is_ascii_digit() {
42+
i += 1;
43+
}
44+
if i > 0 && i < trimmed.len() && matches!(bytes[i], b'.' | b')') && i + 1 < trimmed.len() && bytes[i + 1] == b' ' {
45+
return Some((lead + i + 2, &trimmed[i + 2..]));
46+
}
47+
None
48+
}
49+
50+
/// 解析任务行(纯函数):非任务行 → None。
51+
///
52+
/// @ai-context: GFM 勾选框以 `[ ]`/`[x]`/`[X]`(±空格)识别;`☑️` 前缀标记为
53+
/// 产物遗留(unrefined);其余形态(任务符号在段落中间等)不猜。
54+
pub fn parse_task_line(line: &str) -> Option<ParsedTaskLine> {
55+
let (skip, rest) = split_marker(line)?;
56+
let _ = skip;
57+
if let Some(after_box) = rest.strip_prefix("[ ]").or_else(|| rest.strip_prefix("[]")) {
58+
return Some(ParsedTaskLine {
59+
status: TaskStatus::Todo,
60+
unrefined: false,
61+
payload: after_box.trim().to_string(),
62+
});
63+
}
64+
if let Some(after_box) = rest.strip_prefix("[x]").or_else(|| rest.strip_prefix("[X]")) {
65+
return Some(ParsedTaskLine {
66+
status: TaskStatus::Done,
67+
unrefined: false,
68+
payload: after_box.trim().to_string(),
69+
});
70+
}
71+
if let Some(after) = rest.strip_prefix("☑️") {
72+
// 产物遗留行形如 `- ☑️ 待办 xxx`:待办为标记词(剥离),载荷=后续内容
73+
let after = after.trim_start();
74+
let after = after.strip_prefix("待办").map(|a| a.trim_start()).unwrap_or(after);
75+
return Some(ParsedTaskLine {
76+
status: TaskStatus::Todo,
77+
unrefined: true,
78+
payload: after.trim().to_string(),
79+
});
80+
}
81+
None
82+
}
83+
84+
/// 迁移任务行状态(纯函数,字符级):把原行勾选框改写为 to 对应形态;
85+
/// 非任务行或目标即当前状态 → 原样返回。
86+
pub fn migrate_status(line: &str, to: TaskStatus) -> Option<String> {
87+
let parsed = parse_task_line(line)?;
88+
if parsed.unrefined {
89+
return None; // 产物遗留行无勾选框——提炼(replace_line)而非迁移
90+
}
91+
let marker = match (parsed.status, to) {
92+
(TaskStatus::Done, TaskStatus::Todo) => "[ ]",
93+
(TaskStatus::Todo, TaskStatus::Done) => "[x]",
94+
(TaskStatus::Todo, TaskStatus::Todo) | (TaskStatus::Done, TaskStatus::Done) => return Some(line.to_string()),
95+
};
96+
let mut chars: Vec<char> = line.chars().collect();
97+
// 定位 `[` 索引(首个 `[` 前的空白与前缀保持不动——只换方括号内容)
98+
let start = line.find('[')?;
99+
let _ = &mut chars;
100+
let mut out = line.to_string();
101+
out.replace_range(start..start + 3, marker);
102+
Some(out)
103+
}
104+
105+
/// 替换指定行(纯函数,0 基;body 以换行切分——行号越界 → None)。
106+
pub fn replace_line(body: &str, line_no: usize, new_text: &str) -> Option<String> {
107+
let mut parts: Vec<&str> = body.split('\n').collect();
108+
if line_no >= parts.len() {
109+
return None;
110+
}
111+
parts[line_no] = new_text;
112+
Some(parts.join("\n"))
113+
}
114+
115+
#[cfg(test)]
116+
#[path = "tasks_core_tests.rs"]
117+
mod tests;

0 commit comments

Comments
 (0)