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}