mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-15 17:41:59 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d445bb02c |
No files matched your search
Generated
+8
-51
@@ -1114,12 +1114,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "data-encoding"
|
|
||||||
version = "2.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbl"
|
name = "dbl"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2908,32 +2902,6 @@ dependencies = [
|
|||||||
"scopeguard",
|
"scopeguard",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lofty"
|
|
||||||
version = "0.25.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "35d45c446382e04663705aa8e786157f64ef1fa85755d410121ffbf08f54b0db"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"data-encoding",
|
|
||||||
"flate2",
|
|
||||||
"lofty_attr",
|
|
||||||
"log",
|
|
||||||
"ogg_pager",
|
|
||||||
"paste",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lofty_attr"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.35"
|
version = "0.4.35"
|
||||||
@@ -3355,15 +3323,6 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ogg_pager"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "166611b491f939446e64c6be930843a31eb8e6798204ec3e9f57e084c18e7446"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -3582,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3599,7 +3558,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3627,7 +3586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3635,12 +3594,10 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
"lofty",
|
|
||||||
"notify",
|
"notify",
|
||||||
"parking_lot 0.13.0",
|
"parking_lot 0.13.0",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
"proton-drive-rs",
|
"proton-drive-rs",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3651,7 +3608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.8.0"
|
version = "3.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3891,9 +3848,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.12.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4265c2cf3bdc59ee273d7363c8b6a7cb4e98be6102ffa18144c4d8ebda00737b"
|
checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3919,9 +3876,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.12.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fb98ab3e336ed95f82368a50348dad00e5fc919e982d748e83e844afcadbd5d8"
|
checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+3
-7
@@ -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 = "3.8.0"
|
version = "3.7.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.12.0" }
|
proton-sdk = { version = "0.11.0" }
|
||||||
proton-drive-rs = { version = "0.12.0" }
|
proton-drive-rs = { version = "0.11.0" }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
@@ -50,10 +50,6 @@ sha1 = "0.11"
|
|||||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
# Cover pictures of songs, read from their tags (ID3, FLAC, Ogg, MP4, APE) for
|
|
||||||
# the song's thumbnail. Its default feature is required: 0.25 does not build
|
|
||||||
# without it.
|
|
||||||
lofty = "0.25"
|
|
||||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||||
|
|||||||
+38
-180
@@ -228,19 +228,14 @@ enum Command {
|
|||||||
/// numbered parts, and a photo's metadata often sits in a different part
|
/// numbered parts, and a photo's metadata often sits in a different part
|
||||||
/// than the photo. Albums in the export are recreated as Proton albums, and
|
/// than the photo. Albums in the export are recreated as Proton albums, and
|
||||||
/// photos already on the account (same name and same content) are skipped,
|
/// photos already on the account (same name and same content) are skipped,
|
||||||
/// so an interrupted import can simply be run again, or resumed with
|
/// so an interrupted import can simply be run again.
|
||||||
/// `--resume` without naming the archives again.
|
|
||||||
ImportGooglePhotos {
|
ImportGooglePhotos {
|
||||||
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
||||||
#[arg(required_unless_present = "resume", value_hint = ValueHint::FilePath)]
|
#[arg(required = true, value_hint = ValueHint::FilePath)]
|
||||||
archives: Vec<PathBuf>,
|
archives: Vec<PathBuf>,
|
||||||
/// Report what would be imported without uploading anything.
|
/// Report what would be imported without uploading anything.
|
||||||
#[arg(long, conflicts_with = "resume")]
|
#[arg(long)]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
/// Start the last import again with the same archives, after it was
|
|
||||||
/// interrupted, stopped or failed.
|
|
||||||
#[arg(long, conflicts_with = "archives")]
|
|
||||||
resume: bool,
|
|
||||||
/// Stay attached and print progress until the import finishes.
|
/// Stay attached and print progress until the import finishes.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wait: bool,
|
wait: bool,
|
||||||
@@ -1035,9 +1030,8 @@ fn main() -> Result<()> {
|
|||||||
Command::ImportGooglePhotos {
|
Command::ImportGooglePhotos {
|
||||||
archives,
|
archives,
|
||||||
dry_run,
|
dry_run,
|
||||||
resume,
|
|
||||||
wait,
|
wait,
|
||||||
} => cmd_import_google_photos(archives, dry_run, resume, wait),
|
} => cmd_import_google_photos(archives, dry_run, wait),
|
||||||
Command::RedatePhotos {
|
Command::RedatePhotos {
|
||||||
dry_run,
|
dry_run,
|
||||||
limit,
|
limit,
|
||||||
@@ -2922,34 +2916,24 @@ fn photo_count(n: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_import_google_photos(
|
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
||||||
archives: Vec<PathBuf>,
|
// The daemon opens the archives itself off the shared filesystem, so it gets
|
||||||
dry_run: bool,
|
// absolute paths — the same arrangement `pdfs upload` uses.
|
||||||
resume: bool,
|
let mut absolute = Vec::with_capacity(archives.len());
|
||||||
wait: bool,
|
for archive in &archives {
|
||||||
) -> Result<()> {
|
let path = std::fs::canonicalize(archive)
|
||||||
let request = if resume {
|
.with_context(|| format!("resolve {}", archive.display()))?;
|
||||||
CtlRequest::ResumeImport
|
absolute.push(
|
||||||
} else {
|
path.to_str()
|
||||||
// The daemon opens the archives itself off the shared filesystem, so it
|
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?
|
||||||
// gets absolute paths — the same arrangement `pdfs upload` uses.
|
.to_owned(),
|
||||||
let mut absolute = Vec::with_capacity(archives.len());
|
);
|
||||||
for archive in &archives {
|
}
|
||||||
let path = std::fs::canonicalize(archive)
|
|
||||||
.with_context(|| format!("resolve {}", archive.display()))?;
|
|
||||||
absolute.push(
|
|
||||||
path.to_str()
|
|
||||||
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?
|
|
||||||
.to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
CtlRequest::ImportTakeout {
|
|
||||||
archives: absolute,
|
|
||||||
dry_run,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match control_request(request)? {
|
match control_request(CtlRequest::ImportTakeout {
|
||||||
|
archives: absolute,
|
||||||
|
dry_run,
|
||||||
|
})? {
|
||||||
CtlResponse::Ok { message } => println!("{message}"),
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2961,34 +2945,20 @@ fn cmd_import_google_photos(
|
|||||||
|
|
||||||
// Poll rather than stream: the control protocol is request/response, and an
|
// Poll rather than stream: the control protocol is request/response, and an
|
||||||
// import runs for hours — a held-open socket would be the only long-lived
|
// import runs for hours — a held-open socket would be the only long-lived
|
||||||
// connection the daemon has. A line is printed when the step changes and
|
// connection the daemon has.
|
||||||
// every few percent of it, not on every poll.
|
|
||||||
let mut last_line = String::new();
|
|
||||||
let mut last_printed = std::time::Instant::now();
|
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
match control_request(CtlRequest::ImportStatus)? {
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
CtlResponse::ImportStatus {
|
CtlResponse::ImportStatus {
|
||||||
running: true,
|
running: true,
|
||||||
progress,
|
summary: _,
|
||||||
..
|
} => continue,
|
||||||
} => {
|
CtlResponse::ImportStatus { summary, .. } => {
|
||||||
let line = progress.as_ref().map(progress_line).unwrap_or_default();
|
print_import_summary(summary.as_ref());
|
||||||
let step_changed = line.split(':').next() != last_line.split(':').next();
|
return finished_run(
|
||||||
if !line.is_empty()
|
"the import",
|
||||||
&& line != last_line
|
summary.map(|s| (s.failed, s.cancelled, s.error)),
|
||||||
&& (step_changed || last_printed.elapsed().as_secs() >= 30)
|
);
|
||||||
{
|
|
||||||
println!("{line}");
|
|
||||||
last_line = line;
|
|
||||||
last_printed = std::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CtlResponse::ImportStatus {
|
|
||||||
summary, resumable, ..
|
|
||||||
} => {
|
|
||||||
print_import_summary(summary.as_ref(), &resumable);
|
|
||||||
return import_outcome(summary.as_ref());
|
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -2996,47 +2966,6 @@ fn cmd_import_google_photos(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line saying where a running import is.
|
|
||||||
fn progress_line(progress: &pdfs_core::control::ImportProgress) -> String {
|
|
||||||
use pdfs_core::control::ImportPhase;
|
|
||||||
let step = match progress.phase {
|
|
||||||
ImportPhase::Scanning => "Reading archives",
|
|
||||||
ImportPhase::Checking => "Checking for photos already on Proton",
|
|
||||||
ImportPhase::Uploading => "Uploading",
|
|
||||||
ImportPhase::Albums => "Filing photos into albums",
|
|
||||||
};
|
|
||||||
match (progress.total, &progress.current) {
|
|
||||||
(0, _) => format!("{step}…"),
|
|
||||||
(total, Some(name)) => format!("{step}: {} of {total} ({name})", progress.done),
|
|
||||||
(total, None) => format!("{step}: {} of {total}", progress.done),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How an import `--wait` followed ended, as the exit status: an error unless
|
|
||||||
/// it did everything it set out to do, so a script can tell.
|
|
||||||
fn import_outcome(summary: Option<&pdfs_core::control::ImportSummary>) -> Result<()> {
|
|
||||||
let Some(summary) = summary else {
|
|
||||||
bail!("the import ended without a result; the service may have restarted");
|
|
||||||
};
|
|
||||||
if let Some(error) = &summary.error {
|
|
||||||
bail!("the import failed: {error}");
|
|
||||||
}
|
|
||||||
if summary.interrupted {
|
|
||||||
bail!("the import was interrupted; run `pdfs import-google-photos --resume` to continue");
|
|
||||||
}
|
|
||||||
if summary.cancelled {
|
|
||||||
bail!("the import was cancelled");
|
|
||||||
}
|
|
||||||
if summary.failed > 0 || !summary.unreadable_archives.is_empty() {
|
|
||||||
bail!(
|
|
||||||
"the import finished, but {} photo(s) and {} archive(s) failed",
|
|
||||||
summary.failed,
|
|
||||||
summary.unreadable_archives.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_redate_photos(
|
fn cmd_redate_photos(
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
@@ -3171,19 +3100,11 @@ fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) {
|
|||||||
|
|
||||||
fn cmd_import_status() -> Result<()> {
|
fn cmd_import_status() -> Result<()> {
|
||||||
match control_request(CtlRequest::ImportStatus)? {
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
CtlResponse::ImportStatus {
|
CtlResponse::ImportStatus { running, summary } => {
|
||||||
running,
|
|
||||||
summary,
|
|
||||||
progress,
|
|
||||||
resumable,
|
|
||||||
} => {
|
|
||||||
if running {
|
if running {
|
||||||
match progress.as_ref() {
|
println!("Import running. `pdfs transfers` shows its progress.");
|
||||||
Some(progress) => println!("Import running. {}", progress_line(progress)),
|
|
||||||
None => println!("Import running."),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
print_import_summary(summary.as_ref(), &resumable);
|
print_import_summary(summary.as_ref());
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -3200,28 +3121,18 @@ fn cmd_cancel_import() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>, resumable: &[String]) {
|
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
|
||||||
let Some(summary) = summary else {
|
let Some(summary) = summary else {
|
||||||
println!("No import has run yet.");
|
println!("No import has run yet.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if summary.dry_run {
|
|
||||||
println!("Scan only; nothing was uploaded.");
|
|
||||||
}
|
|
||||||
if let Some(error) = &summary.error {
|
if let Some(error) = &summary.error {
|
||||||
println!("Stopped by : {error}");
|
println!("Failed : {error}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
println!("Photos in export : {}", summary.found);
|
println!("Photos in export : {}", summary.found);
|
||||||
if summary.dry_run {
|
println!("Uploaded : {}", summary.uploaded);
|
||||||
println!("Would upload : {}", summary.to_upload);
|
|
||||||
println!("Bytes to upload : {}", summary.to_upload_bytes);
|
|
||||||
} else {
|
|
||||||
println!("Uploaded : {}", summary.uploaded);
|
|
||||||
}
|
|
||||||
println!("Already on Proton: {}", summary.duplicates);
|
println!("Already on Proton: {}", summary.duplicates);
|
||||||
if summary.live_photos > 0 {
|
|
||||||
println!("Live photos : {}", summary.live_photos);
|
|
||||||
}
|
|
||||||
if summary.skipped_trashed > 0 {
|
if summary.skipped_trashed > 0 {
|
||||||
println!("Skipped (trash) : {}", summary.skipped_trashed);
|
println!("Skipped (trash) : {}", summary.skipped_trashed);
|
||||||
}
|
}
|
||||||
@@ -3234,20 +3145,12 @@ fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>, res
|
|||||||
if summary.failed > 0 {
|
if summary.failed > 0 {
|
||||||
println!("Failed : {}", summary.failed);
|
println!("Failed : {}", summary.failed);
|
||||||
}
|
}
|
||||||
for archive in &summary.unreadable_archives {
|
|
||||||
println!("Unreadable : {archive}");
|
|
||||||
}
|
|
||||||
if summary.bytes > 0 {
|
if summary.bytes > 0 {
|
||||||
println!("Uploaded bytes : {}", summary.bytes);
|
println!("Uploaded bytes : {}", summary.bytes);
|
||||||
}
|
}
|
||||||
if summary.interrupted {
|
if summary.cancelled {
|
||||||
println!("The service stopped before the import finished.");
|
|
||||||
} else if summary.cancelled {
|
|
||||||
println!("Import was cancelled before it finished.");
|
println!("Import was cancelled before it finished.");
|
||||||
}
|
}
|
||||||
if !resumable.is_empty() {
|
|
||||||
println!("Run `pdfs import-google-photos --resume` to continue it.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
||||||
@@ -4606,51 +4509,6 @@ mod login_wait_tests {
|
|||||||
assert!(finished_run("the import", None).is_err());
|
assert!(finished_run("the import", None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_waited_for_import_that_did_not_finish_exits_with_an_error() {
|
|
||||||
use pdfs_core::control::ImportSummary;
|
|
||||||
let done = ImportSummary {
|
|
||||||
uploaded: 4,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(import_outcome(Some(&done)).is_ok());
|
|
||||||
assert!(import_outcome(None).is_err());
|
|
||||||
for broken in [
|
|
||||||
ImportSummary {
|
|
||||||
interrupted: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
ImportSummary {
|
|
||||||
unreadable_archives: vec!["/exports/takeout-002.zip".into()],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
ImportSummary {
|
|
||||||
error: Some("check for existing photos: offline".into()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
] {
|
|
||||||
assert!(import_outcome(Some(&broken)).is_err(), "{broken:?}");
|
|
||||||
}
|
|
||||||
let interrupted = import_outcome(Some(&ImportSummary {
|
|
||||||
interrupted: true,
|
|
||||||
..Default::default()
|
|
||||||
}));
|
|
||||||
assert!(interrupted.unwrap_err().to_string().contains("--resume"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resume_needs_no_archives_and_excludes_them() {
|
|
||||||
let cli = Cli::try_parse_from(["pdfs", "import-google-photos", "--resume"]).unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
cli.command,
|
|
||||||
Command::ImportGooglePhotos { resume: true, .. }
|
|
||||||
));
|
|
||||||
assert!(Cli::try_parse_from(["pdfs", "import-google-photos"]).is_err());
|
|
||||||
assert!(
|
|
||||||
Cli::try_parse_from(["pdfs", "import-google-photos", "--resume", "a.zip"]).is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
||||||
let mut checks_left = 20;
|
let mut checks_left = 20;
|
||||||
|
|||||||
+33
-253
@@ -123,84 +123,13 @@ fn client_config() -> ProtonClientConfiguration {
|
|||||||
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
|
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `op` on the session's keyring entry.
|
fn keyring_entry() -> Result<Entry> {
|
||||||
fn with_keyring<T>(op: impl Fn(&Entry) -> keyring_core::Result<T>) -> Result<T> {
|
// Installed lazily rather than once at startup so a session bus that was not
|
||||||
with_keyring_entry(KEYRING_SERVICE, KEYRING_USER, op)
|
// up yet on the first attempt is retried on the next one.
|
||||||
}
|
if keyring_core::get_default_store().is_none() {
|
||||||
|
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
||||||
/// Run `op` on the entry `service`/`user`, reconnecting once if the Secret
|
|
||||||
/// Service fails the call.
|
|
||||||
///
|
|
||||||
/// The store holds one D-Bus connection and one Secret Service session for the
|
|
||||||
/// whole process. The session lives in the provider's memory: once the provider
|
|
||||||
/// restarts (oo7's unit restarts it on failure, and both oo7 and gnome-keyring
|
|
||||||
/// can be `--replace`d), every read and write on the old session fails with
|
|
||||||
/// `NoSession`, for as long as this process runs. A daemon then keeps refreshed
|
|
||||||
/// tokens only in memory, and a cold boot loses them (`docs/BUGS.md` B285). A
|
|
||||||
/// platform failure therefore drops the store, and the call is made once more
|
|
||||||
/// on a new one. A locked keyring, a dismissed prompt or a missing entry is
|
|
||||||
/// not the connection's fault and comes back as it is.
|
|
||||||
fn with_keyring_entry<T>(
|
|
||||||
service: &str,
|
|
||||||
user: &str,
|
|
||||||
op: impl Fn(&Entry) -> keyring_core::Result<T>,
|
|
||||||
) -> Result<T> {
|
|
||||||
let store = keyring_store()?;
|
|
||||||
match op(&Entry::new(service, user)?) {
|
|
||||||
Err(keyring_core::Error::PlatformFailure(e)) => {
|
|
||||||
tracing::warn!(
|
|
||||||
error = %e,
|
|
||||||
"the Secret Service failed this process's connection; reconnecting"
|
|
||||||
);
|
|
||||||
drop_keyring_store(&store);
|
|
||||||
keyring_store()?;
|
|
||||||
Ok(op(&Entry::new(service, user)?)?)
|
|
||||||
}
|
|
||||||
result => Ok(result?),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Held while the default store is looked at and replaced, so that threads
|
|
||||||
/// failing at the same time drop the broken store once and share its
|
|
||||||
/// replacement.
|
|
||||||
static KEYRING_STORE: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
|
||||||
|
|
||||||
/// The process's Secret Service store, connected now if there is none.
|
|
||||||
///
|
|
||||||
/// Connected lazily rather than once at startup so a session bus that was not
|
|
||||||
/// up yet on the first attempt is tried again on the next one.
|
|
||||||
fn keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
|
|
||||||
let _swap = KEYRING_STORE.lock();
|
|
||||||
if let Some(store) = keyring_core::get_default_store() {
|
|
||||||
return Ok(store);
|
|
||||||
}
|
|
||||||
let store = connect_keyring_store()?;
|
|
||||||
keyring_core::set_default_store(Arc::clone(&store));
|
|
||||||
Ok(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forget `failed`, unless another thread has already replaced it.
|
|
||||||
fn drop_keyring_store(failed: &Arc<keyring_core::CredentialStore>) {
|
|
||||||
let _swap = KEYRING_STORE.lock();
|
|
||||||
if keyring_core::get_default_store().is_some_and(|store| Arc::ptr_eq(&store, failed)) {
|
|
||||||
keyring_core::unset_default_store();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
fn connect_keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
|
|
||||||
Ok(dbus_secret_service_keyring_store::Store::new()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The tests' in-memory store when they set one, so a reconnect finds the
|
|
||||||
/// same entries; the real Secret Service otherwise.
|
|
||||||
#[cfg(test)]
|
|
||||||
fn connect_keyring_store() -> Result<Arc<keyring_core::CredentialStore>> {
|
|
||||||
tests::KEYRING_CONNECTS.fetch_add(1, Ordering::SeqCst);
|
|
||||||
match tests::MOCK_STORE.lock().clone() {
|
|
||||||
Some(store) => Ok(store),
|
|
||||||
None => Ok(dbus_secret_service_keyring_store::Store::new()?),
|
|
||||||
}
|
}
|
||||||
|
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the user answered when asked for the mailbox password.
|
/// What the user answered when asked for the mailbox password.
|
||||||
@@ -492,15 +421,17 @@ fn classify_verification_gate(e: ProtonError) -> Error {
|
|||||||
|
|
||||||
fn write_stored(stored: &StoredSession) -> Result<()> {
|
fn write_stored(stored: &StoredSession) -> Result<()> {
|
||||||
let json = Zeroizing::new(serde_json::to_string(stored)?);
|
let json = Zeroizing::new(serde_json::to_string(stored)?);
|
||||||
with_keyring(|entry| entry.set_password(&json))
|
keyring_entry()?.set_password(&json)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the persisted session blob, or `Error::NotLoggedIn` if absent.
|
/// Load the persisted session blob, or `Error::NotLoggedIn` if absent.
|
||||||
pub fn load() -> Result<StoredSession> {
|
pub fn load() -> Result<StoredSession> {
|
||||||
match with_keyring(|entry| entry.get_password()) {
|
let entry = keyring_entry()?;
|
||||||
|
match entry.get_password() {
|
||||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||||
Err(Error::Keyring(keyring_core::Error::NoEntry)) => Err(Error::NotLoggedIn),
|
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,9 +589,9 @@ pub fn logout() -> Result<bool> {
|
|||||||
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
||||||
}
|
}
|
||||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||||
match with_keyring(|entry| entry.delete_credential()) {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(Error::Keyring(keyring_core::Error::NoEntry)) => Ok(ended),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1065,15 +996,21 @@ impl TokenSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the unsaved rotation, if there is one. A blob that moved on drops
|
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
||||||
/// it. A missing blob is retried: some Secret Service providers also report
|
/// is gone with a logout, drops it: there is nothing it may still go into.
|
||||||
/// locked or temporarily hidden items as absent. Never recreate it here.
|
|
||||||
fn flush(&self) -> Result<()> {
|
fn flush(&self) -> Result<()> {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
let Some(tokens) = state.unsaved.clone() else {
|
let Some(tokens) = state.unsaved.clone() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let mut stored = load()?;
|
let mut stored = match load() {
|
||||||
|
Ok(stored) => stored,
|
||||||
|
Err(Error::NotLoggedIn) => {
|
||||||
|
state.settle();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
match take_rotation(
|
match take_rotation(
|
||||||
&mut stored,
|
&mut stored,
|
||||||
&self.session_id,
|
&self.session_id,
|
||||||
@@ -1276,138 +1213,12 @@ mod tests {
|
|||||||
/// The keyring is one store per process, so the tests that use it,
|
/// The keyring is one store per process, so the tests that use it,
|
||||||
/// through `keyring-core`'s in-memory store, take turns.
|
/// through `keyring-core`'s in-memory store, take turns.
|
||||||
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
||||||
|
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||||
let turn = KEYRING.lock();
|
let turn = KEYRING.lock();
|
||||||
let store: Arc<keyring_core::CredentialStore> = keyring_core::mock::Store::new().unwrap();
|
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
|
||||||
*MOCK_STORE.lock() = Some(Arc::clone(&store));
|
|
||||||
keyring_core::set_default_store(store);
|
|
||||||
turn
|
turn
|
||||||
}
|
}
|
||||||
|
|
||||||
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
|
||||||
|
|
||||||
/// The in-memory store a reconnect hands out, so it finds the same
|
|
||||||
/// entries; `None` connects to the real Secret Service.
|
|
||||||
pub(super) static MOCK_STORE: parking_lot::Mutex<Option<Arc<keyring_core::CredentialStore>>> =
|
|
||||||
parking_lot::Mutex::new(None);
|
|
||||||
|
|
||||||
/// How many times a store was connected.
|
|
||||||
pub(super) static KEYRING_CONNECTS: std::sync::atomic::AtomicUsize =
|
|
||||||
std::sync::atomic::AtomicUsize::new(0);
|
|
||||||
|
|
||||||
/// The session's entry, to make its next call fail.
|
|
||||||
fn keyring_entry() -> Entry {
|
|
||||||
Entry::new(KEYRING_SERVICE, KEYRING_USER).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make the session entry's next call fail with `error`.
|
|
||||||
fn fail_next(error: keyring_core::Error) {
|
|
||||||
keyring_entry()
|
|
||||||
.as_any()
|
|
||||||
.downcast_ref::<keyring_core::mock::Cred>()
|
|
||||||
.unwrap()
|
|
||||||
.set_error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_session() -> keyring_core::Error {
|
|
||||||
keyring_core::Error::PlatformFailure(
|
|
||||||
std::io::Error::other("The session does not exist (NoSession)").into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// B285: a restarted Secret Service no longer knows the process's
|
|
||||||
/// session, and every call on it failed until the process restarted.
|
|
||||||
#[test]
|
|
||||||
fn a_dropped_secret_service_session_is_reconnected_and_retried() {
|
|
||||||
let _turn = mock_keyring();
|
|
||||||
write_stored(&stored_with("r")).unwrap();
|
|
||||||
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
|
|
||||||
|
|
||||||
fail_next(no_session());
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "r");
|
|
||||||
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects + 1);
|
|
||||||
|
|
||||||
fail_next(no_session());
|
|
||||||
write_stored(&stored_with("r2")).unwrap();
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "r2");
|
|
||||||
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_rotation_lands_at_once_after_the_keyring_restarts() {
|
|
||||||
let _turn = mock_keyring();
|
|
||||||
write_stored(&stored_with("r")).unwrap();
|
|
||||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
|
||||||
|
|
||||||
fail_next(no_session());
|
|
||||||
sink.rotated(tokens("a2", "r2")).unwrap();
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "r2");
|
|
||||||
assert!(sink.state.lock().unsaved.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A locked keyring or a dismissed prompt is no fault of the connection.
|
|
||||||
#[test]
|
|
||||||
fn a_locked_keyring_is_not_reconnected() {
|
|
||||||
let _turn = mock_keyring();
|
|
||||||
write_stored(&stored_with("r")).unwrap();
|
|
||||||
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
|
|
||||||
|
|
||||||
fail_next(keyring_core::Error::NoStorageAccess(
|
|
||||||
std::io::Error::other("locked").into(),
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
load(),
|
|
||||||
Err(Error::Keyring(keyring_core::Error::NoStorageAccess(_)))
|
|
||||||
));
|
|
||||||
assert_eq!(KEYRING_CONNECTS.load(Ordering::SeqCst), connects);
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "r");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The real Secret Service, restarted between two calls on one store.
|
|
||||||
///
|
|
||||||
/// Run inside a throwaway session bus, never against the desktop's
|
|
||||||
/// keyring. With gnome-keyring:
|
|
||||||
///
|
|
||||||
/// ```sh
|
|
||||||
/// echo -n pw | gnome-keyring-daemon --unlock --components=secrets
|
|
||||||
/// PDFS_SECRET_SERVICE_RESTART_TEST=1 \
|
|
||||||
/// PDFS_RESTART_PROVIDER='echo -n pw | gnome-keyring-daemon --replace --unlock --components=secrets' \
|
|
||||||
/// cargo test -p pdfs-core real_secret_service -- --ignored
|
|
||||||
/// ```
|
|
||||||
#[test]
|
|
||||||
#[ignore = "needs a throwaway Secret Service; see the doc comment"]
|
|
||||||
fn real_secret_service_survives_a_provider_restart() {
|
|
||||||
if std::env::var_os("PDFS_SECRET_SERVICE_RESTART_TEST").is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let restart = std::env::var("PDFS_RESTART_PROVIDER").expect("PDFS_RESTART_PROVIDER");
|
|
||||||
let _turn = KEYRING.lock();
|
|
||||||
*MOCK_STORE.lock() = None;
|
|
||||||
keyring_core::unset_default_store();
|
|
||||||
let (service, user) = ("pdfs-test", "restart");
|
|
||||||
|
|
||||||
with_keyring_entry(service, user, |e| e.set_password("before")).unwrap();
|
|
||||||
let status = std::process::Command::new("sh")
|
|
||||||
.args(["-c", &restart])
|
|
||||||
.status()
|
|
||||||
.unwrap();
|
|
||||||
assert!(status.success(), "restarting the provider failed");
|
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
|
||||||
|
|
||||||
let connects = KEYRING_CONNECTS.load(Ordering::SeqCst);
|
|
||||||
let read = with_keyring_entry(service, user, |e| e.get_password()).unwrap();
|
|
||||||
assert_eq!(read, "before");
|
|
||||||
assert_eq!(
|
|
||||||
KEYRING_CONNECTS.load(Ordering::SeqCst),
|
|
||||||
connects + 1,
|
|
||||||
"the provider kept the old session, so nothing was tested"
|
|
||||||
);
|
|
||||||
with_keyring_entry(service, user, |e| e.set_password("after")).unwrap();
|
|
||||||
let read = with_keyring_entry(service, user, |e| e.get_password()).unwrap();
|
|
||||||
assert_eq!(read, "after");
|
|
||||||
with_keyring_entry(service, user, |e| e.delete_credential()).unwrap();
|
|
||||||
keyring_core::unset_default_store();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stored_with(refresh: &str) -> StoredSession {
|
fn stored_with(refresh: &str) -> StoredSession {
|
||||||
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
||||||
}
|
}
|
||||||
@@ -1462,7 +1273,12 @@ mod tests {
|
|||||||
write_stored(&stored_with("r")).unwrap();
|
write_stored(&stored_with("r")).unwrap();
|
||||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||||
|
|
||||||
fail_next(keyring_core::Error::NoStorageAccess(
|
let entry = keyring_entry().unwrap();
|
||||||
|
let cred = entry
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<keyring_core::mock::Cred>()
|
||||||
|
.unwrap();
|
||||||
|
cred.set_error(keyring_core::Error::NoStorageAccess(
|
||||||
std::io::Error::other("locked").into(),
|
std::io::Error::other("locked").into(),
|
||||||
));
|
));
|
||||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||||
@@ -1490,42 +1306,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
|
|
||||||
/// Treating that as logout loses the only live tokens (B284, issue #78).
|
|
||||||
#[test]
|
|
||||||
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
|
|
||||||
let _turn = mock_keyring();
|
|
||||||
write_stored(&stored_with("r")).unwrap();
|
|
||||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
|
||||||
fail_next(keyring_core::Error::NoEntry);
|
|
||||||
|
|
||||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
|
||||||
assert!(sink.state.lock().unsaved.is_some());
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "r");
|
|
||||||
|
|
||||||
sink.flush().unwrap();
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "r2");
|
|
||||||
assert!(sink.state.lock().unsaved.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
|
|
||||||
let _turn = mock_keyring();
|
|
||||||
let sink = TokenSink::new("s", "r".into());
|
|
||||||
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
|
|
||||||
|
|
||||||
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
|
|
||||||
assert!(matches!(load(), Err(Error::NotLoggedIn)));
|
|
||||||
assert!(sink.state.lock().unsaved.is_some());
|
|
||||||
|
|
||||||
let mut login = stored_with("fresh");
|
|
||||||
login.session_id = "new".into();
|
|
||||||
write_stored(&login).unwrap();
|
|
||||||
sink.flush().unwrap();
|
|
||||||
assert!(sink.state.lock().unsaved.is_none());
|
|
||||||
assert_eq!(load().unwrap().refresh_token, "fresh");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||||
let unsaved = UnsavedRotation {
|
let unsaved = UnsavedRotation {
|
||||||
|
|||||||
@@ -562,12 +562,6 @@ impl AppDirs {
|
|||||||
self.state_dir().join("control.sock")
|
self.state_dir().join("control.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Google Photos import's last run: its archives and how it ended, so
|
|
||||||
/// an import cut short by a restart can be resumed.
|
|
||||||
pub fn takeout_import_path(&self) -> PathBuf {
|
|
||||||
self.state_dir().join("takeout-import.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unix domain socket the tray uses to ensure single instance.
|
/// Unix domain socket the tray uses to ensure single instance.
|
||||||
pub fn tray_socket(&self) -> PathBuf {
|
pub fn tray_socket(&self) -> PathBuf {
|
||||||
self.state_dir().join("tray.sock")
|
self.state_dir().join("tray.sock")
|
||||||
|
|||||||
+11
-135
@@ -279,11 +279,6 @@ pub enum Request {
|
|||||||
/// and files what it has uploaded into its albums, then reports
|
/// and files what it has uploaded into its albums, then reports
|
||||||
/// `cancelled`. Replies with [`Response::Ok`].
|
/// `cancelled`. Replies with [`Response::Ok`].
|
||||||
CancelImport,
|
CancelImport,
|
||||||
/// Start the last import again with the same archives, after it was
|
|
||||||
/// interrupted, cancelled or failed. Photos it already uploaded are found
|
|
||||||
/// on the account and skipped. Replies like [`Request::ImportTakeout`];
|
|
||||||
/// [`ErrorKind::NotFound`] when there is nothing to resume.
|
|
||||||
ResumeImport,
|
|
||||||
/// Upload the photo at `source_path` under the given name and media type.
|
/// Upload the photo at `source_path` under the given name and media type.
|
||||||
///
|
///
|
||||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||||
@@ -773,9 +768,6 @@ pub enum Topic {
|
|||||||
Conflicts,
|
Conflicts,
|
||||||
/// The activity log gained entries.
|
/// The activity log gained entries.
|
||||||
Activity,
|
Activity,
|
||||||
/// File thumbnails were made or fetched. A front-end asks again for the
|
|
||||||
/// tiles it still shows blank; the ones it already painted stay as they are.
|
|
||||||
Thumbnails,
|
|
||||||
/// A topic from a newer daemon, which this front-end cannot act on.
|
/// A topic from a newer daemon, which this front-end cannot act on.
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -785,7 +777,7 @@ impl Topic {
|
|||||||
/// Every topic a front-end can act on. A new subscription is told all of
|
/// Every topic a front-end can act on. A new subscription is told all of
|
||||||
/// them have changed, so it catches up on whatever happened while it was
|
/// them have changed, so it catches up on whatever happened while it was
|
||||||
/// not connected.
|
/// not connected.
|
||||||
pub const ALL: [Topic; 10] = [
|
pub const ALL: [Topic; 9] = [
|
||||||
Topic::Files,
|
Topic::Files,
|
||||||
Topic::Photos,
|
Topic::Photos,
|
||||||
Topic::Trash,
|
Topic::Trash,
|
||||||
@@ -795,7 +787,6 @@ impl Topic {
|
|||||||
Topic::Queue,
|
Topic::Queue,
|
||||||
Topic::Conflicts,
|
Topic::Conflicts,
|
||||||
Topic::Activity,
|
Topic::Activity,
|
||||||
Topic::Thumbnails,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1694,75 +1685,10 @@ pub struct ImportSummary {
|
|||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// True when the run stopped early because it was cancelled.
|
/// True when the run stopped early because it was cancelled.
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
/// Why the run as a whole stopped early, when it did, such as the
|
/// Why the run as a whole failed, when it did, such as an archive that
|
||||||
/// account being unreachable. The counts say how far it got.
|
/// is not a zip. The counts are then empty.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// What kind of failure [`error`](Self::error) is, so a front-end can word
|
|
||||||
/// it in the user's language. The text itself is the daemon's English.
|
|
||||||
#[serde(default)]
|
|
||||||
pub error_kind: Option<ErrorKind>,
|
|
||||||
/// True for a scan that uploads nothing ([`Request::ImportTakeout`] with
|
|
||||||
/// `dry_run`). [`to_upload`](Self::to_upload) then says what an import
|
|
||||||
/// would upload.
|
|
||||||
#[serde(default)]
|
|
||||||
pub dry_run: bool,
|
|
||||||
/// Photos not yet on the account: what a dry run would upload, and what a
|
|
||||||
/// real run set out to upload once the duplicates were known.
|
|
||||||
#[serde(default)]
|
|
||||||
pub to_upload: usize,
|
|
||||||
/// Bytes of [`to_upload`](Self::to_upload).
|
|
||||||
#[serde(default)]
|
|
||||||
pub to_upload_bytes: u64,
|
|
||||||
/// Live and motion photos whose video was filed with its still.
|
|
||||||
#[serde(default)]
|
|
||||||
pub live_photos: usize,
|
|
||||||
/// True when the service stopped while the import ran (a shutdown or a
|
|
||||||
/// restart). [`Request::ResumeImport`] picks it up again.
|
|
||||||
#[serde(default)]
|
|
||||||
pub interrupted: bool,
|
|
||||||
/// Archives that could not be read and were left out, by path.
|
|
||||||
#[serde(default)]
|
|
||||||
pub unreadable_archives: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportSummary {
|
|
||||||
/// Whether the run did all it set out to do.
|
|
||||||
pub fn complete(&self) -> bool {
|
|
||||||
self.error.is_none()
|
|
||||||
&& !self.cancelled
|
|
||||||
&& !self.interrupted
|
|
||||||
&& self.failed == 0
|
|
||||||
&& self.unreadable_archives.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which step a running Takeout import is on.
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ImportPhase {
|
|
||||||
/// Reading the archives' directories and metadata.
|
|
||||||
#[default]
|
|
||||||
Scanning,
|
|
||||||
/// Asking the account which photos it already has.
|
|
||||||
Checking,
|
|
||||||
/// Uploading the photos that are new.
|
|
||||||
Uploading,
|
|
||||||
/// Creating albums and filing photos into them.
|
|
||||||
Albums,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a running import is ([`Response::ImportStatus`]).
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
|
||||||
pub struct ImportProgress {
|
|
||||||
pub phase: ImportPhase,
|
|
||||||
/// Steps done in this phase.
|
|
||||||
pub done: u64,
|
|
||||||
/// Steps in this phase; `0` while not yet known.
|
|
||||||
pub total: u64,
|
|
||||||
/// The photo being uploaded, during [`ImportPhase::Uploading`].
|
|
||||||
#[serde(default)]
|
|
||||||
pub current: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One album in a [`Response::Albums`] listing.
|
/// One album in a [`Response::Albums`] listing.
|
||||||
@@ -1866,11 +1792,6 @@ pub struct PhotoItem {
|
|||||||
/// the JPEG it shows is not the whole of what is stored.
|
/// the JPEG it shows is not the whole of what is stored.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
/// GeoNames id of the town the photo was taken in, when its location is
|
|
||||||
/// known; [`crate::places::city`] names it. Older daemons omit it, and
|
|
||||||
/// album entries do not carry it.
|
|
||||||
#[serde(default)]
|
|
||||||
pub place_id: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||||
@@ -1951,26 +1872,11 @@ pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
|
|||||||
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
|
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `name` is a song or other recording in a format that can carry
|
|
||||||
/// its own cover picture: ID3 (MP3, AAC, WAV, AIFF), FLAC and Ogg pictures,
|
|
||||||
/// MP4 `covr` (M4A, M4B), or APE tags (WavPack, Monkey's Audio).
|
|
||||||
pub fn is_audio_file_name(name: &str) -> bool {
|
|
||||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
AUDIO_COVER_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUDIO_COVER_EXTENSIONS: &[&str] = &[
|
|
||||||
"aac", "aif", "aiff", "ape", "flac", "m4a", "m4b", "mp3", "oga", "ogg", "opus", "wav", "wv",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Whether file listings show a thumbnail for a file named `name`, of `size`
|
/// Whether file listings show a thumbnail for a file named `name`, of `size`
|
||||||
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, a video,
|
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
|
||||||
/// whose thumbnail is a frame the daemon takes with ffmpeg, or a song, whose
|
/// whose thumbnail is a frame the daemon takes with ffmpeg.
|
||||||
/// thumbnail is its cover: the picture in its tags, or a cover image beside it.
|
|
||||||
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
|
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
|
||||||
is_thumbnail_image_name(name) || is_video_file_name(name, size) || is_audio_file_name(name)
|
is_thumbnail_image_name(name) || is_video_file_name(name, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
||||||
@@ -2278,17 +2184,8 @@ pub enum Response {
|
|||||||
/// report; `summary` is `None` when no import has run this session.
|
/// report; `summary` is `None` when no import has run this session.
|
||||||
ImportStatus {
|
ImportStatus {
|
||||||
running: bool,
|
running: bool,
|
||||||
/// The counts so far while running, the final report after.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
summary: Option<ImportSummary>,
|
summary: Option<ImportSummary>,
|
||||||
/// The current step, while running.
|
|
||||||
#[serde(default)]
|
|
||||||
progress: Option<ImportProgress>,
|
|
||||||
/// The archives of the last run, when it can be resumed with
|
|
||||||
/// [`Request::ResumeImport`]: it was a real import that did not
|
|
||||||
/// finish. Survives a restart of the service.
|
|
||||||
#[serde(default)]
|
|
||||||
resumable: Vec<String>,
|
|
||||||
},
|
},
|
||||||
/// How the photo re-date is doing (reply to [`Request::RedateStatus`]).
|
/// How the photo re-date is doing (reply to [`Request::RedateStatus`]).
|
||||||
/// `running` is false once it has finished, when `summary` is the final
|
/// `running` is false once it has finished, when `summary` is the final
|
||||||
@@ -2761,16 +2658,6 @@ mod tests {
|
|||||||
assert!(end.is_none(), "{end:?}");
|
assert!(end.is_none(), "{end:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_thumbnail_change_travels_as_its_own_topic() {
|
|
||||||
let line = serde_json::to_string(&Event::Changed {
|
|
||||||
topics: vec![Topic::Thumbnails],
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(line, r#"{"Changed":{"topics":["thumbnails"]}}"#);
|
|
||||||
assert!(Topic::ALL.contains(&Topic::Thumbnails));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
||||||
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
||||||
@@ -2835,7 +2722,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn listings_thumbnail_images_videos_and_songs() {
|
fn listings_thumbnail_images_and_videos() {
|
||||||
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
|
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
|
||||||
assert!(is_thumbnail_media_name("camera.nef", None));
|
assert!(is_thumbnail_media_name("camera.nef", None));
|
||||||
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
|
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
|
||||||
@@ -2844,24 +2731,13 @@ mod tests {
|
|||||||
Some(64 * 1024 * 1024)
|
Some(64 * 1024 * 1024)
|
||||||
));
|
));
|
||||||
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
|
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
|
||||||
assert!(is_thumbnail_media_name("song.flac", Some(64 * 1024 * 1024)));
|
assert!(!is_thumbnail_media_name(
|
||||||
assert!(is_thumbnail_media_name("Track 01.MP3", None));
|
"song.flac",
|
||||||
|
Some(64 * 1024 * 1024)
|
||||||
|
));
|
||||||
assert!(!is_thumbnail_media_name("notes.txt", None));
|
assert!(!is_thumbnail_media_name("notes.txt", None));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn songs_are_told_by_their_extension() {
|
|
||||||
for name in [
|
|
||||||
"a.mp3", "b.FLAC", "c.ogg", "d.oga", "e.opus", "f.m4a", "g.m4b", "h.aac", "i.wav",
|
|
||||||
"j.aiff", "k.aif", "l.wv", "m.ape",
|
|
||||||
] {
|
|
||||||
assert!(is_audio_file_name(name), "{name}");
|
|
||||||
}
|
|
||||||
for name in ["mp3", "song.mp3.zip", "clip.mp4", "notes.txt", "cover.jpg"] {
|
|
||||||
assert!(!is_audio_file_name(name), "{name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep every extension accepted by the shared classifier covered. Drive
|
/// Keep every extension accepted by the shared classifier covered. Drive
|
||||||
/// activation uses this classification before it knows whether opening a
|
/// activation uses this classification before it knows whether opening a
|
||||||
/// file should stream through FUSE or materialise a complete cache entry;
|
/// file should stream through FUSE or materialise a complete cache entry;
|
||||||
|
|||||||
@@ -186,8 +186,6 @@ impl Db {
|
|||||||
// made, and it shows exactly what they put in it.
|
// made, and it shows exactly what they put in it.
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
// Album rows carry no location.
|
|
||||||
place_id: None,
|
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
@@ -249,8 +247,6 @@ impl Db {
|
|||||||
// made, and it shows exactly what they put in it.
|
// made, and it shows exactly what they put in it.
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
// Album rows carry no location.
|
|
||||||
place_id: None,
|
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ pub struct StoredPhoto {
|
|||||||
/// Whether any member of the group is a camera raw file. The grid badges
|
/// Whether any member of the group is a camera raw file. The grid badges
|
||||||
/// that, because the JPEG it shows is not the whole of what is stored.
|
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
/// The town the photo was taken in, once its location has been read.
|
|
||||||
pub place_id: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||||
@@ -258,7 +256,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
|||||||
/// own group: the column is `NULL` until the next refresh computes it, and a
|
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||||
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||||
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||||
p.kind, p.favorite, g.n, g.raw, p.place_id \
|
p.kind, p.favorite, g.n, g.raw \
|
||||||
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||||
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||||
|
|
||||||
@@ -274,7 +272,6 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
|||||||
favorite: r.get::<_, i64>(6)? != 0,
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||||
has_raw: r.get::<_, i64>(8)? != 0,
|
has_raw: r.get::<_, i64>(8)? != 0,
|
||||||
place_id: r.get(9)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -363,23 +363,6 @@ fn place_photos_carry_their_own_location() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A timeline photo names the town it was taken in, so the viewer can show it;
|
|
||||||
/// one without a location names none.
|
|
||||||
#[test]
|
|
||||||
fn timeline_photos_name_their_town() {
|
|
||||||
let db = Db::open_in_memory().unwrap();
|
|
||||||
db.photos_replace(&[
|
|
||||||
located("b", 500, "IMG_5.JPG", BERLIN),
|
|
||||||
TimelineRow::new("n", 400),
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
let berlin = db.photo_places().unwrap()[0].id;
|
|
||||||
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
|
|
||||||
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
|
|
||||||
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
|
|
||||||
assert_eq!(towns, [Some(berlin), None]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||||||
/// that reads the node again takes what the node now says.
|
/// that reads the node again takes what the node now says.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+70
-488
@@ -25,9 +25,7 @@
|
|||||||
//! The service folder (`Google Photos`) is localized, as are the year folders
|
//! The service folder (`Google Photos`) is localized, as are the year folders
|
||||||
//! (`Photos from 2019`) and the trash folder, so nothing here matches on the
|
//! (`Photos from 2019`) and the trash folder, so nothing here matches on the
|
||||||
//! service name: the first component is dropped when it is the export root, the
|
//! service name: the first component is dropped when it is the export root, the
|
||||||
//! second is the service, and the rest is `<folder>/<file>`. The Photos service
|
//! second is the service, and the rest is `<folder>/<file>`. A folder is an
|
||||||
//! is the one whose sidecars carry a `photoTakenTime`; other services in the same
|
|
||||||
//! export (`Google Drive/`, `YouTube/`) are left out. A folder is an
|
|
||||||
//! **album** when it carries an album metadata sidecar naming it; anything else
|
//! **album** when it carries an album metadata sidecar naming it; anything else
|
||||||
//! is a plain bucket that imports into the timeline only. Trash is matched by
|
//! is a plain bucket that imports into the timeline only. Trash is matched by
|
||||||
//! name against [`TRASH_FOLDERS`] — the one place a localized name is
|
//! name against [`TRASH_FOLDERS`] — the one place a localized name is
|
||||||
@@ -123,14 +121,8 @@ const MEDIA_EXTENSIONS: &[(&str, &str)] = &[
|
|||||||
("m2ts", "video/mp2t"),
|
("m2ts", "video/mp2t"),
|
||||||
("mpg", "video/mpeg"),
|
("mpg", "video/mpeg"),
|
||||||
("mpeg", "video/mpeg"),
|
("mpeg", "video/mpeg"),
|
||||||
// The video half of a Pixel motion photo, `PXL_….MP` beside `PXL_….MP.jpg`.
|
|
||||||
("mp", "video/mp4"),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Sidecars bigger than this are not read. A real one is a few kilobytes; a
|
|
||||||
/// `.json` this large is some other service's data and not worth the memory.
|
|
||||||
const SIDECAR_MAX_BYTES: u64 = 1024 * 1024;
|
|
||||||
|
|
||||||
/// The media type for `name`'s extension, or `None` when it is not media this
|
/// The media type for `name`'s extension, or `None` when it is not media this
|
||||||
/// import handles.
|
/// import handles.
|
||||||
pub fn media_type_of(name: &str) -> Option<&'static str> {
|
pub fn media_type_of(name: &str) -> Option<&'static str> {
|
||||||
@@ -155,13 +147,6 @@ pub enum Bucket {
|
|||||||
Archived,
|
Archived,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A position in WGS 84 decimal degrees, from a sidecar's `geoData`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct GeoPoint {
|
|
||||||
pub latitude: f64,
|
|
||||||
pub longitude: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One media file found in the export.
|
/// One media file found in the export.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct TakeoutPhoto {
|
pub struct TakeoutPhoto {
|
||||||
@@ -177,49 +162,14 @@ pub struct TakeoutPhoto {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// Media type from the extension.
|
/// Media type from the extension.
|
||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
/// Capture time in epoch seconds, from the sidecar's `photoTakenTime`,
|
/// Capture time in epoch seconds, from `photoTakenTime` (falling back to
|
||||||
/// else from a date in the file name. `None` when neither has one; the
|
/// `creationTime`). `None` when the photo has no sidecar — the upload then
|
||||||
/// importer then tries the file's EXIF, then [`creation_time`](Self::creation_time).
|
/// defaults it to now, as the SDK does.
|
||||||
pub capture_time: Option<i64>,
|
pub capture_time: Option<i64>,
|
||||||
/// The sidecar's `creationTime`: when the photo reached Google Photos, not
|
|
||||||
/// when it was taken. Only a last resort for the capture time.
|
|
||||||
#[serde(default)]
|
|
||||||
pub creation_time: Option<i64>,
|
|
||||||
/// Where the photo was taken, from the sidecar's `geoData` (or
|
|
||||||
/// `geoDataExif`). Google writes `0, 0` for "unknown", which is dropped.
|
|
||||||
#[serde(default)]
|
|
||||||
pub location: Option<GeoPoint>,
|
|
||||||
/// The sidecar's `favorited` flag.
|
/// The sidecar's `favorited` flag.
|
||||||
pub favorite: bool,
|
pub favorite: bool,
|
||||||
/// Which bucket the file was found in.
|
/// Which bucket the file was found in.
|
||||||
pub bucket: Bucket,
|
pub bucket: Bucket,
|
||||||
/// Pairs the halves of a live or motion photo: a still and a video in the
|
|
||||||
/// same folder carry the same key (`IMG_1.HEIC` + `IMG_1.MOV`,
|
|
||||||
/// `PXL_1.MP.jpg` + `PXL_1.MP`). `None` for media that cannot be either half.
|
|
||||||
#[serde(default)]
|
|
||||||
pub live_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TakeoutPhoto {
|
|
||||||
/// Whether this is the video half of a pair, as opposed to the still.
|
|
||||||
pub fn is_video(&self) -> bool {
|
|
||||||
self.media_type.starts_with("video/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the pair this belongs to is a Pixel motion photo (`.MP`) rather
|
|
||||||
/// than an Apple live photo.
|
|
||||||
pub fn is_motion_photo(&self) -> bool {
|
|
||||||
self.live_key
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|key| key.ends_with(".mp"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An archive the scan had to leave out, and why.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct UnreadableArchive {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything one scan found, ready to be turned into an import plan.
|
/// Everything one scan found, ready to be turned into an import plan.
|
||||||
@@ -233,16 +183,8 @@ pub struct TakeoutScan {
|
|||||||
/// Media files skipped because they sit in the trash bucket.
|
/// Media files skipped because they sit in the trash bucket.
|
||||||
pub skipped_trashed: usize,
|
pub skipped_trashed: usize,
|
||||||
/// Entries that were neither media nor a sidecar (`archive_browser.html`,
|
/// Entries that were neither media nor a sidecar (`archive_browser.html`,
|
||||||
/// activity `.csv`s), and media from other Google services in the same
|
/// activity `.csv`s, `.mp` motion-photo parts).
|
||||||
/// export (`Google Drive/`, `YouTube/`).
|
|
||||||
pub skipped_other: usize,
|
pub skipped_other: usize,
|
||||||
/// Archives that could not be opened or are not a zip. The rest of the
|
|
||||||
/// export is still imported.
|
|
||||||
#[serde(default)]
|
|
||||||
pub unreadable_archives: Vec<UnreadableArchive>,
|
|
||||||
/// Entries inside an otherwise readable archive that could not be read.
|
|
||||||
#[serde(default)]
|
|
||||||
pub unreadable_entries: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TakeoutScan {
|
impl TakeoutScan {
|
||||||
@@ -253,7 +195,7 @@ impl TakeoutScan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Google's per-photo sidecar. Only the fields the import uses are read; the
|
/// Google's per-photo sidecar. Only the fields the import uses are read; the
|
||||||
/// file also carries people tags, a description and view counts.
|
/// file also carries geo data, people tags and view counts.
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct PhotoSidecar {
|
struct PhotoSidecar {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -263,69 +205,19 @@ struct PhotoSidecar {
|
|||||||
#[serde(rename = "creationTime", default)]
|
#[serde(rename = "creationTime", default)]
|
||||||
creation_time: Option<SidecarTime>,
|
creation_time: Option<SidecarTime>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
favorited: Option<bool>,
|
favorited: bool,
|
||||||
/// The position as edited in Google Photos, else as the camera wrote it.
|
|
||||||
#[serde(rename = "geoData", default)]
|
|
||||||
geo_data: Option<SidecarGeo>,
|
|
||||||
#[serde(rename = "geoDataExif", default)]
|
|
||||||
geo_data_exif: Option<SidecarGeo>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PhotoSidecar {
|
/// A sidecar timestamp: epoch seconds, as a *string*.
|
||||||
fn location(&self) -> Option<GeoPoint> {
|
|
||||||
self.geo_data
|
|
||||||
.as_ref()
|
|
||||||
.and_then(SidecarGeo::point)
|
|
||||||
.or_else(|| self.geo_data_exif.as_ref().and_then(SidecarGeo::point))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A sidecar timestamp: epoch seconds, written as a string, sometimes a number.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SidecarTime {
|
struct SidecarTime {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
timestamp: Option<SidecarNumber>,
|
timestamp: Option<String>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum SidecarNumber {
|
|
||||||
Number(i64),
|
|
||||||
Text(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SidecarTime {
|
impl SidecarTime {
|
||||||
/// The timestamp, when it is a real moment. `0` and negative values are
|
|
||||||
/// Google's "unknown" and would file the photo under 1970.
|
|
||||||
fn epoch_seconds(&self) -> Option<i64> {
|
fn epoch_seconds(&self) -> Option<i64> {
|
||||||
let seconds = match self.timestamp.as_ref()? {
|
self.timestamp.as_ref()?.parse().ok()
|
||||||
SidecarNumber::Number(seconds) => *seconds,
|
|
||||||
SidecarNumber::Text(text) => text.trim().parse().ok()?,
|
|
||||||
};
|
|
||||||
(seconds > 0).then_some(seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `geoData` in a sidecar. All-zero means Google has no position.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct SidecarGeo {
|
|
||||||
#[serde(default)]
|
|
||||||
latitude: Option<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
longitude: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidecarGeo {
|
|
||||||
fn point(&self) -> Option<GeoPoint> {
|
|
||||||
let (latitude, longitude) = (self.latitude?, self.longitude?);
|
|
||||||
let on_globe = latitude.is_finite()
|
|
||||||
&& longitude.is_finite()
|
|
||||||
&& (-90.0..=90.0).contains(&latitude)
|
|
||||||
&& (-180.0..=180.0).contains(&longitude);
|
|
||||||
(on_globe && (latitude != 0.0 || longitude != 0.0)).then_some(GeoPoint {
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,58 +262,21 @@ impl RawEntry {
|
|||||||
/// is one album, and a sidecar in part 3 is matched to its photo in part 1.
|
/// is one album, and a sidecar in part 3 is matched to its photo in part 1.
|
||||||
/// Reads only the central directory and the JSON sidecars — no media bytes, so
|
/// Reads only the central directory and the JSON sidecars — no media bytes, so
|
||||||
/// this stays fast on a 200 GB export.
|
/// this stays fast on a 200 GB export.
|
||||||
///
|
|
||||||
/// An archive that cannot be opened is left out and listed in
|
|
||||||
/// [`TakeoutScan::unreadable_archives`]; the rest still imports.
|
|
||||||
pub fn scan_archives(archives: &[PathBuf]) -> CoreResult<TakeoutScan> {
|
pub fn scan_archives(archives: &[PathBuf]) -> CoreResult<TakeoutScan> {
|
||||||
scan_archives_until(archives, || false)
|
let mut entries: Vec<RawEntry> = Vec::new();
|
||||||
}
|
let mut sidecars: HashMap<String, Vec<u8>> = HashMap::new();
|
||||||
|
|
||||||
/// [`scan_archives`] that gives up when `stop` returns true, checked between
|
|
||||||
/// archives and every few hundred entries.
|
|
||||||
pub fn scan_archives_until(
|
|
||||||
archives: &[PathBuf],
|
|
||||||
stop: impl Fn() -> bool,
|
|
||||||
) -> CoreResult<TakeoutScan> {
|
|
||||||
let mut entries: Vec<(String, RawEntry)> = Vec::new();
|
|
||||||
let mut sidecars: Vec<(String, String, Vec<u8>)> = Vec::new();
|
|
||||||
let mut unreadable_archives = Vec::new();
|
|
||||||
let mut unreadable_entries = 0;
|
|
||||||
|
|
||||||
for (index, path) in archives.iter().enumerate() {
|
for (index, path) in archives.iter().enumerate() {
|
||||||
if stop() {
|
let file = std::fs::File::open(path)
|
||||||
return Err(CoreError::conflict("the import was cancelled"));
|
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
||||||
}
|
let mut zip = zip::ZipArchive::new(std::io::BufReader::new(file)).map_err(|e| {
|
||||||
let opened = std::fs::File::open(path)
|
CoreError::invalid(format!("{} is not a readable zip: {e}", path.display()))
|
||||||
.map_err(|e| e.to_string())
|
})?;
|
||||||
.and_then(|file| {
|
|
||||||
zip::ZipArchive::new(std::io::BufReader::new(file))
|
|
||||||
.map_err(|e| format!("not a readable zip: {e}"))
|
|
||||||
});
|
|
||||||
let mut zip = match opened {
|
|
||||||
Ok(zip) => zip,
|
|
||||||
Err(reason) => {
|
|
||||||
tracing::warn!(archive = %path.display(), %reason, "skipping an unreadable takeout archive");
|
|
||||||
unreadable_archives.push(UnreadableArchive {
|
|
||||||
path: path.clone(),
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for position in 0..zip.len() {
|
for position in 0..zip.len() {
|
||||||
if position % 512 == 0 && stop() {
|
let mut entry = zip.by_index(position).map_err(|e| {
|
||||||
return Err(CoreError::conflict("the import was cancelled"));
|
CoreError::invalid(format!("{}: unreadable entry: {e}", path.display()))
|
||||||
}
|
})?;
|
||||||
let mut entry = match zip.by_index(position) {
|
|
||||||
Ok(entry) => entry,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(archive = %path.display(), position, error = %e, "unreadable takeout entry");
|
|
||||||
unreadable_entries += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if entry.is_dir() {
|
if entry.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -431,92 +286,32 @@ pub fn scan_archives_until(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let name = name.to_string_lossy().replace('\\', "/");
|
let name = name.to_string_lossy().replace('\\', "/");
|
||||||
let Some((service, relative)) = strip_export_prefix(&name) else {
|
let Some(relative) = strip_export_prefix(&name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let (service, relative) = (service.to_string(), relative.to_string());
|
|
||||||
let size = entry.size();
|
let size = entry.size();
|
||||||
|
|
||||||
// Sidecars are read now, while the entry is open: they are a few
|
// Sidecars are read now, while the entry is open: they are a few
|
||||||
// hundred bytes each and reopening the archive per photo later would
|
// hundred bytes each and reopening the archive per photo later would
|
||||||
// be a second full pass.
|
// be a second full pass.
|
||||||
if relative.to_ascii_lowercase().ends_with(".json") {
|
if relative.to_ascii_lowercase().ends_with(".json") {
|
||||||
if size <= SIDECAR_MAX_BYTES {
|
let mut body = Vec::with_capacity(size.min(64 * 1024) as usize);
|
||||||
let mut body = Vec::with_capacity(size as usize);
|
if entry.read_to_end(&mut body).is_ok() {
|
||||||
if entry.read_to_end(&mut body).is_ok() {
|
sidecars.insert(relative.to_string(), body);
|
||||||
sidecars.push((service, relative, body));
|
|
||||||
} else {
|
|
||||||
unreadable_entries += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
entries.push((
|
|
||||||
service,
|
entries.push(RawEntry {
|
||||||
RawEntry {
|
archive: index,
|
||||||
archive: index,
|
entry: name.clone(),
|
||||||
entry: name,
|
relative: relative.to_string(),
|
||||||
relative,
|
size,
|
||||||
size,
|
});
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (entries, sidecars, other_services) = keep_photos_services(entries, sidecars);
|
Ok(assemble(entries, &sidecars))
|
||||||
let mut scan = assemble(entries, &sidecars);
|
|
||||||
scan.skipped_other += other_services;
|
|
||||||
scan.unreadable_archives = unreadable_archives;
|
|
||||||
scan.unreadable_entries = unreadable_entries;
|
|
||||||
Ok(scan)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop what other Google services put into the same export.
|
|
||||||
///
|
|
||||||
/// A Takeout can hold `Google Drive/`, `YouTube/` and others next to
|
|
||||||
/// `Google Photos/`, and their media must not land in the photo timeline. The
|
|
||||||
/// service folder's name is localized (`Google Fotos`), so the Photos service
|
|
||||||
/// is recognized by its content instead: it is the folder whose sidecars carry
|
|
||||||
/// a `photoTakenTime`. When no folder does — an export without sidecars, or
|
|
||||||
/// one re-zipped without the service level — everything is kept, as before.
|
|
||||||
///
|
|
||||||
/// Returns the entries and sidecars to import, and how many media files were
|
|
||||||
/// left out.
|
|
||||||
fn keep_photos_services(
|
|
||||||
entries: Vec<(String, RawEntry)>,
|
|
||||||
sidecars: Vec<(String, String, Vec<u8>)>,
|
|
||||||
) -> (Vec<RawEntry>, HashMap<String, Vec<u8>>, usize) {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Marker {
|
|
||||||
#[serde(rename = "photoTakenTime")]
|
|
||||||
photo_taken_time: Option<serde::de::IgnoredAny>,
|
|
||||||
}
|
|
||||||
let mut photos_services: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
||||||
for (service, _, body) in &sidecars {
|
|
||||||
if photos_services.contains(service.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if serde_json::from_slice::<Marker>(body).is_ok_and(|m| m.photo_taken_time.is_some()) {
|
|
||||||
photos_services.insert(service.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let keep = |service: &str| photos_services.is_empty() || photos_services.contains(service);
|
|
||||||
|
|
||||||
let mut skipped = 0;
|
|
||||||
let mut kept_entries = Vec::with_capacity(entries.len());
|
|
||||||
for (service, raw) in entries {
|
|
||||||
if keep(&service) {
|
|
||||||
kept_entries.push(raw);
|
|
||||||
} else if media_type_of(raw.file_name()).is_some() {
|
|
||||||
skipped += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let kept_sidecars = sidecars
|
|
||||||
.iter()
|
|
||||||
.filter(|(service, _, _)| keep(service))
|
|
||||||
.map(|(_, relative, body)| (relative.clone(), body.clone()))
|
|
||||||
.collect();
|
|
||||||
(kept_entries, kept_sidecars, skipped)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the scan from raw entries plus the sidecar bodies keyed by their path
|
/// Build the scan from raw entries plus the sidecar bodies keyed by their path
|
||||||
@@ -608,19 +403,19 @@ 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. `creationTime`
|
// Sidecar first, then the date stamped into the name.
|
||||||
// is when the photo reached Google, which for a scan or an old photo
|
|
||||||
// uploaded late is years off; the importer uses it only after EXIF.
|
|
||||||
let capture_time = sidecar
|
let capture_time = sidecar
|
||||||
.photo_taken_time
|
.photo_taken_time
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
.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(&name))
|
||||||
.or_else(|| capture_time_from_name(&file_name));
|
.or_else(|| capture_time_from_name(&file_name));
|
||||||
let creation_time = sidecar
|
|
||||||
.creation_time
|
|
||||||
.as_ref()
|
|
||||||
.and_then(SidecarTime::epoch_seconds);
|
|
||||||
|
|
||||||
scan.photos.push(TakeoutPhoto {
|
scan.photos.push(TakeoutPhoto {
|
||||||
archive: raw.archive,
|
archive: raw.archive,
|
||||||
@@ -629,57 +424,31 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
size: raw.size,
|
size: raw.size,
|
||||||
media_type: media_type.to_string(),
|
media_type: media_type.to_string(),
|
||||||
capture_time,
|
capture_time,
|
||||||
creation_time,
|
favorite: sidecar.favorited,
|
||||||
location: sidecar.location(),
|
|
||||||
favorite: sidecar.favorited.unwrap_or(false),
|
|
||||||
bucket,
|
bucket,
|
||||||
live_key: live_key(&folder, &file_name),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scan
|
scan
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a path inside the archive into the localized service folder and the
|
/// Drop the export root (`Takeout/`) and the localized service folder from a
|
||||||
/// rest, dropping the export root (`Takeout/`): `("Google Fotos",
|
/// path inside the archive, leaving `<folder>/<file>` or `<file>`.
|
||||||
/// "<folder>/<file>")`.
|
|
||||||
///
|
///
|
||||||
/// `None` for a path with no service folder at all (the export's own
|
/// `None` for a path with no service folder at all (the export's own
|
||||||
/// `archive_browser.html`), which carries nothing to import.
|
/// `archive_browser.html`), which carries nothing to import.
|
||||||
fn strip_export_prefix(path: &str) -> Option<(&str, &str)> {
|
fn strip_export_prefix(path: &str) -> Option<&str> {
|
||||||
let mut rest = path;
|
let mut rest = path;
|
||||||
// The root is `Takeout/`, but an export unzipped and re-zipped by the user
|
// The root is usually `Takeout/`, but an export unzipped and re-zipped by
|
||||||
// may not have it. `Google Photos/<album>/<file>` then has three parts like
|
// the user may not have it — so the root is dropped only when a service
|
||||||
// `Takeout/Google Photos/<file>`, so the count alone cannot tell them
|
// folder follows it, which the component count decides.
|
||||||
// apart: a root of another name is only taken for one with four parts.
|
|
||||||
let (first, after) = rest.split_once('/')?;
|
|
||||||
let components = rest.split('/').count();
|
let components = rest.split('/').count();
|
||||||
if (first.eq_ignore_ascii_case("takeout") && components >= 3) || components >= 4 {
|
if components >= 3 {
|
||||||
rest = after;
|
rest = rest.split_once('/')?.1;
|
||||||
}
|
}
|
||||||
// The service folder (`Google Photos`, `Google Fotos`, …).
|
// Drop the service folder (`Google Photos`, `Google Fotos`, …).
|
||||||
rest.split_once('/')
|
let (_service, tail) = rest.split_once('/')?;
|
||||||
}
|
Some(tail)
|
||||||
|
|
||||||
/// The key that pairs the still and the video of one live or motion photo, or
|
|
||||||
/// `None` when `file_name` cannot be either half.
|
|
||||||
///
|
|
||||||
/// Apple writes `IMG_1.HEIC` (or `.JPG`) and `IMG_1.MOV`, which share the stem.
|
|
||||||
/// A Pixel writes `PXL_1.MP.jpg` and `PXL_1.MP`; there the still's stem is the
|
|
||||||
/// video's whole name. The folder is part of the key so two cameras' `IMG_1`
|
|
||||||
/// in different albums never pair.
|
|
||||||
fn live_key(folder: &str, file_name: &str) -> Option<String> {
|
|
||||||
let lower = file_name.to_lowercase();
|
|
||||||
let (stem, ext) = lower.rsplit_once('.')?;
|
|
||||||
let key = match ext {
|
|
||||||
"heic" | "heif" | "jpg" | "jpeg" => stem,
|
|
||||||
// Apple's video half. A plain `.mp4` beside a photo of the same name is
|
|
||||||
// far more often an unrelated clip than half of a live photo.
|
|
||||||
"mov" => stem,
|
|
||||||
"mp" => lower.as_str(),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
(!key.is_empty()).then(|| format!("{folder}/{key}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split `<folder>/<file>` into its parts; the folder is `""` at the root.
|
/// Split `<folder>/<file>` into its parts; the folder is `""` at the root.
|
||||||
@@ -1089,13 +858,13 @@ mod tests {
|
|||||||
RawEntry {
|
RawEntry {
|
||||||
archive,
|
archive,
|
||||||
entry: path.to_string(),
|
entry: path.to_string(),
|
||||||
relative: strip_export_prefix(path).unwrap().1.to_string(),
|
relative: strip_export_prefix(path).unwrap().to_string(),
|
||||||
size,
|
size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sidecar(map: &mut HashMap<String, Vec<u8>>, path: &str, body: &str) {
|
fn sidecar(map: &mut HashMap<String, Vec<u8>>, path: &str, body: &str) {
|
||||||
let relative = strip_export_prefix(path).unwrap().1.to_string();
|
let relative = strip_export_prefix(path).unwrap().to_string();
|
||||||
map.insert(relative, body.as_bytes().to_vec());
|
map.insert(relative, body.as_bytes().to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,17 +872,12 @@ mod tests {
|
|||||||
fn strips_export_root_and_localized_service_folder() {
|
fn strips_export_root_and_localized_service_folder() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_export_prefix("Takeout/Google Fotos/Island 2019/IMG_1.jpg"),
|
strip_export_prefix("Takeout/Google Fotos/Island 2019/IMG_1.jpg"),
|
||||||
Some(("Google Fotos", "Island 2019/IMG_1.jpg"))
|
Some("Island 2019/IMG_1.jpg")
|
||||||
);
|
);
|
||||||
// Already unwrapped by the user: no `Takeout/` root left.
|
// Already unwrapped by the user: no `Takeout/` root left.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_export_prefix("Google Photos/IMG_1.jpg"),
|
strip_export_prefix("Google Photos/IMG_1.jpg"),
|
||||||
Some(("Google Photos", "IMG_1.jpg"))
|
Some("IMG_1.jpg")
|
||||||
);
|
|
||||||
// ... and an album keeps its folder.
|
|
||||||
assert_eq!(
|
|
||||||
strip_export_prefix("Google Photos/Iceland/IMG_1.jpg"),
|
|
||||||
Some(("Google Photos", "Iceland/IMG_1.jpg"))
|
|
||||||
);
|
);
|
||||||
// Nothing below a service folder is nothing to import.
|
// Nothing below a service folder is nothing to import.
|
||||||
assert_eq!(strip_export_prefix("archive_browser.html"), None);
|
assert_eq!(strip_export_prefix("archive_browser.html"), None);
|
||||||
@@ -1193,144 +957,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creation_time_is_kept_apart_as_the_last_resort() {
|
fn creation_time_is_the_fallback_capture_time() {
|
||||||
// `creationTime` is when the photo reached Google. It must not beat the
|
|
||||||
// date in the name, and the importer tries EXIF before it.
|
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.json",
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.json",
|
||||||
r#"{"creationTime":{"timestamp":"1500000000"}}"#,
|
r#"{"creationTime":{"timestamp":"1500000000"}}"#,
|
||||||
);
|
);
|
||||||
sidecar(
|
|
||||||
&mut sidecars,
|
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_20190612_101500.jpg.json",
|
|
||||||
r#"{"creationTime":{"timestamp":"1700000000"}}"#,
|
|
||||||
);
|
|
||||||
let scan = assemble(
|
let scan = assemble(
|
||||||
vec![
|
vec![entry(
|
||||||
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
|
0,
|
||||||
entry(
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg",
|
||||||
0,
|
10,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_20190612_101500.jpg",
|
)],
|
||||||
10,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
&sidecars,
|
&sidecars,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(scan.photos[0].capture_time, None);
|
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
||||||
assert_eq!(scan.photos[0].creation_time, Some(1_500_000_000));
|
|
||||||
assert_eq!(
|
|
||||||
scan.photos[1].capture_time,
|
|
||||||
capture_time_from_name("IMG_20190612_101500.jpg")
|
|
||||||
);
|
|
||||||
assert_eq!(scan.photos[1].creation_time, Some(1_700_000_000));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_timestamps_tolerate_numbers_zero_and_null() {
|
|
||||||
let mut sidecars = HashMap::new();
|
|
||||||
for (name, body) in [
|
|
||||||
("A.jpg", r#"{"photoTakenTime":{"timestamp":1560000000}}"#),
|
|
||||||
(
|
|
||||||
"B.jpg",
|
|
||||||
r#"{"photoTakenTime":{"timestamp":"0"},"favorited":null}"#,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"C.jpg",
|
|
||||||
r#"{"photoTakenTime":{"timestamp":null},"geoData":null}"#,
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
sidecar(
|
|
||||||
&mut sidecars,
|
|
||||||
&format!("Takeout/Google Photos/Photos from 2019/{name}.json"),
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let scan = assemble(
|
|
||||||
["A.jpg", "B.jpg", "C.jpg"]
|
|
||||||
.iter()
|
|
||||||
.map(|name| {
|
|
||||||
entry(
|
|
||||||
0,
|
|
||||||
&format!("Takeout/Google Photos/Photos from 2019/{name}"),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
&sidecars,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(scan.photos[0].capture_time, Some(1_560_000_000));
|
|
||||||
assert_eq!(scan.photos[1].capture_time, None, "0 is not 1970");
|
|
||||||
assert!(!scan.photos[1].favorite);
|
|
||||||
assert_eq!(scan.photos[2].capture_time, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn location_comes_from_geo_data_and_zero_means_none() {
|
|
||||||
let mut sidecars = HashMap::new();
|
|
||||||
sidecar(
|
|
||||||
&mut sidecars,
|
|
||||||
"Takeout/Google Photos/Iceland/IMG_1.jpg.json",
|
|
||||||
r#"{"geoData":{"latitude":64.1466,"longitude":-21.9426,"altitude":0.0}}"#,
|
|
||||||
);
|
|
||||||
sidecar(
|
|
||||||
&mut sidecars,
|
|
||||||
"Takeout/Google Photos/Iceland/IMG_2.jpg.json",
|
|
||||||
r#"{"geoData":{"latitude":0.0,"longitude":0.0},
|
|
||||||
"geoDataExif":{"latitude":63.4186,"longitude":-19.0060}}"#,
|
|
||||||
);
|
|
||||||
sidecar(
|
|
||||||
&mut sidecars,
|
|
||||||
"Takeout/Google Photos/Iceland/IMG_3.jpg.json",
|
|
||||||
r#"{"geoData":{"latitude":0.0,"longitude":0.0}}"#,
|
|
||||||
);
|
|
||||||
let scan = assemble(
|
|
||||||
(1..=3)
|
|
||||||
.map(|n| entry(0, &format!("Takeout/Google Photos/Iceland/IMG_{n}.jpg"), 1))
|
|
||||||
.collect(),
|
|
||||||
&sidecars,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
scan.photos[0].location,
|
|
||||||
Some(GeoPoint {
|
|
||||||
latitude: 64.1466,
|
|
||||||
longitude: -21.9426
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
scan.photos[1].location.map(|p| p.latitude),
|
|
||||||
Some(63.4186),
|
|
||||||
"geoDataExif fills in for an empty geoData"
|
|
||||||
);
|
|
||||||
assert_eq!(scan.photos[2].location, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_and_motion_photo_halves_share_a_key() {
|
|
||||||
let scan = assemble(
|
|
||||||
vec![
|
|
||||||
entry(0, "Takeout/Google Photos/Trip/IMG_1.HEIC", 1),
|
|
||||||
entry(0, "Takeout/Google Photos/Trip/IMG_1.MOV", 1),
|
|
||||||
entry(0, "Takeout/Google Photos/Trip/PXL_2.MP.jpg", 1),
|
|
||||||
entry(0, "Takeout/Google Photos/Trip/PXL_2.MP", 1),
|
|
||||||
entry(0, "Takeout/Google Photos/Other/IMG_1.MOV", 1),
|
|
||||||
entry(0, "Takeout/Google Photos/Trip/scan.png", 1),
|
|
||||||
],
|
|
||||||
&HashMap::new(),
|
|
||||||
);
|
|
||||||
let key = |i: usize| scan.photos[i].live_key.clone();
|
|
||||||
|
|
||||||
assert_eq!(key(0), key(1));
|
|
||||||
assert!(!scan.photos[0].is_motion_photo());
|
|
||||||
assert_eq!(key(2), key(3));
|
|
||||||
assert!(scan.photos[3].is_motion_photo());
|
|
||||||
assert!(scan.photos[3].is_video(), "the .MP half is a video");
|
|
||||||
assert_ne!(key(1), key(4), "another folder's IMG_1 does not pair");
|
|
||||||
assert_eq!(key(5), None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1744,77 +1387,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_that_is_not_a_zip_is_left_out_and_the_rest_imports() {
|
fn a_file_that_is_not_a_zip_is_a_readable_error() {
|
||||||
let broken =
|
let path =
|
||||||
std::env::temp_dir().join(format!("pdfs-takeout-not-a-zip-{}", std::process::id()));
|
std::env::temp_dir().join(format!("pdfs-takeout-not-a-zip-{}", std::process::id()));
|
||||||
std::fs::write(&broken, b"not a zip at all").unwrap();
|
std::fs::write(&path, b"not a zip at all").unwrap();
|
||||||
let good = write_zip(
|
let error = scan_archives(std::slice::from_ref(&path)).unwrap_err();
|
||||||
"beside-broken",
|
let _ = std::fs::remove_file(&path);
|
||||||
&[("Takeout/Google Photos/Photos from 2019/IMG_1.jpg", b"x")],
|
|
||||||
);
|
|
||||||
|
|
||||||
let scan = scan_archives(&[broken.clone(), good.clone()]).unwrap();
|
|
||||||
let _ = std::fs::remove_file(&broken);
|
|
||||||
let _ = std::fs::remove_file(&good);
|
|
||||||
|
|
||||||
assert_eq!(scan.photos.len(), 1);
|
|
||||||
assert_eq!(scan.photos[0].archive, 1);
|
|
||||||
assert_eq!(scan.unreadable_archives.len(), 1);
|
|
||||||
assert_eq!(scan.unreadable_archives[0].path, broken);
|
|
||||||
assert!(
|
assert!(
|
||||||
scan.unreadable_archives[0]
|
error.message.contains("not a readable zip"),
|
||||||
.reason
|
"unexpected message: {}",
|
||||||
.contains("not a readable zip"),
|
error.message
|
||||||
"unexpected reason: {}",
|
|
||||||
scan.unreadable_archives[0].reason
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn other_services_in_the_same_export_are_not_imported() {
|
|
||||||
let zip = write_zip(
|
|
||||||
"services",
|
|
||||||
&[
|
|
||||||
(
|
|
||||||
"Takeout/Google Fotos/Fotos von 2019/IMG_1.jpg",
|
|
||||||
b"x".as_slice(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Takeout/Google Fotos/Fotos von 2019/IMG_1.jpg.json",
|
|
||||||
br#"{"photoTakenTime":{"timestamp":"1560000000"}}"#.as_slice(),
|
|
||||||
),
|
|
||||||
("Takeout/Drive/My Drive/holiday.jpg", b"y".as_slice()),
|
|
||||||
("Takeout/YouTube/videos/clip.mp4", b"z".as_slice()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let scan = scan_archives(std::slice::from_ref(&zip)).unwrap();
|
|
||||||
let _ = std::fs::remove_file(&zip);
|
|
||||||
|
|
||||||
assert_eq!(scan.photos.len(), 1);
|
|
||||||
assert_eq!(scan.photos[0].name, "IMG_1.jpg");
|
|
||||||
assert_eq!(scan.skipped_other, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_export_without_sidecars_keeps_every_service() {
|
|
||||||
let zip = write_zip(
|
|
||||||
"no-sidecars",
|
|
||||||
&[("Takeout/Google Photos/Photos from 2019/IMG_1.jpg", b"x")],
|
|
||||||
);
|
|
||||||
let scan = scan_archives(std::slice::from_ref(&zip)).unwrap();
|
|
||||||
let _ = std::fs::remove_file(&zip);
|
|
||||||
assert_eq!(scan.photos.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_stop_request_ends_the_scan() {
|
|
||||||
let zip = write_zip(
|
|
||||||
"stop",
|
|
||||||
&[("Takeout/Google Photos/Photos from 2019/IMG_1.jpg", b"x")],
|
|
||||||
);
|
|
||||||
let result = scan_archives_until(std::slice::from_ref(&zip), || true);
|
|
||||||
let _ = std::fs::remove_file(&zip);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,9 @@ tokio.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
lofty.workspace = true
|
|
||||||
notify = "8"
|
notify = "8"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -157,17 +157,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
if ours {
|
if ours {
|
||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
}
|
}
|
||||||
// A file kept offline keeps its copy through a change, and the
|
|
||||||
// keeper replaces it once this batch's listings are read again.
|
|
||||||
// The copy is checked against the revision's `(mtime, size)`, so
|
|
||||||
// no read takes it for a newer revision. Evicted, it was gone until
|
|
||||||
// something read the file, which then downloaded it (B282). A
|
|
||||||
// node new in, or moved into, a kept folder is fetched by checking
|
|
||||||
// that folder.
|
|
||||||
let kept = match !*is_trashed && !ours {
|
|
||||||
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
|
|
||||||
false => None,
|
|
||||||
};
|
|
||||||
let mut had_node = false;
|
let mut had_node = false;
|
||||||
core.for_each_mount(|st, notify| {
|
core.for_each_mount(|st, notify| {
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
@@ -183,12 +172,9 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Once, not once per mount: the content cache is per-daemon.
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
if had_node && kept.is_none() {
|
if had_node {
|
||||||
core.cache.evict(node_uid);
|
core.cache.evict(node_uid);
|
||||||
}
|
}
|
||||||
if let Some(kept) = kept {
|
|
||||||
dirty.kept.insert(kept);
|
|
||||||
}
|
|
||||||
// A create, rename or move-in shows up as a change to the parent
|
// A create, rename or move-in shows up as a change to the parent
|
||||||
// listing too, which is recorded for the end of the batch rather
|
// listing too, which is recorded for the end of the batch rather
|
||||||
// than acted on here — see [`DirtyParents`].
|
// than acted on here — see [`DirtyParents`].
|
||||||
@@ -219,8 +205,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
| DriveEvent::ScopeAccessLost { .. }
|
| DriveEvent::ScopeAccessLost { .. }
|
||||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
// Changes went by unseen, kept files among them (B282).
|
|
||||||
core.recheck_all_pins();
|
|
||||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
core.for_each_mount(|st, notify| {
|
core.for_each_mount(|st, notify| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
@@ -312,21 +296,6 @@ struct DirtyParents {
|
|||||||
/// Checked per mount — a mount that already lists every one of those
|
/// Checked per mount — a mount that already lists every one of those
|
||||||
/// children under that parent keeps its listing.
|
/// children under that parent keeps its listing.
|
||||||
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
||||||
/// Pinned files and folders the batch changed, for the pin keeper to
|
|
||||||
/// check once the listings above are dropped (`keeper`).
|
|
||||||
kept: HashSet<NodeUid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the pin keeper has to check after a remote change to `node_uid`,
|
|
||||||
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
|
|
||||||
/// else the parent, when a pinned folder holds it and the node is new there.
|
|
||||||
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
|
|
||||||
if core.cache.is_pinned(node_uid) {
|
|
||||||
return Some(node_uid.clone());
|
|
||||||
}
|
|
||||||
parent_uid
|
|
||||||
.filter(|parent| core.cache.is_pinned(parent))
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
||||||
@@ -507,9 +476,6 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
for uid in &dirty.kept {
|
|
||||||
core.recheck_pinned(uid);
|
|
||||||
}
|
|
||||||
// Another device changed the tree: whatever a front-end lists may have
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
// moved, including what is shared and what is in the trash.
|
// moved, including what is shared and what is in the trash.
|
||||||
core.events
|
core.events
|
||||||
|
|||||||
@@ -204,42 +204,18 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
||||||
/// against: a file there is an ordinary local file.
|
/// against: a file there is an ordinary local file.
|
||||||
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
route_within(core, mountpoint, path, |path| {
|
|
||||||
format!("{path} is in a mirrored folder; change the local file there instead")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
|
|
||||||
/// on-demand synced folder is a different Drive node from any file of the
|
|
||||||
/// same name under the primary mount, and keeping one offline did nothing for
|
|
||||||
/// the other: a program reading the synced folder's copy downloaded it
|
|
||||||
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
|
|
||||||
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
|
||||||
route_within(core, mountpoint, path, |path| {
|
|
||||||
format!(
|
|
||||||
"{path} is in a mirrored folder, which keeps all its files on this computer already"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
|
|
||||||
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
|
|
||||||
fn route_within(
|
|
||||||
core: &Core,
|
|
||||||
mountpoint: &Path,
|
|
||||||
path: &str,
|
|
||||||
mirrored: impl FnOnce(&str) -> String,
|
|
||||||
) -> CoreResult<(Core, PathBuf)> {
|
|
||||||
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
let in_mirror = core
|
let mirrored = core
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
if in_mirror {
|
if mirrored {
|
||||||
CoreError::invalid(mirrored(path))
|
CoreError::invalid(format!(
|
||||||
|
"{path} is in a mirrored folder; change the local file there instead"
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
@@ -255,18 +231,6 @@ fn route_within(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path the pin registry keeps for a pin made through `routed`: the
|
|
||||||
/// mount-relative path on the primary mount, as it always was, and the
|
|
||||||
/// absolute path the request named on an on-demand folder's mount, since
|
|
||||||
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
|
|
||||||
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
|
|
||||||
if routed.primary {
|
|
||||||
rel.to_path_buf()
|
|
||||||
} else {
|
|
||||||
Path::new(path).components().collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`route_change`] for a request that trashes or renames the node it names.
|
/// [`route_change`] for a request that trashes or renames the node it names.
|
||||||
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
||||||
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
||||||
@@ -557,8 +521,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(CtlRequest::Subscribe) => {
|
Ok(CtlRequest::Subscribe) => {
|
||||||
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -569,8 +533,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
|
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok((routed, rel)) => match routed.unpin(&rel) {
|
Ok(rel) => match core.unpin(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -877,23 +841,35 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
if archives.is_empty() {
|
if archives.is_empty() {
|
||||||
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
|
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
|
||||||
} else {
|
} else {
|
||||||
let paths = archives.into_iter().map(PathBuf::from).collect();
|
let paths: Vec<PathBuf> = archives.into_iter().map(PathBuf::from).collect();
|
||||||
start_takeout_import(core, paths, dry_run)
|
let missing: Vec<String> = paths
|
||||||
|
.iter()
|
||||||
|
.filter(|path| !path.is_file())
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
CtlResponse::error(CoreError::not_found(format!(
|
||||||
|
"cannot read: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
let count = paths.len();
|
||||||
|
match core.spawn_takeout_import(paths, dry_run) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"{} {count} archive(s)",
|
||||||
|
if dry_run { "scanning" } else { "importing" }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::ImportStatus) => {
|
Ok(CtlRequest::ImportStatus) => {
|
||||||
let report = crate::takeout::import_status();
|
let (running, summary) = crate::takeout::import_status();
|
||||||
CtlResponse::ImportStatus {
|
CtlResponse::ImportStatus { running, summary }
|
||||||
running: report.running,
|
|
||||||
summary: report.summary,
|
|
||||||
progress: report.progress,
|
|
||||||
resumable: report.resumable,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::ResumeImport) => match crate::takeout::resumable_archives() {
|
|
||||||
None => CtlResponse::error(CoreError::not_found("there is no import to resume")),
|
|
||||||
Some(paths) => start_takeout_import(core, paths, false),
|
|
||||||
},
|
|
||||||
Ok(CtlRequest::CancelImport) => {
|
Ok(CtlRequest::CancelImport) => {
|
||||||
if crate::takeout::cancel_import() {
|
if crate::takeout::cancel_import() {
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -1997,31 +1973,6 @@ pub(crate) fn run_control_socket(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check that every archive is there, then start the import in the background.
|
|
||||||
fn start_takeout_import(core: &Core, paths: Vec<PathBuf>, dry_run: bool) -> CtlResponse {
|
|
||||||
let missing: Vec<String> = paths
|
|
||||||
.iter()
|
|
||||||
.filter(|path| !path.is_file())
|
|
||||||
.map(|path| path.display().to_string())
|
|
||||||
.collect();
|
|
||||||
if !missing.is_empty() {
|
|
||||||
return CtlResponse::error(CoreError::not_found(format!(
|
|
||||||
"cannot read: {}",
|
|
||||||
missing.join(", ")
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let count = paths.len();
|
|
||||||
match core.spawn_takeout_import(paths, dry_run) {
|
|
||||||
Ok(()) => CtlResponse::Ok {
|
|
||||||
message: format!(
|
|
||||||
"{} {count} archive(s)",
|
|
||||||
if dry_run { "scanning" } else { "importing" }
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Err(e) => CtlResponse::error(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod request_limit_tests {
|
mod request_limit_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
//! Keeping pinned files on this computer after they were pinned.
|
|
||||||
//!
|
|
||||||
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
|
|
||||||
//! change from another device deleted the cached copy, a file added to a
|
|
||||||
//! pinned folder elsewhere was never fetched, and a download that failed while
|
|
||||||
//! the link was down stayed failed. Each of those left a file listed as kept
|
|
||||||
//! offline that the next read downloaded, and that could not be read at all
|
|
||||||
//! without a network (`docs/BUGS.md` B282).
|
|
||||||
//!
|
|
||||||
//! The keeper is one thread that checks pins again and downloads what is
|
|
||||||
//! missing or stale: every pin when the daemon starts and when the link comes
|
|
||||||
//! back, and the pins a batch of remote events touched once the batch is
|
|
||||||
//! applied. A remote change no longer deletes a pinned file's copy. The copy
|
|
||||||
//! is stored under the revision's `(mtime, size)`, so a read of a newer
|
|
||||||
//! revision does not take it, and the keeper replaces it once the new one is
|
|
||||||
//! downloaded.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use proton_drive_rs::NodeKind;
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
use super::{Core, is_local_uid, node_size, parse_uid};
|
|
||||||
|
|
||||||
/// How often the keeper looks for work.
|
|
||||||
const KEEPER_TICK: Duration = Duration::from_secs(1);
|
|
||||||
/// The first wait before a check that could not download everything is tried
|
|
||||||
/// again. It doubles on every failure, up to [`RETRY_MAX`].
|
|
||||||
const RETRY_FIRST: Duration = Duration::from_secs(30);
|
|
||||||
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
|
|
||||||
|
|
||||||
/// What the keeper owes a check.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub(crate) struct PinWork {
|
|
||||||
/// Every pin.
|
|
||||||
all: bool,
|
|
||||||
/// The pinned files and folders named here. A folder is checked whole.
|
|
||||||
nodes: HashSet<NodeUid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PinWork {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
!self.all && self.nodes.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge(&mut self, other: PinWork) {
|
|
||||||
self.all |= other.all;
|
|
||||||
self.nodes.extend(other.nodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file a pin keeps, as the tree knows it now.
|
|
||||||
pub(crate) struct KeptFile {
|
|
||||||
pub(crate) uid: NodeUid,
|
|
||||||
pub(crate) name: String,
|
|
||||||
pub(crate) mtime: i64,
|
|
||||||
pub(crate) size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Core {
|
|
||||||
/// Have the keeper check every pin.
|
|
||||||
pub(crate) fn recheck_all_pins(&self) {
|
|
||||||
self.pin_work.lock().all = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Have the keeper check the pinned file or folder `uid`.
|
|
||||||
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
|
|
||||||
self.pin_work.lock().nodes.insert(uid.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The keeper's loop, until the daemon stops. Starts with a check of every
|
|
||||||
/// pin, which catches up on whatever changed while the daemon was down.
|
|
||||||
///
|
|
||||||
/// Work that failed waits out a backoff of its own. New work does not wait
|
|
||||||
/// for it, so a file that keeps failing does not hold up the next change.
|
|
||||||
pub(crate) fn run_pin_keeper(&self) {
|
|
||||||
self.recheck_all_pins();
|
|
||||||
let mut backlog = PinWork::default();
|
|
||||||
let mut backlog_due = Instant::now();
|
|
||||||
let mut retry = RETRY_FIRST;
|
|
||||||
while self.shutdown.sleep(KEEPER_TICK) {
|
|
||||||
if !self.is_online() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut work = std::mem::take(&mut *self.pin_work.lock());
|
|
||||||
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
|
|
||||||
if retrying {
|
|
||||||
work.merge(std::mem::take(&mut backlog));
|
|
||||||
}
|
|
||||||
if work.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let failed = self.keep_pins(&work);
|
|
||||||
if failed.is_empty() {
|
|
||||||
if retrying {
|
|
||||||
retry = RETRY_FIRST;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
backlog.merge(failed);
|
|
||||||
backlog_due = Instant::now() + retry;
|
|
||||||
retry = (retry * 2).min(RETRY_MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download every file `work` covers whose cached copy is missing or
|
|
||||||
/// stale. Returns what could not be checked or downloaded.
|
|
||||||
fn keep_pins(&self, work: &PinWork) -> PinWork {
|
|
||||||
let mut failed = PinWork::default();
|
|
||||||
let roots: Vec<NodeUid> = if work.all {
|
|
||||||
self.cache
|
|
||||||
.list_pins()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|pin| parse_uid(&pin.uid))
|
|
||||||
.chain(work.nodes.iter().cloned())
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
work.nodes.iter().cloned().collect()
|
|
||||||
};
|
|
||||||
let mut seen: HashSet<u64> = HashSet::new();
|
|
||||||
let mut files: Vec<u64> = Vec::new();
|
|
||||||
for uid in roots {
|
|
||||||
if self.shutdown.is_stopping() {
|
|
||||||
return failed;
|
|
||||||
}
|
|
||||||
if !self.cache.is_pinned(&uid) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let found = {
|
|
||||||
let st = self.state();
|
|
||||||
st.by_uid.get(&uid).and_then(|&ino| {
|
|
||||||
let e = st.entries.get(&ino)?;
|
|
||||||
Some((ino, e.parent, e.node.is_folder()))
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let Some((ino, parent, is_folder)) = found else {
|
|
||||||
debug!(%uid, "pin keeper: pinned node is not in the tree");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if is_folder {
|
|
||||||
match self.pin_subtree_files(ino) {
|
|
||||||
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
|
|
||||||
failed.nodes.insert(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// An event about this file dropped its parent's listing. Listing
|
|
||||||
// it again brings the file's entry up to the revision Drive has
|
|
||||||
// now, which is what the cached copy is checked against.
|
|
||||||
if !work.all
|
|
||||||
&& parent != ino
|
|
||||||
&& let Err(e) = self.ensure_children(parent)
|
|
||||||
{
|
|
||||||
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
|
|
||||||
failed.nodes.insert(uid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if seen.insert(ino) {
|
|
||||||
files.push(ino);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stale: Vec<KeptFile> = files
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|ino| self.kept_file(ino))
|
|
||||||
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
|
|
||||||
.collect();
|
|
||||||
if stale.is_empty() {
|
|
||||||
return failed;
|
|
||||||
}
|
|
||||||
let job = self.transfers.begin_job("Updating files kept offline");
|
|
||||||
job.set_total(stale.len() as u64);
|
|
||||||
let (mut fetched, mut failures) = (0usize, 0usize);
|
|
||||||
for file in stale {
|
|
||||||
if self.shutdown.is_stopping() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Unpinned while the check ran.
|
|
||||||
if !self.cache.is_pinned(&file.uid) {
|
|
||||||
job.step();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
job.detail(file.name.clone());
|
|
||||||
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
|
|
||||||
Ok(()) => fetched += 1,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
|
|
||||||
failed.nodes.insert(file.uid);
|
|
||||||
failures += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
job.step();
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
fetched,
|
|
||||||
failed = failures,
|
|
||||||
"pin keeper: brought files kept offline up to date"
|
|
||||||
);
|
|
||||||
failed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
|
|
||||||
/// daemon still owes an upload for, or a file whose real size cannot be
|
|
||||||
/// read.
|
|
||||||
///
|
|
||||||
/// A queued write is ahead of Drive. Downloading Drive's revision would
|
|
||||||
/// cache what the write replaces, and the drain keeps the uploaded bytes
|
|
||||||
/// of a pinned file once they land.
|
|
||||||
///
|
|
||||||
/// A listing carries the ciphertext size until its size upgrade lands
|
|
||||||
/// (B12). Stored under that size, the copy never matched the plaintext it
|
|
||||||
/// holds, so every read downloaded the file again and every check
|
|
||||||
/// downloaded it once more.
|
|
||||||
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
|
|
||||||
let (uid, name, mtime, size, provisional) = {
|
|
||||||
let st = self.state();
|
|
||||||
let e = st.entries.get(&ino)?;
|
|
||||||
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
(
|
|
||||||
e.uid.clone(),
|
|
||||||
e.node.name.clone(),
|
|
||||||
e.node.modification_time,
|
|
||||||
node_size(&e.node),
|
|
||||||
claimed_size.is_none(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let size = match provisional {
|
|
||||||
true => self.resolve_provisional_size(ino, &uid)?,
|
|
||||||
false => size,
|
|
||||||
};
|
|
||||||
Some(KeptFile {
|
|
||||||
uid,
|
|
||||||
name,
|
|
||||||
mtime,
|
|
||||||
size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn work_that_failed_keeps_every_node_and_the_full_check() {
|
|
||||||
let mut backlog = PinWork::default();
|
|
||||||
assert!(backlog.is_empty());
|
|
||||||
let uid = parse_uid("vol~a").unwrap();
|
|
||||||
backlog.merge(PinWork {
|
|
||||||
all: false,
|
|
||||||
nodes: HashSet::from([uid.clone()]),
|
|
||||||
});
|
|
||||||
backlog.merge(PinWork {
|
|
||||||
all: true,
|
|
||||||
nodes: HashSet::from([uid.clone()]),
|
|
||||||
});
|
|
||||||
assert!(backlog.all);
|
|
||||||
assert_eq!(backlog.nodes.len(), 1);
|
|
||||||
assert!(!backlog.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-54
@@ -91,7 +91,6 @@ mod drive;
|
|||||||
use drive::{DriveApi, RevisionRead};
|
use drive::{DriveApi, RevisionRead};
|
||||||
mod events;
|
mod events;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod keeper;
|
|
||||||
mod link;
|
mod link;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
@@ -590,8 +589,6 @@ struct Core {
|
|||||||
/// Nudges the drain workers: set true and notify to have them re-examine
|
/// Nudges the drain workers: set true and notify to have them re-examine
|
||||||
/// the queue instead of waiting out their backoff.
|
/// the queue instead of waiting out their backoff.
|
||||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||||
/// The pins the keeper owes a check (`keeper`).
|
|
||||||
pin_work: Arc<Mutex<keeper::PinWork>>,
|
|
||||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||||
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||||
shutdown: Arc<shutdown::Shutdown>,
|
shutdown: Arc<shutdown::Shutdown>,
|
||||||
@@ -3168,28 +3165,6 @@ impl Core {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
|
|
||||||
///
|
|
||||||
/// A photo lives on the photos volume, which no location shows, so the
|
|
||||||
/// residency proof above never finds it. The timeline in the `photos` table
|
|
||||||
/// is this daemon's proof instead: it lists only photos on the user's own
|
|
||||||
/// volume, which are theirs to share. A photo in an album someone else
|
|
||||||
/// shared is not in it and stays refused.
|
|
||||||
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
|
|
||||||
resolve_anywhere_with(
|
|
||||||
uid,
|
|
||||||
|uid| self.owns_visible_uid(uid),
|
|
||||||
|uid| {
|
|
||||||
let uid = uid.to_string();
|
|
||||||
Ok(self.db.mirror_contains_uid(&uid)?
|
|
||||||
|| !self
|
|
||||||
.db
|
|
||||||
.photos_by_uid(std::slice::from_ref(&uid))?
|
|
||||||
.is_empty())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
||||||
let state = self.state();
|
let state = self.state();
|
||||||
let entry = state
|
let entry = state
|
||||||
@@ -4295,12 +4270,7 @@ impl Core {
|
|||||||
/// recursive pin and downloads every descendant file on a background thread,
|
/// recursive pin and downloads every descendant file on a background thread,
|
||||||
/// reported as a job: a big folder far outlasts the socket read timeout.
|
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||||
/// Returns a human message.
|
/// Returns a human message.
|
||||||
///
|
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
||||||
/// `recorded` is the path the pin registry keeps for the front ends to
|
|
||||||
/// open the pin by: `rel` itself on the primary mount, which they join to
|
|
||||||
/// the mountpoint, and the absolute local path on an on-demand folder's
|
|
||||||
/// mount, whose node is not reachable under the primary mountpoint at all.
|
|
||||||
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
|
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder, mtime, size) = {
|
let (name, is_folder, mtime, size) = {
|
||||||
let st = self.state();
|
let st = self.state();
|
||||||
@@ -4324,7 +4294,7 @@ impl Core {
|
|||||||
// Record the recursive pin first so every descendant is eviction-
|
// Record the recursive pin first so every descendant is eviction-
|
||||||
// exempt before we start filling the cache with the subtree.
|
// exempt before we start filling the cache with the subtree.
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, recorded, true)
|
.add_pin(&uid, rel, true)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
let folder = name.clone();
|
let folder = name.clone();
|
||||||
@@ -4334,15 +4304,9 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||||
return Ok(format!("{name}: downloading its files in the background"));
|
return Ok(format!("{name}: downloading its files in the background"));
|
||||||
}
|
}
|
||||||
// Stored under a provisional size, the copy would never match the
|
|
||||||
// plaintext it holds (`docs/BUGS.md` B282).
|
|
||||||
let size = match self.kept_file(ino) {
|
|
||||||
Some(file) if file.uid == uid => file.size,
|
|
||||||
_ => size,
|
|
||||||
};
|
|
||||||
self.fetch_content(&uid, &name, mtime, size)?;
|
self.fetch_content(&uid, &name, mtime, size)?;
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, recorded, false)
|
.add_pin(&uid, rel, false)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
@@ -4360,20 +4324,14 @@ impl Core {
|
|||||||
Ok(files) => {
|
Ok(files) => {
|
||||||
job.set_total(files.len() as u64);
|
job.set_total(files.len() as u64);
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
for ino in files {
|
for (uid, file, mtime, size) in files {
|
||||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A file with a queued write, or no readable size, is
|
job.detail(file.clone());
|
||||||
// left to the drain and the keeper (`keeper::kept_file`).
|
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||||
if let Some(file) = self.kept_file(ino) {
|
warn!(%uid, error = %e, "pin folder: download failed");
|
||||||
job.detail(file.name.clone());
|
failed += 1;
|
||||||
if let Err(e) =
|
|
||||||
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
|
|
||||||
{
|
|
||||||
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
job.step();
|
job.step();
|
||||||
}
|
}
|
||||||
@@ -4407,9 +4365,9 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inode of every file in the subtree rooted at folder `ino`.
|
/// Every file in the subtree rooted at folder `ino`.
|
||||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
|
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||||
let mut files: Vec<u64> = Vec::new();
|
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||||
let mut stack = vec![ino];
|
let mut stack = vec![ino];
|
||||||
while let Some(dir) = stack.pop() {
|
while let Some(dir) = stack.pop() {
|
||||||
if self.shutdown.is_stopping() {
|
if self.shutdown.is_stopping() {
|
||||||
@@ -4424,7 +4382,12 @@ impl Core {
|
|||||||
if e.node.is_folder() {
|
if e.node.is_folder() {
|
||||||
stack.push(k);
|
stack.push(k);
|
||||||
} else {
|
} else {
|
||||||
files.push(k);
|
files.push((
|
||||||
|
e.uid.clone(),
|
||||||
|
e.node.name.clone(),
|
||||||
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,9 +286,6 @@ impl Core {
|
|||||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||||
}
|
}
|
||||||
self.wake_drain();
|
self.wake_drain();
|
||||||
// Downloads for pins failed while the link was down, and the events
|
|
||||||
// that would have named the files are behind us (`docs/BUGS.md` B282).
|
|
||||||
self.recheck_all_pins();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||||
|
|||||||
@@ -511,7 +511,6 @@ pub(crate) fn mount_with(
|
|||||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||||
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
pin_work: Arc::new(Mutex::new(Default::default())),
|
|
||||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
@@ -622,17 +621,6 @@ pub(crate) fn mount_with(
|
|||||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep pinned files on this computer through changes made elsewhere, and
|
|
||||||
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
|
|
||||||
// B282). Its own thread: a check lists folders and downloads files.
|
|
||||||
{
|
|
||||||
let keeper_core = core.clone();
|
|
||||||
let spawned = std::thread::Builder::new()
|
|
||||||
.name("pdfs-pin-keeper".into())
|
|
||||||
.spawn(move || keeper_core.run_pin_keeper());
|
|
||||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||||
if matches!(host, Host::Daemon) {
|
if matches!(host, Host::Daemon) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
|
|||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||||||
ThumbnailBuildStatus, Topic, is_audio_file_name, is_raw_image_name, is_thumbnail_image_name,
|
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
||||||
is_thumbnail_media_name,
|
is_thumbnail_media_name,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{self, StoredPhoto};
|
use pdfs_core::db::{self, StoredPhoto};
|
||||||
@@ -76,36 +76,6 @@ const VIDEO_BOX_WALK_MAX: usize = 32;
|
|||||||
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
||||||
/// ffmpeg from holding a thumbnail permit.
|
/// ffmpeg from holding a thumbnail permit.
|
||||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
/// A song no larger than this is downloaded whole for its cover.
|
|
||||||
const AUDIO_WHOLE_MAX_BYTES: u64 = 4 * 1024 * 1024;
|
|
||||||
/// How much of a song's start is read first, to learn where its tags end.
|
|
||||||
const AUDIO_PROBE_BYTES: u64 = 64 * 1024;
|
|
||||||
/// How much of a song's start is read when its first bytes don't say where its
|
|
||||||
/// tags end: an Ogg comment packet, which holds the cover, has no size up front.
|
|
||||||
const AUDIO_HEAD_BYTES: u64 = 4 * 1024 * 1024;
|
|
||||||
/// Read past the tags at a song's start, so the first audio frames are there
|
|
||||||
/// for a reader that looks for them.
|
|
||||||
const AUDIO_HEAD_SLACK: u64 = 64 * 1024;
|
|
||||||
/// Largest start of a song read for its tags. A cover bigger than this is not
|
|
||||||
/// worth the download for a 512-pixel thumbnail.
|
|
||||||
const AUDIO_TAG_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
|
||||||
/// How much of a song's end is read in formats that keep their tags there
|
|
||||||
/// (APE tags in WavPack and Monkey's Audio, `id3 ` chunks in WAV and AIFF).
|
|
||||||
const AUDIO_TAIL_BYTES: u64 = 4 * 1024 * 1024;
|
|
||||||
/// Names of a folder's cover image, best first, matched without case and
|
|
||||||
/// extension. A song without a picture in its tags gets this image's.
|
|
||||||
const FOLDER_COVER_STEMS: [&str; 6] = [
|
|
||||||
"cover",
|
|
||||||
"folder",
|
|
||||||
"front",
|
|
||||||
"album",
|
|
||||||
"albumart",
|
|
||||||
"albumartsmall",
|
|
||||||
];
|
|
||||||
/// Image types a folder cover is taken in.
|
|
||||||
const FOLDER_COVER_EXTENSIONS: [&str; 4] = ["jpg", "jpeg", "png", "webp"];
|
|
||||||
/// Largest folder cover image downloaded whole for a song's thumbnail.
|
|
||||||
const FOLDER_COVER_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
|
||||||
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
|
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
|
||||||
/// this, stepping the JPEG quality down until it fits.
|
/// this, stepping the JPEG quality down until it fits.
|
||||||
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
|
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
|
||||||
@@ -660,7 +630,6 @@ enum Fetched {
|
|||||||
/// A whole image.
|
/// A whole image.
|
||||||
Whole(Vec<u8>),
|
Whole(Vec<u8>),
|
||||||
Video(VideoSample),
|
Video(VideoSample),
|
||||||
Audio(AudioSample),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parts of a video its thumbnail needs, downloaded: the start of the
|
/// The parts of a video its thumbnail needs, downloaded: the start of the
|
||||||
@@ -797,181 +766,13 @@ fn video_sample_thumbnail(
|
|||||||
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parts of a song its cover needs, downloaded: the start, where most
|
|
||||||
/// formats keep their tags, an M4A's index, and for formats that keep their
|
|
||||||
/// tags at the end, the end.
|
|
||||||
struct AudioSample {
|
|
||||||
/// The whole file's length.
|
|
||||||
size: u64,
|
|
||||||
/// Each part and the offset it came from.
|
|
||||||
parts: Vec<(u64, Vec<u8>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the tags at the start of a song end, read from its first `probe`
|
|
||||||
/// bytes: past an ID3v2 tag, and past FLAC's metadata blocks, which hold its
|
|
||||||
/// picture. `None` when the start says nothing, as for Ogg. A FLAC block
|
|
||||||
/// header past `probe` stops the walk with a guess of [`AUDIO_HEAD_BYTES`]
|
|
||||||
/// more.
|
|
||||||
fn audio_tags_end(probe: &[u8]) -> Option<u64> {
|
|
||||||
let mut offset = 0u64;
|
|
||||||
let mut known = false;
|
|
||||||
if probe.get(0..3) == Some(b"ID3") {
|
|
||||||
let size = probe.get(6..10)?;
|
|
||||||
if size.iter().any(|byte| byte & 0x80 != 0) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let size = size
|
|
||||||
.iter()
|
|
||||||
.fold(0u64, |acc, byte| (acc << 7) | u64::from(*byte));
|
|
||||||
let footer = probe.get(5).is_some_and(|flags| flags & 0x10 != 0);
|
|
||||||
offset = 10 + size + if footer { 10 } else { 0 };
|
|
||||||
known = true;
|
|
||||||
}
|
|
||||||
let start = usize::try_from(offset).ok()?;
|
|
||||||
if probe.get(start..start + 4) == Some(b"fLaC") {
|
|
||||||
offset += 4;
|
|
||||||
loop {
|
|
||||||
let at = usize::try_from(offset).ok()?;
|
|
||||||
let Some(header) = probe.get(at..at + 4) else {
|
|
||||||
return Some(offset + AUDIO_HEAD_BYTES);
|
|
||||||
};
|
|
||||||
let len = u64::from_be_bytes([0, 0, 0, 0, 0, header[1], header[2], header[3]]);
|
|
||||||
offset += 4 + len;
|
|
||||||
if header[0] & 0x80 != 0 {
|
|
||||||
return Some(offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
known.then_some(offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a song in format `name` may keep its tags at its end.
|
|
||||||
fn audio_tags_at_end(name: &str) -> bool {
|
|
||||||
let extension = name
|
|
||||||
.rsplit_once('.')
|
|
||||||
.map(|(_, extension)| extension.to_ascii_lowercase())
|
|
||||||
.unwrap_or_default();
|
|
||||||
matches!(extension.as_str(), "wv" | "ape" | "wav" | "aif" | "aiff")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download what the cover of the song `reader` reads, named `name`, needs. A
|
|
||||||
/// song of up to [`AUDIO_WHOLE_MAX_BYTES`] comes whole; a larger one brings its
|
|
||||||
/// tags, never its audio.
|
|
||||||
async fn sample_audio(reader: &dyn RevisionRead, name: &str) -> DriveResult<AudioSample> {
|
|
||||||
let size = reader.size();
|
|
||||||
if size <= AUDIO_WHOLE_MAX_BYTES {
|
|
||||||
let whole = reader.read_at(0, size).await?;
|
|
||||||
return Ok(AudioSample {
|
|
||||||
size,
|
|
||||||
parts: vec![(0, whole)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let probe = reader.read_at(0, AUDIO_PROBE_BYTES.min(size)).await?;
|
|
||||||
// An M4A keeps its cover in its index, fetched below wherever it sits.
|
|
||||||
let iso_media = probe.get(4..8) == Some(b"ftyp");
|
|
||||||
let want = match audio_tags_end(&probe) {
|
|
||||||
_ if iso_media => 0,
|
|
||||||
Some(end) => end.saturating_add(AUDIO_HEAD_SLACK),
|
|
||||||
None => AUDIO_HEAD_BYTES,
|
|
||||||
}
|
|
||||||
.min(AUDIO_TAG_MAX_BYTES)
|
|
||||||
.min(size);
|
|
||||||
let head = if want > probe.len() as u64 {
|
|
||||||
reader.read_at(0, want).await?
|
|
||||||
} else {
|
|
||||||
probe
|
|
||||||
};
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
match iso_media_index(reader, &head, size).await? {
|
|
||||||
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
|
|
||||||
parts.push((offset, reader.read_at(offset, len).await?));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
let head_len = head.len() as u64;
|
|
||||||
parts.push((0, head));
|
|
||||||
if audio_tags_at_end(name) {
|
|
||||||
let start = size.saturating_sub(AUDIO_TAIL_BYTES).max(head_len);
|
|
||||||
if start < size {
|
|
||||||
parts.push((start, reader.read_at(start, size - start).await?));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(AudioSample { size, parts })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The picture a song's tags carry: the front cover, or else the first one.
|
|
||||||
/// `None` for a song without one, or one lofty can't read.
|
|
||||||
fn embedded_cover(path: &Path) -> Option<Vec<u8>> {
|
|
||||||
use lofty::config::{ParseOptions, ParsingMode};
|
|
||||||
use lofty::file::TaggedFileExt as _;
|
|
||||||
use lofty::picture::PictureType;
|
|
||||||
|
|
||||||
let tagged = lofty::probe::Probe::open(path)
|
|
||||||
.ok()?
|
|
||||||
.options(
|
|
||||||
ParseOptions::new()
|
|
||||||
.read_properties(false)
|
|
||||||
.parsing_mode(ParsingMode::Relaxed),
|
|
||||||
)
|
|
||||||
.guess_file_type()
|
|
||||||
.ok()?
|
|
||||||
.read()
|
|
||||||
.ok()?;
|
|
||||||
let pictures = || tagged.tags().iter().flat_map(|tag| tag.pictures());
|
|
||||||
pictures()
|
|
||||||
.find(|picture| picture.pic_type() == PictureType::CoverFront)
|
|
||||||
.or_else(|| pictures().next())
|
|
||||||
.map(|picture| picture.data().to_vec())
|
|
||||||
.filter(|data| !data.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A thumbnail from the cover in a downloaded [`AudioSample`]'s tags. The
|
|
||||||
/// sample is laid out in a staging file at the offsets it came from, so the
|
|
||||||
/// tags are where the file's own headers say they are; the audio between
|
|
||||||
/// holds no data and costs no disk. A song without a cover, or with one the
|
|
||||||
/// image decoder can't read, is [`ScaleAttempt::Undecodable`].
|
|
||||||
fn audio_cover_thumbnail(sample: &AudioSample, name: &str, staging_dir: &Path) -> ScaleAttempt {
|
|
||||||
let Some(file) = RawTempFile::create_in(staging_dir, &[], name) else {
|
|
||||||
return ScaleAttempt::Unavailable;
|
|
||||||
};
|
|
||||||
let laid_out = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.open(&file.0)
|
|
||||||
.and_then(|staged| {
|
|
||||||
for (offset, bytes) in &sample.parts {
|
|
||||||
staged.write_all_at(bytes, *offset)?;
|
|
||||||
}
|
|
||||||
staged.set_len(sample.size)
|
|
||||||
});
|
|
||||||
if laid_out.is_err() {
|
|
||||||
return ScaleAttempt::Unavailable;
|
|
||||||
}
|
|
||||||
match embedded_cover(&file.0) {
|
|
||||||
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
|
|
||||||
None => ScaleAttempt::Undecodable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How good a folder cover `name` is: its place in [`FOLDER_COVER_STEMS`],
|
|
||||||
/// lowest best, or `None` for a file that is no cover image.
|
|
||||||
fn cover_rank(name: &str) -> Option<usize> {
|
|
||||||
let (stem, extension) = name.rsplit_once('.')?;
|
|
||||||
let extension = extension.to_ascii_lowercase();
|
|
||||||
if !FOLDER_COVER_EXTENSIONS.contains(&extension.as_str()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let stem = stem.to_ascii_lowercase();
|
|
||||||
FOLDER_COVER_STEMS.iter().position(|known| *known == stem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
||||||
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
||||||
/// the gallery has to download the whole file to make its own.
|
/// the gallery has to download the whole file to make its own.
|
||||||
///
|
///
|
||||||
/// Best effort: a file that isn't an image, a video or a song with a cover in
|
/// Best effort: a file that isn't an image or a video, is too large to read, or
|
||||||
/// its tags, is too large to read, or can't be decoded uploads without a
|
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
|
||||||
/// thumbnail, as it always did. Blocking and CPU-bound, so async callers run it
|
/// CPU-bound, so async callers run it on the blocking pool.
|
||||||
/// on the blocking pool.
|
|
||||||
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
|
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
|
||||||
if is_video_name(name) && !ffmpeg_available() {
|
if is_video_name(name) && !ffmpeg_available() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -1005,14 +806,6 @@ fn upload_thumbnails_with(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
|
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
|
||||||
} else if is_audio_file_name(name) {
|
|
||||||
// Only the song's own picture: a cover image beside it is not known
|
|
||||||
// here, where `path` may be a staging copy rather than the song's
|
|
||||||
// folder. The daemon finds that one when a listing asks.
|
|
||||||
match embedded_cover(path) {
|
|
||||||
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
|
|
||||||
None => return Vec::new(),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -1193,7 +986,7 @@ impl Core {
|
|||||||
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !self.refresh_timeline_joined()? {
|
if !self.rt.block_on(self.refresh_timeline())? {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||||
@@ -1234,7 +1027,6 @@ impl Core {
|
|||||||
favorite: photo.favorite,
|
favorite: photo.favorite,
|
||||||
group_size: photo.group_size as u32,
|
group_size: photo.group_size as u32,
|
||||||
has_raw: photo.has_raw,
|
has_raw: photo.has_raw,
|
||||||
place_id: photo.place_id,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1910,7 +1702,6 @@ impl Core {
|
|||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
}
|
}
|
||||||
let video = is_video_name(&name);
|
let video = is_video_name(&name);
|
||||||
let audio = is_audio_file_name(&name);
|
|
||||||
// Without ffmpeg a video can still have its uploader's
|
// Without ffmpeg a video can still have its uploader's
|
||||||
// thumbnail, but Photos has asked the server already.
|
// thumbnail, but Photos has asked the server already.
|
||||||
let can_sample = !video || ffmpeg_available();
|
let can_sample = !video || ffmpeg_available();
|
||||||
@@ -1965,7 +1756,6 @@ impl Core {
|
|||||||
|
|
||||||
let download_uid = uid.clone();
|
let download_uid = uid.clone();
|
||||||
let drive = core.drive.clone();
|
let drive = core.drive.clone();
|
||||||
let audio_name = name.clone();
|
|
||||||
let download = async move {
|
let download = async move {
|
||||||
// Photos asked the server in one batch already. A
|
// Photos asked the server in one batch already. A
|
||||||
// file view's or a build's file may carry a
|
// file view's or a build's file may carry a
|
||||||
@@ -1985,11 +1775,6 @@ impl Core {
|
|||||||
if video {
|
if video {
|
||||||
let reader = drive.open_revision(&download_uid).await?;
|
let reader = drive.open_revision(&download_uid).await?;
|
||||||
sample_video(reader.as_ref()).await.map(Fetched::Video)
|
sample_video(reader.as_ref()).await.map(Fetched::Video)
|
||||||
} else if audio {
|
|
||||||
let reader = drive.open_revision(&download_uid).await?;
|
|
||||||
sample_audio(reader.as_ref(), &audio_name)
|
|
||||||
.await
|
|
||||||
.map(Fetched::Audio)
|
|
||||||
} else {
|
} else {
|
||||||
client
|
client
|
||||||
.download_file(&download_uid)
|
.download_file(&download_uid)
|
||||||
@@ -2047,11 +1832,7 @@ impl Core {
|
|||||||
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
||||||
// stall the runtime's worker; hand it to the blocking pool.
|
// stall the runtime's worker; hand it to the blocking pool.
|
||||||
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||||
let staging = staging_dir.clone();
|
|
||||||
let made = tokio::task::spawn_blocking(move || match fetched {
|
let made = tokio::task::spawn_blocking(move || match fetched {
|
||||||
Fetched::Audio(sample) => {
|
|
||||||
audio_cover_thumbnail(&sample, &name, &staging_dir)
|
|
||||||
}
|
|
||||||
Fetched::Video(sample) => video_sample_thumbnail(
|
Fetched::Video(sample) => video_sample_thumbnail(
|
||||||
&sample,
|
&sample,
|
||||||
&name,
|
&name,
|
||||||
@@ -2068,14 +1849,6 @@ impl Core {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or(ScaleAttempt::Unavailable);
|
.unwrap_or(ScaleAttempt::Unavailable);
|
||||||
// A song without a picture of its own shows its
|
|
||||||
// folder's cover.
|
|
||||||
let made = match made {
|
|
||||||
ScaleAttempt::Undecodable if audio && core.thumb_job_current(job) => {
|
|
||||||
core.folder_cover_thumbnail(&uid, staging).await
|
|
||||||
}
|
|
||||||
made => made,
|
|
||||||
};
|
|
||||||
if !core.thumb_job_current(job) {
|
if !core.thumb_job_current(job) {
|
||||||
return (uid, ThumbAttempt::Cancelled);
|
return (uid, ThumbAttempt::Cancelled);
|
||||||
}
|
}
|
||||||
@@ -2136,85 +1909,9 @@ impl Core {
|
|||||||
ThumbAttempt::Cancelled => summary.cancelled += 1,
|
ThumbAttempt::Cancelled => summary.cancelled += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A folder on screen whose tiles were given up on, as a recursive
|
|
||||||
// build's start does to them, fills in without being opened again.
|
|
||||||
if summary.made > 0 {
|
|
||||||
self.events.publish(&[Topic::Thumbnails]);
|
|
||||||
}
|
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cover image in the folder of the song `uid`: the best-named one of
|
|
||||||
/// [`FOLDER_COVER_STEMS`] among its siblings the database knows.
|
|
||||||
fn folder_cover(&self, uid: &NodeUid) -> Option<proton_drive_rs::Node> {
|
|
||||||
let node = self.db.node_by_uid(&uid.to_string()).ok()??;
|
|
||||||
let parent = node.parent_uid?;
|
|
||||||
let siblings = match self.db.children_if_listed(&parent) {
|
|
||||||
Ok(Some(children)) => children,
|
|
||||||
_ => self.db.known_children(&parent).ok()?,
|
|
||||||
};
|
|
||||||
siblings
|
|
||||||
.into_iter()
|
|
||||||
.filter(|sibling| !sibling.is_folder() && !sibling.trashed)
|
|
||||||
.filter_map(|sibling| cover_rank(&sibling.name).map(|rank| (rank, sibling)))
|
|
||||||
.min_by(|(a, x), (b, y)| a.cmp(b).then_with(|| x.name.cmp(&y.name)))
|
|
||||||
.map(|(_, cover)| cover)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A thumbnail of the song `uid` made from its folder's cover image: the
|
|
||||||
/// cover's own cached thumbnail when it has one, else the thumbnail its
|
|
||||||
/// uploader attached, else the image itself, up to
|
|
||||||
/// [`FOLDER_COVER_MAX_BYTES`].
|
|
||||||
///
|
|
||||||
/// The result is stored under the song, so a cover added to the folder or
|
|
||||||
/// replaced later shows only once the song changes or the daemon restarts
|
|
||||||
/// and forgets the songs it found no cover for.
|
|
||||||
async fn folder_cover_thumbnail(&self, uid: &NodeUid, staging_dir: PathBuf) -> ScaleAttempt {
|
|
||||||
let Some(cover) = self.folder_cover(uid) else {
|
|
||||||
return ScaleAttempt::Undecodable;
|
|
||||||
};
|
|
||||||
let ttype = ThumbnailType::Thumbnail;
|
|
||||||
let cached = self
|
|
||||||
.cache
|
|
||||||
.cached_thumbnail_path(&cover.uid, ttype.as_i32(), cover.modification_time)
|
|
||||||
.and_then(|path| std::fs::read(path).ok());
|
|
||||||
let ready = match cached {
|
|
||||||
Some(bytes) => Some(bytes),
|
|
||||||
None => match self.drive.download_thumbnail(&cover.uid, ttype).await {
|
|
||||||
Ok(bytes) => bytes.filter(|bytes| !bytes.is_empty()),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(cover = %cover.uid, error = %e, "folder cover thumbnail download failed");
|
|
||||||
return ScaleAttempt::Unavailable;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if let Some(bytes) = ready {
|
|
||||||
return match ratio_of(&bytes) {
|
|
||||||
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
|
|
||||||
None => ScaleAttempt::Undecodable,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let read = async {
|
|
||||||
let reader = self.drive.open_revision(&cover.uid).await?;
|
|
||||||
if reader.size() > FOLDER_COVER_MAX_BYTES {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
reader.read_at(0, reader.size()).await.map(Some)
|
|
||||||
};
|
|
||||||
let bytes = match read.await {
|
|
||||||
Ok(Some(bytes)) => bytes,
|
|
||||||
Ok(None) => return ScaleAttempt::Undecodable,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(cover = %cover.uid, error = %e, "folder cover download failed");
|
|
||||||
return ScaleAttempt::Unavailable;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let name = cover.name;
|
|
||||||
tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name, &staging_dir))
|
|
||||||
.await
|
|
||||||
.unwrap_or(ScaleAttempt::Unavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus {
|
pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus {
|
||||||
self.thumbnail_build.lock().clone()
|
self.thumbnail_build.lock().clone()
|
||||||
}
|
}
|
||||||
@@ -2640,36 +2337,6 @@ impl Core {
|
|||||||
|
|
||||||
/// Refresh the timeline off the request path, so a stale page is still served
|
/// Refresh the timeline off the request path, so a stale page is still served
|
||||||
/// at DB speed. At most one refresh runs at a time.
|
/// at DB speed. At most one refresh runs at a time.
|
||||||
/// Refresh the timeline and wait for it, sharing a refresh already under
|
|
||||||
/// way instead of starting a second one beside it.
|
|
||||||
///
|
|
||||||
/// The first request after an empty cache has nothing to serve until the
|
|
||||||
/// fetch lands, so it waits. Two such requests (the gallery and its retry,
|
|
||||||
/// or a background refresh started by an import) used to each fetch the
|
|
||||||
/// whole library at once, sharing the session's rate limit and taking
|
|
||||||
/// minutes. Returns whether the account has a photos library.
|
|
||||||
pub(crate) fn refresh_timeline_joined(&self) -> CoreResult<bool> {
|
|
||||||
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
|
|
||||||
while self.timeline_refreshing.load(Ordering::SeqCst) && !self.shutdown.is_stopping() {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
// A refresh that landed stamped the timeline fresh. One that failed
|
|
||||||
// did not, and the caller gets this refresh's own error instead of
|
|
||||||
// an empty timeline.
|
|
||||||
if !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL)
|
|
||||||
|| self.timeline_refreshing.swap(true, Ordering::SeqCst)
|
|
||||||
{
|
|
||||||
return Ok(self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() != Some(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let refreshed = self.rt.block_on(self.refresh_timeline());
|
|
||||||
self.timeline_refreshing.store(false, Ordering::SeqCst);
|
|
||||||
if refreshed.is_ok() {
|
|
||||||
self.events.publish(&[Topic::Photos]);
|
|
||||||
}
|
|
||||||
refreshed
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn spawn_timeline_refresh(&self) {
|
pub(crate) fn spawn_timeline_refresh(&self) {
|
||||||
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
|
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
@@ -2786,10 +2453,6 @@ mod decode_budget_tests {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod thumb_tests {
|
mod thumb_tests {
|
||||||
use super::{
|
|
||||||
AUDIO_HEAD_BYTES, AUDIO_TAIL_BYTES, AudioSample, audio_cover_thumbnail, audio_tags_end,
|
|
||||||
cover_rank, sample_audio,
|
|
||||||
};
|
|
||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
||||||
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
|
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
|
||||||
@@ -3301,28 +2964,6 @@ mod thumb_tests {
|
|||||||
assert!(thumb.ratio > 0.0);
|
assert!(thumb.ratio > 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional check against real songs, through the same partial download a
|
|
||||||
/// Drive song gets. `PDFS_AUDIO_FIXTURE` lists files separated by `:`.
|
|
||||||
#[test]
|
|
||||||
#[ignore = "set PDFS_AUDIO_FIXTURE to songs with a cover in their tags"]
|
|
||||||
fn real_audio_fixture_yields_its_cover() {
|
|
||||||
let paths = std::env::var_os("PDFS_AUDIO_FIXTURE").expect("PDFS_AUDIO_FIXTURE");
|
|
||||||
for path in std::env::split_paths(&paths) {
|
|
||||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
|
||||||
let bytes = std::fs::read(&path).unwrap();
|
|
||||||
let revision = FakeRevision::new(bytes.len() as u64).with(0, bytes);
|
|
||||||
let sample = block_on(sample_audio(&revision, &name)).unwrap();
|
|
||||||
let read = revision.read.load(Ordering::Relaxed);
|
|
||||||
let attempt = audio_cover_thumbnail(&sample, &name, &std::env::temp_dir());
|
|
||||||
assert!(
|
|
||||||
matches!(attempt, ScaleAttempt::Made(_)),
|
|
||||||
"{name}: no cover, read {read} of {} bytes",
|
|
||||||
revision.size
|
|
||||||
);
|
|
||||||
eprintln!("{name}: read {read} of {} bytes", revision.size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn videos_are_told_apart_by_name() {
|
fn videos_are_told_apart_by_name() {
|
||||||
for name in [
|
for name in [
|
||||||
@@ -3479,201 +3120,4 @@ mod thumb_tests {
|
|||||||
"a small one is untouched"
|
"a small one is untouched"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `width`×`height` PNG, the cover a tag carries.
|
|
||||||
fn png(width: u32, height: u32) -> Vec<u8> {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height))
|
|
||||||
.write_to(
|
|
||||||
&mut std::io::Cursor::new(&mut bytes),
|
|
||||||
image::ImageFormat::Png,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An ID3v2.3 tag holding `picture` as its front cover.
|
|
||||||
fn id3_with_cover(picture: &[u8]) -> Vec<u8> {
|
|
||||||
let mut body = vec![0u8];
|
|
||||||
body.extend_from_slice(b"image/png\0");
|
|
||||||
body.push(3);
|
|
||||||
body.push(0);
|
|
||||||
body.extend_from_slice(picture);
|
|
||||||
let mut frame = b"APIC".to_vec();
|
|
||||||
frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
|
||||||
frame.extend_from_slice(&[0, 0]);
|
|
||||||
frame.extend_from_slice(&body);
|
|
||||||
let len = frame.len() as u32;
|
|
||||||
let mut tag = b"ID3\x03\x00\x00".to_vec();
|
|
||||||
tag.extend((0..4).rev().map(|i| ((len >> (7 * i)) & 0x7f) as u8));
|
|
||||||
tag.extend_from_slice(&frame);
|
|
||||||
tag
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ten silent MPEG-1 Layer III frames at 128 kbit/s and 44.1 kHz.
|
|
||||||
fn mpeg_frames() -> Vec<u8> {
|
|
||||||
let mut frame = vec![0u8; 417];
|
|
||||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
|
|
||||||
frame.repeat(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A FLAC stream's metadata: STREAMINFO, then `picture` as its front cover.
|
|
||||||
fn flac_with_cover(picture: &[u8]) -> Vec<u8> {
|
|
||||||
let mut info = vec![0x10, 0x00, 0x10, 0x00, 0, 0, 0, 0, 0, 0];
|
|
||||||
let packed: u64 = (44_100 << 44) | (1 << 41) | (15 << 36);
|
|
||||||
info.extend_from_slice(&packed.to_be_bytes());
|
|
||||||
info.extend_from_slice(&[0; 16]);
|
|
||||||
let mut block = Vec::new();
|
|
||||||
block.extend_from_slice(&3u32.to_be_bytes());
|
|
||||||
block.extend_from_slice(&9u32.to_be_bytes());
|
|
||||||
block.extend_from_slice(b"image/png");
|
|
||||||
block.extend_from_slice(&0u32.to_be_bytes());
|
|
||||||
for value in [16u32, 8, 24, 0, picture.len() as u32] {
|
|
||||||
block.extend_from_slice(&value.to_be_bytes());
|
|
||||||
}
|
|
||||||
block.extend_from_slice(picture);
|
|
||||||
let mut out = b"fLaC".to_vec();
|
|
||||||
out.push(0);
|
|
||||||
out.extend_from_slice(&(info.len() as u32).to_be_bytes()[1..]);
|
|
||||||
out.extend_from_slice(&info);
|
|
||||||
out.push(0x80 | 6);
|
|
||||||
out.extend_from_slice(&(block.len() as u32).to_be_bytes()[1..]);
|
|
||||||
out.extend_from_slice(&block);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn whole_song(bytes: Vec<u8>) -> AudioSample {
|
|
||||||
AudioSample {
|
|
||||||
size: bytes.len() as u64,
|
|
||||||
parts: vec![(0, bytes)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_song_shows_the_cover_in_its_tags() {
|
|
||||||
let tmp = scratch("song-cover");
|
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
|
||||||
let mut mp3 = id3_with_cover(&png(16, 8));
|
|
||||||
mp3.extend(mpeg_frames());
|
|
||||||
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&whole_song(mp3), "song.mp3", &tmp)
|
|
||||||
else {
|
|
||||||
panic!("an ID3 cover scales");
|
|
||||||
};
|
|
||||||
assert!((thumb.ratio - 2.0).abs() < 1e-6);
|
|
||||||
|
|
||||||
let ScaleAttempt::Made(thumb) =
|
|
||||||
audio_cover_thumbnail(&whole_song(flac_with_cover(&png(8, 16))), "song.flac", &tmp)
|
|
||||||
else {
|
|
||||||
panic!("a FLAC picture scales");
|
|
||||||
};
|
|
||||||
assert!((thumb.ratio - 0.5).abs() < 1e-6);
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_song_without_a_cover_is_no_thumbnail_of_its_own() {
|
|
||||||
let tmp = scratch("song-bare");
|
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
|
||||||
let attempt = audio_cover_thumbnail(&whole_song(mpeg_frames()), "song.mp3", &tmp);
|
|
||||||
assert!(matches!(attempt, ScaleAttempt::Undecodable));
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_start_of_a_song_says_where_its_tags_end() {
|
|
||||||
let tag = id3_with_cover(&png(4, 4));
|
|
||||||
assert_eq!(audio_tags_end(&tag), Some(tag.len() as u64));
|
|
||||||
let flac = flac_with_cover(&png(4, 4));
|
|
||||||
assert_eq!(audio_tags_end(&flac), Some(flac.len() as u64));
|
|
||||||
// FLAC after an ID3 tag, as some taggers write it.
|
|
||||||
let both = [tag.clone(), flac.clone()].concat();
|
|
||||||
assert_eq!(audio_tags_end(&both), Some(both.len() as u64));
|
|
||||||
// The picture's block header cut off by the end of the probe, just
|
|
||||||
// past STREAMINFO: a guess past it.
|
|
||||||
let cut = &flac[..4 + 4 + 34 + 2];
|
|
||||||
assert!(audio_tags_end(cut).is_some_and(|end| end > cut.len() as u64));
|
|
||||||
assert_eq!(audio_tags_end(b"OggS\0\0\0\0"), None);
|
|
||||||
assert_eq!(
|
|
||||||
audio_tags_end(b"ID3\x03\x00\x00\x80\0\0\0"),
|
|
||||||
None,
|
|
||||||
"not synchsafe"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_large_song_downloads_its_tags_not_its_audio() {
|
|
||||||
const SIZE: u64 = 300 << 20;
|
|
||||||
let mut start = id3_with_cover(&png(16, 8));
|
|
||||||
start.extend(mpeg_frames());
|
|
||||||
let revision = FakeRevision::new(SIZE).with(0, start);
|
|
||||||
let sample = block_on(sample_audio(&revision, "long mix.mp3")).unwrap();
|
|
||||||
let read = revision.read.load(Ordering::Relaxed);
|
|
||||||
assert!(read < 1 << 20, "read {read} bytes");
|
|
||||||
|
|
||||||
let tmp = scratch("song-sparse");
|
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
|
||||||
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&sample, "long mix.mp3", &tmp) else {
|
|
||||||
panic!("the cover comes from the start alone");
|
|
||||||
};
|
|
||||||
assert!((thumb.ratio - 2.0).abs() < 1e-6);
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
|
||||||
|
|
||||||
// Ogg says nothing up front: a bounded head. WavPack keeps its tags at
|
|
||||||
// the end: the tail too.
|
|
||||||
let revision = FakeRevision::new(SIZE);
|
|
||||||
block_on(sample_audio(&revision, "live.ogg")).unwrap();
|
|
||||||
assert!(revision.read.load(Ordering::Relaxed) <= AUDIO_HEAD_BYTES + (64 << 10));
|
|
||||||
let revision = FakeRevision::new(SIZE);
|
|
||||||
let sample = block_on(sample_audio(&revision, "live.wv")).unwrap();
|
|
||||||
assert!(
|
|
||||||
sample
|
|
||||||
.parts
|
|
||||||
.iter()
|
|
||||||
.any(|(offset, bytes)| *offset == SIZE - AUDIO_TAIL_BYTES
|
|
||||||
&& bytes.len() as u64 == AUDIO_TAIL_BYTES)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_uploaded_song_carries_its_cover() {
|
|
||||||
let tmp = scratch("upload-song");
|
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
|
||||||
// A staging blob has no extension: the format comes from the bytes.
|
|
||||||
let blob = tmp.join("blob");
|
|
||||||
let mut mp3 = id3_with_cover(&png(64, 64));
|
|
||||||
mp3.extend(mpeg_frames());
|
|
||||||
std::fs::write(&blob, mp3).unwrap();
|
|
||||||
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
|
||||||
let thumbs = upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str());
|
|
||||||
let [thumbnail] = thumbs.as_slice() else {
|
|
||||||
panic!("one thumbnail, got {}", thumbs.len());
|
|
||||||
};
|
|
||||||
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
|
|
||||||
assert_eq!(ratio_of(&thumbnail.content), Some(1.0));
|
|
||||||
|
|
||||||
std::fs::write(&blob, mpeg_frames()).unwrap();
|
|
||||||
assert!(
|
|
||||||
upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str()).is_empty(),
|
|
||||||
"a song without a cover uploads bare"
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_dir_all(&tmp);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_folder_cover_is_found_by_its_usual_names() {
|
|
||||||
assert_eq!(cover_rank("cover.jpg"), Some(0));
|
|
||||||
assert_eq!(cover_rank("Cover.JPG"), Some(0));
|
|
||||||
assert_eq!(cover_rank("folder.png"), Some(1));
|
|
||||||
assert_eq!(cover_rank("AlbumArtSmall.jpg"), Some(5));
|
|
||||||
assert!(cover_rank("cover.jpg") < cover_rank("front.webp"));
|
|
||||||
for name in [
|
|
||||||
"cover.txt",
|
|
||||||
"my cover.jpg",
|
|
||||||
"cover",
|
|
||||||
"back.jpg",
|
|
||||||
"cover.jpg.part",
|
|
||||||
] {
|
|
||||||
assert_eq!(cover_rank(name), None, "{name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -111,11 +111,6 @@ impl Core {
|
|||||||
// stale from here on, and the server may not have applied the swap yet.
|
// stale from here on, and the server may not have applied the swap yet.
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
// A file kept offline gets the restored content back on disk, and
|
|
||||||
// again when the event of the swap arrives (`docs/BUGS.md` B282).
|
|
||||||
if self.cache.is_pinned(uid) {
|
|
||||||
self.recheck_pinned(uid);
|
|
||||||
}
|
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Restore,
|
ActivityKind::Restore,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ impl Core {
|
|||||||
role: &str,
|
role: &str,
|
||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
) -> CoreResult<(usize, usize)> {
|
) -> CoreResult<(usize, usize)> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.share_node_for_uid(&uid, emails, role, message)
|
self.share_node_for_uid(&uid, emails, role, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ impl Core {
|
|||||||
&self,
|
&self,
|
||||||
uid: &str,
|
uid: &str,
|
||||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.list_share_for_uid(&uid)
|
self.list_share_for_uid(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ impl Core {
|
|||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
role: &str,
|
role: &str,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ impl Core {
|
|||||||
id: &str,
|
id: &str,
|
||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ impl Core {
|
|||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
expires: Option<i64>,
|
expires: Option<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ impl Core {
|
|||||||
password: LinkChange<String>,
|
password: LinkChange<String>,
|
||||||
expires: LinkChange<i64>,
|
expires: LinkChange<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ impl Core {
|
|||||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||||
/// location.
|
/// location.
|
||||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.remove_public_link_for_uid(&uid, id)
|
self.remove_public_link_for_uid(&uid, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +486,7 @@ impl Core {
|
|||||||
|
|
||||||
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||||
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||||
let uid = self.resolve_shareable(uid)?;
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
self.stop_sharing_for_uid(&uid)
|
self.stop_sharing_for_uid(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3447,118 +3447,6 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
|
||||||
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
|
|
||||||
// Keeping a file offline took only paths under the primary mount, so
|
|
||||||
// the copy in an on-demand synced folder could not be kept, and a
|
|
||||||
// program reading it there downloaded it every time (B281).
|
|
||||||
let drive = FakeDrive::new();
|
|
||||||
let dir = scratch("pin-in-ondemand");
|
|
||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
|
||||||
let ondemand = dir.join("ondemand");
|
|
||||||
sync_folder(&daemon, &ondemand, "ondemand");
|
|
||||||
|
|
||||||
let content = b"not a real png, but kept all the same".repeat(64);
|
|
||||||
let file = ondemand.join("photo.png");
|
|
||||||
std::fs::write(&file, &content).unwrap();
|
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
|
||||||
}));
|
|
||||||
daemon.request(&Request::PurgeCache).unwrap();
|
|
||||||
|
|
||||||
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
|
|
||||||
let pinned = daemon
|
|
||||||
.request(&Request::Pin { path: path.clone() })
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
|
|
||||||
let pins = || match daemon.request(&Request::ListPins) {
|
|
||||||
Ok(Response::Pins { pins }) => pins,
|
|
||||||
other => panic!("pins: {other:?}"),
|
|
||||||
};
|
|
||||||
let listed = pins();
|
|
||||||
assert!(
|
|
||||||
listed.iter().any(|pin| pin.path == path && pin.cached),
|
|
||||||
"the pin opens by the path it was made through: {listed:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// With the link gone, only a kept copy can answer the read.
|
|
||||||
daemon.client.set_online(false);
|
|
||||||
assert_eq!(std::fs::read(&file).unwrap(), content);
|
|
||||||
daemon.client.set_online(true);
|
|
||||||
|
|
||||||
let unpinned = daemon
|
|
||||||
.request(&Request::Unpin { path: path.clone() })
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
|
|
||||||
assert!(pins().is_empty());
|
|
||||||
|
|
||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
|
||||||
fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
|
|
||||||
// A change from another device deleted a pinned file's copy and
|
|
||||||
// nothing downloaded it again, and a file added to a pinned folder
|
|
||||||
// elsewhere was never downloaded: both were listed as kept offline
|
|
||||||
// and read only with a network (B282).
|
|
||||||
let drive = FakeDrive::new();
|
|
||||||
let dir = scratch("pin-keeper");
|
|
||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
|
||||||
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
|
|
||||||
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
|
|
||||||
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
|
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
|
||||||
&& drive.lookup("Docs/a.txt").is_some()
|
|
||||||
&& drive.lookup("solo.txt").is_some()
|
|
||||||
}));
|
|
||||||
daemon.request(&Request::PurgeCache).unwrap();
|
|
||||||
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
|
|
||||||
path: folder.into(),
|
|
||||||
}) {
|
|
||||||
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
for path in ["Docs", "solo.txt"] {
|
|
||||||
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
|
|
||||||
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
|
|
||||||
}
|
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
|
||||||
entry("Docs", "a.txt").is_some_and(|e| e.cached)
|
|
||||||
&& entry("", "solo.txt").is_some_and(|e| e.cached)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Another device edits both files and adds one to the kept folder.
|
|
||||||
drive.device().write("Docs/a.txt", b"second, and longer");
|
|
||||||
drive.device().write("solo.txt", b"alone, edited");
|
|
||||||
drive.device().write("Docs/new.txt", b"made elsewhere");
|
|
||||||
assert!(
|
|
||||||
wait_until(Duration::from_secs(60), || {
|
|
||||||
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
|
|
||||||
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
|
|
||||||
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
|
|
||||||
}),
|
|
||||||
"the keeper downloaded the new content: {:?} {:?} {:?}",
|
|
||||||
entry("Docs", "a.txt"),
|
|
||||||
entry("", "solo.txt"),
|
|
||||||
entry("Docs", "new.txt"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// With the link gone, only the kept copies can answer.
|
|
||||||
daemon.client.set_online(false);
|
|
||||||
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
|
|
||||||
assert_eq!(read("Docs/a.txt"), b"second, and longer");
|
|
||||||
assert_eq!(read("solo.txt"), b"alone, edited");
|
|
||||||
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
|
|
||||||
daemon.client.set_online(true);
|
|
||||||
|
|
||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The synced folder `id` as the daemon lists it.
|
/// The synced folder `id` as the daemon lists it.
|
||||||
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||||
match daemon.request(&Request::ListSyncFolders) {
|
match daemon.request(&Request::ListSyncFolders) {
|
||||||
@@ -3732,88 +3620,6 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A song with no picture in its tags shows the cover image in its folder,
|
|
||||||
/// and the daemon tells a following front-end that a thumbnail landed, so
|
|
||||||
/// a folder on screen fills in without being opened again.
|
|
||||||
#[test]
|
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
|
||||||
fn a_song_without_a_cover_shows_its_folders_and_says_so() {
|
|
||||||
let dir = scratch("song-cover");
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
// Silent MPEG-1 Layer III frames, without any tag.
|
|
||||||
let mut frame = vec![0u8; 417];
|
|
||||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
|
|
||||||
let song = frame.repeat(64);
|
|
||||||
let mut cover = Vec::new();
|
|
||||||
image::codecs::jpeg::JpegEncoder::new(&mut cover)
|
|
||||||
.encode_image(&image::RgbImage::new(300, 300))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let drive = FakeDrive::new();
|
|
||||||
drive.device().write("track one.mp3", &song);
|
|
||||||
drive.device().write("Cover.jpg", &cover);
|
|
||||||
let client = drive.client(1, Faults::lan());
|
|
||||||
let daemon = Daemon::start(&dir.join("daemon"), client).unwrap();
|
|
||||||
|
|
||||||
let landed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let mut subscription = control::subscribe(&daemon.socket).unwrap();
|
|
||||||
let flag = landed.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
// The first change names every topic: a new subscription catches up.
|
|
||||||
let mut caught_up = false;
|
|
||||||
while let Ok(Some(event)) = subscription.next_event() {
|
|
||||||
if let control::Event::Changed { topics } = event {
|
|
||||||
if caught_up && topics.contains(&control::Topic::Thumbnails) {
|
|
||||||
flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
caught_up = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
|
|
||||||
path: String::new(),
|
|
||||||
}) else {
|
|
||||||
panic!("listing");
|
|
||||||
};
|
|
||||||
let song = entries
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.name == "track one.mp3")
|
|
||||||
.expect("the song is listed");
|
|
||||||
let items = vec![control::FileThumbRequest {
|
|
||||||
uid: song.uid.clone(),
|
|
||||||
modified: song.modified,
|
|
||||||
name: song.name.clone(),
|
|
||||||
}];
|
|
||||||
let Ok(Response::FileThumbGeneration { generation }) =
|
|
||||||
daemon.request(&Request::ReserveFileThumbGeneration)
|
|
||||||
else {
|
|
||||||
panic!("generation");
|
|
||||||
};
|
|
||||||
let mut path = None;
|
|
||||||
let settled = wait_until(Duration::from_secs(60), || {
|
|
||||||
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
|
|
||||||
items: items.clone(),
|
|
||||||
generation,
|
|
||||||
}) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
path = replies.first().and_then(|reply| reply.path.clone());
|
|
||||||
path.is_some()
|
|
||||||
});
|
|
||||||
assert!(settled, "the song got no thumbnail");
|
|
||||||
let thumb = image::load_from_memory(&std::fs::read(path.unwrap()).unwrap()).unwrap();
|
|
||||||
assert_eq!(thumb.width(), thumb.height(), "the square cover");
|
|
||||||
assert!(
|
|
||||||
wait_until(Duration::from_secs(15), || landed
|
|
||||||
.load(std::sync::atomic::Ordering::SeqCst)),
|
|
||||||
"no thumbnails change was published"
|
|
||||||
);
|
|
||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A video in My files gets a thumbnail from a frame of its own, but a
|
/// A video in My files gets a thumbnail from a frame of its own, but a
|
||||||
/// recording can be gigabytes: only its start, and an MP4's index, are
|
/// recording can be gigabytes: only its start, and an MP4's index, are
|
||||||
/// downloaded for it. A video with a thumbnail from its uploader is not
|
/// downloaded for it. A video with a thumbnail from its uploader is not
|
||||||
|
|||||||
+236
-891
File diff suppressed because it is too large.
Load diff
@@ -61,28 +61,14 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
|
|||||||
|
|
||||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||||
.photo-viewer-window { background-color: #111014; }
|
.photo-viewer-window { background-color: #111014; }
|
||||||
.media-player-window video { background-color: black; }
|
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
||||||
.media-player-note { color: rgba(255, 255, 255, 0.5); }
|
|
||||||
.media-player-note .file-thumbnail { border-radius: 12px; }
|
|
||||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
|
|
||||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||||
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
|
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
||||||
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
||||||
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
|
||||||
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
.viewer-close-btn { margin-left: 12px; }
|
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
||||||
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||||
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
|
|
||||||
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
|
||||||
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
|
||||||
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
|
|
||||||
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
|
|
||||||
.viewer-strip { background: none; }
|
|
||||||
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
|
|
||||||
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
|
|
||||||
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
|
|
||||||
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
|
|
||||||
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||||
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||||
.viewer-nav-btn:disabled { opacity: 0; }
|
.viewer-nav-btn:disabled { opacity: 0; }
|
||||||
|
|||||||
@@ -128,22 +128,8 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
|||||||
if changed(Topic::Shares) {
|
if changed(Topic::Shares) {
|
||||||
refresh_invitations_badge(ui, Duration::ZERO);
|
refresh_invitations_badge(ui, Duration::ZERO);
|
||||||
}
|
}
|
||||||
// On any page: a grid, a list or a player may show a tile still blank.
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
if changed(Topic::Thumbnails) {
|
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
||||||
refresh_file_thumbnails(ui);
|
|
||||||
}
|
|
||||||
let page = ui.stack.visible_child_name();
|
|
||||||
// A timeline out of sight is read again when the user comes back to it;
|
|
||||||
// one on screen is left alone (below).
|
|
||||||
if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) {
|
|
||||||
ui.gallery.timeline_stale.set(true);
|
|
||||||
}
|
|
||||||
match page.as_deref() {
|
|
||||||
// A search on screen is left alone: reloading would swap its hits for
|
|
||||||
// the folder's listing while the query stays in the box.
|
|
||||||
Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => {
|
|
||||||
load_browser(ui)
|
|
||||||
}
|
|
||||||
// The timeline is left alone: reloading it would throw away the place
|
// The timeline is left alone: reloading it would throw away the place
|
||||||
// the user scrolled to. F5 reloads it on request.
|
// the user scrolled to. F5 reloads it on request.
|
||||||
Some("gallery")
|
Some("gallery")
|
||||||
|
|||||||
+15
-103
@@ -40,8 +40,6 @@ use widgets::crumb::*;
|
|||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
use widgets::photo_drag::*;
|
|
||||||
use widgets::player::*;
|
|
||||||
use widgets::scrubber::*;
|
use widgets::scrubber::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
@@ -77,11 +75,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView, PhotosView, WindowState};
|
|||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||||
DirEntry, ErrorKind, ImportPhase, ImportProgress, ImportSummary, InvitationInfo, JobItem,
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
||||||
LinkChange, MappingProgress, PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo,
|
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
||||||
PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, RevisionInfo,
|
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
||||||
SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase,
|
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
||||||
SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
@@ -459,8 +457,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
files: browser_widgets.files.clone(),
|
files: browser_widgets.files.clone(),
|
||||||
history: RefCell::new(Vec::new()),
|
history: RefCell::new(Vec::new()),
|
||||||
future: RefCell::new(Vec::new()),
|
future: RefCell::new(Vec::new()),
|
||||||
places: RefCell::new(HashMap::new()),
|
|
||||||
restore: RefCell::new(None),
|
|
||||||
crumb: browser_widgets.crumb.clone(),
|
crumb: browser_widgets.crumb.clone(),
|
||||||
content: browser_widgets.content.clone(),
|
content: browser_widgets.content.clone(),
|
||||||
status: browser_widgets.status.clone(),
|
status: browser_widgets.status.clone(),
|
||||||
@@ -527,7 +523,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
jump: Cell::new(None),
|
jump: Cell::new(None),
|
||||||
burst: Cell::new(false),
|
burst: Cell::new(false),
|
||||||
import_banner: gallery_widgets.import_banner.clone(),
|
import_banner: gallery_widgets.import_banner.clone(),
|
||||||
upload_bar: gallery_widgets.upload_bar.clone(),
|
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||||
title: gallery_widgets.title.clone(),
|
title: gallery_widgets.title.clone(),
|
||||||
@@ -668,11 +663,9 @@ fn build_window(app: &adw::Application) {
|
|||||||
progress_bar: takeout_widgets.progress_bar.clone(),
|
progress_bar: takeout_widgets.progress_bar.clone(),
|
||||||
summary_group: takeout_widgets.summary_group.clone(),
|
summary_group: takeout_widgets.summary_group.clone(),
|
||||||
summary_rows: RefCell::new(Vec::new()),
|
summary_rows: RefCell::new(Vec::new()),
|
||||||
resume_group: takeout_widgets.resume_group.clone(),
|
|
||||||
resume_row: takeout_widgets.resume_row.clone(),
|
|
||||||
resume_button: takeout_widgets.resume_button.clone(),
|
|
||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
running: Cell::new(false),
|
running: Cell::new(false),
|
||||||
|
dry_run: Cell::new(false),
|
||||||
},
|
},
|
||||||
update,
|
update,
|
||||||
});
|
});
|
||||||
@@ -733,26 +726,17 @@ fn build_window(app: &adw::Application) {
|
|||||||
cancel_file_thumbnails(&ui_nav);
|
cancel_file_thumbnails(&ui_nav);
|
||||||
sync_sidebar(&ui_nav);
|
sync_sidebar(&ui_nav);
|
||||||
match st.visible_child_name().as_deref() {
|
match st.visible_child_name().as_deref() {
|
||||||
Some("browser") => reload_browser_view(&ui_nav),
|
Some("browser") => load_browser(&ui_nav),
|
||||||
// The Albums and Places listings are re-read in place. Loading the
|
// The Albums and Places listings are re-read in place. Loading the
|
||||||
// timeline instead would swap it in under the Albums or Places toggle.
|
// timeline instead would swap it in under the Albums or Places toggle.
|
||||||
// The timeline, an album or a place comes back as it was left,
|
|
||||||
// scrolled where it was, unless photos changed while it was out of
|
|
||||||
// sight (see `follow_changes`) or nothing is loaded yet.
|
|
||||||
Some("gallery") if !ui_nav.gallery.in_collection() => {
|
Some("gallery") if !ui_nav.gallery.in_collection() => {
|
||||||
match ui_nav.gallery.view_switch.active() {
|
match ui_nav.gallery.view_switch.active() {
|
||||||
VIEW_ALBUMS => load_albums(&ui_nav),
|
VIEW_ALBUMS => load_albums(&ui_nav),
|
||||||
VIEW_PLACES => load_places(&ui_nav),
|
VIEW_PLACES => load_places(&ui_nav),
|
||||||
_ if ui_nav.gallery.timeline_stale.replace(false)
|
_ => load_gallery(&ui_nav, false),
|
||||||
|| ui_nav.gallery.model.n_items() == 0 =>
|
|
||||||
{
|
|
||||||
load_gallery(&ui_nav, false)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false),
|
Some("gallery") => load_gallery(&ui_nav, false),
|
||||||
Some("gallery") => {}
|
|
||||||
// Every visit reads the page again. The rows already on screen stay
|
// Every visit reads the page again. The rows already on screen stay
|
||||||
// up while it does, so arriving costs no "Loading…" flash.
|
// up while it does, so arriving costs no "Loading…" flash.
|
||||||
Some("sharedbyme") => load_shared_by_me(&ui_nav),
|
Some("sharedbyme") => load_shared_by_me(&ui_nav),
|
||||||
@@ -1082,7 +1066,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
match ui.stack.visible_child_name().as_deref() {
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
Some("browser") => {
|
Some("browser") => {
|
||||||
let path = ui.browser.path.borrow().clone();
|
let path = ui.browser.path.borrow().clone();
|
||||||
refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view);
|
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||||
}
|
}
|
||||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||||
// the views — a grid, or the timeline, an album or a place — is on screen.
|
// the views — a grid, or the timeline, an album or a place — is on screen.
|
||||||
@@ -1344,7 +1328,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
// The Albums view's New Album button.
|
// The Albums view's New Album button.
|
||||||
let new_album = gio::SimpleAction::new("new-album", None);
|
let new_album = gio::SimpleAction::new("new-album", None);
|
||||||
let ui_album = ui.clone();
|
let ui_album = ui.clone();
|
||||||
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
|
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
||||||
window.add_action(&new_album);
|
window.add_action(&new_album);
|
||||||
|
|
||||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||||
@@ -1522,7 +1506,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
|||||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||||
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
||||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||||
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
|
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
||||||
(
|
(
|
||||||
gettext_noop("General"),
|
gettext_noop("General"),
|
||||||
&[
|
&[
|
||||||
@@ -1555,7 +1539,6 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
||||||
("<Control>0", gettext_noop("Reset thumbnail size")),
|
("<Control>0", gettext_noop("Reset thumbnail size")),
|
||||||
("<Control>a", gettext_noop("Select all")),
|
("<Control>a", gettext_noop("Select all")),
|
||||||
("Escape <Alt>Left", gettext_noop("Back")),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1565,20 +1548,8 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|||||||
("Home End", gettext_noop("First / last photo")),
|
("Home End", gettext_noop("First / last photo")),
|
||||||
("i", gettext_noop("Show details")),
|
("i", gettext_noop("Show details")),
|
||||||
("f", gettext_noop("Fullscreen")),
|
("f", gettext_noop("Fullscreen")),
|
||||||
("F5", gettext_noop("Slideshow")),
|
|
||||||
("F10", gettext_noop("More")),
|
|
||||||
("<Control>c", gettext_noop("Copy Image")),
|
|
||||||
("Delete", gettext_noop("Move to Trash")),
|
("Delete", gettext_noop("Move to Trash")),
|
||||||
("Escape <Alt>Left", gettext_noop("Close")),
|
("Escape", gettext_noop("Close")),
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
gettext_noop("Video Player"),
|
|
||||||
&[
|
|
||||||
("space k", gettext_noop("Play / pause")),
|
|
||||||
("j l", gettext_noop("Back / forward 10 seconds")),
|
|
||||||
("m", gettext_noop("Mute")),
|
|
||||||
("f", gettext_noop("Fullscreen")),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -1604,17 +1575,8 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
controller.connect_key_pressed(move |_, key, _, state| {
|
controller.connect_key_pressed(move |_, key, _, state| {
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||||
let page = ui.stack.visible_child_name();
|
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
||||||
let on_browser = page.as_deref() == Some("browser");
|
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
|
||||||
let on_trash = page.as_deref() == Some("trash");
|
|
||||||
let on_gallery = page.as_deref() == Some("gallery");
|
|
||||||
// Pages with a level to go back to, besides My files' own history.
|
|
||||||
let in_collection = on_gallery && ui.gallery.in_collection();
|
|
||||||
let in_shared_folder =
|
|
||||||
page.as_deref() == Some("shared") && !ui.shared.nav.borrow().is_empty();
|
|
||||||
let in_device_folder =
|
|
||||||
page.as_deref() == Some("devices") && !ui.devices.nav.borrow().is_empty();
|
|
||||||
let back_key = (key == gtk4::gdk::Key::Left && alt) || key == gtk4::gdk::Key::BackSpace;
|
|
||||||
match key {
|
match key {
|
||||||
// Refresh works on every page, so it is matched before the
|
// Refresh works on every page, so it is matched before the
|
||||||
// browser-only bindings.
|
// browser-only bindings.
|
||||||
@@ -1671,21 +1633,6 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
gtk4::gdk::Key::BackSpace if on_browser => {
|
gtk4::gdk::Key::BackSpace if on_browser => {
|
||||||
ui.browser.actions.activate_action("back", None)
|
ui.browser.actions.activate_action("back", None)
|
||||||
}
|
}
|
||||||
// Selection mode ends before Escape leaves anything; the grid
|
|
||||||
// handles this itself while it has the focus.
|
|
||||||
gtk4::gdk::Key::Escape if on_gallery && ui.gallery.selecting.get() => {
|
|
||||||
set_selection_mode(&ui, false)
|
|
||||||
}
|
|
||||||
// An album or a place is a level below Photos, as a folder is
|
|
||||||
// below its parent: Back leaves it, and so does Escape.
|
|
||||||
_ if in_collection
|
|
||||||
&& !ui.gallery.selecting.get()
|
|
||||||
&& (back_key || key == gtk4::gdk::Key::Escape) =>
|
|
||||||
{
|
|
||||||
ui.gallery.back.emit_clicked()
|
|
||||||
}
|
|
||||||
_ if in_shared_folder && back_key => shared_back(&ui),
|
|
||||||
_ if in_device_folder && back_key => devices_back(&ui),
|
|
||||||
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
||||||
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
|
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
|
||||||
if let Some(list) = visible_file_list(&ui) {
|
if let Some(list) = visible_file_list(&ui) {
|
||||||
@@ -2027,7 +1974,6 @@ mod tests {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
group_size: 2,
|
group_size: 2,
|
||||||
has_raw: kind == PhotoKind::Raw,
|
has_raw: kind == PhotoKind::Raw,
|
||||||
place_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2050,40 +1996,6 @@ mod tests {
|
|||||||
assert!(next_group_member(&[], "jpeg").is_none());
|
assert!(next_group_member(&[], "jpeg").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The upload bar counts the upload under way, not the ones finished.
|
|
||||||
#[test]
|
|
||||||
fn the_upload_bar_counts_the_upload_under_way() {
|
|
||||||
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
|
|
||||||
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pill names what kind of shot it switches through, and lights up
|
|
||||||
/// on the file that is not the shot's main one.
|
|
||||||
#[test]
|
|
||||||
fn the_group_pill_names_the_kind_of_shot() {
|
|
||||||
let pair = [
|
|
||||||
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
|
||||||
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
|
||||||
];
|
|
||||||
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
|
|
||||||
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
|
|
||||||
|
|
||||||
let live = [
|
|
||||||
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
|
|
||||||
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
|
|
||||||
];
|
|
||||||
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
|
|
||||||
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
|
|
||||||
|
|
||||||
let burst = [
|
|
||||||
member("a", None, PhotoKind::Photo),
|
|
||||||
member("b", None, PhotoKind::Photo),
|
|
||||||
member("c", None, PhotoKind::Photo),
|
|
||||||
];
|
|
||||||
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
|
|
||||||
assert_eq!(group_pill(&burst, "elsewhere"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The switch names what it will show; a file whose name the daemon has not
|
/// The switch names what it will show; a file whose name the daemon has not
|
||||||
/// resolved still gets a sentence rather than a blank.
|
/// resolved still gets a sentence rather than a blank.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -225,13 +225,12 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64
|
|||||||
/// while the entry is blank.
|
/// while the entry is blank.
|
||||||
fn prompt_album_name(
|
fn prompt_album_name(
|
||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
parent: Option<gtk4::Window>,
|
|
||||||
heading: &str,
|
heading: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
current: &str,
|
current: &str,
|
||||||
on_name: impl Fn(String) + 'static,
|
on_name: impl Fn(String) + 'static,
|
||||||
) {
|
) {
|
||||||
let win = parent.or_else(|| ui_window(ui));
|
let win = ui_window(ui);
|
||||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
let row = adw::EntryRow::builder()
|
let row = adw::EntryRow::builder()
|
||||||
@@ -261,12 +260,10 @@ fn prompt_album_name(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ask for a name and create an album, then add `photos` to it, if any.
|
/// Ask for a name and create an album, then add `photos` to it, if any.
|
||||||
///
|
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||||
/// The dialogs open over `parent`, or over the main window without one.
|
|
||||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
|
||||||
let ui_c = ui.clone();
|
let ui_c = ui.clone();
|
||||||
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
||||||
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
|
prompt_album_name(ui, &heading, &action, "", move |name| {
|
||||||
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||||
let ui = ui_c.clone();
|
let ui = ui_c.clone();
|
||||||
let photos = photos.clone();
|
let photos = photos.clone();
|
||||||
@@ -299,7 +296,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<
|
|||||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
||||||
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
|
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
|
||||||
run_album_request(
|
run_album_request(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
Request::RenameAlbum {
|
Request::RenameAlbum {
|
||||||
@@ -371,7 +368,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Offer our own albums to add `photos` to, plus a new one.
|
/// Offer our own albums to add `photos` to, plus a new one.
|
||||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -393,7 +390,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Opti
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if albums.is_empty() {
|
if albums.is_empty() {
|
||||||
prompt_new_album(&ui, photos, parent);
|
prompt_new_album(&ui, photos);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,11 +436,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Opti
|
|||||||
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let (ui_c, parent_c) = (ui.clone(), parent.clone());
|
let ui_c = ui.clone();
|
||||||
dialog.connect_response(Some("new"), move |_, _| {
|
dialog.connect_response(Some("new"), move |_, _| {
|
||||||
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
|
prompt_new_album(&ui_c, photos.clone())
|
||||||
});
|
});
|
||||||
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
|
dialog.present(ui_window(&ui).as_ref());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +595,10 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
|||||||
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.await_thumb(&uid, picture);
|
ui.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(uid.clone(), picture.clone());
|
||||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||||
if !queue.contains(&uid) {
|
if !queue.contains(&uid) {
|
||||||
queue.push_back(uid);
|
queue.push_back(uid);
|
||||||
@@ -664,25 +664,12 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
close_place(&ui_photos);
|
close_place(&ui_photos);
|
||||||
let gallery = &ui_photos.gallery;
|
ui_photos.gallery.filters.set_visible(true);
|
||||||
gallery.filters.set_visible(true);
|
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||||
gallery.content.set_visible_child_name("timeline");
|
|
||||||
// An album or a place cleared the kind and the month it does not use;
|
|
||||||
// the filter controls still show them, so the timeline comes back
|
|
||||||
// filtered the way they say.
|
|
||||||
let kind = kind_for_tab(gallery.tabs.active() as usize);
|
|
||||||
let range = gallery
|
|
||||||
.date_ranges
|
|
||||||
.borrow()
|
|
||||||
.get(gallery.dates.selected() as usize)
|
|
||||||
.copied()
|
|
||||||
.flatten();
|
|
||||||
let kind_changed = gallery.kind.replace(kind) != kind;
|
|
||||||
let range_changed = gallery.range.replace(range) != range;
|
|
||||||
let refiltered = kind_changed || range_changed;
|
|
||||||
// The timeline comes back as it was left unless an album took over the
|
// The timeline comes back as it was left unless an album took over the
|
||||||
// model since; then it has to be reloaded.
|
// model since; then it has to be reloaded.
|
||||||
if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 {
|
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
||||||
|
{
|
||||||
load_gallery(&ui_photos, false);
|
load_gallery(&ui_photos, false);
|
||||||
} else {
|
} else {
|
||||||
update_gallery_subtitle(&ui_photos);
|
update_gallery_subtitle(&ui_photos);
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
/// How a folder in My files was left: scrolled `offset` px down, with the
|
|
||||||
/// entries named `selected` highlighted.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
|
||||||
pub(crate) struct FolderPlace {
|
|
||||||
pub(crate) offset: f64,
|
|
||||||
pub(crate) selected: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct BrowserState {
|
pub(crate) struct BrowserState {
|
||||||
// Files (browser) page.
|
// Files (browser) page.
|
||||||
/// The grid and list; the model is repopulated per directory.
|
/// The grid and list; the model is repopulated per directory.
|
||||||
@@ -18,13 +10,6 @@ pub(crate) struct BrowserState {
|
|||||||
/// breadcrumb.
|
/// breadcrumb.
|
||||||
pub(crate) history: RefCell<Vec<String>>,
|
pub(crate) history: RefCell<Vec<String>>,
|
||||||
pub(crate) future: RefCell<Vec<String>>,
|
pub(crate) future: RefCell<Vec<String>>,
|
||||||
/// Where each folder left by Back, Forward, Up or a descent was scrolled
|
|
||||||
/// and what was selected in it, by path. Back, Forward and Up put the
|
|
||||||
/// folder back that way; opening it afresh starts at the top.
|
|
||||||
pub(crate) places: RefCell<HashMap<String, FolderPlace>>,
|
|
||||||
/// The folder whose [`Self::places`] entry the next listing restores, set
|
|
||||||
/// by Back, Forward and Up.
|
|
||||||
pub(crate) restore: RefCell<Option<String>>,
|
|
||||||
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
|
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
|
||||||
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
|
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
|
||||||
pub(crate) crumb: gtk4::Box,
|
pub(crate) crumb: gtk4::Box,
|
||||||
@@ -898,15 +883,27 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
|||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
// Open does what a double-click does: a video or a song plays in the app,
|
|
||||||
// and Open With is the way to another player.
|
|
||||||
menu.item(&pgettext("verb", "Open"), move || {
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
activate_entry(&ui_c, &entry_c)
|
// Open always means "download a local copy and hand off", even for a
|
||||||
|
// media file — `activate_entry` would otherwise stream it.
|
||||||
|
if is_streamable_media_entry(&entry_c) {
|
||||||
|
download_and_open(&ui_c, &entry_c);
|
||||||
|
} else {
|
||||||
|
activate_entry(&ui_c, &entry_c);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Open With…"), move || {
|
menu.item(&gettext("Open With…"), move || {
|
||||||
open_entry_with(&ui_c, &entry_c)
|
open_entry_with(&ui_c, &entry_c)
|
||||||
});
|
});
|
||||||
|
// Play streams from the mount, no download; Open above still fetches a
|
||||||
|
// local copy for anyone who wants one.
|
||||||
|
if is_streamable_media_entry(entry) {
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&pgettext("verb", "Play"), move || {
|
||||||
|
stream_entry(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
menu.section();
|
menu.section();
|
||||||
|
|
||||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||||
@@ -1095,8 +1092,38 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
|
|||||||
Some(entry)
|
Some(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an entry the Nautilus way: folders descend, media plays in the app,
|
/// Whether an entry can be opened through FUSE instead of fully materialized.
|
||||||
/// streamed from the mount, other files download-and-open.
|
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
|
||||||
|
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream media straight from the mount, no download. Drive folders *are* part
|
||||||
|
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
|
||||||
|
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
|
||||||
|
/// seeks and buffers — instead of waiting for the whole file to land like
|
||||||
|
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
|
||||||
|
/// `.mkv` starts playing in seconds.
|
||||||
|
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let rel = entry_rel(ui, entry);
|
||||||
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
|
let abs = mounted_path(&mountpoint, &rel);
|
||||||
|
let Some(path) = abs.to_str() else {
|
||||||
|
toast_error(
|
||||||
|
ui,
|
||||||
|
&gettext("Couldn't play media"),
|
||||||
|
&gettext("The file path isn't valid UTF-8."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
|
||||||
|
);
|
||||||
|
play_external(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
|
||||||
|
/// other files download-and-open.
|
||||||
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
@@ -1109,16 +1136,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
||||||
// Media streams rather than downloads: that is exactly the "play it,
|
// Media streams rather than downloads: that is exactly the "play it,
|
||||||
// don't fetch the whole thing" behaviour this is for.
|
// don't fetch the whole thing" behaviour this is for.
|
||||||
open_media_player(ui, entry);
|
stream_entry(ui, entry);
|
||||||
} else {
|
} else {
|
||||||
download_and_open(ui, entry);
|
download_and_open(ui, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a file to the user's default application: through the mount when it is
|
/// Hand a file to the user's default application: through the mount when it is
|
||||||
/// there, otherwise downloaded into the cache first. The open path behind a
|
/// there, otherwise downloaded into the cache first. The open path behind both
|
||||||
/// double-click and the context menu's "Open" for anything but a folder or
|
/// a plain double-click and the context menu's "Open" (including for a video,
|
||||||
/// media, and for media the mount doesn't show.
|
/// when the user wants their default application rather than the player).
|
||||||
///
|
///
|
||||||
/// The mount comes first because the cache blob is a *copy* keyed by content
|
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||||
/// hash — an application saving into it writes where Drive will never look, and
|
/// hash — an application saving into it writes where Drive will never look, and
|
||||||
@@ -1529,29 +1556,8 @@ pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remember how the folder on screen is scrolled and what is selected in it,
|
|
||||||
/// for Back, Forward or Up to bring it back that way. Only a listing of the
|
|
||||||
/// folder itself counts; search hits are not its rows.
|
|
||||||
fn remember_place(ui: &Rc<Ui>) {
|
|
||||||
let path = ui.browser.path.borrow().clone();
|
|
||||||
if ui.browser.listed.borrow().as_deref() != Some(path.as_str()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let files = &ui.browser.files;
|
|
||||||
let place = FolderPlace {
|
|
||||||
offset: files.scroll_offset(),
|
|
||||||
selected: files
|
|
||||||
.selected()
|
|
||||||
.into_iter()
|
|
||||||
.map(|entry| entry.name)
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
ui.browser.places.borrow_mut().insert(path, place);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the folder on screen in a new place, remembering where we were.
|
/// Open the folder on screen in a new place, remembering where we were.
|
||||||
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
|
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
|
||||||
remember_place(ui);
|
|
||||||
let left = ui.browser.path.replace(target.clone());
|
let left = ui.browser.path.replace(target.clone());
|
||||||
if left != target {
|
if left != target {
|
||||||
ui.browser.history.borrow_mut().push(left);
|
ui.browser.history.borrow_mut().push(left);
|
||||||
@@ -1576,8 +1582,6 @@ fn browse_back(ui: &Rc<Ui>) {
|
|||||||
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
|
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
remember_place(ui);
|
|
||||||
*ui.browser.restore.borrow_mut() = Some(previous.clone());
|
|
||||||
let left = ui.browser.path.replace(previous);
|
let left = ui.browser.path.replace(previous);
|
||||||
ui.browser.future.borrow_mut().push(left);
|
ui.browser.future.borrow_mut().push(left);
|
||||||
load_browser(ui);
|
load_browser(ui);
|
||||||
@@ -1588,8 +1592,6 @@ fn browse_forward(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
ui.browser.search.set_text("");
|
ui.browser.search.set_text("");
|
||||||
remember_place(ui);
|
|
||||||
*ui.browser.restore.borrow_mut() = Some(next.clone());
|
|
||||||
let left = ui.browser.path.replace(next);
|
let left = ui.browser.path.replace(next);
|
||||||
ui.browser.history.borrow_mut().push(left);
|
ui.browser.history.borrow_mut().push(left);
|
||||||
load_browser(ui);
|
load_browser(ui);
|
||||||
@@ -1602,7 +1604,6 @@ fn browse_up(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
ui.browser.search.set_text("");
|
ui.browser.search.set_text("");
|
||||||
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
|
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
|
||||||
*ui.browser.restore.borrow_mut() = Some(parent.to_string());
|
|
||||||
browse_to(ui, parent.to_string());
|
browse_to(ui, parent.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2920,18 +2921,6 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read again what My files shows: the search hits while there is a query,
|
|
||||||
/// the folder otherwise. [`load_browser`] alone would swap a search's hits
|
|
||||||
/// for the folder while the query stays in the box.
|
|
||||||
pub(crate) fn reload_browser_view(ui: &Rc<Ui>) {
|
|
||||||
let query = ui.browser.search.text().trim().to_string();
|
|
||||||
if query.is_empty() {
|
|
||||||
load_browser(ui);
|
|
||||||
} else {
|
|
||||||
run_search(ui, &query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the model and show the daemon's error on the status page. Used for
|
/// Clear the model and show the daemon's error on the status page. Used for
|
||||||
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
|
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
|
||||||
/// (which restarts the service) wouldn't help and isn't offered.
|
/// (which restarts the service) wouldn't help and isn't offered.
|
||||||
@@ -2970,10 +2959,7 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
|||||||
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
show_location_column(ui, false);
|
show_location_column(ui, false);
|
||||||
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
||||||
// `listed` still names the folder the rows belong to only on a refresh;
|
*ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
|
||||||
// any other load forgot it (see `load_browser`).
|
|
||||||
let path = ui.browser.path.borrow().clone();
|
|
||||||
let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path);
|
|
||||||
ui.browser.summary.set_label(&listing_summary(
|
ui.browser.summary.set_label(&listing_summary(
|
||||||
entries.iter().filter(|entry| !entry.is_dir).count(),
|
entries.iter().filter(|entry| !entry.is_dir).count(),
|
||||||
entries.iter().filter(|entry| entry.is_dir).count(),
|
entries.iter().filter(|entry| entry.is_dir).count(),
|
||||||
@@ -3002,16 +2988,6 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
// Only the rows that changed are swapped, so a refresh keeps the
|
// Only the rows that changed are swapped, so a refresh keeps the
|
||||||
// selection, the scroll position and the thumbnails already drawn.
|
// selection, the scroll position and the thumbnails already drawn.
|
||||||
replace_items(&ui.browser.files.model, &sorted);
|
replace_items(&ui.browser.files.model, &sorted);
|
||||||
// Back, Forward and Up return to a folder as it was left; any other way
|
|
||||||
// in starts at the top.
|
|
||||||
let restore = ui.browser.restore.take().filter(|target| *target == path);
|
|
||||||
if another_folder {
|
|
||||||
let place = restore.and_then(|path| ui.browser.places.borrow().get(&path).cloned());
|
|
||||||
match place {
|
|
||||||
Some(place) => ui.browser.files.restore(place.offset, &place.selected),
|
|
||||||
None => ui.browser.files.scroll_to_top(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget which folder the rows belong to and drop the selection, before the
|
/// Forget which folder the rows belong to and drop the selection, before the
|
||||||
@@ -3190,9 +3166,6 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
.collect();
|
.collect();
|
||||||
sort_entries(&mut entries, ui.browser.view.get());
|
sort_entries(&mut entries, ui.browser.view.get());
|
||||||
replace_items(&ui.browser.files.model, &entries);
|
replace_items(&ui.browser.files.model, &entries);
|
||||||
// Every search starts over (`run_search` forgets the listing), so its hits
|
|
||||||
// read from the first one.
|
|
||||||
ui.browser.files.scroll_to_top();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||||
|
|||||||
@@ -190,9 +190,10 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets
|
widgets.back.connect_clicked(move |_| {
|
||||||
.back
|
ui_back.devices.nav.borrow_mut().pop();
|
||||||
.connect_clicked(move |_| devices_back(&ui_back));
|
load_devices(&ui_back);
|
||||||
|
});
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.retry
|
.retry
|
||||||
@@ -210,14 +211,6 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
|||||||
ui.devices.content.set_visible_child_name("status");
|
ui.devices.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Back out of a computer's folder to the level above: the page's Back button, and
|
|
||||||
/// Alt+Left or Backspace. A no-op at the top.
|
|
||||||
pub(crate) fn devices_back(ui: &Rc<Ui>) {
|
|
||||||
if ui.devices.nav.borrow_mut().pop().is_some() {
|
|
||||||
load_devices(ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
|
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
|
||||||
/// of another computer's backup.
|
/// of another computer's backup.
|
||||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||||
@@ -304,7 +297,6 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
};
|
};
|
||||||
ui.devices.content.set_visible_child_name("browse");
|
ui.devices.content.set_visible_child_name("browse");
|
||||||
repaint_device_crumb(&ui, &nav);
|
repaint_device_crumb(&ui, &nav);
|
||||||
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
|
|
||||||
*ui.devices.listed.borrow_mut() = Some(nav);
|
*ui.devices.listed.borrow_mut() = Some(nav);
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
ui.devices.files.model.remove_all();
|
ui.devices.files.model.remove_all();
|
||||||
@@ -312,10 +304,6 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
} else {
|
} else {
|
||||||
ui.devices.files_content.set_visible_child_name("list");
|
ui.devices.files_content.set_visible_child_name("list");
|
||||||
replace_items(&ui.devices.files.model, &entries);
|
replace_items(&ui.devices.files.model, &entries);
|
||||||
// A refresh keeps its place; another folder starts at the top.
|
|
||||||
if another_folder {
|
|
||||||
ui.devices.files.scroll_to_top();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ pub(crate) struct Viewer {
|
|||||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||||
pub(crate) favorite: gtk4::ToggleButton,
|
pub(crate) favorite: gtk4::ToggleButton,
|
||||||
pub(crate) favorite_suppress: Cell<bool>,
|
pub(crate) favorite_suppress: Cell<bool>,
|
||||||
/// The pill that switches between the files of one shot, and the files it
|
/// The button that switches between the files of one shot, and the files it
|
||||||
/// switches between (the JPEG and the RAW of a photo, a live photo and its
|
/// switches between (the JPEG and the RAW of a photo, a live photo and its
|
||||||
/// clip). Empty, and the pill hidden, for a photo stored as one file.
|
/// clip). Empty, and the button hidden, for a photo stored as one file.
|
||||||
pub(crate) group_btn: gtk4::Button,
|
pub(crate) group_btn: gtk4::Button,
|
||||||
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
||||||
/// uid of the photo currently on screen.
|
/// uid of the photo currently on screen.
|
||||||
@@ -81,25 +81,14 @@ pub(crate) struct Viewer {
|
|||||||
/// rests; whether the pointer is over one of them (which keeps them up);
|
/// rests; whether the pointer is over one of them (which keeps them up);
|
||||||
/// and the pending fade-out.
|
/// and the pending fade-out.
|
||||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||||
/// The strip of thumbnails along the bottom, its selection (the photo on
|
|
||||||
/// screen), and the revealer it fades with. Hidden in a slideshow.
|
|
||||||
pub(crate) strip: gtk4::ListView,
|
|
||||||
pub(crate) strip_selection: gtk4::SingleSelection,
|
|
||||||
pub(crate) strip_reveal: gtk4::Revealer,
|
|
||||||
pub(crate) chrome_hover: Cell<bool>,
|
pub(crate) chrome_hover: Cell<bool>,
|
||||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||||
/// The ⋮ button, and whether its menu is open, which keeps the controls
|
/// The slideshow toggle; the timer that moves it on; and whether it put
|
||||||
/// up the way resting the pointer on them does.
|
/// the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||||
pub(crate) more: gtk4::Button,
|
|
||||||
pub(crate) menu_open: Cell<bool>,
|
|
||||||
/// Whether a slideshow runs; the timer that moves it on; and whether it
|
|
||||||
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
|
|
||||||
/// entered itself.
|
/// entered itself.
|
||||||
pub(crate) slideshow: Cell<bool>,
|
pub(crate) slideshow: gtk4::ToggleButton,
|
||||||
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) slide_fullscreened: Cell<bool>,
|
pub(crate) slide_fullscreened: Cell<bool>,
|
||||||
/// Toasts over the lightbox. The main window's would land behind it.
|
|
||||||
pub(crate) toasts: adw::ToastOverlay,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||||
@@ -174,14 +163,6 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
|
|||||||
.group_btn
|
.group_btn
|
||||||
.set_tooltip_text(Some(&member_tooltip(next)));
|
.set_tooltip_text(Some(&member_tooltip(next)));
|
||||||
}
|
}
|
||||||
if let Some((label, lit)) = group_pill(&items, &uid) {
|
|
||||||
viewer.group_btn.set_label(&label);
|
|
||||||
if lit {
|
|
||||||
viewer.group_btn.add_css_class("lit");
|
|
||||||
} else {
|
|
||||||
viewer.group_btn.remove_css_class("lit");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*viewer.group.borrow_mut() = items;
|
*viewer.group.borrow_mut() = items;
|
||||||
viewer.group_btn.set_visible(true);
|
viewer.group_btn.set_visible(true);
|
||||||
});
|
});
|
||||||
@@ -194,34 +175,6 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
|
|||||||
members.get((at + 1) % members.len())
|
members.get((at + 1) % members.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the group pill reads for the shot whose files are `members`, with
|
|
||||||
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
|
|
||||||
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
|
|
||||||
/// a burst counts its frames.
|
|
||||||
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
|
|
||||||
let at = members.iter().position(|item| item.uid == uid)?;
|
|
||||||
let shown = members[at].kind;
|
|
||||||
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
|
|
||||||
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
|
||||||
return Some((gettext("RAW"), shown == PhotoKind::Raw));
|
|
||||||
}
|
|
||||||
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
|
|
||||||
// Translators: the switch between a live photo and its short clip in the photo viewer.
|
|
||||||
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
|
|
||||||
}
|
|
||||||
Some((
|
|
||||||
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
|
|
||||||
gettext_f(
|
|
||||||
"{position} of {total}",
|
|
||||||
&[
|
|
||||||
("position", &thousands(at + 1)),
|
|
||||||
("total", &thousands(members.len())),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What to call one file of a shot: its name, or its kind when the daemon has
|
/// What to call one file of a shot: its name, or its kind when the daemon has
|
||||||
/// not resolved a name for it.
|
/// not resolved a name for it.
|
||||||
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
||||||
@@ -279,7 +232,6 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
clear_info(viewer);
|
clear_info(viewer);
|
||||||
stop_video(viewer);
|
stop_video(viewer);
|
||||||
viewer.media.set_visible_child_name("photo");
|
viewer.media.set_visible_child_name("photo");
|
||||||
sync_strip(viewer);
|
|
||||||
reset_zoom(viewer);
|
reset_zoom(viewer);
|
||||||
// A slideshow waits for this photo to land before it starts counting.
|
// A slideshow waits for this photo to land before it starts counting.
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
@@ -357,7 +309,6 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
None => {
|
None => {
|
||||||
viewer.video.set_filename(Some(&path));
|
viewer.video.set_filename(Some(&path));
|
||||||
viewer.media.set_visible_child_name("video");
|
viewer.media.set_visible_child_name("video");
|
||||||
sync_strip(&viewer);
|
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*viewer.path.borrow_mut() = Some(path.clone());
|
||||||
show_info(&viewer, size, ExifInfo::default());
|
show_info(&viewer, size, ExifInfo::default());
|
||||||
}
|
}
|
||||||
@@ -390,15 +341,14 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
||||||
/// screen counted down once it has loaded.
|
/// screen counted down once it has loaded.
|
||||||
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
if viewer.slideshow.replace(true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if !viewer.window.is_fullscreen() {
|
if !viewer.window.is_fullscreen() {
|
||||||
viewer.window.fullscreen();
|
viewer.window.fullscreen();
|
||||||
viewer.slide_fullscreened.set(true);
|
viewer.slide_fullscreened.set(true);
|
||||||
}
|
}
|
||||||
|
viewer
|
||||||
|
.slideshow
|
||||||
|
.set_icon_name("media-playback-pause-symbolic");
|
||||||
hide_chrome(viewer);
|
hide_chrome(viewer);
|
||||||
sync_strip(viewer);
|
|
||||||
if !viewer.loading.get() {
|
if !viewer.loading.get() {
|
||||||
arm_slide(ui, viewer);
|
arm_slide(ui, viewer);
|
||||||
}
|
}
|
||||||
@@ -406,20 +356,13 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|||||||
|
|
||||||
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||||
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||||
viewer.slideshow.set(false);
|
|
||||||
sync_strip(viewer);
|
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
if viewer.slide_fullscreened.replace(false) {
|
if viewer.slide_fullscreened.replace(false) {
|
||||||
viewer.window.unfullscreen();
|
viewer.window.unfullscreen();
|
||||||
}
|
}
|
||||||
}
|
viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic");
|
||||||
|
if viewer.slideshow.is_active() {
|
||||||
/// Start the slideshow, or stop the one running.
|
viewer.slideshow.set_active(false);
|
||||||
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
if viewer.slideshow.get() {
|
|
||||||
stop_slideshow(viewer);
|
|
||||||
} else {
|
|
||||||
start_slideshow(ui, viewer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +370,7 @@ fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|||||||
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
||||||
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
disarm_slide(viewer);
|
disarm_slide(viewer);
|
||||||
if !viewer.slideshow.get() {
|
if !viewer.slideshow.is_active() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||||
@@ -847,7 +790,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
|
|||||||
};
|
};
|
||||||
// This timer has fired, so its id must not be removed again.
|
// This timer has fired, so its id must not be removed again.
|
||||||
viewer.chrome_source.borrow_mut().take();
|
viewer.chrome_source.borrow_mut().take();
|
||||||
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
|
if !viewer.chrome_hover.get() {
|
||||||
hide_chrome(&viewer);
|
hide_chrome(&viewer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -865,14 +808,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the photo on screen to where the user picks, under its Drive name.
|
/// Copy the photo on screen to where the user picks, under its Drive name.
|
||||||
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
|
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &str, name: &str) {
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
.title(gettext("Save a Copy"))
|
.title(gettext("Save a Copy"))
|
||||||
.initial_name(name)
|
.initial_name(name)
|
||||||
.build();
|
.build();
|
||||||
let source = source.to_string();
|
let source = source.to_string();
|
||||||
let toasts = viewer.toasts.clone();
|
let ui = ui.clone();
|
||||||
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
|
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
|
||||||
// An error here is the dialog being dismissed.
|
// An error here is the dialog being dismissed.
|
||||||
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
||||||
return;
|
return;
|
||||||
@@ -881,67 +824,26 @@ pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str)
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let copied =
|
let copied =
|
||||||
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
||||||
let message = match copied {
|
match copied {
|
||||||
Ok(Ok(dest)) => {
|
Ok(Ok(dest)) => {
|
||||||
let saved = dest.file_name().map_or_else(
|
let saved = dest.file_name().map_or_else(
|
||||||
|| dest.display().to_string(),
|
|| dest.display().to_string(),
|
||||||
|name| name.to_string_lossy().into_owned(),
|
|name| name.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
// Translators: {name} is the saved file's name.
|
// Translators: {name} is the saved file's name.
|
||||||
gettext_f("Saved “{name}”", &[("name", &saved)])
|
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
|
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
|
||||||
Err(_) => failure(
|
Err(_) => toast_error(
|
||||||
|
&ui,
|
||||||
&gettext("Couldn't save the copy"),
|
&gettext("Couldn't save the copy"),
|
||||||
&gettext("The copy was interrupted."),
|
&gettext("The copy was interrupted."),
|
||||||
),
|
),
|
||||||
};
|
}
|
||||||
toasts.add_toast(
|
|
||||||
adw::Toast::builder()
|
|
||||||
.title(&message)
|
|
||||||
.use_markup(false)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failed action's toast text, worded like [`toast_error`]'s.
|
|
||||||
fn failure(what: &str, detail: &str) -> String {
|
|
||||||
tracing::warn!("{what}: {detail}");
|
|
||||||
// Translators: a failed action's toast. {what} says what failed, such as
|
|
||||||
// "Couldn't rename"; {detail} is the reason, usually untranslated text
|
|
||||||
// from the mount service.
|
|
||||||
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Put the photo on screen on the clipboard as an image, for pasting into a
|
|
||||||
/// chat or a document.
|
|
||||||
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
let Some(texture) = viewer
|
|
||||||
.picture
|
|
||||||
.paintable()
|
|
||||||
.and_downcast::<gtk4::gdk::Texture>()
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
viewer.window.clipboard().set_texture(&texture);
|
|
||||||
viewer.toasts.add_toast(
|
|
||||||
adw::Toast::builder()
|
|
||||||
// Translators: toast after the photo viewer put the photo on the clipboard.
|
|
||||||
.title(gettext("Image copied"))
|
|
||||||
.use_markup(false)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the Share dialog for the photo on screen, over the lightbox.
|
|
||||||
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
let uid = viewer.uid.borrow().clone();
|
|
||||||
let name = viewer.name.borrow().clone().unwrap_or_default();
|
|
||||||
share_photo(ui, uid, name, Some(viewer.window.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
||||||
/// of its own below `base`. The cache names files by content hash, with no
|
/// of its own below `base`. The cache names files by content hash, with no
|
||||||
/// extension; the app chooser and the app it launches both need the real name
|
/// extension; the app chooser and the app it launches both need the real name
|
||||||
@@ -1081,124 +983,16 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
|
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
|
||||||
/// 2026, 13:42".
|
|
||||||
fn photo_title(capture_time: i64) -> String {
|
|
||||||
let title = dates::full(capture_time);
|
|
||||||
if title.is_empty() {
|
|
||||||
gettext("Unknown Date")
|
|
||||||
} else {
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the lightbox's title (the photo's date) and the line under it: the town
|
|
||||||
/// it was taken in, when known, and its "12 of 340" counter.
|
|
||||||
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
||||||
let loaded = ui.gallery.model.n_items();
|
let loaded = ui.gallery.model.n_items();
|
||||||
let more = ui.gallery.has_more.get();
|
let more = ui.gallery.has_more.get();
|
||||||
viewer.prev.set_sensitive(index > 0);
|
viewer.prev.set_sensitive(index > 0);
|
||||||
viewer.next.set_sensitive(index + 1 < loaded || more);
|
viewer.next.set_sensitive(index + 1 < loaded || more);
|
||||||
viewer.title.set_label(&photo_title(capture_time));
|
viewer.title.set_label(&format_capture_time(capture_time));
|
||||||
let position = position_label(index, loaded, more, gallery_total(ui));
|
|
||||||
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
|
|
||||||
.and_then(|item| item.place_id)
|
|
||||||
.and_then(pdfs_core::places::city)
|
|
||||||
.map(|city| city.name);
|
|
||||||
let line = match place {
|
|
||||||
Some(place) => {
|
|
||||||
let args = [("place", place), ("position", position.as_str())];
|
|
||||||
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
|
|
||||||
gettext_f("{place} · {position}", &args)
|
|
||||||
}
|
|
||||||
None => position,
|
|
||||||
};
|
|
||||||
viewer.counter.set_label(&line);
|
|
||||||
sync_strip(viewer);
|
|
||||||
mark_in_strip(viewer, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show the strip of thumbnails when there is more than one photo to pick
|
|
||||||
/// from. Not in a slideshow, and not over a video, where it would cover the
|
|
||||||
/// player's seek bar.
|
|
||||||
fn sync_strip(viewer: &Rc<Viewer>) {
|
|
||||||
let video = viewer.media.visible_child_name().as_deref() == Some("video");
|
|
||||||
viewer
|
viewer
|
||||||
.strip_reveal
|
.counter
|
||||||
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
|
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark the photo at `index` in the strip and bring it into view.
|
|
||||||
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
|
|
||||||
if index >= viewer.strip_selection.n_items() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
viewer.strip_selection.set_selected(index);
|
|
||||||
viewer
|
|
||||||
.strip
|
|
||||||
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
|
|
||||||
/// photos in its order, the one on screen marked. A click steps straight to a
|
|
||||||
/// photo; the thumbnails come through the gallery's own pipeline and cache.
|
|
||||||
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
|
|
||||||
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
|
|
||||||
selection.set_autoselect(false);
|
|
||||||
selection.set_can_unselect(true);
|
|
||||||
|
|
||||||
let factory = gtk4::SignalListItemFactory::new();
|
|
||||||
factory.connect_setup(|_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let picture = gtk4::Picture::builder()
|
|
||||||
.content_fit(gtk4::ContentFit::Cover)
|
|
||||||
.width_request(56)
|
|
||||||
.height_request(56)
|
|
||||||
.can_shrink(true)
|
|
||||||
.build();
|
|
||||||
picture.add_css_class("viewer-strip-thumb");
|
|
||||||
// The arrow keys belong to the lightbox, not to the strip.
|
|
||||||
item.set_focusable(false);
|
|
||||||
item.set_child(Some(&picture));
|
|
||||||
});
|
|
||||||
let ui_bind = ui.clone();
|
|
||||||
factory.connect_bind(move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let photo = boxed.borrow::<PhotoItem>().clone();
|
|
||||||
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
|
||||||
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
|
|
||||||
want_thumb(&ui_bind, &photo, &picture);
|
|
||||||
schedule_thumbs(&ui_bind);
|
|
||||||
});
|
|
||||||
let ui_unbind = ui.clone();
|
|
||||||
factory.connect_unbind(move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let (Some(picture), Some(boxed)) = (
|
|
||||||
item.child().and_downcast::<gtk4::Picture>(),
|
|
||||||
item.item().and_downcast::<BoxedAnyObject>(),
|
|
||||||
) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let uid = boxed.borrow::<PhotoItem>().uid.clone();
|
|
||||||
ui_unbind
|
|
||||||
.gallery
|
|
||||||
.release_thumb(&uid, |waiting| waiting == &picture);
|
|
||||||
});
|
|
||||||
|
|
||||||
let list = gtk4::ListView::builder()
|
|
||||||
.model(&selection)
|
|
||||||
.factory(&factory)
|
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
|
||||||
.focusable(false)
|
|
||||||
.build();
|
|
||||||
list.add_css_class("viewer-strip");
|
|
||||||
(list, selection)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
||||||
@@ -1207,10 +1001,7 @@ fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
|
|||||||
///
|
///
|
||||||
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
|
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
|
||||||
/// button, or a click on the backdrop beside the photo all dismiss it.
|
/// button, or a click on the backdrop beside the photo all dismiss it.
|
||||||
///
|
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||||
/// Returns the lightbox's window, for a caller that has to put the page
|
|
||||||
/// behind it back once it closes.
|
|
||||||
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Window {
|
|
||||||
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
|
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
|
||||||
|
|
||||||
// An `adw::Window` for its breakpoints: the details panel lies over the
|
// An `adw::Window` for its breakpoints: the details panel lies over the
|
||||||
@@ -1247,7 +1038,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
wire_player(&video, &window);
|
|
||||||
let media = gtk4::Stack::new();
|
let media = gtk4::Stack::new();
|
||||||
media.add_named(&scroller, Some("photo"));
|
media.add_named(&scroller, Some("photo"));
|
||||||
media.add_named(&video, Some("video"));
|
media.add_named(&video, Some("video"));
|
||||||
@@ -1332,21 +1122,22 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
favorite_btn.add_css_class("flat");
|
favorite_btn.add_css_class("flat");
|
||||||
favorite_btn.add_css_class("viewer-action-btn");
|
favorite_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
let slideshow_btn = gtk4::ToggleButton::builder()
|
||||||
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
|
.icon_name("pdfs-slideshow-symbolic")
|
||||||
let group_btn = gtk4::Button::builder()
|
.tooltip_text(gettext("Slideshow (F5)"))
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.visible(false)
|
|
||||||
.build();
|
.build();
|
||||||
group_btn.add_css_class("flat");
|
slideshow_btn.add_css_class("flat");
|
||||||
group_btn.add_css_class("viewer-group-pill");
|
slideshow_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
// Everything used less often than the favorite and the details sits in
|
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||||
// one menu, named in words rather than in icons.
|
// decides that per photo.
|
||||||
let more_btn = action("view-more-symbolic", &gettext("More"));
|
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
|
||||||
let fullscreen_btn = fullscreen_button(&window);
|
group_btn.set_visible(false);
|
||||||
fullscreen_btn.add_css_class("flat");
|
|
||||||
fullscreen_btn.add_css_class("viewer-action-btn");
|
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
|
||||||
|
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
|
||||||
|
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
|
||||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||||
close_btn.add_css_class("viewer-close-btn");
|
close_btn.add_css_class("viewer-close-btn");
|
||||||
|
|
||||||
@@ -1356,26 +1147,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
top_bar.append(&group_btn);
|
top_bar.append(&group_btn);
|
||||||
top_bar.append(&favorite_btn);
|
top_bar.append(&favorite_btn);
|
||||||
top_bar.append(&info_toggle);
|
top_bar.append(&info_toggle);
|
||||||
top_bar.append(&fullscreen_btn);
|
top_bar.append(&slideshow_btn);
|
||||||
top_bar.append(&more_btn);
|
top_bar.append(&download_btn);
|
||||||
|
top_bar.append(&delete_btn);
|
||||||
|
top_bar.append(&open_ext_btn);
|
||||||
top_bar.append(&close_btn);
|
top_bar.append(&close_btn);
|
||||||
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
||||||
overlay.add_overlay(&top_reveal);
|
overlay.add_overlay(&top_reveal);
|
||||||
|
|
||||||
let (strip, strip_selection) = build_strip(ui);
|
|
||||||
let strip_scroll = gtk4::ScrolledWindow::builder()
|
|
||||||
.hscrollbar_policy(gtk4::PolicyType::External)
|
|
||||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
|
||||||
.child(&strip)
|
|
||||||
.build();
|
|
||||||
strip_scroll.add_css_class("viewer-strip-bar");
|
|
||||||
let strip_reveal = fade(
|
|
||||||
strip_scroll.upcast_ref(),
|
|
||||||
gtk4::Align::Fill,
|
|
||||||
gtk4::Align::End,
|
|
||||||
);
|
|
||||||
overlay.add_overlay(&strip_reveal);
|
|
||||||
|
|
||||||
// Details panel: docked beside the image as a real surface, so opening it
|
// Details panel: docked beside the image as a real surface, so opening it
|
||||||
// narrows the photo instead of covering part of it.
|
// narrows the photo instead of covering part of it.
|
||||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -1463,9 +1242,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
|
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
|
||||||
adw_window.add_breakpoint(narrow);
|
adw_window.add_breakpoint(narrow);
|
||||||
let body = info_split.clone();
|
let body = info_split.clone();
|
||||||
let toasts = adw::ToastOverlay::new();
|
adw_window.set_content(Some(&body));
|
||||||
toasts.set_child(Some(&body));
|
|
||||||
adw_window.set_content(Some(&toasts));
|
|
||||||
|
|
||||||
let viewer = Rc::new(Viewer {
|
let viewer = Rc::new(Viewer {
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
@@ -1496,23 +1273,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
pinch_start: Cell::new(1.0),
|
pinch_start: Cell::new(1.0),
|
||||||
drag_start: Cell::new((0.0, 0.0)),
|
drag_start: Cell::new((0.0, 0.0)),
|
||||||
pointer: Cell::new((0.0, 0.0)),
|
pointer: Cell::new((0.0, 0.0)),
|
||||||
chrome: vec![
|
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
||||||
top_reveal.clone(),
|
|
||||||
prev_reveal.clone(),
|
|
||||||
next_reveal.clone(),
|
|
||||||
strip_reveal.clone(),
|
|
||||||
],
|
|
||||||
strip: strip.clone(),
|
|
||||||
strip_selection: strip_selection.clone(),
|
|
||||||
strip_reveal: strip_reveal.clone(),
|
|
||||||
chrome_hover: Cell::new(false),
|
chrome_hover: Cell::new(false),
|
||||||
chrome_source: RefCell::new(None),
|
chrome_source: RefCell::new(None),
|
||||||
more: more_btn.clone(),
|
slideshow: slideshow_btn.clone(),
|
||||||
menu_open: Cell::new(false),
|
|
||||||
slideshow: Cell::new(false),
|
|
||||||
slide_source: RefCell::new(None),
|
slide_source: RefCell::new(None),
|
||||||
slide_fullscreened: Cell::new(false),
|
slide_fullscreened: Cell::new(false),
|
||||||
toasts,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||||
@@ -1538,7 +1304,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.add_controller(motion);
|
body.add_controller(motion);
|
||||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
|
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
||||||
let hover = gtk4::EventControllerMotion::new();
|
let hover = gtk4::EventControllerMotion::new();
|
||||||
let viewer_enter = Rc::downgrade(&viewer);
|
let viewer_enter = Rc::downgrade(&viewer);
|
||||||
hover.connect_enter(move |_, _, _| {
|
hover.connect_enter(move |_, _, _| {
|
||||||
@@ -1654,52 +1420,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
w_close.close();
|
w_close.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// A click on a thumbnail selects it; step there. Every selection the
|
|
||||||
// lightbox sets itself is the photo on screen, and the model's own changes
|
|
||||||
// (a page landing, a favorite rewritten in place) keep the selected photo
|
|
||||||
// or drop it, so a selection elsewhere can only be the user's.
|
|
||||||
let ui_strip = ui.clone();
|
|
||||||
let viewer_strip = Rc::downgrade(&viewer);
|
|
||||||
strip_selection.connect_selection_changed(move |selection, _, _| {
|
|
||||||
let Some(viewer) = viewer_strip.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let picked = selection.selected();
|
|
||||||
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
|
|
||||||
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let viewer_items = Rc::downgrade(&viewer);
|
|
||||||
let items_handler = ui
|
|
||||||
.gallery
|
|
||||||
.model
|
|
||||||
.connect_items_changed(move |model, _, _, _| {
|
|
||||||
let Some(viewer) = viewer_items.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
|
|
||||||
viewer.strip_selection.set_selected(index);
|
|
||||||
}
|
|
||||||
sync_strip(&viewer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||||
// strong one in a handler of a widget the viewer holds is a cycle, which
|
// strong one in a handler of a widget the viewer holds is a cycle, which
|
||||||
// kept every closed lightbox, its window and its full-size photo in memory
|
// kept every closed lightbox, its window and its full-size photo in memory
|
||||||
// for the life of the app.
|
// for the life of the app.
|
||||||
let keep = RefCell::new(Some(viewer.clone()));
|
let keep = RefCell::new(Some(viewer.clone()));
|
||||||
let ui_close = ui.clone();
|
|
||||||
let items_handler = RefCell::new(Some(items_handler));
|
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
// The gallery's model outlives the lightbox.
|
|
||||||
if let Some(handler) = items_handler.borrow_mut().take() {
|
|
||||||
ui_close.gallery.model.disconnect(handler);
|
|
||||||
}
|
|
||||||
if let Some(viewer) = keep.borrow_mut().take() {
|
if let Some(viewer) = keep.borrow_mut().take() {
|
||||||
reveal_photo(&ui_close, &viewer.uid.borrow());
|
|
||||||
stop_video(&viewer);
|
stop_video(&viewer);
|
||||||
disarm_slide(&viewer);
|
disarm_slide(&viewer);
|
||||||
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||||
@@ -1725,14 +1452,36 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let ui_slides = ui.clone();
|
||||||
|
let viewer_slides = Rc::downgrade(&viewer);
|
||||||
|
slideshow_btn.connect_toggled(move |toggle| {
|
||||||
|
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if toggle.is_active() {
|
||||||
|
start_slideshow(&ui_slides, &viewer_slides);
|
||||||
|
} else {
|
||||||
|
stop_slideshow(&viewer_slides);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let toggle_off = info_toggle.clone();
|
let toggle_off = info_toggle.clone();
|
||||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||||
|
|
||||||
let ui_map = ui.clone();
|
let ui_map = ui.clone();
|
||||||
let viewer_map = Rc::downgrade(&viewer);
|
let viewer_map = Rc::downgrade(&viewer);
|
||||||
|
let w_map = window.downgrade();
|
||||||
info_map.connect_clicked(move |_| {
|
info_map.connect_clicked(move |_| {
|
||||||
if let Some(viewer_map) = viewer_map.upgrade() {
|
let Some(w_map) = w_map.upgrade() else {
|
||||||
show_photo_on_map(&ui_map, &viewer_map);
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_map) = viewer_map.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let coords = *viewer_map.coords.borrow();
|
||||||
|
if let Some((lat, lon)) = coords {
|
||||||
|
w_map.close();
|
||||||
|
show_on_map(&ui_map, lat, lon);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1761,9 +1510,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
let failed = match rx.recv().await {
|
let failed = match rx.recv().await {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
set_gallery_favorite(&ui_result, &uid, favorite);
|
set_gallery_favorite(&ui_result, &uid, favorite);
|
||||||
// The tiles hold their own copy of the photo, so the star
|
|
||||||
// shows on the grid only once it is laid out again.
|
|
||||||
repaint_gallery(&ui_result);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||||
@@ -1784,6 +1530,24 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deleting the photo on screen closes the lightbox: what it was showing is
|
||||||
|
// gone, and leaving it open on a trashed photo invites a second Delete on
|
||||||
|
// something the user cannot see.
|
||||||
|
let ui_delete = ui.clone();
|
||||||
|
let viewer_delete = Rc::downgrade(&viewer);
|
||||||
|
let w_delete = window.downgrade();
|
||||||
|
delete_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_delete) = w_delete.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_delete) = viewer_delete.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let uid = viewer_delete.uid.borrow().clone();
|
||||||
|
w_delete.close();
|
||||||
|
trash_photos(&ui_delete, vec![uid], false);
|
||||||
|
});
|
||||||
|
|
||||||
let ui_group = ui.clone();
|
let ui_group = ui.clone();
|
||||||
let viewer_group = Rc::downgrade(&viewer);
|
let viewer_group = Rc::downgrade(&viewer);
|
||||||
group_btn.connect_clicked(move |_| {
|
group_btn.connect_clicked(move |_| {
|
||||||
@@ -1799,11 +1563,38 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_more = ui.clone();
|
let ui_download = ui.clone();
|
||||||
let viewer_more = Rc::downgrade(&viewer);
|
let w_download = window.downgrade();
|
||||||
more_btn.connect_clicked(move |_| {
|
let viewer_download = Rc::downgrade(&viewer);
|
||||||
if let Some(viewer_more) = viewer_more.upgrade() {
|
download_btn.connect_clicked(move |_| {
|
||||||
open_viewer_menu(&ui_more, &viewer_more);
|
let Some(w_download) = w_download.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_download) = viewer_download.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||||
|
let name = viewer_download.name.borrow();
|
||||||
|
save_photo_to_disk(
|
||||||
|
&ui_download,
|
||||||
|
&w_download,
|
||||||
|
path,
|
||||||
|
name.as_deref().unwrap_or("photo"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let w_ext = window.downgrade();
|
||||||
|
let viewer_ext = Rc::downgrade(&viewer);
|
||||||
|
open_ext_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_ext) = w_ext.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(viewer_ext) = viewer_ext.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||||
|
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1881,22 +1672,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
return glib::Propagation::Proceed;
|
return glib::Propagation::Proceed;
|
||||||
};
|
};
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
|
||||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||||
if playing_video
|
|
||||||
&& let Some(name) = key.name()
|
|
||||||
&& player_key(&viewer_key.video, &name, state)
|
|
||||||
{
|
|
||||||
return glib::Propagation::Stop;
|
|
||||||
}
|
|
||||||
match key.name().as_deref() {
|
match key.name().as_deref() {
|
||||||
Some("space") if playing_video => {
|
Some("space") if playing_video => {
|
||||||
if let Some(stream) = viewer_key.video.media_stream() {
|
if let Some(stream) = viewer_key.video.media_stream() {
|
||||||
stream.set_playing(!stream.is_playing());
|
stream.set_playing(!stream.is_playing());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Alt+Left is Back everywhere else in the app.
|
|
||||||
Some("Left") if alt => w_key.close(),
|
|
||||||
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
||||||
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
||||||
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
||||||
@@ -1905,20 +1687,30 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||||
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
|
Some("F5") => viewer_key
|
||||||
|
.slideshow
|
||||||
|
.set_active(!viewer_key.slideshow.is_active()),
|
||||||
// Escape ends a slideshow before it closes anything.
|
// Escape ends a slideshow before it closes anything.
|
||||||
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
|
Some("Escape") if viewer_key.slideshow.is_active() => {
|
||||||
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
|
viewer_key.slideshow.set_active(false)
|
||||||
|
}
|
||||||
Some("i") => viewer_key
|
Some("i") => viewer_key
|
||||||
.info_toggle
|
.info_toggle
|
||||||
.set_active(!viewer_key.info_toggle.is_active()),
|
.set_active(!viewer_key.info_toggle.is_active()),
|
||||||
Some("f" | "F11") => toggle_fullscreen(&w_key),
|
Some("f" | "F11") => {
|
||||||
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
|
if w_key.is_fullscreen() {
|
||||||
|
w_key.unfullscreen();
|
||||||
|
} else {
|
||||||
|
w_key.fullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("Delete" | "KP_Delete") => {
|
||||||
|
let uid = viewer_key.uid.borrow().clone();
|
||||||
|
w_key.close();
|
||||||
|
trash_photos(&ui_key, vec![uid], false);
|
||||||
|
}
|
||||||
Some("Escape" | "q") => w_key.close(),
|
Some("Escape" | "q") => w_key.close(),
|
||||||
Some("w") if ctrl => w_key.close(),
|
Some("w") if ctrl => w_key.close(),
|
||||||
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
|
|
||||||
copy_shown_photo(&ui_key, &viewer_key)
|
|
||||||
}
|
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
@@ -1926,113 +1718,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
|||||||
window.add_controller(key_controller);
|
window.add_controller(key_controller);
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
window
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
|
|
||||||
/// starring it and showing its details. Built when it opens, so it offers
|
|
||||||
/// only what applies to this photo.
|
|
||||||
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
let weak = Rc::downgrade(viewer);
|
|
||||||
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
|
|
||||||
let (ui, weak) = (ui.clone(), weak.clone());
|
|
||||||
move || {
|
|
||||||
if let Some(viewer) = weak.upgrade() {
|
|
||||||
run(&ui, &viewer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut menu = ActionMenu::new();
|
|
||||||
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
|
|
||||||
if viewer.media.visible_child_name().as_deref() == Some("video") {
|
|
||||||
let video = viewer.video.clone();
|
|
||||||
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
|
|
||||||
video.set_loop(on)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
menu.section();
|
|
||||||
// These need the full-size file, which may still be downloading.
|
|
||||||
if viewer.path.borrow().is_some() {
|
|
||||||
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
|
|
||||||
if still {
|
|
||||||
// Translators: menu item that puts the photo on the clipboard as an image.
|
|
||||||
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
|
|
||||||
}
|
|
||||||
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
|
|
||||||
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
|
|
||||||
}
|
|
||||||
if photo_shareable(ui) {
|
|
||||||
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
|
|
||||||
}
|
|
||||||
// A RAW is not on the timeline, and an album holds the shot it belongs to.
|
|
||||||
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
|
|
||||||
menu.item(
|
|
||||||
&gettext("Add to Album…"),
|
|
||||||
with_viewer(add_shown_photo_to_album, ui),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if viewer.coords.borrow().is_some() {
|
|
||||||
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
|
|
||||||
}
|
|
||||||
menu.section();
|
|
||||||
menu.item(
|
|
||||||
&gettext("Move to Trash…"),
|
|
||||||
with_viewer(trash_shown_photo, ui),
|
|
||||||
);
|
|
||||||
|
|
||||||
let button = &viewer.more;
|
|
||||||
let popover = menu.popup_at(
|
|
||||||
button,
|
|
||||||
f64::from(button.width()) / 2.0,
|
|
||||||
f64::from(button.height()),
|
|
||||||
);
|
|
||||||
viewer.menu_open.set(true);
|
|
||||||
let weak = Rc::downgrade(viewer);
|
|
||||||
popover.connect_closed(move |_| {
|
|
||||||
if let Some(viewer) = weak.upgrade() {
|
|
||||||
viewer.menu_open.set(false);
|
|
||||||
// A slideshow started from the menu keeps the controls hidden.
|
|
||||||
if !viewer.slideshow.get() {
|
|
||||||
show_chrome(&viewer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
if let Some(path) = viewer.path.borrow().as_deref() {
|
|
||||||
let name = viewer.name.borrow();
|
|
||||||
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
if let Some(path) = viewer.path.borrow().as_deref() {
|
|
||||||
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
let uid = viewer.uid.borrow().clone();
|
|
||||||
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close the lightbox and show where the photo was taken.
|
|
||||||
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
let coords = *viewer.coords.borrow();
|
|
||||||
if let Some((lat, lon)) = coords {
|
|
||||||
viewer.window.close();
|
|
||||||
show_on_map(ui, lat, lon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
|
|
||||||
/// closes first: what it was showing is gone, and leaving it open on a
|
|
||||||
/// trashed photo invites a second Delete on something the user cannot see.
|
|
||||||
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
|
||||||
let uid = viewer.uid.borrow().clone();
|
|
||||||
let window = viewer.window.clone();
|
|
||||||
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) burst: Cell<bool>,
|
pub(crate) burst: Cell<bool>,
|
||||||
/// Says a Google Photos import is running, with a way to its page.
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
pub(crate) import_banner: adw::Banner,
|
pub(crate) import_banner: adw::Banner,
|
||||||
/// The bar that counts uploads from this computer through, under the
|
|
||||||
/// header while any run.
|
|
||||||
pub(crate) upload_bar: UploadBar,
|
|
||||||
pub(crate) upload: adw::SplitButton,
|
pub(crate) upload: adw::SplitButton,
|
||||||
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
||||||
/// account is a place to start rather than a dead end. Hidden on every other
|
/// account is a place to start rather than a dead end. Hidden on every other
|
||||||
@@ -160,11 +157,10 @@ pub(crate) struct GalleryState {
|
|||||||
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
||||||
/// can never be filled isn't requested again on every scroll past it.
|
/// can never be filled isn't requested again on every scroll past it.
|
||||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||||
/// Pictures on screen still waiting for their thumbnail, by uid. Populated
|
/// Tiles on screen still waiting for their thumbnail, by uid. Populated as
|
||||||
/// as they are bound, drained as batches land, cleared on unbind — so a
|
/// sections are bound, drained as batches land, cleared on unbind — so a
|
||||||
/// batch only ever paints a widget that is still showing that photo. One
|
/// batch only ever paints a widget that is still showing that photo.
|
||||||
/// photo can be waited for twice: by its tile and by the lightbox's strip.
|
pub(crate) thumb_wanted: RefCell<HashMap<String, gtk4::Picture>>,
|
||||||
pub(crate) thumb_wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
|
||||||
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
|
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
|
||||||
/// batch is already in flight (only one at a time, so a long scroll can't
|
/// batch is already in flight (only one at a time, so a long scroll can't
|
||||||
/// stack requests on the daemon).
|
/// stack requests on the daemon).
|
||||||
@@ -247,27 +243,6 @@ impl GalleryState {
|
|||||||
pub(crate) fn in_collection(&self) -> bool {
|
pub(crate) fn in_collection(&self) -> bool {
|
||||||
self.album.borrow().is_some() || self.place.borrow().is_some()
|
self.album.borrow().is_some() || self.place.borrow().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Have `picture` painted with the thumbnail of `uid` once it lands.
|
|
||||||
pub(crate) fn await_thumb(&self, uid: &str, picture: >k4::Picture) {
|
|
||||||
let mut wanted = self.thumb_wanted.borrow_mut();
|
|
||||||
let waiting = wanted.entry(uid.to_string()).or_default();
|
|
||||||
if !waiting.contains(picture) {
|
|
||||||
waiting.push(picture.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop waiting for `uid` in the pictures `drop` picks, because they show
|
|
||||||
/// another photo now.
|
|
||||||
pub(crate) fn release_thumb(&self, uid: &str, drop: impl Fn(>k4::Picture) -> bool) {
|
|
||||||
let mut wanted = self.thumb_wanted.borrow_mut();
|
|
||||||
if let Some(waiting) = wanted.get_mut(uid) {
|
|
||||||
waiting.retain(|picture| !drop(picture));
|
|
||||||
if waiting.is_empty() {
|
|
||||||
wanted.remove(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
@@ -481,9 +456,6 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) scrubber: Scrubber,
|
pub(crate) scrubber: Scrubber,
|
||||||
/// Says a Google Photos import is running, with a way to its page.
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
pub(crate) import_banner: adw::Banner,
|
pub(crate) import_banner: adw::Banner,
|
||||||
/// The bar that counts uploads from this computer through, under the
|
|
||||||
/// header while any run.
|
|
||||||
pub(crate) upload_bar: UploadBar,
|
|
||||||
pub(crate) list: gtk4::ListView,
|
pub(crate) list: gtk4::ListView,
|
||||||
pub(crate) scroll: gtk4::ScrolledWindow,
|
pub(crate) scroll: gtk4::ScrolledWindow,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
@@ -909,8 +881,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.action_name("win.show-import")
|
.action_name("win.show-import")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let upload_bar = UploadBar::new();
|
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(12);
|
||||||
@@ -921,7 +891,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
|
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
body.append(&import_banner);
|
body.append(&import_banner);
|
||||||
body.append(&upload_bar.revealer);
|
|
||||||
body.append(&inner);
|
body.append(&inner);
|
||||||
// The title slot holds the view switch on the page itself, the album's or
|
// The title slot holds the view switch on the page itself, the album's or
|
||||||
// place's name inside one, and the count in selection mode (see
|
// place's name inside one, and the count in selection mode (see
|
||||||
@@ -952,7 +921,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
pager,
|
pager,
|
||||||
scrubber,
|
scrubber,
|
||||||
import_banner,
|
import_banner,
|
||||||
upload_bar,
|
|
||||||
list,
|
list,
|
||||||
scroll,
|
scroll,
|
||||||
retry,
|
retry,
|
||||||
@@ -1346,10 +1314,14 @@ pub(crate) fn wire_gallery(
|
|||||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
||||||
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
||||||
{
|
{
|
||||||
|
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
||||||
for tile in tiles {
|
for tile in tiles {
|
||||||
ui_unbind
|
if wanted
|
||||||
.gallery
|
.get(&tile.photo.uid)
|
||||||
.release_thumb(&tile.photo.uid, |picture| picture.is_ancestor(&row_box));
|
.is_some_and(|picture| picture.is_ancestor(&row_box))
|
||||||
|
{
|
||||||
|
wanted.remove(&tile.photo.uid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1394,13 +1366,6 @@ pub(crate) fn wire_gallery(
|
|||||||
sync_float_heading(&ui_scroll);
|
sync_float_heading(&ui_scroll);
|
||||||
ui_scroll.gallery.scrubber.flash();
|
ui_scroll.gallery.scrubber.flash();
|
||||||
});
|
});
|
||||||
// Zooming out or making the window taller can leave every loaded photo on
|
|
||||||
// one screen. Nothing scrolls then, so the scrolling above never asks for
|
|
||||||
// the next page: ask whenever the content shrinks under the viewport.
|
|
||||||
let ui_fill = ui.clone();
|
|
||||||
scroll
|
|
||||||
.vadjustment()
|
|
||||||
.connect_changed(move |_| fill_viewport(&ui_fill));
|
|
||||||
wire_scrubber(ui);
|
wire_scrubber(ui);
|
||||||
wire_drag_select(ui);
|
wire_drag_select(ui);
|
||||||
|
|
||||||
@@ -1476,7 +1441,7 @@ pub(crate) fn wire_gallery(
|
|||||||
let ui_album = ui.clone();
|
let ui_album = ui.clone();
|
||||||
selection.album.connect_clicked(move |_| {
|
selection.album.connect_clicked(move |_| {
|
||||||
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
||||||
prompt_add_to_album(&ui_album, uids, None);
|
prompt_add_to_album(&ui_album, uids);
|
||||||
});
|
});
|
||||||
let ui_remove = ui.clone();
|
let ui_remove = ui.clone();
|
||||||
selection.remove.connect_clicked(move |_| {
|
selection.remove.connect_clicked(move |_| {
|
||||||
@@ -1530,11 +1495,9 @@ pub(crate) fn wire_gallery(
|
|||||||
gtk4::gdk::DragAction::COPY,
|
gtk4::gdk::DragAction::COPY,
|
||||||
);
|
);
|
||||||
let ui_drop = ui.clone();
|
let ui_drop = ui.clone();
|
||||||
drop.connect_accept(move |_, drop| {
|
drop.connect_accept(move |_, _| {
|
||||||
let gallery = &ui_drop.gallery;
|
let gallery = &ui_drop.gallery;
|
||||||
// Photos dragged out of the timeline are already in it.
|
!gallery.in_collection()
|
||||||
drop.drag().is_none()
|
|
||||||
&& !gallery.in_collection()
|
|
||||||
&& gallery.view_switch.active() == VIEW_TIMELINE
|
&& gallery.view_switch.active() == VIEW_TIMELINE
|
||||||
&& gallery.library.get()
|
&& gallery.library.get()
|
||||||
&& *ui_drop.mounted.borrow()
|
&& *ui_drop.mounted.borrow()
|
||||||
@@ -1932,86 +1895,6 @@ pub(crate) fn copy_without_overwrite(
|
|||||||
unreachable!("some numbered name is free")
|
unreachable!("some numbered name is free")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far the uploads from this computer have come: a count and a bar, so a
|
|
||||||
/// drop of a few hundred photos shows it is moving. Batches started while one
|
|
||||||
/// runs join its count.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct UploadBar {
|
|
||||||
pub(crate) revealer: gtk4::Revealer,
|
|
||||||
label: gtk4::Label,
|
|
||||||
progress: gtk4::ProgressBar,
|
|
||||||
/// Uploads finished, and uploads asked for, since the bar last emptied.
|
|
||||||
count: Rc<Cell<(usize, usize)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UploadBar {
|
|
||||||
fn new() -> Self {
|
|
||||||
let label = gtk4::Label::builder()
|
|
||||||
.halign(gtk4::Align::Start)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
|
||||||
.build();
|
|
||||||
let progress = gtk4::ProgressBar::builder().hexpand(true).build();
|
|
||||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
|
||||||
content.set_margin_top(10);
|
|
||||||
content.set_margin_bottom(4);
|
|
||||||
content.set_margin_start(16);
|
|
||||||
content.set_margin_end(16);
|
|
||||||
content.append(&label);
|
|
||||||
content.append(&progress);
|
|
||||||
let revealer = gtk4::Revealer::builder()
|
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
|
||||||
.child(&content)
|
|
||||||
.build();
|
|
||||||
Self {
|
|
||||||
revealer,
|
|
||||||
label,
|
|
||||||
progress,
|
|
||||||
count: Rc::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count `n` more uploads in.
|
|
||||||
pub(crate) fn add(&self, n: usize) {
|
|
||||||
let (done, total) = self.count.get();
|
|
||||||
self.count.set((done, total + n));
|
|
||||||
self.paint();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count one upload as finished, whether or not it went through.
|
|
||||||
pub(crate) fn finish(&self) {
|
|
||||||
let (done, total) = self.count.get();
|
|
||||||
let done = (done + 1).min(total);
|
|
||||||
if done == total {
|
|
||||||
self.count.set((0, 0));
|
|
||||||
self.revealer.set_reveal_child(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.count.set((done, total));
|
|
||||||
self.paint();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint(&self) {
|
|
||||||
let (done, total) = self.count.get();
|
|
||||||
if total == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.label.set_label(&upload_progress_text(done, total));
|
|
||||||
self.progress.set_fraction(done as f64 / total as f64);
|
|
||||||
self.revealer.set_reveal_child(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`.
|
|
||||||
pub(crate) fn upload_progress_text(done: usize, total: usize) -> String {
|
|
||||||
// Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are.
|
|
||||||
ngettext_f(
|
|
||||||
"Uploading {position} of {n} item…",
|
|
||||||
"Uploading {position} of {n} items…",
|
|
||||||
total as u64,
|
|
||||||
&[("position", &thousands(done + 1))],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload photos and videos from this computer into the timeline, one after
|
/// Upload photos and videos from this computer into the timeline, one after
|
||||||
/// the other, then reload it once.
|
/// the other, then reload it once.
|
||||||
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||||
@@ -2026,7 +1909,15 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
let total = uploads.len();
|
let total = uploads.len();
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
ui.gallery.upload_bar.add(total);
|
toast(
|
||||||
|
ui,
|
||||||
|
&ngettext_f(
|
||||||
|
"Uploading {n} item…",
|
||||||
|
"Uploading {n} items…",
|
||||||
|
total as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -2064,9 +1955,6 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if total > 1 {
|
|
||||||
ui.gallery.upload_bar.finish();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
if uploaded > 0 {
|
if uploaded > 0 {
|
||||||
@@ -2475,23 +2363,6 @@ impl TileWidget {
|
|||||||
});
|
});
|
||||||
button.add_controller(context);
|
button.add_controller(context);
|
||||||
|
|
||||||
// A picked photo drags out, with the rest of the selection, into a
|
|
||||||
// file manager or any app that takes files. A drag from anywhere else
|
|
||||||
// selects instead (see `wire_drag_select`).
|
|
||||||
let drag_out = gtk4::DragSource::new();
|
|
||||||
drag_out.set_actions(gtk4::gdk::DragAction::COPY);
|
|
||||||
let ui_drag = ui.clone();
|
|
||||||
let drag_uid = uid.clone();
|
|
||||||
drag_out.connect_prepare(move |source, x, y| {
|
|
||||||
let photos = dragged_photos(&ui_drag, &drag_uid()?)?;
|
|
||||||
if let Some(tile) = source.widget() {
|
|
||||||
let icon = gtk4::WidgetPaintable::new(Some(&tile));
|
|
||||||
source.set_icon(Some(&icon), x as i32, y as i32);
|
|
||||||
}
|
|
||||||
Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast())
|
|
||||||
});
|
|
||||||
button.add_controller(drag_out);
|
|
||||||
|
|
||||||
// A tile opens in the in-app lightbox, which plays a video in place.
|
// A tile opens in the in-app lightbox, which plays a video in place.
|
||||||
// In selection mode a tile picks instead of opening: the lightbox is
|
// In selection mode a tile picks instead of opening: the lightbox is
|
||||||
// one Escape away.
|
// one Escape away.
|
||||||
@@ -2584,28 +2455,13 @@ impl TileWidget {
|
|||||||
let Some(photo) = self.photo.borrow_mut().take() else {
|
let Some(photo) = self.photo.borrow_mut().take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
ui.gallery
|
let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
|
||||||
.release_thumb(&photo.uid, |picture| picture == &self.picture);
|
if wanted.get(&photo.uid) == Some(&self.picture) {
|
||||||
|
wanted.remove(&photo.uid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The photos a drag that starts on `uid` carries out: the whole selection,
|
|
||||||
/// in timeline order, as uid and name. `None` unless `uid` is picked.
|
|
||||||
fn dragged_photos(ui: &Rc<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
|
|
||||||
let selected = ui.gallery.selected.borrow();
|
|
||||||
if !ui.gallery.selecting.get() || !selected.contains(uid) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let model = &ui.gallery.model;
|
|
||||||
let photos: Vec<(String, Option<String>)> = (0..model.n_items())
|
|
||||||
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
|
|
||||||
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
|
|
||||||
.filter(|photo| selected.contains(&photo.uid))
|
|
||||||
.map(|photo| (photo.uid, photo.name))
|
|
||||||
.collect();
|
|
||||||
(!photos.is_empty()).then_some(photos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add or remove one CSS class, for widgets that are rebound rather than
|
/// Add or remove one CSS class, for widgets that are rebound rather than
|
||||||
/// rebuilt.
|
/// rebuilt.
|
||||||
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
||||||
@@ -2684,9 +2540,6 @@ struct DragSelect {
|
|||||||
pointer: (f64, f64),
|
pointer: (f64, f64),
|
||||||
/// The edge autoscroll, while the pointer is near the top or bottom.
|
/// The edge autoscroll, while the pointer is near the top or bottom.
|
||||||
scroll: Option<glib::SourceId>,
|
scroll: Option<glib::SourceId>,
|
||||||
/// The drag started on a picked photo, so it carries the selection out
|
|
||||||
/// of the app rather than selecting.
|
|
||||||
out: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dragging across the timeline selects every photo from where the drag
|
/// Dragging across the timeline selects every photo from where the drag
|
||||||
@@ -2703,33 +2556,21 @@ fn wire_drag_select(ui: &Rc<Ui>) {
|
|||||||
let state = Rc::new(RefCell::new(DragSelect::default()));
|
let state = Rc::new(RefCell::new(DragSelect::default()));
|
||||||
|
|
||||||
let state_begin = state.clone();
|
let state_begin = state.clone();
|
||||||
let ui_begin = ui.clone();
|
drag.connect_drag_begin(move |_, x, y| {
|
||||||
drag.connect_drag_begin(move |gesture, x, y| {
|
|
||||||
let mut drag = state_begin.borrow_mut();
|
let mut drag = state_begin.borrow_mut();
|
||||||
if let Some(source) = drag.scroll.take() {
|
if let Some(source) = drag.scroll.take() {
|
||||||
source.remove();
|
source.remove();
|
||||||
}
|
}
|
||||||
let out = ui_begin.gallery.selecting.get()
|
|
||||||
&& tile_at(&ui_begin, x, y)
|
|
||||||
.is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid));
|
|
||||||
*drag = DragSelect {
|
*drag = DragSelect {
|
||||||
start: (x, y),
|
start: (x, y),
|
||||||
pointer: (x, y),
|
pointer: (x, y),
|
||||||
out,
|
|
||||||
..DragSelect::default()
|
..DragSelect::default()
|
||||||
};
|
};
|
||||||
// Left to the tile's own drag source.
|
|
||||||
if out {
|
|
||||||
gesture.set_state(gtk4::EventSequenceState::Denied);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_update = ui.clone();
|
let ui_update = ui.clone();
|
||||||
let state_update = state.clone();
|
let state_update = state.clone();
|
||||||
drag.connect_drag_update(move |gesture, dx, dy| {
|
drag.connect_drag_update(move |gesture, dx, dy| {
|
||||||
if state_update.borrow().out {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (sx, sy) = state_update.borrow().start;
|
let (sx, sy) = state_update.borrow().start;
|
||||||
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
||||||
if !state_update.borrow().active {
|
if !state_update.borrow().active {
|
||||||
@@ -2923,18 +2764,6 @@ pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
/// Confirm, then move the photos behind `uids` to Proton trash.
|
/// Confirm, then move the photos behind `uids` to Proton trash.
|
||||||
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||||
ask_trash_photos(ui, uids, None, || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`confirm_trash_photos`] over `parent` rather than the main window, for
|
|
||||||
/// the photo viewer, which covers it. `before` runs once the user confirms,
|
|
||||||
/// before the photos go.
|
|
||||||
pub(crate) fn ask_trash_photos(
|
|
||||||
ui: &Rc<Ui>,
|
|
||||||
uids: Vec<String>,
|
|
||||||
parent: Option<gtk4::Window>,
|
|
||||||
before: impl Fn() + 'static,
|
|
||||||
) {
|
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2974,11 +2803,10 @@ pub(crate) fn ask_trash_photos(
|
|||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
let win = parent.or_else(|| ui_window(ui));
|
let win = ui_window(ui);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
dialog.connect_response(None, move |_, response| {
|
dialog.connect_response(None, move |_, response| {
|
||||||
if response == "trash" {
|
if response == "trash" {
|
||||||
before();
|
|
||||||
trash_photos(&ui, uids.clone(), false);
|
trash_photos(&ui, uids.clone(), false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3123,7 +2951,10 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.gallery.await_thumb(&photo.uid, picture);
|
ui.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(photo.uid.clone(), picture.clone());
|
||||||
|
|
||||||
match photo.thumb_path.as_deref() {
|
match photo.thumb_path.as_deref() {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
@@ -3342,8 +3173,7 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
ui.store_texture(&uid, texture.clone());
|
ui.store_texture(&uid, texture.clone());
|
||||||
let waiting = ui.gallery.thumb_wanted.borrow_mut().remove(&uid);
|
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||||
for picture in waiting.into_iter().flatten() {
|
|
||||||
picture.set_paintable(Some(&texture));
|
picture.set_paintable(Some(&texture));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3458,16 +3288,6 @@ fn top_anchor(ui: &Rc<Ui>) -> Option<(String, f32)> {
|
|||||||
.or(first)
|
.or(first)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll the grid just far enough to show photo `uid`, as the lightbox
|
|
||||||
/// closes: paging with the arrow keys may have left the grid far behind.
|
|
||||||
pub(crate) fn reveal_photo(ui: &Rc<Ui>, uid: &str) {
|
|
||||||
if let Some(row) = row_of_photo(&ui.gallery.groups, uid) {
|
|
||||||
ui.gallery
|
|
||||||
.list
|
|
||||||
.scroll_to(row, gtk4::ListScrollFlags::NONE, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which row of the rendered model holds `uid`, if any.
|
/// Which row of the rendered model holds `uid`, if any.
|
||||||
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
||||||
(0..store.n_items()).find(|i| {
|
(0..store.n_items()).find(|i| {
|
||||||
@@ -4097,32 +3917,17 @@ fn continue_jump(ui: &Rc<Ui>) {
|
|||||||
/// Load the next page when the timeline does not fill the window yet: with no
|
/// Load the next page when the timeline does not fill the window yet: with no
|
||||||
/// scrollbar there is no scrolling to trigger it.
|
/// scrollbar there is no scrolling to trigger it.
|
||||||
fn fill_viewport(ui: &Rc<Ui>) {
|
fn fill_viewport(ui: &Rc<Ui>) {
|
||||||
if !ui.gallery.has_more.get() || ui.gallery.loading.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::idle_add_local_once(move || {
|
glib::idle_add_local_once(move || {
|
||||||
let Some(adj) = ui.gallery.list.vadjustment() else {
|
let Some(adj) = ui.gallery.list.vadjustment() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline");
|
if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
|
||||||
if timeline
|
|
||||||
&& ui.gallery.has_more.get()
|
|
||||||
&& adj.page_size() > 0.0
|
|
||||||
&& viewport_unfilled(adj.upper(), adj.page_size())
|
|
||||||
{
|
|
||||||
load_gallery(&ui, true);
|
load_gallery(&ui, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether content `upper` px tall leaves a viewport `page_size` px tall short
|
|
||||||
/// of a screen and a half to scroll through, the margin the scrolling pager
|
|
||||||
/// keeps. Below it the pager would never fire.
|
|
||||||
pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool {
|
|
||||||
upper <= page_size * 2.5 + 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select every photo loaded so far (Ctrl+A).
|
/// Select every photo loaded so far (Ctrl+A).
|
||||||
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
||||||
set_selection_mode(ui, true);
|
set_selection_mode(ui, true);
|
||||||
@@ -4147,9 +3952,7 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64
|
|||||||
} else {
|
} else {
|
||||||
pgettext("verb", "Open")
|
pgettext("verb", "Open")
|
||||||
};
|
};
|
||||||
menu.item(&label, move || {
|
menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone()));
|
||||||
open_photo_viewer(&ui_c, uid.clone());
|
|
||||||
});
|
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
// Translators: a check item in a photo's menu, on while the photo is a favorite.
|
// Translators: a check item in a photo's menu, on while the photo is a favorite.
|
||||||
let favorite_label = pgettext("state", "Favorite");
|
let favorite_label = pgettext("state", "Favorite");
|
||||||
@@ -4164,18 +3967,8 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64
|
|||||||
menu.section();
|
menu.section();
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
menu.item(&gettext("Add to Album…"), move || {
|
menu.item(&gettext("Add to Album…"), move || {
|
||||||
prompt_add_to_album(&ui_c, vec![uid.clone()], None)
|
prompt_add_to_album(&ui_c, vec![uid.clone()])
|
||||||
});
|
});
|
||||||
if photo_shareable(ui) {
|
|
||||||
let (ui_c, uid, name) = (
|
|
||||||
ui.clone(),
|
|
||||||
photo.uid.clone(),
|
|
||||||
photo.name.clone().unwrap_or_default(),
|
|
||||||
);
|
|
||||||
menu.item(&gettext("Share…"), move || {
|
|
||||||
share_photo(&ui_c, uid.clone(), name.clone(), None)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let open_album = ui.gallery.album.borrow().clone();
|
let open_album = ui.gallery.album.borrow().clone();
|
||||||
if let Some(album) = open_album.filter(|album| !album.shared) {
|
if let Some(album) = open_album.filter(|album| !album.shared) {
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
@@ -4227,44 +4020,8 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the photos on show are the user's own to share: everything but an
|
|
||||||
/// album someone else shared, whose photos live on that person's volume.
|
|
||||||
pub(crate) fn photo_shareable(ui: &Ui) -> bool {
|
|
||||||
!ui.gallery
|
|
||||||
.album
|
|
||||||
.borrow()
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|album| album.shared)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the Share dialog for one photo, addressed by uid: the photos volume
|
|
||||||
/// has no path in any mount. Over `parent` when the lightbox is open.
|
|
||||||
pub(crate) fn share_photo(ui: &Rc<Ui>, uid: String, name: String, parent: Option<gtk4::Window>) {
|
|
||||||
let entry = DirEntry {
|
|
||||||
uid,
|
|
||||||
name,
|
|
||||||
..DirEntry::default()
|
|
||||||
};
|
|
||||||
open_share_dialog_over(ui, &entry, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the photo grid is the view the Photos page shows: an album or a
|
|
||||||
/// place, or the timeline itself. Under Albums or Places it is out of sight.
|
|
||||||
pub(crate) fn photo_grid_shown(ui: &Ui) -> bool {
|
|
||||||
ui.gallery.in_collection() || ui.gallery.view_switch.active() == VIEW_TIMELINE
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||||
sync_filter_badge(ui);
|
sync_filter_badge(ui);
|
||||||
// A load would put the grid, or its status page, on screen in place of
|
|
||||||
// the Albums or Places view the user is looking at. Leave the timeline to
|
|
||||||
// be read again when they switch back to it.
|
|
||||||
if !photo_grid_shown(ui) {
|
|
||||||
ui.gallery.timeline_stale.set(true);
|
|
||||||
// A scrubber jump was paging the grid the user has just left.
|
|
||||||
ui.gallery.jump.set(None);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ui.gallery.loading.get() {
|
if ui.gallery.loading.get() {
|
||||||
// A filter flipped while a page is on its way: that page answers the
|
// A filter flipped while a page is on its way: that page answers the
|
||||||
// old question, so fetch again once it lands. More pages can wait.
|
// old question, so fetch again once it lands. More pages can wait.
|
||||||
@@ -4280,9 +4037,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
|
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
|
||||||
let collection = album.is_some() || place.is_some();
|
let collection = album.is_some() || place.is_some();
|
||||||
if !append {
|
if !append {
|
||||||
// A scrubber jump looks for a month of the listing being replaced:
|
|
||||||
// another filter, album or place has its own months, if any.
|
|
||||||
ui.gallery.jump.set(None);
|
|
||||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||||
ui.gallery.model.remove_all();
|
ui.gallery.model.remove_all();
|
||||||
gallery_status(
|
gallery_status(
|
||||||
@@ -4344,12 +4098,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
if !waiters.is_empty() {
|
if !waiters.is_empty() {
|
||||||
glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter()));
|
glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter()));
|
||||||
}
|
}
|
||||||
// The user left for Albums or Places while the page was on its way;
|
|
||||||
// painting it now would switch the view under them.
|
|
||||||
if !photo_grid_shown(&ui) {
|
|
||||||
ui.gallery.timeline_stale.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Photos {
|
Ok(Ok(Response::Photos {
|
||||||
available,
|
available,
|
||||||
@@ -4390,20 +4138,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
|
ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
|
||||||
}
|
}
|
||||||
repaint_gallery(&ui);
|
repaint_gallery(&ui);
|
||||||
// The rows are swapped in place, which keeps the old scroll
|
|
||||||
// offset; a fresh load (another kind, month, album or place)
|
|
||||||
// reads from its newest photo.
|
|
||||||
if !append
|
|
||||||
&& ui
|
|
||||||
.gallery
|
|
||||||
.list
|
|
||||||
.model()
|
|
||||||
.is_some_and(|rows| rows.n_items() > 0)
|
|
||||||
{
|
|
||||||
ui.gallery
|
|
||||||
.list
|
|
||||||
.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
|
|
||||||
}
|
|
||||||
if ui.gallery.model.n_items() == 0 {
|
if ui.gallery.model.n_items() == 0 {
|
||||||
let filtered = ui.gallery.kind.get().is_some()
|
let filtered = ui.gallery.kind.get().is_some()
|
||||||
|| ui.gallery.favorites.get()
|
|| ui.gallery.favorites.get()
|
||||||
@@ -4502,13 +4236,24 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
|||||||
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
|
||||||
|
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
|
||||||
|
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
||||||
|
/// user's default handler when mpv isn't installed.
|
||||||
|
pub(crate) fn play_external(path: &str) {
|
||||||
|
match Command::new("mpv").arg(path).spawn() {
|
||||||
|
Ok(child) => pdfs_core::opener::reap(child),
|
||||||
|
Err(_) => open_path(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
|
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
|
||||||
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
|
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
|
||||||
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
|
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
|
||||||
tile_ratio, viewport_unfilled,
|
tile_ratio,
|
||||||
};
|
};
|
||||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
||||||
|
|
||||||
@@ -4631,18 +4376,6 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).unwrap();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A first page zoomed all the way out fits on one screen, with nothing
|
|
||||||
/// to scroll: the next page has to load without a scroll asking for it.
|
|
||||||
#[test]
|
|
||||||
fn a_page_zoomed_out_onto_one_screen_asks_for_more() {
|
|
||||||
let (columns, size) = plan_grid(1580, ROW_MIN);
|
|
||||||
let rows = PHOTOS_PAGE.div_ceil(columns) as i32;
|
|
||||||
let height = f64::from(rows * (size + TILE_GAP) + 40);
|
|
||||||
assert!(viewport_unfilled(height, 880.0));
|
|
||||||
assert!(viewport_unfilled(880.0, 880.0));
|
|
||||||
assert!(!viewport_unfilled(880.0 * 3.0, 880.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zooming_in_puts_fewer_tiles_in_a_row() {
|
fn zooming_in_puts_fewer_tiles_in_a_row() {
|
||||||
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
|
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
|
||||||
@@ -4707,7 +4440,6 @@ mod tests {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
group_size: 1,
|
group_size: 1,
|
||||||
has_raw: false,
|
has_raw: false,
|
||||||
place_id: None,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Newest first, the way the daemon sends them.
|
// Newest first, the way the daemon sends them.
|
||||||
|
|||||||
@@ -267,12 +267,7 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
|||||||
}
|
}
|
||||||
// A town's card may be waiting for the same photo: share its picture
|
// A town's card may be waiting for the same photo: share its picture
|
||||||
// rather than take its place in the queue.
|
// rather than take its place in the queue.
|
||||||
let waiting = ui
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
.gallery
|
|
||||||
.thumb_wanted
|
|
||||||
.borrow()
|
|
||||||
.get(&photo.uid)
|
|
||||||
.and_then(|waiting| waiting.first().cloned());
|
|
||||||
let picture = waiting.unwrap_or_else(|| {
|
let picture = waiting.unwrap_or_else(|| {
|
||||||
let picture = gtk4::Picture::new();
|
let picture = gtk4::Picture::new();
|
||||||
want_thumb(ui, photo, &picture);
|
want_thumb(ui, photo, &picture);
|
||||||
@@ -367,12 +362,7 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
|
|||||||
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||||
/// already waiting for.
|
/// already waiting for.
|
||||||
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
let waiting = ui
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
.gallery
|
|
||||||
.thumb_wanted
|
|
||||||
.borrow()
|
|
||||||
.get(&photo.uid)
|
|
||||||
.and_then(|waiting| waiting.first().cloned());
|
|
||||||
match waiting {
|
match waiting {
|
||||||
Some(waiting) => {
|
Some(waiting) => {
|
||||||
waiting
|
waiting
|
||||||
@@ -402,45 +392,21 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open photo `uid` of `place` from the map or a spot's list, paging through
|
/// Open `place` and then photo `uid` in it, paging through the town until
|
||||||
/// the town until the photo has loaded.
|
/// the photo has loaded.
|
||||||
///
|
|
||||||
/// The lightbox steps through the gallery's model, so the town is opened
|
|
||||||
/// behind it. Closing the lightbox goes back to the map rather than leaving
|
|
||||||
/// the user in a town timeline they never asked for.
|
|
||||||
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
||||||
let id = place.id;
|
|
||||||
open_place(ui, place);
|
open_place(ui, place);
|
||||||
view_when_loaded(ui, id, uid);
|
view_when_loaded(ui, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open place's id, if a place is open.
|
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
|
||||||
fn open_place_id(ui: &Ui) -> Option<u32> {
|
|
||||||
ui.gallery.place.borrow().as_ref().map(|place| place.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_when_loaded(ui: &Rc<Ui>, id: u32, uid: String) {
|
|
||||||
let ui_page = ui.clone();
|
let ui_page = ui.clone();
|
||||||
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||||
let ui = ui_page;
|
let ui = ui_page;
|
||||||
// The user went elsewhere while the town loaded: whatever page lands
|
|
||||||
// now belongs to that, and must not pop the lightbox up over it.
|
|
||||||
if open_place_id(&ui) != Some(id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
||||||
let window = open_photo_viewer(&ui, uid);
|
open_photo_viewer(&ui, uid);
|
||||||
let ui_close = ui.clone();
|
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
|
||||||
window.connect_close_request(move |_| {
|
view_when_loaded(&ui, uid);
|
||||||
// Only if the town is still what the lightbox was showing: "Show
|
|
||||||
// on map" in the lightbox has already moved on by itself.
|
|
||||||
if open_place_id(&ui_close) == Some(id) {
|
|
||||||
show_place_grid(&ui_close);
|
|
||||||
}
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
});
|
|
||||||
} else if ui.gallery.has_more.get() {
|
|
||||||
view_when_loaded(&ui, id, uid);
|
|
||||||
ui.gallery.burst.set(true);
|
ui.gallery.burst.set(true);
|
||||||
load_gallery(&ui, true);
|
load_gallery(&ui, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,7 +279,10 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets.back.connect_clicked(move |_| shared_back(&ui_back));
|
widgets.back.connect_clicked(move |_| {
|
||||||
|
ui_back.shared.nav.borrow_mut().pop();
|
||||||
|
load_shared(&ui_back);
|
||||||
|
});
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.retry
|
.retry
|
||||||
@@ -315,14 +318,6 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
|
|||||||
ui.shared.content.set_visible_child_name("status");
|
ui.shared.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Back out of a shared folder to the level above: the page's Back button, and
|
|
||||||
/// Alt+Left or Backspace. A no-op at the top.
|
|
||||||
pub(crate) fn shared_back(ui: &Rc<Ui>) {
|
|
||||||
if ui.shared.nav.borrow_mut().pop().is_some() {
|
|
||||||
load_shared(ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
|
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
|
||||||
/// and repaint the page once all three land.
|
/// and repaint the page once all three land.
|
||||||
///
|
///
|
||||||
@@ -448,7 +443,6 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
let nav = ui.shared.nav.borrow().clone();
|
let nav = ui.shared.nav.borrow().clone();
|
||||||
repaint_shared_crumb(ui, &nav);
|
repaint_shared_crumb(ui, &nav);
|
||||||
let top = nav.is_empty();
|
let top = nav.is_empty();
|
||||||
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
|
|
||||||
*ui.shared.listed.borrow_mut() = Some(nav);
|
*ui.shared.listed.borrow_mut() = Some(nav);
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
ui.shared.files.model.remove_all();
|
ui.shared.files.model.remove_all();
|
||||||
@@ -473,11 +467,8 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
}
|
}
|
||||||
ui.shared.files_content.set_visible_child_name("list");
|
ui.shared.files_content.set_visible_child_name("list");
|
||||||
// Only the rows that changed are swapped, so a refresh keeps the selection
|
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||||
// and the scroll position. Another folder starts from its first entry.
|
// and the scroll position.
|
||||||
replace_items(&ui.shared.files.model, entries);
|
replace_items(&ui.shared.files.model, entries);
|
||||||
if another_folder {
|
|
||||||
ui.shared.files.scroll_to_top();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
|
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
|
||||||
|
|||||||
@@ -1115,6 +1115,9 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
|||||||
// count falling to zero is what "the sync finished" looks like from here.
|
// count falling to zero is what "the sync finished" looks like from here.
|
||||||
// Jobs are deliberately not counted — a bulk upload retires its scan job and
|
// Jobs are deliberately not counted — a bulk upload retires its scan job and
|
||||||
// starts its upload job mid-flight, which is not a thing finishing.
|
// starts its upload job mid-flight, which is not a thing finishing.
|
||||||
|
// The Import page reads its progress off the same snapshot rather than
|
||||||
|
// polling the daemon a second time for numbers already on the wire.
|
||||||
|
takeout_progress(ui, jobs);
|
||||||
|
|
||||||
let previous = ui.status.active_transfers.replace(items.len());
|
let previous = ui.status.active_transfers.replace(items.len());
|
||||||
if !items.is_empty() {
|
if !items.is_empty() {
|
||||||
@@ -1208,18 +1211,13 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
|||||||
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
|
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
|
||||||
let done = j.done.to_string();
|
let done = j.done.to_string();
|
||||||
let total = j.total.to_string();
|
let total = j.total.to_string();
|
||||||
let (title, detail) = if j.title == IMPORT_JOB {
|
|
||||||
import_job_text(&j.detail)
|
|
||||||
} else {
|
|
||||||
(j.title.clone(), j.detail.clone())
|
|
||||||
};
|
|
||||||
let args: [(&str, &str); 4] = [
|
let args: [(&str, &str); 4] = [
|
||||||
("title", &title),
|
("title", &j.title),
|
||||||
("detail", &detail),
|
("detail", &j.detail),
|
||||||
("done", &done),
|
("done", &done),
|
||||||
("total", &total),
|
("total", &total),
|
||||||
];
|
];
|
||||||
let text = match (j.total > 0, detail.is_empty()) {
|
let text = match (j.total > 0, j.detail.is_empty()) {
|
||||||
// Translators: a background job's progress. {title} is the job, such as
|
// Translators: a background job's progress. {title} is the job, such as
|
||||||
// "Scanning Photos"; {done} and {total} are counts.
|
// "Scanning Photos"; {done} and {total} are counts.
|
||||||
(true, true) => gettext_f("{title} — {done} of {total}", &args),
|
(true, true) => gettext_f("{title} — {done} of {total}", &args),
|
||||||
|
|||||||
@@ -15,30 +15,17 @@
|
|||||||
//! Import — staging is local, so a mis-drop costs nothing.
|
//! Import — staging is local, so a mis-drop costs nothing.
|
||||||
//!
|
//!
|
||||||
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||||
//! stages paths, launches, and asks [`Request::ImportStatus`] for the step, the
|
//! stages paths, launches, asks [`Request::ImportStatus`] for the final report,
|
||||||
//! running counts and the final report. It asks again whenever the import's job
|
//! and reads live progress off the import's job in the transfer snapshot the
|
||||||
//! moves in the transfer snapshot the daemon already pushes. A run the service
|
//! daemon already pushes (see [`takeout_progress`]).
|
||||||
//! did not get to finish can be picked up with [`Request::ResumeImport`].
|
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||||
/// Seeing it move is the cue to ask for the import's status; the Activity list
|
/// Matching on it is how this page borrows the progress the daemon already
|
||||||
/// shows it under its translated name ([`import_job_text`]).
|
/// pushes instead of asking for it separately.
|
||||||
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
|
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
|
||||||
|
|
||||||
/// The import job's title and detail in the user's language. The daemon names
|
|
||||||
/// its steps in English; the file being uploaded passes through as it is.
|
|
||||||
pub(crate) fn import_job_text(detail: &str) -> (String, String) {
|
|
||||||
let detail = match detail {
|
|
||||||
"reading archives" => gettext("Reading the archives"),
|
|
||||||
"checking for photos you already have" => gettext("Checking for photos you already have"),
|
|
||||||
"sorting photos into albums" => gettext("Sorting photos into albums"),
|
|
||||||
other => other.to_string(),
|
|
||||||
};
|
|
||||||
(gettext("Importing from Google Photos"), detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the archive list says before anything is staged. `repaint_archives`
|
/// What the archive list says before anything is staged. `repaint_archives`
|
||||||
/// swaps in the count and total once there is something to report, and swaps
|
/// swaps in the count and total once there is something to report, and swaps
|
||||||
/// this back when the list is emptied again.
|
/// this back when the list is emptied again.
|
||||||
@@ -108,19 +95,18 @@ pub(crate) struct TakeoutState {
|
|||||||
pub(crate) progress_group: adw::PreferencesGroup,
|
pub(crate) progress_group: adw::PreferencesGroup,
|
||||||
pub(crate) progress_label: gtk4::Label,
|
pub(crate) progress_label: gtk4::Label,
|
||||||
pub(crate) progress_bar: gtk4::ProgressBar,
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
/// The running counts, or the report for the last finished run; hidden
|
/// Report group for the last finished run, hidden until there is one.
|
||||||
/// until there is one.
|
|
||||||
pub(crate) summary_group: adw::PreferencesGroup,
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
|
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
/// Offers to resume an import the service did not get to finish.
|
|
||||||
pub(crate) resume_group: adw::PreferencesGroup,
|
|
||||||
pub(crate) resume_row: adw::ActionRow,
|
|
||||||
pub(crate) resume_button: gtk4::Button,
|
|
||||||
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
|
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
|
||||||
/// can't stack worker threads on the 2s tick.
|
/// can't stack worker threads on the 2s tick.
|
||||||
pub(crate) inflight: Cell<bool>,
|
pub(crate) inflight: Cell<bool>,
|
||||||
/// Whether the daemon reported an import running on the last poll.
|
/// Whether the daemon reported an import running on the last poll.
|
||||||
pub(crate) running: Cell<bool>,
|
pub(crate) running: Cell<bool>,
|
||||||
|
/// Whether the run in flight was launched as a dry run, so the report can
|
||||||
|
/// say "would upload" rather than claiming photos were sent. The daemon's
|
||||||
|
/// summary carries no such flag — it is the same shape either way.
|
||||||
|
pub(crate) dry_run: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Import page hands back for wiring.
|
/// Widgets the Import page hands back for wiring.
|
||||||
@@ -140,9 +126,6 @@ pub(crate) struct TakeoutWidgets {
|
|||||||
pub(crate) progress_label: gtk4::Label,
|
pub(crate) progress_label: gtk4::Label,
|
||||||
pub(crate) progress_bar: gtk4::ProgressBar,
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
pub(crate) summary_group: adw::PreferencesGroup,
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
pub(crate) resume_group: adw::PreferencesGroup,
|
|
||||||
pub(crate) resume_row: adw::ActionRow,
|
|
||||||
pub(crate) resume_button: gtk4::Button,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||||
@@ -278,23 +261,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
.visible(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// An import cut short by a restart or a lost connection, offered back at the
|
|
||||||
// top of the page: picking the archives again is exactly what a reboot in
|
|
||||||
// the middle of a migration should not cost.
|
|
||||||
let resume_button = gtk4::Button::builder()
|
|
||||||
.label(pgettext("verb", "Resume"))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
resume_button.add_css_class("suggested-action");
|
|
||||||
let resume_row = adw::ActionRow::builder()
|
|
||||||
.title(gettext("The last import didn't finish"))
|
|
||||||
.build();
|
|
||||||
resume_row.add_suffix(&resume_button);
|
|
||||||
let resume_group = adw::PreferencesGroup::builder().visible(false).build();
|
|
||||||
resume_group.add(&resume_row);
|
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
groups.append(&resume_group);
|
|
||||||
groups.append(&dropzone);
|
groups.append(&dropzone);
|
||||||
groups.append(&list_group);
|
groups.append(&list_group);
|
||||||
groups.append(&actions);
|
groups.append(&actions);
|
||||||
@@ -334,9 +301,6 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
progress_label: progress_label.clone(),
|
progress_label: progress_label.clone(),
|
||||||
progress_bar: progress_bar.clone(),
|
progress_bar: progress_bar.clone(),
|
||||||
summary_group: summary_group.clone(),
|
summary_group: summary_group.clone(),
|
||||||
resume_group,
|
|
||||||
resume_row,
|
|
||||||
resume_button,
|
|
||||||
};
|
};
|
||||||
(frame.upcast(), widgets)
|
(frame.upcast(), widgets)
|
||||||
}
|
}
|
||||||
@@ -463,28 +427,6 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_resume = ui.clone();
|
|
||||||
widgets.resume_button.connect_clicked(move |_| {
|
|
||||||
let ui = ui_resume.clone();
|
|
||||||
ui.busy_begin();
|
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ResumeImport);
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let result = rx.recv().await;
|
|
||||||
ui.busy_end();
|
|
||||||
match result {
|
|
||||||
Ok(Ok(Response::Ok { .. })) => import_started(&ui, false),
|
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
|
||||||
toast_failure(&ui, &gettext("Couldn't resume the import"), &message, kind);
|
|
||||||
}
|
|
||||||
_ => toast_error(
|
|
||||||
&ui,
|
|
||||||
&gettext("Couldn't resume the import"),
|
|
||||||
&gettext("The Proton Drive service didn't respond."),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.back_button
|
.back_button
|
||||||
@@ -617,7 +559,6 @@ fn sync_takeout_actions(ui: &Rc<Ui>) {
|
|||||||
.import_button
|
.import_button
|
||||||
.set_sensitive(has_files && !running);
|
.set_sensitive(has_files && !running);
|
||||||
ui.takeout.clear_button.set_sensitive(has_files && !running);
|
ui.takeout.clear_button.set_sensitive(has_files && !running);
|
||||||
ui.takeout.resume_button.set_sensitive(!running);
|
|
||||||
ui.takeout.cancel_button.set_visible(running);
|
ui.takeout.cancel_button.set_visible(running);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +577,7 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui.takeout.dry_run.set(dry_run);
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
@@ -646,7 +588,27 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => import_started(&ui, dry_run),
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
ui.takeout.running.set(true);
|
||||||
|
ui.gallery.import_banner.set_revealed(true);
|
||||||
|
ui.takeout.summary_group.set_visible(false);
|
||||||
|
ui.takeout.progress_group.set_visible(true);
|
||||||
|
ui.takeout.progress_bar.set_fraction(0.0);
|
||||||
|
ui.takeout.progress_label.set_text(&if dry_run {
|
||||||
|
gettext("Reading archives…")
|
||||||
|
} else {
|
||||||
|
gettext("Starting import…")
|
||||||
|
});
|
||||||
|
sync_takeout_actions(&ui);
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
&if dry_run {
|
||||||
|
gettext("Scanning the export…")
|
||||||
|
} else {
|
||||||
|
gettext("Import started — you can leave this page, it keeps running.")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
||||||
}
|
}
|
||||||
@@ -659,39 +621,9 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch the page over to reporting progress once the daemon took the run.
|
/// Ask the daemon for the import's state, and paint the report once a run has
|
||||||
fn import_started(ui: &Rc<Ui>, dry_run: bool) {
|
/// finished. Asked on arrival and while import progress keeps arriving. Cheap
|
||||||
ui.takeout.running.set(true);
|
/// and inflight-guarded.
|
||||||
if !dry_run {
|
|
||||||
ui.gallery.import_banner.set_revealed(true);
|
|
||||||
}
|
|
||||||
ui.takeout.resume_group.set_visible(false);
|
|
||||||
ui.takeout.summary_group.set_visible(false);
|
|
||||||
ui.takeout.progress_group.set_visible(true);
|
|
||||||
ui.takeout.progress_group.set_title(&if dry_run {
|
|
||||||
gettext("Scan in progress")
|
|
||||||
} else {
|
|
||||||
gettext("Import in progress")
|
|
||||||
});
|
|
||||||
ui.takeout.progress_bar.set_fraction(0.0);
|
|
||||||
ui.takeout
|
|
||||||
.progress_label
|
|
||||||
.set_text(&gettext("Reading the archives…"));
|
|
||||||
sync_takeout_actions(ui);
|
|
||||||
toast(
|
|
||||||
ui,
|
|
||||||
&if dry_run {
|
|
||||||
gettext("Scanning the export…")
|
|
||||||
} else {
|
|
||||||
gettext("Import started — you can leave this page, it keeps running.")
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask the daemon for the import's state and paint it: the step and counts
|
|
||||||
/// while it runs, the report once it has ended, and the offer to resume a run
|
|
||||||
/// that did not finish. Asked on arrival and while import progress keeps
|
|
||||||
/// arriving. Cheap and inflight-guarded.
|
|
||||||
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||||
if ui.takeout.inflight.get() {
|
if ui.takeout.inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -702,250 +634,124 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.takeout.inflight.set(false);
|
ui.takeout.inflight.set(false);
|
||||||
let Ok(Ok(Response::ImportStatus {
|
let Ok(Ok(Response::ImportStatus { running, summary })) = result else {
|
||||||
running,
|
|
||||||
summary,
|
|
||||||
progress,
|
|
||||||
resumable,
|
|
||||||
})) = result
|
|
||||||
else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let was_running = ui.takeout.running.replace(running);
|
let was_running = ui.takeout.running.replace(running);
|
||||||
let dry = summary.as_ref().is_some_and(|s| s.dry_run);
|
ui.gallery.import_banner.set_revealed(running);
|
||||||
ui.gallery.import_banner.set_revealed(running && !dry);
|
|
||||||
ui.takeout.progress_group.set_visible(running);
|
|
||||||
if running {
|
if running {
|
||||||
ui.takeout.progress_group.set_title(&if dry {
|
ui.takeout.progress_group.set_visible(true);
|
||||||
gettext("Scan in progress")
|
} else {
|
||||||
} else {
|
ui.takeout.progress_group.set_visible(false);
|
||||||
gettext("Import in progress")
|
// An import runs for hours, so its ending is announced rather than
|
||||||
});
|
// left to be discovered: a toast for whoever is looking at the
|
||||||
paint_progress(&ui, progress.as_ref());
|
// window, and a desktop notification for whoever is not.
|
||||||
} else if was_running && let Some(summary) = &summary {
|
let failed = summary.as_ref().and_then(|s| s.error.clone());
|
||||||
announce_end(&ui, summary);
|
if was_running && let Some(error) = &failed {
|
||||||
|
let headline = gettext("Couldn't import from Google Photos");
|
||||||
|
toast_error(&ui, &headline, error);
|
||||||
|
notify("takeout-import", &headline, error);
|
||||||
|
} else if was_running {
|
||||||
|
let dry = ui.takeout.dry_run.get();
|
||||||
|
let headline = if dry {
|
||||||
|
gettext("Scan finished")
|
||||||
|
} else {
|
||||||
|
gettext("Import finished")
|
||||||
|
};
|
||||||
|
toast(&ui, &headline);
|
||||||
|
if !dry {
|
||||||
|
let body = match &summary {
|
||||||
|
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
||||||
|
Some(s) if s.failed > 0 => ngettext_f(
|
||||||
|
"{n} photo added, {failed} failed.",
|
||||||
|
"{n} photos added, {failed} failed.",
|
||||||
|
s.uploaded as u64,
|
||||||
|
&[("failed", &s.failed.to_string())],
|
||||||
|
),
|
||||||
|
Some(s) if s.cancelled => ngettext_f(
|
||||||
|
"Stopped after {n} photo.",
|
||||||
|
"Stopped after {n} photos.",
|
||||||
|
s.uploaded as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
Some(s) => ngettext_f(
|
||||||
|
"{n} photo added to Proton Photos.",
|
||||||
|
"{n} photos added to Proton Photos.",
|
||||||
|
s.uploaded as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
None => gettext("The Google Photos import has finished."),
|
||||||
|
};
|
||||||
|
notify("takeout-import", &headline, &body);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if was_running != running {
|
if was_running != running {
|
||||||
sync_takeout_actions(&ui);
|
sync_takeout_actions(&ui);
|
||||||
}
|
}
|
||||||
match &summary {
|
match summary {
|
||||||
Some(summary) => repaint_summary(&ui, summary, running),
|
Some(summary) => repaint_summary(&ui, &summary),
|
||||||
None => ui.takeout.summary_group.set_visible(false),
|
None => ui.takeout.summary_group.set_visible(false),
|
||||||
}
|
}
|
||||||
paint_resume(&ui, running, summary.as_ref(), &resumable);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tell the user how a run ended. An import runs for hours, so its ending is
|
/// Paint the last run's report.
|
||||||
/// announced rather than left to be discovered: a toast for whoever is looking
|
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||||
/// at the window, and a desktop notification for whoever is not.
|
|
||||||
fn announce_end(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|
||||||
if summary.dry_run {
|
|
||||||
toast(ui, &gettext("Scan finished"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(error) = &summary.error {
|
|
||||||
let kind = summary.error_kind.unwrap_or_default();
|
|
||||||
let headline = gettext("Couldn't import from Google Photos");
|
|
||||||
toast_failure(ui, &headline, error, kind);
|
|
||||||
notify(
|
|
||||||
"takeout-import",
|
|
||||||
&headline,
|
|
||||||
&gettext("You can resume it once the problem is solved."),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (headline, body) = if summary.interrupted {
|
|
||||||
(
|
|
||||||
gettext("Import interrupted"),
|
|
||||||
gettext("You can resume it where it stopped."),
|
|
||||||
)
|
|
||||||
} else if summary.cancelled {
|
|
||||||
(
|
|
||||||
gettext("Import stopped"),
|
|
||||||
ngettext_f(
|
|
||||||
"Stopped after {n} photo.",
|
|
||||||
"Stopped after {n} photos.",
|
|
||||||
summary.uploaded as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if summary.failed > 0 {
|
|
||||||
(
|
|
||||||
gettext("Import finished"),
|
|
||||||
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
|
||||||
ngettext_f(
|
|
||||||
"{n} photo added, {failed} failed.",
|
|
||||||
"{n} photos added, {failed} failed.",
|
|
||||||
summary.uploaded as u64,
|
|
||||||
&[("failed", &summary.failed.to_string())],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
gettext("Import finished"),
|
|
||||||
ngettext_f(
|
|
||||||
"{n} photo added to Proton Photos.",
|
|
||||||
"{n} photos added to Proton Photos.",
|
|
||||||
summary.uploaded as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
toast(ui, &headline);
|
|
||||||
notify("takeout-import", &headline, &body);
|
|
||||||
|
|
||||||
// A complete import is done with its archives; keeping them staged would
|
|
||||||
// only invite running the same export again.
|
|
||||||
if summary.complete() {
|
|
||||||
ui.takeout.archives.borrow_mut().clear();
|
|
||||||
repaint_archives(ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paint where a running import is.
|
|
||||||
fn paint_progress(ui: &Rc<Ui>, progress: Option<&ImportProgress>) {
|
|
||||||
let Some(progress) = progress else {
|
|
||||||
ui.takeout.progress_bar.pulse();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let done = progress.done.to_string();
|
|
||||||
let total = progress.total.to_string();
|
|
||||||
let current = progress.current.clone().unwrap_or_default();
|
|
||||||
let counted = progress.total > 0;
|
|
||||||
let args = [
|
|
||||||
("done", done.as_str()),
|
|
||||||
("total", total.as_str()),
|
|
||||||
("name", current.as_str()),
|
|
||||||
];
|
|
||||||
let text = match (progress.phase, counted) {
|
|
||||||
(ImportPhase::Scanning, _) => gettext("Reading the archives…"),
|
|
||||||
(ImportPhase::Checking, false) => gettext("Checking for photos you already have…"),
|
|
||||||
// Translators: {done} and {total} are counts of photos.
|
|
||||||
(ImportPhase::Checking, true) => gettext_f(
|
|
||||||
"Checking for photos you already have — {done} of {total}",
|
|
||||||
&args,
|
|
||||||
),
|
|
||||||
(ImportPhase::Uploading, false) => gettext("Uploading…"),
|
|
||||||
(ImportPhase::Uploading, true) if current.is_empty() => {
|
|
||||||
// Translators: {done} and {total} are counts of photos.
|
|
||||||
gettext_f("Uploading {done} of {total}", &args)
|
|
||||||
}
|
|
||||||
// Translators: {done} and {total} are counts of photos; {name} is the file being uploaded.
|
|
||||||
(ImportPhase::Uploading, true) => gettext_f("Uploading {done} of {total} — {name}", &args),
|
|
||||||
(ImportPhase::Albums, false) => gettext("Sorting photos into albums…"),
|
|
||||||
// Translators: {done} and {total} are counts of albums.
|
|
||||||
(ImportPhase::Albums, true) => {
|
|
||||||
gettext_f("Sorting photos into albums — {done} of {total}", &args)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ui.takeout.progress_label.set_text(&text);
|
|
||||||
if counted {
|
|
||||||
ui.takeout
|
|
||||||
.progress_bar
|
|
||||||
.set_fraction((progress.done as f64 / progress.total as f64).min(1.0));
|
|
||||||
} else {
|
|
||||||
ui.takeout.progress_bar.pulse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Offer to resume the last run when the service says it can be.
|
|
||||||
fn paint_resume(ui: &Rc<Ui>, running: bool, summary: Option<&ImportSummary>, resumable: &[String]) {
|
|
||||||
let offer = !running && !resumable.is_empty();
|
|
||||||
ui.takeout.resume_group.set_visible(offer);
|
|
||||||
if !offer {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let uploaded = summary.map_or(0, |s| s.uploaded) as u64;
|
|
||||||
// Translators: {n} is the number of photos uploaded before the import stopped.
|
|
||||||
ui.takeout.resume_row.set_subtitle(&ngettext_f(
|
|
||||||
"{n} photo uploaded before it stopped. Resuming skips what is already there.",
|
|
||||||
"{n} photos uploaded before it stopped. Resuming skips what is already there.",
|
|
||||||
uploaded,
|
|
||||||
&[],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paint the counts of a running import, or the report of the last one.
|
|
||||||
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary, running: bool) {
|
|
||||||
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
|
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
|
||||||
ui.takeout.summary_group.remove(&row);
|
ui.takeout.summary_group.remove(&row);
|
||||||
}
|
}
|
||||||
let dry = summary.dry_run;
|
let dry = ui.takeout.dry_run.get();
|
||||||
ui.takeout.summary_group.set_title(&if running {
|
ui.takeout.summary_group.set_title(&if dry {
|
||||||
gettext("So far")
|
|
||||||
} else if dry {
|
|
||||||
gettext("Scan result")
|
gettext("Scan result")
|
||||||
} else {
|
} else {
|
||||||
gettext("Last import")
|
gettext("Last import")
|
||||||
});
|
});
|
||||||
let description = if running {
|
ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error {
|
||||||
None
|
// The reason is the service's own text, which is not translated. A
|
||||||
} else if let Some(error) = &summary.error {
|
// group's description is markup, and the reason may name a path.
|
||||||
// The reason is the service's own text, which is not translated; the
|
glib::markup_escape_text(&gettext_f(
|
||||||
// headline in front of it is. A group's description is markup, and the
|
"{what}: {detail}",
|
||||||
// reason may name a path.
|
&[
|
||||||
let what = gettext("Couldn't import from Google Photos");
|
("what", &gettext("Couldn't import from Google Photos")),
|
||||||
Some(
|
("detail", error),
|
||||||
glib::markup_escape_text(&gettext_f(
|
],
|
||||||
"{what}: {detail}",
|
))
|
||||||
&[
|
.to_string()
|
||||||
(
|
|
||||||
"what",
|
|
||||||
error_headline(summary.error_kind.unwrap_or_default(), &what),
|
|
||||||
),
|
|
||||||
("detail", error),
|
|
||||||
],
|
|
||||||
))
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
} else if dry {
|
} else if dry {
|
||||||
Some(gettext(
|
gettext("Nothing was uploaded — this is what an import would do.")
|
||||||
"Nothing was uploaded — this is what an import would do.",
|
|
||||||
))
|
|
||||||
} else if summary.interrupted {
|
|
||||||
Some(gettext(
|
|
||||||
"The Proton Drive service stopped while the import ran. Resume it to continue where it left off.",
|
|
||||||
))
|
|
||||||
} else if summary.cancelled {
|
} else if summary.cancelled {
|
||||||
Some(gettext(
|
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
||||||
"Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.",
|
|
||||||
))
|
|
||||||
} else if !summary.complete() {
|
|
||||||
Some(gettext(
|
|
||||||
"Finished with problems. Run it again to retry what didn't upload.",
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
Some(gettext("Finished."))
|
gettext("Finished.")
|
||||||
};
|
}));
|
||||||
ui.takeout
|
|
||||||
.summary_group
|
|
||||||
.set_description(description.as_deref());
|
|
||||||
|
|
||||||
// Only lines that carry information: a run with no failures should not have
|
// Only lines that carry information: a run with no failures should not have
|
||||||
// to show a "0 failed" row for the user to read past.
|
// to show a "0 failed" row for the user to read past.
|
||||||
let mut lines: Vec<(String, String)> =
|
let mut lines: Vec<(String, String)> = vec![
|
||||||
vec![(gettext("Photos in the export"), summary.found.to_string())];
|
(gettext("Photos in the export"), summary.found.to_string()),
|
||||||
if dry {
|
(
|
||||||
lines.push((gettext("Would upload"), summary.to_upload.to_string()));
|
if dry {
|
||||||
} else {
|
gettext("Would upload")
|
||||||
lines.push((gettext("Uploaded"), summary.uploaded.to_string()));
|
} else {
|
||||||
}
|
gettext("Uploaded")
|
||||||
|
},
|
||||||
|
summary.uploaded.to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
if summary.duplicates > 0 {
|
if summary.duplicates > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
gettext("Already in Proton Photos"),
|
gettext("Already in Proton Photos"),
|
||||||
summary.duplicates.to_string(),
|
summary.duplicates.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.live_photos > 0 {
|
|
||||||
lines.push((
|
|
||||||
gettext("Live photos kept together"),
|
|
||||||
summary.live_photos.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if summary.albums_created > 0 {
|
if summary.albums_created > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
gettext("Albums created"),
|
if dry {
|
||||||
|
gettext("Albums to create")
|
||||||
|
} else {
|
||||||
|
gettext("Albums created")
|
||||||
|
},
|
||||||
summary.albums_created.to_string(),
|
summary.albums_created.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -961,31 +767,25 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary, running: bool) {
|
|||||||
summary.skipped_trashed.to_string(),
|
summary.skipped_trashed.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if dry && summary.to_upload_bytes > 0 {
|
if summary.bytes > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
gettext("Data to upload"),
|
if dry {
|
||||||
human_bytes(summary.to_upload_bytes),
|
gettext("Data to upload")
|
||||||
|
} else {
|
||||||
|
gettext("Data uploaded")
|
||||||
|
},
|
||||||
|
human_bytes(summary.bytes),
|
||||||
));
|
));
|
||||||
} else if summary.bytes > 0 {
|
|
||||||
lines.push((gettext("Data uploaded"), human_bytes(summary.bytes)));
|
|
||||||
}
|
}
|
||||||
if summary.failed > 0 {
|
if summary.failed > 0 {
|
||||||
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
|
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
|
||||||
}
|
}
|
||||||
for path in &summary.unreadable_archives {
|
|
||||||
let name = Path::new(path)
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| path.clone());
|
|
||||||
lines.push((gettext("Couldn't read this archive"), name));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for (title, value) in lines {
|
for (title, value) in lines {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(title)
|
.title(title)
|
||||||
.subtitle(value)
|
.subtitle(value)
|
||||||
.use_markup(false)
|
|
||||||
.build();
|
.build();
|
||||||
row.add_css_class("property");
|
row.add_css_class("property");
|
||||||
ui.takeout.summary_group.add(&row);
|
ui.takeout.summary_group.add(&row);
|
||||||
@@ -994,3 +794,35 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary, running: bool) {
|
|||||||
*ui.takeout.summary_rows.borrow_mut() = rows;
|
*ui.takeout.summary_rows.borrow_mut() = rows;
|
||||||
ui.takeout.summary_group.set_visible(true);
|
ui.takeout.summary_group.set_visible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paint the import's progress from the transfer snapshot the daemon pushed,
|
||||||
|
/// so this page costs no extra request. A job with no known total
|
||||||
|
/// pulses instead of showing a fraction, the same as the Activity list.
|
||||||
|
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||||
|
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ui.takeout.progress_group.set_visible(true);
|
||||||
|
if job.total > 0 {
|
||||||
|
ui.takeout
|
||||||
|
.progress_bar
|
||||||
|
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
|
||||||
|
let done = job.done.to_string();
|
||||||
|
let total = job.total.to_string();
|
||||||
|
// Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
|
||||||
|
let text = gettext_f(
|
||||||
|
"{done} of {total} — {detail}",
|
||||||
|
&[("done", &done), ("total", &total), ("detail", &job.detail)],
|
||||||
|
);
|
||||||
|
ui.takeout.progress_label.set_text(&text);
|
||||||
|
} else {
|
||||||
|
ui.takeout.progress_bar.pulse();
|
||||||
|
ui.takeout
|
||||||
|
.progress_label
|
||||||
|
.set_text(&if job.detail.is_empty() {
|
||||||
|
gettext("Working…")
|
||||||
|
} else {
|
||||||
|
job.detail.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,49 +224,6 @@ impl FileList {
|
|||||||
.set_visible_child_name(if list { "list" } else { "grid" });
|
.set_visible_child_name(if list { "list" } else { "grid" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring both views back to the first entry. For a listing of something
|
|
||||||
/// else: [`replace_items`] keeps the scroll position, which is right for a
|
|
||||||
/// refresh, but another folder would open where the last one was left —
|
|
||||||
/// often at its bottom.
|
|
||||||
pub(crate) fn scroll_to_top(&self) {
|
|
||||||
if self.model.n_items() == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.grid.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
|
|
||||||
self.column_view
|
|
||||||
.scroll_to(0, None, gtk4::ListScrollFlags::NONE, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How far the view on screen is scrolled, in px from the top.
|
|
||||||
pub(crate) fn scroll_offset(&self) -> f64 {
|
|
||||||
self.views
|
|
||||||
.visible_child()
|
|
||||||
.and_downcast::<gtk4::ScrolledWindow>()
|
|
||||||
.map_or(0.0, |scroll| scroll.vadjustment().value())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select the entries named `selected` and scroll the view on screen to
|
|
||||||
/// `offset`, as they were when the folder was left. The scroll waits for
|
|
||||||
/// an idle, when the views have measured the rows just put in.
|
|
||||||
pub(crate) fn restore(&self, offset: f64, selected: &[String]) {
|
|
||||||
self.selection.unselect_all();
|
|
||||||
for i in 0..self.selection.n_items() {
|
|
||||||
if entry_at(Some(&self.selection), i)
|
|
||||||
.is_some_and(|entry| selected.contains(&entry.name))
|
|
||||||
{
|
|
||||||
self.selection.select_item(i, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(scroll) = self
|
|
||||||
.views
|
|
||||||
.visible_child()
|
|
||||||
.and_downcast::<gtk4::ScrolledWindow>()
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
glib::idle_add_local_once(move || scroll.vadjustment().set_value(offset));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the list, rather than the grid, is on screen.
|
/// Whether the list, rather than the grid, is on screen.
|
||||||
pub(crate) fn shows_list(&self) -> bool {
|
pub(crate) fn shows_list(&self) -> bool {
|
||||||
self.views.visible_child_name().as_deref() == Some("list")
|
self.views.visible_child_name().as_deref() == Some("list")
|
||||||
|
|||||||
@@ -101,14 +101,8 @@ impl ActionMenu {
|
|||||||
|
|
||||||
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||||
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||||
/// item that closed it still finds its action. Returned for a caller that
|
/// item that closed it still finds its action.
|
||||||
/// has to know when it closes.
|
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
||||||
pub(crate) fn popup_at(
|
|
||||||
self,
|
|
||||||
anchor: &impl IsA<gtk4::Widget>,
|
|
||||||
x: f64,
|
|
||||||
y: f64,
|
|
||||||
) -> gtk4::PopoverMenu {
|
|
||||||
let (menu, group) = self.finish();
|
let (menu, group) = self.finish();
|
||||||
let anchor = anchor.as_ref();
|
let anchor = anchor.as_ref();
|
||||||
let host = popover_host(anchor);
|
let host = popover_host(anchor);
|
||||||
@@ -128,7 +122,6 @@ impl ActionMenu {
|
|||||||
glib::idle_add_local_once(move || p.unparent());
|
glib::idle_add_local_once(move || p.unparent());
|
||||||
});
|
});
|
||||||
popover.popup();
|
popover.popup();
|
||||||
popover
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A flat ⋮ button that opens this menu.
|
/// A flat ⋮ button that opens this menu.
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ pub(crate) mod crumb;
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
pub(crate) mod photo_drag;
|
|
||||||
pub(crate) mod player;
|
|
||||||
pub(crate) mod scrubber;
|
pub(crate) mod scrubber;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
pub(crate) mod thumbnails;
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
//! Dragging photos out of the timeline into a file manager or another app.
|
|
||||||
//!
|
|
||||||
//! A photo is not a file anywhere the receiver can read until it has been
|
|
||||||
//! downloaded, and a drag has to start the moment the pointer moves. So the
|
|
||||||
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
|
|
||||||
//! the photos only when the receiver asks for the list, which it does once the
|
|
||||||
//! photos are dropped on it, and then hands over copies under their own names.
|
|
||||||
|
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::time::{Duration, SystemTime};
|
|
||||||
|
|
||||||
use gtk4::gdk;
|
|
||||||
use gtk4::gdk::subclass::prelude::*;
|
|
||||||
|
|
||||||
use crate::*;
|
|
||||||
|
|
||||||
/// The one format a drag of photos offers. GTK turns it into a file list for
|
|
||||||
/// a receiver in this app, and every file manager reads it.
|
|
||||||
const URI_LIST: &str = "text/uri-list";
|
|
||||||
|
|
||||||
/// How long the copies handed to a receiver are kept. The receiver copies
|
|
||||||
/// them on drop, so a day is far more than it needs.
|
|
||||||
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
|
|
||||||
|
|
||||||
mod imp {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct PhotoDrag {
|
|
||||||
/// The photos dragged, as uid and Drive name.
|
|
||||||
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
|
|
||||||
pub(super) socket: RefCell<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[glib::object_subclass]
|
|
||||||
impl ObjectSubclass for PhotoDrag {
|
|
||||||
const NAME: &'static str = "PdfsPhotoDrag";
|
|
||||||
type Type = super::PhotoDrag;
|
|
||||||
type ParentType = gdk::ContentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObjectImpl for PhotoDrag {}
|
|
||||||
|
|
||||||
impl ContentProviderImpl for PhotoDrag {
|
|
||||||
fn formats(&self) -> gdk::ContentFormats {
|
|
||||||
gdk::ContentFormats::new(&[URI_LIST])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_mime_type_future(
|
|
||||||
&self,
|
|
||||||
mime_type: &str,
|
|
||||||
stream: &gio::OutputStream,
|
|
||||||
io_priority: glib::Priority,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
|
|
||||||
let photos = self.photos.borrow().clone();
|
|
||||||
let socket = self.socket.borrow().clone();
|
|
||||||
let stream = stream.clone();
|
|
||||||
let wanted = mime_type == URI_LIST;
|
|
||||||
Box::pin(async move {
|
|
||||||
if !wanted {
|
|
||||||
return Err(glib::Error::new(
|
|
||||||
gio::IOErrorEnum::NotSupported,
|
|
||||||
"photos are offered as a list of files only",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let files = fetch_dragged(socket, photos).await?;
|
|
||||||
let list: String = files
|
|
||||||
.iter()
|
|
||||||
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
|
|
||||||
.collect();
|
|
||||||
stream
|
|
||||||
.write_all_future(list.into_bytes(), io_priority)
|
|
||||||
.await
|
|
||||||
.map_err(|(_, e)| e)?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
glib::wrapper! {
|
|
||||||
/// The content of a drag of photos: their files, fetched when dropped.
|
|
||||||
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
|
|
||||||
@extends gdk::ContentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PhotoDrag {
|
|
||||||
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
|
|
||||||
let drag: Self = glib::Object::new();
|
|
||||||
*drag.imp().photos.borrow_mut() = photos;
|
|
||||||
*drag.imp().socket.borrow_mut() = socket;
|
|
||||||
drag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download each dragged photo and put a copy under its Drive name where the
|
|
||||||
/// receiver can read it. A photo that fails is left out; only when every one
|
|
||||||
/// fails does the drop fail.
|
|
||||||
async fn fetch_dragged(
|
|
||||||
socket: PathBuf,
|
|
||||||
photos: Vec<(String, Option<String>)>,
|
|
||||||
) -> Result<Vec<PathBuf>, glib::Error> {
|
|
||||||
let base = glib::user_cache_dir().join("pdfs-drag");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
let mut last_error = None;
|
|
||||||
for (uid, name) in photos {
|
|
||||||
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
|
|
||||||
let source = match rx.recv().await {
|
|
||||||
Ok(Ok(Response::FilePath { path })) => path,
|
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
|
||||||
last_error = Some(message);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
last_error = Some("the Proton Drive service didn't respond".to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let base = base.clone();
|
|
||||||
let copied = gio::spawn_blocking(move || {
|
|
||||||
prune_dragged(&base, SystemTime::now());
|
|
||||||
let name = name.unwrap_or_else(|| "photo".to_string());
|
|
||||||
hand_over(&base, Path::new(&source), &uid, &name)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
match copied {
|
|
||||||
Ok(Ok(path)) => files.push(path),
|
|
||||||
Ok(Err(e)) => last_error = Some(e.to_string()),
|
|
||||||
Err(_) => last_error = Some("the copy was interrupted".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if files.is_empty() {
|
|
||||||
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
|
|
||||||
tracing::warn!("dragging photos out failed: {message}");
|
|
||||||
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
|
|
||||||
}
|
|
||||||
Ok(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
|
|
||||||
/// copies it from.
|
|
||||||
///
|
|
||||||
/// A hard link when the cache is on the same file system, so a large video is
|
|
||||||
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
|
|
||||||
/// symlink as a link, which would leave the user a link into the cache.
|
|
||||||
pub(crate) fn hand_over(
|
|
||||||
base: &Path,
|
|
||||||
source: &Path,
|
|
||||||
uid: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> std::io::Result<PathBuf> {
|
|
||||||
let file_name = Path::new(name)
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
|
|
||||||
let dir = base.join(uid.replace('/', "_"));
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
|
||||||
let dest = dir.join(file_name);
|
|
||||||
let _ = std::fs::remove_file(&dest);
|
|
||||||
if std::fs::hard_link(source, &dest).is_err() {
|
|
||||||
std::fs::copy(source, &dest)?;
|
|
||||||
}
|
|
||||||
Ok(dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
|
|
||||||
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
|
|
||||||
let Ok(entries) = std::fs::read_dir(base) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let old = entry
|
|
||||||
.metadata()
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.is_ok_and(|modified| {
|
|
||||||
now.duration_since(modified)
|
|
||||||
.is_ok_and(|age| age > KEEP_DRAGGED)
|
|
||||||
});
|
|
||||||
if old {
|
|
||||||
let _ = std::fs::remove_dir_all(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn scratch(name: &str) -> PathBuf {
|
|
||||||
let dir =
|
|
||||||
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_dragged_photo_is_handed_over_under_its_own_name() {
|
|
||||||
let dir = scratch("name");
|
|
||||||
let source = dir.join("0f3a9c");
|
|
||||||
std::fs::write(&source, b"jpeg bytes").unwrap();
|
|
||||||
let base = dir.join("out");
|
|
||||||
|
|
||||||
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
|
||||||
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
|
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
|
|
||||||
assert!(!path.is_symlink());
|
|
||||||
|
|
||||||
// Dragged again, it replaces the copy rather than failing on it.
|
|
||||||
std::fs::write(&source, b"new bytes").unwrap();
|
|
||||||
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
|
||||||
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_name_cannot_climb_out_of_its_folder() {
|
|
||||||
let dir = scratch("climb");
|
|
||||||
let source = dir.join("blob");
|
|
||||||
std::fs::write(&source, b"x").unwrap();
|
|
||||||
let base = dir.join("out");
|
|
||||||
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
|
|
||||||
assert_eq!(path, base.join("u").join("escape.jpg"));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_old_handovers_are_pruned() {
|
|
||||||
let dir = scratch("prune");
|
|
||||||
std::fs::create_dir_all(dir.join("fresh")).unwrap();
|
|
||||||
prune_dragged(&dir, SystemTime::now());
|
|
||||||
assert!(dir.join("fresh").exists());
|
|
||||||
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
|
|
||||||
assert!(!dir.join("fresh").exists());
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
//! Playing videos and music inside the app.
|
|
||||||
//!
|
|
||||||
//! The pieces both players share live here: the one My files opens a video or
|
|
||||||
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
|
|
||||||
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
|
|
||||||
//! fullscreen, the keys every video player answers to, a repeat switch and a
|
|
||||||
//! volume that carries over from one video to the next.
|
|
||||||
|
|
||||||
use crate::activation::{mounted_path, present_within};
|
|
||||||
use crate::*;
|
|
||||||
|
|
||||||
/// How far `J` and `L` (or Shift+←/→) jump.
|
|
||||||
const SEEK_LONG: i64 = 10;
|
|
||||||
|
|
||||||
/// How far ← and → jump in the My files player, where they don't move to
|
|
||||||
/// another photo.
|
|
||||||
const SEEK_SHORT: i64 = 5;
|
|
||||||
|
|
||||||
/// How much ↑ and ↓ change the volume in the My files player.
|
|
||||||
const VOLUME_STEP: f64 = 0.1;
|
|
||||||
|
|
||||||
/// The edge of a song's cover in the My files player, in logical pixels.
|
|
||||||
const COVER_SIZE: i32 = 320;
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
/// The volume and mute the user last set, applied to the next stream so
|
|
||||||
/// muting one video doesn't leave the next one blaring. Kept for the run
|
|
||||||
/// of the app only.
|
|
||||||
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
|
|
||||||
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
|
|
||||||
/// means the stream doesn't know its length yet, which bounds only the start.
|
|
||||||
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
|
|
||||||
let target = now
|
|
||||||
.saturating_add(delta_secs.saturating_mul(1_000_000))
|
|
||||||
.max(0);
|
|
||||||
if duration > 0 {
|
|
||||||
target.min(duration)
|
|
||||||
} else {
|
|
||||||
target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump the stream `delta_secs` forward, or back when negative.
|
|
||||||
pub(crate) fn seek_by(video: >k4::Video, delta_secs: i64) {
|
|
||||||
if let Some(stream) = video.media_stream()
|
|
||||||
&& stream.is_seekable()
|
|
||||||
{
|
|
||||||
stream.seek(seek_target(
|
|
||||||
stream.timestamp(),
|
|
||||||
stream.duration(),
|
|
||||||
delta_secs,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play a paused stream, pause a playing one.
|
|
||||||
pub(crate) fn toggle_playing(video: >k4::Video) {
|
|
||||||
if let Some(stream) = video.media_stream() {
|
|
||||||
stream.set_playing(!stream.is_playing());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_muted(video: >k4::Video) {
|
|
||||||
if let Some(stream) = video.media_stream() {
|
|
||||||
stream.set_muted(!stream.is_muted());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn change_volume(video: >k4::Video, delta: f64) {
|
|
||||||
if let Some(stream) = video.media_stream() {
|
|
||||||
stream.set_muted(false);
|
|
||||||
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter fullscreen, or leave it.
|
|
||||||
pub(crate) fn toggle_fullscreen(window: >k4::Window) {
|
|
||||||
if window.is_fullscreen() {
|
|
||||||
window.unfullscreen();
|
|
||||||
} else {
|
|
||||||
window.fullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
|
|
||||||
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
|
|
||||||
/// was one of them.
|
|
||||||
pub(crate) fn player_key(video: >k4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
|
|
||||||
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
|
||||||
let plain = !state
|
|
||||||
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
|
|
||||||
if !plain {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match key {
|
|
||||||
"k" | "K" => toggle_playing(video),
|
|
||||||
"j" | "J" => seek_by(video, -SEEK_LONG),
|
|
||||||
"l" | "L" => seek_by(video, SEEK_LONG),
|
|
||||||
"Left" if shift => seek_by(video, -SEEK_LONG),
|
|
||||||
"Right" if shift => seek_by(video, SEEK_LONG),
|
|
||||||
"m" | "M" => toggle_muted(video),
|
|
||||||
_ => return false,
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make `video` carry the volume over between streams and toggle `window`'s
|
|
||||||
/// fullscreen on a double-click.
|
|
||||||
pub(crate) fn wire_player(video: >k4::Video, window: >k4::Window) {
|
|
||||||
video.connect_media_stream_notify(|video| {
|
|
||||||
let Some(stream) = video.media_stream() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some((volume, muted)) = LEVEL.get() {
|
|
||||||
stream.set_volume(volume);
|
|
||||||
stream.set_muted(muted);
|
|
||||||
}
|
|
||||||
let remember = |stream: >k4::MediaStream| {
|
|
||||||
LEVEL.set(Some((stream.volume(), stream.is_muted())));
|
|
||||||
};
|
|
||||||
stream.connect_volume_notify(remember);
|
|
||||||
stream.connect_muted_notify(remember);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The controls' own buttons and seek bar claim their clicks, so this only
|
|
||||||
// sees a double-click on the picture.
|
|
||||||
let click = gtk4::GestureClick::new();
|
|
||||||
let window = window.downgrade();
|
|
||||||
click.connect_pressed(move |_, n_press, _, _| {
|
|
||||||
if n_press == 2
|
|
||||||
&& let Some(window) = window.upgrade()
|
|
||||||
{
|
|
||||||
toggle_fullscreen(&window);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
video.add_controller(click);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A button that enters and leaves `window`'s fullscreen, showing which one
|
|
||||||
/// it will do.
|
|
||||||
pub(crate) fn fullscreen_button(window: >k4::Window) -> gtk4::Button {
|
|
||||||
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
|
||||||
let paint = |button: >k4::Button, fullscreen: bool| {
|
|
||||||
if fullscreen {
|
|
||||||
button.set_icon_name("view-restore-symbolic");
|
|
||||||
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
|
|
||||||
} else {
|
|
||||||
button.set_icon_name("view-fullscreen-symbolic");
|
|
||||||
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
paint(&button, window.is_fullscreen());
|
|
||||||
let weak = button.downgrade();
|
|
||||||
window.connect_fullscreened_notify(move |window| {
|
|
||||||
if let Some(button) = weak.upgrade() {
|
|
||||||
paint(&button, window.is_fullscreen());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let target = window.downgrade();
|
|
||||||
button.connect_clicked(move |_| {
|
|
||||||
if let Some(window) = target.upgrade() {
|
|
||||||
toggle_fullscreen(&window);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
button
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play a video or a song from My files in a window of its own, streamed from
|
|
||||||
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
|
|
||||||
/// long video starts in seconds instead of after a full download. A file the
|
|
||||||
/// mount doesn't show, or the service is down, opens in the default
|
|
||||||
/// application instead, after a download.
|
|
||||||
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
|
|
||||||
let rel = entry_rel(ui, entry);
|
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
|
||||||
let (ui, entry) = (ui.clone(), entry.clone());
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
match present_within(mounted_path(&mountpoint, &rel)).await {
|
|
||||||
Some(path) => build_media_player(&ui, &entry, &path),
|
|
||||||
None => download_and_open(&ui, &entry),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
|
|
||||||
let window = adw::Window::builder()
|
|
||||||
.title(&entry.name)
|
|
||||||
.default_width(960)
|
|
||||||
.default_height(600)
|
|
||||||
.build();
|
|
||||||
if let Some(parent) = ui_window(ui) {
|
|
||||||
window.set_transient_for(Some(&parent));
|
|
||||||
}
|
|
||||||
window.add_css_class("media-player-window");
|
|
||||||
let gtk_window: gtk4::Window = window.clone().upcast();
|
|
||||||
|
|
||||||
let video = gtk4::Video::builder()
|
|
||||||
.autoplay(true)
|
|
||||||
.hexpand(true)
|
|
||||||
.vexpand(true)
|
|
||||||
.build();
|
|
||||||
wire_player(&video, >k_window);
|
|
||||||
|
|
||||||
// A song has no picture, which would leave a black window above the
|
|
||||||
// controls: its cover stands in for it, the one its listing tile shows,
|
|
||||||
// and a note while there is none.
|
|
||||||
let note = file_thumbnail_widget(COVER_SIZE, 128);
|
|
||||||
note.set_can_target(false);
|
|
||||||
note.set_visible(false);
|
|
||||||
note.add_css_class("media-player-note");
|
|
||||||
if let Some(cover) = note
|
|
||||||
.child()
|
|
||||||
.and_then(|fallback| fallback.next_sibling())
|
|
||||||
.and_downcast::<gtk4::Picture>()
|
|
||||||
{
|
|
||||||
// The whole cover, not a square cut out of it.
|
|
||||||
cover.set_content_fit(gtk4::ContentFit::Contain);
|
|
||||||
}
|
|
||||||
bind_file_thumbnail(ui, ¬e, entry, true);
|
|
||||||
let overlay = gtk4::Overlay::new();
|
|
||||||
overlay.set_child(Some(&video));
|
|
||||||
overlay.add_overlay(¬e);
|
|
||||||
|
|
||||||
let failed = adw::StatusPage::builder()
|
|
||||||
.icon_name("dialog-warning-symbolic")
|
|
||||||
.title(gettext("Couldn't play this file"))
|
|
||||||
.build();
|
|
||||||
let open_with = gtk4::Button::builder()
|
|
||||||
.label(gettext("Open With…"))
|
|
||||||
.halign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
open_with.add_css_class("pill");
|
|
||||||
open_with.add_css_class("suggested-action");
|
|
||||||
failed.set_child(Some(&open_with));
|
|
||||||
|
|
||||||
let stack = gtk4::Stack::new();
|
|
||||||
stack.add_named(&overlay, Some("player"));
|
|
||||||
stack.add_named(&failed, Some("failed"));
|
|
||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
|
||||||
header.pack_end(&fullscreen_button(>k_window));
|
|
||||||
let mut menu = ActionMenu::new();
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
menu.item(&gettext("Open With…"), move || {
|
|
||||||
open_entry_with(&ui_c, &entry_c)
|
|
||||||
});
|
|
||||||
let video_c = video.clone();
|
|
||||||
// Translators: menu item of the video player that plays the file again
|
|
||||||
// and again.
|
|
||||||
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
|
|
||||||
header.pack_end(&menu.button());
|
|
||||||
|
|
||||||
let toolbar = adw::ToolbarView::new();
|
|
||||||
toolbar.add_top_bar(&header);
|
|
||||||
toolbar.set_content(Some(&stack));
|
|
||||||
// Fullscreen is the video alone, its own controls still at hand.
|
|
||||||
window
|
|
||||||
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
|
|
||||||
.invert_boolean()
|
|
||||||
.sync_create()
|
|
||||||
.build();
|
|
||||||
window.set_content(Some(&toolbar));
|
|
||||||
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
|
||||||
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
|
|
||||||
|
|
||||||
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
|
|
||||||
video.connect_media_stream_notify(move |video| {
|
|
||||||
let Some(stream) = video.media_stream() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (stack, failed) = (stack_c.clone(), failed_c.clone());
|
|
||||||
stream.connect_error_notify(move |stream| {
|
|
||||||
if let Some(error) = stream.error() {
|
|
||||||
tracing::warn!("in-app player failed: {error}");
|
|
||||||
failed.set_description(Some(&error.to_string()));
|
|
||||||
stack.set_visible_child_name("failed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let note = note_c.clone();
|
|
||||||
stream.connect_prepared_notify(move |stream| {
|
|
||||||
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
video.set_filename(Some(path));
|
|
||||||
|
|
||||||
let keys = gtk4::EventControllerKey::new();
|
|
||||||
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
|
|
||||||
keys.connect_key_pressed(move |_, key, _code, state| {
|
|
||||||
let Some(window) = window_k.upgrade() else {
|
|
||||||
return glib::Propagation::Proceed;
|
|
||||||
};
|
|
||||||
let Some(name) = key.name() else {
|
|
||||||
return glib::Propagation::Proceed;
|
|
||||||
};
|
|
||||||
// The open menu keeps its arrows and Enter.
|
|
||||||
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
|
|
||||||
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
|
|
||||||
if in_menu {
|
|
||||||
return glib::Propagation::Proceed;
|
|
||||||
}
|
|
||||||
if player_key(&video_k, &name, state) {
|
|
||||||
return glib::Propagation::Stop;
|
|
||||||
}
|
|
||||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
|
||||||
match name.as_str() {
|
|
||||||
"space" => toggle_playing(&video_k),
|
|
||||||
"Left" => seek_by(&video_k, -SEEK_SHORT),
|
|
||||||
"Right" => seek_by(&video_k, SEEK_SHORT),
|
|
||||||
"Up" => change_volume(&video_k, VOLUME_STEP),
|
|
||||||
"Down" => change_volume(&video_k, -VOLUME_STEP),
|
|
||||||
"f" | "F11" => toggle_fullscreen(&window),
|
|
||||||
// Escape leaves fullscreen before it closes anything.
|
|
||||||
"Escape" if window.is_fullscreen() => window.unfullscreen(),
|
|
||||||
"Escape" | "q" => window.close(),
|
|
||||||
"w" if ctrl => window.close(),
|
|
||||||
_ => return glib::Propagation::Proceed,
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
|
||||||
});
|
|
||||||
// Capture, so the arrows and Space reach the player rather than moving the
|
|
||||||
// focus between its controls.
|
|
||||||
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
||||||
window.add_controller(keys);
|
|
||||||
|
|
||||||
// Stop the sound with the window, not when the window is finally freed.
|
|
||||||
let video_c = video.clone();
|
|
||||||
window.connect_close_request(move |_| {
|
|
||||||
video_c.set_media_stream(gtk4::MediaStream::NONE);
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
});
|
|
||||||
window.present();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const SEC: i64 = 1_000_000;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_jump_stays_inside_the_video() {
|
|
||||||
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
|
|
||||||
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
|
|
||||||
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
|
|
||||||
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
|
|
||||||
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -334,13 +334,6 @@ pub(crate) struct ShareDialog {
|
|||||||
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
||||||
/// access and their roles, and create/copy/remove a public link.
|
/// access and their roles, and create/copy/remove a public link.
|
||||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
open_share_dialog_over(ui, entry, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
|
|
||||||
/// viewer is a window of its own, and a dialog on the main window would open
|
|
||||||
/// behind it.
|
|
||||||
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
|
|
||||||
if !*ui.mounted.borrow() {
|
if !*ui.mounted.borrow() {
|
||||||
toast_error(
|
toast_error(
|
||||||
ui,
|
ui,
|
||||||
@@ -498,7 +491,7 @@ pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Opti
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
|
dialog.present(ui_window(ui).as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_fil
|
|||||||
const THUMB_BATCH: usize = 32;
|
const THUMB_BATCH: usize = 32;
|
||||||
const THUMB_CACHE_MAX: usize = 256;
|
const THUMB_CACHE_MAX: usize = 256;
|
||||||
const THUMB_MISSING_MAX: usize = 2048;
|
const THUMB_MISSING_MAX: usize = 2048;
|
||||||
const THUMB_BLANK_MAX: usize = 1024;
|
|
||||||
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
||||||
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -21,9 +20,6 @@ pub(crate) struct FileThumbnailState {
|
|||||||
missing: RefCell<HashSet<String>>,
|
missing: RefCell<HashSet<String>>,
|
||||||
missing_order: RefCell<VecDeque<String>>,
|
missing_order: RefCell<VecDeque<String>>,
|
||||||
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||||
/// Tiles on screen still showing their icon, by key, kept across a
|
|
||||||
/// cancelled request so [`refresh_file_thumbnails`] can ask for them again.
|
|
||||||
blank: RefCell<HashMap<String, BlankTile>>,
|
|
||||||
queue: RefCell<VecDeque<FileThumbRequest>>,
|
queue: RefCell<VecDeque<FileThumbRequest>>,
|
||||||
inflight: Cell<bool>,
|
inflight: Cell<bool>,
|
||||||
source: RefCell<Option<glib::SourceId>>,
|
source: RefCell<Option<glib::SourceId>>,
|
||||||
@@ -31,18 +27,6 @@ pub(crate) struct FileThumbnailState {
|
|||||||
generation_loading: Cell<bool>,
|
generation_loading: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a tile left blank needs to be asked for again.
|
|
||||||
struct BlankTile {
|
|
||||||
request: FileThumbRequest,
|
|
||||||
pictures: Vec<glib::WeakRef<gtk4::Picture>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a picture registered as blank under `key` still is: bound to that
|
|
||||||
/// key, not painted, and in a window rather than in a list's spare pool.
|
|
||||||
fn tile_still_blank(key: &str, bound: &str, painted: bool, attached: bool) -> bool {
|
|
||||||
bound == key && !painted && attached
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum ReplyDisposition {
|
enum ReplyDisposition {
|
||||||
Ignore,
|
Ignore,
|
||||||
@@ -71,7 +55,6 @@ impl FileThumbnailState {
|
|||||||
missing: RefCell::new(HashSet::new()),
|
missing: RefCell::new(HashSet::new()),
|
||||||
missing_order: RefCell::new(VecDeque::new()),
|
missing_order: RefCell::new(VecDeque::new()),
|
||||||
wanted: RefCell::new(HashMap::new()),
|
wanted: RefCell::new(HashMap::new()),
|
||||||
blank: RefCell::new(HashMap::new()),
|
|
||||||
queue: RefCell::new(VecDeque::new()),
|
queue: RefCell::new(VecDeque::new()),
|
||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
source: RefCell::new(None),
|
source: RefCell::new(None),
|
||||||
@@ -105,85 +88,6 @@ impl FileThumbnailState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remember_blank(&self, key: &str, request: &FileThumbRequest, picture: >k4::Picture) {
|
|
||||||
let mut blank = self.blank.borrow_mut();
|
|
||||||
if blank.len() >= THUMB_BLANK_MAX {
|
|
||||||
prune_blank(&mut blank);
|
|
||||||
}
|
|
||||||
let tile = blank.entry(key.to_string()).or_insert_with(|| BlankTile {
|
|
||||||
request: request.clone(),
|
|
||||||
pictures: Vec::new(),
|
|
||||||
});
|
|
||||||
if !tile
|
|
||||||
.pictures
|
|
||||||
.iter()
|
|
||||||
.any(|weak| weak.upgrade().as_ref() == Some(picture))
|
|
||||||
{
|
|
||||||
tile.pictures.push(picture.downgrade());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop the tiles that were painted, rebound to another file, or freed.
|
|
||||||
fn prune_blank(blank: &mut HashMap<String, BlankTile>) {
|
|
||||||
blank.retain(|key, tile| {
|
|
||||||
tile.pictures.retain(|weak| {
|
|
||||||
weak.upgrade().is_some_and(|picture| {
|
|
||||||
tile_still_blank(
|
|
||||||
key,
|
|
||||||
&picture.widget_name(),
|
|
||||||
picture.is_visible(),
|
|
||||||
picture.root().is_some(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
!tile.pictures.is_empty()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask again for every tile on screen still showing its icon. Sent when the
|
|
||||||
/// daemon says thumbnails landed, so a folder fills in while a build runs in
|
|
||||||
/// it, or after its own requests were cancelled, without being opened again.
|
|
||||||
pub(crate) fn refresh_file_thumbnails(ui: &Rc<Ui>) {
|
|
||||||
let tiles: Vec<(String, FileThumbRequest, Vec<gtk4::Picture>)> = {
|
|
||||||
let mut blank = ui.file_thumbs.blank.borrow_mut();
|
|
||||||
prune_blank(&mut blank);
|
|
||||||
blank
|
|
||||||
.iter()
|
|
||||||
.map(|(key, tile)| {
|
|
||||||
let pictures = tile.pictures.iter().filter_map(|w| w.upgrade()).collect();
|
|
||||||
(key.clone(), tile.request.clone(), pictures)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
if tiles.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (key, request, pictures) in tiles {
|
|
||||||
let cached = ui.file_thumbs.textures.borrow().get(&key).cloned();
|
|
||||||
if let Some(texture) = cached {
|
|
||||||
for picture in &pictures {
|
|
||||||
paint(picture, &key, &texture);
|
|
||||||
}
|
|
||||||
ui.file_thumbs.blank.borrow_mut().remove(&key);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A miss from before the thumbnail landed is no verdict any more.
|
|
||||||
ui.file_thumbs.missing.borrow_mut().remove(&key);
|
|
||||||
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
|
|
||||||
let waiters = wanted.entry(key).or_default();
|
|
||||||
for picture in pictures {
|
|
||||||
if !waiters.contains(&picture) {
|
|
||||||
waiters.push(picture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(wanted);
|
|
||||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
|
||||||
queue.retain(|item| item.uid != request.uid);
|
|
||||||
queue.push_back(request);
|
|
||||||
}
|
|
||||||
schedule_file_thumbs(ui);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
||||||
@@ -326,12 +230,6 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
paint(&picture, &key, texture);
|
paint(&picture, &key, texture);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let request = FileThumbRequest {
|
|
||||||
uid: entry.uid.clone(),
|
|
||||||
modified: entry.modified,
|
|
||||||
name: entry.name.clone(),
|
|
||||||
};
|
|
||||||
ui.file_thumbs.remember_blank(&key, &request, &picture);
|
|
||||||
if ui.file_thumbs.missing.borrow().contains(&key) {
|
if ui.file_thumbs.missing.borrow().contains(&key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,7 +242,11 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
.push(picture);
|
.push(picture);
|
||||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
queue.retain(|item| item.uid != entry.uid);
|
queue.retain(|item| item.uid != entry.uid);
|
||||||
queue.push_back(request);
|
queue.push_back(FileThumbRequest {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
modified: entry.modified,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
});
|
||||||
drop(queue);
|
drop(queue);
|
||||||
schedule_file_thumbs(ui);
|
schedule_file_thumbs(ui);
|
||||||
}
|
}
|
||||||
@@ -485,7 +387,6 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
|
|||||||
match gtk4::gdk::Texture::from_filename(&path) {
|
match gtk4::gdk::Texture::from_filename(&path) {
|
||||||
Ok(texture) => {
|
Ok(texture) => {
|
||||||
ui.file_thumbs.store_texture(&key, texture.clone());
|
ui.file_thumbs.store_texture(&key, texture.clone());
|
||||||
ui.file_thumbs.blank.borrow_mut().remove(&key);
|
|
||||||
if let Some(pictures) =
|
if let Some(pictures) =
|
||||||
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
||||||
{
|
{
|
||||||
@@ -546,16 +447,7 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ReplyDisposition, reply_disposition, tile_still_blank};
|
use super::{ReplyDisposition, reply_disposition};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_a_tile_still_showing_its_icon_is_asked_for_again() {
|
|
||||||
assert!(tile_still_blank("a:1", "a:1", false, true));
|
|
||||||
assert!(!tile_still_blank("a:1", "a:1", true, true), "painted");
|
|
||||||
assert!(!tile_still_blank("a:1", "b:1", false, true), "rebound");
|
|
||||||
assert!(!tile_still_blank("a:1", "a:2", false, true), "file changed");
|
|
||||||
assert!(!tile_still_blank("a:1", "a:1", false, false), "spare row");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_image_name(name: &str) -> bool {
|
fn is_image_name(name: &str) -> bool {
|
||||||
pdfs_core::control::is_thumbnail_image_name(name)
|
pdfs_core::control::is_thumbnail_image_name(name)
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const STOCK: &[&str] = &[
|
|||||||
// GTK's built-in icons
|
// GTK's built-in icons
|
||||||
"info-outline-symbolic",
|
"info-outline-symbolic",
|
||||||
// Adwaita
|
// Adwaita
|
||||||
"audio-x-generic-symbolic",
|
|
||||||
"checkbox-checked-symbolic",
|
"checkbox-checked-symbolic",
|
||||||
"checkbox-symbolic",
|
"checkbox-symbolic",
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
@@ -69,14 +68,12 @@ const STOCK: &[&str] = &[
|
|||||||
"text-x-generic-symbolic",
|
"text-x-generic-symbolic",
|
||||||
"user-trash-symbolic",
|
"user-trash-symbolic",
|
||||||
"video-x-generic-symbolic",
|
"video-x-generic-symbolic",
|
||||||
"view-fullscreen-symbolic",
|
|
||||||
"view-grid-symbolic",
|
"view-grid-symbolic",
|
||||||
"view-list-symbolic",
|
"view-list-symbolic",
|
||||||
"view-more-symbolic",
|
"view-more-symbolic",
|
||||||
"view-paged-symbolic",
|
"view-paged-symbolic",
|
||||||
"view-pin-symbolic",
|
"view-pin-symbolic",
|
||||||
"view-refresh-symbolic",
|
"view-refresh-symbolic",
|
||||||
"view-restore-symbolic",
|
|
||||||
"window-close-symbolic",
|
"window-close-symbolic",
|
||||||
"zoom-fit-best-symbolic",
|
"zoom-fit-best-symbolic",
|
||||||
"zoom-in-symbolic",
|
"zoom-in-symbolic",
|
||||||
|
|||||||
@@ -467,7 +467,6 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
|
|||||||
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
|
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
|
||||||
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
||||||
| `pdfs-online-probe` | Probes Drive while offline |
|
| `pdfs-online-probe` | Probes Drive while offline |
|
||||||
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
|
|
||||||
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
|
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
|
||||||
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
|
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
|
||||||
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
|
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
|
||||||
|
|||||||
-262
@@ -109,15 +109,7 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [B289](#b289--the-takeout-import-mixes-in-other-services-and-drops-location-and-live-photos) | The Takeout import mixes in other services and drops location and live photos |
|
|
||||||
| [B288](#b288--an-interrupted-takeout-import-reads-as-finished-and-one-error-ends-it) | An interrupted Takeout import reads as finished, and one error ends it |
|
|
||||||
| [B287](#b287--a-photo-draft-left-by-an-interrupted-upload-blocks-that-name) | A photo draft left by an interrupted upload blocks that name |
|
|
||||||
| [B286](#b286--resuming-a-takeout-import-fails-on-a-draft-with-no-content-hash) | Resuming a Takeout import fails on a draft with no content hash |
|
|
||||||
| [B285](#b285--a-restarted-secret-service-leaves-the-process-on-a-dead-session) | A restarted Secret Service leaves the process on a dead session |
|
|
||||||
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
|
|
||||||
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
||||||
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
|
||||||
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
|
||||||
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
|
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
|
||||||
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
|
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
|
||||||
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
|
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
|
||||||
@@ -392,190 +384,6 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
### B289 — The Takeout import mixes in other services and drops location and live photos
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-15, auditing the Google Photos import alongside
|
|
||||||
[issue #94](https://github.com/narrrl/proton-drive-linux/issues/94).
|
|
||||||
**Where:** `crates/pdfs-core/src/takeout.rs`; `plan` and `upload_takeout_photo` in
|
|
||||||
`crates/pdfs-fuse/src/takeout.rs`
|
|
||||||
|
|
||||||
**Repro:** Import a Takeout export that also holds `Drive/` or `YouTube/`, with Pixel motion
|
|
||||||
photos, iPhone live photos and photos that have a place in Google Photos.
|
|
||||||
|
|
||||||
**Cause.** Five gaps in what the import carried over:
|
|
||||||
- The service folder was dropped without being checked, so media from other Google services in
|
|
||||||
the same export landed in the photo timeline.
|
|
||||||
- `geoData` was never read, although `docs/PHOTOS.md` said the import takes locations from the
|
|
||||||
export, and the SDK had no way to upload a location.
|
|
||||||
- The two halves of a live or motion photo uploaded as unrelated photos, the `.MOV` half usually
|
|
||||||
without a date, and the `.MP` half of a Pixel motion photo was skipped as unknown.
|
|
||||||
- `creationTime`, the day the photo reached Google, outranked the date in the file name and the
|
|
||||||
photo's own EXIF.
|
|
||||||
- A sidecar with `null` in `favorited`, a numeric `timestamp` or a `0` timestamp lost its other
|
|
||||||
fields or filed the photo under 1970.
|
|
||||||
|
|
||||||
**Fix:** The Photos service is the folder whose sidecars carry `photoTakenTime`, and only it is
|
|
||||||
imported (everything, when no folder does). `geoData`, else `geoDataExif`, becomes the photo's
|
|
||||||
`Location` attribute through SDK 0.12.0's `PhotoUploadMetadata::location`; `0, 0` is no position.
|
|
||||||
Stills and videos with the same stem in one folder (`IMG_1.HEIC` + `IMG_1.MOV`,
|
|
||||||
`PXL_1.MP.jpg` + `PXL_1.MP`) are paired unless their dates are minutes apart: the still is
|
|
||||||
tagged as a live or motion photo, the video is uploaded after it with `main_photo_uid` and takes
|
|
||||||
its date and place. The capture time is `photoTakenTime`, then the name, then EXIF, then
|
|
||||||
`creationTime`. Favorites are also set on photos that were already on the account.
|
|
||||||
|
|
||||||
**Verified:** `takeout::tests` in `pdfs-core` (services, locations, timestamps, live keys) and
|
|
||||||
`pdfs-fuse` (pairing, skew, folded copies). Not run against a real export and account.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B288 — An interrupted Takeout import reads as finished, and one error ends it
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-15, auditing the Google Photos import alongside
|
|
||||||
[issue #94](https://github.com/narrrl/proton-drive-linux/issues/94).
|
|
||||||
**Where:** `crates/pdfs-fuse/src/takeout.rs`; `refresh_takeout` in
|
|
||||||
`crates/pdfs-gui/src/app/pages/takeout.rs`; `cmd_import_google_photos` in
|
|
||||||
`crates/pdfs-cli/src/main.rs`
|
|
||||||
|
|
||||||
**Repro:** Start an import of several archives, one of them damaged, and restart the service
|
|
||||||
while it uploads.
|
|
||||||
|
|
||||||
**Cause.**
|
|
||||||
- The import state lived only in memory. After a restart `ImportStatus` had no summary, and the
|
|
||||||
app announced "Import finished". The archives had to be chosen again to continue.
|
|
||||||
- A daemon shutdown broke the upload loop but still filed albums and logged a success.
|
|
||||||
- One unreadable archive failed the scan, and a failed request in the middle of the run
|
|
||||||
discarded every count and skipped the albums for what had uploaded.
|
|
||||||
- The progress total counted photos already on the account, so the bar stopped short of the
|
|
||||||
end; a dry run reported "Would upload: 0"; the job's phases reached the app in English.
|
|
||||||
|
|
||||||
**Fix:** The last run's archives and summary are kept in `takeout-import.json` in the state
|
|
||||||
directory. A run still marked running at start-up is `interrupted`, and `ImportStatus` lists
|
|
||||||
its archives as `resumable`; `ResumeImport` (`pdfs import-google-photos --resume`, **Resume** in
|
|
||||||
the app) starts it again, and the duplicate check skips what is there. An unreadable archive is
|
|
||||||
listed in `unreadable_archives` and the rest imports. A failure keeps the counts and still files
|
|
||||||
albums. `ImportStatus` carries the phase and its own progress, the summary says `dry_run`,
|
|
||||||
`to_upload` and `error_kind`, and the app words them in the user's language. The inline
|
|
||||||
timeline refresh of an empty gallery waits for a refresh already running instead of starting a
|
|
||||||
second one (`refresh_timeline_joined`).
|
|
||||||
|
|
||||||
**Verified:** `takeout::tests` in `pdfs-fuse` (saved state, resumable runs) and `pdfs-core`
|
|
||||||
(unreadable archive), and the CLI tests for `--resume` and the exit status. Not run live.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B287 — A photo draft left by an interrupted upload blocks that name
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-15, reading the SDK upload path for
|
|
||||||
[issue #94](https://github.com/narrrl/proton-drive-linux/issues/94).
|
|
||||||
**Where:** `upload_photo_from`, `upload_file_from_inner` and `abandon_file_draft` in
|
|
||||||
`proton-drive-rs` (SDK 0.12.0)
|
|
||||||
|
|
||||||
**Repro:** Interrupt a photo upload after its draft is created, sign in again, and upload a
|
|
||||||
photo with the same name.
|
|
||||||
|
|
||||||
**Cause.** Photo uploads replaced a leftover draft only when its `ClientUID` was the current
|
|
||||||
session's. A new login is a new session, so the next upload of that name failed with 2500. A
|
|
||||||
failed upload also never deleted its own draft, which the C# and TypeScript SDKs do.
|
|
||||||
|
|
||||||
**Fix:** SDK 0.12.0 adds `PhotoUploadMetadata::replace_existing_draft`, which the Takeout import
|
|
||||||
sets, and deletes the draft of a new file or photo whose upload fails. After a failed seal it
|
|
||||||
only does so on a definite rejection, because a seal lost in transit may have been applied.
|
|
||||||
|
|
||||||
**Verified:** SDK unit test `draft_cleanup_only_after_a_definite_seal_rejection`. Not run live.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B286 — Resuming a Takeout import fails on a draft with no content hash
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-15, [issue #94](https://github.com/narrrl/proton-drive-linux/issues/94)
|
|
||||||
(3.7.0, Fedora). An import stopped by a restart failed every re-run at once with
|
|
||||||
`check for existing photos: serialization error: invalid type: null, expected a string at line
|
|
||||||
1 column 2063`.
|
|
||||||
**Where:** `FoundPhotoDuplicateDto` in `proton-drive-rs` (SDK 0.11.0)
|
|
||||||
|
|
||||||
**Repro:** Interrupt a photo upload after its draft is created, then run the import again.
|
|
||||||
|
|
||||||
**Cause.** The duplicates endpoint sends `ContentHash` (and can send `Hash`) as `null` for a
|
|
||||||
draft, as its API schema says. The SDK declared both as `String` with `#[serde(default)]`,
|
|
||||||
which covers a missing field but not `null`, so one draft failed the whole batch of 150 names
|
|
||||||
and the import ended before uploading anything.
|
|
||||||
|
|
||||||
**Fix:** SDK 0.12.0 makes both fields optional and skips drafts, trashed links and candidates
|
|
||||||
without hashes, like the TypeScript SDK. Every other defaulted DTO field reads `null` as its
|
|
||||||
default too, and an undecodable response is logged with its path.
|
|
||||||
|
|
||||||
**Verified:** SDK unit tests `duplicate_response_tolerates_null_fields_and_skips_drafts` and
|
|
||||||
`duplicate_response_tolerates_a_null_list`. Not run against the reporter's account.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B285 — A restarted Secret Service leaves the process on a dead session
|
|
||||||
|
|
||||||
**Status:** Fixed (unverified)
|
|
||||||
**Found:** 2026-11-14, following up [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
|
||||||
(signed out after every cold boot on Fedora 45, which replaced gnome-keyring with oo7). See
|
|
||||||
[the investigation](ISSUE-78.md#a-restarted-secret-service).
|
|
||||||
**Where:** `crates/pdfs-core/src/auth.rs`, `with_keyring_entry`
|
|
||||||
|
|
||||||
**Repro:** Write the session blob, restart the Secret Service provider
|
|
||||||
(`gnome-keyring-daemon --replace`, or `oo7-daemon --replace`), then read or write it again from
|
|
||||||
the same process. `dbus-secret-service-keyring-store` keeps one D-Bus connection and one Secret
|
|
||||||
Service session for the life of the process, and pdfs installed it once. The session exists only
|
|
||||||
in the provider's memory, so the new provider answers every `GetSecret` and `SetSecret` with
|
|
||||||
`org.freedesktop.Secret.Error.NoSession`, for as long as the process runs. A fresh store in the
|
|
||||||
same process reads the same entry fine. oo7's systemd unit restarts the daemon on failure, and
|
|
||||||
both providers can be replaced, so the daemon could meet this mid-session. A token refresh after
|
|
||||||
that point reached only memory and the kernel keyring: B61's retry kept asking on the dead
|
|
||||||
session, and the next cold boot read the spent refresh token (`InvalidRefreshToken`).
|
|
||||||
|
|
||||||
**Fix:** Every keyring call goes through `with_keyring_entry`. A platform failure (which is what
|
|
||||||
`NoSession` and other D-Bus errors become) logs "the Secret Service failed this process's
|
|
||||||
connection; reconnecting", drops the store unless another thread already replaced it, and makes
|
|
||||||
the call once more on a new connection and session. A locked keyring, a dismissed prompt and a
|
|
||||||
missing entry come back unchanged. Affects the daemon, the app and the tray alike.
|
|
||||||
|
|
||||||
**Verified:** `auth::tests::a_dropped_secret_service_session_is_reconnected_and_retried`,
|
|
||||||
`a_rotation_lands_at_once_after_the_keyring_restarts` and `a_locked_keyring_is_not_reconnected`
|
|
||||||
on the in-memory store. The ignored `auth::tests::real_secret_service_survives_a_provider_restart`
|
|
||||||
passes against gnome-keyring 51.1 and oo7 0.7.1 (built from its tag), each in a throwaway session
|
|
||||||
bus and restarted with `--replace`; it checks that a reconnect actually happened. A standalone
|
|
||||||
program holding one store, as before the fix, gets `NoSession` on read and write after the same
|
|
||||||
restart under both providers. Whether the reporter's oo7 restarts during a session is not known.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B284 — A keyring entry reported missing discards refreshed session tokens
|
|
||||||
|
|
||||||
**Status:** Fixed (unverified)
|
|
||||||
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
|
||||||
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
|
|
||||||
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
|
|
||||||
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
|
|
||||||
|
|
||||||
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
|
|
||||||
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
|
|
||||||
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
|
|
||||||
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
|
|
||||||
The running session can still work, but restarting it exposes the stale saved credentials.
|
|
||||||
|
|
||||||
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
|
|
||||||
Persistence still requires the matching existing blob; a retry never recreates a deleted
|
|
||||||
session or overwrites a new login. This does not make a locked keyring writable, and pending
|
|
||||||
tokens still need to reach it before power-off to survive a cold boot.
|
|
||||||
|
|
||||||
**Verified:** The two regression tests
|
|
||||||
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
|
|
||||||
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
|
|
||||||
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
|
|
||||||
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
|
|
||||||
has not been reproduced; issue #78's precise trigger remains unconfirmed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
@@ -619,76 +427,6 @@ videos of over 70 MiB and takes nothing from a video with an uploader's thumbnai
|
|||||||
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
|
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
|
||||||
index at the end, an MPEG-TS and an MKV.
|
index at the end, an MPEG-TS and an MKV.
|
||||||
|
|
||||||
### B282 — A file kept offline is downloaded again after a change made elsewhere
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
|
|
||||||
pin had downloaded it once.
|
|
||||||
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
|
|
||||||
`crates/pdfs-fuse/src/keeper.rs`
|
|
||||||
|
|
||||||
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
|
|
||||||
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
|
|
||||||
fails without a network. Rename or move it elsewhere and the same happens, though its content
|
|
||||||
did not change. Add a file to a pinned folder from another device and it is never downloaded.
|
|
||||||
A folder pin whose downloads failed while the link was down never retries them.
|
|
||||||
|
|
||||||
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
|
|
||||||
pins, and only the pin request itself downloaded anything. The copy was also stored under the
|
|
||||||
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
|
|
||||||
(B12), and a copy stored under it never matches the plaintext it holds.
|
|
||||||
|
|
||||||
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
|
|
||||||
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
|
|
||||||
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
|
|
||||||
downloads files that are missing or stale:
|
|
||||||
|
|
||||||
- the pins a batch of remote events changed, after the batch's listings are dropped;
|
|
||||||
- a pinned folder that gets a new file;
|
|
||||||
- every pin at startup, when the link comes back, and after the event feed loses continuity;
|
|
||||||
- a file whose version is restored.
|
|
||||||
|
|
||||||
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
|
|
||||||
provisional size before it stores the copy.
|
|
||||||
|
|
||||||
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
|
|
||||||
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
|
|
||||||
folder, and adds a file to that folder. The test waits until all three are cached, then reads
|
|
||||||
them with the link down. With the keeper disabled, it fails with all three uncached.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B281 — A file in a synced folder cannot be kept offline
|
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
|
||||||
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
|
|
||||||
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
|
|
||||||
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
|
|
||||||
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
|
|
||||||
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
|
|
||||||
different mtime. The daemon treats every program the same way: it does not look at the process
|
|
||||||
that reads.
|
|
||||||
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
|
|
||||||
|
|
||||||
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
|
|
||||||
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
|
|
||||||
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
|
|
||||||
folder then downloads it.
|
|
||||||
|
|
||||||
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
|
|
||||||
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
|
|
||||||
the mount that serves an absolute path. Pin and unpin were left out.
|
|
||||||
|
|
||||||
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
|
|
||||||
path in a mirror folder gets an error that says its files are already on this computer. The pin
|
|
||||||
registry records the absolute local path for a pin made through an on-demand folder, so
|
|
||||||
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
|
|
||||||
under the primary mount still record the path relative to it.
|
|
||||||
|
|
||||||
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
|
|
||||||
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
|
|
||||||
takes the link down, and reads the file through that folder.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
|
||||||
|
|||||||
+23
-181
@@ -11,40 +11,32 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [3.8.0] — 2026-11-14
|
### Changed
|
||||||
|
|
||||||
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
|
- Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
|
||||||
Existing folders stay device backups. Older builds cannot open the upgraded database.
|
Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
|
||||||
|
times now that they are independent of capture times. See [the SDK review](SDK-0.11.0.md)
|
||||||
|
for features and breaking changes.
|
||||||
|
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
|
||||||
|
Existing folders remain device backups; older builds cannot open the upgraded database.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Sync a local folder with a folder you already have in My files** (#67, #77).
|
- Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
|
||||||
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
|
and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
|
||||||
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
|
paths in My files and the ids and uids the running service knows (synced folders, the queue,
|
||||||
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
|
computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
|
||||||
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
|
a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
|
||||||
uploaded too. Attached folders are always kept on this computer, never online only. They are
|
the request.
|
||||||
listed apart from device backups and are not part of the device profile used to restore another
|
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
|
||||||
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
|
My files folder. The first sync preserves one-sided files and differing versions (#67).
|
||||||
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
|
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
|
||||||
files stay. Attaching a folder is done from a terminal for now.
|
the remote root when syncing is stopped.
|
||||||
|
|
||||||
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
|
|
||||||
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
|
|
||||||
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
|
|
||||||
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
|
|
||||||
file's versions or the share's members and link. Without the service it still completes commands
|
|
||||||
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
|
|
||||||
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
|
|
||||||
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
|
|
||||||
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
|
|
||||||
|
|
||||||
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
|
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
|
||||||
badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
|
badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
|
||||||
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
|
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
|
||||||
index, and never downloads more than about 16 MiB of a video for it, however large the
|
index, and never downloads more than about 16 MiB of a video for it, however large the
|
||||||
video is. **Build Thumbnails** covers videos too (B283).
|
video is. **Build Thumbnails** covers videos too (B283).
|
||||||
|
|
||||||
- **A new layout for Photos.**
|
- **A new layout for Photos.**
|
||||||
- The grid shows every photo at its own shape, in rows that fill the width (portraits
|
- The grid shows every photo at its own shape, in rows that fill the width (portraits
|
||||||
are no longer cropped to squares). **Square Tiles** in the page menu brings the even
|
are no longer cropped to squares). **Square Tiles** in the page menu brings the even
|
||||||
@@ -69,168 +61,18 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
|||||||
- The zoom level and the tile shape are remembered between runs.
|
- The zoom level and the tile shape are remembered between runs.
|
||||||
- In the photo viewer, the details panel lies over the photo when the window is
|
- In the photo viewer, the details panel lies over the photo when the window is
|
||||||
narrow instead of squeezing it.
|
narrow instead of squeezing it.
|
||||||
|
|
||||||
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
|
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
|
||||||
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
|
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
|
||||||
change it too, and the size is remembered between runs.
|
change it too, and the size is remembered between runs.
|
||||||
|
|
||||||
- **Back takes you to where you were.** Back, Forward and Up in My files return to a folder
|
|
||||||
scrolled where it was left, with the same items selected, and closing the photo viewer scrolls
|
|
||||||
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
|
|
||||||
and Computers and leave an open album or place in Photos, as Escape does too.
|
|
||||||
|
|
||||||
- **Share photos, and more in the photo viewer.**
|
|
||||||
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
|
|
||||||
people or make a public link. It is not offered for a photo in an album someone else shared.
|
|
||||||
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
|
|
||||||
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
|
|
||||||
screen; click one to jump to it. It fades with the other controls and stays hidden during a
|
|
||||||
slideshow.
|
|
||||||
- The line under the date names the town the photo was taken in, when it is known.
|
|
||||||
|
|
||||||
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
|
|
||||||
or any app that takes files. The photos download when dropped and arrive under their own
|
|
||||||
names. A drag that starts on a photo that is not selected still selects, as before.
|
|
||||||
|
|
||||||
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
|
|
||||||
items…" with a progress bar above the timeline, instead of a single toast at the start.
|
|
||||||
|
|
||||||
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
|
|
||||||
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
|
|
||||||
still hands it to another player, and the separate **Play** item is gone. When the file can't
|
|
||||||
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
|
|
||||||
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
|
|
||||||
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
|
|
||||||
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
|
|
||||||
thumbnail strip in Photos stays off a video, where it covered the seek bar.
|
|
||||||
|
|
||||||
- **Album covers for songs.** A song in My files, in search and in Shared shows its cover as its
|
|
||||||
thumbnail, and the player shows it above the controls instead of a music note. The cover is the
|
|
||||||
picture in the song's tags (MP3, FLAC, Ogg, Opus, M4A, WAV, AIFF, WavPack, Monkey's Audio), or
|
|
||||||
else a `cover`, `folder`, `front` or `album` image (`.jpg`, `.png`, `.webp`) in the same folder.
|
|
||||||
Only the song's tags are downloaded for it, not the whole song. Songs uploaded from this computer
|
|
||||||
carry their cover as their thumbnail, so they show it at once on other devices too. A cover
|
|
||||||
image added to a folder later is picked up once the song changes or the service restarts.
|
|
||||||
|
|
||||||
- **Resume a Google Photos import** (B288). The archives and the outcome of the last import are
|
|
||||||
kept, so an import cut short by a restart or a reboot no longer has to be set up again. The
|
|
||||||
Import page offers **Resume**, and `pdfs import-google-photos --resume` does the same. Photos
|
|
||||||
that are already uploaded are skipped as before.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **`proton-sdk` and `proton-drive-rs` 0.12.0.** Timestamps are converted where they enter
|
|
||||||
from the SDK, and a photo keeps its own modification time now that it no longer follows the
|
|
||||||
capture time. 0.12.0 fixes the photo duplicate check and failed-upload drafts below.
|
|
||||||
[The 0.11.0 review](SDK-0.11.0.md) and [the 0.12.0 review](SDK-0.12.0.md) list the new
|
|
||||||
features and breaking changes.
|
|
||||||
|
|
||||||
- **The Google Photos import brings more of the export along** (B289).
|
|
||||||
- Places from Google Photos are kept, so imported photos show up under **Places**.
|
|
||||||
- Live photos and Pixel motion photos stay one photo: the video is filed with its still and
|
|
||||||
takes its date. The `.MP` half of a motion photo is no longer skipped.
|
|
||||||
- Only the Google Photos part of an export is imported. Files from Google Drive, YouTube and
|
|
||||||
other services in the same Takeout stay out of the timeline.
|
|
||||||
- A photo without a date of its own is dated by its file name and then by its EXIF before
|
|
||||||
the day it reached Google Photos.
|
|
||||||
- Favorites are also set on photos that were already in Proton Photos.
|
|
||||||
|
|
||||||
- **The import reports what it is doing** (B288). The Import page shows the current step
|
|
||||||
(reading, checking for photos you already have, uploading, sorting into albums), the counts
|
|
||||||
so far, and a progress bar that reaches the end. A scan says how many photos and how much
|
|
||||||
data an import would upload. `pdfs import-google-photos --wait` prints its progress, and
|
|
||||||
`pdfs import-status` shows the step.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Resuming a Google Photos import no longer fails at once** (#94, B286, B287). A photo upload
|
- Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a
|
||||||
that was cut short left a draft behind, and every later run stopped with
|
thumbnail, and the `@media` open rule rather than `@text`. A `.ts` of 1 MiB or more counts
|
||||||
`check for existing photos: serialization error: invalid type: null`. Such drafts are now
|
as a video, a smaller one stays TypeScript source, and the daemon checks the stream's sync
|
||||||
skipped by the duplicate check and replaced by the next upload of that photo, also after
|
byte before it takes a frame (B283).
|
||||||
signing in again. A failed upload removes its draft.
|
- A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into
|
||||||
|
memory for one frame (B283).
|
||||||
- **One bad archive or error no longer ends the whole import** (B288). An archive that cannot be
|
|
||||||
read is left out and named in the report, and the rest of the export imports. When an import
|
|
||||||
stops early, it keeps the counts of what it did and still files the uploaded photos into their
|
|
||||||
albums. A restart of the service while importing is reported as an interruption, not as a
|
|
||||||
finished import.
|
|
||||||
|
|
||||||
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
|
|
||||||
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
|
|
||||||
don't fill the window, also after a zoom or a resize.
|
|
||||||
|
|
||||||
- **Signed out after a cold boot on Fedora 45** (issue #78, B284, B285). Fedora 45 replaced the
|
|
||||||
GNOME keyring with oo7, and two things there could keep a refreshed sign-in from being saved:
|
|
||||||
oo7 reports the entries of a locked keyring as missing, which the client took for a sign-out,
|
|
||||||
and a restarted oo7 no longer knew the client's connection, which then failed every save until
|
|
||||||
the service restarted. The refreshed sign-in lived only in memory, and the next boot found the
|
|
||||||
spent one. The client now keeps such a sign-in and saves it once the keyring can be read, and
|
|
||||||
reconnects to a keyring service that restarted.
|
|
||||||
|
|
||||||
- **Thumbnails appear in the folder you are looking at as soon as they are made.** During
|
|
||||||
**Build thumbnails**, the folder on screen kept its file icons until you left it and opened it
|
|
||||||
again. Its tiles now fill in while the build runs, and the same goes for any tile whose
|
|
||||||
thumbnail is made while it is on screen.
|
|
||||||
|
|
||||||
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
|
||||||
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
|
||||||
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
|
||||||
a dead mount before it opens the folder. On a large account a restart that did open My files was
|
|
||||||
killed again, because loading the account took longer than the 2 minutes the service has to
|
|
||||||
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
|
|
||||||
|
|
||||||
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
|
|
||||||
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
|
|
||||||
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
|
|
||||||
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
|
|
||||||
and a very large photo waits and is made on its own.
|
|
||||||
|
|
||||||
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
|
|
||||||
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
|
|
||||||
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
|
|
||||||
read it.
|
|
||||||
|
|
||||||
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
|
|
||||||
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
|
|
||||||
it again, so the next read downloaded it or failed without a network. Files added to a folder
|
|
||||||
kept offline from another device were never downloaded. The copy now stays until the new
|
|
||||||
version is downloaded, which happens on its own. Files kept offline are also checked at
|
|
||||||
startup and when the connection returns, and failed downloads are tried again.
|
|
||||||
|
|
||||||
- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the
|
|
||||||
text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail
|
|
||||||
and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays
|
|
||||||
TypeScript source, and the daemon checks the stream's sync byte before it takes a frame.
|
|
||||||
|
|
||||||
- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
|
|
||||||
downloaded whole into memory for one frame.
|
|
||||||
|
|
||||||
- **A folder opens at the top.** A folder opened in My files, Shared with me or Computers could
|
|
||||||
open scrolled to where its parent was left, often at the bottom. Another folder, a new search
|
|
||||||
and a new Photos filter, album or place now start at the top, and a refresh keeps its place.
|
|
||||||
|
|
||||||
- **Closing a photo opened from the Places map goes back to the map**, not to the town's
|
|
||||||
timeline.
|
|
||||||
|
|
||||||
- **Albums and Places stay on screen.** They could switch to the timeline by themselves when a
|
|
||||||
timeline page arrived late, after an upload or after a trash Undo. Coming back to Photos no
|
|
||||||
longer reloads the timeline and loses its scroll position, unless photos changed while it was
|
|
||||||
out of sight. After an album or a place, the timeline came back unfiltered while the kind tab
|
|
||||||
and the date still showed the filter.
|
|
||||||
|
|
||||||
- **A search in My files stays.** It was replaced by the folder's listing whenever files
|
|
||||||
changed, on refresh and on coming back to the page, while the query stayed in the box.
|
|
||||||
|
|
||||||
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
|
|
||||||
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
|
|
||||||
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
|
|
||||||
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
|
|
||||||
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
|
|
||||||
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
|
|
||||||
the grid, and Alt+Left closes the viewer.
|
|
||||||
|
|
||||||
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
|
|
||||||
kept going after another filter, album or place was opened.
|
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. |
|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
|
||||||
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
||||||
| `pdfs pins` | List files and folders kept available offline. |
|
| `pdfs pins` | List files and folders kept available offline. |
|
||||||
|
|
||||||
@@ -196,7 +196,6 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs add-to-album UID PHOTOS...` | Add photos to an album. |
|
| `pdfs add-to-album UID PHOTOS...` | Add photos to an album. |
|
||||||
| `pdfs remove-from-album UID PHOTOS...` | Take photos out of an album. |
|
| `pdfs remove-from-album UID PHOTOS...` | Take photos out of an album. |
|
||||||
| `pdfs import-google-photos ARCHIVES... [--dry-run] [--wait]` | Import a Google Takeout export. Pass every `.zip` of the export. |
|
| `pdfs import-google-photos ARCHIVES... [--dry-run] [--wait]` | Import a Google Takeout export. Pass every `.zip` of the export. |
|
||||||
| `pdfs import-google-photos --resume [--wait]` | Start the last import again with the same archives after it was interrupted, stopped or failed. |
|
|
||||||
| `pdfs import-status` / `pdfs cancel-import` | Follow or stop a running import. |
|
| `pdfs import-status` / `pdfs cancel-import` | Follow or stop a running import. |
|
||||||
| `pdfs redate-photos [--dry-run] [--from DATE] [--to DATE] [--limit N] [--wait]` | Fix photos filed under their upload day when the file name carries the real date. |
|
| `pdfs redate-photos [--dry-run] [--from DATE] [--to DATE] [--limit N] [--wait]` | Fix photos filed under their upload day when the file name carries the real date. |
|
||||||
| `pdfs redate-status` / `pdfs cancel-redate` | Follow or stop a running re-date. |
|
| `pdfs redate-status` / `pdfs cancel-redate` | Follow or stop a running re-date. |
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
|
|||||||
the tag to update:
|
the tag to update:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each release tag is built with Nix before the release is published.
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
# Issue #78: session loss on Fedora 45
|
|
||||||
|
|
||||||
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
|
|
||||||
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
|
|
||||||
|
|
||||||
## What is established
|
|
||||||
|
|
||||||
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
|
|
||||||
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
|
|
||||||
is present in that release. In an isolated test, three successive binary `SetSecret` updates
|
|
||||||
survived reopening the keyring file. Repeating the original persistence explanation is therefore
|
|
||||||
insufficient.
|
|
||||||
|
|
||||||
A different, reproducible failure exists at the boundary between oo7 and pdfs:
|
|
||||||
|
|
||||||
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
|
|
||||||
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
|
|
||||||
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
|
|
||||||
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
|
|
||||||
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
|
|
||||||
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
|
|
||||||
is reported.
|
|
||||||
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
|
|
||||||
logout, cleared the pending rotation and returned success. It skipped the error path that
|
|
||||||
retains the new tokens in the kernel keyring and retries their persistence. The code was
|
|
||||||
unchanged between v3.7.0 and this investigation.
|
|
||||||
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
|
|
||||||
running process can keep working with the new tokens in memory; a restart then exposes the
|
|
||||||
stale saved credentials. A regression test failed before the fix and passes afterward.
|
|
||||||
|
|
||||||
The same unsafe assumption also applies to transient empty lookups such as the race reported in
|
|
||||||
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
|
|
||||||
replacement race was not independently reproduced here; pdfs normally updates existing items
|
|
||||||
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
|
|
||||||
|
|
||||||
## Change and limits
|
|
||||||
|
|
||||||
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
|
|
||||||
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
|
|
||||||
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
|
|
||||||
session is never recreated: flushing still requires a readable blob with the matching session
|
|
||||||
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
|
|
||||||
|
|
||||||
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
|
|
||||||
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
|
|
||||||
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
|
|
||||||
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
|
|
||||||
entry is unreadable.
|
|
||||||
|
|
||||||
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
|
|
||||||
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
|
|
||||||
or credential format is introduced.
|
|
||||||
|
|
||||||
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
|
|
||||||
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
|
|
||||||
can resolve the default alias to a locked collection. Both have fixes in
|
|
||||||
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
|
|
||||||
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
|
|
||||||
matching runtime evidence.
|
|
||||||
|
|
||||||
## A restarted Secret Service
|
|
||||||
|
|
||||||
A second path to the same symptom, independent of locking (B285). The keyring store pdfs used
|
|
||||||
holds one D-Bus connection and one Secret Service session for the whole process, and was
|
|
||||||
installed once. The session lives only in the provider's memory. When the provider restarts,
|
|
||||||
the new one answers every `GetSecret` and `SetSecret` on the old session with
|
|
||||||
`org.freedesktop.Secret.Error.NoSession`, until the pdfs process itself restarts. Reproduced
|
|
||||||
with both gnome-keyring 51.1 and oo7 0.7.1 using `--replace`.
|
|
||||||
|
|
||||||
gnome-keyring practically never restarts within a desktop session. oo7 can:
|
|
||||||
`oo7-daemon.service` has `Restart=on-failure`, the daemon allows itself to be replaced, and it
|
|
||||||
exits on a wrong credential password and is D-Bus-activated again. A daemon that met a restarted
|
|
||||||
provider kept every later token refresh only in memory and the kernel keyring, and a cold boot
|
|
||||||
then left the spent refresh token in the saved blob.
|
|
||||||
|
|
||||||
Now a platform failure drops the store and the call is retried once on a new connection. The
|
|
||||||
daemon log shows `the Secret Service failed this process's connection; reconnecting` when that
|
|
||||||
happens. To check whether a machine meets this, look for oo7 restarts in the same boot:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
journalctl --user -b -1 -u oo7-daemon.service | grep -E 'Started|Stopped|Starting oo7|Main process exited'
|
|
||||||
journalctl --user -b -1 -u proton-drive.service | grep -iE 'keyring|reconnecting|refresh'
|
|
||||||
```
|
|
||||||
|
|
||||||
Reproduce in a throwaway session bus (never against the desktop's keyring; the runtime directory
|
|
||||||
must be short, as it holds a Unix socket):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
T=$(mktemp -d /tmp/ss.XXXX); mkdir -p $T/data $T/run; chmod 700 $T/run
|
|
||||||
XDG_DATA_HOME=$T/data XDG_RUNTIME_DIR=$T/run PDFS_SECRET_SERVICE_RESTART_TEST=1 \
|
|
||||||
PDFS_RESTART_PROVIDER='echo -n pw | gnome-keyring-daemon --replace --unlock --components=secrets >/dev/null' \
|
|
||||||
dbus-run-session -- sh -c 'echo -n pw | gnome-keyring-daemon --unlock --components=secrets >/dev/null; sleep 1;
|
|
||||||
cargo test -p pdfs-core real_secret_service -- --ignored'
|
|
||||||
```
|
|
||||||
|
|
||||||
For oo7, start `oo7-daemon --login` with the password on stdin instead, and restart it with
|
|
||||||
`--login --replace` in the background.
|
|
||||||
|
|
||||||
## Reproduce without a Proton account or desktop keyring
|
|
||||||
|
|
||||||
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
|
|
||||||
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
|
|
||||||
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
|
|
||||||
|
|
||||||
From the pdfs repository, choose an unused temporary checkout path:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
|
|
||||||
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
|
|
||||||
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
|
|
||||||
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed output:
|
|
||||||
|
|
||||||
```text
|
|
||||||
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
|
|
||||||
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
|
|
||||||
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
|
|
||||||
```
|
|
||||||
|
|
||||||
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
|
|
||||||
`a_temporarily_missing_session_does_not_discard_a_rotation` and
|
|
||||||
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
|
|
||||||
|
|
||||||
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
|
|
||||||
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
|
|
||||||
daemon's package version rather than just the CLI version. The investigation did not use a real
|
|
||||||
Proton account, alter a desktop keyring, or post to either GitHub issue.
|
|
||||||
+2
-17
@@ -55,7 +55,6 @@ server learns which areas you look at.
|
|||||||
2. In the app, open the **Upload** menu on the Photos page and choose **Import from Google
|
2. In the app, open the **Upload** menu on the Photos page and choose **Import from Google
|
||||||
Photos…**. Select all the `.zip` files at once, because a photo and its details are often in
|
Photos…**. Select all the `.zip` files at once, because a photo and its details are often in
|
||||||
different parts of the export.
|
different parts of the export.
|
||||||
3. Choose **Scan** to see what an import would do without uploading anything, then **Import**.
|
|
||||||
|
|
||||||
From a terminal:
|
From a terminal:
|
||||||
|
|
||||||
@@ -64,22 +63,8 @@ pdfs import-google-photos --dry-run ~/Downloads/takeout-*.zip # show what woul
|
|||||||
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
|
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
What the import takes from the export:
|
The import recreates your albums and takes capture dates and locations from the export. Photos
|
||||||
|
already in your library are skipped, so an interrupted import can simply be run again.
|
||||||
- **Albums** are recreated. A photo that is in several albums is uploaded once and added to each.
|
|
||||||
- **Dates** come from the export's details, else from a date in the file name, else from the
|
|
||||||
photo's EXIF.
|
|
||||||
- **Places** come from the export, so imported photos appear under [Places](#places).
|
|
||||||
- **Favorites** are kept.
|
|
||||||
- **Live photos and motion photos** stay one photo: the video is filed with its still.
|
|
||||||
|
|
||||||
What it leaves out: photos in Google's trash, and files from other Google services (Drive,
|
|
||||||
YouTube) that are in the same export. A `.zip` that cannot be read is left out and named in the
|
|
||||||
report; the rest still imports.
|
|
||||||
|
|
||||||
Photos already in your library are skipped, so an interrupted import can simply be run again.
|
|
||||||
If the import was cut short, for example by a restart, the Import page offers **Resume**, and
|
|
||||||
`pdfs import-google-photos --resume` does the same without naming the archives again.
|
|
||||||
`pdfs import-status` shows how far an import is, and `pdfs cancel-import` stops it.
|
`pdfs import-status` shows how far an import is, and `pdfs cancel-import` stops it.
|
||||||
|
|
||||||
## Fixing photos filed under the wrong date
|
## Fixing photos filed under the wrong date
|
||||||
|
|||||||
+1
-2
@@ -63,8 +63,7 @@ them along.
|
|||||||
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
||||||
| Photo name search | Not started | Photos search by date and filters only |
|
| Photo name search | Not started | Photos search by date and filters only |
|
||||||
| Automatic photo upload from a folder | Not started | |
|
| Automatic photo upload from a folder | Not started | |
|
||||||
| Faster Google Photos import | Not started | Uploads run one at a time. Trash folders of locales missing from `TRASH_FOLDERS` import as photos |
|
| Faster Google Photos import | Not started | Uploads run one at a time. Trash folders of locales missing from `TRASH_FOLDERS` import as photos; motion-photo `.MP` parts are skipped |
|
||||||
| More of a Google Photos export | Not started | `.tgz` exports and extracted folders; shared albums told apart from own ones; album descriptions and covers; photo descriptions |
|
|
||||||
| Content-addressed block cache | Idea | Store blocks by hash to deduplicate identical content |
|
| Content-addressed block cache | Idea | Store blocks by hash to deduplicate identical content |
|
||||||
|
|
||||||
## Performance and correctness
|
## Performance and correctness
|
||||||
|
|||||||
Whitespace-only changes.
@@ -1,53 +0,0 @@
|
|||||||
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
|
|
||||||
index 99c25dc..2ba705d 100644
|
|
||||||
--- a/server/src/item/tests.rs
|
|
||||||
+++ b/server/src/item/tests.rs
|
|
||||||
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
|
|
||||||
|
|
||||||
use crate::{service::PrompterType, tests::TestServiceSetup};
|
|
||||||
|
|
||||||
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
|
|
||||||
+// no desktop keyring or Proton account is used.
|
|
||||||
+#[tokio::test]
|
|
||||||
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
+ let setup =
|
|
||||||
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
|
|
||||||
+ .await?;
|
|
||||||
+ let collection = setup.default_collection().await?;
|
|
||||||
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
|
|
||||||
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
|
|
||||||
+ collection
|
|
||||||
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
|
|
||||||
+ .await?;
|
|
||||||
+
|
|
||||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
|
||||||
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
|
||||||
+ setup.lock_collection(collection).await?;
|
|
||||||
+ assert!(collection.is_locked().await?);
|
|
||||||
+
|
|
||||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
|
||||||
+ // This is the problematic reply, not the Secret Service behavior expected
|
|
||||||
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
|
|
||||||
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
|
|
||||||
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
|
|
||||||
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
|
|
||||||
+
|
|
||||||
+ setup.unlock_collection(collection).await?;
|
|
||||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
|
||||||
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
|
||||||
+
|
|
||||||
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
|
|
||||||
+ // the disk keyring after each update, rather than trusting a memory read.
|
|
||||||
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
|
|
||||||
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
|
|
||||||
+ unlocked[0].set_secret(&secret).await?;
|
|
||||||
+ let items = setup.login_keyring().await?.items().await?;
|
|
||||||
+ assert_eq!(items[0].secret().as_bytes(), refresh);
|
|
||||||
+ }
|
|
||||||
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
|
|
||||||
+ Ok(())
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
#[tokio::test]
|
|
||||||
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let setup = TestServiceSetup::plain_session(true).await?;
|
|
||||||
+1
-1
@@ -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=3.8.0
|
pkgver=3.7.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')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
%global _lto_cflags %{nil}
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
%{!?version: %global version 3.8.0}
|
%{!?version: %global version 3.7.0}
|
||||||
%global forge https://github.com/narrrl/proton-drive-linux
|
%global forge https://github.com/narrrl/proton-drive-linux
|
||||||
# Files come from the checkout, or from the unpacked tag archive.
|
# Files come from the checkout, or from the unpacked tag archive.
|
||||||
%global src %{?git_dir}%{!?git_dir:.}
|
%global src %{?git_dir}%{!?git_dir:.}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ crates/pdfs-gui/src/app/widgets/compat.rs
|
|||||||
crates/pdfs-gui/src/app/widgets/details.rs
|
crates/pdfs-gui/src/app/widgets/details.rs
|
||||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||||
crates/pdfs-gui/src/app/widgets/menu.rs
|
crates/pdfs-gui/src/app/widgets/menu.rs
|
||||||
crates/pdfs-gui/src/app/widgets/player.rs
|
|
||||||
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
||||||
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
||||||
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
||||||
|
|||||||
+996
-1180
File diff suppressed because it is too large.
Load diff
+929
-1082
File diff suppressed because it is too large.
Load diff
+938
-1122
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ SECTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md", "SDK-0.12.0.md"}
|
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
|
||||||
|
|
||||||
# Files outside docs/ whose links are checked but not published.
|
# Files outside docs/ whose links are checked but not published.
|
||||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||||
|
|||||||
Reference in new issue
Block a user