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}