use crate::db::row_mapping::parse_datetime;
use crate::db::Database;
use crate::models::{Link, Scrap, TodoStatus};
use crate::utils::{Result, TrackError};
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
pub struct LinkService<'a> {
db: &'a Database,
}
impl<'a> LinkService<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
pub fn add_link(&self, task_id: i64, url: &str, title: Option<&str>) -> Result<Link> {
self.validate_url(url)?;
let title = title.unwrap_or(url).to_string();
let url = url.to_string();
let now = Utc::now().to_rfc3339();
self.db.with_transaction(|| {
let conn = self.db.get_connection();
let next_index: i64 = conn.query_row(
"SELECT COALESCE(MAX(task_index), 0) + 1 FROM links WHERE task_id = ?1",
params![task_id],
|row| row.get(0),
)?;
conn.execute(
"INSERT INTO links (task_id, task_index, url, title, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![task_id, next_index, url, title, now],
)?;
let link_id = conn.last_insert_rowid();
self.db.increment_rev("links")?;
self.get_link(link_id)
})
}
pub fn get_link(&self, link_id: i64) -> Result<Link> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare(
"SELECT id, task_id, task_index, url, title, created_at FROM links WHERE id = ?1",
)?;
let link = stmt.query_row(params![link_id], |row| {
Ok(Link {
id: row.get(0)?,
task_id: row.get(1)?,
task_index: row.get(2)?,
url: row.get(3)?,
title: row.get(4)?,
created_at: parse_datetime(row.get::<_, String>(5)?)?,
})
})?;
Ok(link)
}
pub fn list_links(&self, task_id: i64) -> Result<Vec<Link>> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare(
"SELECT id, task_id, task_index, url, title, created_at FROM links WHERE task_id = ?1 ORDER BY task_index ASC"
)?;
let links = stmt
.query_map(params![task_id], |row| {
Ok(Link {
id: row.get(0)?,
task_id: row.get(1)?,
task_index: row.get(2)?,
url: row.get(3)?,
title: row.get(4)?,
created_at: parse_datetime(row.get::<_, String>(5)?)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(links)
}
fn validate_url(&self, url: &str) -> Result<()> {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(())
} else {
Err(TrackError::InvalidUrl(url.to_string()))
}
}
pub fn delete_link(&self, link_id: i64) -> Result<()> {
let conn = self.db.get_connection();
let affected = conn.execute("DELETE FROM links WHERE id = ?1", params![link_id])?;
if affected == 0 {
return Err(TrackError::Other(format!("Link #{} not found", link_id)));
}
self.db.increment_rev("links")?;
Ok(())
}
}
pub struct ScrapService<'a> {
db: &'a Database,
}
impl<'a> ScrapService<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
pub fn add_scrap(&self, task_id: i64, content: &str) -> Result<Scrap> {
if content.trim().is_empty() {
return Err(TrackError::EmptyScrapContent);
}
let now = Utc::now().to_rfc3339();
let content = content.to_string();
self.db.with_transaction(|| {
let conn = self.db.get_connection();
let next_index: i64 = conn.query_row(
"SELECT COALESCE(MAX(task_index), 0) + 1 FROM scraps WHERE task_id = ?1",
params![task_id],
|row| row.get(0),
)?;
let active_query = format!(
"SELECT task_index FROM todos WHERE task_id = ?1 AND status = '{}' ORDER BY task_index ASC LIMIT 1",
TodoStatus::PENDING
);
let active_todo_id: Option<i64> = conn
.query_row(&active_query, params![task_id], |row| row.get(0))
.optional()?;
conn.execute(
"INSERT INTO scraps (task_id, task_index, content, created_at, active_todo_id) VALUES (?1, ?2, ?3, ?4, ?5)",
params![task_id, next_index, content, now, active_todo_id],
)?;
let scrap_id = conn.last_insert_rowid();
self.db.increment_rev("scraps")?;
self.get_scrap(scrap_id)
})
}
pub fn get_scrap(&self, scrap_id: i64) -> Result<Scrap> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare(
"SELECT id, task_id, task_index, content, created_at, active_todo_id FROM scraps WHERE id = ?1",
)?;
let scrap = stmt.query_row(params![scrap_id], |row| {
Ok(Scrap {
id: row.get(0)?,
task_id: row.get(1)?,
scrap_id: row.get(2)?,
content: row.get(3)?,
created_at: parse_datetime(row.get::<_, String>(4)?)?,
active_todo_id: row.get(5)?,
})
})?;
Ok(scrap)
}
pub fn list_scraps(&self, task_id: i64) -> Result<Vec<Scrap>> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare(
"SELECT id, task_id, task_index, content, created_at, active_todo_id FROM scraps WHERE task_id = ?1 ORDER BY created_at ASC"
)?;
let scraps = stmt
.query_map(params![task_id], |row| {
Ok(Scrap {
id: row.get(0)?,
task_id: row.get(1)?,
scrap_id: row.get(2)?,
content: row.get(3)?,
created_at: parse_datetime(row.get::<_, String>(4)?)?,
active_todo_id: row.get(5)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(scraps)
}
pub fn copy_linked_scraps(
&self,
from_task_id: i64,
to_task_id: i64,
todo_mapping: &std::collections::HashMap<i64, i64>,
) -> Result<()> {
if todo_mapping.is_empty() {
return Ok(());
}
self.db.with_transaction(|| {
self.copy_linked_scraps_in_tx(from_task_id, to_task_id, todo_mapping)
})
}
pub(crate) fn copy_linked_scraps_in_tx(
&self,
from_task_id: i64,
to_task_id: i64,
todo_mapping: &std::collections::HashMap<i64, i64>,
) -> Result<()> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare(
"SELECT content, active_todo_id FROM scraps WHERE task_id = ?1 AND active_todo_id IS NOT NULL ORDER BY created_at ASC"
)?;
let linked_scraps: Vec<(String, i64)> = stmt
.query_map(params![from_task_id], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?;
let has_scraps = !linked_scraps.is_empty();
for (content, old_todo_index) in linked_scraps {
if let Some(&new_todo_index) = todo_mapping.get(&old_todo_index) {
let next_index: i64 = conn.query_row(
"SELECT COALESCE(MAX(task_index), 0) + 1 FROM scraps WHERE task_id = ?1",
params![to_task_id],
|row| row.get(0),
)?;
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO scraps (task_id, task_index, content, created_at, active_todo_id) VALUES (?1, ?2, ?3, ?4, ?5)",
params![to_task_id, next_index, content, now, new_todo_index],
)?;
}
}
if has_scraps {
self.db.increment_rev("scraps")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::services::TaskService;
fn setup_db() -> Database {
Database::new_in_memory().unwrap()
}
fn create_test_task(db: &Database) -> i64 {
let task_service = TaskService::new(db);
task_service
.create_task("Test Task", None, None, None)
.unwrap()
.id
}
#[test]
fn test_add_link_success() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = LinkService::new(&db);
let link = service
.add_link(task_id, "https://example.com", Some("Example"))
.unwrap();
assert_eq!(link.url, "https://example.com");
assert_eq!(link.title, "Example");
}
#[test]
fn test_add_link_default_title() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = LinkService::new(&db);
let link = service
.add_link(task_id, "https://example.com", None)
.unwrap();
assert_eq!(link.title, "https://example.com");
}
#[test]
fn test_add_link_invalid_url() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = LinkService::new(&db);
let result = service.add_link(task_id, "invalid-url", None);
assert!(matches!(result, Err(TrackError::InvalidUrl(_))));
}
#[test]
fn test_list_links() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = LinkService::new(&db);
service
.add_link(task_id, "https://example1.com", None)
.unwrap();
service
.add_link(task_id, "https://example2.com", None)
.unwrap();
let links = service.list_links(task_id).unwrap();
assert_eq!(links.len(), 2);
}
#[test]
fn test_validate_url_http() {
let db = setup_db();
let service = LinkService::new(&db);
assert!(service.validate_url("http://example.com").is_ok());
}
#[test]
fn test_validate_url_https() {
let db = setup_db();
let service = LinkService::new(&db);
assert!(service.validate_url("https://example.com").is_ok());
}
#[test]
fn test_validate_url_invalid() {
let db = setup_db();
let service = LinkService::new(&db);
assert!(matches!(
service.validate_url("ftp://example.com"),
Err(TrackError::InvalidUrl(_))
));
}
#[test]
fn test_delete_link_success() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = LinkService::new(&db);
let link = service
.add_link(task_id, "https://example.com", Some("Example"))
.unwrap();
service.delete_link(link.id).unwrap();
let links = service.list_links(task_id).unwrap();
assert_eq!(links.len(), 0);
}
#[test]
fn test_delete_link_not_found() {
let db = setup_db();
let service = LinkService::new(&db);
let result = service.delete_link(999);
assert!(result.is_err());
}
#[test]
fn test_add_scrap_empty_content() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = ScrapService::new(&db);
let result = service.add_scrap(task_id, " ");
assert!(matches!(result, Err(TrackError::EmptyScrapContent)));
}
#[test]
fn test_add_scrap_success() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = ScrapService::new(&db);
let scrap = service.add_scrap(task_id, "Test scrap content").unwrap();
assert_eq!(scrap.content, "Test scrap content");
}
#[test]
fn test_get_scrap_success() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = ScrapService::new(&db);
let created = service.add_scrap(task_id, "Test scrap").unwrap();
let retrieved = service.get_scrap(created.id).unwrap();
assert_eq!(retrieved.id, created.id);
assert_eq!(retrieved.content, "Test scrap");
}
#[test]
fn test_list_scraps() {
let db = setup_db();
let task_id = create_test_task(&db);
let service = ScrapService::new(&db);
service.add_scrap(task_id, "Scrap 1").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
service.add_scrap(task_id, "Scrap 2").unwrap();
let scraps = service.list_scraps(task_id).unwrap();
assert_eq!(scraps.len(), 2);
assert_eq!(scraps[0].content, "Scrap 1");
assert_eq!(scraps[1].content, "Scrap 2");
}
#[test]
fn test_copy_linked_scraps_public_api() {
use crate::services::TodoService;
use std::collections::HashMap;
let db = setup_db();
let from_task = create_test_task(&db);
let to_task = create_test_task(&db);
let todo_service = TodoService::new(&db);
let scrap_service = ScrapService::new(&db);
let todo = todo_service.add_todo(from_task, "Todo", false).unwrap();
scrap_service.add_scrap(from_task, "Linked scrap").unwrap();
let mut mapping = HashMap::new();
mapping.insert(todo.task_index, 1);
scrap_service
.copy_linked_scraps(from_task, to_task, &mapping)
.unwrap();
let copied = scrap_service.list_scraps(to_task).unwrap();
assert_eq!(copied.len(), 1);
assert_eq!(copied[0].content, "Linked scrap");
assert_eq!(copied[0].active_todo_id, Some(1));
}
}