Skip to content

Commit 8d314b0

Browse files
committed
feat(tasks): REQ-294/295/299/300 批决议/迁出/练习/问题命令族
1 parent 6b8a112 commit 8d314b0

10 files changed

Lines changed: 751 additions & 2 deletions
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//! commands_after 单测(v0.20.3 / REQ-294/295 收尾命令族)。
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- [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 weekly_batch_resolves_atomically() {
24+
let db = Db::open(":memory:").unwrap();
25+
let nid = seed(&db);
26+
let rows = db.list_task_queue(Some(nid)).unwrap();
27+
let r1 = rows.iter().find(|r| r.task_text == "任务一").unwrap();
28+
let r2 = rows.iter().find(|r| r.task_text == "任务二").unwrap();
29+
let decisions = vec![
30+
WeeklyDecision { row_id: r1.id, action: "done".into(), reason: None },
31+
WeeklyDecision { row_id: r2.id, action: "abandon".into(), reason: Some("不适用".into()) },
32+
];
33+
let view = weekly_resolve_core(&db, &decisions).unwrap();
34+
assert_eq!(view.done, 1);
35+
assert_eq!(view.abandoned, 1);
36+
// 正文两行均 [x] 化(单事务)
37+
let note = db.get_note(nid).unwrap().unwrap();
38+
assert!(note.content.contains("- [x] 任务一"));
39+
assert!(note.content.contains("- [x] 任务二"));
40+
// 完成史 done + abandoned(原因随行)
41+
let done = db.list_completion_events(Some("done"), 10).unwrap();
42+
let ab = db.list_completion_events(Some("abandoned"), 10).unwrap();
43+
assert_eq!(done.len(), 1);
44+
assert_eq!(ab.len(), 1);
45+
assert_eq!(ab[0].note.as_deref(), Some("不适用"));
46+
// 索引已无 todo 行(重扫)
47+
let after = db.list_task_queue(Some(nid)).unwrap();
48+
assert!(!after.iter().any(|r| r.status == "todo"));
49+
}
50+
51+
#[test]
52+
fn weekly_batch_tolerates_stale_rows() {
53+
let db = Db::open(":memory:").unwrap();
54+
let nid = seed(&db);
55+
let rows = db.list_task_queue(Some(nid)).unwrap();
56+
let r1 = rows.iter().find(|r| r.task_text == "任务一").unwrap();
57+
let decisions = vec![
58+
WeeklyDecision { row_id: r1.id, action: "done".into(), reason: None },
59+
WeeklyDecision { row_id: 999_999, action: "done".into(), reason: None },
60+
WeeklyDecision { row_id: r1.id, action: "weird".into(), reason: None },
61+
];
62+
let view = weekly_resolve_core(&db, &decisions).unwrap();
63+
assert_eq!(view.done, 1, "合法项照常执行");
64+
assert_eq!(view.failed.len(), 2, "失效行与非法动作显式记录,不静默");
65+
// 重复行(done 后同 id 再出现 done——重复裁决幂等防御:第二行仍在 todo 之前已被执行,
66+
// 该重复 action 视为失效(重扫后无 todo 行,get 仍取到 done 行并二次迁移无变化)
67+
assert!(db.get_note(nid).unwrap().unwrap().content.contains("[x] 任务一"));
68+
}
69+
70+
#[test]
71+
fn manual_fill_export_done_records_history() {
72+
let db = Db::open(":memory:").unwrap();
73+
let id = db
74+
.add_completion_event(EV_EXPORT_MANUAL_DONE, "export", None, None, "找资料(外部完成)", Some("Todoist"), None)
75+
.unwrap();
76+
assert!(id > 0);
77+
let rows = db.list_completion_events(Some(EV_EXPORT_MANUAL_DONE), 10).unwrap();
78+
assert_eq!(rows.len(), 1);
79+
assert_eq!(rows[0].note.as_deref(), Some("Todoist"));
80+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,9 @@ CREATE TABLE IF NOT EXISTS contracts (
509509
crate::db_completion::init(conn)?;
510510
// v0.20.3(REQ-296/297):SOP 功能区三表(模板行范围引用/run/步骤轨迹)
511511
crate::db_sop::init(conn)?;
512+
// v0.20.3(REQ-299/300):练习条目 + 问题清单(Me 问题化)
513+
crate::db_practice::init(conn)?;
514+
crate::db_questions::init(conn)?;
512515
Ok(())
513516
}
514517

0 commit comments

Comments
 (0)