Skip to main content

void/db/
mod.rs

1mod export;
2mod import;
3mod schema;
4
5use std::path::PathBuf;
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection};
10
11use crate::model::{
12    AppData, EmptyQueueBehavior, EstimateCompleteBehavior, FocusSessionRecord, Priority,
13    StoredSession, Task, TaskStatus, ThemeVariant, TimerMode,
14};
15
16pub struct Database {
17    conn: Connection,
18}
19
20impl Database {
21    pub fn open() -> Result<Self> {
22        let path = db_path()?;
23        let existed = path.exists();
24        let conn = Connection::open(&path).context("opening SQLite database")?;
25        conn.pragma_update(None, "journal_mode", "WAL")?;
26        conn.pragma_update(None, "foreign_keys", "ON")?;
27        schema::migrate(&conn)?;
28
29        let db = Self { conn };
30        if !existed {
31            let json = legacy_json_path()?;
32            if json.exists() {
33                import::import_json(&db, &json)?;
34                let backup = json.with_extension("json.migrated");
35                let _ = std::fs::rename(&json, &backup);
36            }
37        }
38        Ok(db)
39    }
40
41    pub fn load_app_data(&self) -> Result<AppData> {
42        let mut data = AppData::default();
43        load_settings(&self.conn, &mut data)?;
44        data.tasks = load_tasks(&self.conn)?;
45        data.session_history = Vec::new();
46        Ok(data)
47    }
48
49    pub fn save_app_data(&self, data: &AppData) -> Result<()> {
50        let tx = self.conn.unchecked_transaction()?;
51        save_settings(&tx, data)?;
52        sync_tasks(&tx, &data.tasks)?;
53        tx.commit()?;
54        Ok(())
55    }
56
57    pub fn insert_focus_session(&self, record: &FocusSessionRecord) -> Result<i64> {
58        self.conn.execute(
59            "INSERT INTO focus_sessions (date, minutes, task_id, mode, completed_at)
60             VALUES (?1, ?2, ?3, ?4, ?5)",
61            params![
62                record.date,
63                record.minutes,
64                record.task_id.map(|id| id as i64),
65                encode_timer_mode(record.mode),
66                record.completed_at.to_rfc3339(),
67            ],
68        )?;
69        Ok(self.conn.last_insert_rowid())
70    }
71
72    pub fn get_session(&self, id: i64) -> Result<StoredSession> {
73        Ok(self.conn.query_row(
74            "SELECT date, minutes, task_id, mode, completed_at
75             FROM focus_sessions WHERE id = ?1",
76            params![id],
77            |row| {
78                let mode_str: String = row.get(3)?;
79                Ok(StoredSession {
80                    id,
81                    record: FocusSessionRecord {
82                        date: row.get(0)?,
83                        minutes: row.get(1)?,
84                        task_id: read_opt_u64(row, 2)?,
85                        mode: decode_timer_mode(&mode_str),
86                        completed_at: parse_datetime(&row.get::<_, String>(4)?),
87                    },
88                })
89            },
90        )?)
91    }
92
93    pub fn delete_focus_session(&self, id: i64) -> Result<()> {
94        self.conn
95            .execute("DELETE FROM focus_sessions WHERE id = ?1", params![id])?;
96        Ok(())
97    }
98
99    pub fn update_session_minutes(&self, id: i64, minutes: u32) -> Result<()> {
100        self.conn.execute(
101            "UPDATE focus_sessions SET minutes = ?1 WHERE id = ?2",
102            params![minutes, id],
103        )?;
104        Ok(())
105    }
106
107    pub fn recent_sessions(&self, limit: usize) -> Result<Vec<StoredSession>> {
108        let mut stmt = self.conn.prepare(
109            "SELECT id, date, minutes, task_id, mode, completed_at
110             FROM focus_sessions
111             ORDER BY completed_at DESC
112             LIMIT ?1",
113        )?;
114        let rows = stmt.query_map(params![limit as i64], |row| {
115            let id: i64 = row.get(0)?;
116            let mode_str: String = row.get(4)?;
117            Ok(StoredSession {
118                id,
119                record: FocusSessionRecord {
120                    date: row.get(1)?,
121                    minutes: row.get(2)?,
122                    task_id: read_opt_u64(row, 3)?,
123                    mode: decode_timer_mode(&mode_str),
124                    completed_at: parse_datetime(&row.get::<_, String>(5)?),
125                },
126            })
127        })?;
128        rows.collect::<Result<Vec<_>, _>>()
129            .context("loading recent sessions")
130    }
131
132    pub fn session_counts_by_mode(&self) -> Result<(u32, u32, u32)> {
133        let focus: u32 = self.conn.query_row(
134            "SELECT COUNT(*) FROM focus_sessions WHERE mode = ?1",
135            params![encode_timer_mode(TimerMode::Focus)],
136            |row| row.get(0),
137        )?;
138        let custom: u32 = self.conn.query_row(
139            "SELECT COUNT(*) FROM focus_sessions WHERE mode = ?1",
140            params![encode_timer_mode(TimerMode::Custom)],
141            |row| row.get(0),
142        )?;
143        let breaks: u32 = self.conn.query_row(
144            "SELECT COUNT(*) FROM focus_sessions WHERE mode IN (?1, ?2)",
145            params![
146                encode_timer_mode(TimerMode::ShortBreak),
147                encode_timer_mode(TimerMode::LongBreak),
148            ],
149            |row| row.get(0),
150        )?;
151        Ok((focus, custom, breaks))
152    }
153
154    pub fn load_timer_state(&self) -> (u32, TimerMode) {
155        let count: u32 = self
156            .conn
157            .query_row(
158                "SELECT value FROM settings WHERE key = 'timer_completed_focus_sessions'",
159                [],
160                |row| row.get::<_, String>(0),
161            )
162            .ok()
163            .and_then(|s| s.parse().ok())
164            .unwrap_or(0);
165        let mode = self
166            .conn
167            .query_row(
168                "SELECT value FROM settings WHERE key = 'timer_mode'",
169                [],
170                |row| row.get::<_, String>(0),
171            )
172            .ok()
173            .map(|s| decode_timer_mode(&s))
174            .unwrap_or(TimerMode::Focus);
175        (count, mode)
176    }
177
178    pub fn persist_timer_state(&self, completed: u32, mode: TimerMode) -> Result<()> {
179        self.set_setting("timer_completed_focus_sessions", completed.to_string())?;
180        self.set_setting("timer_mode", encode_timer_mode(mode))?;
181        Ok(())
182    }
183
184    pub fn set_setting(&self, key: &str, value: impl AsRef<str>) -> Result<()> {
185        self.conn.execute(
186            "INSERT INTO settings (key, value) VALUES (?1, ?2)
187             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
188            params![key, value.as_ref()],
189        )?;
190        Ok(())
191    }
192
193    pub fn upsert_task(&self, task: &Task) -> Result<()> {
194        let tx = self.conn.unchecked_transaction()?;
195        upsert_task_row(&tx, task)?;
196        tx.commit()?;
197        Ok(())
198    }
199
200    pub fn delete_task(&self, id: u64) -> Result<()> {
201        self.conn
202            .execute("DELETE FROM tasks WHERE id = ?1", params![id as i64])?;
203        Ok(())
204    }
205
206    pub fn sync_sort_orders(&self, tasks: &[Task]) -> Result<()> {
207        let tx = self.conn.unchecked_transaction()?;
208        for task in tasks {
209            tx.execute(
210                "UPDATE tasks SET sort_order = ?1 WHERE id = ?2",
211                params![task.sort_order, task.id as i64],
212            )?;
213        }
214        tx.commit()?;
215        Ok(())
216    }
217
218    pub fn persist_session_stats(&self, data: &AppData) -> Result<()> {
219        self.set_setting("total_focus_minutes", data.total_focus_minutes.to_string())?;
220        self.set_setting("total_sessions", data.total_sessions.to_string())?;
221        self.set_setting("streak_days", data.streak_days.to_string())?;
222        self.set_setting(
223            "last_session_date",
224            data.last_session_date.clone().unwrap_or_default(),
225        )?;
226        self.set_setting("today_focus_minutes", data.today_focus_minutes.to_string())?;
227        self.set_setting("today_date", data.today_date.clone().unwrap_or_default())?;
228        self.set_setting("goal_streak_days", data.goal_streak_days.to_string())?;
229        self.set_setting(
230            "last_goal_date",
231            data.last_goal_date.clone().unwrap_or_default(),
232        )?;
233        Ok(())
234    }
235
236    pub fn persist_timer_settings(&self, data: &AppData) -> Result<()> {
237        self.set_setting("focus_minutes", data.focus_minutes.to_string())?;
238        self.set_setting("short_break_minutes", data.short_break_minutes.to_string())?;
239        self.set_setting("long_break_minutes", data.long_break_minutes.to_string())?;
240        self.set_setting("long_break_every", data.long_break_every.to_string())?;
241        Ok(())
242    }
243
244    pub fn persist_active_task(&self, id: Option<u64>) -> Result<()> {
245        let value = id.map(|i| i.to_string()).unwrap_or_default();
246        self.set_setting("active_task_id", value)
247    }
248
249    pub fn export_json(&self) -> Result<PathBuf> {
250        export::export_json(&self.conn)
251    }
252
253    pub fn minutes_by_date(&self, days: usize) -> Result<Vec<(String, u32)>> {
254        let today = chrono::Local::now().date_naive();
255        let mut out = Vec::with_capacity(days);
256        for offset in (0..days).rev() {
257            let date = today - chrono::Duration::days(offset as i64);
258            let key = date.format("%Y-%m-%d").to_string();
259            let mins = self.focus_minutes_on_date(&key)?;
260            let label = date.format("%a").to_string();
261            out.push((label, mins));
262        }
263        Ok(out)
264    }
265
266    /// Daily focus minutes keyed by `YYYY-MM-DD` (oldest first).
267    pub fn focus_minutes_series(&self, days: usize) -> Result<Vec<(String, u32)>> {
268        let today = chrono::Local::now().date_naive();
269        let mut out = Vec::with_capacity(days);
270        for offset in (0..days).rev() {
271            let date = today - chrono::Duration::days(offset as i64);
272            let key = date.format("%Y-%m-%d").to_string();
273            let mins = self.focus_minutes_on_date(&key)?;
274            out.push((key, mins));
275        }
276        Ok(out)
277    }
278
279    /// All days with logged focus/custom minutes from the database.
280    pub fn focus_minutes_grouped(&self) -> Result<Vec<(String, u32)>> {
281        let mut stmt = self.conn.prepare(
282            "SELECT date, COALESCE(SUM(minutes), 0) AS mins
283             FROM focus_sessions
284             WHERE mode IN (?1, ?2)
285             GROUP BY date
286             ORDER BY date ASC",
287        )?;
288        let rows = stmt.query_map(
289            params![
290                encode_timer_mode(TimerMode::Focus),
291                encode_timer_mode(TimerMode::Custom),
292            ],
293            |row| Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?)),
294        )?;
295        rows.collect::<Result<Vec<_>, _>>()
296            .context("loading focus minutes")
297    }
298
299    fn focus_minutes_on_date(&self, key: &str) -> Result<u32> {
300        self.conn
301            .query_row(
302                "SELECT COALESCE(SUM(minutes), 0) FROM focus_sessions
303                 WHERE date = ?1 AND mode IN (?2, ?3)",
304                params![
305                    key,
306                    encode_timer_mode(TimerMode::Focus),
307                    encode_timer_mode(TimerMode::Custom),
308                ],
309                |row| row.get(0),
310            )
311            .map_err(Into::into)
312    }
313}
314
315pub fn db_path() -> Result<PathBuf> {
316    let dir = data_dir()?;
317    Ok(dir.join("void.db"))
318}
319
320pub fn legacy_json_path() -> Result<PathBuf> {
321    Ok(data_dir()?.join("data.json"))
322}
323
324fn data_dir() -> Result<PathBuf> {
325    let dir = dirs::data_local_dir()
326        .or_else(dirs::config_dir)
327        .context("could not resolve local data directory")?;
328    let focus_dir = dir.join("void");
329    std::fs::create_dir_all(&focus_dir).context("creating data directory")?;
330    Ok(focus_dir)
331}
332
333// ── settings ─────────────────────────────────────────────────────────────────
334
335pub(crate) fn load_settings(conn: &Connection, data: &mut AppData) -> Result<()> {
336    let mut stmt = conn.prepare("SELECT key, value FROM settings")?;
337    let rows = stmt.query_map([], |row| {
338        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
339    })?;
340    for row in rows {
341        let (key, value) = row?;
342        apply_setting(data, &key, &value);
343    }
344    Ok(())
345}
346
347fn save_settings(conn: &Connection, data: &AppData) -> Result<()> {
348    let pairs: Vec<(&str, String)> = vec![
349        ("next_id", data.next_id.to_string()),
350        ("total_focus_minutes", data.total_focus_minutes.to_string()),
351        ("total_sessions", data.total_sessions.to_string()),
352        ("streak_days", data.streak_days.to_string()),
353        (
354            "last_session_date",
355            data.last_session_date.clone().unwrap_or_default(),
356        ),
357        ("daily_goal_minutes", data.daily_goal_minutes.to_string()),
358        ("sound_enabled", bool_str(data.sound_enabled)),
359        ("auto_start_breaks", bool_str(data.auto_start_breaks)),
360        ("auto_start_focus", bool_str(data.auto_start_focus)),
361        ("today_focus_minutes", data.today_focus_minutes.to_string()),
362        ("today_date", data.today_date.clone().unwrap_or_default()),
363        ("focus_minutes", data.focus_minutes.to_string()),
364        ("short_break_minutes", data.short_break_minutes.to_string()),
365        ("long_break_minutes", data.long_break_minutes.to_string()),
366        ("long_break_every", data.long_break_every.to_string()),
367        ("auto_pick_task", bool_str(data.auto_pick_task)),
368        ("auto_advance_task", bool_str(data.auto_advance_task)),
369        ("theme", encode_theme(data.theme).to_string()),
370        (
371            "active_task_id",
372            data.active_task_id
373                .map(|id| id.to_string())
374                .unwrap_or_default(),
375        ),
376        ("notify_on_finish", bool_str(data.notify_on_finish)),
377        ("goal_streak_days", data.goal_streak_days.to_string()),
378        (
379            "last_goal_date",
380            data.last_goal_date.clone().unwrap_or_default(),
381        ),
382        (
383            "empty_queue_behavior",
384            encode_empty_queue(data.empty_queue_behavior).to_string(),
385        ),
386        ("log_breaks", bool_str(data.log_breaks)),
387        (
388            "estimate_complete",
389            encode_estimate_complete(data.estimate_complete).to_string(),
390        ),
391    ];
392
393    for (key, value) in pairs {
394        conn.execute(
395            "INSERT INTO settings (key, value) VALUES (?1, ?2)
396             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
397            params![key, value],
398        )?;
399    }
400    Ok(())
401}
402
403fn apply_setting(data: &mut AppData, key: &str, value: &str) {
404    match key {
405        "next_id" => data.next_id = parse_u64(value, data.next_id),
406        "total_focus_minutes" => {
407            data.total_focus_minutes = parse_u32(value, data.total_focus_minutes)
408        }
409        "total_sessions" => data.total_sessions = parse_u32(value, data.total_sessions),
410        "streak_days" => data.streak_days = parse_u32(value, data.streak_days),
411        "last_session_date" => data.last_session_date = opt_string(value),
412        "daily_goal_minutes" => data.daily_goal_minutes = parse_u32(value, data.daily_goal_minutes),
413        "sound_enabled" => data.sound_enabled = parse_bool(value, data.sound_enabled),
414        "auto_start_breaks" => data.auto_start_breaks = parse_bool(value, data.auto_start_breaks),
415        "auto_start_focus" => data.auto_start_focus = parse_bool(value, data.auto_start_focus),
416        "today_focus_minutes" => {
417            data.today_focus_minutes = parse_u32(value, data.today_focus_minutes)
418        }
419        "today_date" => data.today_date = opt_string(value),
420        "focus_minutes" => data.focus_minutes = parse_u32(value, data.focus_minutes),
421        "short_break_minutes" => {
422            data.short_break_minutes = parse_u32(value, data.short_break_minutes)
423        }
424        "long_break_minutes" => data.long_break_minutes = parse_u32(value, data.long_break_minutes),
425        "long_break_every" => data.long_break_every = parse_u32(value, data.long_break_every),
426        "auto_pick_task" => data.auto_pick_task = parse_bool(value, data.auto_pick_task),
427        "auto_advance_task" => data.auto_advance_task = parse_bool(value, data.auto_advance_task),
428        "theme" => data.theme = decode_theme(value).unwrap_or(data.theme),
429        "active_task_id" => data.active_task_id = value.parse().ok(),
430        "notify_on_finish" => data.notify_on_finish = parse_bool(value, data.notify_on_finish),
431        "goal_streak_days" => data.goal_streak_days = parse_u32(value, data.goal_streak_days),
432        "last_goal_date" => data.last_goal_date = opt_string(value),
433        "empty_queue_behavior" => {
434            data.empty_queue_behavior =
435                decode_empty_queue(value).unwrap_or(data.empty_queue_behavior)
436        }
437        "log_breaks" => data.log_breaks = parse_bool(value, data.log_breaks),
438        "estimate_complete" => {
439            data.estimate_complete =
440                decode_estimate_complete(value).unwrap_or(data.estimate_complete)
441        }
442        _ => {}
443    }
444}
445
446// ── tasks ────────────────────────────────────────────────────────────────────
447
448pub(crate) fn load_tasks(conn: &Connection) -> Result<Vec<Task>> {
449    let mut stmt = conn.prepare(
450        "SELECT id, title, notes, priority, status, estimated_minutes, actual_minutes,
451                sessions, created_at, completed_at, due_date, today, sort_order
452         FROM tasks
453         ORDER BY sort_order ASC, id ASC",
454    )?;
455    let rows = stmt.query_map([], |row| {
456        Ok(Task {
457            id: read_u64(row, 0)?,
458            title: row.get(1)?,
459            notes: row.get(2)?,
460            priority: decode_priority(&row.get::<_, String>(3)?),
461            status: decode_task_status(&row.get::<_, String>(4)?),
462            estimated_minutes: row.get(5)?,
463            actual_minutes: row.get(6)?,
464            sessions: row.get(7)?,
465            created_at: parse_datetime(&row.get::<_, String>(8)?),
466            completed_at: row.get::<_, Option<String>>(9)?.map(|s| parse_datetime(&s)),
467            due_date: row.get::<_, Option<String>>(10)?,
468            today: row.get::<_, i32>(11)? != 0,
469            sort_order: row.get(12)?,
470            tags: Vec::new(),
471        })
472    })?;
473
474    let mut tasks = Vec::new();
475    for row in rows {
476        let mut task = row?;
477        task.tags = load_task_tags(conn, task.id)?;
478        tasks.push(task);
479    }
480    Ok(tasks)
481}
482
483fn load_task_tags(conn: &Connection, task_id: u64) -> Result<Vec<String>> {
484    let mut stmt = conn.prepare("SELECT tag FROM task_tags WHERE task_id = ?1 ORDER BY tag ASC")?;
485    let tags = stmt
486        .query_map(params![task_id as i64], |row| row.get(0))?
487        .collect::<Result<Vec<String>, _>>()?;
488    Ok(tags)
489}
490
491fn sync_tasks(conn: &Connection, tasks: &[Task]) -> Result<()> {
492    conn.execute("DELETE FROM task_tags", [])?;
493    conn.execute("DELETE FROM tasks", [])?;
494    for task in tasks {
495        upsert_task_row(conn, task)?;
496    }
497    Ok(())
498}
499
500fn upsert_task_row(conn: &Connection, task: &Task) -> Result<()> {
501    conn.execute(
502        "INSERT INTO tasks (
503            id, title, notes, priority, status, estimated_minutes, actual_minutes,
504            sessions, created_at, completed_at, due_date, today, sort_order
505         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
506         ON CONFLICT(id) DO UPDATE SET
507            title = excluded.title,
508            notes = excluded.notes,
509            priority = excluded.priority,
510            status = excluded.status,
511            estimated_minutes = excluded.estimated_minutes,
512            actual_minutes = excluded.actual_minutes,
513            sessions = excluded.sessions,
514            created_at = excluded.created_at,
515            completed_at = excluded.completed_at,
516            due_date = excluded.due_date,
517            today = excluded.today,
518            sort_order = excluded.sort_order",
519        params![
520            task.id as i64,
521            task.title,
522            task.notes,
523            encode_priority(task.priority),
524            encode_task_status(task.status),
525            task.estimated_minutes,
526            task.actual_minutes,
527            task.sessions,
528            task.created_at.to_rfc3339(),
529            task.completed_at.map(|dt| dt.to_rfc3339()),
530            task.due_date,
531            if task.today { 1 } else { 0 },
532            task.sort_order,
533        ],
534    )?;
535    conn.execute(
536        "DELETE FROM task_tags WHERE task_id = ?1",
537        params![task.id as i64],
538    )?;
539    for tag in &task.tags {
540        conn.execute(
541            "INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)",
542            params![task.id as i64, tag],
543        )?;
544    }
545    Ok(())
546}
547
548// ── encoding ─────────────────────────────────────────────────────────────────
549
550fn encode_priority(p: Priority) -> &'static str {
551    match p {
552        Priority::Low => "low",
553        Priority::Medium => "medium",
554        Priority::High => "high",
555    }
556}
557
558fn decode_priority(s: &str) -> Priority {
559    match s {
560        "high" => Priority::High,
561        "low" => Priority::Low,
562        _ => Priority::Medium,
563    }
564}
565
566fn encode_task_status(s: TaskStatus) -> &'static str {
567    match s {
568        TaskStatus::Pending => "pending",
569        TaskStatus::InProgress => "inprogress",
570        TaskStatus::Done => "done",
571    }
572}
573
574fn decode_task_status(s: &str) -> TaskStatus {
575    match s {
576        "done" => TaskStatus::Done,
577        "inprogress" | "in_progress" => TaskStatus::InProgress,
578        _ => TaskStatus::Pending,
579    }
580}
581
582fn encode_timer_mode(m: TimerMode) -> &'static str {
583    match m {
584        TimerMode::Focus => "focus",
585        TimerMode::ShortBreak => "shortbreak",
586        TimerMode::LongBreak => "longbreak",
587        TimerMode::Custom => "custom",
588    }
589}
590
591fn decode_timer_mode(s: &str) -> TimerMode {
592    match s {
593        "shortbreak" | "short_break" => TimerMode::ShortBreak,
594        "longbreak" | "long_break" => TimerMode::LongBreak,
595        "custom" => TimerMode::Custom,
596        _ => TimerMode::Focus,
597    }
598}
599
600fn encode_empty_queue(b: EmptyQueueBehavior) -> &'static str {
601    match b {
602        EmptyQueueBehavior::FreeFocus => "free-focus",
603        EmptyQueueBehavior::PauseTimer => "pause-timer",
604        EmptyQueueBehavior::AskEachTime => "ask",
605    }
606}
607
608fn decode_empty_queue(s: &str) -> Option<EmptyQueueBehavior> {
609    Some(match s {
610        "pause-timer" => EmptyQueueBehavior::PauseTimer,
611        "ask" => EmptyQueueBehavior::AskEachTime,
612        _ => EmptyQueueBehavior::FreeFocus,
613    })
614}
615
616fn encode_estimate_complete(b: EstimateCompleteBehavior) -> &'static str {
617    match b {
618        EstimateCompleteBehavior::Nudge => "nudge",
619        EstimateCompleteBehavior::None => "none",
620        EstimateCompleteBehavior::AutoDone => "auto-done",
621    }
622}
623
624fn decode_estimate_complete(s: &str) -> Option<EstimateCompleteBehavior> {
625    Some(match s {
626        "none" => EstimateCompleteBehavior::None,
627        "auto-done" => EstimateCompleteBehavior::AutoDone,
628        _ => EstimateCompleteBehavior::Nudge,
629    })
630}
631
632fn encode_theme(t: ThemeVariant) -> &'static str {
633    match t {
634        ThemeVariant::Dark => "dark",
635        ThemeVariant::Light => "light",
636        ThemeVariant::Polaris => "polaris",
637        ThemeVariant::Matrix => "matrix",
638    }
639}
640
641fn decode_theme(s: &str) -> Option<ThemeVariant> {
642    Some(match s {
643        "dark" => ThemeVariant::Dark,
644        "light" => ThemeVariant::Light,
645        "polaris" => ThemeVariant::Polaris,
646        "matrix" => ThemeVariant::Matrix,
647        _ => return None,
648    })
649}
650
651pub(crate) fn parse_datetime(s: &str) -> DateTime<Utc> {
652    DateTime::parse_from_rfc3339(s)
653        .map(|dt| dt.with_timezone(&Utc))
654        .unwrap_or_else(|_| Utc::now())
655}
656
657fn bool_str(v: bool) -> String {
658    if v { "1" } else { "0" }.to_string()
659}
660
661fn parse_bool(s: &str, default: bool) -> bool {
662    match s {
663        "1" | "true" | "yes" => true,
664        "0" | "false" | "no" => false,
665        _ => default,
666    }
667}
668
669pub(crate) fn read_u64(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<u64> {
670    Ok(row.get::<_, i64>(idx)? as u64)
671}
672
673pub(crate) fn read_opt_u64(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Option<u64>> {
674    let value: Option<i64> = row.get(idx)?;
675    Ok(value.map(|id| id as u64))
676}
677
678fn parse_u32(s: &str, default: u32) -> u32 {
679    s.parse().unwrap_or(default)
680}
681
682fn parse_u64(s: &str, default: u64) -> u64 {
683    s.parse().unwrap_or(default)
684}
685
686fn opt_string(s: &str) -> Option<String> {
687    if s.is_empty() {
688        None
689    } else {
690        Some(s.to_string())
691    }
692}