Skip to main content

things3_core/database/
mappers.rs

1//! Row mapping utilities for converting database rows to domain models
2//!
3//! This module provides reusable mapping functions to eliminate duplication
4//! in Task construction from SQL query results.
5
6use crate::{
7    database::{safe_timestamp_convert, things_date_to_naive_date, things_uuid_to_uuid},
8    error::Result as ThingsResult,
9    models::{Project, Task, TaskStatus, TaskType},
10};
11use chrono::{DateTime, Utc};
12use sqlx::sqlite::SqliteRow;
13use sqlx::Row;
14use uuid::Uuid;
15
16/// Parse a UUID string with fallback to Things UUID conversion
17///
18/// First attempts to parse as a standard UUID format, then falls back
19/// to the Things 3 UUID conversion if that fails.
20pub fn parse_uuid_with_fallback(uuid_str: &str) -> Uuid {
21    Uuid::parse_str(uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(uuid_str))
22}
23
24/// Parse an optional UUID string with fallback
25///
26/// Handles `Option<String>` from database columns, returning None if the
27/// input is None, otherwise using the fallback UUID parsing logic.
28pub fn parse_optional_uuid(opt_str: Option<String>) -> Option<Uuid> {
29    opt_str.map(|s| {
30        Uuid::parse_str(&s)
31            .ok()
32            .unwrap_or_else(|| things_uuid_to_uuid(&s))
33    })
34}
35
36/// Map a database row to a Task struct
37///
38/// This function centralizes all the logic for constructing a Task from
39/// a SQLite row, including UUID parsing, date conversion, and field mapping.
40///
41/// # Errors
42///
43/// Returns an error if required fields are missing or cannot be converted
44pub fn map_task_row(row: &SqliteRow) -> ThingsResult<Task> {
45    let uuid_str: String = row.get("uuid");
46    let uuid = parse_uuid_with_fallback(&uuid_str);
47
48    let title: String = row.get("title");
49
50    let status_i32: i32 = row.get("status");
51    let status = match status_i32 {
52        1 => TaskStatus::Completed,
53        2 => TaskStatus::Canceled,
54        3 => TaskStatus::Trashed,
55        _ => TaskStatus::Incomplete,
56    };
57
58    let type_i32: i32 = row.get("type");
59    let task_type = match type_i32 {
60        1 => TaskType::Project,
61        2 => TaskType::Heading,
62        _ => TaskType::Todo,
63    };
64
65    let notes: Option<String> = row.get("notes");
66
67    let start_date = row
68        .get::<Option<i64>, _>("startDate")
69        .and_then(things_date_to_naive_date);
70
71    let deadline = row
72        .get::<Option<i64>, _>("deadline")
73        .and_then(things_date_to_naive_date);
74
75    let creation_ts: f64 = row.get("creationDate");
76    let created = {
77        let ts = safe_timestamp_convert(creation_ts);
78        DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
79    };
80
81    let modification_ts: f64 = row.get("userModificationDate");
82    let modified = {
83        let ts = safe_timestamp_convert(modification_ts);
84        DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
85    };
86
87    let stop_date = row.get::<Option<f64>, _>("stopDate").and_then(|ts| {
88        let ts_i64 = safe_timestamp_convert(ts);
89        DateTime::from_timestamp(ts_i64, 0)
90    });
91
92    let project_uuid = row
93        .get::<Option<String>, _>("project")
94        .map(|s| parse_uuid_with_fallback(&s));
95
96    let area_uuid = row
97        .get::<Option<String>, _>("area")
98        .map(|s| parse_uuid_with_fallback(&s));
99
100    let parent_uuid = row
101        .get::<Option<String>, _>("heading")
102        .map(|s| parse_uuid_with_fallback(&s));
103
104    // Try to get cachedTags as binary data and parse it
105    let tags = row
106        .get::<Option<Vec<u8>>, _>("cachedTags")
107        .and_then(|blob| {
108            // Parse the JSON blob into a Vec<String>
109            crate::database::deserialize_tags_from_blob(&blob).ok()
110        })
111        .unwrap_or_default();
112
113    Ok(Task {
114        uuid,
115        title,
116        status,
117        task_type,
118        notes,
119        start_date,
120        deadline,
121        created,
122        modified,
123        stop_date,
124        project_uuid,
125        area_uuid,
126        parent_uuid,
127        tags,
128        children: Vec::new(),
129    })
130}
131
132/// Map a `TMTask` row (where `type = 1`) into a [`Project`].
133pub fn map_project_row(row: &SqliteRow) -> Project {
134    Project {
135        uuid: parse_uuid_with_fallback(&row.get::<String, _>("uuid")),
136        title: row.get("title"),
137        status: match row.get::<i32, _>("status") {
138            1 => TaskStatus::Completed,
139            2 => TaskStatus::Canceled,
140            3 => TaskStatus::Trashed,
141            _ => TaskStatus::Incomplete,
142        },
143        area_uuid: parse_optional_uuid(row.get::<Option<String>, _>("area")),
144        notes: row.get("notes"),
145        deadline: row
146            .get::<Option<i64>, _>("deadline")
147            .and_then(|ts| DateTime::from_timestamp(ts, 0))
148            .map(|dt| dt.date_naive()),
149        start_date: row
150            .get::<Option<i64>, _>("startDate")
151            .and_then(|ts| DateTime::from_timestamp(ts, 0))
152            .map(|dt| dt.date_naive()),
153        tags: Vec::new(),
154        tasks: Vec::new(),
155        created: {
156            let ts = safe_timestamp_convert(row.get::<f64, _>("creationDate"));
157            DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
158        },
159        modified: {
160            let ts = safe_timestamp_convert(row.get::<f64, _>("userModificationDate"));
161            DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
162        },
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_parse_uuid_with_fallback_standard() {
172        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
173        let uuid = parse_uuid_with_fallback(uuid_str);
174        assert_eq!(uuid.to_string(), uuid_str);
175    }
176
177    #[test]
178    fn test_parse_uuid_with_fallback_things_format() {
179        // Things 3 uses a different format - should fall back to things_uuid_to_uuid
180        let things_id = "ABC123XYZ";
181        let uuid1 = parse_uuid_with_fallback(things_id);
182        let uuid2 = parse_uuid_with_fallback(things_id);
183        // Should be consistent
184        assert_eq!(uuid1, uuid2);
185    }
186
187    #[test]
188    fn test_parse_optional_uuid_some() {
189        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
190        let result = parse_optional_uuid(Some(uuid_str.to_string()));
191        assert!(result.is_some());
192        assert_eq!(result.unwrap().to_string(), uuid_str);
193    }
194
195    #[test]
196    fn test_parse_optional_uuid_none() {
197        let result = parse_optional_uuid(None);
198        assert!(result.is_none());
199    }
200}