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