//! SQLite metadata store (M1). Schema follows HANDOFF.md §4: //! one row per image; M1 fills `path/mtime/size`, the thumbnail engine (M2) //! fills `width/height/orientation/thumb_path`, `date_taken` later. use rusqlite::{params, Connection}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use crate::scan::Entry; #[cfg(target_os = "android")] const ANDROID_PACKAGE: &str = "wow.gmp.gallery"; /// App-writable data dir on Android. Prefers the per-app external dir /// (/storage/emulated/0/Android/data//files) so the DB is easy to /// inspect via `adb`; falls back to the internal files dir, which is always /// writable. Neither `EXTERNAL_DATA_DIR` (not set by this NativeActivity /// setup) nor `temp_dir()` (=/data/user/0, not writable) can be trusted. #[cfg(target_os = "android")] fn android_data_dir() -> PathBuf { if let Some(ext) = std::env::var_os("EXTERNAL_DATA_DIR").map(PathBuf::from) { if std::fs::create_dir_all(&ext).is_ok() { return ext; } } if let Some(storage) = std::env::var_os("EXTERNAL_STORAGE").map(PathBuf::from) { let ext = storage .join("Android/data") .join(ANDROID_PACKAGE) .join("files"); if std::fs::create_dir_all(&ext).is_ok() { return ext; } } PathBuf::from(format!("/data/data/{ANDROID_PACKAGE}/files")) } /// DB file lives in app-private storage: /// - Android: per-app external dir (see `android_data_dir`), internal fallback. /// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery). pub fn db_path() -> PathBuf { if let Ok(p) = std::env::var("GALLERY_DB_PATH") { return PathBuf::from(p); // test hook } #[cfg(target_os = "android")] let dir = android_data_dir(); #[cfg(not(target_os = "android"))] let dir = { let data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); let base = data_home.unwrap_or_else(|| { let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); home.join(".local/share") }); base.join("gallery") }; dir.join("gallery.db") } pub fn open(path: &std::path::Path) -> Result { let conn = Connection::open(path)?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS items ( path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, size INTEGER NOT NULL, width INTEGER, height INTEGER, orientation INTEGER, thumb_path TEXT, date_taken INTEGER ); CREATE INDEX IF NOT EXISTS items_mtime ON items (mtime DESC);", )?; Ok(conn) } /// Apply a full filesystem snapshot incrementally: insert new paths, refresh /// rows whose mtime/size changed, delete vanished ones. /// Returns (inserted, updated, deleted). pub fn apply( conn: &mut Connection, entries: &[Entry], ) -> Result<(usize, usize, usize), rusqlite::Error> { // Existing state: path -> (mtime, size) let mut existing: HashMap = HashMap::with_capacity(entries.len()); { let mut st = conn.prepare("SELECT path, mtime, size FROM items")?; let rows = st.query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, )) })?; for row in rows { let (p, m, s) = row?; existing.insert(p, (m, s)); } } let mut inserted = 0; let mut updated = 0; let mut deleted = 0; let tx = conn.transaction()?; { let mut ins = tx.prepare( "INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)", )?; // mtime/size changed → content changed → derived columns are stale. let mut upd = tx.prepare( "UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \ orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1", )?; let mut del = tx.prepare("DELETE FROM items WHERE path = ?1")?; let mut seen = HashSet::with_capacity(entries.len()); for e in entries { seen.insert(e.path.clone()); match existing.get(&e.path) { Some(&(m, s)) if m == e.mtime && s == e.size => {} Some(_) => { upd.execute(params![e.path, e.mtime, e.size])?; updated += 1; } None => { ins.execute(params![e.path, e.mtime, e.size])?; inserted += 1; } } } for p in existing.keys() { if !seen.contains(p) { del.execute(params![p])?; deleted += 1; } } } tx.commit()?; Ok((inserted, updated, deleted)) } /// All known entries, newest first. pub fn list_entries(conn: &mut Connection) -> Result, rusqlite::Error> { let mut st = conn.prepare("SELECT path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?; let rows = st.query_map([], |r| { Ok(Entry { path: r.get(0)?, mtime: r.get(1)?, size: r.get(2)? }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } /// Update the derived thumbnail columns once the pool has finished a job. pub fn set_thumb( conn: &mut Connection, path: &str, width: i64, height: i64, orientation: i64, thumb_path: &str, ) -> Result<(), rusqlite::Error> { conn.execute( "UPDATE items SET width = ?2, height = ?3, orientation = ?4, thumb_path = ?5 \ WHERE path = ?1", params![path, width, height, orientation, thumb_path], )?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::scan::{walk, Entry}; fn entry(path: &str, mtime: i64, size: i64) -> Entry { Entry { path: path.into(), mtime, size } } #[test] fn incremental_apply() { let dir = tempfile::tempdir().unwrap(); let mut conn = open(&dir.path().join("t.db")).unwrap(); let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap(); assert_eq!((i, u, d), (2, 0, 0)); // No changes → no-op. let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap(); assert_eq!((i, u, d), (0, 0, 0)); // mtime changed → update; 3.jpg new; 2.jpg removed. let (i, u, d) = apply( &mut conn, &[entry("/a/1.jpg", 5, 11), entry("/a/3.jpg", 3, 30)], ) .unwrap(); assert_eq!((i, u, d), (1, 1, 1)); let list = list_entries(&mut conn).unwrap(); let paths: Vec<&str> = list.iter().map(|e| e.path.as_str()).collect(); assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]); } #[test] fn set_thumb_updates_row_and_invalidation() { let dir = tempfile::tempdir().unwrap(); let mut conn = open(&dir.path().join("t.db")).unwrap(); apply(&mut conn, &[entry("/a/1.jpg", 1, 10)]).unwrap(); set_thumb(&mut conn, "/a/1.jpg", 8000, 6016, 6, "/cache/abc.jpg").unwrap(); let thumb: String = conn .query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0)) .unwrap(); assert_eq!(thumb, "/cache/abc.jpg"); // mtime change → apply() invalidates the derived columns. let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 2, 12)]).unwrap(); assert_eq!((i, u, d), (0, 1, 0)); let thumb: Option = conn .query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0)) .unwrap(); assert_eq!(thumb, None); } #[test] fn scan_then_apply_roundtrip() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("sub")).unwrap(); std::fs::write(dir.path().join("a.jpg"), b"xx").unwrap(); std::fs::write(dir.path().join("sub").join("b.png"), b"y").unwrap(); std::fs::write(dir.path().join("notes.txt"), b"nope").unwrap(); let db_dir = tempfile::tempdir().unwrap(); let mut conn = open(&db_dir.path().join("t.db")).unwrap(); let entries = walk(&[dir.path().to_path_buf()], &|_| {}); let (i, u, d) = apply(&mut conn, &entries).unwrap(); assert_eq!((i, u, d), (2, 0, 0)); let list = list_entries(&mut conn).unwrap(); assert_eq!(list.len(), 2); assert!(list.iter().all(|e| e.path.contains(dir.path().to_str().unwrap()))); } }