1use crate::db::row_mapping::parse_datetime;
2use crate::db::Database;
3use crate::models::{Link, Scrap, TodoStatus};
4use crate::utils::{Result, TrackError};
5use chrono::Utc;
6use rusqlite::{params, OptionalExtension};
7
8pub struct LinkService<'a> {
9 db: &'a Database,
10}
11
12impl<'a> LinkService<'a> {
13 pub fn new(db: &'a Database) -> Self {
14 Self { db }
15 }
16
17 pub fn add_link(&self, task_id: i64, url: &str, title: Option<&str>) -> Result<Link> {
18 self.validate_url(url)?;
19
20 let title = title.unwrap_or(url).to_string();
21 let url = url.to_string();
22 let now = Utc::now().to_rfc3339();
23
24 self.db.with_transaction(|| {
26 let conn = self.db.get_connection();
27
28 let next_index: i64 = conn.query_row(
30 "SELECT COALESCE(MAX(task_index), 0) + 1 FROM links WHERE task_id = ?1",
31 params![task_id],
32 |row| row.get(0),
33 )?;
34
35 conn.execute(
36 "INSERT INTO links (task_id, task_index, url, title, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
37 params![task_id, next_index, url, title, now],
38 )?;
39
40 let link_id = conn.last_insert_rowid();
41 self.db.increment_rev("links")?;
42 self.get_link(link_id)
43 })
44 }
45
46 pub fn get_link(&self, link_id: i64) -> Result<Link> {
47 let conn = self.db.get_connection();
48 let mut stmt = conn.prepare(
49 "SELECT id, task_id, task_index, url, title, created_at FROM links WHERE id = ?1",
50 )?;
51
52 let link = stmt.query_row(params![link_id], |row| {
53 Ok(Link {
54 id: row.get(0)?,
55 task_id: row.get(1)?,
56 task_index: row.get(2)?,
57 url: row.get(3)?,
58 title: row.get(4)?,
59 created_at: parse_datetime(row.get::<_, String>(5)?)?,
60 })
61 })?;
62
63 Ok(link)
64 }
65
66 pub fn list_links(&self, task_id: i64) -> Result<Vec<Link>> {
67 let conn = self.db.get_connection();
68 let mut stmt = conn.prepare(
69 "SELECT id, task_id, task_index, url, title, created_at FROM links WHERE task_id = ?1 ORDER BY task_index ASC"
70 )?;
71
72 let links = stmt
73 .query_map(params![task_id], |row| {
74 Ok(Link {
75 id: row.get(0)?,
76 task_id: row.get(1)?,
77 task_index: row.get(2)?,
78 url: row.get(3)?,
79 title: row.get(4)?,
80 created_at: parse_datetime(row.get::<_, String>(5)?)?,
81 })
82 })?
83 .collect::<std::result::Result<Vec<_>, _>>()?;
84
85 Ok(links)
86 }
87
88 fn validate_url(&self, url: &str) -> Result<()> {
89 if url.starts_with("http://") || url.starts_with("https://") {
90 Ok(())
91 } else {
92 Err(TrackError::InvalidUrl(url.to_string()))
93 }
94 }
95
96 pub fn delete_link(&self, link_id: i64) -> Result<()> {
98 let conn = self.db.get_connection();
99 let affected = conn.execute("DELETE FROM links WHERE id = ?1", params![link_id])?;
100
101 if affected == 0 {
102 return Err(TrackError::LinkNotFound(link_id));
103 }
104
105 self.db.increment_rev("links")?;
106 Ok(())
107 }
108}
109
110pub struct ScrapService<'a> {
111 db: &'a Database,
112}
113
114impl<'a> ScrapService<'a> {
115 pub fn new(db: &'a Database) -> Self {
116 Self { db }
117 }
118
119 pub fn add_scrap(&self, task_id: i64, content: &str) -> Result<Scrap> {
120 if content.trim().is_empty() {
121 return Err(TrackError::EmptyScrapContent);
122 }
123
124 let now = Utc::now().to_rfc3339();
125 let content = content.to_string();
126
127 self.db.with_transaction(|| {
129 let conn = self.db.get_connection();
130
131 let next_index: i64 = conn.query_row(
133 "SELECT COALESCE(MAX(task_index), 0) + 1 FROM scraps WHERE task_id = ?1",
134 params![task_id],
135 |row| row.get(0),
136 )?;
137
138 let active_query = format!(
140 "SELECT task_index FROM todos WHERE task_id = ?1 AND status = '{}' ORDER BY task_index ASC LIMIT 1",
141 TodoStatus::PENDING
142 );
143 let active_todo_id: Option<i64> = conn
144 .query_row(&active_query, params![task_id], |row| row.get(0))
145 .optional()?;
146
147 conn.execute(
148 "INSERT INTO scraps (task_id, task_index, content, created_at, active_todo_id) VALUES (?1, ?2, ?3, ?4, ?5)",
149 params![task_id, next_index, content, now, active_todo_id],
150 )?;
151
152 let scrap_id = conn.last_insert_rowid();
153 self.db.increment_rev("scraps")?;
154 self.get_scrap(scrap_id)
155 })
156 }
157
158 pub fn get_scrap(&self, scrap_id: i64) -> Result<Scrap> {
159 let conn = self.db.get_connection();
160 let mut stmt = conn.prepare(
161 "SELECT id, task_id, task_index, content, created_at, active_todo_id FROM scraps WHERE id = ?1",
162 )?;
163
164 let scrap = stmt.query_row(params![scrap_id], |row| {
165 Ok(Scrap {
166 id: row.get(0)?,
167 task_id: row.get(1)?,
168 scrap_id: row.get(2)?,
169 content: row.get(3)?,
170 created_at: parse_datetime(row.get::<_, String>(4)?)?,
171 active_todo_id: row.get(5)?,
172 })
173 })?;
174
175 Ok(scrap)
176 }
177
178 pub fn list_scraps(&self, task_id: i64) -> Result<Vec<Scrap>> {
179 let conn = self.db.get_connection();
180 let mut stmt = conn.prepare(
181 "SELECT id, task_id, task_index, content, created_at, active_todo_id FROM scraps WHERE task_id = ?1 ORDER BY created_at ASC"
182 )?;
183
184 let scraps = stmt
185 .query_map(params![task_id], |row| {
186 Ok(Scrap {
187 id: row.get(0)?,
188 task_id: row.get(1)?,
189 scrap_id: row.get(2)?,
190 content: row.get(3)?,
191 created_at: parse_datetime(row.get::<_, String>(4)?)?,
192 active_todo_id: row.get(5)?,
193 })
194 })?
195 .collect::<std::result::Result<Vec<_>, _>>()?;
196
197 Ok(scraps)
198 }
199
200 pub fn copy_linked_scraps(
208 &self,
209 from_task_id: i64,
210 to_task_id: i64,
211 todo_mapping: &std::collections::HashMap<i64, i64>,
212 ) -> Result<()> {
213 if todo_mapping.is_empty() {
214 return Ok(());
215 }
216
217 self.db.with_transaction(|| {
218 self.copy_linked_scraps_in_tx(from_task_id, to_task_id, todo_mapping)
219 })
220 }
221
222 pub(crate) fn copy_linked_scraps_in_tx(
224 &self,
225 from_task_id: i64,
226 to_task_id: i64,
227 todo_mapping: &std::collections::HashMap<i64, i64>,
228 ) -> Result<()> {
229 let conn = self.db.get_connection();
230
231 let mut stmt = conn.prepare(
232 "SELECT content, active_todo_id FROM scraps WHERE task_id = ?1 AND active_todo_id IS NOT NULL ORDER BY created_at ASC"
233 )?;
234
235 let linked_scraps: Vec<(String, i64)> = stmt
236 .query_map(params![from_task_id], |row| Ok((row.get(0)?, row.get(1)?)))?
237 .collect::<std::result::Result<Vec<_>, _>>()?;
238
239 let has_scraps = !linked_scraps.is_empty();
240
241 for (content, old_todo_index) in linked_scraps {
242 if let Some(&new_todo_index) = todo_mapping.get(&old_todo_index) {
243 let next_index: i64 = conn.query_row(
244 "SELECT COALESCE(MAX(task_index), 0) + 1 FROM scraps WHERE task_id = ?1",
245 params![to_task_id],
246 |row| row.get(0),
247 )?;
248
249 let now = Utc::now().to_rfc3339();
250
251 conn.execute(
252 "INSERT INTO scraps (task_id, task_index, content, created_at, active_todo_id) VALUES (?1, ?2, ?3, ?4, ?5)",
253 params![to_task_id, next_index, content, now, new_todo_index],
254 )?;
255 }
256 }
257
258 if has_scraps {
259 self.db.increment_rev("scraps")?;
260 }
261
262 Ok(())
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::db::Database;
270 use crate::services::TaskService;
271
272 fn setup_db() -> Database {
273 Database::new_in_memory().unwrap()
274 }
275
276 fn create_test_task(db: &Database) -> i64 {
277 let task_service = TaskService::new(db);
278 task_service
279 .create_task("Test Task", None, None, None)
280 .unwrap()
281 .id
282 }
283
284 #[test]
286 fn test_add_link_success() {
287 let db = setup_db();
288 let task_id = create_test_task(&db);
289 let service = LinkService::new(&db);
290
291 let link = service
292 .add_link(task_id, "https://example.com", Some("Example"))
293 .unwrap();
294 assert_eq!(link.url, "https://example.com");
295 assert_eq!(link.title, "Example");
296 }
297
298 #[test]
299 fn test_add_link_default_title() {
300 let db = setup_db();
301 let task_id = create_test_task(&db);
302 let service = LinkService::new(&db);
303
304 let link = service
305 .add_link(task_id, "https://example.com", None)
306 .unwrap();
307 assert_eq!(link.title, "https://example.com");
308 }
309
310 #[test]
311 fn test_add_link_invalid_url() {
312 let db = setup_db();
313 let task_id = create_test_task(&db);
314 let service = LinkService::new(&db);
315
316 let result = service.add_link(task_id, "invalid-url", None);
317 assert!(matches!(result, Err(TrackError::InvalidUrl(_))));
318 }
319
320 #[test]
321 fn test_list_links() {
322 let db = setup_db();
323 let task_id = create_test_task(&db);
324 let service = LinkService::new(&db);
325
326 service
327 .add_link(task_id, "https://example1.com", None)
328 .unwrap();
329 service
330 .add_link(task_id, "https://example2.com", None)
331 .unwrap();
332
333 let links = service.list_links(task_id).unwrap();
334 assert_eq!(links.len(), 2);
335 }
336
337 #[test]
338 fn test_validate_url_http() {
339 let db = setup_db();
340 let service = LinkService::new(&db);
341
342 assert!(service.validate_url("http://example.com").is_ok());
343 }
344
345 #[test]
346 fn test_validate_url_https() {
347 let db = setup_db();
348 let service = LinkService::new(&db);
349
350 assert!(service.validate_url("https://example.com").is_ok());
351 }
352
353 #[test]
354 fn test_validate_url_invalid() {
355 let db = setup_db();
356 let service = LinkService::new(&db);
357
358 assert!(matches!(
359 service.validate_url("ftp://example.com"),
360 Err(TrackError::InvalidUrl(_))
361 ));
362 }
363
364 #[test]
365 fn test_delete_link_success() {
366 let db = setup_db();
367 let task_id = create_test_task(&db);
368 let service = LinkService::new(&db);
369
370 let link = service
371 .add_link(task_id, "https://example.com", Some("Example"))
372 .unwrap();
373
374 service.delete_link(link.id).unwrap();
376
377 let links = service.list_links(task_id).unwrap();
379 assert_eq!(links.len(), 0);
380 }
381
382 #[test]
383 fn test_delete_link_not_found() {
384 let db = setup_db();
385 let service = LinkService::new(&db);
386
387 let result = service.delete_link(999);
389 assert!(result.is_err());
390 }
391
392 #[test]
394 fn test_add_scrap_empty_content() {
395 let db = setup_db();
396 let task_id = create_test_task(&db);
397 let service = ScrapService::new(&db);
398
399 let result = service.add_scrap(task_id, " ");
400 assert!(matches!(result, Err(TrackError::EmptyScrapContent)));
401 }
402
403 #[test]
404 fn test_add_scrap_success() {
405 let db = setup_db();
406 let task_id = create_test_task(&db);
407 let service = ScrapService::new(&db);
408
409 let scrap = service.add_scrap(task_id, "Test scrap content").unwrap();
410 assert_eq!(scrap.content, "Test scrap content");
411 }
412
413 #[test]
414 fn test_get_scrap_success() {
415 let db = setup_db();
416 let task_id = create_test_task(&db);
417 let service = ScrapService::new(&db);
418
419 let created = service.add_scrap(task_id, "Test scrap").unwrap();
420 let retrieved = service.get_scrap(created.id).unwrap();
421 assert_eq!(retrieved.id, created.id);
422 assert_eq!(retrieved.content, "Test scrap");
423 }
424
425 #[test]
426 fn test_list_scraps() {
427 let db = setup_db();
428 let task_id = create_test_task(&db);
429 let service = ScrapService::new(&db);
430
431 service.add_scrap(task_id, "Scrap 1").unwrap();
432 std::thread::sleep(std::time::Duration::from_millis(10));
433 service.add_scrap(task_id, "Scrap 2").unwrap();
434
435 let scraps = service.list_scraps(task_id).unwrap();
436 assert_eq!(scraps.len(), 2);
437 assert_eq!(scraps[0].content, "Scrap 1");
439 assert_eq!(scraps[1].content, "Scrap 2");
440 }
441
442 #[test]
443 fn test_copy_linked_scraps_public_api() {
444 use crate::services::TodoService;
445 use std::collections::HashMap;
446
447 let db = setup_db();
448 let from_task = create_test_task(&db);
449 let to_task = create_test_task(&db);
450 let todo_service = TodoService::new(&db);
451 let scrap_service = ScrapService::new(&db);
452
453 let todo = todo_service.add_todo(from_task, "Todo", false).unwrap();
454 scrap_service.add_scrap(from_task, "Linked scrap").unwrap();
455
456 let mut mapping = HashMap::new();
457 mapping.insert(todo.task_index, 1);
458
459 scrap_service
460 .copy_linked_scraps(from_task, to_task, &mapping)
461 .unwrap();
462
463 let copied = scrap_service.list_scraps(to_task).unwrap();
464 assert_eq!(copied.len(), 1);
465 assert_eq!(copied[0].content, "Linked scrap");
466 assert_eq!(copied[0].active_todo_id, Some(1));
467 }
468}