1use anyhow::{Context, Result, bail, ensure};
2use sqlx::{Row, SqliteConnection};
3use tracing::info;
4
5use crate::change_log::op_type;
6use crate::db::{Database, begin_immediate, insert_change, set_field_version};
7use crate::ids::{TaskId, now};
8use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
9use crate::projects::{resolve_existing_project_in_workspace, resolve_project_for_stored_value};
10use crate::recurrence::{RecurrenceOutcome, RecurrenceSeriesId, RecurrenceSeriesState};
11use crate::refs::get_task_in_workspace;
12use crate::task_fields::TaskField;
13use crate::types::Task;
14use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
15use crate::workspaces::Workspace;
16
17impl Database {
18 pub async fn list_conflicts(
19 &self,
20 workspace: &Workspace,
21 project_key: Option<&str>,
22 field: Option<&str>,
23 ) -> Result<Vec<ConflictListItem>> {
24 let mut conn = self.acquire().await?;
25 list_conflicts(&mut conn, workspace, project_key, field).await
26 }
27
28 pub async fn task_conflicts(
29 &self,
30 workspace: &Workspace,
31 task_id: &TaskId,
32 field: Option<&str>,
33 ) -> Result<Vec<ConflictDetail>> {
34 let mut conn = self.acquire().await?;
35 task_conflicts(&mut conn, workspace, task_id, field).await
36 }
37
38 pub async fn conflict_variant_value(
39 &self,
40 workspace: &Workspace,
41 task_id: &TaskId,
42 field: &str,
43 token: &str,
44 ) -> Result<String> {
45 let mut conn = self.acquire().await?;
46 conflict_variant_value(&mut conn, workspace, task_id, field, token).await
47 }
48
49 pub async fn resolve_conflict_with_tui_undo(
50 &self,
51 workspace: &Workspace,
52 task_id: &TaskId,
53 field: &str,
54 value: &str,
55 summary: &str,
56 ) -> Result<ConflictResolutionOutcome> {
57 let mut conn = self.acquire().await?;
58 resolve_conflict_value(
59 &mut conn,
60 workspace,
61 task_id,
62 field,
63 ResolutionValue::Explicit(value),
64 Some(summary),
65 )
66 .await
67 }
68
69 pub async fn resolve_conflict(
70 &self,
71 workspace: &Workspace,
72 task_id: &TaskId,
73 field: &str,
74 value: &str,
75 ) -> Result<ConflictOutcome> {
76 let mut conn = self.acquire().await?;
77 resolve_conflict(&mut conn, workspace, task_id, field, value).await
78 }
79
80 pub async fn recurrence_series_conflicts(
81 &self,
82 workspace: &Workspace,
83 series_id: &RecurrenceSeriesId,
84 field: Option<&str>,
85 ) -> Result<Vec<ConflictDetail>> {
86 let mut conn = self.acquire().await?;
87 recurrence_series_conflicts(&mut conn, workspace, series_id, field).await
88 }
89
90 pub async fn recurrence_conflict_variant_value(
91 &self,
92 workspace: &Workspace,
93 series_id: &RecurrenceSeriesId,
94 field: &str,
95 token: &str,
96 ) -> Result<String> {
97 let mut conn = self.acquire().await?;
98 for detail in
99 recurrence_series_conflicts(&mut conn, workspace, series_id, Some(field)).await?
100 {
101 if token == detail.variant_a {
102 return Ok(detail.local_value);
103 }
104 if token == detail.variant_b {
105 return Ok(detail.remote_value);
106 }
107 }
108 bail!("error unknown-variant token={token}")
109 }
110
111 pub async fn resolve_recurrence_conflict(
112 &self,
113 workspace: &Workspace,
114 series_id: &RecurrenceSeriesId,
115 field: &str,
116 value: &str,
117 ) -> Result<String> {
118 let mut conn = self.acquire().await?;
119 resolve_recurrence_conflict(&mut conn, workspace, series_id, field, value).await
120 }
121}
122
123pub struct ConflictListItem {
124 pub task_id: TaskId,
125 pub recurrence_series: bool,
126 pub title: String,
127 pub project_key: String,
128 pub project_prefix: String,
129 pub field: String,
130 pub variant_a: String,
131 pub variant_b: String,
132}
133
134pub struct ConflictDetail {
135 pub field: String,
136 pub variant_a: String,
137 pub local_value: String,
138 pub variant_b: String,
139 pub remote_value: String,
140}
141
142pub struct ConflictOutcome {
143 pub task: Task,
144 pub field: String,
145}
146
147pub struct ConflictResolutionOutcome {
148 pub outcome: ConflictOutcome,
149 pub before: String,
150 pub after: String,
151 pub conflict_id: i64,
152}
153pub async fn list_conflicts(
154 conn: &mut SqliteConnection,
155 workspace: &Workspace,
156 project_key: Option<&str>,
157 field: Option<&str>,
158) -> Result<Vec<ConflictListItem>> {
159 let workspace_id = &workspace.id;
160 let project_id = if let Some(project) = project_key {
161 Some(
162 resolve_existing_project_in_workspace(conn, workspace_id, project)
163 .await?
164 .id,
165 )
166 } else {
167 None
168 };
169 let rows = sqlx::query(
170 r#"SELECT c.entity_id AS task_id, c.entity_type, c.field, c.variant_a, c.variant_b,
171 t.title, p.prefix, p.key AS project_key
172 FROM conflicts c
173 JOIN tasks t ON c.entity_type = 'task' AND t.workspace_id = c.workspace_id AND t.id = c.entity_id
174 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
175 WHERE c.workspace_id = ? AND c.resolved = 0
176 AND (? IS NULL OR t.project_id = ?)
177 AND (? IS NULL OR c.field = ?)
178 UNION ALL
179 SELECT c.entity_id AS task_id, c.entity_type, c.field, c.variant_a, c.variant_b,
180 s.title, p.prefix, p.key AS project_key
181 FROM conflicts c
182 JOIN recurrence_series s ON c.entity_type = 'recurrence_series'
183 AND s.workspace_id = c.workspace_id AND s.id = c.entity_id
184 JOIN projects p ON p.workspace_id = s.workspace_id AND p.id = s.project_id
185 WHERE c.workspace_id = ? AND c.resolved = 0
186 AND (? IS NULL OR s.project_id = ?)
187 AND (? IS NULL OR c.field = ?)
188 ORDER BY field"#,
189 )
190 .bind(workspace_id)
191 .bind(&project_id)
192 .bind(&project_id)
193 .bind(field)
194 .bind(field)
195 .bind(workspace_id)
196 .bind(&project_id)
197 .bind(&project_id)
198 .bind(field)
199 .bind(field)
200 .fetch_all(&mut *conn)
201 .await?;
202 Ok(rows
203 .into_iter()
204 .map(|row| ConflictListItem {
205 task_id: row.get("task_id"),
206 recurrence_series: row.get::<String, _>("entity_type") == "recurrence_series",
207 title: row.get("title"),
208 project_key: row.get("project_key"),
209 project_prefix: row.get("prefix"),
210 field: row.get("field"),
211 variant_a: row.get("variant_a"),
212 variant_b: row.get("variant_b"),
213 })
214 .collect())
215}
216
217pub async fn task_conflicts(
218 conn: &mut SqliteConnection,
219 workspace: &Workspace,
220 task_id: &crate::ids::TaskId,
221 field: Option<&str>,
222) -> Result<Vec<ConflictDetail>> {
223 let workspace_id = &workspace.id;
224 let rows = sqlx::query(
225 r#"SELECT field, variant_a, local_value, variant_b, remote_value
226 FROM conflicts
227 WHERE workspace_id = ? AND task_id = ? AND resolved = 0 AND (? IS NULL OR field = ?)
228 ORDER BY field, id"#,
229 )
230 .bind(workspace_id)
231 .bind(task_id)
232 .bind(field)
233 .bind(field)
234 .fetch_all(&mut *conn)
235 .await?;
236 Ok(rows
237 .into_iter()
238 .map(|row| ConflictDetail {
239 field: row.get("field"),
240 variant_a: row.get("variant_a"),
241 local_value: row.get("local_value"),
242 variant_b: row.get("variant_b"),
243 remote_value: row.get("remote_value"),
244 })
245 .collect())
246}
247
248pub async fn recurrence_series_conflicts(
249 conn: &mut SqliteConnection,
250 workspace: &Workspace,
251 series_id: &RecurrenceSeriesId,
252 field: Option<&str>,
253) -> Result<Vec<ConflictDetail>> {
254 let rows = sqlx::query(
255 "SELECT field, variant_a, local_value, variant_b, remote_value FROM conflicts
256 WHERE workspace_id = ? AND entity_type = 'recurrence_series' AND entity_id = ?
257 AND resolved = 0 AND (? IS NULL OR field = ?) ORDER BY field, id",
258 )
259 .bind(&workspace.id)
260 .bind(series_id)
261 .bind(field)
262 .bind(field)
263 .fetch_all(&mut *conn)
264 .await?;
265 Ok(rows
266 .into_iter()
267 .map(|row| ConflictDetail {
268 field: row.get("field"),
269 variant_a: row.get("variant_a"),
270 local_value: row.get("local_value"),
271 variant_b: row.get("variant_b"),
272 remote_value: row.get("remote_value"),
273 })
274 .collect())
275}
276
277async fn resolve_recurrence_conflict(
278 conn: &mut SqliteConnection,
279 workspace: &Workspace,
280 series_id: &RecurrenceSeriesId,
281 field: &str,
282 value: &str,
283) -> Result<String> {
284 let mut tx = begin_immediate(conn).await?;
285 let conflict = sqlx::query(
286 "SELECT id, local_value, remote_value, remote_change_id FROM conflicts
287 WHERE workspace_id = ? AND entity_type = 'recurrence_series'
288 AND entity_id = ? AND field = ? AND resolved = 0",
289 )
290 .bind(&workspace.id)
291 .bind(series_id)
292 .bind(field)
293 .fetch_optional(&mut *tx)
294 .await?
295 .ok_or_else(|| {
296 anyhow::anyhow!("error conflict-not-found series_id={series_id} field={field}")
297 })?;
298 let local: String = conflict.get("local_value");
299 let remote: String = conflict.get("remote_value");
300 if value != local && value != remote {
301 bail!("error invalid-conflict-value value={value}");
302 }
303 if let Some(slot) = field.strip_prefix("outcome:") {
304 resolve_recurrence_outcome_conflict(
305 &mut tx,
306 workspace,
307 series_id,
308 slot.parse()?,
309 RecurrenceOutcome::parse(value)?,
310 )
311 .await?;
312 } else if field == "state" {
313 resolve_recurrence_state_conflict(
314 &mut tx,
315 workspace,
316 series_id,
317 RecurrenceSeriesState::parse(value)?,
318 &conflict.get::<String, _>("remote_change_id"),
319 )
320 .await?;
321 } else if matches!(
322 field,
323 "title"
324 | "description"
325 | "project"
326 | "priority"
327 | "initial_status"
328 | "available_local_time"
329 | "due_policy"
330 | "labels"
331 ) {
332 resolve_recurrence_template_conflict(&mut tx, workspace, series_id, field, value).await?;
333 }
334 sqlx::query("UPDATE conflicts SET resolved = 1 WHERE id = ?")
335 .bind(conflict.get::<i64, _>("id"))
336 .execute(&mut *tx)
337 .await?;
338 tx.commit().await?;
339 Ok(field.to_string())
340}
341
342async fn resolve_recurrence_template_conflict(
343 conn: &mut SqliteConnection,
344 workspace: &Workspace,
345 series_id: &RecurrenceSeriesId,
346 field: &str,
347 value: &str,
348) -> Result<()> {
349 let updated_at = now();
350 let (fields, labels, labels_changed) = if field == "labels" {
351 let labels: Vec<String> = serde_json::from_str(value)?;
352 sqlx::query(
353 "DELETE FROM recurrence_series_labels WHERE workspace_id = ? AND series_id = ?",
354 )
355 .bind(&workspace.id)
356 .bind(series_id)
357 .execute(&mut *conn)
358 .await?;
359 for label in &labels {
360 sqlx::query(
361 "INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)",
362 )
363 .bind(&workspace.id)
364 .bind(label)
365 .bind(&updated_at)
366 .execute(&mut *conn)
367 .await?;
368 sqlx::query("INSERT INTO recurrence_series_labels(workspace_id, series_id, label) VALUES (?, ?, ?)")
369 .bind(&workspace.id).bind(series_id).bind(label).execute(&mut *conn).await?;
370 }
371 (Vec::<(String, String)>::new(), labels, true)
372 } else {
373 let query = match field {
374 "title" => {
375 "UPDATE recurrence_series SET title = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
376 }
377 "description" => {
378 "UPDATE recurrence_series SET description = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
379 }
380 "project" => {
381 "UPDATE recurrence_series SET project_id = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
382 }
383 "priority" => {
384 "UPDATE recurrence_series SET priority = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
385 }
386 "initial_status" => {
387 "UPDATE recurrence_series SET initial_status = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
388 }
389 "available_local_time" => {
390 "UPDATE recurrence_series SET available_local_time = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
391 }
392 "due_policy" => {
393 "UPDATE recurrence_series SET due_policy = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
394 }
395 _ => unreachable!(),
396 };
397 sqlx::query(query)
398 .bind(value)
399 .bind(&updated_at)
400 .bind(&workspace.id)
401 .bind(series_id)
402 .execute(&mut *conn)
403 .await?;
404 let labels = sqlx::query_scalar("SELECT label FROM recurrence_series_labels WHERE workspace_id = ? AND series_id = ? ORDER BY label")
405 .bind(&workspace.id).bind(series_id).fetch_all(&mut *conn).await?;
406 (vec![(field.to_string(), value.to_string())], labels, false)
407 };
408 let mut base_versions = serde_json::Map::new();
409 base_versions.insert(field.to_string(), serde_json::Value::Null);
410 let change_id = insert_change(
411 conn,
412 "recurrence_series",
413 series_id.as_str(),
414 None,
415 op_type::UPDATE_RECURRENCE_TEMPLATE,
416 crate::change_log::ChangePayload::workspace(workspace)
417 .set("fields", fields)
418 .set("base_versions", base_versions)
419 .set("labels_changed", labels_changed)
420 .set("labels", labels)
421 .set("updated_at", &updated_at)
422 .set("conflict_resolution", true)
423 .into_value(),
424 None,
425 )
426 .await?;
427 crate::db::set_entity_field_version(
428 conn,
429 &workspace.id,
430 crate::types::MutableEntityType::RecurrenceSeries,
431 series_id.as_str(),
432 field,
433 &change_id,
434 )
435 .await?;
436 Ok(())
437}
438
439async fn resolve_recurrence_outcome_conflict(
440 conn: &mut SqliteConnection,
441 workspace: &Workspace,
442 series_id: &RecurrenceSeriesId,
443 slot_on: chrono::NaiveDate,
444 outcome: RecurrenceOutcome,
445) -> Result<()> {
446 let row = sqlx::query("SELECT task_id, resolved_at FROM recurrence_occurrences WHERE workspace_id = ? AND series_id = ? AND slot_on = ?")
447 .bind(&workspace.id).bind(series_id).bind(slot_on.to_string()).fetch_one(&mut *conn).await?;
448 let task_id: String = row.get("task_id");
449 let resolved_at: String = row.get("resolved_at");
450 let series_row = sqlx::query("SELECT workspace_id, id, title, description, project_id, priority, initial_status, frequency, interval, weekdays, timezone, start_on, available_local_time, due_policy, state, stopped_at, created_at, updated_at, deleted FROM recurrence_series WHERE workspace_id = ? AND id = ?")
451 .bind(&workspace.id).bind(series_id).fetch_one(&mut *conn).await?;
452 let series = crate::db::recurrence_series_from_row(&series_row)?;
453 ensure!(
454 !task_id.is_empty(),
455 "error recurrence-outcome-task-missing slot={slot_on}"
456 );
457 let status = if outcome == RecurrenceOutcome::Completed {
458 "done"
459 } else {
460 "canceled"
461 };
462 apply_field_value_in_workspace(conn, &workspace.id, &task_id.parse()?, "status", status)
463 .await?;
464 let change_id = insert_change(
465 conn,
466 "recurrence_series",
467 series_id.as_str(),
468 Some("outcome"),
469 op_type::RESOLVE_RECURRENCE_OCCURRENCE,
470 crate::change_log::ChangePayload::workspace(workspace)
471 .set("slot_on", slot_on.to_string())
472 .set("task_id", &task_id)
473 .set("outcome", outcome.as_str())
474 .set(
475 "task_status",
476 if outcome == RecurrenceOutcome::Completed {
477 "done"
478 } else {
479 "canceled"
480 },
481 )
482 .set("resolved_at", &resolved_at)
483 .set("task_status_change_id", "")
484 .set("successor_task_id", "")
485 .set("frequency", series.rule.frequency().as_str())
486 .set("interval", series.rule.interval())
487 .set("weekdays", series.rule.weekdays_set().to_string())
488 .set("timezone", series.timezone.as_str())
489 .set("start_on", series.start_on.to_string())
490 .set(
491 "available_local_time",
492 series
493 .available_local_time
494 .map(|time| time.format("%H:%M:%S").to_string())
495 .unwrap_or_default(),
496 )
497 .set("due_policy", series.due_policy.as_str())
498 .set("conflict_resolution", true)
499 .into_value(),
500 None,
501 )
502 .await?;
503 sqlx::query("UPDATE recurrence_occurrences SET outcome = ?, outcome_change_id = ? WHERE workspace_id = ? AND series_id = ? AND slot_on = ?")
504 .bind(outcome.as_str()).bind(change_id).bind(&workspace.id).bind(series_id).bind(slot_on.to_string()).execute(&mut *conn).await?;
505 Ok(())
506}
507
508async fn resolve_recurrence_state_conflict(
509 conn: &mut SqliteConnection,
510 workspace: &Workspace,
511 series_id: &RecurrenceSeriesId,
512 state: RecurrenceSeriesState,
513 remote_change_id: &str,
514) -> Result<()> {
515 let local = sqlx::query(
516 "SELECT state, stopped_at FROM recurrence_series WHERE workspace_id = ? AND id = ?",
517 )
518 .bind(&workspace.id)
519 .bind(series_id)
520 .fetch_one(&mut *conn)
521 .await?;
522 let local_state: String = local.get("state");
523 let remote_payload: Option<String> =
524 sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
525 .bind(remote_change_id)
526 .fetch_optional(&mut *conn)
527 .await?;
528 let remote_payload = remote_payload
529 .as_deref()
530 .map(serde_json::from_str::<serde_json::Value>)
531 .transpose()?;
532 let stopped_at = if state == RecurrenceSeriesState::Stopped {
533 if local_state == "stopped" {
534 local.get("stopped_at")
535 } else {
536 remote_payload
537 .as_ref()
538 .and_then(|payload| payload.get("stopped_at"))
539 .and_then(serde_json::Value::as_str)
540 .context("error recurrence-stop-boundary-missing")?
541 .to_string()
542 }
543 } else {
544 String::new()
545 };
546 let mut changed_at = now();
547 if state == RecurrenceSeriesState::Active {
548 let pause_boundaries: Vec<String> = sqlx::query_scalar(
549 "SELECT json_extract(payload, '$.paused_at') FROM changes
550 WHERE entity_type = 'recurrence_series' AND entity_id = ?
551 AND op_type = 'open_recurrence_pause'
552 AND json_extract(payload, '$.workspace_id') = ?",
553 )
554 .bind(series_id)
555 .bind(workspace.id.as_str())
556 .fetch_all(&mut *conn)
557 .await?;
558 for paused_at in pause_boundaries {
559 changed_at = super::recurrence::timestamp_strictly_after(&paused_at, &changed_at)?;
560 }
561 }
562 sqlx::query("UPDATE recurrence_series SET state = ?, stopped_at = ?, updated_at = ? WHERE workspace_id = ? AND id = ?")
563 .bind(state.as_str()).bind(&stopped_at).bind(&changed_at).bind(&workspace.id).bind(series_id).execute(&mut *conn).await?;
564 let operation = if state == RecurrenceSeriesState::Stopped {
565 op_type::STOP_RECURRENCE_SERIES
566 } else {
567 op_type::SET_RECURRENCE_STATE
568 };
569 let change_id = insert_change(
570 conn,
571 "recurrence_series",
572 series_id.as_str(),
573 Some("state"),
574 operation,
575 crate::change_log::ChangePayload::workspace(workspace)
576 .set("state", state.as_str())
577 .set("stopped_at", &stopped_at)
578 .set("changed_at", &changed_at)
579 .set("conflict_resolution", true)
580 .into_value(),
581 None,
582 )
583 .await?;
584 crate::db::set_entity_field_version(
585 conn,
586 &workspace.id,
587 crate::types::MutableEntityType::RecurrenceSeries,
588 series_id.as_str(),
589 "state",
590 &change_id,
591 )
592 .await?;
593 sqlx::query(
594 "UPDATE conflicts SET resolved = 1
595 WHERE workspace_id = ? AND entity_type = 'recurrence_series'
596 AND entity_id = ? AND field = 'state' AND resolved = 0",
597 )
598 .bind(&workspace.id)
599 .bind(series_id)
600 .execute(&mut *conn)
601 .await?;
602 if state == RecurrenceSeriesState::Active {
603 sqlx::query(
604 "UPDATE recurrence_pause_intervals
605 SET resumed_at = ?, resolved_by_change_id = ?
606 WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''",
607 )
608 .bind(&changed_at)
609 .bind(&change_id)
610 .bind(&workspace.id)
611 .bind(series_id)
612 .execute(&mut *conn)
613 .await?;
614 }
615 cleanup_recurrence_projections(conn, workspace, series_id, state, &stopped_at).await?;
616 if state == RecurrenceSeriesState::Active {
617 super::recurrence::reconcile_recurrence_series_in_transaction(
618 conn,
619 workspace,
620 series_id,
621 chrono::DateTime::parse_from_rfc3339(&changed_at)?.with_timezone(&chrono::Utc),
622 )
623 .await?;
624 }
625 Ok(())
626}
627
628pub(crate) async fn cleanup_recurrence_projections(
629 conn: &mut SqliteConnection,
630 workspace: &Workspace,
631 series_id: &RecurrenceSeriesId,
632 state: RecurrenceSeriesState,
633 stopped_at: &str,
634) -> Result<()> {
635 let archived_at = now();
636 if state == RecurrenceSeriesState::Paused {
637 let paused_at: String = sqlx::query_scalar(
638 "SELECT paused_at FROM recurrence_pause_intervals
639 WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''
640 ORDER BY paused_at DESC LIMIT 1",
641 )
642 .bind(&workspace.id)
643 .bind(series_id)
644 .fetch_one(&mut *conn)
645 .await?;
646 archive_projected_occurrences_at_or_after(
647 conn,
648 workspace,
649 series_id,
650 &paused_at,
651 &archived_at,
652 )
653 .await?;
654 } else if state == RecurrenceSeriesState::Stopped {
655 archive_projected_occurrences_at_or_after(
656 conn,
657 workspace,
658 series_id,
659 stopped_at,
660 &archived_at,
661 )
662 .await?;
663 }
664 Ok(())
665}
666
667async fn archive_projected_occurrences_at_or_after(
668 conn: &mut SqliteConnection,
669 workspace: &Workspace,
670 series_id: &RecurrenceSeriesId,
671 boundary: &str,
672 archived_at: &str,
673) -> Result<()> {
674 let row = sqlx::query("SELECT workspace_id, id, title, description, project_id, priority, initial_status, frequency, interval, weekdays, timezone, start_on, available_local_time, due_policy, state, stopped_at, created_at, updated_at, deleted FROM recurrence_series WHERE workspace_id = ? AND id = ?")
675 .bind(&workspace.id).bind(series_id).fetch_one(&mut *conn).await?;
676 let series = crate::db::recurrence_series_from_row(&row)?;
677 let schedule = crate::recurrence::RecurrenceSchedule::new(
678 series.rule,
679 series.timezone,
680 series.start_on,
681 series.available_local_time,
682 series.due_policy,
683 );
684 let rows = sqlx::query("SELECT slot_on FROM recurrence_occurrences WHERE workspace_id = ? AND series_id = ? AND projection_state = 'projected' AND outcome = ''")
685 .bind(&workspace.id).bind(series_id).fetch_all(&mut *conn).await?;
686 for row in rows {
687 let slot: chrono::NaiveDate = row.get::<String, _>("slot_on").parse()?;
688 if crate::recurrence::slot_values(&schedule, slot)?
689 .boundary_at
690 .as_str()
691 >= boundary
692 {
693 sqlx::query("UPDATE recurrence_occurrences SET projection_state = 'archived', archived_at = ? WHERE workspace_id = ? AND series_id = ? AND slot_on = ? AND outcome = ''")
694 .bind(archived_at).bind(&workspace.id).bind(series_id).bind(slot.to_string()).execute(&mut *conn).await?;
695 }
696 }
697 Ok(())
698}
699
700pub async fn conflict_variant_value(
701 conn: &mut SqliteConnection,
702 workspace: &Workspace,
703 task_id: &crate::ids::TaskId,
704 field: &str,
705 token: &str,
706) -> Result<String> {
707 for detail in task_conflicts(conn, workspace, task_id, Some(field)).await? {
708 if token == detail.variant_a {
709 return Ok(detail.local_value);
710 }
711 if token == detail.variant_b {
712 return Ok(detail.remote_value);
713 }
714 }
715 bail!("error unknown-variant token={token}")
716}
717
718pub(crate) enum ConflictValueChoice {
719 Local,
720 Remote,
721}
722
723#[derive(Debug)]
724pub(crate) struct ConflictNotFoundError {
725 task_id: TaskId,
726 field: &'static str,
727}
728
729impl std::fmt::Display for ConflictNotFoundError {
730 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
731 write!(
732 formatter,
733 "error conflict-not-found task_id={} field={}",
734 self.task_id, self.field
735 )
736 }
737}
738
739impl std::error::Error for ConflictNotFoundError {}
740
741pub async fn resolve_conflict(
742 conn: &mut SqliteConnection,
743 workspace: &Workspace,
744 task_id: &crate::ids::TaskId,
745 field: &str,
746 value: &str,
747) -> Result<ConflictOutcome> {
748 Ok(resolve_conflict_value(
749 conn,
750 workspace,
751 task_id,
752 field,
753 ResolutionValue::Explicit(value),
754 None,
755 )
756 .await?
757 .outcome)
758}
759
760pub(crate) async fn resolve_conflict_choice(
761 conn: &mut SqliteConnection,
762 workspace: &Workspace,
763 task_id: &crate::ids::TaskId,
764 field: &str,
765 choice: ConflictValueChoice,
766) -> Result<ConflictOutcome> {
767 Ok(resolve_conflict_value(
768 conn,
769 workspace,
770 task_id,
771 field,
772 ResolutionValue::Choice(choice),
773 None,
774 )
775 .await?
776 .outcome)
777}
778
779enum ResolutionValue<'a> {
780 Explicit(&'a str),
781 Choice(ConflictValueChoice),
782}
783
784async fn resolve_conflict_value(
785 conn: &mut SqliteConnection,
786 workspace: &Workspace,
787 task_id: &crate::ids::TaskId,
788 field: &str,
789 resolution: ResolutionValue<'_>,
790 tui_summary: Option<&str>,
791) -> Result<ConflictResolutionOutcome> {
792 let task_field = TaskField::parse_or_unknown(field)?;
793 let field = task_field.as_str();
794 let mut tx = begin_immediate(conn).await?;
795 let before = crate::undo::task_field_value(&mut tx, &workspace.id, task_id, field).await?;
796 let conflict_id = crate::undo::conflict_row_id(&mut tx, &workspace.id, task_id, field).await?;
797 let value = match resolution {
798 ResolutionValue::Explicit(value) => value.to_string(),
799 ResolutionValue::Choice(choice) => {
800 let values = sqlx::query_as::<_, (String, String)>(
801 "SELECT local_value, remote_value FROM conflicts
802 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
803 )
804 .bind(&workspace.id)
805 .bind(task_id)
806 .bind(field)
807 .fetch_optional(&mut *tx)
808 .await?
809 .ok_or_else(|| {
810 anyhow::Error::new(ConflictNotFoundError {
811 task_id: task_id.clone(),
812 field,
813 })
814 })?;
815 match choice {
816 ConflictValueChoice::Local => values.0,
817 ConflictValueChoice::Remote => values.1,
818 }
819 }
820 };
821 if task_field == TaskField::IsEpic
822 && value == "0"
823 && crate::operations::task_has_epic_children(&mut tx, &workspace.id, task_id).await?
824 {
825 bail!("error epic-has-children task_id={task_id}");
826 }
827 let result = sqlx::query(
828 "UPDATE conflicts SET resolved = 1 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
829 )
830 .bind(&workspace.id)
831 .bind(task_id)
832 .bind(field)
833 .execute(&mut *tx)
834 .await?;
835 if result.rows_affected() != 1 {
836 return Err(anyhow::Error::new(ConflictNotFoundError {
837 task_id: task_id.clone(),
838 field,
839 }));
840 }
841 let payload = if task_field.is_project() {
842 let project = resolve_project_for_stored_value(&mut tx, &workspace.id, &value).await?;
843 apply_project_id_in_workspace(&mut tx, &workspace.id, task_id, &project.id).await?;
844 TaskField::project_payload(&workspace.id, &workspace.key, &project)
845 } else {
846 apply_field_value_in_workspace(&mut tx, &workspace.id, task_id, field, &value).await?;
847 task_field.scalar_payload(&workspace.id, &workspace.key, &value)?
848 };
849 let change_id = insert_change(
850 &mut tx,
851 "task",
852 task_id,
853 Some(field),
854 op_type::RESOLVE_FIELD,
855 payload,
856 None,
857 )
858 .await?;
859 set_field_version(&mut tx, task_id, field, &change_id).await?;
860 let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
861 let after = crate::undo::task_field_value(&mut tx, &workspace.id, task_id, field).await?;
862 if let Some(summary) = tui_summary {
863 record_tui_undo(
864 &mut tx,
865 &workspace.id,
866 summary,
867 UndoPayload {
868 commands: vec![UndoCommand::RestoreConflictResolution {
869 task_id: task_id.clone(),
870 field: field.to_string(),
871 before: before.clone(),
872 after: after.clone(),
873 conflict_id,
874 }],
875 },
876 )
877 .await?;
878 }
879 tx.commit().await?;
880 info!(task_id = %task_id, field = %field, "conflict resolved");
881 Ok(ConflictResolutionOutcome {
882 outcome: ConflictOutcome {
883 task,
884 field: field.to_string(),
885 },
886 before,
887 after,
888 conflict_id,
889 })
890}