1use super::creation::append_created_label_undo_commands;
2use super::{TaskMutationOutcome, TaskMutationReport, TaskOutcome, TaskUpdate, TaskUpdateOutcome};
3use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
4use crate::choices::{TaskPriority, TaskStatus};
5use crate::db::{Database, begin_immediate};
6use crate::ids::{TaskId, WorkspaceId, now};
7use crate::labels::{
8 CreatedLabel, resolve_labels_in_workspace, resolve_or_create_labels_in_workspace,
9};
10use crate::mutation::{set_task_field, set_task_project};
11use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
12use crate::projects::resolve_or_create_project_in_workspace;
13use crate::refs::get_task_in_workspace;
14use crate::types::Task;
15use crate::undo::{
16 TaskUndoSnapshot, UndoCommand, UndoContext, UndoPayload, record_tui_undo, task_snapshot,
17};
18use crate::workspaces::Workspace;
19use anyhow::{Result, bail};
20use sqlx::SqliteConnection;
21use std::collections::BTreeSet;
22use tracing::info;
23
24#[derive(Clone, Copy, PartialEq, Eq)]
25enum TaskMutationDetail {
26 Report,
27 Compact,
28}
29
30struct TaskMutationExecutionOutcome {
31 task: Task,
32 before: Option<TaskUndoSnapshot>,
33 after: Option<TaskUndoSnapshot>,
34 changed: bool,
35}
36
37impl Database {
38 pub async fn update_task(
39 &self,
40 workspace: &Workspace,
41 task_id: &TaskId,
42 update: TaskUpdate,
43 ) -> Result<TaskUpdateOutcome> {
44 let outcomes = self
45 .mutate_tasks_owned(
46 workspace,
47 vec![(task_id.clone(), update)],
48 UndoContext::None,
49 TaskMutationDetail::Compact,
50 )
51 .await?;
52 let outcome = outcomes.into_iter().next().unwrap();
53 Ok(TaskUpdateOutcome {
54 task: outcome.task,
55 changed: outcome.changed,
56 })
57 }
58
59 pub async fn update_tasks(
60 &self,
61 workspace: &Workspace,
62 updates: Vec<(TaskId, TaskUpdate)>,
63 ) -> Result<Vec<TaskUpdateOutcome>> {
64 Ok(self
65 .mutate_tasks_owned(
66 workspace,
67 updates,
68 UndoContext::None,
69 TaskMutationDetail::Compact,
70 )
71 .await?
72 .into_iter()
73 .map(|outcome| TaskUpdateOutcome {
74 task: outcome.task,
75 changed: outcome.changed,
76 })
77 .collect())
78 }
79
80 pub async fn mutate_tasks(
81 &self,
82 workspace: &Workspace,
83 updates: Vec<(TaskId, TaskUpdate)>,
84 undo: UndoContext,
85 ) -> Result<TaskMutationReport> {
86 let outcomes = self
87 .mutate_tasks_owned(workspace, updates, undo, TaskMutationDetail::Report)
88 .await?;
89 Ok(TaskMutationReport {
90 outcomes: outcomes
91 .into_iter()
92 .map(|outcome| TaskMutationOutcome {
93 task: outcome.task,
94 before: outcome.before.expect("report mutation before snapshot"),
95 after: outcome.after.expect("report mutation after snapshot"),
96 changed: outcome.changed,
97 })
98 .collect(),
99 })
100 }
101
102 async fn mutate_tasks_owned(
103 &self,
104 workspace: &Workspace,
105 updates: Vec<(TaskId, TaskUpdate)>,
106 undo: UndoContext,
107 detail: TaskMutationDetail,
108 ) -> Result<Vec<TaskMutationExecutionOutcome>> {
109 for (_, update) in &updates {
110 validate_task_update(update)?;
111 }
112 if updates.is_empty() {
113 return Ok(Vec::new());
114 }
115 if detail == TaskMutationDetail::Compact && !matches!(&undo, UndoContext::None) {
116 bail!("compact task mutations cannot record undo");
117 }
118
119 let mut conn = self.acquire_writer().await?;
120 let mut tx = begin_immediate(&mut conn).await?;
121 let mut outcomes = Vec::with_capacity(updates.len());
122 let mut affected_attachment_hashes = BTreeSet::new();
123 let mut undo_commands = Vec::new();
124 let mut created_labels = Vec::new();
125 for (task_id, update) in &updates {
126 let before =
127 if detail == TaskMutationDetail::Report || task_update_requires_snapshot(update) {
128 Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
129 } else {
130 None
131 };
132 let update = match before.as_ref() {
133 Some(before) => materialize_task_update(update, before)?,
134 None => update.clone(),
135 };
136 let (changed, task_created_labels) = apply_task_update(
137 &mut tx,
138 workspace,
139 task_id,
140 &update,
141 &mut affected_attachment_hashes,
142 )
143 .await?;
144 created_labels.extend(task_created_labels);
145 let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
146 let after = if detail == TaskMutationDetail::Report {
147 Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
148 } else {
149 None
150 };
151 if let (Some(before), Some(after)) = (&before, &after) {
152 append_task_undo_commands(task_id, before, after, &mut undo_commands);
153 }
154 outcomes.push(TaskMutationExecutionOutcome {
155 task,
156 changed,
157 before,
158 after,
159 });
160 }
161 let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
162 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
163 &mut tx,
164 &affected_attachment_hashes,
165 &crate::attachments::lifecycle::SystemClock,
166 )
167 .await?;
168 append_created_label_undo_commands(&mut undo_commands, &created_labels);
169 if matches!(undo, UndoContext::Tui) {
170 record_tui_undo(
171 &mut tx,
172 &workspace.id,
173 UndoPayload {
174 commands: undo_commands,
175 },
176 )
177 .await?;
178 }
179 tx.commit().await?;
180
181 for outcome in &outcomes {
182 info!(task_id = %outcome.task.id, changed = outcome.changed, "task updated");
183 }
184 Ok(outcomes)
185 }
186
187 pub async fn set_task_deleted(
188 &self,
189 workspace: &Workspace,
190 task_id: &TaskId,
191 deleted: bool,
192 ) -> Result<TaskOutcome> {
193 let outcome = self
194 .update_task(
195 workspace,
196 task_id,
197 TaskUpdate {
198 deleted: Some(deleted),
199 ..TaskUpdate::default()
200 },
201 )
202 .await?;
203 Ok(TaskOutcome {
204 task: outcome.task,
205 create_change_id: None,
206 attachment_change_ids: Vec::new(),
207 undo_snapshot: None,
208 })
209 }
210}
211
212#[cfg(any(test, feature = "test-support"))]
213pub(crate) async fn update_task(
214 conn: &mut SqliteConnection,
215 workspace: &Workspace,
216 task_id: &crate::ids::TaskId,
217 update: TaskUpdate,
218) -> Result<TaskUpdateOutcome> {
219 validate_task_update(&update)?;
220 let mut tx = begin_immediate(conn).await?;
221 let mut affected_attachment_hashes = BTreeSet::new();
222 let (changed, _) = apply_task_update(
223 &mut tx,
224 workspace,
225 task_id,
226 &update,
227 &mut affected_attachment_hashes,
228 )
229 .await?;
230 let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
231 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
232 &mut tx,
233 &affected_attachment_hashes,
234 &crate::attachments::lifecycle::SystemClock,
235 )
236 .await?;
237 tx.commit().await?;
238 Ok(TaskUpdateOutcome {
239 task: get_task_in_workspace(conn, workspace, task_id).await?,
240 changed,
241 })
242}
243
244fn append_task_undo_commands(
245 task_id: &TaskId,
246 before_snapshot: &TaskUndoSnapshot,
247 after_snapshot: &TaskUndoSnapshot,
248 commands: &mut Vec<UndoCommand>,
249) {
250 let before = before_snapshot;
251 let after = after_snapshot;
252 let fields = [
253 ("title", before.title.as_str(), after.title.as_str()),
254 (
255 "description",
256 before.description.as_str(),
257 after.description.as_str(),
258 ),
259 (
260 "project",
261 before.project_id.as_str(),
262 after.project_id.as_str(),
263 ),
264 ("status", before.status.as_str(), after.status.as_str()),
265 (
266 "priority",
267 before.priority.as_str(),
268 after.priority.as_str(),
269 ),
270 (
271 "available_at",
272 before.available_at.as_str(),
273 after.available_at.as_str(),
274 ),
275 ("due_on", before.due_on.as_str(), after.due_on.as_str()),
276 ];
277 for (field, before, after) in fields {
278 if before != after {
279 commands.push(UndoCommand::SetTaskField {
280 task_id: task_id.clone(),
281 field: field.to_string(),
282 before: before.to_string(),
283 after: after.to_string(),
284 queue_activity_before: (before_snapshot.queue_activity_at
285 != after_snapshot.queue_activity_at)
286 .then(|| before_snapshot.queue_activity_at.clone()),
287 queue_activity_after: (before_snapshot.queue_activity_at
288 != after_snapshot.queue_activity_at)
289 .then(|| after_snapshot.queue_activity_at.clone()),
290 });
291 }
292 }
293 let before_deleted = if before.deleted { "1" } else { "0" };
294 let after_deleted = if after.deleted { "1" } else { "0" };
295 if before_deleted != after_deleted {
296 commands.push(UndoCommand::SetTaskField {
297 task_id: task_id.clone(),
298 field: "deleted".to_string(),
299 before: before_deleted.to_string(),
300 after: after_deleted.to_string(),
301 queue_activity_before: (before.queue_activity_at != after.queue_activity_at)
302 .then(|| before.queue_activity_at.clone()),
303 queue_activity_after: (before.queue_activity_at != after.queue_activity_at)
304 .then(|| after.queue_activity_at.clone()),
305 });
306 }
307 let before_is_epic = if before.is_epic { "1" } else { "0" };
308 let after_is_epic = if after.is_epic { "1" } else { "0" };
309 if before_is_epic != after_is_epic {
310 commands.push(UndoCommand::SetTaskField {
311 task_id: task_id.clone(),
312 field: "is_epic".to_string(),
313 before: before_is_epic.to_string(),
314 after: after_is_epic.to_string(),
315 queue_activity_before: None,
316 queue_activity_after: None,
317 });
318 }
319 if before.labels != after.labels {
320 commands.push(UndoCommand::SetTaskLabels {
321 task_id: task_id.clone(),
322 before: before.labels.clone(),
323 after: after.labels.clone(),
324 });
325 }
326 let metadata_fields = before
327 .metadata
328 .keys()
329 .chain(after.metadata.keys())
330 .collect::<BTreeSet<_>>();
331 for field_id in metadata_fields {
332 let before_value = before.metadata.get(field_id);
333 let after_value = after.metadata.get(field_id);
334 if before_value != after_value {
335 commands.push(UndoCommand::SetTaskMetadata {
336 task_id: task_id.clone(),
337 field_id: field_id
338 .parse()
339 .expect("task snapshots contain valid metadata field IDs"),
340 before: before_value.cloned(),
341 after: after_value.cloned(),
342 });
343 }
344 }
345}
346
347fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
348 let index = TaskPriority::ALL
349 .iter()
350 .position(|candidate| *candidate == priority)
351 .unwrap_or(0);
352 let next = if reverse {
353 (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
354 } else {
355 (index + 1) % TaskPriority::ALL.len()
356 };
357 TaskPriority::ALL[next]
358}
359
360fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
361 update.cycle_priority.is_some() || update.label_selection.is_some()
362}
363
364fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
365 let mut update = update.clone();
366 if let Some(reverse) = update.cycle_priority.take() {
367 let priority = TaskPriority::parse(&before.priority)?;
368 update.priority = Some(cycled_priority(priority, reverse).to_string());
369 }
370 if let Some(selection) = update.label_selection.take() {
371 let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
372 let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
373 update.add_labels = selection
374 .selected
375 .into_iter()
376 .filter(|label| !before.labels.contains(label))
377 .collect();
378 update.remove_labels = before
379 .labels
380 .iter()
381 .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
382 .cloned()
383 .collect();
384 }
385 Ok(update)
386}
387
388pub(super) fn validate_task_update(update: &TaskUpdate) -> Result<()> {
389 crate::metadata::validate_metadata_update(&update.set_metadata, &update.remove_metadata)?;
390 if update.priority.is_some() && update.cycle_priority.is_some() {
391 bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
392 }
393 if update.label_selection.is_some()
394 && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
395 {
396 bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
397 }
398 if let Some(status) = update.status.as_deref() {
399 TaskStatus::parse(status)?;
400 }
401 if let Some(priority) = update.priority.as_deref() {
402 TaskPriority::parse(priority)?;
403 }
404 if let Some(Some(available_at)) = update.available_at.as_ref() {
405 crate::time_validation::validate_available_at_value(available_at)?;
406 }
407 if let Some(Some(due_on)) = update.due_on.as_ref() {
408 crate::time_validation::validate_due_on_value(due_on)?;
409 }
410 Ok(())
411}
412
413pub(super) async fn apply_task_update(
414 conn: &mut SqliteConnection,
415 workspace: &Workspace,
416 task_id: &crate::ids::TaskId,
417 update: &TaskUpdate,
418 affected_attachment_hashes: &mut BTreeSet<String>,
419) -> Result<(bool, Vec<CreatedLabel>)> {
420 let created_labels = if update.create_missing_labels {
421 resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
422 .await?
423 .created
424 } else {
425 Vec::new()
426 };
427 let promote_inbox = if update
428 .status
429 .as_deref()
430 .is_none_or(|status| status == TaskStatus::Inbox.as_str())
431 && update.priority.as_deref().is_some_and(|priority| {
432 TaskPriority::parse(priority).is_ok_and(TaskPriority::promotes_inbox_to_todo)
433 }) {
434 get_task_in_workspace(conn, workspace, task_id)
435 .await?
436 .status
437 == TaskStatus::Inbox
438 } else {
439 false
440 };
441 let mut changed = false;
442 if let Some(title) = update.title.as_deref() {
443 changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
444 }
445 if let Some(description) = update.description.as_deref() {
446 changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
447 }
448 if let Some(project) = update.project.as_deref() {
449 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
450 changed |= set_task_project(conn, workspace, task_id, &project).await?;
451 }
452 if promote_inbox {
453 changed |= update_task_field(conn, workspace, task_id, "status", "todo").await?;
454 } else if let Some(status) = update.status.as_deref() {
455 changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
456 }
457 if let Some(priority) = update.priority.as_deref() {
458 changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
459 }
460 if let Some(available_at) = update.available_at.as_ref() {
461 changed |= update_task_field(
462 conn,
463 workspace,
464 task_id,
465 "available_at",
466 available_at.as_deref().unwrap_or(""),
467 )
468 .await?;
469 }
470 if let Some(due_on) = update.due_on.as_ref() {
471 changed |= update_task_field(
472 conn,
473 workspace,
474 task_id,
475 "due_on",
476 due_on.as_deref().unwrap_or(""),
477 )
478 .await?;
479 }
480 if update.deleted.is_some() {
481 let attachment_hashes: Vec<String> = sqlx::query_scalar(
482 "SELECT DISTINCT sha256 FROM task_attachments
483 WHERE workspace_id = ? AND task_id = ?",
484 )
485 .bind(&workspace.id)
486 .bind(task_id)
487 .fetch_all(&mut *conn)
488 .await?;
489 affected_attachment_hashes.extend(attachment_hashes);
490 }
491 if let Some(deleted) = update.deleted {
492 changed |= update_task_field(
493 conn,
494 workspace,
495 task_id,
496 "deleted",
497 if deleted { "1" } else { "0" },
498 )
499 .await?;
500 }
501 if let Some(is_epic) = update.is_epic {
502 if !is_epic {
503 let task = get_task_in_workspace(conn, workspace, task_id).await?;
504 if crate::operations::epics::task_has_epic_children(conn, &task.workspace_id, task_id)
505 .await?
506 {
507 bail!("error epic-has-children task_id={task_id}");
508 }
509 }
510 changed |= update_task_field(
511 conn,
512 workspace,
513 task_id,
514 "is_epic",
515 if is_epic { "1" } else { "0" },
516 )
517 .await?;
518 }
519 changed |= update_task_labels_in_workspace(
520 conn,
521 &workspace.id,
522 task_id,
523 &update.add_labels,
524 &update.remove_labels,
525 )
526 .await?;
527 for (id, key) in &update.require_metadata_fields {
528 crate::metadata::require_metadata_field(conn, &workspace.id, id, key).await?;
529 }
530 crate::metadata::validate_task_metadata_result(
531 conn,
532 &workspace.id,
533 task_id,
534 &update.set_metadata,
535 &update.remove_metadata,
536 )
537 .await?;
538 for input in &update.set_metadata {
539 changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
540 }
541 for key in &update.remove_metadata {
542 changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
543 }
544 Ok((changed, created_labels))
545}
546
547pub(crate) async fn update_task_field(
548 conn: &mut SqliteConnection,
549 workspace: &Workspace,
550 task_id: &crate::ids::TaskId,
551 field: &str,
552 value: &str,
553) -> Result<bool> {
554 set_task_field(conn, workspace, task_id, field, value).await
555}
556
557pub async fn update_task_labels_in_workspace(
558 conn: &mut SqliteConnection,
559 workspace_id: &WorkspaceId,
560 task_id: &crate::ids::TaskId,
561 add_labels: &[String],
562 remove_labels: &[String],
563) -> Result<bool> {
564 let mutation_at = now();
565 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
566 crate::operations::route_recurrence_task_mutation(
567 conn,
568 &workspace,
569 task_id,
570 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Labels),
571 &mutation_at,
572 )
573 .await?;
574 let mut changed = false;
575 for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
576 let rows_affected = sqlx::query(
577 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
578 )
579 .bind(&workspace.id)
580 .bind(task_id)
581 .bind(&label)
582 .execute(&mut *conn)
583 .await?
584 .rows_affected();
585 if rows_affected > 0 {
586 append_change(
587 conn,
588 ChangeEntity::Task,
589 task_id,
590 Some("labels"),
591 op_type::LABEL_ADD,
592 ChangePayload::workspace(&workspace).set("label", label),
593 )
594 .await?;
595 changed = true;
596 }
597 }
598 for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
599 let rows_affected = sqlx::query(
600 "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
601 )
602 .bind(&workspace.id)
603 .bind(task_id)
604 .bind(&label)
605 .execute(&mut *conn)
606 .await?
607 .rows_affected();
608 if rows_affected > 0 {
609 append_change(
610 conn,
611 ChangeEntity::Task,
612 task_id,
613 Some("labels"),
614 op_type::LABEL_REMOVE,
615 ChangePayload::workspace(&workspace).set("label", label),
616 )
617 .await?;
618 changed = true;
619 }
620 }
621 if changed {
622 info!(
623 task_id = %task_id,
624 added = add_labels.len(),
625 removed = remove_labels.len(),
626 "task labels changed"
627 );
628 }
629 Ok(changed)
630}