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 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 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
333pub(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
446pub(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
548fn 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}