Skip to main content

aven_core/operations/
labels.rs

1use crate::ids::WorkspaceId;
2
3use anyhow::{Result, bail};
4use sqlx::SqliteConnection;
5use tracing::info;
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{Database, begin_immediate};
9use crate::ids::now;
10use crate::labels::normalize_label;
11use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
12use crate::workspaces::Workspace;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct LabelDeleteOutcome {
16    pub name: String,
17    pub changed: bool,
18    pub task_count: usize,
19    pub series_count: usize,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct LabelRenameOutcome {
24    pub previous_name: String,
25    pub name: String,
26    pub changed: bool,
27    pub task_count: usize,
28    pub series_count: usize,
29}
30
31pub struct LabelOutcome {
32    pub name: String,
33    pub created: bool,
34    pub change_id: Option<String>,
35}
36
37impl Database {
38    pub async fn create_label(&self, workspace: &Workspace, name: &str) -> Result<LabelOutcome> {
39        let mut conn = self.acquire_writer().await?;
40        let mut tx = begin_immediate(&mut conn).await?;
41        let outcome = match create_label_operation(&mut tx, workspace, name).await {
42            Ok(outcome) => outcome,
43            Err(error) => {
44                tx.rollback().await?;
45                return Err(error);
46            }
47        };
48        tx.commit().await?;
49        Ok(outcome)
50    }
51
52    pub async fn create_label_with_tui_undo(
53        &self,
54        workspace: &Workspace,
55        name: &str,
56    ) -> Result<LabelOutcome> {
57        let mut conn = self.acquire_writer().await?;
58        let mut tx = begin_immediate(&mut conn).await?;
59        let outcome = create_label_operation(&mut tx, workspace, name).await?;
60        if outcome.created {
61            record_tui_undo(
62                &mut tx,
63                &workspace.id,
64                UndoPayload {
65                    commands: vec![UndoCommand::DeleteCreatedLabel {
66                        label: outcome.name.clone(),
67                        create_change_id: outcome.change_id.clone().unwrap_or_default(),
68                    }],
69                },
70            )
71            .await?;
72        }
73        tx.commit().await?;
74        Ok(outcome)
75    }
76
77    pub async fn delete_label(
78        &self,
79        workspace: &Workspace,
80        name: &str,
81    ) -> Result<LabelDeleteOutcome> {
82        let mut conn = self.acquire_writer().await?;
83        delete_label_operation(&mut conn, workspace, name, false).await
84    }
85
86    pub async fn delete_label_with_tui_undo(
87        &self,
88        workspace: &Workspace,
89        name: &str,
90    ) -> Result<LabelDeleteOutcome> {
91        let mut conn = self.acquire_writer().await?;
92        delete_label_operation(&mut conn, workspace, name, true).await
93    }
94
95    pub async fn rename_label_with_tui_undo(
96        &self,
97        workspace: &Workspace,
98        name: &str,
99        new_name: &str,
100    ) -> Result<LabelRenameOutcome> {
101        let mut conn = self.acquire_writer().await?;
102        rename_label_operation(&mut conn, workspace, name, new_name, true).await
103    }
104}
105
106pub(crate) async fn create_label_operation(
107    conn: &mut SqliteConnection,
108    workspace: &Workspace,
109    name: &str,
110) -> Result<LabelOutcome> {
111    let name = normalize_label(name);
112    if name.is_empty() {
113        bail!("error invalid-label");
114    }
115    let existed = sqlx::query_scalar::<_, i64>(
116        "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
117    )
118    .bind(&workspace.id)
119    .bind(&name)
120    .fetch_one(&mut *conn)
121    .await?
122        > 0;
123    let created_at = now();
124    sqlx::query("INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
125        .bind(&workspace.id)
126        .bind(&name)
127        .bind(&created_at)
128        .execute(&mut *conn)
129        .await?;
130    let created = !existed;
131    let change_id = if created {
132        Some(
133            append_change(
134                conn,
135                ChangeEntity::Label,
136                &name,
137                None,
138                op_type::CREATE_LABEL,
139                ChangePayload::workspace(workspace)
140                    .set("name", name.clone())
141                    .set("created_at", created_at),
142            )
143            .await?,
144        )
145    } else {
146        None
147    };
148    if created {
149        info!("label created");
150    }
151    Ok(LabelOutcome {
152        name,
153        created,
154        change_id,
155    })
156}
157
158pub async fn rename_label_operation(
159    conn: &mut SqliteConnection,
160    workspace: &Workspace,
161    name: &str,
162    new_name: &str,
163    tui_undo: bool,
164) -> Result<LabelRenameOutcome> {
165    let name = normalize_label(name);
166    let new_name = normalize_label(new_name);
167    if name.is_empty() || new_name.is_empty() {
168        bail!("error invalid-label");
169    }
170    let mut tx = begin_immediate(conn).await?;
171    let snapshot = label_snapshot(&mut tx, &workspace.id, &name)
172        .await?
173        .ok_or_else(|| anyhow::anyhow!("error label-not-found label={name}"))?;
174    if name == new_name {
175        tx.commit().await?;
176        return Ok(LabelRenameOutcome {
177            previous_name: name.clone(),
178            name,
179            changed: false,
180            task_count: snapshot.task_ids.len(),
181            series_count: snapshot.series_ids.len(),
182        });
183    }
184    if label_snapshot(&mut tx, &workspace.id, &new_name)
185        .await?
186        .is_some()
187    {
188        bail!("error label-exists label={new_name}");
189    }
190    set_label_name(&mut tx, workspace, &name, &new_name).await?;
191    if tui_undo {
192        record_tui_undo(
193            &mut tx,
194            &workspace.id,
195            UndoPayload {
196                commands: vec![UndoCommand::SetLabelName {
197                    before: name.clone(),
198                    after: new_name.clone(),
199                }],
200            },
201        )
202        .await?;
203    }
204    tx.commit().await?;
205    info!("label renamed");
206    Ok(LabelRenameOutcome {
207        previous_name: name,
208        name: new_name,
209        changed: true,
210        task_count: snapshot.task_ids.len(),
211        series_count: snapshot.series_ids.len(),
212    })
213}
214
215pub async fn delete_label_operation(
216    conn: &mut SqliteConnection,
217    workspace: &Workspace,
218    name: &str,
219    tui_undo: bool,
220) -> Result<LabelDeleteOutcome> {
221    let name = normalize_label(name);
222    if name.is_empty() {
223        bail!("error invalid-label");
224    }
225    let mut tx = begin_immediate(conn).await?;
226    let Some(snapshot) = label_snapshot(&mut tx, &workspace.id, &name).await? else {
227        tx.commit().await?;
228        return Ok(LabelDeleteOutcome {
229            name,
230            changed: false,
231            task_count: 0,
232            series_count: 0,
233        });
234    };
235    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND label = ?")
236        .bind(&workspace.id)
237        .bind(&name)
238        .execute(&mut *tx)
239        .await?;
240    sqlx::query("DELETE FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?")
241        .bind(&workspace.id)
242        .bind(&name)
243        .execute(&mut *tx)
244        .await?;
245    let labels = sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
246        .bind(&workspace.id)
247        .bind(&name)
248        .execute(&mut *tx)
249        .await?;
250    if labels.rows_affected() != 1 {
251        bail!("error label-delete-race label={name}");
252    }
253    append_change(
254        &mut tx,
255        ChangeEntity::Label,
256        &name,
257        None,
258        op_type::LABEL_DELETE,
259        ChangePayload::workspace(workspace)
260            .set("name", name.clone())
261            .set("deleted_at", now()),
262    )
263    .await?;
264    if tui_undo {
265        record_tui_undo(
266            &mut tx,
267            &workspace.id,
268            UndoPayload {
269                commands: vec![UndoCommand::RestoreDeletedLabel {
270                    name: name.clone(),
271                    created_at: snapshot.created_at.clone(),
272                    task_ids: snapshot.task_ids.clone(),
273                    series_ids: snapshot.series_ids.clone(),
274                }],
275            },
276        )
277        .await?;
278    }
279    tx.commit().await?;
280    info!("label deleted");
281    Ok(LabelDeleteOutcome {
282        name,
283        changed: true,
284        task_count: snapshot.task_ids.len(),
285        series_count: snapshot.series_ids.len(),
286    })
287}
288
289#[derive(Debug)]
290struct LabelSnapshot {
291    created_at: String,
292    task_ids: Vec<crate::ids::TaskId>,
293    series_ids: Vec<crate::recurrence::RecurrenceSeriesId>,
294}
295
296async fn label_snapshot(
297    conn: &mut SqliteConnection,
298    workspace_id: &WorkspaceId,
299    name: &str,
300) -> Result<Option<LabelSnapshot>> {
301    let Some(created_at) = sqlx::query_scalar::<_, String>(
302        "SELECT created_at FROM labels WHERE workspace_id = ? AND name = ?",
303    )
304    .bind(workspace_id)
305    .bind(name)
306    .fetch_optional(&mut *conn)
307    .await?
308    else {
309        return Ok(None);
310    };
311    let task_ids = sqlx::query_scalar(
312        "SELECT task_id FROM task_labels WHERE workspace_id = ? AND label = ? ORDER BY task_id",
313    )
314    .bind(workspace_id)
315    .bind(name)
316    .fetch_all(&mut *conn)
317    .await?;
318    let series_ids = sqlx::query_scalar(
319        "SELECT series_id FROM recurrence_series_labels WHERE workspace_id = ? AND label = ? ORDER BY series_id",
320    )
321    .bind(workspace_id)
322    .bind(name)
323    .fetch_all(&mut *conn)
324    .await?;
325    Ok(Some(LabelSnapshot {
326        created_at,
327        task_ids,
328        series_ids,
329    }))
330}
331
332pub(crate) async fn set_label_name(
333    conn: &mut SqliteConnection,
334    workspace: &Workspace,
335    name: &str,
336    new_name: &str,
337) -> Result<()> {
338    sqlx::query("UPDATE labels SET name = ? WHERE workspace_id = ? AND name = ?")
339        .bind(new_name)
340        .bind(&workspace.id)
341        .bind(name)
342        .execute(&mut *conn)
343        .await?;
344    sqlx::query("UPDATE task_labels SET label = ? WHERE workspace_id = ? AND label = ?")
345        .bind(new_name)
346        .bind(&workspace.id)
347        .bind(name)
348        .execute(&mut *conn)
349        .await?;
350    sqlx::query(
351        "UPDATE recurrence_series_labels SET label = ? WHERE workspace_id = ? AND label = ?",
352    )
353    .bind(new_name)
354    .bind(&workspace.id)
355    .bind(name)
356    .execute(&mut *conn)
357    .await?;
358    append_change(
359        conn,
360        ChangeEntity::Label,
361        name,
362        None,
363        op_type::SET_LABEL_NAME,
364        ChangePayload::workspace(workspace)
365            .set("name", name)
366            .set("new_name", new_name)
367            .set("renamed_at", now()),
368    )
369    .await?;
370    Ok(())
371}