mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcb2ab7fac | ||
|
|
eb56f4a1e0 | ||
|
|
c28803b037 | ||
|
|
cd8474954b | ||
|
|
d7e07d042d | ||
|
|
45852082c0 | ||
|
|
99ad3838e1 | ||
|
|
637b512f6a | ||
|
|
16ad6548c8 |
No files matched your search
Generated
+9
-8
@@ -3538,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.9.1"
|
version = "1.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.9.1"
|
version = "1.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -3576,7 +3576,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.9.1"
|
version = "1.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"fuser",
|
"fuser",
|
||||||
@@ -3596,7 +3596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.9.1"
|
version = "1.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3605,6 +3605,7 @@ dependencies = [
|
|||||||
"ksni",
|
"ksni",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3857,9 +3858,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.6.1"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "58322795538d66137d2eb071590375c5287e69059c429e2309dc37368ab5b588"
|
checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3884,9 +3885,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.6.1"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d77482a3160d14926c65537c64bc95cf27d609597a524c97d97d97d1777e9603"
|
checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.9.1"
|
version = "1.10.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.1" }
|
proton-sdk = { version = "0.6.3" }
|
||||||
proton-drive-rs = { version = "0.6.1" }
|
proton-drive-rs = { version = "0.6.3" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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
|
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||||
own prompt flag is appended.
|
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
|
## Choosing How Files Open
|
||||||
|
|
||||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||||
|
|||||||
+350
-10
@@ -17,6 +17,7 @@ use pdfs_core::control::{
|
|||||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
|
use pdfs_core::service;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -176,6 +177,43 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wait: bool,
|
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<usize>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Only touch photos whose current capture time is before this date
|
||||||
|
/// (`YYYY-MM-DD`, UTC).
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// 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.
|
/// Show how the running (or last) Google Photos import is doing.
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
/// Stop the running Google Photos import.
|
/// Stop the running Google Photos import.
|
||||||
@@ -616,6 +654,15 @@ fn main() -> Result<()> {
|
|||||||
dry_run,
|
dry_run,
|
||||||
wait,
|
wait,
|
||||||
} => cmd_import_google_photos(archives, 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::ImportStatus => cmd_import_status(),
|
||||||
Command::CancelImport => cmd_cancel_import(),
|
Command::CancelImport => cmd_cancel_import(),
|
||||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
@@ -1082,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
|||||||
format!(" ({})", e.role)
|
format!(" ({})", e.role)
|
||||||
};
|
};
|
||||||
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
|
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
|
||||||
|
if let Some(line) = shared_by_line(&e) {
|
||||||
|
println!("{:>15}{line}", "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
@@ -1090,6 +1140,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Who shared an entry with me, and when, as a line printed under it — `None`
|
||||||
|
/// when the invitation said nothing (owned content, or a node inside a shared
|
||||||
|
/// folder rather than its root). An inviter whose signature did not verify is
|
||||||
|
/// said to be claimed, not stated: the name may be forged.
|
||||||
|
fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option<String> {
|
||||||
|
if entry.shared_by.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut line = format!("shared by {}", entry.shared_by);
|
||||||
|
if entry.shared_at > 0 {
|
||||||
|
line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at)));
|
||||||
|
}
|
||||||
|
if entry.shared_by_unverified {
|
||||||
|
line.push_str(" WARNING: invitation signature did not verify; the sender may be forged");
|
||||||
|
}
|
||||||
|
Some(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the
|
||||||
|
/// CLI for one column. Civil-from-days per Howard Hinnant's algorithm.
|
||||||
|
fn format_epoch_utc(secs: i64) -> String {
|
||||||
|
let z = secs.div_euclid(86_400) + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||||
|
format!("{year:04}-{month:02}-{day:02}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Download a file shared with me. The daemon puts the plaintext in its content
|
/// Download a file shared with me. The daemon puts the plaintext in its content
|
||||||
/// cache and replies with that path; with a `dest` we copy it out, so the caller
|
/// cache and replies with that path; with a `dest` we copy it out, so the caller
|
||||||
/// gets a file it owns rather than one the cache may later evict.
|
/// gets a file it owns rather than one the cache may later evict.
|
||||||
@@ -1309,6 +1392,15 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
other => anyhow::Error::new(other).context("login failed"),
|
other => anyhow::Error::new(other).context("login failed"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
||||||
|
// between keyring checks; restart it so the mount appears now instead of
|
||||||
|
// whenever the next poll fires. Only when it is already active — `pdfs
|
||||||
|
// login` deliberately does not enable the unit the way the GUI does, so a
|
||||||
|
// headless user driving `pdfs daemon` by hand keeps that setup.
|
||||||
|
if service::is_active() {
|
||||||
|
service::restart();
|
||||||
|
}
|
||||||
|
|
||||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1448,6 +1540,59 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long to wait before the next login check, after `attempts` checks have
|
||||||
|
/// already come back [`pdfs_core::Error::NotLoggedIn`].
|
||||||
|
///
|
||||||
|
/// Doubles from 3s to a 15s ceiling. The interval is not just a sleep:
|
||||||
|
/// [`auth::load`] builds a fresh `keyring::Entry` on every call, so each check
|
||||||
|
/// opens a new D-Bus connection to the Secret Service and negotiates an
|
||||||
|
/// encrypted session over it. A flat 3s poll meant 28,800 of those a day from a
|
||||||
|
/// daemon that had nothing to do; the ceiling cuts that by 80%.
|
||||||
|
///
|
||||||
|
/// The ceiling stays low because the sleep is also the worst-case login →
|
||||||
|
/// mount latency for anything that does *not* poke systemd: front-ends restart
|
||||||
|
/// the unit on login (`service::enable_start`), which interrupts the sleep
|
||||||
|
/// immediately, but a hand-run `pdfs daemon` has no such wake-up path.
|
||||||
|
fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
||||||
|
const BASE_SECS: u64 = 3;
|
||||||
|
const MAX_SECS: u64 = 15;
|
||||||
|
|
||||||
|
// `checked_shl` rather than `<<`: the daemon can sit unauthenticated for
|
||||||
|
// weeks, and a shift past the width of the type is a panic, not a big number.
|
||||||
|
let factor = 1u64.checked_shl(attempts).unwrap_or(u64::MAX);
|
||||||
|
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until a session is stored, backing off between checks.
|
||||||
|
///
|
||||||
|
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
||||||
|
/// schedule can be tested without a keyring and without real time passing.
|
||||||
|
fn wait_for_session<L, S>(mut load: L, mut sleep: S) -> Result<()>
|
||||||
|
where
|
||||||
|
L: FnMut() -> std::result::Result<(), pdfs_core::Error>,
|
||||||
|
S: FnMut(std::time::Duration),
|
||||||
|
{
|
||||||
|
let mut attempts: u32 = 0;
|
||||||
|
loop {
|
||||||
|
match load() {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||||
|
let delay = login_poll_delay(attempts);
|
||||||
|
// Say it once at INFO. Repeating it every interval buried the
|
||||||
|
// journal in tens of thousands of identical lines a day.
|
||||||
|
if attempts == 0 {
|
||||||
|
tracing::info!("not logged in; waiting for a session…");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(delay_secs = delay.as_secs(), "still not logged in");
|
||||||
|
}
|
||||||
|
sleep(delay);
|
||||||
|
attempts = attempts.saturating_add(1);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
|
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
|
||||||
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
|
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
|
||||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||||
@@ -1456,16 +1601,7 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
loop {
|
loop {
|
||||||
// Wait until a session is stored. The GUI enables this service on login,
|
// Wait until a session is stored. The GUI enables this service on login,
|
||||||
// but the service may also start at boot before the user has logged in.
|
// but the service may also start at boot before the user has logged in.
|
||||||
loop {
|
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
||||||
match auth::load() {
|
|
||||||
Ok(_) => break,
|
|
||||||
Err(pdfs_core::Error::NotLoggedIn) => {
|
|
||||||
tracing::info!("not logged in; waiting…");
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match mount_once(mountpoint.clone()) {
|
match mount_once(mountpoint.clone()) {
|
||||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||||
@@ -1787,6 +1923,115 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_redate_photos(
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
from: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
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<i64> {
|
||||||
|
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<()> {
|
fn cmd_import_status() -> Result<()> {
|
||||||
match control_request(CtlRequest::ImportStatus)? {
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
CtlResponse::ImportStatus { running, summary } => {
|
CtlResponse::ImportStatus { running, summary } => {
|
||||||
@@ -2537,3 +2782,98 @@ mod diagnose_tests {
|
|||||||
std::fs::remove_dir(dir).unwrap();
|
std::fs::remove_dir(dir).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod login_wait_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_delay_doubles_then_holds_at_the_ceiling() {
|
||||||
|
assert_eq!(login_poll_delay(0), Duration::from_secs(3));
|
||||||
|
assert_eq!(login_poll_delay(1), Duration::from_secs(6));
|
||||||
|
assert_eq!(login_poll_delay(2), Duration::from_secs(12));
|
||||||
|
assert_eq!(login_poll_delay(3), Duration::from_secs(15));
|
||||||
|
assert_eq!(login_poll_delay(4), Duration::from_secs(15));
|
||||||
|
// A daemon left unauthenticated for weeks reaches attempt counts well
|
||||||
|
// past the width of the shift; that must cap, not panic.
|
||||||
|
assert_eq!(login_poll_delay(64), Duration::from_secs(15));
|
||||||
|
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
||||||
|
let mut checks_left = 20;
|
||||||
|
let mut slept: Vec<Duration> = Vec::new();
|
||||||
|
|
||||||
|
wait_for_session(
|
||||||
|
|| {
|
||||||
|
if checks_left == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
checks_left -= 1;
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|delay| slept.push(delay),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(slept.len(), 20, "one wait per failed check");
|
||||||
|
assert!(
|
||||||
|
slept.windows(2).all(|w| w[1] >= w[0]),
|
||||||
|
"the interval must never shrink: {slept:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The point of the change. Every check is a fresh D-Bus session against
|
||||||
|
// the Secret Service, so 20 of them must not fit inside a single minute
|
||||||
|
// the way a flat 3s poll did.
|
||||||
|
let total: Duration = slept.iter().sum();
|
||||||
|
assert!(
|
||||||
|
total >= Duration::from_secs(4 * 60),
|
||||||
|
"20 checks should span at least 4 minutes, spanned {total:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_session_is_not_waited_for_at_all() {
|
||||||
|
let mut slept = 0;
|
||||||
|
wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
|
||||||
|
assert_eq!(slept, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn epoch_seconds_format_as_utc_calendar_dates() {
|
||||||
|
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
||||||
|
assert_eq!(format_epoch_utc(951_782_400), "2000-02-29");
|
||||||
|
assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14");
|
||||||
|
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
||||||
|
let mut entry = pdfs_core::control::DirEntry {
|
||||||
|
name: "Budget.ods".into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 1,
|
||||||
|
modified: 0,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
path: String::new(),
|
||||||
|
role: "viewer".into(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
|
};
|
||||||
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||||
|
entry.shared_by = "alice@proton.me".into();
|
||||||
|
entry.shared_at = 1_700_000_000;
|
||||||
|
assert_eq!(
|
||||||
|
shared_by_line(&entry).as_deref(),
|
||||||
|
Some("shared by alice@proton.me at 2023-11-14")
|
||||||
|
);
|
||||||
|
entry.shared_by_unverified = true;
|
||||||
|
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,6 +149,43 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
dry_run: bool,
|
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<usize>,
|
||||||
|
/// 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
|
/// How the running (or last finished) Takeout import is doing. Replies with
|
||||||
/// [`Response::ImportStatus`].
|
/// [`Response::ImportStatus`].
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
@@ -869,6 +906,22 @@ pub struct DirEntry {
|
|||||||
/// report. Defaulted for wire-compat with clients/daemons predating it.
|
/// report. Defaulted for wire-compat with clients/daemons predating it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub role: String,
|
pub role: String,
|
||||||
|
/// Who shared this node with me, as the invitation claims it — empty for
|
||||||
|
/// content I own, for a node reached *inside* a shared folder (only the share
|
||||||
|
/// root carries the invitation), and when the API did not say. Trust it only
|
||||||
|
/// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows.
|
||||||
|
/// Defaulted for wire-compat with clients/daemons predating it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shared_by: String,
|
||||||
|
/// When it was shared with me, epoch seconds; 0 when unknown.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shared_at: i64,
|
||||||
|
/// The invitation's signature did not check out against `shared_by`'s keys,
|
||||||
|
/// so the name may be forged. False when it verified *and* when it could not
|
||||||
|
/// be checked at all (no signature, no keys): only an actual mismatch is a
|
||||||
|
/// warning worth showing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shared_by_unverified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
@@ -1109,6 +1162,35 @@ pub struct PhotoMonth {
|
|||||||
pub count: usize,
|
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
|
/// How a Google Photos Takeout import is going, or how the last one ended
|
||||||
/// ([`Response::ImportStatus`]).
|
/// ([`Response::ImportStatus`]).
|
||||||
///
|
///
|
||||||
@@ -1434,6 +1516,14 @@ pub enum Response {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
summary: Option<ImportSummary>,
|
summary: Option<ImportSummary>,
|
||||||
},
|
},
|
||||||
|
/// 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<RedateSummary>,
|
||||||
|
},
|
||||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||||
/// newest first.
|
/// newest first.
|
||||||
PhotoMonths { months: Vec<PhotoMonth> },
|
PhotoMonths { months: Vec<PhotoMonth> },
|
||||||
|
|||||||
@@ -188,6 +188,19 @@ impl Db {
|
|||||||
Ok(rows)
|
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<Vec<String>> {
|
||||||
|
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::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many photos of `album_uid` are persisted.
|
/// How many photos of `album_uid` are persisted.
|
||||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
|||||||
@@ -165,6 +165,31 @@ impl Db {
|
|||||||
Ok(rows)
|
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<Vec<StoredPhoto>> {
|
||||||
|
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::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// The months the timeline spans, newest first, each with how many photos it
|
/// The months the timeline spans, newest first, each with how many photos it
|
||||||
/// holds — the data behind the date scrubber. Buckets are local-time
|
/// holds — the data behind the date scrubber. Buckets are local-time
|
||||||
/// `(year, month)` so they line up with the day headings the gallery draws
|
/// `(year, month)` so they line up with the day headings the gallery draws
|
||||||
|
|||||||
@@ -3201,6 +3201,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
};
|
};
|
||||||
db.upsert_node(&parent_node).unwrap();
|
db.upsert_node(&parent_node).unwrap();
|
||||||
|
|
||||||
@@ -3227,6 +3229,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
};
|
};
|
||||||
db.upsert_node(&child_node).unwrap();
|
db.upsert_node(&child_node).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum PromptFlag {
|
enum PromptFlag {
|
||||||
/// `--prompt=TEXT`
|
/// `--prompt=TEXT`
|
||||||
@@ -105,6 +124,13 @@ pub struct PromptConfig {
|
|||||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub menu: Option<Vec<String>>,
|
pub menu: Option<Vec<String>>,
|
||||||
|
/// 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<Vec<String>>,
|
||||||
/// How many results to feed the launcher. Unset means
|
/// How many results to feed the launcher. Unset means
|
||||||
/// [`DEFAULT_MENU_LIMIT`].
|
/// [`DEFAULT_MENU_LIMIT`].
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -124,6 +150,8 @@ pub enum PromptMode {
|
|||||||
Gtk,
|
Gtk,
|
||||||
/// The user's own launcher, driven over stdin/stdout.
|
/// The user's own launcher, driven over stdin/stdout.
|
||||||
Dmenu,
|
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
|
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||||
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
|||||||
Some(argv)
|
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<String>>) -> Option<Vec<String>> {
|
||||||
|
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<String> {
|
||||||
|
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.
|
/// 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
|
/// 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)
|
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 {
|
let Some(path) = std::env::var_os("PATH") else {
|
||||||
return false;
|
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]
|
#[test]
|
||||||
fn a_configured_launcher_is_used_verbatim() {
|
fn a_configured_launcher_is_used_verbatim() {
|
||||||
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
|||||||
spawn(&argv, path);
|
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) {
|
fn spawn(argv: &[String], path: &Path) {
|
||||||
let Some((program, args)) = argv.split_first() else {
|
let Some((program, args)) = argv.split_first() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
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}");
|
tracing::error!(command = ?argv, "open failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,18 @@ fn systemctl_query(args: &[&str]) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable the service so it survives reboots/logins and start it now. Called on
|
/// Enable the service so it survives reboots/logins, and bring it up now.
|
||||||
/// successful login.
|
/// Called on successful login.
|
||||||
|
///
|
||||||
|
/// `restart` rather than `enable --now`: the unit may already be active but
|
||||||
|
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
|
||||||
|
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
||||||
|
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||||
|
/// Restarting makes the new session take effect immediately, and starts the
|
||||||
|
/// unit when it is not running at all.
|
||||||
pub fn enable_start() -> bool {
|
pub fn enable_start() -> bool {
|
||||||
systemctl(&["enable", "--now"])
|
let enabled = systemctl(&["enable"]);
|
||||||
|
systemctl(&["restart"]) && enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the running mount without disabling it. Called by the tray's
|
/// Stop the running mount without disabling it. Called by the tray's
|
||||||
|
|||||||
+318
-29
@@ -393,22 +393,27 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.unwrap_or(&file_name)
|
.unwrap_or(&file_name)
|
||||||
.to_string();
|
.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 {
|
scan.photos.push(TakeoutPhoto {
|
||||||
archive: raw.archive,
|
archive: raw.archive,
|
||||||
entry: raw.entry.clone(),
|
entry: raw.entry.clone(),
|
||||||
name,
|
name,
|
||||||
size: raw.size,
|
size: raw.size,
|
||||||
media_type: media_type.to_string(),
|
media_type: media_type.to_string(),
|
||||||
capture_time: sidecar
|
capture_time,
|
||||||
.photo_taken_time
|
|
||||||
.as_ref()
|
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
|
||||||
.or_else(|| {
|
|
||||||
sidecar
|
|
||||||
.creation_time
|
|
||||||
.as_ref()
|
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
|
||||||
}),
|
|
||||||
favorite: sidecar.favorited,
|
favorite: sidecar.favorited,
|
||||||
bucket,
|
bucket,
|
||||||
});
|
});
|
||||||
@@ -507,34 +512,218 @@ fn unswap_duplicate_marker(stem: &str) -> Option<String> {
|
|||||||
Some(format!("{}{}{}", &head[..dot], marker, &head[dot..]))
|
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<i64> {
|
||||||
|
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<i64> { 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<String> {
|
||||||
|
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.
|
/// 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
|
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||||
/// prefix match for the case where truncation cut into the photo name itself.
|
/// 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>(
|
fn find_sidecar<'a>(
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
||||||
) -> Option<&'a Vec<u8>> {
|
) -> Option<&'a Vec<u8>> {
|
||||||
let mut prefix_match: Option<&'a Vec<u8>> = None;
|
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||||
let mut prefix_len = 0usize;
|
|
||||||
|
|
||||||
for (sidecar_name, body) in in_folder {
|
for (sidecar_name, body) in in_folder {
|
||||||
let stem = strip_sidecar_suffix(sidecar_name);
|
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||||
if stem == file_name {
|
continue;
|
||||||
return Some(body);
|
};
|
||||||
}
|
if best.as_ref().is_none_or(|(current, _)| rank < *current) {
|
||||||
if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) {
|
best = Some((rank, body));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.<suffix>` and `IMG_1.jpg(1).<suffix>` 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<usize> = 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<Item = &str> {
|
||||||
|
std::iter::once(stem).chain(
|
||||||
|
stem.char_indices()
|
||||||
|
.filter(|(_, c)| *c == '.')
|
||||||
|
.map(|(index, _)| &stem[..index])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -663,6 +852,106 @@ mod tests {
|
|||||||
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
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]
|
#[test]
|
||||||
fn truncated_supplemental_suffixes_still_match() {
|
fn truncated_supplemental_suffixes_still_match() {
|
||||||
// Google caps the sidecar name at 51 chars, cutting into the suffix.
|
// Google caps the sidecar name at 51 chars, cutting into the suffix.
|
||||||
|
|||||||
@@ -541,6 +541,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -411,6 +411,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
CtlResponse::error(CoreError::invalid("no import is running"))
|
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 {
|
Ok(CtlRequest::UploadPhoto {
|
||||||
name,
|
name,
|
||||||
media_type,
|
media_type,
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
use pdfs_core::batch;
|
use pdfs_core::batch;
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
||||||
use proton_drive_rs::Node;
|
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{Node, NodeMoveItem};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::state::Intervals;
|
use super::state::Intervals;
|
||||||
@@ -642,61 +644,46 @@ impl Core {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The name half goes first, so that a collision on the move half below is
|
let landed = if node.parent_uid.as_ref() == Some(&parent) {
|
||||||
// about the name the node will actually land under rather than the one it
|
self.drain_rename_in_place(&uid, &node.name, &name)?
|
||||||
// is about to lose.
|
} else {
|
||||||
let mut landed = node.name.clone();
|
// Move and rename land as one request (`move-multiple` takes a
|
||||||
if landed != name {
|
// target name), so there is no half-applied state between them and
|
||||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
// no window for the requirements to go stale between two calls —
|
||||||
Ok(()) => landed = name.clone(),
|
// the race B46 queued this op to get away from.
|
||||||
// Someone took the name while we were offline. Renaming to a
|
let target = (node.name != name).then_some(name.as_str());
|
||||||
|
match self.move_renaming(&uid, &parent, target) {
|
||||||
|
Ok(()) => name.clone(),
|
||||||
|
// The destination holds that name already. Landing under a
|
||||||
// *different* name is the non-destructive resolution: it neither
|
// *different* name is the non-destructive resolution: it neither
|
||||||
// clobbers their file nor drops ours, and it is visible.
|
// clobbers their file nor drops ours, and it is visible.
|
||||||
Err(e) if is_already_exists(&e) => {
|
Err(e) if is_already_exists(&e) => {
|
||||||
let alt = conflict_name(&name, now_secs());
|
let alt = conflict_name(&name, now_secs());
|
||||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
|
||||||
self.rt
|
self.move_renaming(&uid, &parent, Some(&alt))?;
|
||||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
|
||||||
landed = alt.clone();
|
|
||||||
self.adopt_drained_name(&uid, &alt);
|
self.adopt_drained_name(&uid, &alt);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Rename,
|
ActivityKind::Rename,
|
||||||
&name,
|
&name,
|
||||||
format!("name was taken remotely; renamed to {alt}"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if node.parent_uid.as_ref() != Some(&parent) {
|
|
||||||
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
// The destination holds a `landed` of its own. Same resolution as
|
|
||||||
// a name collision, and it has to happen before the move: the API
|
|
||||||
// has no move-and-rename, so the node is renamed out of the way
|
|
||||||
// here and moved second.
|
|
||||||
Err(e) if is_already_exists(&e) => {
|
|
||||||
let alt = conflict_name(&landed, now_secs());
|
|
||||||
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
|
|
||||||
self.rt
|
|
||||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
|
||||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
|
||||||
self.adopt_drained_name(&uid, &alt);
|
|
||||||
self.log_activity(
|
|
||||||
ActivityKind::Rename,
|
|
||||||
&landed,
|
|
||||||
format!("destination already had that name; moved as {alt}"),
|
format!("destination already had that name; moved as {alt}"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
alt
|
||||||
}
|
}
|
||||||
// The destination folder is gone. Leaving the node in its current
|
// The destination folder is gone. Leaving the node in its current
|
||||||
// parent is the honest outcome: it is not where the user asked for
|
// parent is the honest outcome: it is not where the user asked for
|
||||||
// it, but it exists, it is where it has always been, and the
|
// it, but it exists and it is where it has always been. The name
|
||||||
// rename half above still applied. Retrying could only fail again
|
// the user chose still applies there. Retrying the move could
|
||||||
// — the folder is not coming back — and would wedge the queue.
|
// only fail again — the folder is not coming back — and would
|
||||||
|
// wedge the queue.
|
||||||
Err(e) if is_gone(&e) => {
|
Err(e) if is_gone(&e) => {
|
||||||
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
|
warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
|
||||||
|
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
|
||||||
|
Ok(landed) => landed,
|
||||||
|
// The node went with it; nothing is left to rename.
|
||||||
|
Err(e) if is_gone(&e) => node.name.clone(),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Rename,
|
ActivityKind::Rename,
|
||||||
&landed,
|
&landed,
|
||||||
@@ -704,16 +691,73 @@ impl Core {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
landed
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
self.note_self_change(&uid);
|
self.note_self_change(&uid);
|
||||||
info!(%uid, name = %landed, "pending rename landed");
|
info!(%uid, name = %landed, "pending rename landed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename `uid` without moving it, from `current` to `name`, and return the
|
||||||
|
/// name it landed under. Someone may have taken the name while we were
|
||||||
|
/// offline; the node then lands under a conflict name instead.
|
||||||
|
fn drain_rename_in_place(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
current: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<String, ProtonError> {
|
||||||
|
if current == name {
|
||||||
|
return Ok(current.to_string());
|
||||||
|
}
|
||||||
|
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
|
||||||
|
Ok(()) => Ok(name.to_string()),
|
||||||
|
Err(e) if is_already_exists(&e) => {
|
||||||
|
let alt = conflict_name(name, now_secs());
|
||||||
|
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||||
|
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
|
||||||
|
self.adopt_drained_name(uid, &alt);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Rename,
|
||||||
|
name,
|
||||||
|
format!("name was taken remotely; renamed to {alt}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Ok(alt)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `uid` under `parent` in one request, renaming it to `target_name` on
|
||||||
|
/// the way when one is given.
|
||||||
|
fn move_renaming(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
parent: &NodeUid,
|
||||||
|
target_name: Option<&str>,
|
||||||
|
) -> Result<(), ProtonError> {
|
||||||
|
let item = NodeMoveItem {
|
||||||
|
uid: uid.clone(),
|
||||||
|
target_name: target_name.map(str::to_string),
|
||||||
|
};
|
||||||
|
self.rt.block_on(async {
|
||||||
|
let mut outcomes =
|
||||||
|
std::pin::pin!(self.client.move_nodes_streaming(vec![item], parent.clone()));
|
||||||
|
match outcomes.next().await {
|
||||||
|
Some(Ok((_, outcome))) => outcome,
|
||||||
|
Some(Err(e)) => Err(e),
|
||||||
|
None => Err(ProtonError::invalid_operation(format!(
|
||||||
|
"move of {uid} reported no outcome"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a queued trash to the remote.
|
/// Apply a queued trash to the remote.
|
||||||
///
|
///
|
||||||
/// A node that is already gone is a success, not a failure: the outcome the
|
/// A node that is already gone is a success, not a failure: the outcome the
|
||||||
@@ -1587,6 +1631,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1879,6 +1925,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ mod mount;
|
|||||||
mod photos;
|
mod photos;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod reads;
|
mod reads;
|
||||||
|
mod redate;
|
||||||
mod revisions;
|
mod revisions;
|
||||||
mod sharing;
|
mod sharing;
|
||||||
mod shutdown;
|
mod shutdown;
|
||||||
@@ -3737,6 +3738,9 @@ impl Core {
|
|||||||
// My own tree: a role only means something under a share, and
|
// My own tree: a role only means something under a share, and
|
||||||
// this listing never crosses into one.
|
// this listing never crosses into one.
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4318,6 +4322,9 @@ impl Core {
|
|||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4856,7 +4863,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
|||||||
|
|
||||||
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
/// 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.
|
/// receive uids as strings over the control socket and pass them back verbatim.
|
||||||
fn parse_uid(s: &str) -> Option<NodeUid> {
|
pub(crate) fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||||
let (vol, link) = s.split_once('~')?;
|
let (vol, link) = s.split_once('~')?;
|
||||||
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
||||||
return None;
|
return None;
|
||||||
@@ -4993,6 +5000,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
|||||||
album: None,
|
album: None,
|
||||||
// Nothing signed it: it has never been near the crypto layer.
|
// Nothing signed it: it has never been near the crypto layer.
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5917,6 +5926,8 @@ mod pending_size_tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7127,6 +7138,9 @@ mod tests {
|
|||||||
share_id,
|
share_id,
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
});
|
});
|
||||||
node
|
node
|
||||||
};
|
};
|
||||||
@@ -7587,6 +7601,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<parking_lot::Mutex<RedateState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RedateState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<RedateSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a re-date is running, and the last one's report.
|
||||||
|
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<RedateSummary> {
|
||||||
|
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<usize>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::QUOTA_TTL;
|
use crate::QUOTA_TTL;
|
||||||
|
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{MemberRole, NodeKind};
|
use proton_drive_rs::{MemberRole, NodeKind};
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
.map(role_to_str)
|
.map(role_to_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let invitation = n.membership.as_ref();
|
||||||
|
let shared_by = invitation
|
||||||
|
.and_then(|m| m.inviter_email.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||||
|
// Only a signature that is present and wrong is a warning. An invitation
|
||||||
|
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||||
|
// is not evidence of forgery, and flagging every such share would teach the
|
||||||
|
// user to ignore the flag.
|
||||||
|
let shared_by_unverified = invitation
|
||||||
|
.and_then(|m| m.inviter_verification)
|
||||||
|
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
role,
|
role,
|
||||||
|
shared_by,
|
||||||
|
shared_at,
|
||||||
|
shared_by_unverified,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1120,6 +1120,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1130,9 @@ mod tests {
|
|||||||
share_id: ShareId::from("share"),
|
share_id: ShareId::from("share"),
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -368,6 +368,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
ksni = "0.2"
|
ksni = "0.2"
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use widgets::versions_dialog::*;
|
|||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -484,7 +484,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
decode_idle: Cell::new(false),
|
decode_idle: Cell::new(false),
|
||||||
thumb_source: RefCell::new(None),
|
thumb_source: RefCell::new(None),
|
||||||
relayout_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 {
|
shared: SharedState {
|
||||||
content: shared_widgets.content.clone(),
|
content: shared_widgets.content.clone(),
|
||||||
|
|||||||
@@ -2345,6 +2345,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| {
|
entries.sort_by(|a, b| {
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ pub(crate) struct GalleryState {
|
|||||||
/// Every photo loaded so far, newest first — the order the lightbox's
|
/// Every photo loaded so far, newest first — the order the lightbox's
|
||||||
/// prev/next walks. The visible day sections are derived from this.
|
/// prev/next walks. The visible day sections are derived from this.
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) model: gio::ListStore,
|
||||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
/// The Photos ListView's own model: one item per *rendered row* — a day
|
||||||
/// [`Self::model`] by [`repaint_gallery`].
|
/// 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,
|
pub(crate) groups: gio::ListStore,
|
||||||
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||||
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
/// [`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.
|
/// re-flow, replaced on each new trigger so only the last one fires.
|
||||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||||
/// The day sections currently realised by the ListView, by their index in
|
/// The rows currently realised by the ListView, as row index -> the uid of
|
||||||
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
|
/// its first photo (absent for a heading row). A resize or a zoom step
|
||||||
/// rebuilding the ListStore instead would reset the scroll position out from
|
/// changes how many tiles fit per row, so the row model has to be rebuilt —
|
||||||
/// under the user.
|
/// this is what lets the rebuild put the user back where they were.
|
||||||
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
|
||||||
|
/// 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.
|
/// 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
|
/// 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
|
/// 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 {
|
pub(crate) struct PhotoGroup {
|
||||||
/// "Today", "Yesterday", or e.g. "3 June 2026".
|
/// "Today", "Yesterday", or e.g. "3 June 2026".
|
||||||
pub(crate) heading: String,
|
pub(crate) heading: String,
|
||||||
pub(crate) photos: Vec<PhotoItem>,
|
pub(crate) photos: Vec<PhotoItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<PhotoItem>,
|
||||||
|
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<String> {
|
||||||
|
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`].
|
/// The widgets [`build_gallery_page`] hands back to [`build_window`].
|
||||||
pub(crate) struct GalleryWidgets {
|
pub(crate) struct GalleryWidgets {
|
||||||
/// Flat, newest-first list of every loaded photo. Backs the lightbox's
|
/// Flat, newest-first list of every loaded photo. Backs the lightbox's
|
||||||
@@ -611,40 +676,64 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
let factory = gtk4::SignalListItemFactory::new();
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
factory.connect_setup(|_, item| {
|
factory.connect_setup(|_, item| {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
// One container for both row kinds: the bind fills it with either a
|
||||||
section.set_margin_bottom(16);
|
// heading label or a strip of tiles. The alternative — two factories, or
|
||||||
let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
// a Stack per row — buys nothing, since a row's children are rebuilt on
|
||||||
heading.add_css_class("heading");
|
// bind either way.
|
||||||
heading.add_css_class("gallery-day");
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||||
section.append(&heading);
|
row.set_halign(gtk4::Align::Start);
|
||||||
item.set_child(Some(§ion));
|
item.set_child(Some(&row));
|
||||||
item.set_activatable(false);
|
item.set_activatable(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_bind = ui.clone();
|
let ui_bind = ui.clone();
|
||||||
factory.connect_bind(move |_, item| {
|
factory.connect_bind(move |_, item| {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
let section = item.child().and_downcast::<gtk4::Box>().unwrap();
|
let row_box = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
let group = obj.borrow::<PhotoGroup>();
|
let row = obj.borrow::<GalleryRow>();
|
||||||
|
|
||||||
let heading = section.first_child().and_downcast::<gtk4::Label>().unwrap();
|
while let Some(child) = row_box.first_child() {
|
||||||
heading.set_label(&group.heading);
|
row_box.remove(&child);
|
||||||
|
}
|
||||||
fill_section(&ui_bind, §ion, &group.photos);
|
match &*row {
|
||||||
// Remember the realised section so a learned aspect ratio or a resize can
|
GalleryRow::Heading(heading) => {
|
||||||
// re-justify it in place, without rebuilding the ListStore (which would
|
row_box.set_margin_top(16);
|
||||||
// yank the scroll position back to the top).
|
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
|
ui_bind
|
||||||
.gallery
|
.gallery
|
||||||
.bound
|
.bound
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.insert(item.position(), section);
|
.insert(item.position(), row.anchor());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ListView recycles section widgets, so a scrolled-away day must give up its
|
// ListView recycles row widgets, so a scrolled-away row must give up its
|
||||||
// claim on the widgets — otherwise a thumbnail landing late would paint into
|
// claim on them — otherwise a thumbnail landing late would paint into a tile
|
||||||
// a tile that now shows a different day.
|
// that now shows a different photo.
|
||||||
let ui_unbind = ui.clone();
|
let ui_unbind = ui.clone();
|
||||||
factory.connect_unbind(move |_, item| {
|
factory.connect_unbind(move |_, item| {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
@@ -653,10 +742,11 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
.bound
|
.bound
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.remove(&item.position());
|
.remove(&item.position());
|
||||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
||||||
let group = obj.borrow::<PhotoGroup>();
|
&& let GalleryRow::Tiles { photos, .. } = &*obj.borrow::<GalleryRow>()
|
||||||
|
{
|
||||||
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
||||||
for photo in &group.photos {
|
for photo in photos {
|
||||||
wanted.remove(&photo.uid);
|
wanted.remove(&photo.uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -917,34 +1007,6 @@ pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
|||||||
(columns, edge)
|
(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<Ui>, 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
|
/// 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.
|
/// px so a rounding error can't push a row into a horizontal overflow.
|
||||||
/// Falls back to a sane guess before the first allocation.
|
/// Falls back to a sane guess before the first allocation.
|
||||||
@@ -1238,27 +1300,35 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
|||||||
*ui.gallery.relayout_source.borrow_mut() = Some(source);
|
*ui.gallery.relayout_source.borrow_mut() = Some(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the tiles of the day sections currently on screen, at the current
|
/// Re-flow the timeline at the current width and zoom.
|
||||||
/// width and zoom. Sections that are *not* realised need no work: they lay
|
///
|
||||||
/// themselves out against the current state when the ListView binds them.
|
/// 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<Ui>) {
|
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
|
||||||
.gallery
|
repaint_gallery(ui);
|
||||||
.bound
|
let Some(anchor) = anchor else { return };
|
||||||
.borrow()
|
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
||||||
.iter()
|
return;
|
||||||
.map(|(pos, section)| (*pos, section.clone()))
|
};
|
||||||
.collect();
|
ui.gallery
|
||||||
for (pos, section) in bound {
|
.list
|
||||||
let Some(obj) = ui.gallery.groups.item(pos) else {
|
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
||||||
continue;
|
}
|
||||||
};
|
|
||||||
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
|
/// Which row of the rendered model holds `uid`, if any.
|
||||||
continue;
|
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
||||||
};
|
(0..store.n_items()).find(|i| {
|
||||||
let photos = boxed.borrow::<PhotoGroup>().photos.clone();
|
store
|
||||||
fill_section(ui, §ion, &photos);
|
.item(*i)
|
||||||
}
|
.and_downcast::<BoxedAnyObject>()
|
||||||
|
.is_some_and(|obj| match &*obj.borrow::<GalleryRow>() {
|
||||||
|
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.
|
/// 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<Ui>, tile: i32) {
|
|||||||
schedule_relayout(ui);
|
schedule_relayout(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the day sections from the flat photo model. The timeline arrives
|
/// Rebuild the ListView's row model from the flat photo model: each day becomes
|
||||||
/// newest-first, so photos of the same day are already contiguous — one pass
|
/// a heading row followed by its grid rows, laid out to the current width and
|
||||||
/// splits them.
|
/// zoom.
|
||||||
///
|
///
|
||||||
/// The groups are diffed into the existing store rather than replacing it: a
|
/// The rows are diffed into the existing store rather than replacing it: a "load
|
||||||
/// "load more" only really changes the last day (the one the new page continues)
|
/// more" only really changes the last day (the one the new page continues) and
|
||||||
/// and appends after it, and clearing the store instead would scroll the user
|
/// appends after it, and clearing the store instead would scroll the user back
|
||||||
/// back to the top of the timeline at the exact moment they asked for more.
|
/// to the top of the timeline at the exact moment they asked for more.
|
||||||
pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
||||||
let groups = group_photos(&ui.gallery.model);
|
let rows = build_rows(ui);
|
||||||
let store = &ui.gallery.groups;
|
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 i = i as u32;
|
||||||
let unchanged = store
|
let unchanged = store
|
||||||
.item(i)
|
.item(i)
|
||||||
.and_downcast::<BoxedAnyObject>()
|
.and_downcast::<BoxedAnyObject>()
|
||||||
.is_some_and(|old| {
|
.is_some_and(|old| row.same_as(&old.borrow::<GalleryRow>()));
|
||||||
let old = old.borrow::<PhotoGroup>();
|
|
||||||
old.heading == group.heading && old.photos.len() == group.photos.len()
|
|
||||||
});
|
|
||||||
if unchanged {
|
if unchanged {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let boxed = BoxedAnyObject::new(PhotoGroup {
|
let boxed = BoxedAnyObject::new(clone_row(row));
|
||||||
heading: group.heading.clone(),
|
|
||||||
photos: group.photos.clone(),
|
|
||||||
});
|
|
||||||
if i < store.n_items() {
|
if i < store.n_items() {
|
||||||
store.splice(i, 1, &[boxed]);
|
store.splice(i, 1, &[boxed]);
|
||||||
} else {
|
} else {
|
||||||
store.append(&boxed);
|
store.append(&boxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Photos only ever get appended, so a shorter model means a fresh load.
|
if store.n_items() > rows.len() as u32 {
|
||||||
if store.n_items() > groups.len() as u32 {
|
let len = rows.len() as u32;
|
||||||
let len = groups.len() as u32;
|
|
||||||
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
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<Ui>) -> Vec<GalleryRow> {
|
||||||
|
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<Ui>) {
|
||||||
let loaded = ui.gallery.model.n_items() as usize;
|
let loaded = ui.gallery.model.n_items() as usize;
|
||||||
ui.gallery.subtitle.set_visible(loaded > 0);
|
ui.gallery.subtitle.set_visible(loaded > 0);
|
||||||
// An album counts what the server says it holds, not how much of it has been
|
// An album counts what the server says it holds, not how much of it has been
|
||||||
|
|||||||
@@ -309,6 +309,17 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
if let Some(badge) = role_badge(&entry.role) {
|
if let Some(badge) = role_badge(&entry.role) {
|
||||||
row.add_suffix(&badge);
|
row.add_suffix(&badge);
|
||||||
}
|
}
|
||||||
|
// The "shared by" in the subtitle is what the invitation claims. When its
|
||||||
|
// signature does not check out against that person's keys, the claim must
|
||||||
|
// not read as fact — someone may be impersonating a contact to get a file
|
||||||
|
// opened.
|
||||||
|
if entry.shared_by_unverified {
|
||||||
|
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||||
|
warning.add_css_class("warning");
|
||||||
|
warning.set_valign(gtk4::Align::Center);
|
||||||
|
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP));
|
||||||
|
row.add_suffix(&warning);
|
||||||
|
}
|
||||||
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||||
let ui_act = ui.clone();
|
let ui_act = ui.clone();
|
||||||
let uid = entry.uid.clone();
|
let uid = entry.uid.clone();
|
||||||
@@ -329,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A shared row's subtitle: its size (files only), and where it can be reached
|
/// Tooltip on the warning shown next to a share whose invitation did not verify.
|
||||||
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
|
||||||
/// it. An unlisted share has no local path yet, and says nothing rather than
|
It may not really be from them.";
|
||||||
/// pointing at a directory that is not there.
|
|
||||||
|
/// A shared row's subtitle: who shared it and when (share roots only — that is
|
||||||
|
/// where the invitation lives), its size (files only), and where it can be
|
||||||
|
/// reached in the local mount once the synthetic `Shared with me/` directory has
|
||||||
|
/// interned it. An unlisted share has no local path yet, and says nothing rather
|
||||||
|
/// than pointing at a directory that is not there.
|
||||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if !entry.shared_by.is_empty() {
|
||||||
|
let date = if entry.shared_at > 0 {
|
||||||
|
format_modified(entry.shared_at)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
parts.push(if date.is_empty() {
|
||||||
|
format!("Shared by {}", entry.shared_by)
|
||||||
|
} else {
|
||||||
|
format!("Shared by {} on {date}", entry.shared_by)
|
||||||
|
});
|
||||||
|
}
|
||||||
if !entry.is_dir {
|
if !entry.is_dir {
|
||||||
parts.push(human_bytes(entry.size));
|
parts.push(human_bytes(entry.size));
|
||||||
}
|
}
|
||||||
@@ -723,9 +751,25 @@ mod tests {
|
|||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
role: role.into(),
|
role: role.into(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_root_says_who_shared_it() {
|
||||||
|
let mut root = entry(true, "", "viewer");
|
||||||
|
root.shared_by = "alice@proton.me".into();
|
||||||
|
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me");
|
||||||
|
root.shared_at = 1_700_000_000;
|
||||||
|
let subtitle = shared_row_subtitle(&root);
|
||||||
|
assert!(
|
||||||
|
subtitle.starts_with("Shared by alice@proton.me on "),
|
||||||
|
"{subtitle}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_share_with_no_local_path_says_nothing_about_one() {
|
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||||
// Before the synthetic directory has been listed, the daemon cannot
|
// Before the synthetic directory has been listed, the daemon cannot
|
||||||
|
|||||||
@@ -293,5 +293,8 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
// Shared *by* me: I own it, so there is no role of mine to report.
|
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+15
-172
@@ -1,26 +1,23 @@
|
|||||||
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
|
//! `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
|
//! 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
|
//! round-trip and then exits, so there is no main loop to keep off. Talking to
|
||||||
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
|
//! the daemon and opening a chosen file are [`crate::query`]'s, shared with the
|
||||||
//! activation policy, so the two front ends cannot drift on what "open" means.
|
//! 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
|
//! 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
|
//! 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
|
//! and accepts free text, and any text that matches no entry becomes the next
|
||||||
//! search. Escape (an empty selection) ends it.
|
//! 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 std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
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::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;
|
||||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
use crate::query;
|
||||||
|
|
||||||
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||||
/// without it a scripted (non-interactive) menu could spin forever.
|
/// without it a scripted (non-interactive) menu could spin forever.
|
||||||
@@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
|||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mountpoint = match request(&socket, Request::Status)? {
|
let mountpoint = query::mountpoint(&socket, &dirs);
|
||||||
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
|
|
||||||
_ => dirs.default_mountpoint(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut query = options
|
let mut query = options
|
||||||
.query
|
.query
|
||||||
@@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
|||||||
.filter(|q| !q.is_empty());
|
.filter(|q| !q.is_empty());
|
||||||
for _ in 0..MAX_ROUNDS {
|
for _ in 0..MAX_ROUNDS {
|
||||||
let hits = match &query {
|
let hits = match &query {
|
||||||
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
|
Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?,
|
||||||
None => pins(&socket)?,
|
None => query::pins(&socket)?,
|
||||||
};
|
};
|
||||||
let items: Vec<MenuItem> = if hits.is_empty() {
|
let items: Vec<MenuItem> = if hits.is_empty() {
|
||||||
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
||||||
@@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
|||||||
query = None;
|
query = None;
|
||||||
continue;
|
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.
|
// No entry matched what was typed: treat it as the next query.
|
||||||
MenuChoice::Custom(text) => query = Some(text),
|
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<Vec<Hit>, 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<Vec<Hit>, 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<Hit> = 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
|
/// 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
|
/// is matched back, and two files of the same name in the same folder listing
|
||||||
/// would otherwise be indistinguishable.
|
/// would otherwise be indistinguishable.
|
||||||
@@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
|
|||||||
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
||||||
suffix += 1;
|
suffix += 1;
|
||||||
}
|
}
|
||||||
items.push(MenuItem::new(label, Some(icon_name(hit))));
|
items.push(MenuItem::new(label, Some(query::icon_name(hit))));
|
||||||
}
|
}
|
||||||
items
|
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<Response, String> {
|
|
||||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pdfs_core::control::LocalHit;
|
use pdfs_core::control::{LocalHit, SearchHit};
|
||||||
|
|
||||||
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
||||||
Hit::Drive(SearchHit {
|
Hit::Drive(SearchHit {
|
||||||
@@ -331,18 +188,4 @@ mod tests {
|
|||||||
assert!(placeholder(None).contains("type to search"));
|
assert!(placeholder(None).contains("type to search"));
|
||||||
assert!(placeholder(Some("md")).contains("md"));
|
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 <TEXT>`: 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<String>,
|
||||||
|
/// 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<String>) -> 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<String> {
|
||||||
|
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<String>) -> 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<Hit> {
|
||||||
|
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<String> {
|
||||||
|
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<String> = [
|
||||||
|
"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<Hit> {
|
||||||
|
let (_, payload) = line.trim_end_matches('\n').rsplit_once(FIELD)?;
|
||||||
|
serde_json::from_str(payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_exe() -> Result<PathBuf, String> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith};
|
|||||||
|
|
||||||
mod activation;
|
mod activation;
|
||||||
mod dmenu;
|
mod dmenu;
|
||||||
|
mod fzf;
|
||||||
|
mod query;
|
||||||
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
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
|
/// 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
|
/// — 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.
|
/// local file is already on disk — but they share one keyboard cursor.
|
||||||
#[derive(Clone)]
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||||
enum Hit {
|
enum Hit {
|
||||||
Drive(SearchHit),
|
Drive(SearchHit),
|
||||||
Local(LocalHit),
|
Local(LocalHit),
|
||||||
@@ -341,12 +343,20 @@ Options:
|
|||||||
rofi, …) instead of the built-in GTK window. Also
|
rofi, …) instead of the built-in GTK window. Also
|
||||||
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
||||||
in config.json.
|
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.
|
--gtk Force the built-in window, overriding that setting.
|
||||||
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
||||||
--menu 'fuzzel --dmenu --width 60'. Overrides
|
--menu 'fuzzel --dmenu --width 60'. Overrides
|
||||||
\"prompt\": { \"menu\": [...] }.
|
\"prompt\": { \"menu\": [...] }.
|
||||||
--query <TEXT> Search for TEXT immediately instead of opening on the
|
--query <TEXT> Search for TEXT immediately instead of opening on the
|
||||||
pinned-files list (--dmenu only).
|
pinned-files list (--dmenu and --fzf only).
|
||||||
|
--feed <TEXT> 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.
|
-h, --help Show this help.
|
||||||
";
|
";
|
||||||
|
|
||||||
@@ -356,6 +366,12 @@ struct Args {
|
|||||||
mode: Option<PromptMode>,
|
mode: Option<PromptMode>,
|
||||||
menu: Option<Vec<String>>,
|
menu: Option<Vec<String>>,
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
|
/// `--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<Option<String>>,
|
||||||
|
/// `--inner`: we are the process a spawned terminal is running.
|
||||||
|
inner: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||||
@@ -363,12 +379,19 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
|||||||
mode: None,
|
mode: None,
|
||||||
menu: None,
|
menu: None,
|
||||||
query: None,
|
query: None,
|
||||||
|
feed: None,
|
||||||
|
inner: false,
|
||||||
};
|
};
|
||||||
let mut argv = argv.peekable();
|
let mut argv = argv.peekable();
|
||||||
while let Some(arg) = argv.next() {
|
while let Some(arg) = argv.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
||||||
|
"--fzf" => args.mode = Some(PromptMode::Fzf),
|
||||||
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
"--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
|
// A launcher command is one shell-ish string so it can live in a
|
||||||
// keybinding; splitting on whitespace is enough for flags, and a
|
// keybinding; splitting on whitespace is enough for flags, and a
|
||||||
// launcher argument needing spaces belongs in config.json.
|
// 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
|
// The stored mode makes an existing keybinding switch front ends without
|
||||||
// being re-bound; an explicit flag still wins.
|
// being re-bound; an explicit flag still wins.
|
||||||
let stored = AppDirs::new()
|
let stored = AppDirs::new()
|
||||||
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
|
let launcher = match args.mode.unwrap_or(stored) {
|
||||||
return match dmenu::run(dmenu::Options {
|
PromptMode::Dmenu => Some(dmenu::run(dmenu::Options {
|
||||||
menu: args.menu,
|
menu: args.menu,
|
||||||
query: args.query,
|
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,
|
Ok(()) => glib::ExitCode::SUCCESS,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
eprintln!("pdfs-prompt: {message}");
|
eprintln!("pdfs-prompt: {message}");
|
||||||
|
|||||||
@@ -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<Vec<Hit>, 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<Vec<Hit>, 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<Hit> = 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<Response, String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2650,6 +2650,16 @@ collisions without losing the source. Simple rename-only and move-only calls
|
|||||||
remain synchronous. The managed live suite subsequently passed the replacement
|
remain synchronous. The managed live suite subsequently passed the replacement
|
||||||
and combined move/rename cases.
|
and combined move/rename cases.
|
||||||
|
|
||||||
|
**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the
|
||||||
|
move and the rename as one `move-multiple` request carrying the target name,
|
||||||
|
instead of renaming in the source folder and then moving. That removes the
|
||||||
|
second call the stale requirements could hit, and the SDK retries a remaining
|
||||||
|
`InvalidRequirements` once with the server's current name hash. A destination
|
||||||
|
name collision now moves under a conflict name in one step. Same-folder renames
|
||||||
|
are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames
|
||||||
|
then moves with its bounded retry and has not been converted. Needs a managed
|
||||||
|
live run of the combined move/rename cases.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
|
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
|
||||||
|
|||||||
@@ -9,6 +9,183 @@ 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
|
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).
|
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
Nothing yet.
|
||||||
|
|
||||||
|
## [1.10.0] — 2026-09-15
|
||||||
|
|
||||||
|
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
|
||||||
|
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Shared with me says who shared an item, and when.** The GUI row subtitle and `pdfs
|
||||||
|
shared-with-me` show the inviter and invite date for a share root. The invitation's signature is
|
||||||
|
checked against the claimed inviter's keys; when it does not verify, the GUI shows a warning
|
||||||
|
and the CLI prints one, since the sender may be forged. `DirEntry` gains `shared_by`,
|
||||||
|
`shared_at` and `shared_by_unverified` (defaulted, so older front-ends and daemons interoperate).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A queued move-and-rename lands as one request.** `drain_rename` used to rename in the source
|
||||||
|
folder and then move, with a rename-out-of-the-way step when the destination held the name. It
|
||||||
|
now calls `move-multiple` with a target name, so there is no half-applied state between the two
|
||||||
|
halves and no second call for the encrypted-name requirements to go stale against (B46). A
|
||||||
|
rename that stays in the same folder still uses the rename endpoint.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Moving a node with no signature email no longer fails.** Such nodes (created anonymously, e.g.
|
||||||
|
through a public link) need the passphrase signed by the mover; SDK 0.6.3 sends that signature.
|
||||||
|
- **A node trashed through the newer API field is recognised as trashed.** The SDK now reads
|
||||||
|
`TrashTime` as well as the legacy `Trashed` field, so the drain no longer tries to rename a node
|
||||||
|
that is already in the trash.
|
||||||
|
|
||||||
|
## [1.9.3] — 2026-09-01
|
||||||
|
|
||||||
|
The daemon's wait-for-login loop, and the latency that fixing it introduced. No schema change.
|
||||||
|
Schema: **28**; SDK unchanged at `proton-sdk` / `proton-drive-rs` **0.6.2**.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **An enabled service with no saved session no longer polls the keyring 28,800 times a day.**
|
||||||
|
`wait_for_session` slept a flat 3 s between checks, and a check is not just a sleep — `auth::load`
|
||||||
|
builds a fresh `keyring::Entry`, so every iteration opened a new D-Bus connection to the Secret
|
||||||
|
Service and negotiated a session on it. The wait now backs off, and "not logged in" is said once
|
||||||
|
at `info` instead of on every pass, which also stops the journal filling with tens of thousands
|
||||||
|
of identical lines (`#21`).
|
||||||
|
- **Signing in wakes the parked daemon instead of leaving the mount up to an interval late.** The
|
||||||
|
backoff had no wake-up path: a unit started at boot reaches the ceiling within ~90 s, and
|
||||||
|
`enable --now` is a no-op on an already-active unit, so the GUI login and the tray's Connect left
|
||||||
|
it sleeping. `service::enable_start` now runs `enable` + `restart`, which interrupts the sleep and
|
||||||
|
still starts a unit that is down, and `pdfs login` restarts the unit when it is already active —
|
||||||
|
deliberately without touching `enable`, so a headless setup driving `pdfs daemon` by hand is left
|
||||||
|
alone. The ceiling also drops from 60 s to 15 s, still an 80% cut in keyring/D-Bus wakeups
|
||||||
|
(28,800/day → 5,760/day), bounding the worst case for paths that never reach systemd.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The wait is extracted into `wait_for_session()`, taking the load and sleep calls as parameters,
|
||||||
|
so the schedule is testable without a keyring and without real time passing.
|
||||||
|
|
||||||
|
## [1.9.2] — 2026-08-28
|
||||||
|
|
||||||
|
Repair for photos a Takeout import filed under the day they were imported, an `fzf` launcher front
|
||||||
|
end, and a gallery that survives 1,600 photos on one date. No schema change. Schema: **28**; SDK
|
||||||
|
`proton-sdk` / `proton-drive-rs` **0.6.2**.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
repository's contribution/branding files. No schema change. Schema: **28**; SDK unchanged at
|
||||||
|
`proton-sdk` / `proton-drive-rs` **0.6.1**.
|
||||||
|
|
||||||
|
`exiftool` (`libimage-exiftool-perl` / `perl-image-exiftool` / `perl-Image-ExifTool`) is a new
|
||||||
|
runtime dependency, declared in the `.deb`, `.rpm` and `PKGBUILD` packaging. It is only used to
|
||||||
|
pull the embedded preview out of a camera RAW; without it every other image format still gets a
|
||||||
|
thumbnail.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Thumbnails for ordinary Drive files**, not just the Photos timeline — painted in Files (both
|
||||||
|
the grid and the Name column of the list), Shared, Shared by me, and Trash. Standard raster
|
||||||
|
formats (`bmp`, `gif`, `jpeg`, `png`, `tiff`, `webp`) are decoded in the daemon; camera RAWs
|
||||||
|
(`arw`, `cr2`, `cr3`, `dng`, `nef`, `orf`, `raf`, `rw2`, and the rest of the concrete list) are
|
||||||
|
served from their embedded preview via `exiftool`. Previews are cached beside the existing photo
|
||||||
|
thumbnails, outside the cache-budget scan, and tagged with the node's modification time — so a
|
||||||
|
new revision of an image can never be shown with its predecessor's thumbnail.
|
||||||
|
- Thumbnails for visible rows are requested opportunistically as tiles scroll in, and the daemon
|
||||||
|
drops work for listings the user has already navigated away from. The listing generation is
|
||||||
|
issued by the daemon (`ReserveFileThumbGeneration`), not seeded from the front-end's clock.
|
||||||
|
- **Build a whole folder tree ahead of time** from the Files toolbar: one recursive local-thumbnail
|
||||||
|
job per daemon, with live scan/found/completed/failed progress and a Cancel that appears only
|
||||||
|
once the daemon has acknowledged the start. The job outlives navigation — it is deliberate work,
|
||||||
|
separate from the opportunistic per-tile requests.
|
||||||
|
- **A Dolphin-style status bar under the Files browser**: the current listing's counts, a Zoom
|
||||||
|
slider for the icon grid, and account storage usage. Zoom resizes the realised grid cells rather
|
||||||
|
than rebuilding the model, so dragging the slider does not tear down the selection; the default
|
||||||
|
size reproduces the previous fixed 72 px tiles. Capacity stays out of the bar until a real
|
||||||
|
reading lands, and the quota fetch (60 s TTL) now also runs while Files is on screen instead of
|
||||||
|
only in Settings.
|
||||||
|
- Repository housekeeping: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull
|
||||||
|
request templates, a project logo, and workspace crate metadata (`repository`, `homepage`,
|
||||||
|
`readme`, `keywords`, `categories`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A remote thumbnail miss no longer suppresses local generation. The two negative results are now
|
||||||
|
held in separate bounded caches: "Proton has no thumbnail for this node" and "this exact revision
|
||||||
|
was decoded locally and could not be read" mean different things, and only the second is a
|
||||||
|
permanent verdict for that revision.
|
||||||
|
- A RAW whose preview could not be extracted because of the *environment* — `exiftool` missing, a
|
||||||
|
failed download — is retried instead of being remembered as undecodable. Only bytes conclusively
|
||||||
|
outside the supported formats are cached as a negative.
|
||||||
|
- A recursive build that finds a uid an opportunistic tile job already owns waits briefly for it
|
||||||
|
rather than either duplicating the work or blocking on it forever, and a cancellation racing a
|
||||||
|
waiter's registration is caught by a backstop instead of leaving the waiter parked.
|
||||||
|
- Starting a build for a root that is already building attaches to that job; starting one for a
|
||||||
|
*different* root is refused explicitly rather than silently reporting the running job's progress
|
||||||
|
as the caller's.
|
||||||
|
- Decrypted camera RAWs are staged for `exiftool` in an owner-only (`0700`) directory of their own,
|
||||||
|
removed as soon as the preview is out and swept at cache open after an unclean shutdown.
|
||||||
|
- The RAW extension list had `rw` (not a format) and was missing `raw`; both classification helpers
|
||||||
|
now read from one shared table instead of two hand-maintained `matches!` arms.
|
||||||
|
- The tray and desktop-entry icons were wrong/missing; `.desktop` files and the packaged SVG were
|
||||||
|
corrected.
|
||||||
|
|
||||||
## [1.9.0] — 2026-08-18
|
## [1.9.0] — 2026-08-18
|
||||||
|
|
||||||
Google Photos import, and a pass over the desktop app's interaction gaps. No schema change.
|
Google Photos import, and a pass over the desktop app's interaction gaps. No schema change.
|
||||||
@@ -501,6 +678,10 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
|
||||||
|
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
|
||||||
|
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
|
||||||
|
[1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0
|
||||||
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
|
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
|
||||||
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
|
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
|
||||||
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
|
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
|
||||||
|
|||||||
+4
-2
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.9.1
|
pkgver=1.10.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -20,7 +20,9 @@ makedepends=('cargo' 'git')
|
|||||||
provides=('pdfs')
|
provides=('pdfs')
|
||||||
# Files-on-demand mount + keyring need these at runtime.
|
# Files-on-demand mount + keyring need these at runtime.
|
||||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
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')
|
options=('!lto' '!debug')
|
||||||
|
|
||||||
# Built in place from the checkout; nothing to fetch or checksum.
|
# Built in place from the checkout; nothing to fetch or checksum.
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ Recommends: gnome-keyring
|
|||||||
Recommends: gnome-shell-extension-appindicator
|
Recommends: gnome-shell-extension-appindicator
|
||||||
Recommends: kwallet
|
Recommends: kwallet
|
||||||
|
|
||||||
|
# Only for `pdfs-prompt --fzf`; every other front end works without them.
|
||||||
|
Suggests: fzf
|
||||||
|
Suggests: foot
|
||||||
|
|
||||||
Provides: pdfs = %{version}-%{release}
|
Provides: pdfs = %{version}-%{release}
|
||||||
|
|
||||||
%description
|
%description
|
||||||
|
|||||||
Reference in new issue
Block a user