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 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 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
334pub(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
449pub(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
551fn 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}