diff --git a/Cargo.lock b/Cargo.lock index 67ecda5..8e1b2b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3538,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pdfs-cli" -version = "1.9.1" +version = "1.9.2" dependencies = [ "anyhow", "clap 4.6.4", @@ -3553,7 +3553,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "1.9.1" +version = "1.9.2" dependencies = [ "async-trait", "directories", @@ -3576,7 +3576,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "1.9.1" +version = "1.9.2" dependencies = [ "base64", "fuser", @@ -3596,7 +3596,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "1.9.1" +version = "1.9.2" dependencies = [ "anyhow", "async-channel", @@ -3605,6 +3605,7 @@ dependencies = [ "ksni", "libadwaita", "pdfs-core", + "serde", "serde_json", "tokio", "tracing", @@ -3857,9 +3858,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58322795538d66137d2eb071590375c5287e69059c429e2309dc37368ab5b588" +checksum = "9d919c52e0ffcb94459ec14daf46d13e7590b81ade0ff757665c62c310faac0b" dependencies = [ "async-trait", "base64", @@ -3884,9 +3885,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77482a3160d14926c65537c64bc95cf27d609597a524c97d97d97d1777e9603" +checksum = "6b531dbbaa8133c0ef63453ed75dbc0a416dfb12f24f39a6e4f0549caa95dcc0" dependencies = [ "aes-gcm 0.11.0", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 3549f1a..89e5c4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "1.9.1" +version = "1.9.2" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] @@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"] categories = ["filesystem", "command-line-utilities"] [workspace.dependencies] -proton-sdk = { version = "0.6.1" } -proton-drive-rs = { version = "0.6.1" } +proton-sdk = { version = "0.6.2" } +proton-drive-rs = { version = "0.6.2" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } diff --git a/README.md b/README.md index 3fd4143..59e80ba 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ pdfs-app # the desktop app - **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account. - **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here. - **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls. -- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. +- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`). - **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages. - **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI). - **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one. @@ -124,6 +124,51 @@ launcher command, set them in `config.json`: anywhere in `menu` is replaced by the prompt text; without one, the launcher's own prompt flag is appended. +### Live launcher search (fzf) + +The two-step above exists because no dmenu-style launcher can call back for new +results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type, +exactly like the built-in window: + +```bash +pdfs-prompt --fzf # pinned files, then live results as you type +pdfs-prompt --fzf --query invoice # start on a search +``` + +Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's +own matcher is switched off, so the ordering you see is the daemon's ranking — +abbreviations and typos included, which a launcher filtering a fixed list cannot +do. Enter opens; Escape closes. + +fzf is a terminal program, so when it is started without one — from a WM +keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty, +wezterm or xterm, whichever is installed. Each is launched with the app-id/class +`pdfs-prompt`, so one window rule floats it whichever terminal you have. In +Hyprland: + +``` +windowrulev2 = float, class:^(pdfs-prompt)$ +windowrulev2 = size 900 500, class:^(pdfs-prompt)$ +windowrulev2 = center, class:^(pdfs-prompt)$ +bind = SUPER, space, exec, pdfs-prompt --fzf +``` + +Set it as the default front end, and pick the terminal explicitly, in +`config.json`: + +```json +{ + "prompt": { + "mode": "fzf", + "terminal": ["foot", "--app-id=pdfs-prompt", "--"], + "menu_limit": 50 + } +} +``` + +A `{cmd}` token anywhere in `terminal` is replaced by the command to run; +without one it is appended, which is what every detected terminal expects. + ## Choosing How Files Open By default every result is handed to `xdg-open`. An `open_with` block in diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 9e6029a..86d6e70 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -176,6 +176,43 @@ enum Command { #[arg(long)] wait: bool, }, + /// Fix photos filed under the day they were imported instead of the day + /// they were taken. + /// + /// A Takeout whose metadata sidecars did not match imports with no capture + /// time, and Proton then stamps every photo with the moment it arrived. The + /// date is still in the file name (`IMG-20230219-WA0001.jpg`), so this finds + /// photos whose name disagrees with their capture time by more than a day + /// and rewrites them at the right time. + /// + /// Proton cannot edit a sealed capture time, so a rewrite means re-uploading + /// the photo and trashing the original — favourites and albums are carried + /// across, and the original goes to Proton's trash, not away. Run it with + /// `--dry-run` first. + RedatePhotos { + /// Report what would be re-dated without uploading or trashing anything. + #[arg(long)] + dry_run: bool, + /// Stop after this many photos. + #[arg(long)] + limit: Option, + /// Only touch photos whose current capture time is on or after this + /// date (`YYYY-MM-DD`, UTC) — aim the repair at the days an import + /// landed on. + #[arg(long)] + from: Option, + /// Only touch photos whose current capture time is before this date + /// (`YYYY-MM-DD`, UTC). + #[arg(long)] + to: Option, + /// Stay attached and print the report when the run finishes. + #[arg(long)] + wait: bool, + }, + /// Show how the running (or last) photo re-date is doing. + RedateStatus, + /// Stop the running photo re-date. + CancelRedate, /// Show how the running (or last) Google Photos import is doing. ImportStatus, /// Stop the running Google Photos import. @@ -616,6 +653,15 @@ fn main() -> Result<()> { dry_run, wait, } => cmd_import_google_photos(archives, dry_run, wait), + Command::RedatePhotos { + dry_run, + limit, + from, + to, + wait, + } => cmd_redate_photos(dry_run, limit, from, to, wait), + Command::RedateStatus => cmd_redate_status(), + Command::CancelRedate => cmd_cancel_redate(), Command::ImportStatus => cmd_import_status(), Command::CancelImport => cmd_cancel_import(), Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset), @@ -1787,6 +1833,115 @@ fn cmd_import_google_photos(archives: Vec, dry_run: bool, wait: bool) - } } +fn cmd_redate_photos( + dry_run: bool, + limit: Option, + from: Option, + to: Option, + wait: bool, +) -> Result<()> { + let range = match (from.as_deref(), to.as_deref()) { + (None, None) => None, + (from, to) => Some(( + from.map(parse_day).transpose()?.unwrap_or(i64::MIN), + to.map(parse_day).transpose()?.unwrap_or(i64::MAX), + )), + }; + match control_request(CtlRequest::RedatePhotos { + dry_run, + limit, + range, + })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + if !wait { + println!("Run `pdfs redate-status` to follow it."); + return Ok(()); + } + // Polled for the same reason the import is: request/response protocol, and a + // run over a whole library is a download plus an upload per photo. + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + match control_request(CtlRequest::RedateStatus)? { + CtlResponse::RedateStatus { running: true, .. } => continue, + CtlResponse::RedateStatus { summary, .. } => { + print_redate_summary(summary.as_ref()); + return Ok(()); + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + } +} + +/// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout +/// scanner's date reader, which already accepts exactly this spelling. +fn parse_day(day: &str) -> Result { + pdfs_core::takeout::capture_time_from_name(day) + .ok_or_else(|| anyhow!("not a date (expected YYYY-MM-DD): {day}")) +} + +fn cmd_redate_status() -> Result<()> { + match control_request(CtlRequest::RedateStatus)? { + CtlResponse::RedateStatus { running, summary } => { + if running { + println!("Re-date running. `pdfs transfers` shows its progress."); + } + print_redate_summary(summary.as_ref()); + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_cancel_redate() -> Result<()> { + match control_request(CtlRequest::CancelRedate)? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) { + let Some(summary) = summary else { + println!("No re-date has run yet."); + return; + }; + println!("Photos examined : {}", summary.examined); + println!("Mis-dated : {}", summary.candidates); + if summary.dry_run { + println!("(dry run — nothing was uploaded or trashed)"); + } else { + println!("Re-dated : {}", summary.redated); + if summary.album_links > 0 { + println!("Albums refiled : {}", summary.album_links); + } + if summary.failed > 0 { + println!("Failed : {}", summary.failed); + } + if summary.bytes > 0 { + println!("Uploaded bytes : {}", summary.bytes); + } + } + for (name, stored, wanted) in &summary.samples { + // Epoch seconds, as `pdfs photos` prints capture times. + println!(" {name}: {stored} -> {wanted}"); + } + if summary.candidates > summary.samples.len() { + println!( + " … and {} more", + summary.candidates - summary.samples.len() + ); + } + if summary.cancelled { + println!("Run was cancelled before it finished."); + } +} + fn cmd_import_status() -> Result<()> { match control_request(CtlRequest::ImportStatus)? { CtlResponse::ImportStatus { running, summary } => { diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 46b7f0e..1cfabd8 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -149,6 +149,43 @@ pub enum Request { #[serde(default)] dry_run: bool, }, + /// Re-date photos whose capture time is when they were *imported* rather + /// than when they were taken. + /// + /// Proton has no API for editing a photo's capture time — it is sealed into + /// the revision — so the only repair is to upload the photo again with the + /// right time and trash the original. That is what this does, for photos + /// whose file name carries a date that disagrees with their stored capture + /// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is + /// the signature of an import whose metadata sidecars did not match. + /// + /// Favourites and album memberships are carried across; the original is + /// **trashed**, not deleted, so a bad run is recoverable from Proton's + /// trash. With `dry_run` nothing is uploaded or trashed and the report says + /// what would have been done. + /// + /// Acks immediately with [`Response::Ok`] and works in the background — each + /// photo is a download plus an upload. Progress shows up in + /// [`Request::GetQueueStatus`] as a job, and the counts in + /// [`Request::RedateStatus`]. + RedatePhotos { + #[serde(default)] + dry_run: bool, + /// Stop after this many photos. `None` is the whole library. + #[serde(default)] + limit: Option, + /// Only consider photos whose *stored* capture time falls in this + /// `[from, to)` window — the way to aim the repair at the days an + /// import landed on and leave the rest of the library untouched. + #[serde(default)] + range: Option<(i64, i64)>, + }, + /// How the running (or last finished) re-date is doing. Replies with + /// [`Response::RedateStatus`]. + RedateStatus, + /// Ask the running re-date to stop. It finishes the photo on the wire, then + /// reports `cancelled`. Replies with [`Response::Ok`]. + CancelRedate, /// How the running (or last finished) Takeout import is doing. Replies with /// [`Response::ImportStatus`]. ImportStatus, @@ -1109,6 +1146,35 @@ pub struct PhotoMonth { pub count: usize, } +/// How a photo re-date is going, or how the last one ended +/// ([`Response::RedateStatus`]). Cumulative for the run, so the same shape +/// serves progress and the final report. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct RedateSummary { + /// Photos examined. + pub examined: usize, + /// Photos whose name implies a capture time more than a day from the one + /// they carry — what the run would (or did) repair. + pub candidates: usize, + /// Photos re-uploaded with the right capture time, their original trashed. + pub redated: usize, + /// Photos that could not be downloaded, re-uploaded, or trashed. The + /// original is left alone in every failure case. + pub failed: usize, + /// Album memberships re-created on the new copies. + pub album_links: usize, + /// Bytes uploaded. + pub bytes: u64, + /// True when the run stopped early because it was cancelled. + pub cancelled: bool, + /// True when nothing was actually uploaded or trashed. + pub dry_run: bool, + /// Up to a handful of examples, as `(name, stored capture time, the time the + /// name implies)` — what a dry run is read for. + #[serde(default)] + pub samples: Vec<(String, i64, i64)>, +} + /// How a Google Photos Takeout import is going, or how the last one ended /// ([`Response::ImportStatus`]). /// @@ -1434,6 +1500,14 @@ pub enum Response { #[serde(default)] summary: Option, }, + /// How the photo re-date is doing (reply to [`Request::RedateStatus`]). + /// `running` is false once it has finished, when `summary` is the final + /// report; `summary` is `None` when no re-date has run this session. + RedateStatus { + running: bool, + #[serde(default)] + summary: Option, + }, /// The months the timeline spans (reply to [`Request::PhotoMonths`]), /// newest first. PhotoMonths { months: Vec }, diff --git a/crates/pdfs-core/src/db/albums.rs b/crates/pdfs-core/src/db/albums.rs index 69ae088..a664234 100644 --- a/crates/pdfs-core/src/db/albums.rs +++ b/crates/pdfs-core/src/db/albums.rs @@ -188,6 +188,19 @@ impl Db { Ok(rows) } + /// Which albums hold `uid`, by album node uid. Read by the photo re-date, + /// which has to put the re-uploaded copy back into the albums the original + /// was in. + pub fn albums_of_photo(&self, uid: &str) -> Result> { + let conn = self.read(); + let mut stmt = + conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?; + let rows = stmt + .query_map([uid], |r| r.get(0))? + .collect::>()?; + Ok(rows) + } + /// How many photos of `album_uid` are persisted. pub fn album_photos_count(&self, album_uid: &str) -> Result { let conn = self.read(); diff --git a/crates/pdfs-core/src/db/photos.rs b/crates/pdfs-core/src/db/photos.rs index 56bf140..7dbac0f 100644 --- a/crates/pdfs-core/src/db/photos.rs +++ b/crates/pdfs-core/src/db/photos.rs @@ -165,6 +165,31 @@ impl Db { Ok(rows) } + /// The whole persisted timeline, in stored order. Only for whole-library + /// passes (the photo re-date) — the gallery pages with + /// [`Db::photos_page`]. + pub fn photos_all(&self) -> Result> { + let conn = self.read(); + let mut stmt = conn.prepare( + "SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite \ + FROM photos ORDER BY seq", + )?; + let rows = stmt + .query_map([], |r| { + Ok(StoredPhoto { + uid: r.get(0)?, + capture_time: r.get(1)?, + name: r.get(2)?, + ratio: r.get(3)?, + thumb_state: r.get(4)?, + kind: crate::control::PhotoKind::from_i64(r.get(5)?), + favorite: r.get::<_, i64>(6)? != 0, + }) + })? + .collect::>()?; + Ok(rows) + } + /// The months the timeline spans, newest first, each with how many photos it /// holds — the data behind the date scrubber. Buckets are local-time /// `(year, month)` so they line up with the day headings the gallery draws diff --git a/crates/pdfs-core/src/menu.rs b/crates/pdfs-core/src/menu.rs index b94ab37..89eff60 100644 --- a/crates/pdfs-core/src/menu.rs +++ b/crates/pdfs-core/src/menu.rs @@ -72,6 +72,25 @@ const KNOWN_MENUS: [MenuFlavor; 6] = [ }, ]; +/// Terminals that can host `pdfs-prompt --fzf`, most-preferred first. +/// +/// The fzf front end is a terminal program, so a keybinding that has no tty +/// needs one spawned for it. Each entry sets a stable app-id/class +/// ([`TERMINAL_APP_ID`]) so a tiling WM can be told to float exactly this +/// window, and ends with the flag after which the command to run follows. +const KNOWN_TERMINALS: [&[&str]; 6] = [ + &["foot", "--app-id=pdfs-prompt", "--"], + &["ghostty", "--class=pdfs-prompt", "-e"], + &["kitty", "--class", "pdfs-prompt"], + &["alacritty", "--class", "pdfs-prompt", "-e"], + &["wezterm", "start", "--class", "pdfs-prompt", "--"], + &["xterm", "-class", "pdfs-prompt", "-e"], +]; + +/// The app-id/class every [`KNOWN_TERMINALS`] entry sets, so the window rule a +/// user writes for one terminal keeps working if they switch. +pub const TERMINAL_APP_ID: &str = "pdfs-prompt"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PromptFlag { /// `--prompt=TEXT` @@ -105,6 +124,13 @@ pub struct PromptConfig { /// the prompt text; without one the launcher's own prompt flag is appended. #[serde(default, skip_serializing_if = "Option::is_none")] pub menu: Option>, + /// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty. + /// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token + /// anywhere in the argv is replaced by the command to run; without one the + /// command is appended, which is what every [`KNOWN_TERMINALS`] entry + /// expects. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal: Option>, /// How many results to feed the launcher. Unset means /// [`DEFAULT_MENU_LIMIT`]. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -124,6 +150,8 @@ pub enum PromptMode { Gtk, /// The user's own launcher, driven over stdin/stdout. Dmenu, + /// `fzf` in a terminal, re-querying the daemon on every keystroke. + Fzf, } /// Rows an external launcher can show without becoming a scroll exercise. Larger @@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec>) -> Option> { Some(argv) } +/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the +/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and +/// including the "run this command" flag — the caller appends the command, or +/// substitutes it for a `{cmd}` token, via [`with_command`]. +pub fn resolve_terminal(configured: Option<&Vec>) -> Option> { + if let Some(argv) = configured.filter(|argv| !argv.is_empty()) { + return Some(argv.clone()); + } + let flavor = KNOWN_TERMINALS + .iter() + .find(|argv| argv.first().is_some_and(|program| on_path(program)))?; + Some(flavor.iter().map(|arg| (*arg).to_string()).collect()) +} + +/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the +/// user wrote one, otherwise appended. +/// +/// The token exists for terminals that want the command in the middle of their +/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose +/// last element is the flag the command follows. +pub fn with_command(argv: &[String], command: &[String]) -> Vec { + if !argv.iter().any(|arg| arg == "{cmd}") { + let mut argv = argv.to_vec(); + argv.extend_from_slice(command); + return argv; + } + let mut out = Vec::with_capacity(argv.len() + command.len()); + for arg in argv { + if arg == "{cmd}" { + out.extend_from_slice(command); + } else { + out.push(arg.clone()); + } + } + out +} + /// Show `items` in the launcher named by `argv` and wait for a choice. /// /// Blocking by design: the caller is a one-shot CLI invocation whose entire job @@ -305,7 +370,8 @@ fn flavor_of(program: &str) -> Option<&'static MenuFlavor> { KNOWN_MENUS.iter().find(|flavor| flavor.program == base) } -fn on_path(program: &str) -> bool { +/// Whether `program` is an executable file on `PATH`. +pub fn on_path(program: &str) -> bool { let Some(path) = std::env::var_os("PATH") else { return false; }; @@ -408,6 +474,47 @@ mod tests { ); } + #[test] + fn a_configured_terminal_is_used_verbatim() { + let configured = vec!["my-term".to_string(), "-e".to_string()]; + assert_eq!( + resolve_terminal(Some(&configured)), + Some(configured.clone()) + ); + // An empty list is "not configured", not "run nothing". + assert_eq!( + resolve_terminal(Some(&Vec::new())), + resolve_terminal(None), + "an empty terminal list must fall back to detection" + ); + } + + #[test] + fn the_command_is_appended_unless_the_user_placed_it() { + let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()]; + assert_eq!( + with_command(&["foot".into(), "--".into()], &command), + vec!["foot", "--", "pdfs-prompt", "--fzf"] + ); + assert_eq!( + with_command( + &["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()], + &command + ), + vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"] + ); + } + + #[test] + fn every_known_terminal_sets_the_same_app_id() { + for argv in KNOWN_TERMINALS { + assert!( + argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)), + "{argv:?} must be floatable by one window rule" + ); + } + } + #[test] fn a_configured_launcher_is_used_verbatim() { let configured = vec!["my-menu".to_string(), "--pick".to_string()]; diff --git a/crates/pdfs-core/src/opener.rs b/crates/pdfs-core/src/opener.rs index 167e18f..f1e356b 100644 --- a/crates/pdfs-core/src/opener.rs +++ b/crates/pdfs-core/src/opener.rs @@ -23,8 +23,9 @@ //! ``` use std::ffi::OsStr; +use std::os::unix::process::CommandExt; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; use serde::{Deserialize, Serialize}; @@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) { spawn(&argv, path); } +/// Launch `argv`, detached from whatever is opening it. +/// +/// The child gets its own process group and no inherited stdio, because the +/// caller may be about to exit and take its terminal with it: `pdfs-prompt +/// --fzf` runs inside a terminal it spawned for itself, and that terminal +/// closes the moment the selection is made. A child left in the terminal's +/// foreground process group is sent `SIGHUP` when the pty hangs up, so +/// "open this folder in a terminal" would open a window that dies immediately +/// — which is exactly what it did. Nothing here needs the caller's stdio in the +/// first place: every `argv` is another application, and a terminal one is +/// wrapped in its own emulator by the `terminal` rule flag. fn spawn(argv: &[String], path: &Path) { let Some((program, args)) = argv.split_first() else { return; }; tracing::info!(path = %path.display(), command = ?argv, "opening"); - if let Err(e) = Command::new(program).args(args).spawn() { + let spawned = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn(); + if let Err(e) = spawned { tracing::error!(command = ?argv, "open failed: {e}"); } } diff --git a/crates/pdfs-core/src/takeout.rs b/crates/pdfs-core/src/takeout.rs index 9e21bdf..f7e0786 100644 --- a/crates/pdfs-core/src/takeout.rs +++ b/crates/pdfs-core/src/takeout.rs @@ -393,22 +393,27 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take .unwrap_or(&file_name) .to_string(); + // Sidecar first, then the date stamped into the name. + let capture_time = sidecar + .photo_taken_time + .as_ref() + .and_then(SidecarTime::epoch_seconds) + .or_else(|| { + sidecar + .creation_time + .as_ref() + .and_then(SidecarTime::epoch_seconds) + }) + .or_else(|| capture_time_from_name(&name)) + .or_else(|| capture_time_from_name(&file_name)); + scan.photos.push(TakeoutPhoto { archive: raw.archive, entry: raw.entry.clone(), name, size: raw.size, media_type: media_type.to_string(), - capture_time: sidecar - .photo_taken_time - .as_ref() - .and_then(SidecarTime::epoch_seconds) - .or_else(|| { - sidecar - .creation_time - .as_ref() - .and_then(SidecarTime::epoch_seconds) - }), + capture_time, favorite: sidecar.favorited, bucket, }); @@ -507,34 +512,218 @@ fn unswap_duplicate_marker(stem: &str) -> Option { Some(format!("{}{}{}", &head[..dot], marker, &head[dot..])) } +/// The capture time a *file name* implies, in epoch seconds, or `None` when it +/// carries no plausible date. +/// +/// The last resort when a photo has no sidecar. Camera and messenger apps stamp +/// the date into the name — `PXL_20260818_171030868.jpg`, +/// `IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`, +/// `IMG-20230219-WA0001.jpg`, `Screenshot from 2023-01-01 10-29-50.png` — and a +/// date that is merely *approximately* right still files the photo in the right +/// year, which "the moment it was imported" does not. +/// +/// The wall clock in a name has no zone attached, so it is read as UTC. That can +/// be a few hours out; it is never the years out that the import-time fallback +/// is. A sidecar always wins over this. +pub fn capture_time_from_name(name: &str) -> Option { + let bytes = name.as_bytes(); + let digit = |i: usize| bytes.get(i).is_some_and(u8::is_ascii_digit); + let num = + |start: usize, len: usize| -> Option { name.get(start..start + len)?.parse().ok() }; + + for start in 0..bytes.len() { + if !digit(start) || (start > 0 && digit(start - 1)) { + continue; + } + let run = |from: usize, len: usize| (0..len).all(|k| digit(from + k)); + // `YYYYMMDD` as one run of exactly eight digits, or `YYYY-MM-DD` with + // any single separator repeated between the parts. + let (year, month, day, after) = if run(start, 8) && !digit(start + 8) { + ( + num(start, 4)?, + num(start + 4, 2)?, + num(start + 6, 2)?, + start + 8, + ) + } else if run(start, 4) + && !digit(start + 4) + && bytes.get(start + 4) == bytes.get(start + 7) + && run(start + 5, 2) + && run(start + 8, 2) + && !digit(start + 10) + { + ( + num(start, 4)?, + num(start + 5, 2)?, + num(start + 8, 2)?, + start + 10, + ) + } else { + continue; + }; + if !(1990..=2100).contains(&year) || !(1..=12).contains(&month) || !(1..=31).contains(&day) + { + continue; + } + + // An optional time right behind the date, past one separator character. + let after = match bytes.get(after) { + Some(b'_' | b'-' | b' ' | b'.' | b',' | b'T' | b't' | b'@') => after + 1, + _ => after, + }; + let time = if run(after, 6) { + // Six digits, possibly with the milliseconds tail Pixel appends. + Some((num(after, 2)?, num(after + 2, 2)?, num(after + 4, 2)?)) + } else if run(after, 2) + && !digit(after + 2) + && bytes.get(after + 2) == bytes.get(after + 5) + && run(after + 3, 2) + && run(after + 6, 2) + { + Some((num(after, 2)?, num(after + 3, 2)?, num(after + 6, 2)?)) + } else { + None + }; + let (hour, minute, second) = match time { + Some((h, m, sec)) if h < 24 && m < 60 && sec < 60 => (h, m, sec), + _ => (0, 0, 0), + }; + + return Some( + days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second, + ); + } + None +} + +/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's +/// `days_from_civil`). Avoids pulling a date crate in for one conversion. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = year - i64::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +/// The inverse of [`unswap_duplicate_marker`]: `IMG_1234(1).jpg` as Google +/// writes its sidecar, `IMG_1234.jpg(1)`. `None` when the name carries no +/// duplicate marker. +fn swap_duplicate_marker(file_name: &str) -> Option { + let marker_start = file_name.rfind('(')?; + let marker_end = marker_start + file_name[marker_start..].find(')')? + 1; + let marker = &file_name[marker_start..marker_end]; + if !marker[1..marker.len() - 1] + .chars() + .all(|c| c.is_ascii_digit()) + { + return None; + } + let tail = &file_name[marker_end..]; + if !tail.starts_with('.') { + return None; + } + Some(format!("{}{}{}", &file_name[..marker_start], tail, marker)) +} + /// Find the sidecar belonging to `file_name` among the JSON files of its folder. /// -/// Tried in order of confidence: the exact name, the `(N)`-swapped name, then a -/// prefix match for the case where truncation cut into the photo name itself. +/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an +/// unknown trailing suffix, then a prefix match for the case where truncation +/// cut into the photo name itself. The best-ranked candidate in the folder wins. fn find_sidecar<'a>( file_name: &str, in_folder: &BTreeMap>, ) -> Option<&'a Vec> { - let mut prefix_match: Option<&'a Vec> = None; - let mut prefix_len = 0usize; - + let mut best: Option<((u8, usize), &'a Vec)> = None; for (sidecar_name, body) in in_folder { - let stem = strip_sidecar_suffix(sidecar_name); - if stem == file_name { - return Some(body); - } - if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) { - return Some(body); - } - // Truncated: the stem is a prefix of the photo's name. Keep the longest - // such prefix — a folder holding `IMG_1.jpg` and `IMG_12.jpg` must not - // let the shorter stem claim the longer photo. - if !stem.is_empty() && file_name.starts_with(&stem) && stem.len() > prefix_len { - prefix_len = stem.len(); - prefix_match = Some(body); + let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else { + continue; + }; + if best.as_ref().is_none_or(|(current, _)| rank < *current) { + best = Some((rank, body)); } } - prefix_match + best.map(|(_, body)| body) +} + +/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort +/// key where **lower is better**: the leading number is the rule that matched, +/// the second breaks ties within it. +fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> { + // 0 — the stem is the photo name once the (English) suffix is off. + let plain = strip_sidecar_suffix(stem); + if plain == file_name { + return Some((0, 0)); + } + // 1 — the same, for `IMG_1234.jpg(1).json` against `IMG_1234(1).jpg`. + if unswap_duplicate_marker(&plain).as_deref() == Some(file_name) { + return Some((1, 0)); + } + // 2 — the photo name is intact but a suffix we do not recognise follows it. + // Google *localizes* `.supplemental-metadata` (a German export writes + // `.ergänzende-Metadaten`) and has shipped misspelt variants of it, so the + // suffix cannot be enumerated — matching on the intact name in front of it + // is what stays locale-agnostic. The shortest leftover wins, so a folder + // holding both `IMG_1.jpg.` and `IMG_1.jpg(1).` gives each + // photo its own. + for candidate in [ + Some(file_name.to_string()), + swap_duplicate_marker(file_name), + ] + .into_iter() + .flatten() + { + if let Some(rest) = stem.strip_prefix(&candidate) + && looks_like_sidecar_suffix(rest) + { + return Some((2, rest.len())); + } + } + // 3 — truncation ate into the photo name itself, leaving only a prefix of + // it. Every suffix boundary in the stem is tried, so this holds for a + // localized suffix too; the longest surviving prefix wins, because a folder + // with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the + // longer photo. + let mut longest: Option = None; + for head in suffix_boundaries(stem) { + if !head.is_empty() + && file_name.starts_with(head) + && longest.is_none_or(|len| head.len() > len) + { + longest = Some(head.len()); + } + } + longest.map(|len| (3, usize::MAX - len)) +} + +/// The stem plus each of its prefixes ending before a `.`, longest first — the +/// possible boundaries between a photo name and a sidecar suffix. +fn suffix_boundaries(stem: &str) -> impl Iterator { + std::iter::once(stem).chain( + stem.char_indices() + .filter(|(_, c)| *c == '.') + .map(|(index, _)| &stem[..index]) + .collect::>() + .into_iter() + .rev(), + ) +} + +/// Whether `rest` — what follows an intact photo name in a sidecar's stem — +/// looks like a metadata suffix rather than the tail of a different file's name. +/// Google's suffixes all start at a `.`, or at the `(N)` duplicate marker. +fn looks_like_sidecar_suffix(rest: &str) -> bool { + rest.starts_with('.') || rest.starts_with('(') +} + +/// Drop a trailing `.json`, case-insensitively. +fn strip_json_suffix(sidecar: &str) -> &str { + match sidecar.len().checked_sub(5) { + Some(cut) if sidecar[cut..].eq_ignore_ascii_case(".json") => &sidecar[..cut], + _ => sidecar, + } } #[cfg(test)] @@ -663,6 +852,106 @@ mod tests { assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000)); } + #[test] + fn a_localized_supplemental_suffix_still_matches() { + // A German export writes `.ergänzende-Metadaten` where an English one + // writes `.supplemental-metadata`; the photo name in front of it is + // what the match hangs on. + let mut sidecars = HashMap::new(); + sidecar( + &mut sidecars, + "Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg.ergänzende-Metadaten.json", + r#"{"photoTakenTime":{"timestamp":"1676800000"}}"#, + ); + let scan = assemble( + vec![entry( + 0, + "Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg", + 10, + )], + &sidecars, + ); + assert_eq!(scan.photos[0].capture_time, Some(1_676_800_000)); + } + + #[test] + fn a_localized_suffix_does_not_let_one_sidecar_claim_a_sibling() { + let mut sidecars = HashMap::new(); + for name in ["IMG_1.jpg", "IMG_1(1).jpg"] { + sidecar( + &mut sidecars, + &format!("Takeout/Google Fotos/Album/{name}.ergänzende-Metadaten.json"), + r#"{"photoTakenTime":{"timestamp":"1"}}"#, + ); + } + // Google writes the duplicate's sidecar with the marker after the + // extension, so this is the spelling that actually ships. + sidecars.remove("Album/IMG_1(1).jpg.ergänzende-Metadaten.json"); + sidecar( + &mut sidecars, + "Takeout/Google Fotos/Album/IMG_1.jpg(1).ergänzende-Metadaten.json", + r#"{"photoTakenTime":{"timestamp":"2"}}"#, + ); + let scan = assemble( + vec![ + entry(0, "Takeout/Google Fotos/Album/IMG_1.jpg", 10), + entry(0, "Takeout/Google Fotos/Album/IMG_1(1).jpg", 10), + ], + &sidecars, + ); + assert_eq!(scan.photos[0].capture_time, Some(1)); + assert_eq!(scan.photos[1].capture_time, Some(2)); + } + + #[test] + fn the_file_name_date_is_the_fallback_when_no_sidecar_matches() { + let scan = assemble( + vec![entry( + 0, + "Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg", + 10, + )], + &HashMap::new(), + ); + // 2023-02-19 00:00:00 UTC. + assert_eq!(scan.photos[0].capture_time, Some(1_676_764_800)); + } + + #[test] + fn file_name_dates_cover_the_common_camera_and_messenger_spellings() { + // 2026-08-18 17:10:30 UTC, with Pixel's milliseconds tail. + assert_eq!( + capture_time_from_name("PXL_20260818_171030868.RAW-01.COVER.jpg"), + Some(1_787_073_030) + ); + assert_eq!( + capture_time_from_name("IMG_20260818_171030.jpg"), + Some(1_787_073_030) + ); + assert_eq!( + capture_time_from_name("Screenshot_20260818-171030.png"), + Some(1_787_073_030) + ); + assert_eq!( + capture_time_from_name("Screenshot from 2026-08-18 17-10-30.png"), + Some(1_787_073_030) + ); + // A date with no usable time lands at midnight, not at import time. + assert_eq!( + capture_time_from_name("IMG-20260818-WA0001.jpg"), + Some(1_787_011_200) + ); + } + + #[test] + fn a_name_without_a_plausible_date_yields_nothing() { + assert_eq!(capture_time_from_name("100_1234.JPG"), None); + assert_eq!(capture_time_from_name("DSC00042.jpg"), None); + // A nine-digit run is not a date; neither is an out-of-range month. + assert_eq!(capture_time_from_name("IMG_202608181.jpg"), None); + assert_eq!(capture_time_from_name("IMG_20261818.jpg"), None); + } + #[test] fn truncated_supplemental_suffixes_still_match() { // Google caps the sidecar name at 51 chars, cutting into the suffix. diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index c5557f3..5ec8f1a 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -411,6 +411,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U CtlResponse::error(CoreError::invalid("no import is running")) } } + // The scan reads the whole persisted timeline and every candidate is a + // download plus an upload, so this goes to a background thread and is + // read back through `RedateStatus`. + Ok(CtlRequest::RedatePhotos { + dry_run, + limit, + range, + }) => match core.spawn_redate(dry_run, limit, range) { + Ok(()) => CtlResponse::Ok { + message: if dry_run { + "checking photo dates".to_string() + } else { + "fixing photo dates".to_string() + }, + }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::RedateStatus) => { + let (running, summary) = crate::redate::redate_status(); + CtlResponse::RedateStatus { running, summary } + } + Ok(CtlRequest::CancelRedate) => { + if crate::redate::cancel_redate() { + CtlResponse::Ok { + message: "stopping the re-date".to_string(), + } + } else { + CtlResponse::error(CoreError::invalid("no photo re-date is running")) + } + } Ok(CtlRequest::UploadPhoto { name, media_type, diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 822ecfb..6d1eda6 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -90,6 +90,7 @@ mod mount; mod photos; mod profile; mod reads; +mod redate; mod revisions; mod sharing; mod shutdown; @@ -4856,7 +4857,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo { /// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends /// receive uids as strings over the control socket and pass them back verbatim. -fn parse_uid(s: &str) -> Option { +pub(crate) fn parse_uid(s: &str) -> Option { let (vol, link) = s.split_once('~')?; if vol.is_empty() || link.is_empty() || link.contains('~') { return None; diff --git a/crates/pdfs-fuse/src/redate.rs b/crates/pdfs-fuse/src/redate.rs new file mode 100644 index 0000000..20d4ff6 --- /dev/null +++ b/crates/pdfs-fuse/src/redate.rs @@ -0,0 +1,327 @@ +//! Repairing photos whose capture time is when they were *imported* rather than +//! when they were taken. +//! +//! A Google Photos Takeout whose metadata sidecars did not match — a localized +//! `.supplemental-metadata` spelling, say — imports with no capture time at all, +//! and the SDK then seals "now" into every revision. A decade of photos lands on +//! one day. +//! +//! Proton has no API for editing a sealed capture time, so the repair is a +//! rewrite: download the photo, upload it again with the capture time its file +//! name implies, put the new copy back into the albums and favourites the old +//! one was in, then **trash** (not delete) the original. Trash rather than +//! delete so a bad run is recoverable from Proton's own trash for 30 days. +//! +//! A photo is a candidate only when its name carries a date that disagrees with +//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow: +//! a photo whose name says nothing about its date is left alone, because there +//! is nothing better to set it to. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use pdfs_core::control::{RedateSummary, TransferDirection}; +use pdfs_core::takeout::{capture_time_from_name, media_type_of}; +use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::proton_sdk::ids::NodeUid; +use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata}; +use tracing::{info, warn}; + +use super::Core; +use crate::parse_uid as parse_node_uid; +use crate::transfers::CountingReader; + +/// How far a stored capture time may sit from the one the file name implies +/// before the photo counts as mis-dated. A day, because a name usually carries +/// only the date and the stored time carries a clock — and because a timezone +/// can legitimately move a photo by hours. +const DRIFT: i64 = 86_400; + +/// How many examples a report carries. +const SAMPLES: usize = 10; + +/// The one re-date this daemon may be running, and how the last one ended. +/// Process-global for the same reasons as the Takeout import's state: one run +/// per daemon however many `Core` clones exist, and a front-end asking after it +/// finished must still find the report. +static REDATE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default())); + +#[derive(Default)] +struct RedateState { + running: bool, + cancel: Option>, + last: Option, +} + +/// Whether a re-date is running, and the last one's report. +pub(crate) fn redate_status() -> (bool, Option) { + let state = REDATE.lock(); + (state.running, state.last.clone()) +} + +/// Ask the running re-date to stop. False when none is running. +pub(crate) fn cancel_redate() -> bool { + match &REDATE.lock().cancel { + Some(flag) => { + flag.store(true, Ordering::Relaxed); + true + } + None => false, + } +} + +/// One photo to repair. +struct Candidate { + uid: NodeUid, + name: String, + /// The capture time it carries now. + stored: i64, + /// The capture time its name implies. + wanted: i64, + favorite: bool, + /// Album node uids the original is filed under. + albums: Vec, +} + +impl Core { + /// Re-date mis-dated photos. Blocking; run on its own thread. + /// + /// `cancel` stops the run at the next photo boundary. A photo is only ever + /// trashed after its replacement has uploaded, so a cancel — or a crash — + /// can leave a duplicate, never a hole. + pub(crate) fn redate_photos( + &self, + dry_run: bool, + limit: Option, + range: Option<(i64, i64)>, + cancel: Arc, + ) -> CoreResult { + let job = self.transfers.begin_job("Fixing photo dates"); + job.detail("looking for mis-dated photos"); + + let stored = self + .db + .photos_all() + .map_err(|e| CoreError::internal(format!("read timeline: {e}")))?; + let mut summary = RedateSummary { + examined: stored.len(), + dry_run, + ..Default::default() + }; + + let mut candidates = Vec::new(); + for photo in stored { + if let Some((from, to)) = range + && !(from..to).contains(&photo.capture_time) + { + continue; + } + let Some(name) = photo.name.filter(|n| !n.is_empty()) else { + continue; + }; + let Some(wanted) = capture_time_from_name(&name) else { + continue; + }; + if (wanted - photo.capture_time).abs() <= DRIFT { + continue; + } + let Some(uid) = parse_node_uid(&photo.uid) else { + continue; + }; + candidates.push(Candidate { + uid, + name, + stored: photo.capture_time, + wanted, + favorite: photo.favorite, + albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(), + }); + } + if let Some(limit) = limit { + candidates.truncate(limit); + } + summary.candidates = candidates.len(); + // Sampled after the limit, so the examples are of photos the run would + // actually touch. + summary.samples = candidates + .iter() + .take(SAMPLES) + .map(|c| (c.name.clone(), c.stored, c.wanted)) + .collect(); + job.set_total(candidates.len() as u64); + info!( + examined = summary.examined, + candidates = summary.candidates, + dry_run, + "photo re-date scan complete" + ); + + if dry_run || candidates.is_empty() { + return Ok(summary); + } + + for candidate in candidates { + if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() { + break; + } + job.detail(candidate.name.clone()); + if let Err(e) = self.redate_one(&candidate, &mut summary) { + summary.failed += 1; + warn!(name = candidate.name, error = %e, "cannot re-date photo"); + } + job.step(); + } + + self.spawn_timeline_refresh(); + summary.cancelled = cancel.load(Ordering::Relaxed); + Ok(summary) + } + + /// Rewrite one photo at the capture time its name implies. + /// + /// Order is load-bearing: upload first, file the new copy, and only then + /// trash the original. Every earlier step failing leaves the account exactly + /// as it was. + fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> { + let photos = self.photos(); + let path = self.open_photo(&candidate.uid)?; + let size = std::fs::metadata(&path) + .map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))? + .len(); + let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream"); + + let guard = + self.transfers + .begin(candidate.name.clone(), "", TransferDirection::Upload, size); + let file = std::fs::File::open(&path) + .map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?; + let reader = CountingReader::new(std::io::BufReader::new(file), &guard); + let metadata = PhotoUploadMetadata { + capture_time: Some(candidate.wanted), + ..Default::default() + }; + let uploaded = self.rt.block_on(photos.upload_photo_from( + &candidate.name, + media_type, + reader, + size as i64, + Vec::new(), + metadata, + false, + )); + drop(guard); + let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?; + + // Favourite and albums before the trash: a failure here leaves both + // copies on the account, which is recoverable by hand. Losing the + // original first would not be. + if candidate.favorite { + let update = PhotoTagsUpdate { + node_uid: new_uid.clone(), + tags_to_add: vec![PhotoTag::Favorite], + tags_to_remove: Vec::new(), + }; + if let Err(e) = self.rt.block_on(photos.update_photos(&[update])) { + warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo"); + } + } + for album in &candidate.albums { + let Some(album_uid) = parse_node_uid(album) else { + continue; + }; + match self + .rt + .block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid))) + { + Ok(_) => summary.album_links += 1, + Err(e) => { + warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo") + } + } + } + + let outcomes = self + .rt + .block_on( + self.client + .trash_nodes(std::slice::from_ref(&candidate.uid)), + ) + .map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?; + if let Some((_, Err(e))) = outcomes.into_iter().next() { + return Err(CoreError::from_api(&e, "trash the mis-dated original")); + } + + summary.redated += 1; + summary.bytes += size; + info!( + name = candidate.name, + from = candidate.stored, + to = candidate.wanted, + "photo re-dated" + ); + Ok(()) + } + + /// Start a re-date on its own thread. Errors when one is already running. + pub(crate) fn spawn_redate( + &self, + dry_run: bool, + limit: Option, + range: Option<(i64, i64)>, + ) -> CoreResult<()> { + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut state = REDATE.lock(); + if state.running { + return Err(CoreError::conflict( + "a photo re-date is already running; wait for it to finish or cancel it", + )); + } + state.running = true; + state.cancel = Some(cancel.clone()); + state.last = None; + } + + let core = self.clone(); + std::thread::spawn(move || { + let outcome = core.redate_photos(dry_run, limit, range, cancel); + let mut state = REDATE.lock(); + state.running = false; + state.cancel = None; + state.last = outcome.as_ref().ok().cloned(); + match &outcome { + Ok(summary) => info!( + candidates = summary.candidates, + redated = summary.redated, + failed = summary.failed, + "photo re-date finished" + ), + Err(e) => warn!(error = %e, "photo re-date failed"), + } + }); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_uid_without_both_halves_is_not_a_node() { + assert!(parse_node_uid("vol~link").is_some()); + assert!(parse_node_uid("vol~").is_none()); + assert!(parse_node_uid("~link").is_none()); + assert!(parse_node_uid("nothing").is_none()); + } + + #[test] + fn only_a_disagreement_larger_than_a_day_counts() { + // The name says 2023-02-19; a stored time inside that day is fine. + let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap(); + assert!((wanted - (wanted + 3_600)).abs() <= DRIFT); + // An import three years later is not. + assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT); + } +} diff --git a/crates/pdfs-gui/Cargo.toml b/crates/pdfs-gui/Cargo.toml index 8893e4d..756b659 100644 --- a/crates/pdfs-gui/Cargo.toml +++ b/crates/pdfs-gui/Cargo.toml @@ -27,6 +27,7 @@ tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true tokio.workspace = true +serde.workspace = true serde_json.workspace = true ksni = "0.2" gtk4 = { version = "0.9", features = ["v4_12"] } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 9a1e7dc..9c4c604 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -24,7 +24,7 @@ use widgets::versions_dialog::*; use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; @@ -484,7 +484,8 @@ fn build_window(app: &adw::Application) { decode_idle: Cell::new(false), thumb_source: RefCell::new(None), relayout_source: RefCell::new(None), - bound: RefCell::new(HashMap::new()), + bound: RefCell::new(BTreeMap::new()), + list: gallery_widgets.list.clone(), }, shared: SharedState { content: shared_widgets.content.clone(), diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 7824f30..67727cc 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -5,8 +5,12 @@ pub(crate) struct GalleryState { /// Every photo loaded so far, newest first — the order the lightbox's /// prev/next walks. The visible day sections are derived from this. pub(crate) model: gio::ListStore, - /// The day sections rendered by the Photos ListView, rebuilt from - /// [`Self::model`] by [`repaint_gallery`]. + /// The Photos ListView's own model: one item per *rendered row* — a day + /// heading, or one row of that day's tile grid — rebuilt from + /// [`Self::model`] by [`repaint_gallery`]. Rows rather than whole days + /// because the ListView only realises what is on screen, and a day holding + /// 1,600 photos (a Takeout import lands one) would otherwise be 1,600 + /// widgets built in a single bind. pub(crate) groups: gio::ListStore, /// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see /// [`zoom_gallery`]). The grid fits as many square tiles of about this size @@ -110,11 +114,14 @@ pub(crate) struct GalleryState { /// re-flow, replaced on each new trigger so only the last one fires. pub(crate) thumb_source: RefCell>, pub(crate) relayout_source: RefCell>, - /// The day sections currently realised by the ListView, by their index in - /// [`Self::groups`]. A resize or a zoom step re-flows these in place — - /// rebuilding the ListStore instead would reset the scroll position out from - /// under the user. - pub(crate) bound: RefCell>, + /// The rows currently realised by the ListView, as row index -> the uid of + /// its first photo (absent for a heading row). A resize or a zoom step + /// changes how many tiles fit per row, so the row model has to be rebuilt — + /// this is what lets the rebuild put the user back where they were. + pub(crate) bound: RefCell>>, + /// The ListView itself, so a rebuild can scroll back to the row the user was + /// looking at. + pub(crate) list: gtk4::ListView, } /// How many photos to pull per [`Request::PhotosTimeline`] page. @@ -158,13 +165,71 @@ pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80); /// One day-section of the photos timeline: a heading plus the photos captured /// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by -/// [`group_photos`] and rendered as one [`gtk4::ListView`] row. +/// [`group_photos`], then flattened into [`GalleryRow`]s for rendering. pub(crate) struct PhotoGroup { /// "Today", "Yesterday", or e.g. "3 June 2026". pub(crate) heading: String, pub(crate) photos: Vec, } +/// One item of the ListView's model — the unit the gallery virtualises at. +/// +/// A whole day is *not* one item: a Google Photos Takeout drops thousands of +/// photos onto a single date, and one ListView item per day means GTK builds +/// every one of those tiles in one bind, on the main thread, before the row can +/// be shown. Splitting the day into its grid rows keeps a bind to a handful of +/// widgets no matter how big the day is. +pub(crate) enum GalleryRow { + /// A day heading: "Today", "3 June 2026". + Heading(String), + /// One row of a day's grid, already laid out to the current width and zoom. + Tiles { + photos: Vec, + edge: i32, + /// True for the last row of its day, which carries the section's bottom + /// margin so days stay visually separated. + last: bool, + }, +} + +impl GalleryRow { + /// The uid of the row's first photo, or `None` for a heading — the anchor a + /// relayout scrolls back to. + fn anchor(&self) -> Option { + match self { + GalleryRow::Heading(_) => None, + GalleryRow::Tiles { photos, .. } => photos.first().map(|p| p.uid.clone()), + } + } + + /// Whether re-rendering `self` would produce exactly what `other` shows — + /// the test [`repaint_gallery`] diffs on, so appending a page only touches + /// the rows that actually changed. + fn same_as(&self, other: &GalleryRow) -> bool { + match (self, other) { + (GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b, + ( + GalleryRow::Tiles { + photos: a, + edge: ae, + last: al, + }, + GalleryRow::Tiles { + photos: b, + edge: be, + last: bl, + }, + ) => { + ae == be + && al == bl + && a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| x.uid == y.uid) + } + _ => false, + } + } +} + /// The widgets [`build_gallery_page`] hands back to [`build_window`]. pub(crate) struct GalleryWidgets { /// Flat, newest-first list of every loaded photo. Backs the lightbox's @@ -611,40 +676,64 @@ pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::Sc let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); - let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - section.set_margin_bottom(16); - let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - heading.add_css_class("heading"); - heading.add_css_class("gallery-day"); - section.append(&heading); - item.set_child(Some(§ion)); + // One container for both row kinds: the bind fills it with either a + // heading label or a strip of tiles. The alternative — two factories, or + // a Stack per row — buys nothing, since a row's children are rebuilt on + // bind either way. + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP); + row.set_halign(gtk4::Align::Start); + item.set_child(Some(&row)); item.set_activatable(false); }); let ui_bind = ui.clone(); factory.connect_bind(move |_, item| { let item = item.downcast_ref::().unwrap(); - let section = item.child().and_downcast::().unwrap(); + let row_box = item.child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); - let group = obj.borrow::(); + let row = obj.borrow::(); - let heading = section.first_child().and_downcast::().unwrap(); - heading.set_label(&group.heading); - - fill_section(&ui_bind, §ion, &group.photos); - // Remember the realised section so a learned aspect ratio or a resize can - // re-justify it in place, without rebuilding the ListStore (which would - // yank the scroll position back to the top). + while let Some(child) = row_box.first_child() { + row_box.remove(&child); + } + match &*row { + GalleryRow::Heading(heading) => { + row_box.set_margin_top(16); + row_box.set_margin_bottom(8); + let label = gtk4::Label::builder() + .label(heading) + .halign(gtk4::Align::Start) + .build(); + label.add_css_class("heading"); + label.add_css_class("gallery-day"); + row_box.append(&label); + } + GalleryRow::Tiles { photos, edge, last } => { + row_box.set_margin_top(0); + // The last row of a day carries the gap to the next heading. + row_box.set_margin_bottom(if *last { 8 } else { TILE_GAP }); + for photo in photos { + row_box.append(&photo_tile( + &ui_bind, + Tile { + photo: photo.clone(), + edge: *edge, + }, + )); + } + schedule_thumbs(&ui_bind); + } + } ui_bind .gallery .bound .borrow_mut() - .insert(item.position(), section); + .insert(item.position(), row.anchor()); }); - // ListView recycles section widgets, so a scrolled-away day must give up its - // claim on the widgets — otherwise a thumbnail landing late would paint into - // a tile that now shows a different day. + // ListView recycles row widgets, so a scrolled-away row must give up its + // claim on them — otherwise a thumbnail landing late would paint into a tile + // that now shows a different photo. let ui_unbind = ui.clone(); factory.connect_unbind(move |_, item| { let item = item.downcast_ref::().unwrap(); @@ -653,10 +742,11 @@ pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::Sc .bound .borrow_mut() .remove(&item.position()); - if let Some(obj) = item.item().and_downcast::() { - let group = obj.borrow::(); + if let Some(obj) = item.item().and_downcast::() + && let GalleryRow::Tiles { photos, .. } = &*obj.borrow::() + { let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut(); - for photo in &group.photos { + for photo in photos { wanted.remove(&photo.uid); } } @@ -917,34 +1007,6 @@ pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) { (columns, edge) } -/// (Re)build a bound day-section's tiles: lay this day's photos out to the -/// current content width and hand each tile whatever thumbnail is already in -/// memory, queueing the rest. Replaces the section's rows in place, leaving the -/// heading — so a re-flow never touches the ListView's model or scroll. -pub(crate) fn fill_section(ui: &Rc, section: >k4::Box, photos: &[PhotoItem]) { - let Some(heading) = section.first_child() else { - return; - }; - while let Some(old) = heading.next_sibling() { - section.remove(&old); - } - - let width = gallery_width(ui); - let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP); - for row in grid_rows(ui, photos, width) { - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP); - // A short last row stays left-aligned at the same tile size rather than - // stretching: an even grid is the whole point of the square layout. - row_box.set_halign(gtk4::Align::Start); - for tile in row { - row_box.append(&photo_tile(ui, tile)); - } - rows.append(&row_box); - } - section.append(&rows); - schedule_thumbs(ui); -} - /// The width the grid is laid out to: the ListView's own width, less a couple of /// px so a rounding error can't push a row into a horizontal overflow. /// Falls back to a sane guess before the first allocation. @@ -1238,27 +1300,35 @@ pub(crate) fn schedule_relayout(ui: &Rc) { *ui.gallery.relayout_source.borrow_mut() = Some(source); } -/// Rebuild the tiles of the day sections currently on screen, at the current -/// width and zoom. Sections that are *not* realised need no work: they lay -/// themselves out against the current state when the ListView binds them. +/// Re-flow the timeline at the current width and zoom. +/// +/// A different column count means different rows, so unlike the old +/// section-per-day model there is nothing to patch in place — the row model is +/// rebuilt. What that would cost the user is their scroll position, so the +/// topmost realised row's first photo is remembered and scrolled back to. pub(crate) fn relayout_gallery(ui: &Rc) { - let bound: Vec<(u32, gtk4::Box)> = ui - .gallery - .bound - .borrow() - .iter() - .map(|(pos, section)| (*pos, section.clone())) - .collect(); - for (pos, section) in bound { - let Some(obj) = ui.gallery.groups.item(pos) else { - continue; - }; - let Some(boxed) = obj.downcast_ref::() else { - continue; - }; - let photos = boxed.borrow::().photos.clone(); - fill_section(ui, §ion, &photos); - } + let anchor: Option = ui.gallery.bound.borrow().values().flatten().next().cloned(); + repaint_gallery(ui); + let Some(anchor) = anchor else { return }; + let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else { + return; + }; + ui.gallery + .list + .scroll_to(row, gtk4::ListScrollFlags::empty(), None); +} + +/// Which row of the rendered model holds `uid`, if any. +fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option { + (0..store.n_items()).find(|i| { + store + .item(*i) + .and_downcast::() + .is_some_and(|obj| match &*obj.borrow::() { + GalleryRow::Heading(_) => false, + GalleryRow::Tiles { photos, .. } => photos.iter().any(|p| p.uid == uid), + }) + }) } /// Step the tile size by `delta` px and re-flow, clamped to the zoom range. @@ -1276,46 +1346,75 @@ pub(crate) fn set_gallery_tile(ui: &Rc, tile: i32) { schedule_relayout(ui); } -/// Rebuild the day sections from the flat photo model. The timeline arrives -/// newest-first, so photos of the same day are already contiguous — one pass -/// splits them. +/// Rebuild the ListView's row model from the flat photo model: each day becomes +/// a heading row followed by its grid rows, laid out to the current width and +/// zoom. /// -/// The groups are diffed into the existing store rather than replacing it: a -/// "load more" only really changes the last day (the one the new page continues) -/// and appends after it, and clearing the store instead would scroll the user -/// back to the top of the timeline at the exact moment they asked for more. +/// The rows are diffed into the existing store rather than replacing it: a "load +/// more" only really changes the last day (the one the new page continues) and +/// appends after it, and clearing the store instead would scroll the user back +/// to the top of the timeline at the exact moment they asked for more. pub(crate) fn repaint_gallery(ui: &Rc) { - let groups = group_photos(&ui.gallery.model); + let rows = build_rows(ui); let store = &ui.gallery.groups; - for (i, group) in groups.iter().enumerate() { + for (i, row) in rows.iter().enumerate() { let i = i as u32; let unchanged = store .item(i) .and_downcast::() - .is_some_and(|old| { - let old = old.borrow::(); - old.heading == group.heading && old.photos.len() == group.photos.len() - }); + .is_some_and(|old| row.same_as(&old.borrow::())); if unchanged { continue; } - let boxed = BoxedAnyObject::new(PhotoGroup { - heading: group.heading.clone(), - photos: group.photos.clone(), - }); + let boxed = BoxedAnyObject::new(clone_row(row)); if i < store.n_items() { store.splice(i, 1, &[boxed]); } else { store.append(&boxed); } } - // Photos only ever get appended, so a shorter model means a fresh load. - if store.n_items() > groups.len() as u32 { - let len = groups.len() as u32; + if store.n_items() > rows.len() as u32 { + let len = rows.len() as u32; store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]); } + update_gallery_subtitle(ui); +} + +/// Flatten the loaded photos into the rows the ListView renders. +fn build_rows(ui: &Rc) -> Vec { + let width = gallery_width(ui); + let mut rows = Vec::new(); + for group in group_photos(&ui.gallery.model) { + rows.push(GalleryRow::Heading(group.heading)); + let grid = grid_rows(ui, &group.photos, width); + let last_index = grid.len().saturating_sub(1); + for (index, tiles) in grid.into_iter().enumerate() { + let edge = tiles.first().map(|t| t.edge).unwrap_or(TILE_DEFAULT); + rows.push(GalleryRow::Tiles { + photos: tiles.into_iter().map(|t| t.photo).collect(), + edge, + last: index == last_index, + }); + } + } + rows +} + +fn clone_row(row: &GalleryRow) -> GalleryRow { + match row { + GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()), + GalleryRow::Tiles { photos, edge, last } => GalleryRow::Tiles { + photos: photos.clone(), + edge: *edge, + last: *last, + }, + } +} + +/// "1,204 photos" under the page title. +fn update_gallery_subtitle(ui: &Rc) { let loaded = ui.gallery.model.n_items() as usize; ui.gallery.subtitle.set_visible(loaded > 0); // An album counts what the server says it holds, not how much of it has been diff --git a/crates/pdfs-gui/src/dmenu.rs b/crates/pdfs-gui/src/dmenu.rs index d5df609..f7f17e2 100644 --- a/crates/pdfs-gui/src/dmenu.rs +++ b/crates/pdfs-gui/src/dmenu.rs @@ -1,26 +1,23 @@ //! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher. //! //! Everything here is blocking and GTK-free — the process exists for one -//! round-trip and then exits, so there is no main loop to keep off. It reuses -//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive -//! activation policy, so the two front ends cannot drift on what "open" means. +//! round-trip and then exits, so there is no main loop to keep off. Talking to +//! the daemon and opening a chosen file are [`crate::query`]'s, shared with the +//! fzf front end and built on the GTK prompt's own [`Hit`](crate::Hit) model +//! and activation policy; this module is only presentation. //! //! Launchers filter a static list; they cannot call back for a new one per //! keystroke. So the interaction is a loop: the first menu shows pinned files //! and accepts free text, and any text that matches no entry becomes the next -//! search. Escape (an empty selection) ends it. - -use std::path::{Path, PathBuf}; +//! search. Escape (an empty selection) ends it. `--fzf` ([`crate::fzf`]) is the +//! front end for users who want results to appear while they type instead. use pdfs_core::config::AppDirs; -use pdfs_core::control::{ - Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send, -}; use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig}; -use pdfs_core::opener::{self, OpenWith}; +use pdfs_core::opener::OpenWith; -use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; -use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits}; +use crate::Hit; +use crate::query; /// Guard against a launcher that keeps handing back text we cannot satisfy — /// without it a scripted (non-interactive) menu could spin forever. @@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> { .to_string() })?; - let mountpoint = match request(&socket, Request::Status)? { - Response::Status { mountpoint, .. } => PathBuf::from(mountpoint), - _ => dirs.default_mountpoint(), - }; + let mountpoint = query::mountpoint(&socket, &dirs); let mut query = options .query @@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> { .filter(|q| !q.is_empty()); for _ in 0..MAX_ROUNDS { let hits = match &query { - Some(text) => search(&socket, text, prompt.resolved_menu_limit())?, - None => pins(&socket)?, + Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?, + None => query::pins(&socket)?, }; let items: Vec = if hits.is_empty() { vec![MenuItem::new(placeholder(query.as_deref()), None)] @@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> { query = None; continue; }; - return open(&socket, &mountpoint, &policy, hit, query.is_none()); + return query::open(&socket, &mountpoint, &policy, hit, query.is_none()); } // No entry matched what was typed: treat it as the next query. MenuChoice::Custom(text) => query = Some(text), @@ -118,57 +112,6 @@ fn placeholder(query: Option<&str>) -> String { } } -/// Pinned files — the same "you asked to keep these" set the GTK prompt opens -/// with, and the only listing the daemon offers without a query. -fn pins(socket: &Path) -> Result, String> { - let Response::Pins { pins } = request(socket, Request::ListPins)? else { - return Ok(Vec::new()); - }; - Ok(pins - .into_iter() - .map(|pin| { - Hit::Drive(SearchHit { - name: file_name(&pin.path), - path: pin.path, - is_dir: pin.is_dir.unwrap_or(pin.recursive), - size: 0, - modified: 0, - pinned: true, - uid: pin.uid, - mounted_path: None, - score: 0, - }) - }) - .collect()) -} - -fn search(socket: &Path, query: &str, limit: usize) -> Result, String> { - let reply = request( - socket, - Request::SearchV2 { - query: query.to_string(), - limit, - filters: SearchFilters { - sources: vec![SearchSource::Drive, SearchSource::Local], - kind: SearchKind::All, - }, - }, - )?; - let Response::SearchResultsV2 { - drive_hits, - local_hits, - .. - } = reply - else { - return Ok(Vec::new()); - }; - let mut hits: Vec = drive_hits.into_iter().map(Hit::Drive).collect(); - hits.extend(local_hits.into_iter().map(Hit::Local)); - rank_hits(&mut hits); - hits.truncate(limit); - Ok(hits) -} - /// One launcher line per hit. Labels must be unique: they are how the selection /// is matched back, and two files of the same name in the same folder listing /// would otherwise be indistinguishable. @@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec { label = format!("{} · {} ({suffix})", hit.name(), hit.location()); suffix += 1; } - items.push(MenuItem::new(label, Some(icon_name(hit)))); + items.push(MenuItem::new(label, Some(query::icon_name(hit)))); } items } -/// Icon theme name for a hit. -/// -/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but -/// only with file *contents* to sniff — a Drive hit is metadata, and passing it -/// an empty buffer answers `application-x-zerosize` for everything. The generic -/// names below exist in every icon theme. -fn icon_name(hit: &Hit) -> String { - if hit.is_dir() { - return "folder".to_string(); - } - let name = hit.name(); - if is_image(name) { - "image-x-generic" - } else if is_media(name) { - "video-x-generic" - } else if is_document(name) { - "text-x-generic" - } else { - "application-x-generic" - } - .to_string() -} - -/// Open a chosen hit, mirroring the GTK prompt: local files are already on -/// disk, a Drive folder or streamable file goes through the mount, and anything -/// else is materialised by the daemon first. -fn open( - socket: &Path, - mountpoint: &Path, - policy: &OpenWith, - hit: &Hit, - from_pins: bool, -) -> Result<(), String> { - match hit { - Hit::Local(local) => { - opener::open(policy, Path::new(&local.path), local.is_dir); - Ok(()) - } - Hit::Drive(drive) => { - // A folder has no materialised form, and a pin row's `is_dir` is pin - // policy rather than node kind, so both go to the mount unchecked. - let unconditional = from_pins - || matches!( - drive_activation(&drive.name, drive.is_dir), - DriveActivation::Folder | DriveActivation::MountedMedia - ); - if unconditional { - opener::open( - policy, - &mounted_or_relative(mountpoint, drive), - drive.is_dir, - ); - return Ok(()); - } - // Anything else the mount exposes opens there too, so an editor - // saves back into Drive instead of into a cache blob. - if let Some(path) = mounted_target(mountpoint, drive) { - opener::open(policy, &path, false); - return Ok(()); - } - match request( - socket, - Request::OpenFile { - path: drive.path.clone(), - uid: Some(drive.uid.clone()), - }, - )? { - Response::FilePath { path } => { - // The reply is a content-hash blob; the rules key off the - // Drive name, which is what the user actually opened. - opener::open_named(policy, Path::new(&path), &drive.name, false); - Ok(()) - } - Response::Error { message, .. } => Err(format!("could not open: {message}")), - _ => Err("unexpected reply from the daemon".to_string()), - } - } - } -} - -/// One control-socket round-trip, with the daemon-is-down case phrased for a -/// terminal rather than a status page. -fn request(socket: &Path, request: Request) -> Result { - send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}")) -} - #[cfg(test)] mod tests { use super::*; - use pdfs_core::control::LocalHit; + use pdfs_core::control::{LocalHit, SearchHit}; fn drive(name: &str, path: &str, is_dir: bool) -> Hit { Hit::Drive(SearchHit { @@ -331,18 +188,4 @@ mod tests { assert!(placeholder(None).contains("type to search")); assert!(placeholder(Some("md")).contains("md")); } - - #[test] - fn icons_are_theme_names_the_launcher_can_look_up() { - assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder"); - assert_eq!( - icon_name(&drive("a.png", "a.png", false)), - "image-x-generic" - ); - assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic"); - assert_eq!( - icon_name(&drive("a.bin", "a.bin", false)), - "application-x-generic" - ); - } } diff --git a/crates/pdfs-gui/src/fzf.rs b/crates/pdfs-gui/src/fzf.rs new file mode 100644 index 0000000..32edf9b --- /dev/null +++ b/crates/pdfs-gui/src/fzf.rs @@ -0,0 +1,372 @@ +//! `pdfs-prompt --fzf`: launcher search that queries the daemon per keystroke. +//! +//! A dmenu-protocol launcher (fuzzel, rofi, wofi, …) is handed one static list +//! and filters it itself; it cannot ask for a new one as the user types, which +//! is why [`crate::dmenu`] has to make the search a two-step. `fzf` can: +//! `--bind change:reload(…)` re-runs a command on every keystroke. So this +//! front end re-executes *this binary* in `--feed` mode, and the daemon's own +//! ranking ([`pdfs_core::control::Request::SearchV2`]) decides the order — +//! `--disabled` turns fzf's matcher off so it does not re-sort what came back. +//! +//! Three roles, all the same binary: +//! +//! * **outer** — `--fzf` with no tty (a WM keybinding): spawn a terminal that +//! runs the inner role, and exit. +//! * **inner** — `--fzf` with a tty: run fzf, open what was chosen. +//! * **feed** — `--feed `: print one line per hit and exit. fzf spawns +//! this per keystroke and kills the one it superseded. +//! +//! A line is `display\tpayload`, shown through `--with-nth=1` so only the left +//! half is visible. The payload is the JSON [`Hit`], which is what makes the +//! selection unambiguous: unlike the dmenu path, identity does not have to be +//! recovered from the label, so [`crate::dmenu::label_all`]'s uniquifying +//! suffix has no counterpart here. JSON escapes tabs and newlines, so no file +//! name can split a line. + +use std::io::{BufRead, BufReader, IsTerminal, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use pdfs_core::config::AppDirs; +use pdfs_core::menu::{self, PromptConfig}; +use pdfs_core::opener::OpenWith; + +use crate::query; +use crate::{Hit, SEARCH_DEBOUNCE}; + +/// Separates the visible half of a feed line from its payload. +const FIELD: char = '\t'; + +/// What the launcher prompt reads, matching the dmenu front end's wording. +const PROMPT: &str = "Search Drive › "; + +pub(crate) struct Options { + /// Search for this immediately instead of opening on the pinned files. + pub query: Option, + /// Set by the terminal we spawned: run fzf here even without a tty rather + /// than spawning a second terminal, which would recurse forever if the + /// terminal does not give its child one. + pub inner: bool, +} + +/// Run the fzf flow. Returns an error only for conditions the user must act on +/// (no fzf, no terminal, no daemon); a plain cancel is `Ok`. +pub(crate) fn run(options: Options) -> Result<(), String> { + if options.inner || std::io::stdin().is_terminal() { + return interactive(options.query); + } + spawn_terminal(options.query) +} + +/// Re-launch this binary's inner role inside a terminal. +/// +/// Not waited on: a keybinding should return immediately, and the terminal +/// owns the rest of the interaction — including opening the chosen file, which +/// `opener` detaches anyway. +fn spawn_terminal(query: Option) -> Result<(), String> { + let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?; + let prompt: PromptConfig = dirs.load_config().resolved_prompt(); + let terminal = menu::resolve_terminal(prompt.terminal.as_ref()).ok_or_else(|| { + "no terminal found — install foot/kitty/alacritty, or set prompt.terminal in config.json" + .to_string() + })?; + + let argv = menu::with_command(&terminal, &inner_command(self_exe()?, query.as_deref())); + let (program, args) = argv + .split_first() + .ok_or_else(|| "no terminal command configured".to_string())?; + Command::new(program) + .args(args) + .spawn() + .map_err(|e| format!("cannot run terminal {program}: {e}"))?; + Ok(()) +} + +/// The argv the spawned terminal runs. +fn inner_command(exe: PathBuf, query: Option<&str>) -> Vec { + let mut argv = vec![ + exe.display().to_string(), + "--fzf".to_string(), + "--inner".to_string(), + ]; + if let Some(text) = query.filter(|text| !text.trim().is_empty()) { + argv.push("--query".to_string()); + argv.push(text.to_string()); + } + argv +} + +/// Run fzf here and act on the choice. +fn interactive(query: Option) -> Result<(), String> { + if !menu::on_path("fzf") { + return Err("fzf is not installed — install it, or use --dmenu".to_string()); + } + let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?; + let config = dirs.load_config(); + let prompt: PromptConfig = config.resolved_prompt(); + let policy: OpenWith = config.resolved_open_with(); + let socket = dirs.control_socket(); + + let query = query + .map(|q| q.trim().to_string()) + .filter(|q| !q.is_empty()); + // fzf only fires `change` for a *keystroke*, so the first screen — pins, or + // the results for an initial --query — has to arrive on stdin. + let initial = hits(&socket, query.as_deref(), prompt.resolved_menu_limit()); + + let argv = fzf_argv(&self_exe()?, query.as_deref()); + let (program, args) = argv.split_first().expect("fzf_argv is never empty"); + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|e| format!("cannot run fzf: {e}"))?; + + // Written on a worker for the same reason the dmenu path does it: fzf reads + // stdin only once its UI is up, and a full pipe buffer would deadlock us. + let payload: String = initial.iter().map(encode_line).collect(); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "fzf stdin unavailable".to_string())?; + let writer = std::thread::spawn(move || { + let _ = stdin.write_all(payload.as_bytes()); + let _ = stdin.flush(); + }); + + let mut selected = String::new(); + if let Some(stdout) = child.stdout.take() { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + while reader.read_line(&mut line).unwrap_or(0) > 0 { + if selected.is_empty() { + selected = line.trim_end_matches('\n').to_string(); + } + line.clear(); + } + } + let _ = child.wait(); + let _ = writer.join(); + + // Escape, or Enter on the "cannot reach the daemon" row, which carries no + // payload — neither is an error. + let Some(hit) = decode_line(&selected) else { + return Ok(()); + }; + let mountpoint = query::mountpoint(&socket, &dirs); + // Only the no-query screen is the pin list, and only there does a row's + // `is_dir` mean pin policy rather than node kind. + query::open(&socket, &mountpoint, &policy, &hit, query.is_none()) +} + +/// `--feed`: one round-trip, printed for fzf to swallow. +/// +/// Always succeeds. A reload command that exits non-zero leaves fzf showing an +/// empty list with no explanation, so a failure becomes a visible row instead. +pub(crate) fn feed(query: Option<&str>) { + let Ok(dirs) = AppDirs::new() else { + println!("(cannot resolve app dirs)"); + return; + }; + let limit = dirs.load_config().resolved_prompt().resolved_menu_limit(); + let socket = dirs.control_socket(); + let query = query.map(str::trim).filter(|text| !text.is_empty()); + + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for line in hits(&socket, query, limit).iter().map(encode_line) { + let _ = out.write_all(line.as_bytes()); + } +} + +/// The rows for one screen: pins when there is nothing typed, search results +/// otherwise — the same split the GTK prompt makes on an empty entry. +fn hits(socket: &std::path::Path, query: Option<&str>, limit: usize) -> Vec { + let result = match query { + Some(text) => query::search(socket, text, limit), + None => query::pins(socket), + }; + match result { + Ok(hits) => hits, + Err(message) => { + // Printed as a row rather than to stderr: fzf's alternate screen + // hides stderr, so this is the only place the user would see it. + println!("({message})"); + Vec::new() + } + } +} + +fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec { + let feed = format!("{} --feed {{q}}", shell_quote(&exe.display().to_string())); + // The sleep is the debounce: fzf kills a reload child that a later keystroke + // superseded, so a burst of typing costs one daemon round-trip, exactly as + // the GTK prompt's timeout does. + let debounce = SEARCH_DEBOUNCE.as_secs_f32(); + let mut argv: Vec = [ + "fzf", + // fzf's own fuzzy matcher would re-order and re-filter what the daemon + // already ranked, and would hide rows it does not think match. + "--disabled", + "--no-sort", + "--no-multi", + "--layout=reverse", + "--info=inline", + "--delimiter=\t", + "--with-nth=1", + ] + .iter() + .map(|arg| (*arg).to_string()) + .collect(); + argv.push(format!("--prompt={PROMPT}")); + // `reload:` rather than `reload(…)`: the unparenthesised form runs to the + // end of the argument, so an install path containing a bracket cannot + // truncate the command fzf parses out of it. + argv.push(format!("--bind=change:reload:sleep {debounce}; {feed}")); + if let Some(text) = query { + argv.push(format!("--query={text}")); + } + argv +} + +/// `display\tpayload\n`. The display half is scrubbed of the delimiter and of +/// newlines; the payload is JSON, which escapes both. +fn encode_line(hit: &Hit) -> String { + let display = + format!("{} · {}", hit.name(), hit.location()).replace(['\n', '\r', FIELD], " "); + let payload = serde_json::to_string(hit).unwrap_or_default(); + format!("{display}{FIELD}{payload}\n") +} + +/// The hit a selected line stands for, or `None` for a line that carries no +/// payload — the error/empty row, or an empty selection (Escape). +fn decode_line(line: &str) -> Option { + let (_, payload) = line.trim_end_matches('\n').rsplit_once(FIELD)?; + serde_json::from_str(payload).ok() +} + +fn self_exe() -> Result { + std::env::current_exe().map_err(|e| format!("cannot locate pdfs-prompt: {e}")) +} + +/// POSIX single-quoting, for the one string that goes through fzf's `sh -c`. +fn shell_quote(text: &str) -> String { + format!("'{}'", text.replace('\'', r"'\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfs_core::control::{LocalHit, SearchHit}; + + fn drive(name: &str, path: &str) -> Hit { + Hit::Drive(SearchHit { + name: name.into(), + path: path.into(), + is_dir: false, + size: 7, + modified: 9, + pinned: false, + uid: "uid~1".into(), + mounted_path: None, + score: 42, + }) + } + + #[test] + fn a_line_round_trips_through_the_payload() { + let hit = drive("notes.md", "Docs/notes.md"); + let line = encode_line(&hit); + assert!(line.ends_with('\n')); + let back = decode_line(&line).expect("payload decodes"); + assert_eq!(back.name(), "notes.md"); + assert_eq!(back.key(), hit.key()); + assert_eq!(back.score(), 42); + } + + #[test] + fn a_local_hit_round_trips_as_itself() { + let hit = Hit::Local(LocalHit { + name: "notes.md".into(), + path: "/home/me/Docs/notes.md".into(), + is_dir: false, + size: 1, + modified: 2, + score: 3, + }); + let back = decode_line(&encode_line(&hit)).expect("payload decodes"); + assert!(matches!(back, Hit::Local(_))); + assert_eq!(back.key(), hit.key()); + } + + #[test] + fn a_name_with_a_tab_or_newline_stays_one_line() { + let hit = drive("two\tparts\nfile.md", "Docs/two\tparts\nfile.md"); + let line = encode_line(&hit); + assert_eq!(line.matches('\n').count(), 1, "{line:?}"); + assert_eq!(line.matches(FIELD).count(), 1, "{line:?}"); + assert_eq!( + decode_line(&line).expect("payload decodes").name(), + "two\tparts\nfile.md" + ); + } + + #[test] + fn a_row_without_a_payload_is_not_a_hit() { + assert!(decode_line("").is_none()); + assert!(decode_line("(cannot reach the Proton Drive daemon)").is_none()); + assert!(decode_line("bogus\tnot json").is_none()); + } + + #[test] + fn fzf_is_told_not_to_do_its_own_matching() { + let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), None); + assert!(argv.contains(&"--disabled".to_string()), "{argv:?}"); + assert!(argv.contains(&"--no-sort".to_string()), "{argv:?}"); + assert!( + argv.iter().any(|arg| arg.starts_with("--with-nth=")), + "the payload field must stay hidden: {argv:?}" + ); + } + + #[test] + fn every_keystroke_re_runs_the_feed_after_the_debounce() { + let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), Some("md")); + let bind = argv + .iter() + .find(|arg| arg.starts_with("--bind=change:")) + .expect("a change binding"); + assert!(bind.contains("reload"), "{bind}"); + assert!(bind.contains("--feed {q}"), "{bind}"); + assert!(bind.contains(&format!("sleep {}", SEARCH_DEBOUNCE.as_secs_f32()))); + assert!(argv.contains(&"--query=md".to_string()), "{argv:?}"); + } + + #[test] + fn a_path_with_a_quote_cannot_break_out_of_the_reload_command() { + let argv = fzf_argv(std::path::Path::new("/home/o'brien/my prompt"), None); + let bind = argv + .iter() + .find(|arg| arg.starts_with("--bind=change:")) + .expect("a change binding"); + assert!(bind.contains(r"'/home/o'\''brien/my prompt'"), "{bind}"); + // The unparenthesised reload form: a bracket in the path must not end + // the command as far as fzf's own parser is concerned. + assert!(bind.starts_with("--bind=change:reload:"), "{bind}"); + } + + #[test] + fn the_terminal_runs_the_inner_role_so_it_cannot_recurse() { + let argv = inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some("md")); + assert_eq!( + argv, + vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner", "--query", "md"] + ); + assert_eq!( + inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some(" ")), + vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner"], + "a blank query is no query" + ); + } +} diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index f4d7640..b620d76 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith}; mod activation; mod dmenu; +mod fzf; +mod query; use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; const APP_ID: &str = "io.narl.proton-drive-linux-prompt"; @@ -125,7 +127,7 @@ fn is_media(name: &str) -> bool { /// One row in the unified result list. The two sections hold different payloads /// — a Drive hit must be hydrated through the daemon before it can be opened, a /// local file is already on disk — but they share one keyboard cursor. -#[derive(Clone)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] enum Hit { Drive(SearchHit), Local(LocalHit), @@ -341,12 +343,20 @@ Options: rofi, …) instead of the built-in GTK window. Also settable permanently as \"prompt\": { \"mode\": \"dmenu\" } in config.json. + --fzf Search in fzf, in a terminal, re-querying the daemon on + every keystroke — results appear as you type, unlike + --dmenu. Also settable as \"prompt\": { \"mode\": \"fzf\" }. --gtk Force the built-in window, overriding that setting. --menu Launcher command line for --dmenu, e.g. --menu 'fuzzel --dmenu --width 60'. Overrides \"prompt\": { \"menu\": [...] }. --query Search for TEXT immediately instead of opening on the - pinned-files list (--dmenu only). + pinned-files list (--dmenu and --fzf only). + --feed Print the hits for TEXT, one per line, and exit. This is + what --fzf re-runs on each keystroke; it is not meant to + be typed. + --inner Run fzf here rather than spawning a terminal. Set by the + terminal --fzf spawns; not meant to be typed. -h, --help Show this help. "; @@ -356,6 +366,12 @@ struct Args { mode: Option, menu: Option>, query: Option, + /// `--feed`: print hits for this text and exit. `Some(None)` is `--feed` + /// with an empty argument, which fzf sends for an empty input — a real + /// request for the pinned list, not an absent flag. + feed: Option>, + /// `--inner`: we are the process a spawned terminal is running. + inner: bool, } fn parse_args(argv: impl Iterator) -> Result { @@ -363,12 +379,19 @@ fn parse_args(argv: impl Iterator) -> Result { mode: None, menu: None, query: None, + feed: None, + inner: false, }; let mut argv = argv.peekable(); while let Some(arg) = argv.next() { match arg.as_str() { "--dmenu" => args.mode = Some(PromptMode::Dmenu), + "--fzf" => args.mode = Some(PromptMode::Fzf), "--gtk" => args.mode = Some(PromptMode::Gtk), + "--inner" => args.inner = true, + // fzf passes an empty argument for an empty input, so a missing + // value here is that case rather than a user error. + "--feed" => args.feed = Some(argv.next().filter(|text| !text.trim().is_empty())), // A launcher command is one shell-ish string so it can live in a // keybinding; splitting on whitespace is enough for flags, and a // launcher argument needing spaces belongs in config.json. @@ -416,16 +439,31 @@ fn main() -> glib::ExitCode { } }; + // A feed is fzf's own reload child, not a front end: it must print hits and + // nothing else, so it is answered before the stored mode is even read. + if let Some(query) = args.feed { + fzf::feed(query.as_deref()); + return glib::ExitCode::SUCCESS; + } + // The stored mode makes an existing keybinding switch front ends without // being re-bound; an explicit flag still wins. let stored = AppDirs::new() .map(|dirs| dirs.load_config().resolved_prompt().resolved_mode()) .unwrap_or_default(); - if args.mode.unwrap_or(stored) == PromptMode::Dmenu { - return match dmenu::run(dmenu::Options { + let launcher = match args.mode.unwrap_or(stored) { + PromptMode::Dmenu => Some(dmenu::run(dmenu::Options { menu: args.menu, query: args.query, - }) { + })), + PromptMode::Fzf => Some(fzf::run(fzf::Options { + query: args.query, + inner: args.inner, + })), + PromptMode::Gtk => None, + }; + if let Some(result) = launcher { + return match result { Ok(()) => glib::ExitCode::SUCCESS, Err(message) => { eprintln!("pdfs-prompt: {message}"); diff --git a/crates/pdfs-gui/src/query.rs b/crates/pdfs-gui/src/query.rs new file mode 100644 index 0000000..94d27e2 --- /dev/null +++ b/crates/pdfs-gui/src/query.rs @@ -0,0 +1,194 @@ +//! The daemon side of both launcher front ends: ask for hits, open a chosen one. +//! +//! `--dmenu` and `--fzf` differ only in how they present a list and collect a +//! choice. Everything between the control socket and the chosen file lives here +//! so the two cannot drift on what a result set is or what "open" means — the +//! same reason both already share [`Hit`] and [`rank_hits`] with the GTK prompt. +//! +//! Everything here is blocking and GTK-free: the callers are one-shot processes +//! with no main loop to keep free. + +use std::path::{Path, PathBuf}; + +use pdfs_core::config::AppDirs; +use pdfs_core::control::{ + Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send, +}; +use pdfs_core::opener::{self, OpenWith}; + +use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; +use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits}; + +/// Where the mount lives, for turning a Drive path into something an +/// application can open. Falls back to the configured default so a momentarily +/// unreachable daemon still produces a plausible path rather than an error. +pub(crate) fn mountpoint(socket: &Path, dirs: &AppDirs) -> PathBuf { + match request(socket, Request::Status) { + Ok(Response::Status { mountpoint, .. }) => PathBuf::from(mountpoint), + _ => dirs.default_mountpoint(), + } +} + +/// Pinned files — the same "you asked to keep these" set the GTK prompt opens +/// with, and the only listing the daemon offers without a query. +pub(crate) fn pins(socket: &Path) -> Result, String> { + let Response::Pins { pins } = request(socket, Request::ListPins)? else { + return Ok(Vec::new()); + }; + Ok(pins + .into_iter() + .map(|pin| { + Hit::Drive(SearchHit { + name: file_name(&pin.path), + path: pin.path, + is_dir: pin.is_dir.unwrap_or(pin.recursive), + size: 0, + modified: 0, + pinned: true, + uid: pin.uid, + mounted_path: None, + score: 0, + }) + }) + .collect()) +} + +pub(crate) fn search(socket: &Path, query: &str, limit: usize) -> Result, String> { + let reply = request( + socket, + Request::SearchV2 { + query: query.to_string(), + limit, + filters: SearchFilters { + sources: vec![SearchSource::Drive, SearchSource::Local], + kind: SearchKind::All, + }, + }, + )?; + let Response::SearchResultsV2 { + drive_hits, + local_hits, + .. + } = reply + else { + return Ok(Vec::new()); + }; + let mut hits: Vec = drive_hits.into_iter().map(Hit::Drive).collect(); + hits.extend(local_hits.into_iter().map(Hit::Local)); + rank_hits(&mut hits); + hits.truncate(limit); + Ok(hits) +} + +/// Icon theme name for a hit. +/// +/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but +/// only with file *contents* to sniff — a Drive hit is metadata, and passing it +/// an empty buffer answers `application-x-zerosize` for everything. The generic +/// names below exist in every icon theme. +pub(crate) fn icon_name(hit: &Hit) -> String { + if hit.is_dir() { + return "folder".to_string(); + } + let name = hit.name(); + if is_image(name) { + "image-x-generic" + } else if is_media(name) { + "video-x-generic" + } else if is_document(name) { + "text-x-generic" + } else { + "application-x-generic" + } + .to_string() +} + +/// Open a chosen hit, mirroring the GTK prompt: local files are already on +/// disk, a Drive folder or streamable file goes through the mount, and anything +/// else is materialised by the daemon first. +pub(crate) fn open( + socket: &Path, + mountpoint: &Path, + policy: &OpenWith, + hit: &Hit, + from_pins: bool, +) -> Result<(), String> { + match hit { + Hit::Local(local) => { + opener::open(policy, Path::new(&local.path), local.is_dir); + Ok(()) + } + Hit::Drive(drive) => { + // A folder has no materialised form, and a pin row's `is_dir` is pin + // policy rather than node kind, so both go to the mount unchecked. + let unconditional = from_pins + || matches!( + drive_activation(&drive.name, drive.is_dir), + DriveActivation::Folder | DriveActivation::MountedMedia + ); + if unconditional { + opener::open( + policy, + &mounted_or_relative(mountpoint, drive), + drive.is_dir, + ); + return Ok(()); + } + // Anything else the mount exposes opens there too, so an editor + // saves back into Drive instead of into a cache blob. + if let Some(path) = mounted_target(mountpoint, drive) { + opener::open(policy, &path, false); + return Ok(()); + } + match request( + socket, + Request::OpenFile { + path: drive.path.clone(), + uid: Some(drive.uid.clone()), + }, + )? { + Response::FilePath { path } => { + // The reply is a content-hash blob; the rules key off the + // Drive name, which is what the user actually opened. + opener::open_named(policy, Path::new(&path), &drive.name, false); + Ok(()) + } + Response::Error { message, .. } => Err(format!("could not open: {message}")), + _ => Err("unexpected reply from the daemon".to_string()), + } + } + } +} + +/// One control-socket round-trip, with the daemon-is-down case phrased for a +/// terminal rather than a status page. +pub(crate) fn request(socket: &Path, request: Request) -> Result { + send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn drive(name: &str, is_dir: bool) -> Hit { + Hit::Drive(SearchHit { + name: name.into(), + path: name.into(), + is_dir, + size: 0, + modified: 0, + pinned: false, + uid: "uid".into(), + mounted_path: None, + score: 0, + }) + } + + #[test] + fn icons_are_theme_names_the_launcher_can_look_up() { + assert_eq!(icon_name(&drive("Photos", true)), "folder"); + assert_eq!(icon_name(&drive("a.png", false)), "image-x-generic"); + assert_eq!(icon_name(&drive("a.md", false)), "text-x-generic"); + assert_eq!(icon_name(&drive("a.bin", false)), "application-x-generic"); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dfac813..a065863 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,69 @@ release ships. Migrations are forward-only — a database written by a newer cli refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from scratch (user data in `staging/` and `recovery/` is never touched by this). +## [Unreleased] + +No schema change. + +### Added +- **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose + metadata sidecars did not match imports with no capture time at all, and the SDK then seals + "now" into every revision — a decade of photos lands on one date. The date is still in the file + name, so this finds photos whose name disagrees with their stored capture time by more than a + day and rewrites them: download, re-upload at the right time, carry favourites and album + memberships across, then **trash** (never delete) the original — Proton has no API for editing a + sealed capture time. `--dry-run` reports without touching anything, `--from`/`--to` aim the run + at the days an import landed on, and `--limit` caps it. Followed with `pdfs redate-status`, + stopped with `pdfs cancel-redate`. + +- **`pdfs-prompt --fzf`: launcher search that updates as you type.** A dmenu-style launcher + (fuzzel, rofi, wofi, …) is handed one static list and filters it itself, which is why `--dmenu` + has to make searching two steps — type, Enter, *then* results. `fzf` can re-run a command on + every keystroke, so the new mode re-queries the daemon per keystroke (debounced to match the GTK + prompt) and keeps `--disabled` on so the daemon's ranking, not fzf's matcher, decides the order. + Abbreviation and typo tolerance therefore work here exactly as they do in the built-in window. +- Started without a tty — from a WM keybinding — `--fzf` spawns a terminal for itself (foot, + ghostty, kitty, alacritty, wezterm or xterm, whichever is installed), each with the app-id/class + `pdfs-prompt` so a single window rule floats it. Override with `"prompt": { "terminal": [...] }`, + where a `{cmd}` token places the command and its absence appends it. `"prompt": { "mode": "fzf" }` + makes it the default front end for an existing keybinding. + +### Fixed +- **Google Photos Takeout: sidecars with a localized suffix are matched again.** Google localizes + the `.supplemental-metadata` it appends to a photo's JSON sidecar — a German export writes + `.ergänzende-Metadaten` — and the scanner only knew the English spelling. No sidecar matched, so + no photo had a capture time, so every photo in the export was stamped with the moment it was + uploaded. Matching now hangs on the photo name in front of the suffix rather than on the suffix + itself, which is locale-agnostic, and covers the misspelt variants Google has also shipped. +- **A photo with no usable sidecar now takes its date from its file name.** `PXL_20260818_171030868.jpg`, + `IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`, `IMG-20230219-WA0001.jpg` and + `Screenshot from 2023-01-01 10-29-50.png` all carry the date the photo was taken. Read as UTC, so + it can be hours out — but never the years out that "the moment it was imported" is. A sidecar + still wins. + +- **An opened file or folder no longer dies with the window that opened it.** Everything launched + through `open_with` was started in the caller's process group with the caller's stdio, which was + invisible from a resident GUI but fatal from `--fzf`: the prompt exits the moment you choose + something, its terminal closes, and the pty hangup killed the application it had just started — + so an `@dir` rule like `alacritty --working-directory {}` opened nothing at all. Opened children + now get their own process group and no inherited stdio. Affects every front end; only `--fzf` + could observe the bug. + +### Changed +- **The gallery virtualises at the row, not the day.** The Photos ListView had one item per day, + and a bind built every one of that day's tiles — fine for a normal day, ruinous for the ~1,600 + photos a Takeout import drops onto a single date, which was 1,600 widgets constructed on the main + thread before the section could be shown. Days are now flattened into heading rows and grid rows, + so a bind is a handful of widgets whatever the day holds. A resize or a zoom step rebuilds the row + model (the column count decides which photos share a row) and scrolls back to the photo that was + at the top. +- Updated to `proton-sdk` / `proton-drive-rs` 0.6.2 (photos save-to-timeline, `enumerate_events`). + +- The daemon side of both launcher front ends — search, pins, mountpoint lookup and the + open-a-result policy — moved out of `pdfs-gui/src/dmenu.rs` into a shared `pdfs-gui/src/query.rs`, + so `--dmenu` and `--fzf` cannot drift on what a result set is or what "open" means. No behaviour + change to `--dmenu`. + ## [1.9.1] — 2026-08-21 Thumbnails everywhere the GUI lists files, a status bar under the Files browser, and the diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 1fba00a..792178d 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=1.9.1 +pkgver=1.9.2 pkgrel=1 pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" arch=('x86_64') @@ -20,7 +20,9 @@ makedepends=('cargo' 'git') provides=('pdfs') # Files-on-demand mount + keyring need these at runtime. optdepends=('gnome-keyring: credential storage via Secret Service' - 'xdg-utils: "Open folder" action from the tray') + 'xdg-utils: "Open folder" action from the tray' + 'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)' + 'foot: terminal for pdfs-prompt --fzf started from a keybinding') options=('!lto' '!debug') # Built in place from the checkout; nothing to fetch or checksum. diff --git a/packaging/proton-drive-linux.spec b/packaging/proton-drive-linux.spec index a71120c..260da8a 100644 --- a/packaging/proton-drive-linux.spec +++ b/packaging/proton-drive-linux.spec @@ -41,6 +41,10 @@ Recommends: gnome-keyring Recommends: gnome-shell-extension-appindicator Recommends: kwallet +# Only for `pdfs-prompt --fzf`; every other front end works without them. +Suggests: fzf +Suggests: foot + Provides: pdfs = %{version}-%{release} %description