|
| 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; |
0 commit comments