|
| 1 | +//! 行动裁决命令族(v0.20.3 / REQ-293/294/298 数据面)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 裁决漏斗哲学(v0.20.md 决策 #2)——不留死尸的机制是裁决不是 |
| 4 | +//! 自动清理:✓完成=正文字符级 [x](tasks_core 原子层)+ 完成史; |
| 5 | +//! ✗放弃=同 [x] + 史留原因;📅改期=只落索引列(计划日是元数据, |
| 6 | +//! 不进正文——不污染可复跑模板);🎴转卡=G7 预留接口(UI 置灰)。 |
| 7 | +//! @ai-context: 提炼(产物 ☑️ 行 → 标准 `- [ ]`)走 replace_line 写回正文后 |
| 8 | +//! update_note(自动触发 task_index 重扫);状态写回全入口收敛 |
| 9 | +//! 于 tasks_core(勾选/裁决同源)。 |
| 10 | +//! @ai-context: 完成史事件(REQ-298)逐裁决落 completion_history——周回顾原料。 |
| 11 | +
|
| 12 | +use serde::Serialize; |
| 13 | +use tauri::State; |
| 14 | + |
| 15 | +use crate::commands::AppState; |
| 16 | +use crate::db::Db; |
| 17 | +use crate::db_completion::{CompletionEvent, EV_ABANDONED, EV_DONE}; |
| 18 | +use crate::db_task_index::TaskIndexRow; |
| 19 | +use crate::tasks_core::{migrate_status, replace_line, TaskStatus}; |
| 20 | + |
| 21 | +/// 队列分区标签。 |
| 22 | +pub const TAB_OVERDUE: &str = "overdue"; |
| 23 | +pub const TAB_PLANNED: &str = "planned"; |
| 24 | +pub const TAB_SOMEDAY: &str = "someday"; |
| 25 | +pub const TAB_UNREFINED: &str = "unrefined"; |
| 26 | + |
| 27 | +/// 任务行 + 裁决上下文(队列视图行)。 |
| 28 | +#[derive(Debug, Clone, Serialize)] |
| 29 | +#[serde(rename_all = "camelCase")] |
| 30 | +pub struct ActionQueueRow { |
| 31 | + pub id: i64, |
| 32 | + pub note_id: i64, |
| 33 | + pub line_no: i64, |
| 34 | + pub text: String, |
| 35 | + pub status: String, |
| 36 | + pub unrefined: bool, |
| 37 | + pub plan_date: Option<i64>, |
| 38 | + pub disposition: Option<String>, |
| 39 | + pub note_title: String, |
| 40 | +} |
| 41 | + |
| 42 | +fn today_start_secs() -> i64 { |
| 43 | + crate::db::unix_seconds() / 86_400 * 86_400 |
| 44 | +} |
| 45 | + |
| 46 | +/// 分区过滤(纯函数):overdue=计划日已过;planned=计划日≥今天;someday=未排期; |
| 47 | +/// unrefined=产物遗留行(仅 todo 语义行参与裁决分区)。 |
| 48 | +pub fn partition_rows<'a>(rows: &'a [TaskIndexRow], tab: &str) -> Vec<&'a TaskIndexRow> { |
| 49 | + let today = today_start_secs(); |
| 50 | + let todo = |r: &TaskIndexRow| r.status == "todo"; |
| 51 | + match tab { |
| 52 | + TAB_OVERDUE => rows.iter().filter(|r| todo(r) && !r.unrefined && r.plan_date.is_some_and(|d| d < today)).collect(), |
| 53 | + TAB_PLANNED => rows.iter().filter(|r| todo(r) && !r.unrefined && r.plan_date.is_some_and(|d| d >= today)).collect(), |
| 54 | + TAB_SOMEDAY => rows.iter().filter(|r| todo(r) && !r.unrefined && r.plan_date.is_none()).collect(), |
| 55 | + TAB_UNREFINED => rows.iter().filter(|r| r.unrefined && r.status == "todo").collect(), |
| 56 | + _ => Vec::new(), |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +fn to_action(row: &TaskIndexRow) -> ActionQueueRow { |
| 61 | + ActionQueueRow { |
| 62 | + id: row.id, |
| 63 | + note_id: row.note_id, |
| 64 | + line_no: row.line_no, |
| 65 | + text: row.task_text.clone(), |
| 66 | + status: row.status.clone(), |
| 67 | + unrefined: row.unrefined, |
| 68 | + plan_date: row.plan_date, |
| 69 | + disposition: row.disposition.clone(), |
| 70 | + note_title: row.note_title.clone(), |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +/// 行动队列(tab 分区;None scope=跨组全量,Some=单笔记)。 |
| 75 | +#[tauri::command] |
| 76 | +pub fn list_action_queue( |
| 77 | + state: State<'_, AppState>, |
| 78 | + tab: String, |
| 79 | + note_id: Option<i64>, |
| 80 | +) -> Result<Vec<ActionQueueRow>, String> { |
| 81 | + let rows = state.db.list_task_queue(note_id).map_err(|e| e.to_string())?; |
| 82 | + let filtered = partition_rows(&rows, &tab); |
| 83 | + Ok(filtered.into_iter().map(to_action).collect()) |
| 84 | +} |
| 85 | + |
| 86 | +/// 行动徽标数(逾期+到期 todo 计数——组侧栏 ✅ 徽标数据源)。 |
| 87 | +#[tauri::command] |
| 88 | +pub fn action_badge_count(state: State<'_, AppState>) -> Result<u64, String> { |
| 89 | + let rows = state.db.list_task_queue(None).map_err(|e| e.to_string())?; |
| 90 | + let today = today_start_secs(); |
| 91 | + let n = rows |
| 92 | + .iter() |
| 93 | + .filter(|r| r.status == "todo" && !r.unrefined && r.plan_date.is_some_and(|d| d < today)) |
| 94 | + .count() as u64; |
| 95 | + Ok(n) |
| 96 | +} |
| 97 | + |
| 98 | +/// 正文写回统一出口:取笔记 → 行变换 → update_note(触发索引重扫/广播)。 |
| 99 | +fn rewrite_body(db: &Db, row: &TaskIndexRow, transform: impl FnOnce(&str) -> Option<String>) -> Result<(), String> { |
| 100 | + let note = db |
| 101 | + .get_note(row.note_id) |
| 102 | + .map_err(|e| e.to_string())? |
| 103 | + .ok_or_else(|| "笔记不存在(可能已删除)".to_string())?; |
| 104 | + let new_content = transform(¬e.content).ok_or_else(|| "任务行已失效(正文变化请刷新)".to_string())?; |
| 105 | + db.update_note(row.note_id, ¬e.title, &new_content) |
| 106 | + .map_err(|e| e.to_string())?; |
| 107 | + Ok(()) |
| 108 | +} |
| 109 | + |
| 110 | +/// ✓ 完成(裁决):正文 [x] + done 入史。核心逻辑注入 Db 可单测。 |
| 111 | +pub fn complete_task_core(db: &Db, row_id: i64) -> Result<String, String> { |
| 112 | + let row = db.get_task_row(row_id).map_err(|e| e.to_string())?.ok_or_else(|| "任务不存在".to_string())?; |
| 113 | + if row.unrefined { |
| 114 | + return Err("产物遗留行请先「提炼为任务行」再裁决".to_string()); |
| 115 | + } |
| 116 | + let payload = row.task_text.clone(); |
| 117 | + rewrite_body(db, &row, |body| { |
| 118 | + let lines: Vec<&str> = body.split('\n').collect(); |
| 119 | + let line = *lines.get(row.line_no as usize)?; |
| 120 | + let migrated = migrate_status(line, TaskStatus::Done)?; |
| 121 | + replace_line(body, row.line_no as usize, &migrated) |
| 122 | + })?; |
| 123 | + db.add_completion_event(EV_DONE, "task_line", Some(row_id), Some(row.note_id), &payload, None, None) |
| 124 | + .map_err(|e| e.to_string())?; |
| 125 | + Ok(format!("✓ 已完成:{}", payload)) |
| 126 | +} |
| 127 | + |
| 128 | +/// ✗ 放弃(裁决留因):正文 [x] + abandoned 入史(原因随行)。 |
| 129 | +pub fn abandon_task_core(db: &Db, row_id: i64, reason: &str) -> Result<String, String> { |
| 130 | + let row = db.get_task_row(row_id).map_err(|e| e.to_string())?.ok_or_else(|| "任务不存在".to_string())?; |
| 131 | + if row.unrefined { |
| 132 | + return Err("产物遗留行请先「提炼为任务行」再裁决".to_string()); |
| 133 | + } |
| 134 | + let payload = row.task_text.clone(); |
| 135 | + rewrite_body(db, &row, |body| { |
| 136 | + let lines: Vec<&str> = body.split('\n').collect(); |
| 137 | + let line = *lines.get(row.line_no as usize)?; |
| 138 | + let migrated = migrate_status(line, TaskStatus::Done)?; |
| 139 | + replace_line(body, row.line_no as usize, &migrated) |
| 140 | + })?; |
| 141 | + let reason = reason.trim(); |
| 142 | + db.add_completion_event( |
| 143 | + EV_ABANDONED, |
| 144 | + "task_line", |
| 145 | + Some(row_id), |
| 146 | + Some(row.note_id), |
| 147 | + &payload, |
| 148 | + (!reason.is_empty()).then_some(reason), |
| 149 | + None, |
| 150 | + ) |
| 151 | + .map_err(|e| e.to_string())?; |
| 152 | + Ok(format!("已放弃(留因):{}", payload)) |
| 153 | +} |
| 154 | + |
| 155 | +/// 提炼(产物 ☑️ 行 → 标准 `- [ ] payload`,插回同位置)。 |
| 156 | +pub fn refine_unrefined_core(db: &Db, row_id: i64) -> Result<String, String> { |
| 157 | + let row = db.get_task_row(row_id).map_err(|e| e.to_string())?.ok_or_else(|| "任务不存在".to_string())?; |
| 158 | + if !row.unrefined { |
| 159 | + return Err("仅产物遗留行(☑️)可提炼".to_string()); |
| 160 | + } |
| 161 | + let payload = row.task_text.clone(); |
| 162 | + rewrite_body(db, &row, |body| { |
| 163 | + let lines: Vec<&str> = body.split('\n').collect(); |
| 164 | + let line = *lines.get(row.line_no as usize)?; |
| 165 | + let indent = &line[..line.find('-').unwrap_or(0)]; |
| 166 | + replace_line(body, row.line_no as usize, &format!("{}- [ ] {}", indent, payload)) |
| 167 | + })?; |
| 168 | + Ok(format!("已提炼为任务行:{}", payload)) |
| 169 | +} |
| 170 | + |
| 171 | +/// ✓ 完成(IPC)。 |
| 172 | +#[tauri::command] |
| 173 | +pub fn task_complete(state: State<'_, AppState>, row_id: i64) -> Result<String, String> { |
| 174 | + complete_task_core(&state.db, row_id) |
| 175 | +} |
| 176 | + |
| 177 | +/// ✗ 放弃(IPC;reason 可空)。 |
| 178 | +#[tauri::command] |
| 179 | +pub fn task_abandon(state: State<'_, AppState>, row_id: i64, reason: Option<String>) -> Result<String, String> { |
| 180 | + abandon_task_core(&state.db, row_id, reason.as_deref().unwrap_or("")) |
| 181 | +} |
| 182 | + |
| 183 | +/// 提炼(IPC)。 |
| 184 | +#[tauri::command] |
| 185 | +pub fn task_refine_unrefined(state: State<'_, AppState>, row_id: i64) -> Result<String, String> { |
| 186 | + refine_unrefined_core(&state.db, row_id) |
| 187 | +} |
| 188 | + |
| 189 | +/// 📅 改期(只落索引列;date=None 清除=归入「搁置」)。 |
| 190 | +#[tauri::command] |
| 191 | +pub fn task_set_plan_date( |
| 192 | + state: State<'_, AppState>, |
| 193 | + row_id: i64, |
| 194 | + plan_date: Option<i64>, |
| 195 | +) -> Result<(), String> { |
| 196 | + state |
| 197 | + .db |
| 198 | + .set_task_plan_date(row_id, plan_date) |
| 199 | + .map_err(|e| e.to_string())?; |
| 200 | + Ok(()) |
| 201 | +} |
| 202 | + |
| 203 | +/// 完成史列表(周回顾/成长轨迹原料)。 |
| 204 | +#[tauri::command] |
| 205 | +pub fn completion_history_list( |
| 206 | + state: State<'_, AppState>, |
| 207 | + event_type: Option<String>, |
| 208 | + limit: Option<usize>, |
| 209 | +) -> Result<Vec<CompletionEvent>, String> { |
| 210 | + state |
| 211 | + .db |
| 212 | + .list_completion_events(event_type.as_deref(), limit.unwrap_or(200)) |
| 213 | + .map_err(|e| e.to_string()) |
| 214 | +} |
| 215 | + |
| 216 | +/// 占位导出(todo.txt 行文本——REQ-295 保底三件套的「复制」面由前端剪贴板完成)。 |
| 217 | +pub fn export_todotxt_lines(rows: &[ActionQueueRow]) -> String { |
| 218 | + rows.iter() |
| 219 | + .map(|r| format!("[ ] {} (via:{})", r.text, r.note_title)) |
| 220 | + .collect::<Vec<_>>() |
| 221 | + .join("\n") |
| 222 | +} |
| 223 | + |
| 224 | +#[cfg(test)] |
| 225 | +#[path = "commands_tasks_tests.rs"] |
| 226 | +mod tests; |
0 commit comments