From 03dd8d8496833f23b7c4b6d76da0d38baf00d0a5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 30 Jun 2026 17:35:18 +0200 Subject: [PATCH] implemented database for search --- crates/pdfs-cli/src/main.rs | 33 + crates/pdfs-core/Cargo.toml | 1 + crates/pdfs-core/src/cache.rs | 362 +++++++---- crates/pdfs-core/src/config.rs | 6 + crates/pdfs-core/src/control.rs | 34 + crates/pdfs-core/src/db.rs | 1048 +++++++++++++++++++++++++++++++ crates/pdfs-core/src/error.rs | 3 + crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-fuse/src/lib.rs | 331 +++++++++- crates/pdfs-gui/src/app.rs | 690 +++++++++++++++++++- 10 files changed, 2342 insertions(+), 167 deletions(-) create mode 100644 crates/pdfs-core/src/db.rs diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index b3a3e97..6885e21 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2,6 +2,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand}; @@ -9,6 +10,7 @@ use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse}; +use pdfs_core::db::Db; #[derive(Parser)] #[command( @@ -76,6 +78,14 @@ enum Command { /// Photo node uid in `volume~link` form (from `pdfs photos`). uid: String, }, + /// Search file/folder names via the running daemon's local index. + Search { + /// Query string (substring match). + query: String, + /// Max hits to return. + #[arg(long, default_value_t = 50)] + limit: usize, + }, } fn main() -> Result<()> { @@ -102,6 +112,7 @@ fn main() -> Result<()> { Command::Ls { path } => cmd_ls(path), Command::Photos { limit, offset } => cmd_photos(limit, offset), Command::OpenPhoto { uid } => cmd_open_photo(uid), + Command::Search { query, limit } => cmd_search(query, limit), } } @@ -217,10 +228,15 @@ fn mount_once(mountpoint: Option) -> Result { .with_context(|| format!("create mountpoint {}", mountpoint.display()))?; let username = auth::load().map(|s| s.username).unwrap_or_default(); + // The daemon owns the unified DB (single writer). Open it here so the content + // cache and the mount share one handle: the cache uses its `cache_entries` + // table as the LRU index, the mount uses it for nodes/search/event cursor. + let db = Arc::new(Db::open(&dirs.db_path()).context("open cache db")?); let cache = ContentCache::open( dirs.content_cache_dir(), dirs.pins_path(), pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES, + db.clone(), ) .context("open content cache")?; let control_socket = dirs.control_socket(); @@ -272,6 +288,7 @@ fn mount_once(mountpoint: Option) -> Result { &mountpoint, cache, &control_socket, + db, username, ); @@ -371,6 +388,22 @@ fn cmd_open_photo(uid: String) -> Result<()> { Ok(()) } +fn cmd_search(query: String, limit: usize) -> Result<()> { + match control_request(CtlRequest::Search { query, limit })? { + CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"), + CtlResponse::SearchResults { hits } => { + for h in hits { + let kind = if h.is_dir { "d" } else { "-" }; + let pin = if h.pinned { "*" } else { " " }; + println!("{kind}{pin} {:>12} {}", h.size, h.path); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + /// Normalize a user-supplied path argument for sending to the daemon. Absolute /// paths are canonicalized (so the daemon can strip its mountpoint prefix); /// relative paths are passed through and resolved against the mount root. diff --git a/crates/pdfs-core/Cargo.toml b/crates/pdfs-core/Cargo.toml index a921109..a5e824c 100644 --- a/crates/pdfs-core/Cargo.toml +++ b/crates/pdfs-core/Cargo.toml @@ -17,4 +17,5 @@ thiserror.workspace = true tracing.workspace = true directories.workspace = true sha2.workspace = true +rusqlite.workspace = true keyring.workspace = true diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs index d2c61c7..cc8eee5 100644 --- a/crates/pdfs-core/src/cache.rs +++ b/crates/pdfs-core/src/cache.rs @@ -19,14 +19,32 @@ use std::collections::{BTreeMap, HashSet}; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use std::time::SystemTime; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use proton_drive_rs::proton_sdk::ids::NodeUid; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::db::Db; use crate::error::Result; +/// `cache_entries.kind` tag for whole-file content blobs. +const KIND_BLOB: &str = "blob"; +/// `cache_entries.kind` tag for on-demand block-cache chunks. +const KIND_BLOCK: &str = "block"; + +/// Current wall-clock time in unix *milliseconds*, for the LRU `last_accessed` +/// column. Milliseconds (not seconds) so two cache events in the same second +/// still order correctly — a coarse second-granularity clock would make LRU +/// eviction order indeterminate under bursty access. +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + /// Block size for the on-demand block cache. Matches the SDK content block size /// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one /// `download_range` fetch with no straddling. @@ -42,18 +60,22 @@ struct Meta { size: u64, } -/// One pinned file, as persisted to the pin registry. +/// One pin, as carried over the control socket and listed in `status`. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Pin { /// Node uid in `volume~link` display form. pub uid: String, - /// Last path the file was pinned under, for display in `status`. Advisory + /// Last path the node was pinned under, for display in `status`. Advisory /// only — the uid is the identity. pub path: String, + /// A folder pin whose whole subtree is kept on disk. `false` for a single + /// file. Defaulted so a legacy `pins.json` (file pins only) still parses. + #[serde(default)] + pub recursive: bool, } -/// The pin registry, persisted as JSON. Keyed by uid display string so a pin -/// survives renames/moves of the node. +/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into +/// the DB once on open. Live pins are owned by the `pins` table. #[derive(Serialize, Deserialize, Default)] struct PinFile { pins: BTreeMap, @@ -80,13 +102,24 @@ pub struct ContentCache { /// disables the cap. Pinned blobs are never evicted, so pins alone may push /// the cache over budget. max_bytes: u64, + /// Unified metadata DB. Its `cache_entries` table is the LRU index: every + /// store/read/evict updates it, and the budget enforcers query it instead of + /// scanning the cache directories (plan.md P4). + db: Arc, } impl ContentCache { /// Open (and create) a cache under `content_dir`, with the pin registry at /// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent - /// directories are created if missing. - pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result { + /// directories are created if missing. `db` is the shared metadata database + /// whose `cache_entries` table backs LRU eviction; the on-disk cache is + /// reconciled into it on open. + pub fn open( + content_dir: PathBuf, + pins_path: PathBuf, + max_bytes: u64, + db: Arc, + ) -> Result { std::fs::create_dir_all(&content_dir)?; let thumb_dir = content_dir.join("thumbs"); std::fs::create_dir_all(&thumb_dir)?; @@ -100,14 +133,71 @@ impl ContentCache { if let Some(parent) = pins_path.parent() { std::fs::create_dir_all(parent)?; } - Ok(Self { + let cache = Self { content_dir, thumb_dir, block_dir, scratch_dir, pins_path, max_bytes, - }) + db, + }; + cache.reconcile()?; + cache.import_legacy_pins()?; + Ok(cache) + } + + /// One-time migration of a pre-P5 `pins.json` into the DB `pins` table, then + /// delete the file so it never re-imports. Legacy pins were all whole-file + /// (non-recursive). Absent file → nothing to do. + fn import_legacy_pins(&self) -> Result<()> { + let Ok(bytes) = std::fs::read(&self.pins_path) else { + return Ok(()); + }; + if let Ok(file) = serde_json::from_slice::(&bytes) { + for (uid, pin) in file.pins { + self.db.pin_add(&uid, &pin.path, false)?; + } + } + let _ = std::fs::remove_file(&self.pins_path); + Ok(()) + } + + /// Rebuild the `cache_entries` LRU index from the on-disk cache. Called once + /// on open: the index is cleared, then every blob (`content_dir`) and block + /// (`block_dir`) file is re-registered with its on-disk size and last-modified + /// time as the initial LRU key. This makes the DB authoritative even after a + /// crash or an external file deletion, and picks up caches written by builds + /// predating the index. In-run accesses then refine the ordering. + fn reconcile(&self) -> Result<()> { + self.db.cache_clear()?; + for (dir, kind) in [ + (&self.content_dir, KIND_BLOB), + (&self.block_dir, KIND_BLOCK), + ] { + let Ok(rd) = std::fs::read_dir(dir) else { + continue; + }; + for entry in rd.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.ends_with(".meta") || name.ends_with(".tmp") { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let at = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + self.db.cache_touch(name, kind, meta.len(), at)?; + } + } + Ok(()) } /// Filesystem-safe, fixed-length cache key for a uid display string: the @@ -171,10 +261,10 @@ impl ContentCache { len: u64, ) -> Option> { let blob = self.valid_blob(uid, mtime, size)?; - // Record the access for LRU: bump the blob's mtime to now. Best effort — - // a failed touch only makes eviction order slightly less accurate. + // Record the access for LRU in the cache index. Best effort — a failed + // update only makes eviction order slightly less accurate. + let _ = self.db.cache_accessed(&Self::key(uid), now_millis()); let mut f = std::fs::File::open(&blob).ok()?; - let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now())); if offset >= size { return Some(Vec::new()); } @@ -197,12 +287,22 @@ impl ContentCache { std::fs::rename(&tmp, &blob)?; let meta = serde_json::to_vec(&Meta { mtime, size })?; std::fs::write(self.meta_path(uid), meta)?; + // Register the blob in the LRU index before enforcing the budget so the + // newest entry is weighed (and ordered most-recent) like any other. + self.db + .cache_touch(&Self::key(uid), KIND_BLOB, bytes.len() as u64, now_millis())?; self.enforce_budget(); Ok(()) } + /// Cache-index key for block `idx` of `uid` — the block file's name, which + /// is also its `cache_entries.cache_key`. + fn block_key(&self, uid: &NodeUid, idx: u64) -> String { + format!("{}.b{idx}", Self::key(uid)) + } + fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf { - self.block_dir.join(format!("{}.b{idx}", Self::key(uid))) + self.block_dir.join(self.block_key(uid, idx)) } fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf { @@ -222,7 +322,10 @@ impl ContentCache { return None; } let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?; - let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now())); + // LRU touch in the cache index (best effort). + let _ = self + .db + .cache_accessed(&self.block_key(uid, idx), now_millis()); let mut buf = Vec::new(); f.read_to_end(&mut buf).ok()?; Some(buf) @@ -249,6 +352,12 @@ impl ContentCache { self.block_meta(uid, idx), serde_json::to_vec(&Meta { mtime, size })?, )?; + self.db.cache_touch( + &self.block_key(uid, idx), + KIND_BLOCK, + bytes.len() as u64, + now_millis(), + )?; self.enforce_block_budget(); Ok(()) } @@ -260,42 +369,25 @@ impl ContentCache { if self.max_bytes == 0 { return; } - let mut total: u64 = 0; - let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); - let Ok(rd) = std::fs::read_dir(&self.block_dir) else { + // LRU-ordered (oldest first) block entries straight from the index — no + // directory scan. All blocks are evictable; pinned files are served from + // whole-file blobs, never blocks. + let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) else { return; }; - for entry in rd.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if name.ends_with(".meta") || name.ends_with(".tmp") { - continue; - } - let Ok(meta) = entry.metadata() else { continue }; - if !meta.is_file() { - continue; - } - total += meta.len(); - let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); - blobs.push((path, meta.len(), atime)); - } + let mut total: u64 = entries.iter().map(|(_, size)| *size).sum(); if total <= self.max_bytes { return; } - blobs.sort_by_key(|(_, _, atime)| *atime); - for (path, len, _) in blobs { + for (key, size) in entries { if total <= self.max_bytes { break; } - let meta = path.with_file_name(format!( - "{}.meta", - path.file_name().unwrap_or_default().to_string_lossy() - )); - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(meta); - total = total.saturating_sub(len); + // `key` is the block file's name (and its `.meta` sibling's stem). + let _ = std::fs::remove_file(self.block_dir.join(&key)); + let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta"))); + let _ = self.db.cache_remove(&key); + total = total.saturating_sub(size); } } @@ -328,7 +420,10 @@ impl ContentCache { let _ = std::fs::remove_file(self.thumb_blob(uid, ttype)); let _ = std::fs::remove_file(self.thumb_meta(uid, ttype)); } - // Drop every cached block (and its meta/tmp) for this uid. + // Drop every cached block (and its meta/tmp) for this uid. The block + // dir is still scanned here because eviction targets one uid's files by + // name prefix, not by LRU order; the index rows are removed in one shot + // by `cache_remove_all` below. let prefix = format!("{}.b", Self::key(uid)); if let Ok(rd) = std::fs::read_dir(&self.block_dir) { for entry in rd.flatten() { @@ -341,6 +436,8 @@ impl ContentCache { } } } + // Forget the blob and all of this uid's block rows in the LRU index. + let _ = self.db.cache_remove_all(&Self::key(uid)); } fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf { @@ -423,101 +520,80 @@ impl ContentCache { if self.max_bytes == 0 { return; } - // Pinned blobs (by cache key) are exempt from eviction. + // Pinned blobs (by cache key) are exempt from eviction. Resolves direct + // pins plus every descendant of a recursively-pinned folder, hashed into + // cache keys to match on-disk filenames. let pinned: HashSet = self - .load_pins() - .pins - .keys() + .db + .pinned_uids() + .unwrap_or_default() + .iter() .map(|uid| Self::key_str(uid)) .collect(); - // Scan blobs (the `` files; skip `.meta`/`.tmp` siblings), record - // size + last-access (mtime) for each. - let mut total: u64 = 0; - let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); - let Ok(rd) = std::fs::read_dir(&self.content_dir) else { + // LRU-ordered (oldest first) blob entries from the index — no directory + // scan. Pinned blobs still count toward the total (so pins alone can hold + // the cache over budget) but are never chosen as victims. + let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) else { return; }; - for entry in rd.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if name.ends_with(".meta") || name.ends_with(".tmp") { - continue; - } - let Ok(meta) = entry.metadata() else { continue }; - if !meta.is_file() { - continue; - } - total += meta.len(); - if pinned.contains(name) { - continue; // counts toward total but is never a victim - } - let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); - blobs.push((path, meta.len(), atime)); - } + let mut total: u64 = entries.iter().map(|(_, size)| *size).sum(); if total <= self.max_bytes { return; } - // Oldest access first. - blobs.sort_by_key(|(_, _, atime)| *atime); - for (path, len, _) in blobs { + for (key, size) in entries { if total <= self.max_bytes { break; } - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(path.with_extension("meta")); - total = total.saturating_sub(len); + if pinned.contains(&key) { + continue; // counts toward total but is never a victim + } + // `key` is the blob file's name (and its `.meta` sibling's stem). + let _ = std::fs::remove_file(self.content_dir.join(&key)); + let _ = std::fs::remove_file(self.content_dir.join(format!("{key}.meta"))); + let _ = self.db.cache_remove(&key); + total = total.saturating_sub(size); } } - fn load_pins(&self) -> PinFile { - std::fs::read(&self.pins_path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default() - } - - fn save_pins(&self, pins: &PinFile) -> Result<()> { - std::fs::write(&self.pins_path, serde_json::to_vec_pretty(pins)?)?; - Ok(()) - } - - /// Whether `uid` is pinned (independent of whether its blob is currently - /// cached). + /// Whether `uid` is pinned — directly, or because an ancestor folder is + /// pinned recursively (resolved in the DB against the node tree). Independent + /// of whether its blob is currently cached. Best effort: a DB error reads as + /// "not pinned" rather than failing the calling FUSE op. pub fn is_pinned(&self, uid: &NodeUid) -> bool { - self.load_pins().pins.contains_key(&uid.to_string()) + self.db.is_pinned(&uid.to_string()).unwrap_or(false) } - /// Record `uid` as pinned under `path`. The caller is responsible for having - /// already cached the content via [`store`](Self::store). - pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> { - let mut file = self.load_pins(); - let key = uid.to_string(); - file.pins.insert( - key.clone(), - Pin { - uid: key, - path: path.display().to_string(), - }, - ); - self.save_pins(&file) + /// Record `uid` as pinned under `path`. `recursive` marks a folder pin whose + /// whole subtree is kept. The caller is responsible for having cached the + /// content (a file via [`store`](Self::store); a folder's descendants by the + /// daemon walking the subtree). + pub fn add_pin(&self, uid: &NodeUid, path: &Path, recursive: bool) -> Result<()> { + self.db + .pin_add(&uid.to_string(), &path.display().to_string(), recursive) } /// Drop `uid` from the pin registry and evict its blob. No-op if not pinned. + /// A recursively-pinned folder's descendants are evicted by the daemon + /// (`Core::unpin`), which knows the node tree. pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> { - let mut file = self.load_pins(); - if file.pins.remove(&uid.to_string()).is_some() { - self.save_pins(&file)?; - } + self.db.pin_remove(&uid.to_string())?; self.evict(uid); Ok(()) } - /// All pinned files, ordered by uid. + /// All pins (files and recursive folders), ordered by uid. pub fn list_pins(&self) -> Vec { - self.load_pins().pins.into_values().collect() + self.db + .pin_list() + .unwrap_or_default() + .into_iter() + .map(|(uid, path, recursive)| Pin { + uid, + path, + recursive, + }) + .collect() } /// Total bytes of cached content blobs (pinned + unpinned), matching what @@ -591,10 +667,12 @@ mod tests { fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) { let dir = TempDir::new(); + let db = Arc::new(Db::open_in_memory().unwrap()); let c = ContentCache::open( dir.path().join("content"), dir.path().join("pins.json"), max_bytes, + db, ) .unwrap(); (c, dir) @@ -618,6 +696,40 @@ mod tests { ); } + #[test] + fn reconcile_rebuilds_index_from_disk() { + let dir = TempDir::new(); + let content = dir.path().join("content"); + let pins = dir.path().join("pins.json"); + + // First session: unlimited cap, store three blobs onto disk. + let db1 = Arc::new(Db::open_in_memory().unwrap()); + { + let c = ContentCache::open(content.clone(), pins.clone(), 0, db1).unwrap(); + c.store(&uid("a"), 1, 4, b"aaaa").unwrap(); + c.store(&uid("b"), 1, 4, b"bbbb").unwrap(); + c.store(&uid("c"), 1, 4, b"cccc").unwrap(); + } + + // Second session with a *fresh* index (as if predating P4, or after a + // crash) but the same on-disk cache: reconcile repopulates from disk. + let db2 = Arc::new(Db::open_in_memory().unwrap()); + assert!(db2.cache_entries_by_kind("blob").unwrap().is_empty()); + let c = ContentCache::open(content, pins, 8, db2.clone()).unwrap(); + assert_eq!(db2.cache_entries_by_kind("blob").unwrap().len(), 3); + + // With the index rebuilt, a further store enforces the budget against the + // reconciled entries instead of leaking them. + c.store(&uid("d"), 1, 4, b"dddd").unwrap(); + let total: u64 = db2 + .cache_entries_by_kind("blob") + .unwrap() + .iter() + .map(|(_, s)| s) + .sum(); + assert!(total <= 8, "budget enforced after reconcile, total={total}"); + } + #[test] fn stale_metadata_is_a_miss() { let (c, _d) = cache(); @@ -630,12 +742,36 @@ mod tests { assert!(c.read_range(&u, 100, 4, 0, 3).is_none()); } + #[test] + fn legacy_pins_json_imported_then_removed() { + let dir = TempDir::new(); + let pins = dir.path().join("pins.json"); + // Hand-write a pre-P5 pins.json (file pins only, no `recursive` field). + std::fs::write( + &pins, + r#"{"pins":{"vol~a":{"uid":"vol~a","path":"docs/a.txt"}}}"#, + ) + .unwrap(); + + let db = Arc::new(Db::open_in_memory().unwrap()); + let c = ContentCache::open(dir.path().join("content"), pins.clone(), 0, db).unwrap(); + + // Imported into the DB as a non-recursive pin... + assert!(c.is_pinned(&uid("a"))); + let listed = c.list_pins(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].path, "docs/a.txt"); + assert!(!listed[0].recursive); + // ...and the legacy file is gone so it never re-imports. + assert!(!pins.exists()); + } + #[test] fn pin_lifecycle_evicts_on_unpin() { let (c, _d) = cache(); let u = uid("a"); c.store(&u, 100, 3, b"abc").unwrap(); - c.add_pin(&u, Path::new("docs/a.txt")).unwrap(); + c.add_pin(&u, Path::new("docs/a.txt"), false).unwrap(); assert!(c.is_pinned(&u)); assert_eq!(c.list_pins().len(), 1); assert_eq!(c.list_pins()[0].path, "docs/a.txt"); @@ -675,7 +811,7 @@ mod tests { let (a, b, d) = (uid("a"), uid("b"), uid("d")); c.store(&a, 1, 4, b"aaaa").unwrap(); - c.add_pin(&a, Path::new("a")).unwrap(); + c.add_pin(&a, Path::new("a"), false).unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); c.store(&b, 1, 4, b"bbbb").unwrap(); // Over budget: `a` is pinned (oldest) so `b` must be the victim. diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 96a7bb4..35a09d4 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -94,6 +94,12 @@ impl AppDirs { self.state_dir().join("pins.json") } + /// Unified SQLite metadata cache (inodes, FTS, cache LRU, pins). Lives in + /// persistent state next to `control.sock`; only the daemon writes it. + pub fn db_path(&self) -> PathBuf { + self.state_dir().join("cache.db") + } + /// Unix domain socket the mount daemon listens on for CLI control commands. pub fn control_socket(&self) -> PathBuf { self.state_dir().join("control.sock") diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 67bb1a6..87ab8cc 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -49,10 +49,20 @@ pub enum Request { PhotosTimeline { offset: usize, limit: usize }, /// Download a photo's full content into the cache; replies with its path. OpenPhoto { uid: String }, + /// Upload a photo with the given name, media type, and content bytes. + UploadPhoto { + name: String, + media_type: String, + bytes: Vec, + capture_time: Option, + }, /// Download a Drive file's full content into the cache; replies with the /// on-disk path so the front-end can open it with the default app. `path` /// is mountpoint-relative. OpenFile { path: String }, + /// Full-text search node names against the daemon's local metadata index. + /// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`]. + Search { query: String, limit: usize }, } /// One entry in a [`Request::ListDir`] listing. @@ -69,6 +79,28 @@ pub struct DirEntry { pub pinned: bool, /// Node uid in `volume~link` form, for follow-up requests. pub uid: String, + /// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing + /// (the entry lives in the requested directory, so the caller derives the + /// path by joining its name); populated when an entry can live anywhere in + /// the tree, as for search hits rendered through the browser. + #[serde(default)] + pub path: String, +} + +/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the +/// full mountpoint-relative `path` (a hit can live anywhere in the tree), so the +/// front-end can navigate to or open it directly. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SearchHit { + pub name: String, + /// Mountpoint-relative path (`/`-joined, no leading slash). + pub path: String, + pub is_dir: bool, + pub size: u64, + pub modified: i64, + pub pinned: bool, + /// Node uid in `volume~link` form. + pub uid: String, } /// One photo in a [`Request::PhotosTimeline`] page. @@ -113,6 +145,8 @@ pub enum Response { }, /// An on-disk path the front-end can open (e.g. a downloaded photo). FilePath { path: String }, + /// Full-text search results (reply to [`Request::Search`]). + SearchResults { hits: Vec }, /// The request failed. Error { message: String }, } diff --git a/crates/pdfs-core/src/db.rs b/crates/pdfs-core/src/db.rs new file mode 100644 index 0000000..9c717a4 --- /dev/null +++ b/crates/pdfs-core/src/db.rs @@ -0,0 +1,1048 @@ +//! Unified SQLite metadata cache — the single persistence layer behind FUSE +//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins. +//! +//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach +//! the same data through the control socket. The connection is wrapped in a +//! `Mutex` because the FUSE callbacks are synchronous and already serialize +//! behind the `State` lock, so a connection pool would be overkill. +//! +//! This module is the P0 foundation: it opens the database, enables WAL, and +//! applies the forward-only schema migrations. Write-through of nodes, the +//! event cursor, FTS, and the cache index land in later phases on this schema. + +use std::path::Path; +use std::sync::Mutex; + +use proton_drive_rs::proton_sdk::ids::NodeUid; +use proton_drive_rs::{Node, NodeKind}; +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::Result; + +/// Current schema version. Bump on every forward migration added below. +const SCHEMA_VERSION: i64 = 5; + +/// Below this length the trigram tokenizer indexes nothing (it needs 3-char +/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`. +const TRIGRAM_MIN: usize = 3; + +/// A node loaded back from the database, paired with whether its directory +/// listing was complete (`listed`) when last persisted. Only meaningful for +/// folders; always `false` for files. +pub struct StoredNode { + pub node: Node, + pub listed: bool, +} + +/// One full-text search match: the stored [`Node`] plus its mountpoint-relative +/// path (`/`-joined, root excluded) so the front-end can navigate to or open it. +pub struct SearchHit { + pub node: Node, + pub path: String, +} + +/// Handle to the unified metadata database. +/// +/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through +/// the inner `Mutex`. +pub struct Db { + conn: Mutex, +} + +impl Db { + /// Open (creating if absent) the database at `path`, enable WAL, and bring + /// the schema up to [`SCHEMA_VERSION`]. + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + // WAL: readers never block the single writer. NORMAL sync is the + // standard durability/throughput tradeoff for WAL. + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + let db = Self { + conn: Mutex::new(conn), + }; + db.migrate()?; + Ok(db) + } + + /// Open an in-memory database. For tests. + #[cfg(test)] + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + let db = Self { + conn: Mutex::new(conn), + }; + db.migrate()?; + Ok(db) + } + + /// Run forward-only migrations from the stored `schema_version` up to + /// [`SCHEMA_VERSION`]. Each step is wrapped in its own transaction. + fn migrate(&self) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + + // `sync_state` is the key/value table holding `schema_version` and the + // event cursor (later). Create it first so we can read the version. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT);", + )?; + + let current: i64 = conn + .query_row( + "SELECT value FROM sync_state WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + if current >= SCHEMA_VERSION { + return Ok(()); + } + + let tx = conn.transaction()?; + if current < 1 { + tx.execute_batch(MIGRATION_V1)?; + } + if current < 2 { + tx.execute_batch(MIGRATION_V2)?; + } + if current < 3 { + tx.execute_batch(MIGRATION_V3)?; + } + if current < 4 { + tx.execute_batch(MIGRATION_V4)?; + } + if current < 5 { + tx.execute_batch(MIGRATION_V5)?; + } + tx.execute( + "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [SCHEMA_VERSION.to_string()], + )?; + tx.commit()?; + Ok(()) + } + + /// Run a closure with the locked connection. Escape hatch for callers in + /// later phases until typed query methods are added. + pub fn with_conn(&self, f: impl FnOnce(&Connection) -> Result) -> Result { + let conn = self.conn.lock().unwrap(); + f(&conn) + } + + /// Write-through a node from the in-memory cache. Indexed columns are kept + /// for query/search/eviction; the full [`Node`] is stored as JSON so the + /// hot-cache maps rehydrate losslessly on the next mount. `listed` is never + /// changed here — it is owned by [`set_listed`](Self::set_listed). + pub fn upsert_node(&self, node: &Node) -> Result<()> { + let json = serde_json::to_string(node)?; + let uid = node.uid.to_string(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO nodes + (uid, parent_uid, name, is_dir, size, mtime, trashed, node_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(uid) DO UPDATE SET + parent_uid = excluded.parent_uid, + name = excluded.name, + is_dir = excluded.is_dir, + size = excluded.size, + mtime = excluded.mtime, + trashed = excluded.trashed, + node_json = excluded.node_json", + params![ + uid, + node.parent_uid.as_ref().map(|u| u.to_string()), + node.name, + node.is_folder() as i64, + node_size(node), + node.modification_time, + node.trashed as i64, + json, + ], + )?; + // FTS5 has no UPSERT, so refresh the row by delete-then-insert. Trashed + // nodes are kept out of the index entirely so they never surface in + // search results. + tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?; + if !node.trashed { + tx.execute( + "INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)", + params![uid, node.name], + )?; + } + tx.commit()?; + Ok(()) + } + + /// Drop a node row (delete or trash from the hot cache). Children rows are + /// not cascaded here; the daemon forgets a whole subtree node-by-node. + pub fn delete_node(&self, uid: &NodeUid) -> Result<()> { + let uid = uid.to_string(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?; + tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?; + tx.commit()?; + Ok(()) + } + + /// Full-text search over node names, newest schema's trigram index giving + /// substring (not just prefix) matches. Returns up to `limit` non-trashed + /// hits, each with its mountpoint-relative path resolved. Queries shorter + /// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes + /// nothing below 3 chars. + pub fn search(&self, query: &str, limit: usize) -> Result> { + let query = query.trim(); + if query.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock().unwrap(); + let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN { + let pat = format!("%{}%", like_escape(query)); + let mut stmt = conn.prepare( + "SELECT node_json, uid FROM nodes + WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL + ORDER BY name LIMIT ?2", + )?; + collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)? + } else { + // Quote the user input as a single FTS phrase so its characters are + // matched literally, never parsed as FTS query syntax. + let phrase = format!("\"{}\"", query.replace('"', "\"\"")); + let mut stmt = conn.prepare( + "SELECT n.node_json, n.uid + FROM nodes_fts f JOIN nodes n ON n.uid = f.uid + WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL + ORDER BY f.rank LIMIT ?2", + )?; + collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)? + }; + + let mut hits = Vec::with_capacity(rows.len()); + for (json, uid) in rows { + let node: Node = serde_json::from_str(&json)?; + let path = path_of(&conn, &uid)?; + hits.push(SearchHit { node, path }); + } + Ok(hits) + } + + /// Mark (or unmark) a folder's child listing as complete. A listed folder + /// rehydrates its `children` map on mount even when empty; an unlisted one + /// re-enumerates from the remote on next access. + pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE nodes SET listed = ?2 WHERE uid = ?1", + params![uid.to_string(), listed as i64], + )?; + Ok(()) + } + + /// Load every persisted node for cold-start hydration of the `State` maps. + pub fn load_all(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?; + let rows = stmt.query_map([], |row| { + let json: String = row.get(0)?; + let listed: i64 = row.get(1)?; + Ok((json, listed != 0)) + })?; + let mut out = Vec::new(); + for row in rows { + let (json, listed) = row?; + let node: Node = serde_json::from_str(&json)?; + out.push(StoredNode { node, listed }); + } + Ok(out) + } + + /// Read the persisted incremental-sync cursor (a `DriveEventId`), if any. + /// The daemon resumes from this on restart instead of reseeding to the + /// server head, so changes made while unmounted are still applied (P2). + pub fn get_event_cursor(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let v = conn + .query_row( + "SELECT value FROM sync_state WHERE key = 'event_cursor'", + [], + |r| r.get::<_, String>(0), + ) + .optional()?; + Ok(v) + } + + /// Persist the incremental-sync cursor after a batch of events is applied. + pub fn set_event_cursor(&self, cursor: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO sync_state (key, value) VALUES ('event_cursor', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![cursor], + )?; + Ok(()) + } + + /// Children of `parent`, for the offline `ensure_children` fast path when a + /// listed folder's `children` map entry was trimmed mid-run. Returns `None` + /// when the folder is not marked `listed` (listing unknown, must re-fetch). + pub fn children_if_listed(&self, parent: &NodeUid) -> Result>> { + let conn = self.conn.lock().unwrap(); + let listed: Option = conn + .query_row( + "SELECT listed FROM nodes WHERE uid = ?1", + params![parent.to_string()], + |r| r.get(0), + ) + .optional()?; + if listed != Some(1) { + return Ok(None); + } + let mut stmt = conn.prepare( + "SELECT node_json FROM nodes + WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0", + )?; + let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for json in rows { + out.push(serde_json::from_str(&json?)?); + } + Ok(Some(out)) + } + + // --- Content-cache LRU index (P4) ------------------------------------- + // + // Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached + // blob/block carries one row keyed by its on-disk filename (`cache_key`), + // tagged with `kind` ('blob' | 'block') so the two byte budgets stay + // separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns + // the on-disk cache and rebuilds this index from disk on open, then keeps it + // in sync on every store/read/evict, so it is authoritative for eviction. + + /// Insert or refresh a cache entry: set its on-disk `size` and bump + /// `last_accessed` to `now`. Called on every store of a blob or block. + pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(cache_key) DO UPDATE SET + kind = excluded.kind, + size_bytes = excluded.size_bytes, + last_accessed = excluded.last_accessed", + params![key, kind, size as i64, now], + )?; + Ok(()) + } + + /// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is + /// a no-op — best effort, mirroring the old best-effort mtime touch. + pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1", + params![key, now], + )?; + Ok(()) + } + + /// Drop a single cache-entry row (one evicted blob or block). + pub fn cache_remove(&self, key: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM cache_entries WHERE cache_key = ?1", + params![key], + )?; + Ok(()) + } + + /// Drop the whole-file blob row for `key` plus every block row for the same + /// uid (`.b`). Called by `ContentCache::evict`, which removes the + /// blob and all of a uid's cached blocks at once. + pub fn cache_remove_all(&self, key: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM cache_entries WHERE cache_key = ?1 OR cache_key LIKE ?2", + params![key, format!("{key}.b%")], + )?; + Ok(()) + } + + /// Every cache entry of `kind`, ordered least-recently-accessed first, as + /// `(cache_key, size_bytes)`. The budget enforcer sums the sizes and evicts + /// from the front until the cache fits. + pub fn cache_entries_by_kind(&self, kind: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT cache_key, size_bytes FROM cache_entries + WHERE kind = ?1 ORDER BY last_accessed ASC", + )?; + let rows = stmt.query_map(params![kind], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64)) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Wipe the entire cache index. The daemon calls this on open before + /// rebuilding the index from the on-disk cache, so a crash or external file + /// deletion can never leave a phantom row inflating the budget total. + pub fn cache_clear(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM cache_entries", [])?; + Ok(()) + } + + // --- Pins (P5) -------------------------------------------------------- + // + // The pin registry, formerly `pins.json`. One row per directly-pinned node, + // keyed by uid display string. `recursive` marks a folder pin: its whole + // subtree counts as pinned, resolved against `nodes` via a CTE so a new + // descendant is covered the moment it lands in the node cache. + + /// Record `uid` as pinned under display `path`. `recursive` keeps the whole + /// subtree of a folder. Idempotent — re-pinning refreshes the path/flag. + pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO pins (uid, path, recursive) VALUES (?1, ?2, ?3) + ON CONFLICT(uid) DO UPDATE SET + path = excluded.path, recursive = excluded.recursive", + params![uid, path, recursive as i64], + )?; + Ok(()) + } + + /// Drop `uid`'s pin row. Returns whether a row existed. + pub fn pin_remove(&self, uid: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let n = conn.execute("DELETE FROM pins WHERE uid = ?1", params![uid])?; + Ok(n > 0) + } + + /// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid. + pub fn pin_list(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)? != 0, + )) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Whether `uid` is pinned — directly, or because a strict ancestor folder + /// is pinned recursively. The ancestor check walks `parent_uid` to the root + /// via a CTE; a direct pin is honoured even when the node has no `nodes` row + /// yet (e.g. a CLI that never hydrates the node cache). + pub fn is_pinned(&self, uid: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let direct: Option = conn + .query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| { + r.get(0) + }) + .optional()?; + if direct.is_some() { + return Ok(true); + } + let anc: Option = conn + .query_row( + "WITH RECURSIVE anc(uid, parent_uid) AS ( + SELECT uid, parent_uid FROM nodes WHERE uid = ?1 + UNION ALL + SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid + ) + SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid + WHERE a.uid != ?1 AND p.recursive = 1 + LIMIT 1", + params![uid], + |r| r.get(0), + ) + .optional()?; + Ok(anc.is_some()) + } + + /// Every uid that is pinned, directly or transitively: all direct pins plus + /// every descendant of a recursively-pinned folder. Used to build the + /// eviction-exempt set (the budget enforcer hashes these into cache keys). + pub fn pinned_uids(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "WITH RECURSIVE sub(uid) AS ( + SELECT uid FROM pins WHERE recursive = 1 + UNION + SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid + ) + SELECT uid FROM sub + UNION + SELECT uid FROM pins", + )?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE. + /// Used to evict a recursively-pinned subtree's cached blobs on unpin. + pub fn descendants(&self, folder: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "WITH RECURSIVE sub(uid) AS ( + SELECT uid FROM nodes WHERE parent_uid = ?1 + UNION + SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid + ) + SELECT uid FROM sub", + )?; + let rows = stmt.query_map(params![folder], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } +} + +/// Row mapper for the `(node_json, uid)` pairs both search paths return. +fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> { + Ok((row.get(0)?, row.get(1)?)) +} + +/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors. +fn collect_pairs( + rows: impl Iterator>, +) -> Result> { + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + +/// Escape `LIKE` wildcards in a user query so `%` and `_` match literally +/// (paired with `ESCAPE '\'` in the statement). +fn like_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the +/// root via a recursive CTE. The root (the node with no parent) is excluded, so +/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`. +fn path_of(conn: &Connection, uid: &str) -> Result { + let mut stmt = conn.prepare( + "WITH RECURSIVE anc(uid, parent_uid, name, depth) AS ( + SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1 + UNION ALL + SELECT n.uid, n.parent_uid, n.name, anc.depth + 1 + FROM nodes n JOIN anc ON n.uid = anc.parent_uid + ) + SELECT name, parent_uid FROM anc ORDER BY depth DESC", + )?; + let rows = stmt.query_map(params![uid], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, Option>(1)?)) + })?; + let mut parts = Vec::new(); + for r in rows { + let (name, parent_uid) = r?; + // Skip the root node (no parent); its name is the mount itself. + if parent_uid.is_some() { + parts.push(name); + } + } + Ok(parts.join("/")) +} + +/// Effective plaintext size of a node for the indexed `size` column: the +/// claimed size when known, else the on-storage size; folders are 0. +fn node_size(node: &Node) -> i64 { + match &node.kind { + NodeKind::Folder => 0, + NodeKind::File { + total_size_on_storage, + claimed_size, + .. + } => claimed_size.unwrap_or(*total_size_on_storage).max(0), + } +} + +/// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created +/// in [`Db::migrate`] before this runs. +const MIGRATION_V1: &str = " +CREATE TABLE nodes ( + uid TEXT PRIMARY KEY, + parent_uid TEXT, + name TEXT NOT NULL, + is_dir INTEGER NOT NULL, + size INTEGER, + mtime INTEGER NOT NULL, + revision_hash TEXT, + trashed INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_nodes_parent ON nodes(parent_uid); + +CREATE VIRTUAL TABLE nodes_fts USING fts5( + name, path, content='', tokenize='trigram' +); + +CREATE TABLE cache_entries ( + cache_key TEXT PRIMARY KEY, + size_bytes INTEGER, + last_accessed INTEGER, + is_pinned INTEGER NOT NULL DEFAULT 0 +); +"; + +/// Schema v2: node write-through (P1). `node_json` stores the full [`Node`] so +/// the hot-cache maps rehydrate losslessly; `listed` records whether a folder's +/// child listing was complete when persisted. +const MIGRATION_V2: &str = " +ALTER TABLE nodes ADD COLUMN node_json TEXT; +ALTER TABLE nodes ADD COLUMN listed INTEGER NOT NULL DEFAULT 0; +"; + +/// Schema v3: FTS search (P3). The v1 `nodes_fts` was contentless (`content=''`) +/// and never populated — drop it and recreate as a self-contained trigram index +/// carrying the `uid` (UNINDEXED, so retrievable but not tokenized) alongside the +/// indexed `name`. Backfill from the nodes already persisted by P1. +const MIGRATION_V3: &str = " +DROP TABLE IF EXISTS nodes_fts; +CREATE VIRTUAL TABLE nodes_fts USING fts5( + uid UNINDEXED, name, tokenize='trigram' +); +INSERT INTO nodes_fts (uid, name) + SELECT uid, name FROM nodes WHERE trashed = 0; +"; + +/// Schema v4: content-cache LRU index (P4). The v1 `cache_entries` keyed only by +/// `cache_key`; add a `kind` discriminator so the whole-file blob pool and the +/// block pool — which carry separate byte budgets — can be summed and evicted +/// independently. Existing rows (none in practice, the table was never written) +/// default to `'blob'`. The daemon rebuilds the index from disk on open, so no +/// backfill is needed here. +const MIGRATION_V4: &str = " +ALTER TABLE cache_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'blob'; +"; + +/// Schema v5: pins move out of `pins.json` into the DB (P5). One row per +/// directly-pinned node, keyed by uid display string so a pin survives +/// renames/moves. `recursive` marks a folder pin whose whole subtree is kept; +/// descendants are resolved on demand against `nodes` via a CTE rather than +/// being expanded into rows here. `ContentCache::open` imports any legacy +/// `pins.json` into this table once, then deletes the file. +const MIGRATION_V5: &str = " +CREATE TABLE pins ( + uid TEXT PRIMARY KEY, + path TEXT NOT NULL, + recursive INTEGER NOT NULL DEFAULT 0 +); +"; + +#[cfg(test)] +mod tests { + use super::*; + use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; + + fn uid(link: &str) -> NodeUid { + NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) + } + + // `NodeVerification` is not re-exported, so build test nodes by + // deserializing JSON (the field has a serde default and can be omitted). + fn node_from( + parent: serde_json::Value, + link: &str, + name: &str, + kind: serde_json::Value, + ) -> Node { + let v = serde_json::json!({ + "uid": {"volume_id": "vol", "link_id": link}, + "parent_uid": parent, + "kind": kind, + "name": name, + "creation_time": 100, + "modification_time": 200, + "trashed": false, + "signature_email": null, + }); + serde_json::from_value(v).unwrap() + } + + fn folder(link: &str, parent: Option<&str>, name: &str) -> Node { + let parent = match parent { + Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}), + None => serde_json::Value::Null, + }; + node_from(parent, link, name, serde_json::json!("Folder")) + } + + fn file(link: &str, parent: &str, name: &str, size: i64) -> Node { + let kind = serde_json::json!({ + "File": { + "media_type": "text/plain", + "total_size_on_storage": size + 10, + "claimed_size": size, + "claimed_modification_time": null, + } + }); + node_from( + serde_json::json!({"volume_id": "vol", "link_id": parent}), + link, + name, + kind, + ) + } + + #[test] + fn upsert_and_load_all_roundtrip() { + let db = Db::open_in_memory().unwrap(); + let root = folder("root", None, "My Files"); + let child = file("f1", "root", "hello.txt", 42); + db.upsert_node(&root).unwrap(); + db.upsert_node(&child).unwrap(); + + let loaded = db.load_all().unwrap(); + assert_eq!(loaded.len(), 2); + let f = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap(); + assert_eq!(f.node.name, "hello.txt"); + assert!(!f.listed); + match &f.node.kind { + NodeKind::File { claimed_size, .. } => assert_eq!(*claimed_size, Some(42)), + _ => panic!("expected file"), + } + } + + #[test] + fn upsert_is_idempotent_update() { + let db = Db::open_in_memory().unwrap(); + let mut n = folder("root", None, "My Files"); + db.upsert_node(&n).unwrap(); + n.name = "Renamed".into(); + db.upsert_node(&n).unwrap(); + let loaded = db.load_all().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].node.name, "Renamed"); + } + + #[test] + fn delete_node_removes_row() { + let db = Db::open_in_memory().unwrap(); + let n = folder("root", None, "My Files"); + db.upsert_node(&n).unwrap(); + db.delete_node(&uid("root")).unwrap(); + assert!(db.load_all().unwrap().is_empty()); + } + + #[test] + fn children_if_listed_gated_on_flag() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap(); + db.upsert_node(&file("f2", "root", "b.txt", 2)).unwrap(); + + // Not listed yet → unknown, force a re-fetch. + assert!(db.children_if_listed(&uid("root")).unwrap().is_none()); + + db.set_listed(&uid("root"), true).unwrap(); + let kids = db.children_if_listed(&uid("root")).unwrap().unwrap(); + assert_eq!(kids.len(), 2); + } + + #[test] + fn children_if_listed_excludes_trashed() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + let mut trashed = file("f1", "root", "a.txt", 1); + trashed.trashed = true; + db.upsert_node(&trashed).unwrap(); + db.set_listed(&uid("root"), true).unwrap(); + assert_eq!( + db.children_if_listed(&uid("root")).unwrap().unwrap().len(), + 0 + ); + } + + #[test] + fn event_cursor_roundtrip() { + let db = Db::open_in_memory().unwrap(); + // Absent before first write → seed from server head on first mount. + assert!(db.get_event_cursor().unwrap().is_none()); + db.set_event_cursor("evt-1").unwrap(); + assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-1")); + // Overwrites, not appends. + db.set_event_cursor("evt-2").unwrap(); + assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-2")); + } + + #[test] + fn search_trigram_substring_and_path() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("docs", Some("root"), "Documents")) + .unwrap(); + db.upsert_node(&file("f1", "docs", "report.pdf", 1)) + .unwrap(); + db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap(); + + // Substring match (trigram), not just prefix. + let hits = db.search("port", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "report.pdf"); + // Path is mountpoint-relative, root excluded. + assert_eq!(hits[0].path, "Documents/report.pdf"); + + // Top-level file → bare name. + let hits = db.search("notes", 10).unwrap(); + assert_eq!(hits[0].path, "notes.txt"); + } + + #[test] + fn search_excludes_trashed_and_respects_limit() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "alpha.txt", 1)).unwrap(); + let mut gone = file("f2", "root", "alphb.txt", 1); + gone.trashed = true; + db.upsert_node(&gone).unwrap(); + + let hits = db.search("alph", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "alpha.txt"); + + db.upsert_node(&file("f3", "root", "alphc.txt", 1)).unwrap(); + assert_eq!(db.search("alph", 1).unwrap().len(), 1); + } + + #[test] + fn search_short_query_like_fallback() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "ab.txt", 1)).unwrap(); + // Under trigram min length → LIKE path still finds it. + let hits = db.search("ab", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "ab.txt"); + } + + #[test] + fn search_drops_fts_row_on_delete_and_trash() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "unique.txt", 1)) + .unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 1); + + // Re-upsert as trashed → leaves the index. + let mut t = file("f1", "root", "unique.txt", 1); + t.trashed = true; + db.upsert_node(&t).unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 0); + + // Resurrect, then hard-delete. + db.upsert_node(&file("f1", "root", "unique.txt", 1)) + .unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 1); + db.delete_node(&uid("f1")).unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 0); + } + + #[test] + fn opens_and_migrates() { + let db = Db::open_in_memory().unwrap(); + let version: String = db + .with_conn(|c| { + Ok(c.query_row( + "SELECT value FROM sync_state WHERE key = 'schema_version'", + [], + |r| r.get(0), + )?) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION.to_string()); + } + + #[test] + fn migrate_is_idempotent() { + let db = Db::open_in_memory().unwrap(); + // Second migrate is a no-op (already at head) and must not error. + db.migrate().unwrap(); + } + + #[test] + fn cache_index_touch_access_and_lru_order() { + let db = Db::open_in_memory().unwrap(); + db.cache_touch("k1", "blob", 100, 10).unwrap(); + db.cache_touch("k2", "blob", 200, 20).unwrap(); + // LRU-first: k1 (older access) before k2. + let rows = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(rows, vec![("k1".into(), 100), ("k2".into(), 200)]); + + // Accessing k1 moves it to the back (most recent). + db.cache_accessed("k1", 30).unwrap(); + let rows = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(rows[0].0, "k2"); + assert_eq!(rows[1].0, "k1"); + + // Re-touch updates size, not just time. + db.cache_touch("k1", "blob", 150, 40).unwrap(); + let rows = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(rows.iter().find(|(k, _)| k == "k1").unwrap().1, 150); + } + + #[test] + fn cache_index_kinds_are_separate() { + let db = Db::open_in_memory().unwrap(); + db.cache_touch("blob1", "blob", 100, 1).unwrap(); + db.cache_touch("blk1.b0", "block", 50, 1).unwrap(); + assert_eq!(db.cache_entries_by_kind("blob").unwrap().len(), 1); + assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); + } + + #[test] + fn cache_index_remove_and_remove_all() { + let db = Db::open_in_memory().unwrap(); + // A blob plus two of its blocks (key prefix shared). + db.cache_touch("abc", "blob", 1, 1).unwrap(); + db.cache_touch("abc.b0", "block", 1, 1).unwrap(); + db.cache_touch("abc.b1", "block", 1, 1).unwrap(); + // An unrelated entry that must survive. + db.cache_touch("xyz", "blob", 1, 1).unwrap(); + + db.cache_remove("abc.b0").unwrap(); + assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); + + // remove_all drops the blob row and every remaining block of that uid. + db.cache_remove_all("abc").unwrap(); + assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); + let blobs = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(blobs, vec![("xyz".into(), 1)]); + } + + #[test] + fn cache_index_clear() { + let db = Db::open_in_memory().unwrap(); + db.cache_touch("k1", "blob", 1, 1).unwrap(); + db.cache_touch("k2", "block", 1, 1).unwrap(); + db.cache_clear().unwrap(); + assert!(db.cache_entries_by_kind("blob").unwrap().is_empty()); + assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); + } + + #[test] + fn pin_add_list_remove_roundtrip() { + let db = Db::open_in_memory().unwrap(); + db.pin_add("vol~a", "docs/a.txt", false).unwrap(); + db.pin_add("vol~d", "docs", true).unwrap(); + let list = db.pin_list().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0], ("vol~a".into(), "docs/a.txt".into(), false)); + assert_eq!(list[1], ("vol~d".into(), "docs".into(), true)); + + // Re-pin refreshes path/flag, not a duplicate row. + db.pin_add("vol~a", "moved/a.txt", false).unwrap(); + let list = db.pin_list().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].1, "moved/a.txt"); + + assert!(db.pin_remove("vol~a").unwrap()); + assert!(!db.pin_remove("vol~a").unwrap()); + assert_eq!(db.pin_list().unwrap().len(), 1); + } + + #[test] + fn is_pinned_direct_without_node_row() { + // A direct pin counts even when the node was never hydrated into `nodes`. + let db = Db::open_in_memory().unwrap(); + db.pin_add("vol~a", "a.txt", false).unwrap(); + assert!(db.is_pinned("vol~a").unwrap()); + assert!(!db.is_pinned("vol~b").unwrap()); + } + + #[test] + fn recursive_folder_pin_covers_subtree() { + let db = Db::open_in_memory().unwrap(); + // root/docs/{report.pdf, sub/deep.txt}, root/loose.txt + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("docs", Some("root"), "Documents")) + .unwrap(); + db.upsert_node(&file("rep", "docs", "report.pdf", 1)) + .unwrap(); + db.upsert_node(&folder("sub", Some("docs"), "Sub")).unwrap(); + db.upsert_node(&file("deep", "sub", "deep.txt", 1)).unwrap(); + db.upsert_node(&file("loose", "root", "loose.txt", 1)) + .unwrap(); + + // Pin the Documents folder recursively (uids are `vol~link` display form). + let du = |l: &str| uid(l).to_string(); + db.pin_add(&du("docs"), "Documents", true).unwrap(); + + // Everything under docs (any depth) is pinned; loose.txt is not. + assert!(db.is_pinned(&du("docs")).unwrap()); + assert!(db.is_pinned(&du("rep")).unwrap()); + assert!(db.is_pinned(&du("deep")).unwrap()); + assert!(!db.is_pinned(&du("loose")).unwrap()); + + // pinned_uids expands the subtree (folder + descendants), no loose.txt. + let mut got = db.pinned_uids().unwrap(); + got.sort(); + assert_eq!(got, vec![du("deep"), du("docs"), du("rep"), du("sub")]); + + // descendants() lists the subtree of a folder (excludes the folder). + let mut desc = db.descendants(&du("docs")).unwrap(); + desc.sort(); + assert_eq!(desc, vec![du("deep"), du("rep"), du("sub")]); + } + + #[test] + fn non_recursive_folder_pin_does_not_cover_children() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("docs", Some("root"), "Documents")) + .unwrap(); + db.upsert_node(&file("rep", "docs", "report.pdf", 1)) + .unwrap(); + // A non-recursive pin on the folder covers only the folder itself. + let du = |l: &str| uid(l).to_string(); + db.pin_add(&du("docs"), "Documents", false).unwrap(); + assert!(db.is_pinned(&du("docs")).unwrap()); + assert!(!db.is_pinned(&du("rep")).unwrap()); + } + + #[test] + fn schema_objects_exist() { + let db = Db::open_in_memory().unwrap(); + let count: i64 = db + .with_conn(|c| { + Ok(c.query_row( + "SELECT count(*) FROM sqlite_master + WHERE name IN ('nodes', 'nodes_fts', 'cache_entries')", + [], + |r| r.get(0), + )?) + }) + .unwrap(); + assert_eq!(count, 3); + } +} diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index b3df861..141d17a 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -16,6 +16,9 @@ pub enum Error { #[error("io: {0}")] Io(#[from] std::io::Error), + #[error("database: {0}")] + Db(#[from] rusqlite::Error), + /// No persisted session found — caller must run an interactive login. #[error("not logged in (no saved session)")] NotLoggedIn, diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index a996fa7..d8e8d9f 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod cache; pub mod config; pub mod control; +pub mod db; pub mod error; pub mod service; diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index d607701..1a1957f 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -49,7 +49,10 @@ use fuser::{ ReplyWrite, Request, Session, TimeOrNow, WriteFlags, }; use pdfs_core::cache::{BLOCK_SIZE, ContentCache}; -use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse}; +use pdfs_core::control::{ + DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit, +}; +use pdfs_core::db::{Db, StoredNode}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::{ DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient, @@ -186,11 +189,19 @@ struct State { /// have no entry here. handles: HashMap, next_fh: u64, + /// Unified SQLite metadata cache. Every map mutation below writes through to + /// it inside the `State` lock so the DB stays the authoritative copy across + /// restarts (see plan.md P1). + db: Arc, } impl State { - /// Allocate (or reuse) a stable inode for a node and store its metadata. + /// Allocate (or reuse) a stable inode for a node and store its metadata, + /// writing the node through to the DB. fn intern(&mut self, parent: u64, node: Node) -> u64 { + if let Err(e) = self.db.upsert_node(&node) { + warn!(uid = %node.uid, error = %e, "db upsert_node failed"); + } if let Some(&ino) = self.by_uid.get(&node.uid) { if let Some(e) = self.entries.get_mut(&ino) { e.node = node; @@ -213,10 +224,14 @@ impl State { } /// Forget a node entirely: drop its inode, its uid mapping, its own cached - /// listing, and its slot in its parent's listing. Returns `(parent_ino, - /// name)` when the node was known, so the caller can notify the kernel. + /// listing, its slot in its parent's listing, and its DB row. Returns + /// `(parent_ino, name)` when the node was known, so the caller can notify + /// the kernel. fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> { let ino = self.by_uid.remove(uid)?; + if let Err(e) = self.db.delete_node(uid) { + warn!(%uid, error = %e, "db delete_node failed"); + } let entry = self.entries.remove(&ino)?; self.children.remove(&ino); if let Some(kids) = self.children.get_mut(&entry.parent) { @@ -225,6 +240,20 @@ impl State { Some((entry.parent, entry.node.name)) } + /// Drop a directory's cached child listing and mark it unlisted in the DB, + /// so the next access re-enumerates instead of trusting a stale listing. + fn invalidate_listing(&mut self, ino: u64) { + if self.children.remove(&ino).is_none() { + return; + } + if let Some(e) = self.entries.get(&ino) { + let uid = e.uid.clone(); + if let Err(err) = self.db.set_listed(&uid, false) { + warn!(%uid, error = %err, "db set_listed(false) failed"); + } + } + } + /// Update a file entry's recorded plaintext size so `getattr` reflects an /// in-progress write before the new revision is sealed. fn set_size(&mut self, ino: u64, size: u64) { @@ -255,6 +284,10 @@ struct Core { rt: tokio::runtime::Handle, state: Arc>, cache: Arc, + /// Unified SQLite metadata cache: the persistence layer behind the in-memory + /// `State` maps. Every mutation writes through here, and the maps rehydrate + /// from it on mount (plan.md P1). + db: Arc, /// Cached full photos timeline (newest first), so paged `PhotosTimeline` /// requests slice memory instead of re-fetching the whole timeline per page. /// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`]. @@ -268,6 +301,71 @@ struct TimelineCache { } impl Core { + /// Rehydrate the in-memory `State` maps from the DB on mount, so a cold + /// start serves previously-seen metadata (stable inodes, instant listings) + /// without re-hitting the API. The root inode is already installed by + /// [`ProtonFs::new`]; this fills in every other persisted node and rebuilds + /// the child listings of folders the DB records as fully enumerated. + fn hydrate(&self) { + let stored = match self.db.load_all() { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "db load_all failed; mounting cold"); + return; + } + }; + if stored.is_empty() { + return; + } + let mut st = self.state.lock().unwrap(); + + // Pass 1: assign a stable inode to every uid (root is already mapped). + for sn in &stored { + if st.by_uid.contains_key(&sn.node.uid) { + continue; + } + let ino = st.next_ino; + st.next_ino += 1; + st.by_uid.insert(sn.node.uid.clone(), ino); + } + + // Pass 2: materialize entries, resolving each parent's inode by uid. + // Track folders flagged complete so their listings rebuild in pass 3. + let mut listed_dirs: Vec = Vec::new(); + for sn in stored { + let StoredNode { node, listed } = sn; + let Some(&ino) = st.by_uid.get(&node.uid) else { + continue; + }; + if listed && node.is_folder() { + listed_dirs.push(ino); + } + // The root entry is owned by `ProtonFs::new`; don't overwrite it. + if ino == ROOT_INO { + continue; + } + let parent = node + .parent_uid + .as_ref() + .and_then(|p| st.by_uid.get(p).copied()) + .unwrap_or(ROOT_INO); + let uid = node.uid.clone(); + st.entries.insert(ino, Entry { uid, parent, node }); + } + + // Pass 3: rebuild child listings for fully-enumerated folders. + for dir_ino in listed_dirs { + let kids: Vec = st + .entries + .iter() + .filter(|(_, e)| e.parent == dir_ino && !e.node.trashed) + .map(|(&ino, _)| ino) + .collect(); + st.children.insert(dir_ino, kids); + } + info!(nodes = st.entries.len(), "hydrated metadata cache from db"); + } + /// Enumerate `ino`'s children from the remote and cache them. No-op if the /// directory has already been listed. Network I/O happens without the lock /// held so concurrent metadata reads aren't blocked behind a fetch. @@ -283,6 +381,29 @@ impl Core { } }; + // Offline fast path: a folder the DB still records as fully enumerated + // can be rebuilt from disk without hitting the API, even if its listing + // was trimmed from the hot cache mid-run. + match self.db.children_if_listed(&folder_uid) { + Ok(Some(nodes)) => { + let mut st = self.state.lock().unwrap(); + if st.children.contains_key(&ino) { + return Ok(()); + } + let mut child_inos = Vec::with_capacity(nodes.len()); + for node in nodes { + if node.trashed { + continue; + } + child_inos.push(st.intern(ino, node)); + } + st.children.insert(ino, child_inos); + return Ok(()); + } + Ok(None) => {} + Err(e) => warn!(%folder_uid, error = %e, "db children_if_listed failed"), + } + let uids = self .rt .block_on(self.client.enumerate_folder_children_node_uids(&folder_uid)) @@ -311,6 +432,11 @@ impl Core { child_inos.push(st.intern(ino, node)); } st.children.insert(ino, child_inos); + // Record the listing as complete so a later restart (or a trimmed hot + // cache) can rebuild it from the DB without the API. + if let Err(e) = self.db.set_listed(&folder_uid, true) { + warn!(%folder_uid, error = %e, "db set_listed(true) failed"); + } Ok(()) } @@ -553,25 +679,32 @@ impl Core { Ok(()) } - /// Pin the file at mountpoint-relative `rel`: download its full plaintext - /// into the content cache and record it in the pin registry. Returns the - /// resolved node name on success. + /// Pin the node at mountpoint-relative `rel`. A file downloads its full + /// plaintext into the content cache; a folder records a recursive pin and + /// downloads every descendant file (selective sync). Returns a human message. fn pin(&self, rel: &Path) -> Result { let (ino, uid) = self .resolve_path(rel) .map_err(|e| format!("resolve path: {e:?}"))?; - let (name, mtime, size) = { + let (name, is_folder, mtime, size) = { let st = self.state.lock().unwrap(); let e = st.entries.get(&ino).ok_or("node vanished")?; - if !e.node.is_file() { - return Err("not a regular file".into()); - } ( e.node.name.clone(), + e.node.is_folder(), e.node.modification_time, node_size(&e.node), ) }; + if is_folder { + // Record the recursive pin first so every descendant is eviction- + // exempt before we start filling the cache with the subtree. + self.cache + .add_pin(&uid, rel, true) + .map_err(|e| format!("pin: {e}"))?; + let n = self.pin_subtree(ino)?; + return Ok(format!("{name} ({n} files)")); + } let bytes = self .rt .block_on(self.client.download_file(&uid)) @@ -580,11 +713,56 @@ impl Core { .store(&uid, mtime, size, &bytes) .map_err(|e| format!("cache store: {e}"))?; self.cache - .add_pin(&uid, rel) + .add_pin(&uid, rel, false) .map_err(|e| format!("pin: {e}"))?; Ok(name) } + /// Download and cache every file in the subtree rooted at folder `ino`, + /// returning the count cached (already-fresh blobs counted, not re-fetched). + /// Walks the tree depth-first, enumerating each folder so a cold subtree is + /// fully discovered; the lock is dropped before each network download. + fn pin_subtree(&self, ino: u64) -> Result { + let mut files: Vec<(NodeUid, i64, u64)> = Vec::new(); + let mut stack = vec![ino]; + while let Some(dir) = stack.pop() { + self.ensure_children(dir) + .map_err(|e| format!("enumerate: {e:?}"))?; + let st = self.state.lock().unwrap(); + if let Some(kids) = st.children.get(&dir) { + for &k in kids { + if let Some(e) = st.entries.get(&k) { + if e.node.is_folder() { + stack.push(k); + } else { + files.push(( + e.uid.clone(), + e.node.modification_time, + node_size(&e.node), + )); + } + } + } + } + } + let mut count = 0; + for (uid, mtime, size) in files { + if self.cache.is_cached(&uid, mtime, size) { + count += 1; + continue; + } + match self.rt.block_on(self.client.download_file(&uid)) { + Ok(bytes) => { + if self.cache.store(&uid, mtime, size, &bytes).is_ok() { + count += 1; + } + } + Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"), + } + } + Ok(count) + } + /// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache /// when fresh and otherwise downloaded from the remote and cached. Returns /// `Ok(None)` when the node is not a file or has no thumbnail of that type. @@ -615,21 +793,32 @@ impl Core { Ok(bytes) } - /// Unpin the file at `rel`, evicting its cached content. + /// Unpin the node at `rel`, evicting its cached content. For a folder, also + /// evicts every descendant's cached blob (the subtree is no longer kept). fn unpin(&self, rel: &Path) -> Result { let (ino, uid) = self .resolve_path(rel) .map_err(|e| format!("resolve path: {e:?}"))?; - let name = { + let (name, is_folder) = { let st = self.state.lock().unwrap(); st.entries .get(&ino) - .map(|e| e.node.name.clone()) + .map(|e| (e.node.name.clone(), e.node.is_folder())) .unwrap_or_default() }; self.cache .remove_pin(&uid) .map_err(|e| format!("unpin: {e}"))?; + // A recursively-pinned folder's descendants were eviction-exempt; now + // that the pin is gone, reclaim their blobs eagerly instead of waiting + // for budget pressure. Descendants come from the DB node tree. + if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) { + for s in uids { + if let Some(u) = parse_uid(&s) { + self.cache.evict(&u); + } + } + } Ok(name) } @@ -681,6 +870,31 @@ impl Core { modified, pinned: self.cache.is_pinned(&uid), uid: uid.to_string(), + // Listing entries live in the requested dir; the caller derives + // the path from its name. Left empty. + path: String::new(), + }) + .collect()) + } + + /// Full-text search node names against the local SQLite index, mapping each + /// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache, + /// which the DB doesn't track). Pure local lookup — never hits the network. + fn search(&self, query: &str, limit: usize) -> Result, String> { + let hits = self + .db + .search(query, limit) + .map_err(|e| format!("search: {e}"))?; + Ok(hits + .into_iter() + .map(|h| SearchHit { + name: h.node.name.clone(), + path: h.path, + is_dir: h.node.is_folder(), + size: node_size(&h.node), + modified: h.node.modification_time, + pinned: self.cache.is_pinned(&h.node.uid), + uid: h.node.uid.to_string(), }) .collect()) } @@ -855,6 +1069,9 @@ impl ProtonFs { fn new(core: Core, root: Node) -> Self { { let mut st = core.state.lock().unwrap(); + if let Err(e) = st.db.upsert_node(&root) { + warn!(uid = %root.uid, error = %e, "db upsert root failed"); + } st.by_uid.insert(root.uid.clone(), ROOT_INO); st.entries.insert( ROOT_INO, @@ -1710,7 +1927,7 @@ fn apply_event( // Known node changed: drop its cached attrs/data (and listing if // it is a directory) so the next access re-fetches. Its content // blob may now be stale, so evict it too. - st.children.remove(&ino); + st.invalidate_listing(ino); content.evict(node_uid); let _ = notifier.inval_inode(INodeNo(ino), 0, 0); } @@ -1718,8 +1935,8 @@ fn apply_event( // drop it so the new child is picked up on the next readdir. if let Some(parent_uid) = parent_node_uid && let Some(&parent) = st.by_uid.get(parent_uid) - && st.children.remove(&parent).is_some() { + st.invalidate_listing(parent); let _ = notifier.inval_inode(INodeNo(parent), 0, 0); } } @@ -1746,8 +1963,8 @@ fn apply_event( warn!("event continuity lost; dropping all cached listings, resyncing lazily"); let mut st = state.lock().unwrap(); let dirs: Vec = st.children.keys().copied().collect(); - st.children.clear(); - for ino in dirs { + for &ino in &dirs { + st.invalidate_listing(ino); let _ = notifier.inval_inode(INodeNo(ino), 0, 0); } } @@ -1758,20 +1975,41 @@ fn apply_event( } /// Poll the remote event cursor forever, applying each batch to the shared -/// state. Seeds the cursor from the current server head so we only react to -/// changes made after mount. Runs as a Tokio task; returns only on fatal error. +/// state. Resumes from the cursor persisted in the DB so changes made while +/// unmounted are applied; only a first-ever mount seeds from the server head. +/// The cursor is persisted after every batch. Runs as a Tokio task; returns +/// only on fatal error. async fn run_event_sync( client: ProtonDriveClient, scope: DriveEventScopeId, state: Arc>, content: Arc, + db: Arc, notifier: Notifier, ) { - // Seed: a `None` cursor yields a single `CursorAdvanced` at the server head. - let mut cursor: Option = match client.enumerate_events(&scope, None).await { - Ok(events) => events.last().map(|e| e.id().clone()), + let mut cursor: Option = match db.get_event_cursor() { + // Resume: pick up exactly where the last run left off. + Ok(Some(saved)) => Some(DriveEventId::from(saved)), + // First mount: a `None` cursor yields a single `CursorAdvanced` at the + // server head; persist it so the next restart resumes instead of + // reseeding (which would skip everything that changed offline). + Ok(None) => match client.enumerate_events(&scope, None).await { + Ok(events) => { + let head = events.last().map(|e| e.id().clone()); + if let Some(c) = &head + && let Err(e) = db.set_event_cursor(c.as_str()) + { + warn!(error = %e, "persist seed cursor failed"); + } + head + } + Err(e) => { + error!(error = %e, "failed to seed event cursor; live sync disabled"); + return; + } + }, Err(e) => { - error!(error = %e, "failed to seed event cursor; live sync disabled"); + error!(error = %e, "read persisted cursor failed; live sync disabled"); return; } }; @@ -1794,6 +2032,11 @@ async fn run_event_sync( apply_event(&state, &content, ¬ifier, event); } cursor = events.last().map(|e| e.id().clone()); + if let Some(c) = &cursor + && let Err(e) = db.set_event_cursor(c.as_str()) + { + warn!(error = %e, "persist event cursor failed"); + } } } @@ -1889,6 +2132,32 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("bad uid: {uid}"), }, }, + Ok(CtlRequest::UploadPhoto { + name, + media_type, + bytes, + capture_time, + }) => { + let photos = core.photos(); + let metadata = proton_drive_rs::PhotoUploadMetadata { + capture_time, + ..Default::default() + }; + match core + .rt + .block_on(photos.upload_photo(&name, &media_type, &bytes, metadata)) + { + Ok(uid) => { + *core.timeline.lock().unwrap() = None; + CtlResponse::Ok { + message: format!("uploaded photo with uid {uid}"), + } + } + Err(e) => CtlResponse::Error { + message: format!("upload photo failed: {e}"), + }, + } + } Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.open_file(&rel) { Ok(p) => CtlResponse::FilePath { @@ -1898,6 +2167,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::Error { message: e }, }, + Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) { + Ok(hits) => CtlResponse::SearchResults { hits }, + Err(e) => CtlResponse::Error { message: e }, + }, Err(e) => CtlResponse::Error { message: format!("bad request: {e}"), }, @@ -1981,6 +2254,7 @@ pub fn mount( mountpoint: &Path, cache: ContentCache, control_socket: &Path, + db: Arc, username: String, ) -> std::io::Result { let root = rt @@ -1998,8 +2272,10 @@ pub fn mount( next_ino: 2, handles: HashMap::new(), next_fh: 1, + db: db.clone(), })), cache: Arc::new(cache), + db, timeline: Arc::new(Mutex::new(None)), }; @@ -2029,6 +2305,9 @@ pub fn mount( } let fs = ProtonFs::new(core.clone(), root); + // Warm the in-memory maps from the DB so a cold start serves previously + // seen metadata without re-hitting the API (plan.md P1). + core.hydrate(); // Build the session explicitly (not `mount2`) so we can grab a `Notifier` // for the event task. `spawn` runs the session loop on its own background @@ -2036,7 +2315,7 @@ pub fn mount( let bg = Session::new(fs, mountpoint, &config)?.spawn()?; let notifier = bg.notifier(); rt.spawn(run_event_sync( - client, scope, core.state, core.cache, notifier, + client, scope, core.state, core.cache, core.db, notifier, )); // Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C) diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index 111b17f..5d9662e 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -32,7 +32,7 @@ use gtk4::glib::BoxedAnyObject; use pdfs_core::auth; use pdfs_core::config::AppDirs; -use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send}; +use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, SearchHit, send}; use pdfs_core::service; /// How many photos to pull per [`Request::PhotosTimeline`] page. @@ -47,6 +47,11 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(2); /// Backoff between auto-retries of a Files/Photos load while the mount service /// is still coming up (see [`load_browser`] / [`load_gallery`]). const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); +/// Idle pause after the last keystroke before a search query is sent, so typing +/// doesn't fire a request per character. +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); +/// Cap on search hits requested from the daemon. +const SEARCH_LIMIT: usize = 200; /// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's /// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. @@ -112,11 +117,17 @@ struct Ui { browser_retry: gtk4::Button, /// Mountpoint-relative path the browser is showing (empty = root). browser_path: RefCell, + /// Debounced full-text search box in the browser header. + browser_search: gtk4::SearchEntry, + /// Pending debounce timer for the search box; replaced on every keystroke so + /// only the last pause actually fires a [`Request::Search`]. + search_source: RefCell>, // Photos (gallery) page. gallery_model: gio::ListStore, gallery_status: gtk4::Label, gallery_retry: gtk4::Button, gallery_more: gtk4::Button, + gallery_upload: gtk4::Button, } impl Ui { @@ -173,7 +184,17 @@ fn load_proton_theme() { .brand-icon {{ color: {PROTON_PURPLE}; }}\n\ .file-grid {{ padding: 6px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\ - .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n" + .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\ + .photo-viewer-window {{ background-color: #000000; }}\n\ + .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 12px 24px; color: white; }}\n\ + .viewer-title {{ font-weight: bold; font-size: 1.1rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ + .viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.15); border-radius: 50%; padding: 8px; margin-left: 4px; }}\n\ + .viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.3); color: white; }}\n\ + .viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.5); color: white; margin: 24px; padding: 16px; border-radius: 50%; }}\n\ + .viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.8); color: white; }}\n\ + .viewer-status {{ color: #ff5555; font-size: 1.1rem; background-color: rgba(0, 0, 0, 0.8); padding: 12px 24px; border-radius: 8px; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }}\n\ + .card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\ + .card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n" ); let provider = gtk4::CssProvider::new(); provider.load_from_string(&css); @@ -252,15 +273,19 @@ fn build_window(app: &adw::Application) { browser_status: browser_widgets.3, browser_retry: browser_widgets.6, browser_path: RefCell::new(String::new()), + browser_search: browser_widgets.7, + search_source: RefCell::new(None), gallery_model: gallery_widgets.0, gallery_status: gallery_widgets.1, gallery_retry: gallery_widgets.4, gallery_more: gallery_widgets.2, + gallery_upload: gallery_widgets.5, }); wire_login(&ui); wire_logout(&ui, &main_widgets.5); wire_browser(&ui, &browser_widgets.4, &browser_widgets.5); + wire_search(&ui); wire_gallery(&ui, &gallery_widgets.3); wire_retry(&ui); @@ -831,6 +856,7 @@ fn build_browser_page() -> ( gtk4::GridView, gtk4::ColumnView, gtk4::Button, + gtk4::SearchEntry, ), ) { let model = gio::ListStore::new::(); @@ -866,9 +892,16 @@ fn build_browser_page() -> ( toggles.append(&grid_toggle); toggles.append(&list_toggle); + let search = gtk4::SearchEntry::builder() + .placeholder_text("Search Drive") + .valign(gtk4::Align::Center) + .build(); + search.set_width_chars(18); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); header.append(&back); header.append(&crumb); + header.append(&search); header.append(&toggles); let status = gtk4::Label::builder().wrap(true).build(); @@ -938,7 +971,7 @@ fn build_browser_page() -> ( ( inner.upcast(), - (model, back, crumb, status, grid, column_view, retry), + (model, back, crumb, status, grid, column_view, retry, search), ) } @@ -1165,6 +1198,11 @@ fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option, entry: &DirEntry) { let rel = entry_rel(ui, entry); if entry.is_dir { + // Descending into a search hit: clear the query so the folder listing + // isn't immediately re-masked by a stale search. + if !entry.path.is_empty() { + ui.browser_search.set_text(""); + } *ui.browser_path.borrow_mut() = rel; load_browser(ui); } else { @@ -1217,6 +1255,11 @@ fn toggle_pin(ui: &Rc, entry: &DirEntry) { /// Join the entry name onto the current browser directory to get its /// mountpoint-relative path. fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { + // Search hits carry an absolute (mountpoint-relative) path since they can + // live anywhere; plain listing entries derive it from the current folder. + if !entry.path.is_empty() { + return entry.path.clone(); + } let base = ui.browser_path.borrow(); if base.is_empty() { entry.name.clone() @@ -1357,6 +1400,97 @@ fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { } } +/// Wire the browser header's search box: debounce keystrokes, then either run a +/// search or — when cleared — restore the current directory listing. +fn wire_search(ui: &Rc) { + let ui_s = ui.clone(); + ui.browser_search.connect_search_changed(move |_| { + // Replace any pending debounce so only the last keystroke's pause fires. + if let Some(src) = ui_s.search_source.borrow_mut().take() { + src.remove(); + } + let ui_t = ui_s.clone(); + let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { + ui_t.search_source.borrow_mut().take(); + let query = ui_t.browser_search.text().trim().to_string(); + if query.is_empty() { + load_browser(&ui_t); + } else { + run_search(&ui_t, &query); + } + }); + *ui_s.search_source.borrow_mut() = Some(src); + }); +} + +/// Send a [`Request::Search`] to the daemon and render the hits in the browser +/// views, reusing the same row model so click-to-open and pin work unchanged +/// (each hit carries its full path; see [`entry_rel`]). +fn run_search(ui: &Rc, query: &str) { + ui.browser_model.remove_all(); + ui.browser_retry.set_visible(false); + ui.browser_status.set_label("Searching…"); + ui.browser_status.set_visible(true); + + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::Search { + query: query.to_string(), + limit: SEARCH_LIMIT, + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + // The box may have been cleared (or changed) while the reply was in + // flight; if so a fresh load/search already owns the model — drop this. + if ui.browser_search.text().trim().is_empty() { + return; + } + match result { + Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits), + Ok(Ok(Response::Error { message })) => browser_message(&ui, &message), + Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."), + Ok(Err(_)) | Err(_) => browser_unreachable(&ui), + } + }); +} + +/// Repopulate the model with search hits — folders first, then by name — mapping +/// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and +/// handlers already understand. +fn repaint_search(ui: &Rc, hits: &[SearchHit]) { + ui.browser_model.remove_all(); + if hits.is_empty() { + browser_message(ui, "No matches."); + return; + } + ui.browser_status.set_visible(false); + + let mut entries: Vec = hits + .iter() + .map(|h| DirEntry { + name: h.name.clone(), + is_dir: h.is_dir, + size: h.size, + modified: h.modified, + pinned: h.pinned, + uid: h.uid.clone(), + path: h.path.clone(), + }) + .collect(); + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in entries { + ui.browser_model.append(&BoxedAnyObject::new(entry)); + } +} + /// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a /// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a /// "Load more" button and a status label. @@ -1368,6 +1502,7 @@ fn build_gallery_page() -> ( gtk4::Button, gtk4::GridView, gtk4::Button, + gtk4::Button, // Upload Photo ), ) { let model = gio::ListStore::new::(); @@ -1429,21 +1564,41 @@ fn build_gallery_page() -> ( .child(&grid) .build(); + // Modern header: "Photos" title + "Upload Photo" action button + let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let title_label = gtk4::Label::builder() + .label("Photos") + .halign(gtk4::Align::Start) + .hexpand(true) + .build(); + title_label.add_css_class("heading"); + + let upload = gtk4::Button::builder() + .label("Upload Photo") + .icon_name("list-add-symbolic") + .valign(gtk4::Align::Center) + .build(); + upload.add_css_class("pill"); + upload.add_css_class("suggested-action"); + header_box.append(&title_label); + header_box.append(&upload); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); inner.set_margin_start(12); inner.set_margin_end(12); + inner.append(&header_box); inner.append(&status); inner.append(&retry); inner.append(&scroll); inner.append(&more); - (inner.upcast(), (model, status, more, grid, retry)) + (inner.upcast(), (model, status, more, grid, retry, upload)) } -/// Wire the gallery: activating a tile downloads the photo and opens it; the -/// "Load more" button appends the next page. +/// Wire the gallery: activating a tile downloads the photo and opens it in our +/// in-app lightbox viewer; the "Load more" button appends the next page. fn wire_gallery(ui: &Rc, grid: >k4::GridView) { let ui_open = ui.clone(); grid.connect_activate(move |grid, pos| { @@ -1454,34 +1609,513 @@ fn wire_gallery(ui: &Rc, grid: >k4::GridView) { .downcast_ref::() .map(|o| o.borrow::().uid.clone()); let Some(uid) = uid else { return }; - // Ignore a repeat activation of a photo already downloading. - if !ui_open.opening.borrow_mut().insert(uid.clone()) { - return; - } - ui_open.busy_begin(); - let rx = spawn_request( - ui_open.dirs.control_socket(), - Request::OpenPhoto { uid: uid.clone() }, - ); - let ui = ui_open.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - ui.opening.borrow_mut().remove(&uid); - match result { - Ok(Ok(Response::FilePath { path })) => open_path(&path), - Ok(Ok(Response::Error { message })) => { - tracing::error!("open photo failed: {message}") - } - _ => tracing::error!("open photo request failed"), - } - }); + open_photo_viewer(&ui_open, uid); }); let ui_more = ui.clone(); ui.gallery_more.clone().connect_clicked(move |_| { load_gallery(&ui_more, true); }); + + let ui_upload = ui.clone(); + ui.gallery_upload.connect_clicked(move |_| { + let dialog = gtk4::FileDialog::builder() + .title("Select Photo to Upload") + .build(); + + let filter = gtk4::FileFilter::new(); + filter.set_name(Some("Images")); + filter.add_mime_type("image/*"); + let filters = gio::ListStore::new::(); + filters.append(&filter); + dialog.set_filters(Some(&filters)); + + let ui = ui_upload.clone(); + let parent_win = ui.stack.root().and_downcast::(); + dialog.open(parent_win.as_ref(), gio::Cancellable::NONE, move |res| { + if let Ok(file) = res { + if let Some(path) = file.path() { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("photo.jpg") + .to_string(); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + tracing::error!("Failed to read photo: {e}"); + return; + } + }; + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("jpg") + .to_lowercase(); + let media_type = match ext.as_str() { + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "tiff" | "tif" => "image/tiff", + _ => "image/jpeg", + }; + let capture_time = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::UploadPhoto { + name, + media_type: media_type.to_string(), + bytes, + capture_time, + }, + ); + let ui_clone = ui.clone(); + glib::spawn_future_local(async move { + let res = rx.recv().await; + ui_clone.busy_end(); + match res { + Ok(Ok(Response::Ok { message })) => { + tracing::info!("Photo uploaded: {message}"); + load_gallery(&ui_clone, false); + } + Ok(Ok(Response::Error { message })) => { + tracing::error!("Upload failed: {message}"); + show_error_dialog(&ui_clone, &format!("Upload failed: {message}")); + } + _ => { + tracing::error!("Upload request failed"); + show_error_dialog( + &ui_clone, + "Upload request failed (daemon unreachable).", + ); + } + } + }); + } + } + }); + }); +} + +fn show_error_dialog(ui: &Rc, message: &str) { + let parent = ui.stack.root().and_downcast::(); + let dialog = adw::AlertDialog::builder() + .heading("Photo Operation") + .body(message) + .build(); + dialog.add_response("ok", "OK"); + dialog.present(parent.as_ref()); +} + +fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option { + for i in 0..model.n_items() { + if let Some(obj) = model.item(i) { + if let Some(boxed) = obj.downcast_ref::() { + if boxed.borrow::().uid == uid { + return Some(i); + } + } + } + } + None +} + +fn format_capture_time(secs: i64) -> String { + let date = glib::DateTime::from_unix_local(secs); + match date { + Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") { + Ok(s) => s.to_string(), + Err(_) => "Unknown Date".to_string(), + }, + Err(_) => "Unknown Date".to_string(), + } +} + +fn load_photo( + ui: &Rc, + uid: String, + picture: >k4::Picture, + spinner: >k4::Spinner, + status_label: >k4::Label, + current_path: Rc>>, +) { + spinner.set_visible(true); + spinner.start(); + picture.set_paintable(gtk4::gdk::Paintable::NONE); + status_label.set_visible(false); + *current_path.borrow_mut() = None; + + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenPhoto { uid: uid.clone() }, + ); + let picture_clone = picture.clone(); + let spinner_clone = spinner.clone(); + let status_clone = status_label.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + spinner_clone.stop(); + spinner_clone.set_visible(false); + match result { + Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { + Ok(texture) => { + picture_clone.set_paintable(Some(&texture)); + *current_path.borrow_mut() = Some(path); + } + Err(e) => { + status_clone.set_label(&format!("Failed to load image: {e}")); + status_clone.set_visible(true); + } + }, + Ok(Ok(Response::Error { message })) => { + status_clone.set_label(&format!("Error downloading photo: {message}")); + status_clone.set_visible(true); + } + _ => { + status_clone.set_label("Failed to connect to daemon"); + status_clone.set_visible(true); + } + } + }); +} + +fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) { + let dialog = gtk4::FileDialog::builder() + .title("Save Photo") + .initial_name(original_name) + .build(); + let source_path_str = source_path.to_string(); + dialog.save(Some(window), gio::Cancellable::NONE, move |res| { + if let Ok(file) = res { + if let Some(dest_path) = file.path() { + if let Err(e) = std::fs::copy(&source_path_str, &dest_path) { + tracing::error!("Failed to copy file to {:?}: {}", dest_path, e); + } else { + tracing::info!("Saved photo to {:?}", dest_path); + } + } + } + }); +} + +fn navigate_photo( + ui: &Rc, + current_uid: &Rc>, + delta: i32, + picture: >k4::Picture, + spinner: >k4::Spinner, + status_label: >k4::Label, + title_label: >k4::Label, + prev_btn: >k4::Button, + next_btn: >k4::Button, + current_path: Rc>>, +) { + let model = &ui.gallery_model; + let n = model.n_items(); + if n == 0 { + return; + } + let uid_val = current_uid.borrow().clone(); + let current_idx = find_photo_index(model, &uid_val).unwrap_or(0); + let next_idx = (current_idx as i32 + delta).clamp(0, n as i32 - 1) as u32; + + let Some(obj) = model.item(next_idx) else { + return; + }; + let Some(boxed) = obj.downcast_ref::() else { + return; + }; + let photo = boxed.borrow::().clone(); + + *current_uid.borrow_mut() = photo.uid.clone(); + + prev_btn.set_sensitive(next_idx > 0); + next_btn.set_sensitive(next_idx < n - 1); + + let date_str = format_capture_time(photo.capture_time); + title_label.set_label(&format!("Photo {} of {} • {}", next_idx + 1, n, date_str)); + + load_photo( + ui, + photo.uid.clone(), + picture, + spinner, + status_label, + current_path, + ); +} + +fn open_photo_viewer(ui: &Rc, initial_uid: String) { + let parent = ui.stack.root().and_downcast::().unwrap(); + + let window = gtk4::Window::builder() + .title("Photo Viewer") + .modal(true) + .transient_for(&parent) + .default_width(800) + .default_height(600) + .build(); + window.add_css_class("photo-viewer-window"); + + let overlay = gtk4::Overlay::new(); + + let picture = gtk4::Picture::builder() + .content_fit(gtk4::ContentFit::Contain) + .hexpand(true) + .vexpand(true) + .build(); + overlay.set_child(Some(&picture)); + + let prev_btn = gtk4::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text("Previous Photo") + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .build(); + prev_btn.add_css_class("circular"); + prev_btn.add_css_class("flat"); + prev_btn.add_css_class("viewer-nav-btn"); + overlay.add_overlay(&prev_btn); + + let next_btn = gtk4::Button::builder() + .icon_name("go-next-symbolic") + .tooltip_text("Next Photo") + .halign(gtk4::Align::End) + .valign(gtk4::Align::Center) + .build(); + next_btn.add_css_class("circular"); + next_btn.add_css_class("flat"); + next_btn.add_css_class("viewer-nav-btn"); + overlay.add_overlay(&next_btn); + + let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + top_bar.add_css_class("viewer-top-bar"); + top_bar.set_valign(gtk4::Align::Start); + top_bar.set_hexpand(true); + + let title_label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .hexpand(true) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + title_label.add_css_class("viewer-title"); + top_bar.append(&title_label); + + let download_btn = gtk4::Button::builder() + .icon_name("document-save-symbolic") + .tooltip_text("Download to Disk") + .build(); + download_btn.add_css_class("flat"); + download_btn.add_css_class("viewer-action-btn"); + top_bar.append(&download_btn); + + let open_ext_btn = gtk4::Button::builder() + .icon_name("document-open-symbolic") + .tooltip_text("Open in External App") + .build(); + open_ext_btn.add_css_class("flat"); + open_ext_btn.add_css_class("viewer-action-btn"); + top_bar.append(&open_ext_btn); + + let close_btn = gtk4::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .build(); + close_btn.add_css_class("flat"); + close_btn.add_css_class("viewer-action-btn"); + top_bar.append(&close_btn); + + overlay.add_overlay(&top_bar); + + let spinner = gtk4::Spinner::builder() + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .width_request(48) + .height_request(48) + .build(); + overlay.add_overlay(&spinner); + + let status_label = gtk4::Label::builder() + .wrap(true) + .justify(gtk4::Justification::Center) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + status_label.add_css_class("viewer-status"); + overlay.add_overlay(&status_label); + + window.set_child(Some(&overlay)); + + let current_uid = Rc::new(RefCell::new(initial_uid.clone())); + let current_path = Rc::new(RefCell::new(None::)); + + let n = ui.gallery_model.n_items(); + let initial_idx = find_photo_index(&ui.gallery_model, &initial_uid).unwrap_or(0); + prev_btn.set_sensitive(initial_idx > 0); + next_btn.set_sensitive(initial_idx < n - 1); + + let mut initial_capture_time = 0; + if let Some(obj) = ui.gallery_model.item(initial_idx) { + if let Some(boxed) = obj.downcast_ref::() { + initial_capture_time = boxed.borrow::().capture_time; + } + } + let date_str = format_capture_time(initial_capture_time); + title_label.set_label(&format!( + "Photo {} of {} • {}", + initial_idx + 1, + n, + date_str + )); + + load_photo( + ui, + initial_uid, + &picture, + &spinner, + &status_label, + current_path.clone(), + ); + + let w_close = window.clone(); + close_btn.connect_clicked(move |_| { + w_close.close(); + }); + + let w_download = window.clone(); + let path_download = current_path.clone(); + let uid_download = current_uid.clone(); + download_btn.connect_clicked(move |_| { + if let Some(path) = path_download.borrow().as_deref() { + let uid_val = uid_download.borrow(); + let name = format!("{}.jpg", uid_val); + save_photo_to_disk(&w_download, path, &name); + } + }); + + let path_ext = current_path.clone(); + open_ext_btn.connect_clicked(move |_| { + if let Some(path) = path_ext.borrow().as_deref() { + open_path(path); + } + }); + + let ui_prev = ui.clone(); + let uid_prev = current_uid.clone(); + let pic_prev = picture.clone(); + let spin_prev = spinner.clone(); + let status_prev = status_label.clone(); + let title_prev = title_label.clone(); + let p_btn = prev_btn.clone(); + let n_btn = next_btn.clone(); + let path_prev = current_path.clone(); + prev_btn.connect_clicked(move |_| { + navigate_photo( + &ui_prev, + &uid_prev, + -1, + &pic_prev, + &spin_prev, + &status_prev, + &title_prev, + &p_btn, + &n_btn, + path_prev.clone(), + ); + }); + + let ui_next = ui.clone(); + let uid_next = current_uid.clone(); + let pic_next = picture.clone(); + let spin_next = spinner.clone(); + let status_next = status_label.clone(); + let title_next = title_label.clone(); + let p_btn = prev_btn.clone(); + let n_btn = next_btn.clone(); + let path_next = current_path.clone(); + next_btn.connect_clicked(move |_| { + navigate_photo( + &ui_next, + &uid_next, + 1, + &pic_next, + &spin_next, + &status_next, + &title_next, + &p_btn, + &n_btn, + path_next.clone(), + ); + }); + + let key_controller = gtk4::EventControllerKey::new(); + let ui_key = ui.clone(); + let uid_key = current_uid.clone(); + let pic_key = picture.clone(); + let spin_key = spinner.clone(); + let status_key = status_label.clone(); + let title_key = title_label.clone(); + let p_btn = prev_btn.clone(); + let n_btn = next_btn.clone(); + let path_key = current_path.clone(); + let w_key = window.clone(); + key_controller.connect_key_pressed(move |_, key, _keycode, _state| { + match key.name().as_deref() { + Some("Left") => { + let current_idx = + find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0); + if current_idx > 0 { + navigate_photo( + &ui_key, + &uid_key, + -1, + &pic_key, + &spin_key, + &status_key, + &title_key, + &p_btn, + &n_btn, + path_key.clone(), + ); + } + glib::Propagation::Stop + } + Some("Right") => { + let n = ui_key.gallery_model.n_items(); + let current_idx = + find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0); + if current_idx < n - 1 { + navigate_photo( + &ui_key, + &uid_key, + 1, + &pic_key, + &spin_key, + &status_key, + &title_key, + &p_btn, + &n_btn, + path_key.clone(), + ); + } + glib::Propagation::Stop + } + Some("Escape") => { + w_key.close(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + window.add_controller(key_controller); + + window.present(); } /// Fetch a timeline page from the daemon. When `append` is false the model is