1use crate::ids::{ProjectId, WorkspaceId};
2use std::collections::{BTreeMap, BTreeSet};
3
4use anyhow::{Result, bail, ensure};
5use sqlx::{Row, SqliteConnection};
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{
9 Database, begin_immediate, conflict_exists, field_version, insert_change, set_field_version,
10 task_from_row,
11};
12use crate::error::CoreError;
13use crate::ids::{new_id, now};
14use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
15use crate::operations::{
16 ProjectMetadata, insert_project_metadata_change, set_project_metadata,
17 update_task_labels_in_workspace,
18};
19use crate::projects::resolve_project_for_stored_value;
20use crate::task_fields::TaskField;
21use crate::workspaces::workspace_key_for_id;
22
23tokio::task_local! {
24 static APPLYING_UNDO: ();
25}
26
27pub fn is_applying_undo() -> bool {
28 APPLYING_UNDO.try_with(|_| ()).is_ok()
29}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct UndoPayload {
33 pub commands: Vec<UndoCommand>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PendingUndoPresentation {
38 pub id: String,
39 pub operation: String,
40 pub task_ids: Vec<crate::ids::TaskId>,
41}
42
43impl PendingUndoPresentation {
44 pub fn operation_phrase(&self) -> &str {
45 &self.operation
46 }
47}
48
49#[derive(Debug, Clone, Default)]
50pub enum UndoContext {
51 #[default]
52 None,
53 Tui {
54 summary: String,
55 },
56 TuiTaskMutation {
57 single_summary: Option<String>,
58 batch_action: String,
59 },
60}
61
62impl UndoContext {
63 pub fn tui(summary: impl Into<String>) -> Self {
64 Self::Tui {
65 summary: summary.into(),
66 }
67 }
68
69 pub fn tui_task_mutation(
70 single_summary: Option<String>,
71 batch_action: impl Into<String>,
72 ) -> Self {
73 Self::TuiTaskMutation {
74 single_summary,
75 batch_action: batch_action.into(),
76 }
77 }
78
79 pub(crate) fn task_mutation_summary(self, changed_count: usize) -> Option<String> {
80 match self {
81 Self::None => None,
82 Self::Tui { summary } => Some(summary),
83 Self::TuiTaskMutation {
84 single_summary,
85 batch_action,
86 } => single_summary.filter(|_| changed_count == 1).or_else(|| {
87 let noun = if changed_count == 1 { "task" } else { "tasks" };
88 Some(format!("{batch_action} {changed_count} {noun}"))
89 }),
90 }
91 }
92}
93
94#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
95#[serde(tag = "op", rename_all = "snake_case")]
96pub enum UndoCommand {
97 SetTaskField {
98 task_id: crate::ids::TaskId,
99 field: String,
100 before: String,
101 after: String,
102 #[serde(default)]
103 queue_activity_before: Option<String>,
104 #[serde(default)]
105 queue_activity_after: Option<String>,
106 },
107 SetTaskLabels {
108 task_id: crate::ids::TaskId,
109 before: Vec<String>,
110 after: Vec<String>,
111 },
112 SetTaskMetadata {
113 task_id: crate::ids::TaskId,
114 field_id: crate::ids::MetadataFieldId,
115 before: Option<String>,
116 after: Option<String>,
117 },
118 DeleteCreatedTask {
119 task_id: crate::ids::TaskId,
120 create_change_id: Option<String>,
121 expected: TaskUndoSnapshot,
122 #[serde(default)]
123 attachment_ids: Vec<String>,
124 #[serde(default)]
125 attachment_change_ids: Vec<String>,
126 },
127 SetNoteBody {
128 task_id: crate::ids::TaskId,
129 note_id: String,
130 before: String,
131 after: String,
132 },
133 RestoreDeletedNote {
134 task_id: crate::ids::TaskId,
135 note_id: String,
136 body: String,
137 created_at: String,
138 },
139 DeleteCreatedNote {
140 task_id: crate::ids::TaskId,
141 note_id: String,
142 note_add_change_id: String,
143 },
144 DeleteCreatedProject {
145 project_key: String,
146 create_change_id: String,
147 expected_name: String,
148 expected_prefix: String,
149 },
150 SetProjectMetadata {
151 project_id: ProjectId,
152 before_key: String,
153 before_name: String,
154 before_prefix: String,
155 after_key: String,
156 after_name: String,
157 after_prefix: String,
158 },
159 DeleteCreatedLabel {
160 label: String,
161 create_change_id: String,
162 },
163 SetLabelName {
164 before: String,
165 after: String,
166 },
167 RestoreDeletedLabel {
168 name: String,
169 created_at: String,
170 task_ids: Vec<crate::ids::TaskId>,
171 series_ids: Vec<crate::recurrence::RecurrenceSeriesId>,
172 },
173 RestoreConflictResolution {
174 task_id: crate::ids::TaskId,
175 field: String,
176 before: String,
177 after: String,
178 conflict_id: i64,
179 },
180 AddTaskDependency {
181 task_id: crate::ids::TaskId,
182 depends_on_task_id: crate::ids::TaskId,
183 },
184 RemoveTaskDependency {
185 task_id: crate::ids::TaskId,
186 depends_on_task_id: crate::ids::TaskId,
187 },
188 SetTaskRelatedLink {
189 task_id: crate::ids::TaskId,
190 related_task_id: crate::ids::TaskId,
191 forward_change_id: String,
192 linked: bool,
193 },
194 AddEpicChild {
195 epic_id: crate::ids::TaskId,
196 child_id: crate::ids::TaskId,
197 },
198 RemoveEpicChild {
199 epic_id: crate::ids::TaskId,
200 child_id: crate::ids::TaskId,
201 },
202}
203
204#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
205pub struct TaskUndoSnapshot {
206 pub title: String,
207 pub description: String,
208 pub project_id: ProjectId,
209 pub project_key: String,
210 pub status: String,
211 pub priority: String,
212 #[serde(default)]
213 pub queue_activity_at: String,
214 pub available_at: String,
215 #[serde(default)]
216 pub due_on: String,
217 pub deleted: bool,
218 #[serde(default)]
219 pub is_epic: bool,
220 pub labels: Vec<String>,
221 #[serde(default)]
222 pub metadata: BTreeMap<String, String>,
223}
224
225pub struct UndoOutcome {
226 pub presentation: PendingUndoPresentation,
227 pub task_id: Option<crate::ids::TaskId>,
228 pub include_deleted: Option<bool>,
229 pub project_rename: Option<ProjectRenameUndoOutcome>,
230 pub label_rename: Option<LabelRenameUndoOutcome>,
231}
232
233pub struct LabelRenameUndoOutcome {
234 pub before: String,
235 pub after: String,
236}
237
238pub struct ProjectRenameUndoOutcome {
239 pub before_key: String,
240 pub after_key: String,
241}
242
243impl Database {
244 pub async fn task_field_value(
245 &self,
246 workspace_id: &WorkspaceId,
247 task_id: &crate::ids::TaskId,
248 field: &str,
249 ) -> Result<String> {
250 let mut conn = self.acquire_reader().await?;
251 task_field_value(&mut conn, workspace_id, task_id, field).await
252 }
253
254 pub async fn task_labels(
255 &self,
256 workspace_id: &WorkspaceId,
257 task_id: &crate::ids::TaskId,
258 ) -> Result<Vec<String>> {
259 let mut conn = self.acquire_reader().await?;
260 task_labels(&mut conn, workspace_id, task_id).await
261 }
262
263 pub async fn task_undo_snapshot(
264 &self,
265 workspace_id: &WorkspaceId,
266 task_id: &crate::ids::TaskId,
267 ) -> Result<TaskUndoSnapshot> {
268 let mut conn = self.acquire_reader().await?;
269 task_snapshot(&mut conn, workspace_id, task_id).await
270 }
271
272 pub async fn conflict_row_id(
273 &self,
274 workspace_id: &WorkspaceId,
275 task_id: &crate::ids::TaskId,
276 field: &str,
277 ) -> Result<i64> {
278 let mut conn = self.acquire_reader().await?;
279 conflict_row_id(&mut conn, workspace_id, task_id, field).await
280 }
281
282 pub async fn clear_pending_tui_undo_entries(&self) -> Result<()> {
283 let mut conn = self.acquire_writer().await?;
284 clear_pending_tui_undo_entries(&mut conn).await
285 }
286
287 pub async fn latest_tui_undo_presentation(
288 &self,
289 workspace_id: &WorkspaceId,
290 ) -> Result<Option<PendingUndoPresentation>> {
291 let mut conn = self.acquire_reader().await?;
292 latest_tui_undo_presentation(&mut conn, workspace_id).await
293 }
294
295 pub async fn apply_latest_tui_undo(
296 &self,
297 workspace_id: &WorkspaceId,
298 ) -> Result<Option<UndoOutcome>> {
299 let mut conn = self.acquire_writer().await?;
300 apply_latest_tui_undo(&mut conn, workspace_id).await
301 }
302}
303
304pub(crate) async fn task_field_value(
305 conn: &mut SqliteConnection,
306 workspace_id: &WorkspaceId,
307 task_id: &crate::ids::TaskId,
308 field: &str,
309) -> Result<String> {
310 let task_field = TaskField::parse_or_unknown(field)?;
311 task_field_value_for_field(conn, workspace_id, task_id, task_field).await
312}
313
314async fn task_field_value_for_field(
315 conn: &mut SqliteConnection,
316 workspace_id: &WorkspaceId,
317 task_id: &crate::ids::TaskId,
318 task_field: TaskField,
319) -> Result<String> {
320 let row = sqlx::query(
321 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id, p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
322 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
323 WHERE t.workspace_id = ? AND t.id = ?",
324 )
325 .bind(workspace_id)
326 .bind(task_id)
327 .fetch_optional(&mut *conn)
328 .await?
329 .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
330 let task = task_from_row(&row)?;
331 Ok(task_field.current_value(&task))
332}
333
334pub(crate) async fn task_labels(
335 conn: &mut SqliteConnection,
336 workspace_id: &WorkspaceId,
337 task_id: &crate::ids::TaskId,
338) -> Result<Vec<String>> {
339 let rows = sqlx::query(
340 "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
341 )
342 .bind(workspace_id)
343 .bind(task_id)
344 .fetch_all(&mut *conn)
345 .await?;
346 Ok(rows.into_iter().map(|row| row.get("label")).collect())
347}
348
349pub(crate) async fn task_snapshot(
350 conn: &mut SqliteConnection,
351 workspace_id: &WorkspaceId,
352 task_id: &crate::ids::TaskId,
353) -> Result<TaskUndoSnapshot> {
354 let row = sqlx::query(
355 "SELECT t.title, t.description, t.project_id, p.key AS project_key, t.status, t.priority, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
356 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
357 WHERE t.workspace_id = ? AND t.id = ?",
358 )
359 .bind(workspace_id)
360 .bind(task_id)
361 .fetch_optional(&mut *conn)
362 .await?
363 .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
364 let labels = task_labels(conn, workspace_id, task_id).await?;
365 let metadata = sqlx::query_as::<_, (String, String)>(
366 "SELECT field_id, value FROM task_metadata
367 WHERE workspace_id = ? AND task_id = ? ORDER BY field_id",
368 )
369 .bind(workspace_id)
370 .bind(task_id)
371 .fetch_all(&mut *conn)
372 .await?
373 .into_iter()
374 .collect();
375 Ok(TaskUndoSnapshot {
376 title: row.get("title"),
377 description: row.get("description"),
378 project_id: row.get("project_id"),
379 project_key: row.get("project_key"),
380 status: row.get("status"),
381 priority: row.get("priority"),
382 queue_activity_at: row.get("queue_activity_at"),
383 available_at: row.get("available_at"),
384 due_on: row.get("due_on"),
385 deleted: row.get::<i64, _>("deleted") != 0,
386 is_epic: row.get::<i64, _>("is_epic") != 0,
387 labels,
388 metadata,
389 })
390}
391
392pub(crate) async fn conflict_row_id(
393 conn: &mut SqliteConnection,
394 workspace_id: &WorkspaceId,
395 task_id: &crate::ids::TaskId,
396 field: &str,
397) -> Result<i64> {
398 sqlx::query_scalar(
399 "SELECT id FROM conflicts
400 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0
401 ORDER BY id LIMIT 1",
402 )
403 .bind(workspace_id)
404 .bind(task_id)
405 .bind(field)
406 .fetch_optional(&mut *conn)
407 .await?
408 .ok_or_else(|| {
409 CoreError::not_found(format!(
410 "error conflict-not-found task_id={task_id} field={field}"
411 ))
412 })
413 .map_err(Into::into)
414}
415
416pub(crate) async fn record_tui_undo(
417 conn: &mut SqliteConnection,
418 workspace_id: &WorkspaceId,
419 summary: &str,
420 payload: UndoPayload,
421) -> Result<()> {
422 if is_applying_undo() || !undo_payload_has_effect(&payload) {
423 return Ok(());
424 }
425 let id = new_id();
426 let created_at = now();
427 let seq: i64 = sqlx::query_scalar(
428 "SELECT COALESCE(MAX(seq), 0) + 1 FROM tui_undo_entries WHERE workspace_id = ?",
429 )
430 .bind(workspace_id)
431 .fetch_one(&mut *conn)
432 .await?;
433 let payload = serde_json::to_string(&payload)?;
434 sqlx::query(
435 "INSERT INTO tui_undo_entries(id, workspace_id, summary, payload_version, payload, seq, created_at)
436 VALUES (?, ?, ?, 1, ?, ?, ?)",
437 )
438 .bind(&id)
439 .bind(workspace_id)
440 .bind(summary)
441 .bind(&payload)
442 .bind(seq)
443 .bind(&created_at)
444 .execute(&mut *conn)
445 .await?;
446 prune_consumed_undo_entries(conn, workspace_id).await?;
447 Ok(())
448}
449
450fn undo_payload_has_effect(payload: &UndoPayload) -> bool {
451 payload.commands.iter().any(|command| match command {
452 UndoCommand::SetTaskField { before, after, .. } => before != after,
453 UndoCommand::SetTaskLabels { before, after, .. } => !label_sets_equal(before, after),
454 UndoCommand::SetTaskMetadata { before, after, .. } => before != after,
455 UndoCommand::SetProjectMetadata {
456 before_key,
457 before_name,
458 before_prefix,
459 after_key,
460 after_name,
461 after_prefix,
462 ..
463 } => before_key != after_key || before_name != after_name || before_prefix != after_prefix,
464 UndoCommand::SetNoteBody { before, after, .. } => before != after,
465 UndoCommand::SetLabelName { before, after } => before != after,
466 UndoCommand::DeleteCreatedTask { .. }
467 | UndoCommand::RestoreDeletedNote { .. }
468 | UndoCommand::DeleteCreatedNote { .. }
469 | UndoCommand::DeleteCreatedProject { .. }
470 | UndoCommand::DeleteCreatedLabel { .. }
471 | UndoCommand::RestoreDeletedLabel { .. }
472 | UndoCommand::RestoreConflictResolution { .. }
473 | UndoCommand::AddTaskDependency { .. }
474 | UndoCommand::RemoveTaskDependency { .. }
475 | UndoCommand::SetTaskRelatedLink { .. }
476 | UndoCommand::AddEpicChild { .. }
477 | UndoCommand::RemoveEpicChild { .. } => true,
478 })
479}
480
481fn classify_undo_commands(id: String, commands: &[UndoCommand]) -> PendingUndoPresentation {
482 let mut task_ids = BTreeSet::new();
483 for command in commands {
484 match command {
485 UndoCommand::SetTaskField { task_id, .. }
486 | UndoCommand::SetTaskLabels { task_id, .. }
487 | UndoCommand::SetTaskMetadata { task_id, .. }
488 | UndoCommand::DeleteCreatedTask { task_id, .. }
489 | UndoCommand::SetNoteBody { task_id, .. }
490 | UndoCommand::RestoreDeletedNote { task_id, .. }
491 | UndoCommand::DeleteCreatedNote { task_id, .. }
492 | UndoCommand::RestoreConflictResolution { task_id, .. }
493 | UndoCommand::AddTaskDependency { task_id, .. }
494 | UndoCommand::RemoveTaskDependency { task_id, .. }
495 | UndoCommand::SetTaskRelatedLink { task_id, .. } => {
496 task_ids.insert(task_id.clone());
497 }
498 UndoCommand::AddEpicChild { child_id, .. }
499 | UndoCommand::RemoveEpicChild { child_id, .. } => {
500 task_ids.insert(child_id.clone());
501 }
502 UndoCommand::DeleteCreatedProject { .. }
503 | UndoCommand::SetProjectMetadata { .. }
504 | UndoCommand::DeleteCreatedLabel { .. }
505 | UndoCommand::SetLabelName { .. }
506 | UndoCommand::RestoreDeletedLabel { .. } => {}
507 }
508 }
509
510 let operation = if commands
511 .iter()
512 .any(|command| matches!(command, UndoCommand::DeleteCreatedTask { .. }))
513 {
514 "task creation"
515 } else if commands
516 .iter()
517 .any(|command| matches!(command, UndoCommand::SetTaskLabels { .. }))
518 {
519 "label change"
520 } else if let Some(command) = commands.iter().find(|command| {
521 matches!(
522 command,
523 UndoCommand::AddEpicChild { .. } | UndoCommand::RemoveEpicChild { .. }
524 )
525 }) {
526 match command {
527 UndoCommand::AddEpicChild { .. } => "epic membership addition",
528 UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
529 _ => unreachable!(),
530 }
531 } else {
532 commands
533 .iter()
534 .find_map(undo_command_operation)
535 .unwrap_or("last TUI mutation")
536 };
537
538 PendingUndoPresentation {
539 id,
540 operation: operation.to_string(),
541 task_ids: task_ids.into_iter().collect(),
542 }
543}
544
545fn undo_command_operation(command: &UndoCommand) -> Option<&'static str> {
546 Some(match command {
547 UndoCommand::SetTaskField { field, after, .. } => match field.as_str() {
548 "title" => "title change",
549 "description" => "description change",
550 "project" => "project change",
551 "status" => "status change",
552 "priority" => "priority change",
553 "available_at" => "availability change",
554 "due_on" => "due date change",
555 "deleted" if after == "1" => "task deletion",
556 "deleted" => "task restoration",
557 "is_epic" => "epic status change",
558 _ => "task change",
559 },
560 UndoCommand::SetTaskLabels { .. } => "label change",
561 UndoCommand::SetTaskMetadata { .. } => "metadata change",
562 UndoCommand::DeleteCreatedTask { .. } => "task creation",
563 UndoCommand::SetNoteBody { .. } => "note edit",
564 UndoCommand::RestoreDeletedNote { .. } => "note deletion",
565 UndoCommand::DeleteCreatedNote { .. } => "note creation",
566 UndoCommand::DeleteCreatedProject { .. } => "project creation",
567 UndoCommand::SetProjectMetadata { .. } => "project change",
568 UndoCommand::DeleteCreatedLabel { .. } => "label creation",
569 UndoCommand::SetLabelName { .. } => "label rename",
570 UndoCommand::RestoreDeletedLabel { .. } => "label deletion",
571 UndoCommand::RestoreConflictResolution { .. } => "conflict resolution",
572 UndoCommand::AddTaskDependency { .. } => "dependency addition",
573 UndoCommand::RemoveTaskDependency { .. } => "dependency removal",
574 UndoCommand::SetTaskRelatedLink { linked, .. } => {
575 if *linked {
576 "related link addition"
577 } else {
578 "related link removal"
579 }
580 }
581 UndoCommand::AddEpicChild { .. } => "epic membership addition",
582 UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
583 })
584}
585
586fn empty_command_outcome() -> CommandOutcome {
587 CommandOutcome {
588 task_id: None,
589 include_deleted: None,
590 project_rename: None,
591 label_rename: None,
592 }
593}
594
595async fn prune_consumed_undo_entries(
596 conn: &mut SqliteConnection,
597 workspace_id: &WorkspaceId,
598) -> Result<()> {
599 sqlx::query(
600 "DELETE FROM tui_undo_entries
601 WHERE workspace_id = ? AND undone_at IS NOT NULL AND id NOT IN (
602 SELECT id FROM tui_undo_entries
603 WHERE workspace_id = ? AND undone_at IS NOT NULL
604 ORDER BY undone_at DESC, seq DESC
605 LIMIT 20
606 )",
607 )
608 .bind(workspace_id)
609 .bind(workspace_id)
610 .execute(&mut *conn)
611 .await?;
612 Ok(())
613}
614
615pub(crate) async fn clear_pending_tui_undo_entries(conn: &mut SqliteConnection) -> Result<()> {
616 sqlx::query("DELETE FROM tui_undo_entries WHERE undone_at IS NULL")
617 .execute(&mut *conn)
618 .await?;
619 Ok(())
620}
621
622pub(crate) async fn latest_tui_undo_presentation(
623 conn: &mut SqliteConnection,
624 workspace_id: &WorkspaceId,
625) -> Result<Option<PendingUndoPresentation>> {
626 let row = sqlx::query(
627 "SELECT id, payload FROM tui_undo_entries
628 WHERE workspace_id = ? AND undone_at IS NULL
629 ORDER BY seq DESC
630 LIMIT 1",
631 )
632 .bind(workspace_id)
633 .fetch_optional(&mut *conn)
634 .await?;
635 let Some(row) = row else {
636 return Ok(None);
637 };
638 let id: String = row.get("id");
639 let payload: String = row.get("payload");
640 let payload: UndoPayload = serde_json::from_str(&payload)?;
641 Ok(Some(classify_undo_commands(id, &payload.commands)))
642}
643
644pub(crate) async fn apply_latest_tui_undo(
645 conn: &mut SqliteConnection,
646 workspace_id: &WorkspaceId,
647) -> Result<Option<UndoOutcome>> {
648 let mut tx = begin_immediate(conn).await?;
649 let row = sqlx::query(
650 "SELECT id, payload FROM tui_undo_entries
651 WHERE workspace_id = ? AND undone_at IS NULL
652 ORDER BY seq DESC
653 LIMIT 1",
654 )
655 .bind(workspace_id)
656 .fetch_optional(&mut *tx)
657 .await?;
658 let Some(row) = row else {
659 return Ok(None);
660 };
661 let entry_id: String = row.get("id");
662 let payload_text: String = row.get("payload");
663 let undone_at = now();
664 let claimed =
665 sqlx::query("UPDATE tui_undo_entries SET undone_at = ? WHERE id = ? AND undone_at IS NULL")
666 .bind(&undone_at)
667 .bind(&entry_id)
668 .execute(&mut *tx)
669 .await?;
670 ensure!(
671 claimed.rows_affected() == 1,
672 "error undo-entry-claim-failed id={entry_id}"
673 );
674 let payload: UndoPayload = serde_json::from_str(&payload_text)?;
675 let presentation = classify_undo_commands(entry_id.clone(), &payload.commands);
676 let apply_result = APPLYING_UNDO
677 .scope(
678 (),
679 apply_undo_commands(&mut tx, workspace_id, &payload.commands),
680 )
681 .await;
682 match apply_result {
683 Ok(outcome) => {
684 tx.commit().await?;
685 Ok(Some(UndoOutcome {
686 presentation,
687 task_id: outcome.task_id,
688 include_deleted: outcome.include_deleted,
689 project_rename: outcome.project_rename,
690 label_rename: outcome.label_rename,
691 }))
692 }
693 Err(error) => {
694 tx.rollback().await?;
695 Err(error)
696 }
697 }
698}
699
700struct CommandOutcome {
701 task_id: Option<crate::ids::TaskId>,
702 include_deleted: Option<bool>,
703 project_rename: Option<ProjectRenameUndoOutcome>,
704 label_rename: Option<LabelRenameUndoOutcome>,
705}
706
707async fn apply_undo_commands(
708 conn: &mut SqliteConnection,
709 workspace_id: &WorkspaceId,
710 commands: &[UndoCommand],
711) -> Result<CommandOutcome> {
712 let mut task_id = None;
713 let mut include_deleted = None;
714 let mut project_rename = None;
715 let mut label_rename = None;
716 let mut affected_attachment_hashes = BTreeSet::new();
717 for command in commands {
718 let outcome =
719 apply_undo_command(conn, workspace_id, command, &mut affected_attachment_hashes)
720 .await?;
721 if outcome.task_id.is_some() {
722 task_id = outcome.task_id;
723 }
724 if outcome.include_deleted.is_some() {
725 include_deleted = outcome.include_deleted;
726 }
727 if outcome.project_rename.is_some() {
728 project_rename = outcome.project_rename;
729 }
730 if outcome.label_rename.is_some() {
731 label_rename = outcome.label_rename;
732 }
733 }
734 let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
735 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
736 conn,
737 &affected_attachment_hashes,
738 &crate::attachments::lifecycle::SystemClock,
739 )
740 .await?;
741 Ok(CommandOutcome {
742 task_id,
743 include_deleted,
744 project_rename,
745 label_rename,
746 })
747}
748
749async fn restore_queue_activity(
750 conn: &mut SqliteConnection,
751 workspace_id: &WorkspaceId,
752 task_id: &crate::ids::TaskId,
753 queue_activity_at: &str,
754) -> Result<()> {
755 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
756 .bind(queue_activity_at)
757 .bind(workspace_id)
758 .bind(task_id)
759 .execute(&mut *conn)
760 .await?;
761 Ok(())
762}
763
764async fn apply_undo_command(
765 conn: &mut SqliteConnection,
766 workspace_id: &WorkspaceId,
767 command: &UndoCommand,
768 affected_attachment_hashes: &mut BTreeSet<String>,
769) -> Result<CommandOutcome> {
770 match command {
771 UndoCommand::SetTaskField {
772 task_id,
773 field,
774 before,
775 after,
776 queue_activity_before,
777 queue_activity_after,
778 } => {
779 let task_field = TaskField::parse_or_unknown(field)?;
780 let current =
781 task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
782 if current != *after {
783 bail!("error undo-state-changed task_id={task_id} field={field}");
784 }
785 let queue_activity_to_restore = if let (Some(before), Some(after)) =
786 (queue_activity_before, queue_activity_after)
787 {
788 let current: String = sqlx::query_scalar(
789 "SELECT queue_activity_at FROM tasks WHERE workspace_id = ? AND id = ?",
790 )
791 .bind(workspace_id)
792 .bind(task_id)
793 .fetch_one(&mut *conn)
794 .await?;
795 Some(if current == *after {
796 before.clone()
797 } else {
798 current
799 })
800 } else {
801 None
802 };
803 if before != after {
804 let recurrence_undone = if task_field == TaskField::Status {
805 crate::operations::undo_recurrence_resolution(
806 conn,
807 workspace_id,
808 task_id,
809 before,
810 after,
811 )
812 .await?
813 } else {
814 false
815 };
816 if recurrence_undone {
817 if let Some(queue_activity_at) = queue_activity_to_restore {
818 restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at)
819 .await?;
820 }
821 return Ok(CommandOutcome {
822 task_id: Some(task_id.clone()),
823 include_deleted: None,
824 project_rename: None,
825 label_rename: None,
826 });
827 }
828 if task_field == TaskField::Project {
829 let project_id = before.parse().map_err(|_| {
830 anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
831 })?;
832 if !project_id_exists(conn, workspace_id, &project_id).await? {
833 bail!("error undo-state-changed task_id={task_id} field={field}");
834 }
835 }
836 if task_field == TaskField::Deleted {
837 collect_task_attachment_hashes(
838 conn,
839 workspace_id,
840 task_id,
841 affected_attachment_hashes,
842 )
843 .await?;
844 }
845 set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before)
846 .await?;
847 if let Some(queue_activity_at) = queue_activity_to_restore {
848 restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at).await?;
849 }
850 }
851 let include_deleted = if task_field == TaskField::Deleted {
852 Some(before == "1")
853 } else {
854 None
855 };
856 Ok(CommandOutcome {
857 task_id: Some(task_id.clone()),
858 include_deleted,
859 project_rename: None,
860 label_rename: None,
861 })
862 }
863 UndoCommand::SetTaskLabels {
864 task_id,
865 before,
866 after,
867 } => {
868 let current = task_labels(conn, workspace_id, task_id).await?;
869 if !label_sets_equal(¤t, after) {
870 bail!("error undo-state-changed task_id={task_id} field=labels");
871 }
872 let (add_labels, remove_labels) = label_delta(¤t, before);
873 update_task_labels_in_workspace(
874 conn,
875 workspace_id,
876 task_id,
877 &add_labels,
878 &remove_labels,
879 )
880 .await?;
881 Ok(CommandOutcome {
882 task_id: Some(task_id.clone()),
883 include_deleted: None,
884 project_rename: None,
885 label_rename: None,
886 })
887 }
888 UndoCommand::SetTaskMetadata {
889 task_id,
890 field_id,
891 before,
892 after,
893 } => {
894 let current: Option<String> = sqlx::query_scalar(
895 "SELECT value FROM task_metadata
896 WHERE workspace_id = ? AND task_id = ? AND field_id = ?",
897 )
898 .bind(workspace_id)
899 .bind(task_id)
900 .bind(field_id)
901 .fetch_optional(&mut *conn)
902 .await?;
903 if current != *after {
904 bail!("error undo-state-changed task_id={task_id} field=metadata");
905 }
906 let field = crate::metadata::metadata_field_by_id(conn, workspace_id, field_id)
907 .await?
908 .ok_or_else(|| anyhow::anyhow!("error metadata-field-not-found"))?;
909 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
910 if let Some(value) = before {
911 crate::metadata::set_task_metadata(
912 conn,
913 &workspace,
914 task_id,
915 &crate::metadata::TaskMetadataInput {
916 key: field.key,
917 value: value.clone(),
918 },
919 )
920 .await?;
921 } else {
922 crate::metadata::remove_task_metadata(conn, &workspace, task_id, &field.key)
923 .await?;
924 }
925 Ok(CommandOutcome {
926 task_id: Some(task_id.clone()),
927 include_deleted: None,
928 project_rename: None,
929 label_rename: None,
930 })
931 }
932 UndoCommand::DeleteCreatedTask {
933 task_id,
934 create_change_id,
935 expected,
936 attachment_ids,
937 attachment_change_ids,
938 } => {
939 let current = task_snapshot(conn, workspace_id, task_id).await?;
940 if current != *expected {
941 bail!("error undo-state-changed task_id={task_id} field=task");
942 }
943 let current_attachment_ids: Vec<String> = sqlx::query_scalar(
944 "SELECT attachment_id FROM task_attachments
945 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
946 ORDER BY created_at, attachment_id",
947 )
948 .bind(workspace_id)
949 .bind(task_id)
950 .fetch_all(&mut *conn)
951 .await?;
952 if let Some(change_id) = create_change_id {
953 let labels_clear = expected.labels.is_empty()
954 || labels_match_create_change(conn, change_id, &expected.labels).await?;
955 let attachment_changes_clear =
956 all_changes_unsynced(conn, attachment_change_ids).await?;
957 let related_state_clear =
958 !crate::operations::task_has_related_state(conn, workspace_id, task_id).await?;
959 if change_is_unsynced(conn, change_id).await?
960 && labels_clear
961 && attachment_changes_clear
962 && related_state_clear
963 && current_attachment_ids == *attachment_ids
964 {
965 hard_delete_created_task(
966 conn,
967 workspace_id,
968 task_id,
969 change_id,
970 attachment_change_ids,
971 affected_attachment_hashes,
972 )
973 .await?;
974 return Ok(CommandOutcome {
975 task_id: Some(task_id.clone()),
976 include_deleted: None,
977 project_rename: None,
978 label_rename: None,
979 });
980 }
981 }
982 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes)
983 .await?;
984 set_task_field_in_workspace(conn, workspace_id, task_id, TaskField::Deleted, "1")
985 .await?;
986 Ok(CommandOutcome {
987 task_id: Some(task_id.clone()),
988 include_deleted: None,
989 project_rename: None,
990 label_rename: None,
991 })
992 }
993 UndoCommand::SetNoteBody {
994 task_id,
995 note_id,
996 before,
997 after,
998 } => {
999 let current = sqlx::query_scalar::<_, String>(
1000 "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1001 )
1002 .bind(workspace_id)
1003 .bind(task_id)
1004 .bind(note_id)
1005 .fetch_optional(&mut *conn)
1006 .await?;
1007 if current.as_deref() != Some(after) {
1008 bail!("error undo-state-changed task_id={task_id} field=notes");
1009 }
1010 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1011 crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "notes", "")
1012 .await?;
1013 let edited_at = now();
1014 sqlx::query(
1015 "UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?",
1016 )
1017 .bind(before)
1018 .bind(workspace_id)
1019 .bind(task_id)
1020 .bind(note_id)
1021 .execute(&mut *conn)
1022 .await?;
1023 crate::change_log::append_change(
1024 conn,
1025 crate::change_log::ChangeEntity::Task,
1026 task_id,
1027 Some("notes"),
1028 crate::change_log::op_type::NOTE_EDIT,
1029 crate::change_log::ChangePayload::workspace(&workspace)
1030 .set("note_id", note_id)
1031 .set("body", before)
1032 .set("edited_at", &edited_at),
1033 )
1034 .await?;
1035 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1036 .bind(&edited_at)
1037 .bind(workspace_id)
1038 .bind(task_id)
1039 .execute(&mut *conn)
1040 .await?;
1041 Ok(CommandOutcome {
1042 task_id: Some(task_id.clone()),
1043 include_deleted: None,
1044 project_rename: None,
1045 label_rename: None,
1046 })
1047 }
1048 UndoCommand::RestoreDeletedNote {
1049 task_id,
1050 note_id,
1051 body,
1052 created_at,
1053 } => {
1054 let exists: i64 = sqlx::query_scalar(
1055 "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1056 )
1057 .bind(workspace_id)
1058 .bind(task_id)
1059 .bind(note_id)
1060 .fetch_one(&mut *conn)
1061 .await?;
1062 if exists != 0 {
1063 bail!("error undo-state-changed task_id={task_id} field=notes");
1064 }
1065 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1066 crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "notes", "")
1067 .await?;
1068 let change_id = crate::change_log::append_change(
1069 conn,
1070 crate::change_log::ChangeEntity::Task,
1071 task_id,
1072 Some("notes"),
1073 crate::change_log::op_type::NOTE_ADD,
1074 crate::change_log::ChangePayload::workspace(&workspace)
1075 .set("note_id", note_id)
1076 .set("body", body)
1077 .set("created_at", created_at),
1078 )
1079 .await?;
1080 sqlx::query(
1081 "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
1082 VALUES (?, ?, ?, ?, ?, ?)",
1083 )
1084 .bind(workspace_id)
1085 .bind(note_id)
1086 .bind(task_id)
1087 .bind(body)
1088 .bind(created_at)
1089 .bind(change_id)
1090 .execute(&mut *conn)
1091 .await?;
1092 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1093 .bind(now())
1094 .bind(workspace_id)
1095 .bind(task_id)
1096 .execute(&mut *conn)
1097 .await?;
1098 Ok(CommandOutcome {
1099 task_id: Some(task_id.clone()),
1100 include_deleted: None,
1101 project_rename: None,
1102 label_rename: None,
1103 })
1104 }
1105 UndoCommand::DeleteCreatedNote {
1106 task_id,
1107 note_id,
1108 note_add_change_id,
1109 } => {
1110 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1111 crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "notes", "")
1112 .await?;
1113 delete_created_note(conn, workspace_id, task_id, note_id, note_add_change_id).await?;
1114 Ok(CommandOutcome {
1115 task_id: Some(task_id.clone()),
1116 include_deleted: None,
1117 project_rename: None,
1118 label_rename: None,
1119 })
1120 }
1121 UndoCommand::DeleteCreatedProject {
1122 project_key,
1123 create_change_id,
1124 expected_name,
1125 expected_prefix,
1126 } => {
1127 delete_created_project(
1128 conn,
1129 workspace_id,
1130 project_key,
1131 create_change_id,
1132 expected_name,
1133 expected_prefix,
1134 )
1135 .await?;
1136 Ok(empty_command_outcome())
1137 }
1138 UndoCommand::SetProjectMetadata {
1139 project_id,
1140 before_key,
1141 before_name,
1142 before_prefix,
1143 after_key,
1144 after_name,
1145 after_prefix,
1146 } => {
1147 set_project_metadata_for_undo(
1148 conn,
1149 workspace_id,
1150 project_id,
1151 ProjectMetadata {
1152 key: before_key,
1153 name: before_name,
1154 prefix: before_prefix,
1155 },
1156 ProjectMetadata {
1157 key: after_key,
1158 name: after_name,
1159 prefix: after_prefix,
1160 },
1161 )
1162 .await?;
1163 Ok(CommandOutcome {
1164 task_id: None,
1165 include_deleted: None,
1166 project_rename: Some(ProjectRenameUndoOutcome {
1167 before_key: before_key.clone(),
1168 after_key: after_key.clone(),
1169 }),
1170 label_rename: None,
1171 })
1172 }
1173 UndoCommand::DeleteCreatedLabel {
1174 label,
1175 create_change_id,
1176 } => {
1177 delete_created_label(conn, workspace_id, label, create_change_id).await?;
1178 Ok(empty_command_outcome())
1179 }
1180 UndoCommand::SetLabelName { before, after } => {
1181 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1182 let after_exists = sqlx::query_scalar::<_, i64>(
1183 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1184 )
1185 .bind(workspace_id)
1186 .bind(after)
1187 .fetch_one(&mut *conn)
1188 .await?
1189 == 1;
1190 let before_exists = sqlx::query_scalar::<_, i64>(
1191 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1192 )
1193 .bind(workspace_id)
1194 .bind(before)
1195 .fetch_one(&mut *conn)
1196 .await?
1197 > 0;
1198 if !after_exists || before_exists {
1199 bail!("error undo-state-changed label={after}");
1200 }
1201 crate::operations::set_label_name(conn, &workspace, after, before).await?;
1202 Ok(CommandOutcome {
1203 task_id: None,
1204 include_deleted: None,
1205 project_rename: None,
1206 label_rename: Some(LabelRenameUndoOutcome {
1207 before: before.clone(),
1208 after: after.clone(),
1209 }),
1210 })
1211 }
1212 UndoCommand::RestoreDeletedLabel {
1213 name,
1214 created_at,
1215 task_ids,
1216 series_ids,
1217 } => {
1218 restore_deleted_label(conn, workspace_id, name, created_at, task_ids, series_ids)
1219 .await?;
1220 Ok(empty_command_outcome())
1221 }
1222 UndoCommand::RestoreConflictResolution {
1223 task_id,
1224 field,
1225 before,
1226 after,
1227 conflict_id,
1228 } => {
1229 let task_field = TaskField::parse_or_unknown(field)?;
1230 let current =
1231 task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
1232 if current != *after {
1233 bail!("error undo-state-changed task_id={task_id} field={field}");
1234 }
1235 if task_field == TaskField::Deleted && before != after {
1236 collect_task_attachment_hashes(
1237 conn,
1238 workspace_id,
1239 task_id,
1240 affected_attachment_hashes,
1241 )
1242 .await?;
1243 }
1244 set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before).await?;
1245 let restored = sqlx::query(
1246 "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
1247 )
1248 .bind(conflict_id)
1249 .bind(workspace_id)
1250 .execute(&mut *conn)
1251 .await?;
1252 ensure!(
1253 restored.rows_affected() == 1,
1254 "error undo-state-changed task_id={task_id} field={field}"
1255 );
1256 Ok(CommandOutcome {
1257 task_id: Some(task_id.clone()),
1258 include_deleted: None,
1259 project_rename: None,
1260 label_rename: None,
1261 })
1262 }
1263 UndoCommand::AddTaskDependency {
1264 task_id,
1265 depends_on_task_id,
1266 } => {
1267 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1268 crate::operations::route_recurrence_task_field(
1269 conn,
1270 &workspace,
1271 task_id,
1272 "dependencies",
1273 "",
1274 )
1275 .await?;
1276 crate::operations::route_recurrence_task_field(
1277 conn,
1278 &workspace,
1279 depends_on_task_id,
1280 "dependencies",
1281 "",
1282 )
1283 .await?;
1284 ensure!(
1285 dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1286 "error undo-state-changed task_id={task_id} field=dependency"
1287 );
1288 remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1289 Ok(CommandOutcome {
1290 task_id: Some(task_id.clone()),
1291 include_deleted: None,
1292 project_rename: None,
1293 label_rename: None,
1294 })
1295 }
1296 UndoCommand::RemoveTaskDependency {
1297 task_id,
1298 depends_on_task_id,
1299 } => {
1300 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1301 crate::operations::route_recurrence_task_field(
1302 conn,
1303 &workspace,
1304 task_id,
1305 "dependencies",
1306 "",
1307 )
1308 .await?;
1309 crate::operations::route_recurrence_task_field(
1310 conn,
1311 &workspace,
1312 depends_on_task_id,
1313 "dependencies",
1314 "",
1315 )
1316 .await?;
1317 ensure!(
1318 !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1319 "error undo-state-changed task_id={task_id} field=dependency"
1320 );
1321 ensure!(
1322 !crate::operations::dependency_path_exists(
1323 conn,
1324 workspace_id,
1325 depends_on_task_id,
1326 task_id,
1327 )
1328 .await?,
1329 "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
1330 );
1331 add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1332 Ok(CommandOutcome {
1333 task_id: Some(task_id.clone()),
1334 include_deleted: None,
1335 project_rename: None,
1336 label_rename: None,
1337 })
1338 }
1339 UndoCommand::SetTaskRelatedLink {
1340 task_id,
1341 related_task_id,
1342 forward_change_id,
1343 linked,
1344 } => {
1345 let (task_a_id, task_b_id) =
1346 crate::operations::canonical_related_pair(task_id, related_task_id)?;
1347 let current_change_id = sqlx::query_scalar::<_, String>(
1348 "SELECT last_change_id FROM task_related_links
1349 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
1350 )
1351 .bind(workspace_id)
1352 .bind(task_a_id)
1353 .bind(task_b_id)
1354 .fetch_optional(&mut *conn)
1355 .await?;
1356 ensure!(
1357 current_change_id.as_deref() == Some(forward_change_id),
1358 "error undo-state-changed task_id={task_id} field=related"
1359 );
1360 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1361 crate::operations::set_task_related_link_in_transaction(
1362 conn,
1363 &workspace,
1364 task_id,
1365 related_task_id,
1366 !linked,
1367 )
1368 .await?;
1369 Ok(CommandOutcome {
1370 task_id: Some(task_id.clone()),
1371 include_deleted: None,
1372 project_rename: None,
1373 label_rename: None,
1374 })
1375 }
1376 UndoCommand::AddEpicChild { epic_id, child_id } => {
1377 ensure!(
1378 epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1379 "error undo-state-changed task_id={child_id} field=epic"
1380 );
1381 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
1382 Ok(CommandOutcome {
1383 task_id: Some(child_id.clone()),
1384 include_deleted: None,
1385 project_rename: None,
1386 label_rename: None,
1387 })
1388 }
1389 UndoCommand::RemoveEpicChild { epic_id, child_id } => {
1390 ensure!(
1391 !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1392 "error undo-state-changed task_id={child_id} field=epic"
1393 );
1394 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
1395 Ok(CommandOutcome {
1396 task_id: Some(child_id.clone()),
1397 include_deleted: None,
1398 project_rename: None,
1399 label_rename: None,
1400 })
1401 }
1402 }
1403}
1404
1405async fn project_id_exists(
1406 conn: &mut SqliteConnection,
1407 workspace_id: &WorkspaceId,
1408 project_id: &ProjectId,
1409) -> Result<bool> {
1410 Ok(sqlx::query_scalar::<_, i64>(
1411 "SELECT count(*) FROM projects
1412 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1413 )
1414 .bind(workspace_id)
1415 .bind(project_id)
1416 .fetch_one(&mut *conn)
1417 .await?
1418 > 0)
1419}
1420
1421async fn set_task_field_in_workspace(
1422 conn: &mut SqliteConnection,
1423 workspace_id: &WorkspaceId,
1424 task_id: &crate::ids::TaskId,
1425 task_field: TaskField,
1426 value: &str,
1427) -> Result<()> {
1428 let field = task_field.as_str();
1429 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1430 crate::operations::route_recurrence_task_field(conn, &workspace, task_id, field, value).await?;
1431 if conflict_exists(conn, workspace_id, task_id, field).await? {
1432 bail!(
1433 "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
1434 task_id,
1435 field
1436 );
1437 }
1438 let base = field_version(conn, task_id, field).await?;
1439 let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
1440 let payload = if task_field.is_project() {
1441 let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
1442 apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
1443 TaskField::project_payload(workspace_id, &workspace_key, &project)
1444 } else {
1445 apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
1446 task_field.scalar_payload(workspace_id, &workspace_key, value)?
1447 };
1448 let change_id = insert_change(
1449 conn,
1450 "task",
1451 task_id,
1452 Some(field),
1453 "set_field",
1454 payload,
1455 base.as_deref(),
1456 )
1457 .await?;
1458 set_field_version(conn, task_id, field, &change_id).await?;
1459 Ok(())
1460}
1461
1462fn label_sets_equal(left: &[String], right: &[String]) -> bool {
1463 let left: BTreeSet<_> = left.iter().collect();
1464 let right: BTreeSet<_> = right.iter().collect();
1465 left == right
1466}
1467
1468fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
1469 let current_set: BTreeSet<_> = current.iter().collect();
1470 let target_set: BTreeSet<_> = target.iter().collect();
1471 let add = target
1472 .iter()
1473 .filter(|label| !current_set.contains(label))
1474 .cloned()
1475 .collect();
1476 let remove = current
1477 .iter()
1478 .filter(|label| !target_set.contains(label))
1479 .cloned()
1480 .collect();
1481 (add, remove)
1482}
1483
1484async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
1485 let server_seq =
1486 sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
1487 .bind(change_id)
1488 .fetch_optional(&mut *conn)
1489 .await?;
1490 Ok(matches!(server_seq, Some(None)))
1491}
1492
1493async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1494 for change_id in change_ids {
1495 if !change_is_unsynced(conn, change_id).await? {
1496 return Ok(false);
1497 }
1498 }
1499 Ok(true)
1500}
1501
1502async fn labels_match_create_change(
1503 conn: &mut SqliteConnection,
1504 change_id: &str,
1505 labels: &[String],
1506) -> Result<bool> {
1507 let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1508 .bind(change_id)
1509 .fetch_one(&mut *conn)
1510 .await?;
1511 let payload: serde_json::Value = serde_json::from_str(&payload)?;
1512 let payload_labels = payload
1513 .get("labels")
1514 .and_then(|labels| labels.as_array())
1515 .map(|labels| {
1516 labels
1517 .iter()
1518 .filter_map(|label| label.as_str().map(str::to_string))
1519 .collect::<Vec<_>>()
1520 })
1521 .unwrap_or_default();
1522 Ok(label_sets_equal(labels, &payload_labels))
1523}
1524
1525async fn collect_task_attachment_hashes(
1526 conn: &mut SqliteConnection,
1527 workspace_id: &WorkspaceId,
1528 task_id: &crate::ids::TaskId,
1529 affected_attachment_hashes: &mut BTreeSet<String>,
1530) -> Result<()> {
1531 let hashes: Vec<String> = sqlx::query_scalar(
1532 "SELECT DISTINCT sha256 FROM task_attachments
1533 WHERE workspace_id = ? AND task_id = ?",
1534 )
1535 .bind(workspace_id)
1536 .bind(task_id)
1537 .fetch_all(&mut *conn)
1538 .await?;
1539 affected_attachment_hashes.extend(hashes);
1540 Ok(())
1541}
1542
1543async fn hard_delete_created_task(
1544 conn: &mut SqliteConnection,
1545 workspace_id: &WorkspaceId,
1546 task_id: &crate::ids::TaskId,
1547 create_change_id: &str,
1548 attachment_change_ids: &[String],
1549 affected_attachment_hashes: &mut BTreeSet<String>,
1550) -> Result<()> {
1551 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1552 sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
1553 .bind(workspace_id)
1554 .bind(task_id)
1555 .execute(&mut *conn)
1556 .await?;
1557 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
1558 .bind(workspace_id)
1559 .bind(task_id)
1560 .execute(&mut *conn)
1561 .await?;
1562 sqlx::query("DELETE FROM task_metadata WHERE workspace_id = ? AND task_id = ?")
1563 .bind(workspace_id)
1564 .bind(task_id)
1565 .execute(&mut *conn)
1566 .await?;
1567 sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1568 .bind(task_id)
1569 .execute(&mut *conn)
1570 .await?;
1571 sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1572 .bind(workspace_id)
1573 .bind(task_id)
1574 .execute(&mut *conn)
1575 .await?;
1576 for change_id in attachment_change_ids {
1577 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1578 .bind(change_id)
1579 .execute(&mut *conn)
1580 .await?;
1581 }
1582 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1583 .bind(create_change_id)
1584 .execute(&mut *conn)
1585 .await?;
1586 Ok(())
1587}
1588
1589async fn delete_created_note(
1590 conn: &mut SqliteConnection,
1591 workspace_id: &WorkspaceId,
1592 task_id: &crate::ids::TaskId,
1593 note_id: &str,
1594 note_add_change_id: &str,
1595) -> Result<()> {
1596 let row = sqlx::query(
1597 "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1598 )
1599 .bind(workspace_id)
1600 .bind(note_id)
1601 .bind(task_id)
1602 .fetch_optional(&mut *conn)
1603 .await?;
1604 let Some(row) = row else {
1605 bail!("error undo-state-changed task_id={task_id} field=note");
1606 };
1607 let stored_change_id: String = row.get("change_id");
1608 if stored_change_id != note_add_change_id {
1609 bail!("error undo-state-changed task_id={task_id} field=note");
1610 }
1611 if !change_is_unsynced(conn, note_add_change_id).await? {
1612 bail!("error undo-state-changed task_id={task_id} field=note");
1613 }
1614 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1615 .bind(workspace_id)
1616 .bind(note_id)
1617 .bind(task_id)
1618 .execute(&mut *conn)
1619 .await?;
1620 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1621 .bind(note_add_change_id)
1622 .execute(&mut *conn)
1623 .await?;
1624 Ok(())
1625}
1626
1627async fn delete_created_project(
1628 conn: &mut SqliteConnection,
1629 workspace_id: &WorkspaceId,
1630 project_key: &str,
1631 create_change_id: &str,
1632 expected_name: &str,
1633 expected_prefix: &str,
1634) -> Result<()> {
1635 let row = sqlx::query(
1636 "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1637 )
1638 .bind(workspace_id)
1639 .bind(project_key)
1640 .fetch_optional(&mut *conn)
1641 .await?;
1642 let Some(row) = row else {
1643 bail!("error undo-state-changed project_key={project_key}");
1644 };
1645 let project_id: ProjectId = row.get("id");
1646 let name: String = row.get("name");
1647 let prefix: String = row.get("prefix");
1648 if name != expected_name || prefix != expected_prefix {
1649 bail!("error undo-state-changed project_key={project_key}");
1650 }
1651 if !change_is_unsynced(conn, create_change_id).await? {
1652 bail!("error undo-state-changed project_key={project_key}");
1653 }
1654 let task_refs: i64 =
1655 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1656 .bind(workspace_id)
1657 .bind(&project_id)
1658 .fetch_one(&mut *conn)
1659 .await?;
1660 if task_refs > 0 {
1661 bail!("error undo-state-changed project_key={project_key}");
1662 }
1663 let path_refs: i64 = sqlx::query_scalar(
1664 "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1665 )
1666 .bind(workspace_id)
1667 .bind(&project_id)
1668 .fetch_one(&mut *conn)
1669 .await?;
1670 if path_refs > 0 {
1671 bail!("error undo-state-changed project_key={project_key}");
1672 }
1673 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1674 .bind(workspace_id)
1675 .bind(project_key)
1676 .execute(&mut *conn)
1677 .await?;
1678 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1679 .bind(create_change_id)
1680 .execute(&mut *conn)
1681 .await?;
1682 Ok(())
1683}
1684
1685async fn set_project_metadata_for_undo(
1686 conn: &mut SqliteConnection,
1687 workspace_id: &WorkspaceId,
1688 project_id: &ProjectId,
1689 before: ProjectMetadata<'_>,
1690 after: ProjectMetadata<'_>,
1691) -> Result<()> {
1692 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1693 let row = sqlx::query(
1694 "SELECT key, name, prefix
1695 FROM projects
1696 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1697 )
1698 .bind(workspace_id)
1699 .bind(project_id)
1700 .fetch_optional(&mut *conn)
1701 .await?;
1702 let Some(row) = row else {
1703 bail!("error undo-state-changed project_id={project_id}");
1704 };
1705 let key: String = row.get("key");
1706 let name: String = row.get("name");
1707 let prefix: String = row.get("prefix");
1708 if key != after.key || name != after.name || prefix != after.prefix {
1709 bail!("error undo-state-changed project_id={project_id}");
1710 }
1711 let key_refs: i64 = sqlx::query_scalar(
1712 "SELECT count(*) FROM projects
1713 WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1714 )
1715 .bind(workspace_id)
1716 .bind(before.key)
1717 .bind(project_id)
1718 .fetch_one(&mut *conn)
1719 .await?;
1720 if key_refs > 0 {
1721 bail!("error undo-state-changed project_id={project_id}");
1722 }
1723 let prefix_refs: i64 = sqlx::query_scalar(
1724 "SELECT count(*) FROM projects
1725 WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1726 )
1727 .bind(workspace_id)
1728 .bind(before.prefix)
1729 .bind(project_id)
1730 .fetch_one(&mut *conn)
1731 .await?;
1732 if prefix_refs > 0 {
1733 bail!("error undo-state-changed project_id={project_id}");
1734 }
1735 set_project_metadata(conn, &workspace, project_id, before, false).await?;
1736 insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1737 Ok(())
1738}
1739
1740async fn delete_created_label(
1741 conn: &mut SqliteConnection,
1742 workspace_id: &WorkspaceId,
1743 label: &str,
1744 create_change_id: &str,
1745) -> Result<()> {
1746 let exists: i64 =
1747 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1748 .bind(workspace_id)
1749 .bind(label)
1750 .fetch_one(&mut *conn)
1751 .await?;
1752 if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1753 bail!("error undo-state-changed label={label}");
1754 }
1755 let task_refs: i64 =
1756 sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1757 .bind(workspace_id)
1758 .bind(label)
1759 .fetch_one(&mut *conn)
1760 .await?;
1761 let series_refs: i64 = sqlx::query_scalar(
1762 "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
1763 )
1764 .bind(workspace_id)
1765 .bind(label)
1766 .fetch_one(&mut *conn)
1767 .await?;
1768 if task_refs > 0 || series_refs > 0 {
1769 bail!("error undo-state-changed label={label}");
1770 }
1771 sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1772 .bind(workspace_id)
1773 .bind(label)
1774 .execute(&mut *conn)
1775 .await?;
1776 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1777 .bind(create_change_id)
1778 .execute(&mut *conn)
1779 .await?;
1780 Ok(())
1781}
1782
1783async fn restore_deleted_label(
1784 conn: &mut SqliteConnection,
1785 workspace_id: &WorkspaceId,
1786 name: &str,
1787 created_at: &str,
1788 task_ids: &[crate::ids::TaskId],
1789 series_ids: &[crate::recurrence::RecurrenceSeriesId],
1790) -> Result<()> {
1791 let exists: i64 =
1792 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1793 .bind(workspace_id)
1794 .bind(name)
1795 .fetch_one(&mut *conn)
1796 .await?;
1797 if exists > 0 {
1798 bail!("error undo-state-changed label={name}");
1799 }
1800 sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
1801 .bind(workspace_id)
1802 .bind(name)
1803 .bind(created_at)
1804 .execute(&mut *conn)
1805 .await?;
1806 for task_id in task_ids {
1807 let inserted = sqlx::query(
1808 "INSERT INTO task_labels(workspace_id, task_id, label)
1809 SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
1810 )
1811 .bind(workspace_id)
1812 .bind(name)
1813 .bind(workspace_id)
1814 .bind(task_id)
1815 .execute(&mut *conn)
1816 .await?;
1817 if inserted.rows_affected() != 1 {
1818 bail!("error undo-state-changed label={name} task_id={task_id}");
1819 }
1820 }
1821 for series_id in series_ids {
1822 let inserted = sqlx::query(
1823 "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
1824 SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1825 )
1826 .bind(workspace_id)
1827 .bind(name)
1828 .bind(workspace_id)
1829 .bind(series_id)
1830 .execute(&mut *conn)
1831 .await?;
1832 if inserted.rows_affected() != 1 {
1833 bail!("error undo-state-changed label={name} series_id={series_id}");
1834 }
1835 }
1836 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1837 append_change(
1838 conn,
1839 ChangeEntity::Label,
1840 name,
1841 None,
1842 op_type::LABEL_RESTORE,
1843 ChangePayload::workspace(&workspace)
1844 .set("name", name)
1845 .set("created_at", created_at)
1846 .set("task_ids", task_ids)
1847 .set("series_ids", series_ids)
1848 .set("restored_at", now()),
1849 )
1850 .await?;
1851 Ok(())
1852}
1853
1854async fn dependency_task_exists(
1855 conn: &mut SqliteConnection,
1856 workspace_id: &WorkspaceId,
1857 task_id: &crate::ids::TaskId,
1858) -> Result<bool> {
1859 Ok(
1860 sqlx::query_scalar::<_, i64>(
1861 "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
1862 )
1863 .bind(workspace_id)
1864 .bind(task_id)
1865 .fetch_one(&mut *conn)
1866 .await?
1867 > 0,
1868 )
1869}
1870
1871async fn dependency_edge_exists(
1872 conn: &mut SqliteConnection,
1873 workspace_id: &WorkspaceId,
1874 task_id: &crate::ids::TaskId,
1875 depends_on_task_id: &crate::ids::TaskId,
1876) -> Result<bool> {
1877 ensure!(
1878 dependency_task_exists(conn, workspace_id, task_id).await?
1879 && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
1880 "error undo-state-changed task_id={task_id} field=dependency"
1881 );
1882 Ok(sqlx::query_scalar::<_, i64>(
1883 "SELECT count(*) FROM task_dependencies
1884 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1885 )
1886 .bind(workspace_id)
1887 .bind(task_id)
1888 .bind(depends_on_task_id)
1889 .fetch_one(&mut *conn)
1890 .await?
1891 > 0)
1892}
1893
1894async fn epic_edge_exists(
1895 conn: &mut SqliteConnection,
1896 workspace_id: &WorkspaceId,
1897 epic_id: &crate::ids::TaskId,
1898 child_id: &crate::ids::TaskId,
1899) -> Result<bool> {
1900 Ok(sqlx::query_scalar::<_, i64>(
1901 "SELECT count(*) FROM task_epic_links
1902 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
1903 )
1904 .bind(workspace_id)
1905 .bind(epic_id)
1906 .bind(child_id)
1907 .fetch_one(&mut *conn)
1908 .await?
1909 > 0)
1910}
1911
1912async fn set_epic_edge_for_undo(
1913 conn: &mut SqliteConnection,
1914 workspace_id: &WorkspaceId,
1915 epic_id: &crate::ids::TaskId,
1916 child_id: &crate::ids::TaskId,
1917 linked: bool,
1918) -> Result<()> {
1919 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1920 let outcome = if linked {
1921 crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
1922 .await?
1923 } else {
1924 crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
1925 .await?
1926 };
1927 ensure!(
1928 outcome.changed,
1929 "error undo-state-changed task_id={child_id} field=epic"
1930 );
1931 Ok(())
1932}
1933
1934async fn add_dependency_for_undo(
1935 conn: &mut SqliteConnection,
1936 workspace_id: &WorkspaceId,
1937 task_id: &crate::ids::TaskId,
1938 depends_on_task_id: &crate::ids::TaskId,
1939) -> Result<()> {
1940 let created_at = now();
1941 sqlx::query(
1942 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
1943 VALUES (?, ?, ?, ?)",
1944 )
1945 .bind(workspace_id)
1946 .bind(task_id)
1947 .bind(depends_on_task_id)
1948 .bind(&created_at)
1949 .execute(&mut *conn)
1950 .await?;
1951 append_dependency_change(
1952 conn,
1953 workspace_id,
1954 task_id,
1955 depends_on_task_id,
1956 crate::change_log::op_type::DEPENDENCY_ADD,
1957 )
1958 .await
1959}
1960
1961async fn remove_dependency_for_undo(
1962 conn: &mut SqliteConnection,
1963 workspace_id: &WorkspaceId,
1964 task_id: &crate::ids::TaskId,
1965 depends_on_task_id: &crate::ids::TaskId,
1966) -> Result<()> {
1967 sqlx::query(
1968 "DELETE FROM task_dependencies
1969 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1970 )
1971 .bind(workspace_id)
1972 .bind(task_id)
1973 .bind(depends_on_task_id)
1974 .execute(&mut *conn)
1975 .await?;
1976 append_dependency_change(
1977 conn,
1978 workspace_id,
1979 task_id,
1980 depends_on_task_id,
1981 crate::change_log::op_type::DEPENDENCY_REMOVE,
1982 )
1983 .await
1984}
1985
1986async fn append_dependency_change(
1987 conn: &mut SqliteConnection,
1988 workspace_id: &WorkspaceId,
1989 task_id: &crate::ids::TaskId,
1990 depends_on_task_id: &crate::ids::TaskId,
1991 op_type: &'static str,
1992) -> Result<()> {
1993 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1994 crate::change_log::append_change(
1995 conn,
1996 crate::change_log::ChangeEntity::Task,
1997 task_id,
1998 Some("dependencies"),
1999 op_type,
2000 crate::change_log::ChangePayload::workspace(&workspace)
2001 .set("depends_on_task_id", depends_on_task_id.to_string()),
2002 )
2003 .await?;
2004 Ok(())
2005}
2006
2007#[cfg(test)]
2008mod presentation_tests {
2009 use super::*;
2010
2011 fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2012 UndoCommand::SetTaskField {
2013 task_id: task_id.clone(),
2014 field: field.to_string(),
2015 before: "before".to_string(),
2016 after: "after".to_string(),
2017 queue_activity_before: None,
2018 queue_activity_after: None,
2019 }
2020 }
2021
2022 #[test]
2023 fn classifier_derives_operations_and_unique_task_scope() {
2024 let first = crate::ids::TaskId::new();
2025 let second = crate::ids::TaskId::new();
2026 let cases = [
2027 (vec![set_field(&first, "priority")], "priority change", 1),
2028 (
2029 vec![
2030 set_field(&first, "status"),
2031 set_field(&first, "status"),
2032 set_field(&second, "status"),
2033 ],
2034 "status change",
2035 2,
2036 ),
2037 (
2038 vec![UndoCommand::SetTaskMetadata {
2039 task_id: first.clone(),
2040 field_id: crate::ids::MetadataFieldId::new(),
2041 before: None,
2042 after: Some("value".to_string()),
2043 }],
2044 "metadata change",
2045 1,
2046 ),
2047 ];
2048
2049 for (commands, operation, task_count) in cases {
2050 let presentation = classify_undo_commands("entry".to_string(), &commands);
2051 assert_eq!(presentation.operation, operation);
2052 assert_eq!(presentation.task_ids.len(), task_count);
2053 }
2054 }
2055
2056 #[test]
2057 fn classifier_describes_related_link_changes_on_the_initiating_task() {
2058 let task_id = crate::ids::TaskId::new();
2059 let related_task_id = crate::ids::TaskId::new();
2060
2061 for (linked, operation) in [
2062 (true, "related link addition"),
2063 (false, "related link removal"),
2064 ] {
2065 let presentation = classify_undo_commands(
2066 "entry".to_string(),
2067 &[UndoCommand::SetTaskRelatedLink {
2068 task_id: task_id.clone(),
2069 related_task_id: related_task_id.clone(),
2070 forward_change_id: "change".to_string(),
2071 linked,
2072 }],
2073 );
2074
2075 assert_eq!(presentation.operation, operation);
2076 assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2077 }
2078 }
2079
2080 #[test]
2081 fn classifier_uses_primary_intent_for_mixed_payloads() {
2082 let task_id = crate::ids::TaskId::new();
2083 let project_id = ProjectId::new();
2084 let creation = UndoCommand::DeleteCreatedTask {
2085 task_id: task_id.clone(),
2086 create_change_id: None,
2087 expected: TaskUndoSnapshot {
2088 title: "title".to_string(),
2089 description: String::new(),
2090 project_id,
2091 project_key: "project".to_string(),
2092 status: "inbox".to_string(),
2093 priority: "none".to_string(),
2094 queue_activity_at: String::new(),
2095 available_at: String::new(),
2096 due_on: String::new(),
2097 deleted: false,
2098 is_epic: false,
2099 labels: vec!["new".to_string()],
2100 metadata: BTreeMap::new(),
2101 },
2102 attachment_ids: Vec::new(),
2103 attachment_change_ids: Vec::new(),
2104 };
2105 let cleanup = UndoCommand::DeleteCreatedLabel {
2106 label: "new".to_string(),
2107 create_change_id: "change".to_string(),
2108 };
2109 let presentation =
2110 classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2111 assert_eq!(presentation.operation, "task creation");
2112
2113 let label_change = UndoCommand::SetTaskLabels {
2114 task_id,
2115 before: Vec::new(),
2116 after: vec!["new".to_string()],
2117 };
2118 let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2119 assert_eq!(presentation.operation, "label change");
2120
2121 let epic_id = crate::ids::TaskId::new();
2122 let child_id = crate::ids::TaskId::new();
2123 let presentation = classify_undo_commands(
2124 "entry".to_string(),
2125 &[
2126 set_field(&epic_id, "is_epic"),
2127 UndoCommand::AddEpicChild { epic_id, child_id },
2128 ],
2129 );
2130 assert_eq!(presentation.operation, "epic membership addition");
2131 }
2132}