package core import "core:fmt" import "core:os" import "core:path/filepath" import "core:strings" import "core:time" import "lib:database/sqlite" Storage :: struct { db: ^sqlite.DB, } Error :: sqlite.Error DEFAULT_DB_PATH :: "~/.local/share/data-goblin/goblin.db" storage_look_up_db_path :: proc() -> (db_path: string, err: os.Error) { db_path = DEFAULT_DB_PATH if val, ok := os.lookup_env("DATA_GOBLIN_DB", context.temp_allocator); ok { db_path = val } fixed_db_path, _ := strings.replace_all(db_path, "~", "$HOME", context.temp_allocator) db_path = os.replace_environment_placeholders(fixed_db_path, context.temp_allocator) if !os.is_directory(filepath.dir(db_path)) { os.make_directory_all(filepath.dir(db_path)) or_return } return } storage_open :: proc(store: ^Storage, db_path: string) -> Error { ensure(store != nil, "store can't be nil") db := sqlite.open(db_path) or_return store.db = db return nil } storage_close :: proc(store: ^Storage) -> Error { ensure(store != nil, "store can't be nil") if err := sqlite.close(store.db); err != nil { ensure(err == .Busy, "database is busy on shutdown!!!") return err } return nil } storage_ensure_schema :: proc(store: Storage) -> Error { version := get_schema_version(store.db) or_return // TODO: Move this to .sql files if version < 1 { if err := run_migration_v1(store.db); err != nil { return err } } return nil } storage_add_to_daily_log :: proc( store: Storage, mood: int, sleep: f64, sleep_quality: int, date: time.Time, notes: string, ) -> Error { buf: [time.MIN_YYYY_DATE_LEN]u8 date_str := time.to_string_yyyy_mm_dd(date, buf[:]) created_str, _ := time.time_to_rfc3339(time.now(), 0, false, context.temp_allocator) rows := sqlite.query( store.db, "INSERT INTO daily_log (date, mood, sleep_quality, slept_hours, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", date_str, mood, sleep_quality, sleep, notes, created_str, created_str, ) or_return sqlite.rows_next(rows) or_return sqlite.rows_close(rows) or_return return nil } Daily_Log :: struct { mood: int, sleep: f64, sleep_quality: int, date: string, notes: string, } storage_get_daily_log :: proc(store: Storage, date: time.Time) -> (result: Daily_Log, err: Error) { buf: [time.MIN_YYYY_DATE_LEN]u8 date_str := time.to_string_yyyy_mm_dd(date, buf[:]) rows := sqlite.query( store.db, "SELECT date, mood, slept_hours, sleep_quality, notes FROM daily_log WHERE date = ?", date_str, ) or_return found := sqlite.rows_next(rows) or_return defer sqlite.rows_close(rows) if !found { err = .Error return } sqlite.rows_scan(rows, &result.date, &result.mood, &result.sleep, &result.sleep_quality, &result.notes) return } storage_get_daily_log_span :: proc( store: Storage, start_date, end_date: time.Time, ) -> ( result: []Daily_Log, err: Error, ) { daily_log_aggregate := make([dynamic]Daily_Log, allocator = context.temp_allocator) start_date_buf: [time.MIN_YYYY_DATE_LEN]u8 end_date_buf: [time.MIN_YYYY_DATE_LEN]u8 start_date_str := time.to_string_yyyy_mm_dd(start_date, start_date_buf[:]) end_date_str := time.to_string_yyyy_mm_dd(end_date, end_date_buf[:]) rows := sqlite.query( store.db, "SELECT date, mood, slept_hours, sleep_quality FROM daily_log WHERE date >= DATE(?) AND date <= DATE(?) ORDER BY date ASC", start_date_str, end_date_str, ) or_return defer sqlite.rows_close(rows) for { if ok, row_err := sqlite.rows_next(rows); !ok || row_err != nil { if row_err != nil { return nil, row_err } break } result: Daily_Log sqlite.rows_scan(rows, &result.date, &result.mood, &result.sleep, &result.sleep_quality) or_return append(&daily_log_aggregate, result) } return daily_log_aggregate[:], nil } storage_add_to_symptoms :: proc( store: Storage, symptom: string, severity: int, date_time: time.Time, notes: string, ) -> Error { date_buf: [time.MIN_YYYY_DATE_LEN]u8 time_buf: [time.MIN_HMS_LEN]u8 date_str := fmt.tprintf( "%s %s", time.to_string_yyyy_mm_dd(date_time, date_buf[:]), time.to_string_hms(date_time, time_buf[:]), ) created_str, _ := time.time_to_rfc3339(time.now(), 0, false, context.temp_allocator) rows := sqlite.query( store.db, "INSERT INTO daily_symptom (timestamp, symptom, severity, notes, created_at) VALUES (?,?,?,?,?)", date_str, symptom, severity, notes, created_str, ) or_return sqlite.rows_next(rows) or_return sqlite.rows_close(rows) or_return return nil } Symptom :: struct { symptom: string, severity: int, notes: string, } storage_get_daily_symptoms :: proc(store: Storage, date: time.Time) -> (result: []Symptom, err: Error) { buf: [time.MIN_YYYY_DATE_LEN]u8 date_str := time.to_string_yyyy_mm_dd(date, buf[:]) symptoms := make([dynamic]Symptom, allocator = context.temp_allocator) rows := sqlite.query( store.db, "SELECT symptom, severity, notes FROM daily_symptom WHERE timestamp >= DATE(?) AND timestamp < DATE(?, '+1 day') ORDER BY timestamp ASC", date_str, date_str, ) or_return defer sqlite.rows_close(rows) for { if ok, row_err := sqlite.rows_next(rows); !ok || row_err != nil { if row_err != nil { return nil, row_err } break } result: Symptom sqlite.rows_scan(rows, &result.symptom, &result.severity, &result.notes) or_return append(&symptoms, result) } return symptoms[:], nil } storage_add_to_events :: proc(store: Storage, kind: string, amount: f64, unit: string, date_time: time.Time) -> Error { date_str, ok := time.time_to_rfc3339(date_time, allocator = context.temp_allocator) if !ok { return .Error } created_str, _ := time.time_to_rfc3339(time.now(), 0, false, context.temp_allocator) rows := sqlite.query( store.db, "INSERT INTO event (timestamp, kind, amount, unit, created_at) VALUES (?,?,?,?,?)", date_str, kind, amount, unit, created_str, ) or_return sqlite.rows_next(rows) or_return sqlite.rows_close(rows) or_return return nil } Event :: struct { timestamp: time.Time, kind: string, amount: f64, unit: string, } storage_get_daily_events :: proc(store: Storage, date: time.Time) -> (result: []Event, err: Error) { buf: [time.MIN_YYYY_DATE_LEN]u8 date_str := time.to_string_yyyy_mm_dd(date, buf[:]) events := make([dynamic]Event, allocator = context.temp_allocator) rows := sqlite.query( store.db, "SELECT timestamp, kind, amount, unit FROM event WHERE timestamp >= DATE(?) AND timestamp < DATE(?, '+1 day') ORDER BY timestamp ASC", date_str, date_str, ) or_return defer sqlite.rows_close(rows) for { if ok, row_err := sqlite.rows_next(rows); !ok || row_err != nil { if row_err != nil { return nil, row_err } break } result: Event timestamp: string sqlite.rows_scan(rows, ×tamp, &result.kind, &result.amount, &result.unit) or_return result.timestamp, _ = time.iso8601_to_time_utc(timestamp) append(&events, result) } return events[:], nil } @(private) get_schema_version :: proc(db: ^sqlite.DB) -> (version: int, err: Error) { rows: ^sqlite.Rows found: bool rows = sqlite.query( db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'", ) or_return found = sqlite.rows_next(rows) or_return sqlite.rows_close(rows) or_return if !found { return 0, nil } rows = sqlite.query(db, "SELECT version FROM schema_version") or_return found = sqlite.rows_next(rows) or_return defer sqlite.rows_close(rows) if !found { return 0, nil } sqlite.rows_scan(rows, &version) return } // TODO: Rip all database handling into a storage layer @(private) run_migration_v1 :: proc(db: ^sqlite.DB) -> Error { sql := "-- Create your tables\n" + "CREATE TABLE IF NOT EXISTS schema_version (" + " version INTEGER NOT NULL," + " applied_at TEXT NOT NULL" + ");\n" + "\n" + "CREATE TABLE IF NOT EXISTS daily_log (" + " date TEXT PRIMARY KEY," + " mood INTEGER," + " sleep_quality INTEGER," + " slept_hours REAL," + " notes TEXT," + " created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL" + ");\n" + "\n" + "CREATE TABLE IF NOT EXISTS daily_symptom (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " timestamp TEXT NOT NULL," + " symptom TEXT NOT NULL," + " severity INTEGER NOT NULL," + " notes TEXT," + " created_at TEXT NOT NULL" + ");\n" + "\n" + "CREATE TABLE IF NOT EXISTS event (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " timestamp TEXT NOT NULL," + " kind TEXT NOT NULL," + " amount REAL," + " unit TEXT," + " notes TEXT," + " created_at TEXT NOT NULL" + ");\n" + "\n" + "-- Record the version\n" + "INSERT INTO schema_version (version, applied_at) VALUES (1, 'now');" return sqlite.exec(db, sql) }