1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
#![allow(deprecated)]
use crate::{
database::{
conversions::naive_date_to_things_timestamp, query_builders::TaskUpdateBuilder, validators,
ThingsDatabase,
},
error::{Result as ThingsResult, ThingsError},
models::{
CreateTaskRequest, DeleteChildHandling, TaskStatus, TaskType, ThingsId, UpdateTaskRequest,
},
};
use chrono::Utc;
use sqlx::Row;
use tracing::{info, instrument};
impl ThingsDatabase {
/// Create a new task in the database
///
/// Validates:
/// - Project UUID exists if provided
/// - Area UUID exists if provided
/// - Parent task UUID exists if provided
/// - Date range (deadline >= start_date)
///
/// Returns the UUID of the created task
///
/// # Examples
///
/// ```no_run
/// use things3_core::{ThingsDatabase, CreateTaskRequest, ThingsError};
/// use std::path::Path;
/// use chrono::NaiveDate;
///
/// # async fn example() -> Result<(), ThingsError> {
/// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
///
/// // Create a simple task
/// let request = CreateTaskRequest {
/// title: "Buy groceries".to_string(),
/// notes: Some("Milk, eggs, bread".to_string()),
/// deadline: Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()),
/// start_date: None,
/// project_uuid: None,
/// area_uuid: None,
/// parent_uuid: None,
/// tags: None,
/// task_type: None,
/// status: None,
/// };
///
/// let task_uuid = db.create_task(request).await?;
/// println!("Created task with UUID: {}", task_uuid);
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// Returns an error if validation fails or if the database insert fails
#[instrument(skip(self))]
pub async fn create_task(&self, request: CreateTaskRequest) -> ThingsResult<ThingsId> {
// Validate date range (deadline must be >= start_date)
crate::database::validate_date_range(request.start_date, request.deadline)?;
// Generate ID for new task
let id = ThingsId::new_things_native();
// Validate referenced entities
if let Some(project_uuid) = &request.project_uuid {
validators::validate_project_exists(&self.pool, project_uuid).await?;
}
if let Some(area_uuid) = &request.area_uuid {
validators::validate_area_exists(&self.pool, area_uuid).await?;
}
if let Some(parent_uuid) = &request.parent_uuid {
validators::validate_task_exists(&self.pool, parent_uuid).await?;
}
// Convert dates to Things 3 format (seconds since 2001-01-01)
let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
// Get current timestamp for creation/modification dates
let now = Utc::now().timestamp() as f64;
// Insert into TMTask table
sqlx::query(
r"
INSERT INTO TMTask (
uuid, title, type, status, notes,
startDate, deadline, project, area, heading,
creationDate, userModificationDate,
trashed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(id.as_str())
.bind(&request.title)
.bind(request.task_type.unwrap_or(TaskType::Todo) as i32)
.bind(request.status.unwrap_or(TaskStatus::Incomplete) as i32)
.bind(request.notes.as_ref())
.bind(start_date_ts)
.bind(deadline_ts)
.bind(request.project_uuid.map(|u| u.into_string()))
.bind(request.area_uuid.map(|u| u.into_string()))
.bind(request.parent_uuid.map(|u| u.into_string()))
.bind(now)
.bind(now)
.bind(0) // not trashed
.execute(&self.pool)
.await
.map_err(|e| ThingsError::unknown(format!("Failed to create task: {e}")))?;
// Handle tags via TMTaskTag
if let Some(tags) = request.tags {
self.set_task_tags(&id, tags).await?;
}
info!("Created task with UUID: {}", id);
Ok(id)
}
/// Update an existing task
///
/// Only updates fields that are provided (Some(_))
/// Validates existence of referenced entities
///
/// # Errors
///
/// Returns an error if the task doesn't exist, validation fails, or the database update fails
#[instrument(skip(self))]
pub async fn update_task(&self, request: UpdateTaskRequest) -> ThingsResult<()> {
// Verify task exists
validators::validate_task_exists(&self.pool, &request.uuid).await?;
// Validate dates if either is being updated
if request.start_date.is_some() || request.deadline.is_some() {
// Get current task to merge dates
if let Some(current_task) = self.get_task_by_uuid(&request.uuid).await? {
let final_start = request.start_date.or(current_task.start_date);
let final_deadline = request.deadline.or(current_task.deadline);
crate::database::validate_date_range(final_start, final_deadline)?;
}
}
// Validate referenced entities if being updated
if let Some(project_uuid) = &request.project_uuid {
validators::validate_project_exists(&self.pool, project_uuid).await?;
}
if let Some(area_uuid) = &request.area_uuid {
validators::validate_area_exists(&self.pool, area_uuid).await?;
}
// Use the TaskUpdateBuilder to construct the query
let builder = TaskUpdateBuilder::from_request(&request);
// If no fields to update, just return (modification date will still be updated)
if builder.is_empty() {
return Ok(());
}
let query_string = builder.build_query_string();
let mut q = sqlx::query(&query_string);
// Bind values in the same order as the builder added fields
if let Some(title) = &request.title {
q = q.bind(title);
}
if let Some(notes) = &request.notes {
q = q.bind(notes);
}
if let Some(start_date) = request.start_date {
q = q.bind(naive_date_to_things_timestamp(start_date));
}
if let Some(deadline) = request.deadline {
q = q.bind(naive_date_to_things_timestamp(deadline));
}
if let Some(status) = request.status {
q = q.bind(status as i32);
}
if let Some(project_uuid) = request.project_uuid {
q = q.bind(project_uuid.into_string());
}
if let Some(area_uuid) = request.area_uuid {
q = q.bind(area_uuid.into_string());
}
// Bind modification date and UUID (always added by builder)
let now = Utc::now().timestamp() as f64;
q = q.bind(now).bind(request.uuid.as_str());
q.execute(&self.pool)
.await
.map_err(|e| ThingsError::unknown(format!("Failed to update task: {e}")))?;
// Handle tags via TMTaskTag (separate from the UPDATE query)
if let Some(tags) = request.tags {
self.set_task_tags(&request.uuid, tags).await?;
}
info!("Updated task with UUID: {}", request.uuid);
Ok(())
}
/// Mark a task as completed
///
/// # Errors
///
/// Returns an error if the task does not exist or if the database update fails
#[instrument(skip(self))]
pub async fn complete_task(&self, id: &ThingsId) -> ThingsResult<()> {
// Verify task exists
validators::validate_task_exists(&self.pool, id).await?;
let now = Utc::now().timestamp() as f64;
sqlx::query(
"UPDATE TMTask SET status = 3, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
)
.bind(now)
.bind(now)
.bind(id.as_str())
.execute(&self.pool)
.await
.map_err(|e| ThingsError::unknown(format!("Failed to complete task: {e}")))?;
info!("Completed task with UUID: {}", id);
Ok(())
}
/// Mark a completed task as incomplete
///
/// # Errors
///
/// Returns an error if the task does not exist or if the database update fails
#[instrument(skip(self))]
pub async fn uncomplete_task(&self, id: &ThingsId) -> ThingsResult<()> {
// Verify task exists
validators::validate_task_exists(&self.pool, id).await?;
let now = Utc::now().timestamp() as f64;
sqlx::query(
"UPDATE TMTask SET status = 0, stopDate = NULL, userModificationDate = ? WHERE uuid = ?",
)
.bind(now)
.bind(id.as_str())
.execute(&self.pool)
.await
.map_err(|e| ThingsError::unknown(format!("Failed to uncomplete task: {e}")))?;
info!("Uncompleted task with UUID: {}", id);
Ok(())
}
/// Soft delete a task (set trashed flag)
///
/// # Errors
///
/// Returns an error if the task does not exist, if child handling fails, or if the database update fails
#[instrument(skip(self))]
pub async fn delete_task(
&self,
id: &ThingsId,
child_handling: DeleteChildHandling,
) -> ThingsResult<()> {
// Verify task exists
validators::validate_task_exists(&self.pool, id).await?;
// Check for child tasks
let children = sqlx::query("SELECT uuid FROM TMTask WHERE heading = ? AND trashed = 0")
.bind(id.as_str())
.fetch_all(&self.pool)
.await
.map_err(|e| ThingsError::unknown(format!("Failed to query child tasks: {e}")))?;
let has_children = !children.is_empty();
if has_children {
match child_handling {
DeleteChildHandling::Error => {
return Err(ThingsError::unknown(format!(
"Task {} has {} child task(s). Use cascade or orphan mode to delete.",
id,
children.len()
)));
}
DeleteChildHandling::Cascade => {
// Delete all children
let now = Utc::now().timestamp() as f64;
for child_row in &children {
let child_uuid: String = child_row.get("uuid");
sqlx::query(
"UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?",
)
.bind(now)
.bind(&child_uuid)
.execute(&self.pool)
.await
.map_err(|e| {
ThingsError::unknown(format!("Failed to delete child task: {e}"))
})?;
}
info!("Cascade deleted {} child task(s)", children.len());
}
DeleteChildHandling::Orphan => {
// Clear parent reference for children
let now = Utc::now().timestamp() as f64;
for child_row in &children {
let child_uuid: String = child_row.get("uuid");
sqlx::query(
"UPDATE TMTask SET heading = NULL, userModificationDate = ? WHERE uuid = ?",
)
.bind(now)
.bind(&child_uuid)
.execute(&self.pool)
.await
.map_err(|e| {
ThingsError::unknown(format!("Failed to orphan child task: {e}"))
})?;
}
info!("Orphaned {} child task(s)", children.len());
}
}
}
// Delete the parent task
let now = Utc::now().timestamp() as f64;
sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
.bind(now)
.bind(id.as_str())
.execute(&self.pool)
.await
.map_err(|e| ThingsError::unknown(format!("Failed to delete task: {e}")))?;
info!("Deleted task with UUID: {}", id);
Ok(())
}
}