Skip to content

Commit 7a31ab4

Browse files
committed
feat(tasks): REQ-293/294/298 行动裁决命令族与完成史表
1 parent c78e294 commit 7a31ab4

7 files changed

Lines changed: 565 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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(&note.content).ok_or_else(|| "任务行已失效(正文变化请刷新)".to_string())?;
105+
db.update_note(row.note_id, &note.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;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! 行动裁决命令族单测(v0.20.3 / REQ-293/294/298 数据面,内存库)。
2+
3+
use super::*;
4+
use crate::db::Db;
5+
6+
fn seed(db: &Db) -> i64 {
7+
db.create_note(&crate::types::NewNote {
8+
title: "行动笔记".to_string(),
9+
content: "- [ ] 待办甲\n- ☑️ 待办 找两篇文章剪藏\n普通段落\n- [x] 历史完成\n".to_string(),
10+
source: "manual".to_string(),
11+
session_id: None,
12+
rule_version: None,
13+
purify_stats: None,
14+
tags: None,
15+
properties: None,
16+
group_id: None,
17+
})
18+
.unwrap()
19+
.id
20+
}
21+
22+
#[test]
23+
fn partition_tabs_basic() {
24+
let db = Db::open(":memory:").unwrap();
25+
let nid = seed(&db);
26+
let rows = db.list_task_queue(Some(nid)).unwrap();
27+
let someday = partition_rows(&rows, TAB_SOMEDAY);
28+
assert_eq!(someday.len(), 1, "未排期 todo 归搁置");
29+
let unrefined = partition_rows(&rows, TAB_UNREFINED);
30+
assert_eq!(unrefined.len(), 1, "☑️ 行归待提炼");
31+
assert!(partition_rows(&rows, TAB_OVERDUE).is_empty());
32+
assert!(partition_rows(&rows, TAB_PLANNED).is_empty());
33+
}
34+
35+
#[test]
36+
fn complete_rewrites_body_and_history() {
37+
let db = Db::open(":memory:").unwrap();
38+
let nid = seed(&db);
39+
let rows = db.list_task_queue(Some(nid)).unwrap();
40+
let todo = rows.iter().find(|r| r.task_text == "待办甲").unwrap();
41+
let done_msg = complete_task_core(&db, todo.id).unwrap();
42+
assert!(done_msg.contains("已完成"));
43+
// 正文 [x] 化(字符级;其余行不动)
44+
let note = db.get_note(nid).unwrap().unwrap();
45+
assert!(note.content.contains("- [x] 待办甲"));
46+
assert!(note.content.contains("- ☑️ 待办 找两篇文章剪藏"), "其余行原样");
47+
let history = db.list_completion_events(Some(EV_DONE), 10).unwrap();
48+
assert_eq!(history.len(), 1);
49+
assert_eq!(history[0].text, "待办甲");
50+
}
51+
52+
#[test]
53+
fn abandon_marks_done_with_reason() {
54+
let db = Db::open(":memory:").unwrap();
55+
let nid = seed(&db);
56+
let rows = db.list_task_queue(Some(nid)).unwrap();
57+
let a = rows.iter().find(|r| r.task_text == "待办甲").unwrap();
58+
abandon_task_core(&db, a.id, "优先级转移").unwrap();
59+
let note = db.get_note(nid).unwrap().unwrap();
60+
assert!(note.content.contains("- [x] 待办甲"), "放弃=[x]+留因");
61+
let ab = db.list_completion_events(Some(EV_ABANDONED), 10).unwrap();
62+
assert_eq!(ab.len(), 1);
63+
assert_eq!(ab[0].note.as_deref(), Some("优先级转移"));
64+
}
65+
66+
#[test]
67+
fn refine_unrefined_becomes_standard_line() {
68+
let db = Db::open(":memory:").unwrap();
69+
let nid = seed(&db);
70+
let rows = db.list_task_queue(Some(nid)).unwrap();
71+
let u = rows.iter().find(|r| r.unrefined).unwrap();
72+
refine_unrefined_core(&db, u.id).unwrap();
73+
let note = db.get_note(nid).unwrap().unwrap();
74+
assert!(note.content.contains("- [ ] 找两篇文章剪藏"), "提炼为标准任务行");
75+
assert!(!note.content.contains("☑️"), "遗留行被替换");
76+
}
77+
78+
#[test]
79+
fn plan_date_persists_index_only() {
80+
let db = Db::open(":memory:").unwrap();
81+
let nid = seed(&db);
82+
let rows = db.list_task_queue(Some(nid)).unwrap();
83+
let todo = rows.iter().find(|r| r.task_text == "待办甲").unwrap();
84+
let tomorrow = today_start_secs() + 86_400;
85+
db.set_task_plan_date(todo.id, Some(tomorrow)).unwrap();
86+
// 正文零变化(计划日是元数据——仅索引列写)
87+
let note = db.get_note(nid).unwrap().unwrap();
88+
assert_eq!(note.content, "- [ ] 待办甲\n- ☑️ 待办 找两篇文章剪藏\n普通段落\n- [x] 历史完成\n");
89+
let rows = db.list_task_queue(Some(nid)).unwrap();
90+
assert!(partition_rows(&rows, TAB_PLANNED).len() == 1);
91+
assert!(partition_rows(&rows, TAB_SOMEDAY).is_empty());
92+
}

0 commit comments

Comments
 (0)