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
244pub(super) fn 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) fn update_requests_inbox_promotion(update: &TaskUpdate) -> bool {
414 update
415 .status
416 .as_deref()
417 .is_none_or(|status| status == TaskStatus::Inbox.as_str())
418 && update.priority.as_deref().is_some_and(|priority| {
419 TaskPriority::parse(priority).is_ok_and(TaskPriority::promotes_inbox_to_todo)
420 })
421}
422
423pub(super) async fn apply_task_update(
424 conn: &mut SqliteConnection,
425 workspace: &Workspace,
426 task_id: &crate::ids::TaskId,
427 update: &TaskUpdate,
428 affected_attachment_hashes: &mut BTreeSet<String>,
429) -> Result<(bool, Vec<CreatedLabel>)> {
430 let created_labels = if update.create_missing_labels {
431 resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
432 .await?
433 .created
434 } else {
435 Vec::new()
436 };
437 let promote_inbox = if update_requests_inbox_promotion(update) {
438 get_task_in_workspace(conn, workspace, task_id)
439 .await?
440 .status
441 == TaskStatus::Inbox
442 } else {
443 false
444 };
445 let mut changed = false;
446 if let Some(title) = update.title.as_deref() {
447 changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
448 }
449 if let Some(description) = update.description.as_deref() {
450 changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
451 }
452 if let Some(project) = update.project.as_deref() {
453 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
454 changed |= set_task_project(conn, workspace, task_id, &project).await?;
455 }
456 if promote_inbox {
457 changed |= update_task_field(conn, workspace, task_id, "status", "todo").await?;
458 } else if let Some(status) = update.status.as_deref() {
459 changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
460 }
461 if let Some(priority) = update.priority.as_deref() {
462 changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
463 }
464 if let Some(available_at) = update.available_at.as_ref() {
465 changed |= update_task_field(
466 conn,
467 workspace,
468 task_id,
469 "available_at",
470 available_at.as_deref().unwrap_or(""),
471 )
472 .await?;
473 }
474 if let Some(due_on) = update.due_on.as_ref() {
475 changed |= update_task_field(
476 conn,
477 workspace,
478 task_id,
479 "due_on",
480 due_on.as_deref().unwrap_or(""),
481 )
482 .await?;
483 }
484 if update.deleted.is_some() {
485 let attachment_hashes: Vec<String> = sqlx::query_scalar(
486 "SELECT DISTINCT sha256 FROM task_attachments
487 WHERE workspace_id = ? AND task_id = ?",
488 )
489 .bind(&workspace.id)
490 .bind(task_id)
491 .fetch_all(&mut *conn)
492 .await?;
493 affected_attachment_hashes.extend(attachment_hashes);
494 }
495 if let Some(deleted) = update.deleted {
496 changed |= update_task_field(
497 conn,
498 workspace,
499 task_id,
500 "deleted",
501 if deleted { "1" } else { "0" },
502 )
503 .await?;
504 }
505 if let Some(is_epic) = update.is_epic {
506 if !is_epic {
507 let task = get_task_in_workspace(conn, workspace, task_id).await?;
508 if crate::operations::epics::task_has_epic_children(conn, &task.workspace_id, task_id)
509 .await?
510 {
511 bail!("error epic-has-children task_id={task_id}");
512 }
513 }
514 changed |= update_task_field(
515 conn,
516 workspace,
517 task_id,
518 "is_epic",
519 if is_epic { "1" } else { "0" },
520 )
521 .await?;
522 }
523 changed |= update_task_labels_in_workspace(
524 conn,
525 &workspace.id,
526 task_id,
527 &update.add_labels,
528 &update.remove_labels,
529 )
530 .await?;
531 for (id, key) in &update.require_metadata_fields {
532 crate::metadata::require_metadata_field(conn, &workspace.id, id, key).await?;
533 }
534 crate::metadata::validate_task_metadata_result(
535 conn,
536 &workspace.id,
537 task_id,
538 &update.set_metadata,
539 &update.remove_metadata,
540 )
541 .await?;
542 for input in &update.set_metadata {
543 changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
544 }
545 for key in &update.remove_metadata {
546 changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
547 }
548 Ok((changed, created_labels))
549}
550
551pub(crate) async fn update_task_field(
552 conn: &mut SqliteConnection,
553 workspace: &Workspace,
554 task_id: &crate::ids::TaskId,
555 field: &str,
556 value: &str,
557) -> Result<bool> {
558 set_task_field(conn, workspace, task_id, field, value).await
559}
560
561pub async fn update_task_labels_in_workspace(
562 conn: &mut SqliteConnection,
563 workspace_id: &WorkspaceId,
564 task_id: &crate::ids::TaskId,
565 add_labels: &[String],
566 remove_labels: &[String],
567) -> Result<bool> {
568 let mutation_at = now();
569 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
570 crate::operations::route_recurrence_task_mutation(
571 conn,
572 &workspace,
573 task_id,
574 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Labels),
575 &mutation_at,
576 )
577 .await?;
578 let mut changed = false;
579 for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
580 let rows_affected = sqlx::query(
581 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
582 )
583 .bind(&workspace.id)
584 .bind(task_id)
585 .bind(&label)
586 .execute(&mut *conn)
587 .await?
588 .rows_affected();
589 if rows_affected > 0 {
590 append_change(
591 conn,
592 ChangeEntity::Task,
593 task_id,
594 Some("labels"),
595 op_type::LABEL_ADD,
596 ChangePayload::workspace(&workspace).set("label", label),
597 )
598 .await?;
599 changed = true;
600 }
601 }
602 for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
603 let rows_affected = sqlx::query(
604 "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
605 )
606 .bind(&workspace.id)
607 .bind(task_id)
608 .bind(&label)
609 .execute(&mut *conn)
610 .await?
611 .rows_affected();
612 if rows_affected > 0 {
613 append_change(
614 conn,
615 ChangeEntity::Task,
616 task_id,
617 Some("labels"),
618 op_type::LABEL_REMOVE,
619 ChangePayload::workspace(&workspace).set("label", label),
620 )
621 .await?;
622 changed = true;
623 }
624 }
625 if changed {
626 info!(
627 task_id = %task_id,
628 added = add_labels.len(),
629 removed = remove_labels.len(),
630 "task labels changed"
631 );
632 }
633 Ok(changed)
634}