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