|
| 1 | +//! 后处理收尾命令面(v0.20.3 / REQ-294/295/299/300)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 本文件收敛 v0.20.3 尾部数据面——迁出通道(.todo.txt 文件导出 + |
| 4 | +//! 手动回填史,保底三件套之文件/回填;scheme/邮件=复制指引文案 |
| 5 | +//! 后置)+ 周回顾批决议(batch_weekly_resolve 单事务:正文 [x] 化 + |
| 6 | +//! 完成史逐条 + task_index 重建)+ 练习条目打点 + 问题清单 |
| 7 | +//! (Me 问题化 open/answered/archived)。UI 触点零新增哲学: |
| 8 | +//! 练习/问题入口在行动中心页签(前端)与笔记工具栏「转为问题」。 |
| 9 | +
|
| 10 | +use serde::Deserialize; |
| 11 | +use rusqlite::params; |
| 12 | +use tauri::State; |
| 13 | + |
| 14 | +use crate::commands::AppState; |
| 15 | +use crate::db::Db; |
| 16 | +use crate::db_completion::EV_EXPORT_MANUAL_DONE; |
| 17 | +use crate::db_practice::PracticeItem; |
| 18 | +use crate::db_questions::QuestionItem; |
| 19 | +use crate::tasks_core::{migrate_status, TaskStatus}; |
| 20 | + |
| 21 | +/// 迁出文件导出(保存对话框返回路径由用户授权;仅 .txt + 大小护栏)。 |
| 22 | +#[tauri::command] |
| 23 | +pub fn export_write_todotxt_file(path: String, content: String) -> Result<(), String> { |
| 24 | + let p = std::path::Path::new(&path); |
| 25 | + if p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() != Some("txt") { |
| 26 | + return Err("仅支持 .txt 文件(todo.txt 保底格式)".to_string()); |
| 27 | + } |
| 28 | + if content.len() > 1_000_000 { |
| 29 | + return Err("导出内容超限(>1MB)".to_string()); |
| 30 | + } |
| 31 | + std::fs::write(p, content).map_err(|e| format!("写入失败: {}", e)) |
| 32 | +} |
| 33 | + |
| 34 | +/// 手动回填(周回顾):迁出事件手动标"已完成"入史(出口 G/决策 4——不回填正文)。 |
| 35 | +#[tauri::command] |
| 36 | +pub fn export_manual_fill_done( |
| 37 | + state: State<'_, AppState>, |
| 38 | + text: String, |
| 39 | + note: Option<String>, |
| 40 | +) -> Result<(), String> { |
| 41 | + let text = text.trim(); |
| 42 | + if text.is_empty() || text.chars().count() > 1000 { |
| 43 | + return Err("回填文本为空或超长".to_string()); |
| 44 | + } |
| 45 | + state |
| 46 | + .db |
| 47 | + .add_completion_event( |
| 48 | + EV_EXPORT_MANUAL_DONE, |
| 49 | + "export", |
| 50 | + None, |
| 51 | + None, |
| 52 | + text, |
| 53 | + note.as_deref(), |
| 54 | + None, |
| 55 | + ) |
| 56 | + .map_err(|e| e.to_string())?; |
| 57 | + Ok(()) |
| 58 | +} |
| 59 | + |
| 60 | +/// 周回顾批决议项(每行:裁决动作 done=执行完成 / abandon=有意识放弃)。 |
| 61 | +#[derive(Debug, Clone, Deserialize)] |
| 62 | +#[serde(rename_all = "camelCase")] |
| 63 | +pub struct WeeklyDecision { |
| 64 | + pub row_id: i64, |
| 65 | + pub action: String, |
| 66 | + pub reason: Option<String>, |
| 67 | +} |
| 68 | + |
| 69 | +/// 批决议结果。 |
| 70 | +#[derive(Debug, Clone, serde::Serialize)] |
| 71 | +#[serde(rename_all = "camelCase")] |
| 72 | +pub struct WeeklyResolveView { |
| 73 | + pub done: usize, |
| 74 | + pub abandoned: usize, |
| 75 | + pub failed: Vec<String>, |
| 76 | +} |
| 77 | + |
| 78 | +/// 周回顾批决议(单事务:正文 [x] 化 + 完成史逐条 + task_index 重建原子提交; |
| 79 | +/// 任一步失败整体回滚——不留半态裁决)。 |
| 80 | +#[tauri::command] |
| 81 | +pub fn batch_weekly_resolve( |
| 82 | + state: State<'_, AppState>, |
| 83 | + decisions: Vec<WeeklyDecision>, |
| 84 | +) -> Result<WeeklyResolveView, String> { |
| 85 | + if decisions.len() > 200 { |
| 86 | + return Err("单次批决议上限 200 条".to_string()); |
| 87 | + } |
| 88 | + weekly_resolve_core(&state.db, &decisions).map_err(|e| e.to_string()) |
| 89 | +} |
| 90 | + |
| 91 | +pub(crate) fn weekly_resolve_core( |
| 92 | + db: &Db, |
| 93 | + decisions: &[WeeklyDecision], |
| 94 | +) -> Result<WeeklyResolveView, String> { |
| 95 | + // 预读:行 → (note_id, line_no, text, 是否 abandon) + 事件载荷 |
| 96 | + let mut by_note: std::collections::HashMap<i64, Vec<(i64, String)>> = Default::default(); |
| 97 | + let mut view = WeeklyResolveView { done: 0, abandoned: 0, failed: Vec::new() }; |
| 98 | + let mut events: Vec<(String, Option<String>, i64)> = Vec::new(); // (text, reason, note_id) |
| 99 | + let mut items: Vec<(i64, i64, String)> = Vec::new(); // (note_id, line_no, action) |
| 100 | + for d in decisions { |
| 101 | + let row = match db.get_task_row(d.row_id) { |
| 102 | + Ok(Some(r)) => r, |
| 103 | + Ok(None) => { |
| 104 | + view.failed.push(format!("行 {} 不存在(已失效)", d.row_id)); |
| 105 | + continue; |
| 106 | + } |
| 107 | + Err(e) => { |
| 108 | + view.failed.push(format!("行 {} 读取失败: {}", d.row_id, e)); |
| 109 | + continue; |
| 110 | + } |
| 111 | + }; |
| 112 | + let action = d.action.clone(); |
| 113 | + if !matches!(action.as_str(), "done" | "abandon") { |
| 114 | + view.failed.push(format!("行 {} 非法动作 {}", row.id, action)); |
| 115 | + continue; |
| 116 | + } |
| 117 | + items.push((row.note_id, row.line_no, action.clone())); |
| 118 | + by_note.entry(row.note_id).or_default().push((row.line_no, row.task_text.clone())); |
| 119 | + if action == "abandon" { |
| 120 | + events.push((row.task_text.clone(), d.reason.clone(), row.note_id)); |
| 121 | + } else { |
| 122 | + events.push((row.task_text.clone(), None, row.note_id)); |
| 123 | + } |
| 124 | + if action == "abandon" { |
| 125 | + view.abandoned += 1; |
| 126 | + } else { |
| 127 | + view.done += 1; |
| 128 | + } |
| 129 | + } |
| 130 | + if items.is_empty() { |
| 131 | + return Ok(view); |
| 132 | + } |
| 133 | + // 每笔记单次读正文 + 行变换(字符级迁移)——事务外只读 |
| 134 | + let mut contents: std::collections::HashMap<i64, (String, String)> = Default::default(); // note_id -> (title, content) |
| 135 | + for (note_id, lines) in &by_note { |
| 136 | + let note = db |
| 137 | + .get_note(*note_id) |
| 138 | + .map_err(|e| e.to_string())? |
| 139 | + .ok_or_else(|| format!("笔记 {} 不存在", note_id))?; |
| 140 | + let mut body_lines: Vec<String> = note.content.split('\n').map(|s| s.to_string()).collect(); |
| 141 | + for (line_no, _) in lines { |
| 142 | + if let Some(line) = body_lines.get_mut(*line_no as usize) { |
| 143 | + if let Some(next) = migrate_status(line, TaskStatus::Done) { |
| 144 | + *line = next; |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + contents.insert(*note_id, (note.title.clone(), body_lines.join("\n"))); |
| 149 | + } |
| 150 | + // 单事务提交:正文/索引/完成史原子 |
| 151 | + let now = crate::db::unix_seconds(); |
| 152 | + let tx: crate::error::Result<()> = db.with_conn(|conn| { |
| 153 | + conn.execute("BEGIN TRANSACTION", [])?; |
| 154 | + let result = (|| -> rusqlite::Result<()> { |
| 155 | + { |
| 156 | + let mut stmt = conn.prepare("UPDATE notes SET content = ?1, updated_at = ?2 WHERE id = ?3")?; |
| 157 | + for (note_id, (_title, content)) in &contents { |
| 158 | + stmt.execute(params![content, now, note_id])?; |
| 159 | + crate::db_task_index::rebuild_note_tasks(conn, *note_id, content); |
| 160 | + } |
| 161 | + } |
| 162 | + { |
| 163 | + let mut stmt = conn.prepare( |
| 164 | + "INSERT INTO completion_history (ts, event_type, source_type, source_id, note_id, text, note) |
| 165 | + VALUES (?1, ?2, 'task_line', NULL, ?3, ?4, ?5)", |
| 166 | + )?; |
| 167 | + for (text, reason, note_id) in &events { |
| 168 | + let event_type = if reason.is_some() { "abandoned" } else { "done" }; |
| 169 | + stmt.execute(params![now, event_type, note_id, text, reason])?; |
| 170 | + } |
| 171 | + } |
| 172 | + Ok(()) |
| 173 | + })(); |
| 174 | + match result { |
| 175 | + Ok(()) => { |
| 176 | + conn.execute("COMMIT", [])?; |
| 177 | + } |
| 178 | + Err(e) => { |
| 179 | + let _ = conn.execute("ROLLBACK", []); |
| 180 | + return Err(e.into()); |
| 181 | + } |
| 182 | + } |
| 183 | + Ok(()) |
| 184 | + }); |
| 185 | + tx.map_err(|e| e.to_string())?; |
| 186 | + Ok(view) |
| 187 | +} |
| 188 | + |
| 189 | +/// 练习条目命令(REQ-299)——建/列/打点/状态。 |
| 190 | +#[tauri::command] |
| 191 | +pub fn practice_create( |
| 192 | + state: State<'_, AppState>, |
| 193 | + text: String, |
| 194 | + frequency: Option<String>, |
| 195 | + goal: Option<String>, |
| 196 | +) -> Result<i64, String> { |
| 197 | + let text = text.trim(); |
| 198 | + if text.is_empty() || text.chars().count() > 300 { |
| 199 | + return Err("练习文本为空或超长(≤300 字)".to_string()); |
| 200 | + } |
| 201 | + state |
| 202 | + .db |
| 203 | + .create_practice_item(text, frequency.as_deref().unwrap_or("manual"), goal.as_deref()) |
| 204 | + .map_err(|e| e.to_string()) |
| 205 | +} |
| 206 | + |
| 207 | +#[tauri::command] |
| 208 | +pub fn practice_list( |
| 209 | + state: State<'_, AppState>, |
| 210 | + status: Option<String>, |
| 211 | +) -> Result<Vec<PracticeItem>, String> { |
| 212 | + state |
| 213 | + .db |
| 214 | + .list_practice_items(status.as_deref()) |
| 215 | + .map_err(|e| e.to_string()) |
| 216 | +} |
| 217 | + |
| 218 | +#[tauri::command] |
| 219 | +pub fn practice_tick( |
| 220 | + state: State<'_, AppState>, |
| 221 | + item_id: i64, |
| 222 | + mastery: Option<i64>, |
| 223 | +) -> Result<(), String> { |
| 224 | + if let Some(m) = mastery { |
| 225 | + if !(1..=5).contains(&m) { |
| 226 | + return Err("熟练度自评须在 1-5".to_string()); |
| 227 | + } |
| 228 | + } |
| 229 | + let ok = state.db.practice_tick(item_id, mastery).map_err(|e| e.to_string())?; |
| 230 | + if !ok { |
| 231 | + return Err("练习条目不存在".to_string()); |
| 232 | + } |
| 233 | + Ok(()) |
| 234 | +} |
| 235 | + |
| 236 | +/// 问题清单命令(REQ-300)——建/列/已答/归档。 |
| 237 | +#[tauri::command] |
| 238 | +pub fn question_create( |
| 239 | + state: State<'_, AppState>, |
| 240 | + text: String, |
| 241 | + note_id: Option<i64>, |
| 242 | + context: Option<String>, |
| 243 | +) -> Result<i64, String> { |
| 244 | + let text = text.trim(); |
| 245 | + if text.is_empty() || text.chars().count() > 1000 { |
| 246 | + return Err("问题文本为空或超长".to_string()); |
| 247 | + } |
| 248 | + state |
| 249 | + .db |
| 250 | + .create_question(text, note_id, context.as_deref()) |
| 251 | + .map_err(|e| e.to_string()) |
| 252 | +} |
| 253 | + |
| 254 | +#[tauri::command] |
| 255 | +pub fn question_list( |
| 256 | + state: State<'_, AppState>, |
| 257 | + status: Option<String>, |
| 258 | +) -> Result<Vec<QuestionItem>, String> { |
| 259 | + state.db.list_questions(status.as_deref()).map_err(|e| e.to_string()) |
| 260 | +} |
| 261 | + |
| 262 | +#[tauri::command] |
| 263 | +pub fn question_answer( |
| 264 | + state: State<'_, AppState>, |
| 265 | + id: i64, |
| 266 | + answer_ref: Option<String>, |
| 267 | +) -> Result<(), String> { |
| 268 | + let ok = state.db.answer_question(id, answer_ref.as_deref()).map_err(|e| e.to_string())?; |
| 269 | + if !ok { |
| 270 | + return Err("问题不存在".to_string()); |
| 271 | + } |
| 272 | + Ok(()) |
| 273 | +} |
| 274 | + |
| 275 | +#[tauri::command] |
| 276 | +pub fn question_set_status(state: State<'_, AppState>, id: i64, status: String) -> Result<(), String> { |
| 277 | + let ok = state.db.set_question_status(id, &status).map_err(|e| e.to_string())?; |
| 278 | + if !ok { |
| 279 | + return Err("问题不存在".to_string()); |
| 280 | + } |
| 281 | + Ok(()) |
| 282 | +} |
| 283 | + |
| 284 | +#[cfg(test)] |
| 285 | +#[path = "commands_after_tests.rs"] |
| 286 | +mod tests; |
0 commit comments