mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847ece17d7 | ||
|
|
50d2acdaba | ||
|
|
53caf63ad4 | ||
|
|
5176dd91e1 | ||
|
|
e88903d5ff | ||
|
|
b3e6af1ae5 | ||
|
|
b0f9513915 | ||
|
|
91b0864fce | ||
|
|
8bd9481902 | ||
|
|
b2d5870834 |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in the client misbehaves
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
|
||||||
|
tracked there with context.
|
||||||
|
|
||||||
|
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Distribution and desktop
|
||||||
|
placeholder: "Fedora 44, GNOME 48 (Wayland)"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Where does it happen
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount / file operations
|
||||||
|
- Sync or backed-up folders (Computers)
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Login / keyring
|
||||||
|
- Photos
|
||||||
|
- Packaging / installation
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: diagnose
|
||||||
|
attributes:
|
||||||
|
label: "`pdfs diagnose` output"
|
||||||
|
description: Redact paths or the account address if you prefer.
|
||||||
|
render: console
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Daemon logs
|
||||||
|
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
|
||||||
|
render: console
|
||||||
|
- type: checkboxes
|
||||||
|
id: dataloss
|
||||||
|
attributes:
|
||||||
|
label: Data
|
||||||
|
options:
|
||||||
|
- label: This bug involved missing, truncated, or overwritten file content.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||||
|
about: Report privately — never in a public issue.
|
||||||
|
- name: Known issues
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||||
|
about: The authoritative ledger of tracked bugs and their status.
|
||||||
|
- name: Proton Drive service problems
|
||||||
|
url: https://proton.me/support
|
||||||
|
about: This is an unofficial client. Account or service issues go to Proton AG.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a capability or an improvement
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: The problem
|
||||||
|
description: What are you trying to do that the client makes hard or impossible today?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: What you'd like to see
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Which part of the client
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount
|
||||||
|
- Sync / Computers
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Photos
|
||||||
|
- Packaging
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives you considered
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## What this changes
|
||||||
|
|
||||||
|
<!-- The user-visible effect, in a sentence or two. -->
|
||||||
|
|
||||||
|
**Crates touched:**
|
||||||
|
**Related `docs/BUGS.md` entries:**
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
|
||||||
|
|
||||||
|
## Implications
|
||||||
|
|
||||||
|
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
|
||||||
|
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||||
|
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||||
|
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||||
|
- [ ] None of the above
|
||||||
|
|
||||||
|
<!-- Screenshots for GTK changes. -->
|
||||||
@@ -122,13 +122,14 @@ jobs:
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Nils Pukropp <contact@narl.io>
|
Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
|
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||||
Installed-Size: ${INSTALLED_SIZE}
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
Description: Proton Drive client for Linux
|
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||||
Proton Drive client for Linux featuring:
|
Description: Unofficial Proton Drive client for Linux
|
||||||
- FUSE files-on-demand mount
|
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||||
- Command-line interface (CLI)
|
- FUSE virtual mount with block-level caching
|
||||||
- GTK4 system tray, desktop application, and search launcher
|
- Scriptable command-line interface (pdfs)
|
||||||
|
- GTK4 desktop application, system tray, and search launcher
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Build Debian package
|
# Build Debian package
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our pledge
|
||||||
|
|
||||||
|
We want participation in this project to be a harassment-free experience for everyone, regardless
|
||||||
|
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||||
|
level of experience, education, socioeconomic status, nationality, personal appearance, race,
|
||||||
|
religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Standards
|
||||||
|
|
||||||
|
Examples of behavior that helps:
|
||||||
|
|
||||||
|
- being respectful of differing viewpoints and experience levels,
|
||||||
|
- giving and accepting constructive feedback gracefully,
|
||||||
|
- focusing on what is best for the project and its users.
|
||||||
|
|
||||||
|
Examples of unacceptable behavior:
|
||||||
|
|
||||||
|
- sexualized language or imagery, and unwelcome sexual attention,
|
||||||
|
- trolling, insulting or derogatory comments, and personal or political attacks,
|
||||||
|
- public or private harassment,
|
||||||
|
- publishing others' private information without explicit permission.
|
||||||
|
|
||||||
|
## Scope and enforcement
|
||||||
|
|
||||||
|
This applies in all project spaces — issues, pull requests, discussions — and when representing the
|
||||||
|
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
|
||||||
|
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
|
||||||
|
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
|
||||||
|
|
||||||
|
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
|
||||||
|
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
|
||||||
|
deliberately high — everything else is ordinary Rust review.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
|
||||||
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||||
|
together.
|
||||||
|
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||||
|
already be tracked there with context.
|
||||||
|
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||||
|
spend the time.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||||
|
cd proton-drive-linux
|
||||||
|
cargo build --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
||||||
|
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
|
||||||
|
version is 1.96.
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
CI runs exactly these, and they must pass with zero warnings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
||||||
|
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
||||||
|
use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
|
## Making changes
|
||||||
|
|
||||||
|
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
|
||||||
|
they speak the control protocol to the daemon.
|
||||||
|
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
|
||||||
|
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
|
||||||
|
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
|
||||||
|
control-socket work stays off GTK's main thread.
|
||||||
|
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
||||||
|
them opportunistically.
|
||||||
|
|
||||||
|
## Commits and pull requests
|
||||||
|
|
||||||
|
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
|
||||||
|
`fix(fuse,core): keep truncate composable with queued revisions`.
|
||||||
|
|
||||||
|
A pull request should state:
|
||||||
|
|
||||||
|
- the user-visible effect and which crates it touches,
|
||||||
|
- the `docs/BUGS.md` entries it fixes or affects,
|
||||||
|
- the commands you ran to verify it,
|
||||||
|
- screenshots for GTK changes,
|
||||||
|
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
|
||||||
Generated
+45
-8
@@ -3538,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.8.2"
|
version = "1.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.8.2"
|
version = "1.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -3566,16 +3566,19 @@ dependencies = [
|
|||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1 0.10.7",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.8.2"
|
version = "1.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"fuser",
|
"fuser",
|
||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
@@ -3585,13 +3588,15 @@ dependencies = [
|
|||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
"proton-drive-rs",
|
"proton-drive-rs",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1 0.10.7",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.8.2"
|
version = "1.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3852,9 +3857,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe0e39da5b61ceb862af2f4406870b9c1d9a1554889e8d40a32fff13882ec690"
|
checksum = "58322795538d66137d2eb071590375c5287e69059c429e2309dc37368ab5b588"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3879,9 +3884,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97cde8cf8e3099d1dbb56302462a632a12e0ff818c012b9c9b0a7a5200544fd6"
|
checksum = "d77482a3160d14926c65537c64bc95cf27d609597a524c97d97d97d1777e9603"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5313,6 +5318,12 @@ dependencies = [
|
|||||||
"cipher 0.4.4",
|
"cipher 0.4.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -6254,6 +6265,20 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "8.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"indexmap",
|
||||||
|
"memchr",
|
||||||
|
"typed-path",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.6"
|
version = "0.6.6"
|
||||||
@@ -6266,6 +6291,18 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
+15
-3
@@ -3,15 +3,20 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.8.2"
|
version = "1.9.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||||
|
homepage = "https://github.com/narrrl/proton-drive-linux"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||||
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.0" }
|
proton-sdk = { version = "0.6.1" }
|
||||||
proton-drive-rs = { version = "0.6.0" }
|
proton-drive-rs = { version = "0.6.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -20,6 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||||
@@ -32,6 +38,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
directories = "5"
|
directories = "5"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
|
# directories and streams entries out without extracting to disk first.
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
|
# import has to speak SHA-1 whatever its age.
|
||||||
|
sha1 = "0.10"
|
||||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||||
# 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.
|
||||||
|
|||||||
@@ -1,12 +1,70 @@
|
|||||||
# Proton Drive client for Linux (unofficial)
|
<div align="center">
|
||||||
|
|
||||||
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration.
|
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||||
|
|
||||||
|
# Proton Drive for Linux
|
||||||
|
|
||||||
|
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
|
||||||
|
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
|
||||||
|
|
||||||
|
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](#prerequisites)
|
||||||
|
|
||||||
|
<img src="images/files.png" alt="The GTK4 file browser" width="820">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||||
|
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||||
|
> to describe what the software talks to. Use a test account first, and read
|
||||||
|
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian / Ubuntu
|
||||||
|
sudo apt install ./proton-drive-linux_*.deb
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install ./proton-drive-linux-*.rpm
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
cd packaging && makepkg -fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Packages for each release are attached to the [latest
|
||||||
|
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
|
||||||
|
[Installation & Packages](#installation--packages) for details and
|
||||||
|
[Building from Source](#building-from-source) to compile it yourself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
|
||||||
|
pdfs status # mount state, queued writes, cache usage
|
||||||
|
pdfs-app # the desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
|
||||||
|
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
|
||||||
|
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
|
||||||
|
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
||||||
|
|
||||||
|
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
||||||
|
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
|
||||||
|
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
||||||
|
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||||
|
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
||||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||||
@@ -270,19 +328,21 @@ sudo apt-get install -y \
|
|||||||
libadwaita-1-dev \
|
libadwaita-1-dev \
|
||||||
libwebkitgtk-6.0-dev \
|
libwebkitgtk-6.0-dev \
|
||||||
libsecret-1-dev \
|
libsecret-1-dev \
|
||||||
libdbus-1-dev
|
libdbus-1-dev \
|
||||||
|
libimage-exiftool-perl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch Linux
|
### Arch Linux
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
|
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fedora (44+)
|
### Fedora (44+)
|
||||||
```bash
|
```bash
|
||||||
sudo dnf install -y \
|
sudo dnf install -y \
|
||||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
|
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
||||||
|
perl-Image-ExifTool cargo rust
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime extras (pick your desktop):
|
Runtime extras (pick your desktop):
|
||||||
@@ -302,7 +362,7 @@ Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96
|
|||||||
|
|
||||||
1. Clone the repository and navigate into the project directory:
|
1. Clone the repository and navigate into the project directory:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/narl/proton-drive-linux.git
|
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||||
cd proton-drive-linux
|
cd proton-drive-linux
|
||||||
```
|
```
|
||||||
2. Build the workspace in release mode:
|
2. Build the workspace in release mode:
|
||||||
@@ -372,6 +432,15 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
||||||
|
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
|
||||||
|
vulnerability privately.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||||
|
affiliation with or endorsement from Proton AG.
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Report privately through GitHub's
|
||||||
|
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
|
||||||
|
or by email to <contact@narl.io>.
|
||||||
|
|
||||||
|
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
|
||||||
|
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
|
||||||
|
days; a fix ships in the next release, and you are credited in the advisory unless you ask
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This client holds Proton account credentials and decrypted file content on the local machine, so
|
||||||
|
the interesting boundaries are:
|
||||||
|
|
||||||
|
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
|
||||||
|
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
|
||||||
|
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
|
||||||
|
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
|
||||||
|
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
|
||||||
|
those checks are in scope.
|
||||||
|
- **Local cache and staging.** Decrypted block content and staged writes on disk.
|
||||||
|
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||||
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
|
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
||||||
|
require an attacker who already has your user account on the machine.
|
||||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "CLI for the Proton Drive Linux client"
|
description = "CLI for the Proton Drive Linux client"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -158,6 +158,28 @@ enum Command {
|
|||||||
},
|
},
|
||||||
/// List the photo albums (including ones shared with this account).
|
/// List the photo albums (including ones shared with this account).
|
||||||
Albums,
|
Albums,
|
||||||
|
/// Import a Google Photos Takeout export into Proton Photos.
|
||||||
|
///
|
||||||
|
/// Pass every zip of the export at once — Google splits one export across
|
||||||
|
/// 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
|
||||||
|
/// photos already on the account (same name and same content) are skipped,
|
||||||
|
/// so an interrupted import can simply be run again.
|
||||||
|
ImportGooglePhotos {
|
||||||
|
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
||||||
|
#[arg(required = true)]
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
/// Report what would be imported without uploading anything.
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
/// Stay attached and print progress until the import finishes.
|
||||||
|
#[arg(long)]
|
||||||
|
wait: bool,
|
||||||
|
},
|
||||||
|
/// Show how the running (or last) Google Photos import is doing.
|
||||||
|
ImportStatus,
|
||||||
|
/// Stop the running Google Photos import.
|
||||||
|
CancelImport,
|
||||||
/// List the photos in one album (newest capture first).
|
/// List the photos in one album (newest capture first).
|
||||||
Album {
|
Album {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
@@ -589,6 +611,13 @@ fn main() -> Result<()> {
|
|||||||
} => cmd_photos(limit, offset, favorites),
|
} => cmd_photos(limit, offset, favorites),
|
||||||
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
|
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
|
||||||
Command::Albums => cmd_albums(),
|
Command::Albums => cmd_albums(),
|
||||||
|
Command::ImportGooglePhotos {
|
||||||
|
archives,
|
||||||
|
dry_run,
|
||||||
|
wait,
|
||||||
|
} => cmd_import_google_photos(archives, dry_run, wait),
|
||||||
|
Command::ImportStatus => cmd_import_status(),
|
||||||
|
Command::CancelImport => cmd_cancel_import(),
|
||||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
Command::Search { query, limit } => cmd_search(query, limit),
|
Command::Search { query, limit } => cmd_search(query, limit),
|
||||||
@@ -1711,6 +1740,104 @@ fn cmd_albums() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
||||||
|
// The daemon opens the archives itself off the shared filesystem, so it gets
|
||||||
|
// absolute paths — the same arrangement `pdfs upload` uses.
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match control_request(CtlRequest::ImportTakeout {
|
||||||
|
archives: absolute,
|
||||||
|
dry_run,
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
if !wait {
|
||||||
|
println!("Run `pdfs import-status` to follow it.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// connection the daemon has.
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
|
CtlResponse::ImportStatus {
|
||||||
|
running: true,
|
||||||
|
summary: _,
|
||||||
|
} => continue,
|
||||||
|
CtlResponse::ImportStatus { summary, .. } => {
|
||||||
|
print_import_summary(summary.as_ref());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_import_status() -> Result<()> {
|
||||||
|
match control_request(CtlRequest::ImportStatus)? {
|
||||||
|
CtlResponse::ImportStatus { running, summary } => {
|
||||||
|
if running {
|
||||||
|
println!("Import running. `pdfs transfers` shows its progress.");
|
||||||
|
}
|
||||||
|
print_import_summary(summary.as_ref());
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_cancel_import() -> Result<()> {
|
||||||
|
match control_request(CtlRequest::CancelImport)? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
|
||||||
|
let Some(summary) = summary else {
|
||||||
|
println!("No import has run yet.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
println!("Photos in export : {}", summary.found);
|
||||||
|
println!("Uploaded : {}", summary.uploaded);
|
||||||
|
println!("Already on Proton: {}", summary.duplicates);
|
||||||
|
if summary.skipped_trashed > 0 {
|
||||||
|
println!("Skipped (trash) : {}", summary.skipped_trashed);
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 || summary.album_links > 0 {
|
||||||
|
println!(
|
||||||
|
"Albums : {} created, {} photos filed",
|
||||||
|
summary.albums_created, summary.album_links
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
println!("Failed : {}", summary.failed);
|
||||||
|
}
|
||||||
|
if summary.bytes > 0 {
|
||||||
|
println!("Uploaded bytes : {}", summary.bytes);
|
||||||
|
}
|
||||||
|
if summary.cancelled {
|
||||||
|
println!("Import was cancelled before it finished.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
||||||
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
|
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
|
||||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "Core auth/session/config for the Proton Drive Linux client"
|
description = "Core auth/session/config for the Proton Drive Linux client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -18,6 +20,8 @@ tracing.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
ignore.workspace = true
|
ignore.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -415,6 +416,10 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||||
thumb_dir: PathBuf,
|
thumb_dir: PathBuf,
|
||||||
|
/// Owner-protected scratch space for decrypted camera RAWs passed to
|
||||||
|
/// exiftool. Files are removed on drop and swept on the next cache open after
|
||||||
|
/// an unclean shutdown.
|
||||||
|
raw_thumb_dir: PathBuf,
|
||||||
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
||||||
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||||
/// sees them; blocks carry their own LRU budget.
|
/// sees them; blocks carry their own LRU budget.
|
||||||
@@ -507,6 +512,19 @@ impl ContentCache {
|
|||||||
std::fs::create_dir_all(&content_dir)?;
|
std::fs::create_dir_all(&content_dir)?;
|
||||||
let thumb_dir = content_dir.join("thumbs");
|
let thumb_dir = content_dir.join("thumbs");
|
||||||
std::fs::create_dir_all(&thumb_dir)?;
|
std::fs::create_dir_all(&thumb_dir)?;
|
||||||
|
let raw_thumb_dir = content_dir.join("raw-thumbnails");
|
||||||
|
std::fs::create_dir_all(&raw_thumb_dir)?;
|
||||||
|
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name.to_string_lossy().starts_with("pdfs-raw-")
|
||||||
|
&& entry.file_type().is_ok_and(|kind| kind.is_file())
|
||||||
|
{
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let block_dir = content_dir.join("blocks");
|
let block_dir = content_dir.join("blocks");
|
||||||
std::fs::create_dir_all(&block_dir)?;
|
std::fs::create_dir_all(&block_dir)?;
|
||||||
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
||||||
@@ -530,6 +548,7 @@ impl ContentCache {
|
|||||||
let cache = Self {
|
let cache = Self {
|
||||||
content_dir,
|
content_dir,
|
||||||
thumb_dir,
|
thumb_dir,
|
||||||
|
raw_thumb_dir,
|
||||||
block_dir,
|
block_dir,
|
||||||
scratch_dir,
|
scratch_dir,
|
||||||
staging_dir,
|
staging_dir,
|
||||||
@@ -1666,6 +1685,12 @@ impl ContentCache {
|
|||||||
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owner-protected transient directory used to stage decrypted camera RAWs
|
||||||
|
/// for metadata helpers. Callers still remove individual files promptly.
|
||||||
|
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
|
||||||
|
&self.raw_thumb_dir
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
||||||
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
||||||
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
||||||
|
|||||||
@@ -93,6 +93,32 @@ pub enum Request {
|
|||||||
/// already cached (one batched round-trip) and replying with their on-disk
|
/// already cached (one batched round-trip) and replying with their on-disk
|
||||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||||
PhotoThumbs { uids: Vec<String> },
|
PhotoThumbs { uids: Vec<String> },
|
||||||
|
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
||||||
|
/// timeline. The modification time is the cache validity tag, so replacing
|
||||||
|
/// an image can never reuse its previous revision's thumbnail.
|
||||||
|
FileThumbs {
|
||||||
|
items: Vec<FileThumbRequest>,
|
||||||
|
/// Identifies the front-end's current listing. Moving to another page or
|
||||||
|
/// folder advances it, allowing the daemon to stop work for rows that
|
||||||
|
/// are no longer visible.
|
||||||
|
#[serde(default)]
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
/// Reserve a daemon-issued generation for ordinary-file thumbnail work.
|
||||||
|
/// The daemon is the ordering authority, so GUI restarts and wall-clock
|
||||||
|
/// corrections cannot make a new listing look older than an earlier one.
|
||||||
|
ReserveFileThumbGeneration,
|
||||||
|
/// Stop ordinary-file thumbnail work belonging to older listings. Photos
|
||||||
|
/// timeline work and an explicit recursive build are deliberately separate.
|
||||||
|
CancelFileThumbs { generation: u64 },
|
||||||
|
/// Start building local thumbnails for every supported image below `path`.
|
||||||
|
/// The request returns immediately; progress is read with
|
||||||
|
/// [`Request::ThumbnailBuildStatus`].
|
||||||
|
StartThumbnailBuild { path: String },
|
||||||
|
/// Stop the current recursive ordinary-file thumbnail build, if any.
|
||||||
|
CancelThumbnailBuild,
|
||||||
|
/// Read progress for the recursive ordinary-file thumbnail build.
|
||||||
|
ThumbnailBuildStatus,
|
||||||
/// Download a photo's full content into the cache; replies with its path.
|
/// Download a photo's full content into the cache; replies with its path.
|
||||||
OpenPhoto { uid: String },
|
OpenPhoto { uid: String },
|
||||||
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
|
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
|
||||||
@@ -103,6 +129,33 @@ pub enum Request {
|
|||||||
/// re-encrypted for our timeline, which the SDK does not implement yet; the
|
/// re-encrypted for our timeline, which the SDK does not implement yet; the
|
||||||
/// daemon reports that as an error rather than silently doing nothing.
|
/// daemon reports that as an error rather than silently doing nothing.
|
||||||
SetPhotoFavorite { uid: String, favorite: bool },
|
SetPhotoFavorite { uid: String, favorite: bool },
|
||||||
|
/// Import Google Photos **Takeout** archives into Proton Photos.
|
||||||
|
///
|
||||||
|
/// `archives` are absolute paths to the export's `.zip` parts on the
|
||||||
|
/// daemon's own filesystem — the whole set at once, because a photo and its
|
||||||
|
/// metadata sidecar routinely land in different parts. Photos already on the
|
||||||
|
/// account (matched by name *and* content digest) are skipped, so a library
|
||||||
|
/// partly synced by Proton Photos elsewhere does not import twice, and an
|
||||||
|
/// interrupted import resumes by simply being run again.
|
||||||
|
///
|
||||||
|
/// With `dry_run` the daemon scans and reports what it *would* do without
|
||||||
|
/// uploading anything. Otherwise it acks immediately with
|
||||||
|
/// [`Response::Ok`] and works in the background — an export is hours of
|
||||||
|
/// upload, far past any socket timeout. Progress shows up in
|
||||||
|
/// [`Request::GetQueueStatus`] as a job, and the final counts in
|
||||||
|
/// [`Request::ImportStatus`].
|
||||||
|
ImportTakeout {
|
||||||
|
archives: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
dry_run: bool,
|
||||||
|
},
|
||||||
|
/// How the running (or last finished) Takeout import is doing. Replies with
|
||||||
|
/// [`Response::ImportStatus`].
|
||||||
|
ImportStatus,
|
||||||
|
/// Ask the running Takeout import to stop. It finishes the photo on the wire
|
||||||
|
/// and files what it has uploaded into its albums, then reports
|
||||||
|
/// `cancelled`. Replies with [`Response::Ok`].
|
||||||
|
CancelImport,
|
||||||
/// 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
|
||||||
@@ -652,6 +705,10 @@ pub struct SharedItem {
|
|||||||
/// The shared node's decrypted name.
|
/// The shared node's decrypted name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
|
/// Modification time used to validate an image thumbnail. Older daemons
|
||||||
|
/// omit it, in which case clients use `0` as a conservative fallback tag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub modified: i64,
|
||||||
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in
|
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in
|
||||||
/// my own tree). Empty when the path is unknown.
|
/// my own tree). Empty when the path is unknown.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1052,6 +1109,35 @@ pub struct PhotoMonth {
|
|||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a Google Photos Takeout import is going, or how the last one ended
|
||||||
|
/// ([`Response::ImportStatus`]).
|
||||||
|
///
|
||||||
|
/// The counts are cumulative for the run, so the same shape serves progress and
|
||||||
|
/// the final report. `found` counts *distinct* photos: Google stores a separate
|
||||||
|
/// copy of a photo in each album folder it appears in, and those fold into one
|
||||||
|
/// upload that joins several albums.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ImportSummary {
|
||||||
|
/// Distinct photos the export holds.
|
||||||
|
pub found: usize,
|
||||||
|
/// Photos uploaded by this run.
|
||||||
|
pub uploaded: usize,
|
||||||
|
/// Photos already on the account, matched by name *and* content digest.
|
||||||
|
pub duplicates: usize,
|
||||||
|
/// Photos that could not be read or uploaded.
|
||||||
|
pub failed: usize,
|
||||||
|
/// Photos in the export's trash bucket, which are never imported.
|
||||||
|
pub skipped_trashed: usize,
|
||||||
|
/// Albums created by this run.
|
||||||
|
pub albums_created: usize,
|
||||||
|
/// Album memberships added by this run.
|
||||||
|
pub album_links: usize,
|
||||||
|
/// Bytes uploaded.
|
||||||
|
pub bytes: u64,
|
||||||
|
/// True when the run stopped early because it was cancelled.
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// One album in a [`Response::Albums`] listing.
|
/// One album in a [`Response::Albums`] listing.
|
||||||
///
|
///
|
||||||
/// An album is a folder node on the photos volume carrying album properties, so
|
/// An album is a folder node on the photos volume carrying album properties, so
|
||||||
@@ -1131,6 +1217,76 @@ pub struct PhotoThumb {
|
|||||||
pub pending: bool,
|
pub pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One ordinary Drive file whose thumbnail a front-end wants to paint.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct FileThumbRequest {
|
||||||
|
/// Node uid in `volume~link` form.
|
||||||
|
pub uid: String,
|
||||||
|
/// File modification time, used as the thumbnail cache validity tag.
|
||||||
|
pub modified: i64,
|
||||||
|
/// Original Drive name. Local RAW-preview extraction uses the extension as a
|
||||||
|
/// format hint; defaulted for wire compatibility with older front-ends.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a Drive name is a raster image that the built-in decoder can read,
|
||||||
|
/// or a concrete camera RAW format whose embedded preview exiftool can extract.
|
||||||
|
/// Keep this list shared by the daemon and every GUI surface so a visible tile
|
||||||
|
/// is never advertised without a matching generation path.
|
||||||
|
pub fn is_thumbnail_image_name(name: &str) -> bool {
|
||||||
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let extension = extension.to_ascii_lowercase();
|
||||||
|
STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||||
|
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
||||||
|
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
|
||||||
|
/// preview availability is not dependable.
|
||||||
|
pub fn is_raw_image_name(name: &str) -> bool {
|
||||||
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let extension = extension.to_ascii_lowercase();
|
||||||
|
RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] =
|
||||||
|
&["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"];
|
||||||
|
|
||||||
|
/// Concrete camera RAW formats with embedded previews handled by exiftool.
|
||||||
|
/// This is the single source used by both RAW classification and the broader
|
||||||
|
/// thumbnail capability check.
|
||||||
|
const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[
|
||||||
|
"arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf",
|
||||||
|
"raw", "rw2", "sr2", "srf", "x3f",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Progress of the one recursive ordinary-file thumbnail build the daemon may
|
||||||
|
/// run at a time.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ThumbnailBuildStatus {
|
||||||
|
pub running: bool,
|
||||||
|
/// True while folders are still being discovered, before the final image
|
||||||
|
/// count is known and the progress bar can become determinate.
|
||||||
|
pub scanning: bool,
|
||||||
|
/// Mountpoint-relative root selected when the build started.
|
||||||
|
pub path: String,
|
||||||
|
pub folders_scanned: u64,
|
||||||
|
pub images_found: u64,
|
||||||
|
/// Images already cached or processed during this build. Includes failures,
|
||||||
|
/// so `completed == images_found` still means the job has finished.
|
||||||
|
pub completed: u64,
|
||||||
|
/// Images that could not be downloaded or decoded.
|
||||||
|
pub failed: u64,
|
||||||
|
/// A traversal-level problem, such as a folder that could not be listed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// A daemon too old to report connectivity is assumed online — it could not
|
/// A daemon too old to report connectivity is assumed online — it could not
|
||||||
/// have mounted at all otherwise.
|
/// have mounted at all otherwise.
|
||||||
fn default_online() -> bool {
|
fn default_online() -> bool {
|
||||||
@@ -1270,11 +1426,28 @@ pub enum Response {
|
|||||||
available: bool,
|
available: bool,
|
||||||
items: Vec<AlbumInfo>,
|
items: Vec<AlbumInfo>,
|
||||||
},
|
},
|
||||||
|
/// How the Takeout import is doing (reply to [`Request::ImportStatus`]).
|
||||||
|
/// `running` is false once it has finished, when `summary` is the final
|
||||||
|
/// report; `summary` is `None` when no import has run this session.
|
||||||
|
ImportStatus {
|
||||||
|
running: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: Option<ImportSummary>,
|
||||||
|
},
|
||||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||||
/// newest first.
|
/// newest first.
|
||||||
PhotoMonths { months: Vec<PhotoMonth> },
|
PhotoMonths { months: Vec<PhotoMonth> },
|
||||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||||
Thumbs { items: Vec<PhotoThumb> },
|
Thumbs { items: Vec<PhotoThumb> },
|
||||||
|
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
||||||
|
/// retryable after reserving a fresh generation and must never be interpreted
|
||||||
|
/// as a permanent "no thumbnail" verdict.
|
||||||
|
FileThumbsStale,
|
||||||
|
/// Daemon-issued generation reserved by
|
||||||
|
/// [`Request::ReserveFileThumbGeneration`].
|
||||||
|
FileThumbGeneration { generation: u64 },
|
||||||
|
/// Current recursive thumbnail-build progress.
|
||||||
|
ThumbnailBuild { status: ThumbnailBuildStatus },
|
||||||
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
||||||
FilePath { path: String },
|
FilePath { path: String },
|
||||||
/// Full-text search results (reply to [`Request::Search`]).
|
/// Full-text search results (reply to [`Request::Search`]).
|
||||||
@@ -1409,7 +1582,7 @@ impl ErrorKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
||||||
/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening.
|
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
|
||||||
///
|
///
|
||||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||||
@@ -1568,6 +1741,86 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_thumbnail_requests_keep_their_revision_tags() {
|
||||||
|
let request = Request::FileThumbs {
|
||||||
|
items: vec![
|
||||||
|
FileThumbRequest {
|
||||||
|
uid: "volume~first".into(),
|
||||||
|
modified: 1_700_000_001,
|
||||||
|
name: "first.jpg".into(),
|
||||||
|
},
|
||||||
|
FileThumbRequest {
|
||||||
|
uid: "volume~second".into(),
|
||||||
|
modified: 1_700_000_002,
|
||||||
|
name: "second.nef".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generation: 42,
|
||||||
|
};
|
||||||
|
let line = serde_json::to_string(&request).unwrap();
|
||||||
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_file_thumbnail_requests_default_the_name() {
|
||||||
|
let decoded: Request = serde_json::from_str(
|
||||||
|
r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let Request::FileThumbs { items, generation } = decoded else {
|
||||||
|
panic!("decoded the wrong request variant");
|
||||||
|
};
|
||||||
|
assert_eq!(generation, 42);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert!(items[0].name.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_build_requests_and_progress_roundtrip() {
|
||||||
|
let requests = [
|
||||||
|
Request::ReserveFileThumbGeneration,
|
||||||
|
Request::CancelFileThumbs { generation: 43 },
|
||||||
|
Request::StartThumbnailBuild {
|
||||||
|
path: "pictures/events".into(),
|
||||||
|
},
|
||||||
|
Request::CancelThumbnailBuild,
|
||||||
|
Request::ThumbnailBuildStatus,
|
||||||
|
];
|
||||||
|
for request in requests {
|
||||||
|
let line = serde_json::to_string(&request).unwrap();
|
||||||
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = Response::ThumbnailBuild {
|
||||||
|
status: ThumbnailBuildStatus {
|
||||||
|
running: true,
|
||||||
|
scanning: false,
|
||||||
|
path: "pictures/events".into(),
|
||||||
|
folders_scanned: 12,
|
||||||
|
images_found: 80,
|
||||||
|
completed: 25,
|
||||||
|
failed: 1,
|
||||||
|
message: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let line = serde_json::to_string(&response).unwrap();
|
||||||
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||||
|
|
||||||
|
let response = Response::FileThumbGeneration { generation: 44 };
|
||||||
|
let line = serde_json::to_string(&response).unwrap();
|
||||||
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||||
|
|
||||||
|
let response = Response::FileThumbsStale;
|
||||||
|
let line = serde_json::to_string(&response).unwrap();
|
||||||
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unified_search_request_and_response_roundtrip() {
|
fn unified_search_request_and_response_roundtrip() {
|
||||||
let request = Request::SearchV2 {
|
let request = Request::SearchV2 {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod search;
|
|||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
|
pub mod takeout;
|
||||||
|
|
||||||
pub use access::{Access, access_for, perm_bits};
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
pub use error::{CoreError, CoreResult, Error, Result};
|
||||||
|
|||||||
@@ -0,0 +1,887 @@
|
|||||||
|
//! Reading a Google Photos **Takeout** export: what is in the archives, which
|
||||||
|
//! album each photo belongs to, and when it was taken.
|
||||||
|
//!
|
||||||
|
//! This module is pure structure — it opens the archives' central directories
|
||||||
|
//! and reads the small JSON sidecars, never the media bytes. The import itself
|
||||||
|
//! (hashing, duplicate detection, upload, album creation) lives in the daemon;
|
||||||
|
//! everything here is offline-testable and has no Proton dependency.
|
||||||
|
//!
|
||||||
|
//! # What a Takeout export looks like
|
||||||
|
//!
|
||||||
|
//! Google splits an export into numbered zips (`takeout-20260817T...-001.zip`,
|
||||||
|
//! `-002.zip`, …) that are *independent* archives — no zip64 spanning — but a
|
||||||
|
//! photo and its JSON sidecar can land in different parts, and one album's
|
||||||
|
//! folder can be spread over several. So the scan treats the whole set as one
|
||||||
|
//! namespace keyed by the path inside the export:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Takeout/Google Photos/Iceland 2019/IMG_0042.jpg
|
||||||
|
//! Takeout/Google Photos/Iceland 2019/IMG_0042.jpg.supplemental-metadata.json
|
||||||
|
//! Takeout/Google Photos/Iceland 2019/metadata.json <- album title
|
||||||
|
//! Takeout/Google Photos/Photos from 2019/IMG_0043.jpg <- no album
|
||||||
|
//! Takeout/Google Photos/Trash/IMG_0044.jpg <- skipped
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! service name: the first component is dropped when it is the export root, the
|
||||||
|
//! second is the service, and the rest is `<folder>/<file>`. A folder is an
|
||||||
|
//! **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
|
||||||
|
//! name against [`TRASH_FOLDERS`] — the one place a localized name is
|
||||||
|
//! unavoidable, because a trashed photo is not marked as such anywhere else.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{CoreError, CoreResult};
|
||||||
|
|
||||||
|
/// Folder names Google uses for the trash bucket, lowercased. A Takeout export
|
||||||
|
/// only contains one when the user asked for it, and its photos are deleted —
|
||||||
|
/// importing them would resurrect them, so they are skipped.
|
||||||
|
///
|
||||||
|
/// Localized, and necessarily incomplete: a locale not listed here imports its
|
||||||
|
/// trash as ordinary timeline photos. Extend as reports come in rather than
|
||||||
|
/// guessing at transliterations.
|
||||||
|
pub const TRASH_FOLDERS: &[&str] = &[
|
||||||
|
"trash",
|
||||||
|
"bin",
|
||||||
|
"papierkorb",
|
||||||
|
"corbeille",
|
||||||
|
"papelera",
|
||||||
|
"papelera de reciclaje",
|
||||||
|
"cestino",
|
||||||
|
"lixeira",
|
||||||
|
"prullenbak",
|
||||||
|
"kosz",
|
||||||
|
"koš",
|
||||||
|
"skräpkorgen",
|
||||||
|
"papperskorg",
|
||||||
|
"papirkurv",
|
||||||
|
"roskakori",
|
||||||
|
"kuka",
|
||||||
|
"回收站",
|
||||||
|
"回收筒",
|
||||||
|
"ゴミ箱",
|
||||||
|
"휴지통",
|
||||||
|
"корзина",
|
||||||
|
"кошик",
|
||||||
|
"çöp kutusu",
|
||||||
|
"σκουπίδια",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Sidecar file names that describe the *folder* (its album title) rather than
|
||||||
|
/// one photo, lowercased. Google has renamed this file more than once.
|
||||||
|
const ALBUM_METADATA_NAMES: &[&str] = &[
|
||||||
|
"metadata.json",
|
||||||
|
"album-metadata.json",
|
||||||
|
"print-subscriptions.json",
|
||||||
|
"shared_album_comments.json",
|
||||||
|
"user-generated-memory-titles.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The suffix Google appends to a photo's sidecar, lowercased. Truncation means
|
||||||
|
/// only a *prefix* of it may survive (see [`strip_sidecar_suffix`]).
|
||||||
|
const SUPPLEMENTAL: &str = ".supplemental-metadata";
|
||||||
|
|
||||||
|
/// File extensions imported as photos or videos. Anything else in the export —
|
||||||
|
/// `archive_browser.html`, `.csv` activity logs, `print-subscriptions.json` —
|
||||||
|
/// is not media and is ignored.
|
||||||
|
const MEDIA_EXTENSIONS: &[(&str, &str)] = &[
|
||||||
|
("jpg", "image/jpeg"),
|
||||||
|
("jpeg", "image/jpeg"),
|
||||||
|
("png", "image/png"),
|
||||||
|
("gif", "image/gif"),
|
||||||
|
("webp", "image/webp"),
|
||||||
|
("bmp", "image/bmp"),
|
||||||
|
("tif", "image/tiff"),
|
||||||
|
("tiff", "image/tiff"),
|
||||||
|
("heic", "image/heic"),
|
||||||
|
("heif", "image/heif"),
|
||||||
|
("avif", "image/avif"),
|
||||||
|
("dng", "image/x-adobe-dng"),
|
||||||
|
("cr2", "image/x-canon-cr2"),
|
||||||
|
("cr3", "image/x-canon-cr3"),
|
||||||
|
("nef", "image/x-nikon-nef"),
|
||||||
|
("arw", "image/x-sony-arw"),
|
||||||
|
("orf", "image/x-olympus-orf"),
|
||||||
|
("rw2", "image/x-panasonic-rw2"),
|
||||||
|
("raf", "image/x-fuji-raf"),
|
||||||
|
("mp4", "video/mp4"),
|
||||||
|
("m4v", "video/x-m4v"),
|
||||||
|
("mov", "video/quicktime"),
|
||||||
|
("avi", "video/x-msvideo"),
|
||||||
|
("mkv", "video/x-matroska"),
|
||||||
|
("webm", "video/webm"),
|
||||||
|
("3gp", "video/3gpp"),
|
||||||
|
("mts", "video/mp2t"),
|
||||||
|
("m2ts", "video/mp2t"),
|
||||||
|
("mpg", "video/mpeg"),
|
||||||
|
("mpeg", "video/mpeg"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The media type for `name`'s extension, or `None` when it is not media this
|
||||||
|
/// import handles.
|
||||||
|
pub fn media_type_of(name: &str) -> Option<&'static str> {
|
||||||
|
let ext = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())?
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
MEDIA_EXTENSIONS
|
||||||
|
.iter()
|
||||||
|
.find(|(candidate, _)| *candidate == ext)
|
||||||
|
.map(|(_, media_type)| *media_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a photo sits in the export, which decides whether it joins an album.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Bucket {
|
||||||
|
/// A named album folder — the photo joins that album *and* the timeline.
|
||||||
|
Album(String),
|
||||||
|
/// A year folder (`Photos from 2019`) or the export root: timeline only.
|
||||||
|
Timeline,
|
||||||
|
/// The archive bucket: timeline only, like Google shows it.
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One media file found in the export.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TakeoutPhoto {
|
||||||
|
/// Index into the archive list the scan was given.
|
||||||
|
pub archive: usize,
|
||||||
|
/// The entry's full path inside that archive — how the importer reopens it.
|
||||||
|
pub entry: String,
|
||||||
|
/// The name to upload under: the sidecar's `title` when it has one (Google
|
||||||
|
/// mangles long names on disk but keeps the original there), else the file
|
||||||
|
/// name on disk.
|
||||||
|
pub name: String,
|
||||||
|
/// Uncompressed size in bytes, from the zip's central directory.
|
||||||
|
pub size: u64,
|
||||||
|
/// Media type from the extension.
|
||||||
|
pub media_type: String,
|
||||||
|
/// Capture time in epoch seconds, from `photoTakenTime` (falling back to
|
||||||
|
/// `creationTime`). `None` when the photo has no sidecar — the upload then
|
||||||
|
/// defaults it to now, as the SDK does.
|
||||||
|
pub capture_time: Option<i64>,
|
||||||
|
/// The sidecar's `favorited` flag.
|
||||||
|
pub favorite: bool,
|
||||||
|
/// Which bucket the file was found in.
|
||||||
|
pub bucket: Bucket,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one scan found, ready to be turned into an import plan.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TakeoutScan {
|
||||||
|
/// Every importable media file, in archive then entry order.
|
||||||
|
pub photos: Vec<TakeoutPhoto>,
|
||||||
|
/// Album names in the order first seen, so the importer creates them in a
|
||||||
|
/// stable order.
|
||||||
|
pub albums: Vec<String>,
|
||||||
|
/// Media files skipped because they sit in the trash bucket.
|
||||||
|
pub skipped_trashed: usize,
|
||||||
|
/// Entries that were neither media nor a sidecar (`archive_browser.html`,
|
||||||
|
/// activity `.csv`s, `.mp` motion-photo parts).
|
||||||
|
pub skipped_other: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TakeoutScan {
|
||||||
|
/// Total bytes of media to be read, before duplicate detection.
|
||||||
|
pub fn total_bytes(&self) -> u64 {
|
||||||
|
self.photos.iter().map(|p| p.size).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google's per-photo sidecar. Only the fields the import uses are read; the
|
||||||
|
/// file also carries geo data, people tags and view counts.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct PhotoSidecar {
|
||||||
|
#[serde(default)]
|
||||||
|
title: Option<String>,
|
||||||
|
#[serde(rename = "photoTakenTime", default)]
|
||||||
|
photo_taken_time: Option<SidecarTime>,
|
||||||
|
#[serde(rename = "creationTime", default)]
|
||||||
|
creation_time: Option<SidecarTime>,
|
||||||
|
#[serde(default)]
|
||||||
|
favorited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sidecar timestamp: epoch seconds, as a *string*.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SidecarTime {
|
||||||
|
#[serde(default)]
|
||||||
|
timestamp: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SidecarTime {
|
||||||
|
fn epoch_seconds(&self) -> Option<i64> {
|
||||||
|
self.timestamp.as_ref()?.parse().ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder-level sidecar. `title` is the album name as the user typed it,
|
||||||
|
/// which is what the folder name is derived (and sometimes truncated) from.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AlbumSidecar {
|
||||||
|
#[serde(default)]
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry as the scan sees it, independent of where it came from.
|
||||||
|
struct RawEntry {
|
||||||
|
archive: usize,
|
||||||
|
entry: String,
|
||||||
|
/// `<folder>/<file>` with the export root and service folder removed.
|
||||||
|
relative: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawEntry {
|
||||||
|
/// The folder component of `relative`, or `""` at the service root.
|
||||||
|
fn folder(&self) -> &str {
|
||||||
|
match self.relative.rfind('/') {
|
||||||
|
Some(index) => &self.relative[..index],
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name component of `relative`.
|
||||||
|
fn file_name(&self) -> &str {
|
||||||
|
match self.relative.rfind('/') {
|
||||||
|
Some(index) => &self.relative[index + 1..],
|
||||||
|
None => &self.relative,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan a set of Takeout zip archives into one plan.
|
||||||
|
///
|
||||||
|
/// The archives are treated as one export: an album folder split across parts
|
||||||
|
/// 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
|
||||||
|
/// this stays fast on a 200 GB export.
|
||||||
|
pub fn scan_archives(archives: &[PathBuf]) -> CoreResult<TakeoutScan> {
|
||||||
|
let mut entries: Vec<RawEntry> = Vec::new();
|
||||||
|
let mut sidecars: HashMap<String, Vec<u8>> = HashMap::new();
|
||||||
|
|
||||||
|
for (index, path) in archives.iter().enumerate() {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.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| {
|
||||||
|
CoreError::invalid(format!("{} is not a readable zip: {e}", path.display()))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for position in 0..zip.len() {
|
||||||
|
let mut entry = zip.by_index(position).map_err(|e| {
|
||||||
|
CoreError::invalid(format!("{}: unreadable entry: {e}", path.display()))
|
||||||
|
})?;
|
||||||
|
if entry.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `enclosed_name` refuses paths that escape the archive root, which
|
||||||
|
// is exactly the check we want before using one as a key.
|
||||||
|
let Some(name) = entry.enclosed_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = name.to_string_lossy().replace('\\', "/");
|
||||||
|
let Some(relative) = strip_export_prefix(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let size = entry.size();
|
||||||
|
|
||||||
|
// Sidecars are read now, while the entry is open: they are a few
|
||||||
|
// hundred bytes each and reopening the archive per photo later would
|
||||||
|
// be a second full pass.
|
||||||
|
if relative.to_ascii_lowercase().ends_with(".json") {
|
||||||
|
let mut body = Vec::with_capacity(size.min(64 * 1024) as usize);
|
||||||
|
if entry.read_to_end(&mut body).is_ok() {
|
||||||
|
sidecars.insert(relative.to_string(), body);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(RawEntry {
|
||||||
|
archive: index,
|
||||||
|
entry: name.clone(),
|
||||||
|
relative: relative.to_string(),
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(assemble(entries, &sidecars))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the scan from raw entries plus the sidecar bodies keyed by their path
|
||||||
|
/// inside the export. Split out from [`scan_archives`] so the matching rules can
|
||||||
|
/// be tested without building zip fixtures.
|
||||||
|
fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> TakeoutScan {
|
||||||
|
// Album titles per folder, from the folder-level sidecars.
|
||||||
|
let mut album_titles: HashMap<String, String> = HashMap::new();
|
||||||
|
for (path, body) in sidecars {
|
||||||
|
let (folder, file) = split_folder(path);
|
||||||
|
if !ALBUM_METADATA_NAMES.contains(&file.to_ascii_lowercase().as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(album) = serde_json::from_slice::<AlbumSidecar>(body)
|
||||||
|
&& let Some(title) = album.title.filter(|t| !t.trim().is_empty())
|
||||||
|
{
|
||||||
|
album_titles.insert(folder.to_string(), title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-photo sidecars, indexed by folder so a truncated name only ever
|
||||||
|
// matches within its own folder.
|
||||||
|
let mut per_folder_sidecars: HashMap<String, BTreeMap<String, &Vec<u8>>> = HashMap::new();
|
||||||
|
for (path, body) in sidecars {
|
||||||
|
let (folder, file) = split_folder(path);
|
||||||
|
if ALBUM_METADATA_NAMES.contains(&file.to_ascii_lowercase().as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
per_folder_sidecars
|
||||||
|
.entry(folder.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(file.to_string(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut scan = TakeoutScan::default();
|
||||||
|
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
||||||
|
|
||||||
|
for raw in &entries {
|
||||||
|
let folder = raw.folder().to_string();
|
||||||
|
let file_name = raw.file_name().to_string();
|
||||||
|
|
||||||
|
if is_trash_folder(&folder) {
|
||||||
|
if media_type_of(&file_name).is_some() {
|
||||||
|
scan.skipped_trashed += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(media_type) = media_type_of(&file_name) else {
|
||||||
|
scan.skipped_other += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidecar = per_folder_sidecars
|
||||||
|
.get(&folder)
|
||||||
|
.and_then(|in_folder| find_sidecar(&file_name, in_folder))
|
||||||
|
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let bucket = match album_titles.get(&folder) {
|
||||||
|
Some(title) => Bucket::Album(title.clone()),
|
||||||
|
None if is_archive_folder(&folder) => Bucket::Archived,
|
||||||
|
None => Bucket::Timeline,
|
||||||
|
};
|
||||||
|
if let Bucket::Album(title) = &bucket
|
||||||
|
&& seen_albums.insert(title.clone(), ()).is_none()
|
||||||
|
{
|
||||||
|
scan.albums.push(title.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sidecar's `title` is the name Google had before it mangled the
|
||||||
|
// on-disk one (truncation, `(1)` suffixes, character replacement), so it
|
||||||
|
// is the better name to upload under — but only when it still looks like
|
||||||
|
// the same file, i.e. the extension agrees.
|
||||||
|
let name = sidecar
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
|
||||||
|
.unwrap_or(&file_name)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
scan.photos.push(TakeoutPhoto {
|
||||||
|
archive: raw.archive,
|
||||||
|
entry: raw.entry.clone(),
|
||||||
|
name,
|
||||||
|
size: raw.size,
|
||||||
|
media_type: media_type.to_string(),
|
||||||
|
capture_time: sidecar
|
||||||
|
.photo_taken_time
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SidecarTime::epoch_seconds)
|
||||||
|
.or_else(|| {
|
||||||
|
sidecar
|
||||||
|
.creation_time
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SidecarTime::epoch_seconds)
|
||||||
|
}),
|
||||||
|
favorite: sidecar.favorited,
|
||||||
|
bucket,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the export root (`Takeout/`) and the localized service folder from a
|
||||||
|
/// path inside the archive, leaving `<folder>/<file>` or `<file>`.
|
||||||
|
///
|
||||||
|
/// `None` for a path with no service folder at all (the export's own
|
||||||
|
/// `archive_browser.html`), which carries nothing to import.
|
||||||
|
fn strip_export_prefix(path: &str) -> Option<&str> {
|
||||||
|
let mut rest = path;
|
||||||
|
// The root is usually `Takeout/`, but an export unzipped and re-zipped by
|
||||||
|
// the user may not have it — so the root is dropped only when a service
|
||||||
|
// folder follows it, which the component count decides.
|
||||||
|
let components = rest.split('/').count();
|
||||||
|
if components >= 3 {
|
||||||
|
rest = rest.split_once('/')?.1;
|
||||||
|
}
|
||||||
|
// Drop the service folder (`Google Photos`, `Google Fotos`, …).
|
||||||
|
let (_service, tail) = rest.split_once('/')?;
|
||||||
|
Some(tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `<folder>/<file>` into its parts; the folder is `""` at the root.
|
||||||
|
fn split_folder(path: &str) -> (&str, &str) {
|
||||||
|
match path.rfind('/') {
|
||||||
|
Some(index) => (&path[..index], &path[index + 1..]),
|
||||||
|
None => ("", path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_trash_folder(folder: &str) -> bool {
|
||||||
|
let name = folder.rsplit('/').next().unwrap_or(folder).to_lowercase();
|
||||||
|
TRASH_FOLDERS.contains(&name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google's archive bucket, which is not an album but is not the plain timeline
|
||||||
|
/// either. Only the English name is matched; a missed locale simply imports as
|
||||||
|
/// [`Bucket::Timeline`], which is the same upload either way.
|
||||||
|
fn is_archive_folder(folder: &str) -> bool {
|
||||||
|
let name = folder.rsplit('/').next().unwrap_or(folder).to_lowercase();
|
||||||
|
name == "archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two file names end in the same extension, case-insensitively.
|
||||||
|
fn same_extension(a: &str, b: &str) -> bool {
|
||||||
|
let ext = |name: &str| {
|
||||||
|
Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
};
|
||||||
|
ext(a) == ext(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a sidecar's `.json` and any `.supplemental-metadata` suffix, including
|
||||||
|
/// the *truncated* forms Google produces.
|
||||||
|
///
|
||||||
|
/// Google caps the sidecar's file name at 51 characters, so a long photo name
|
||||||
|
/// leaves only a prefix of the suffix behind — `IMG_1234.jpg.supplemental-me`,
|
||||||
|
/// `…-metad`, or nothing at all. Any non-empty prefix of the suffix is stripped;
|
||||||
|
/// a bare `.json` sidecar loses only that.
|
||||||
|
fn strip_sidecar_suffix(sidecar: &str) -> String {
|
||||||
|
let stem = sidecar
|
||||||
|
.strip_suffix(".json")
|
||||||
|
.or_else(|| sidecar.strip_suffix(".JSON"))
|
||||||
|
.unwrap_or(sidecar);
|
||||||
|
// Longest match first so `.supplemental-metadata` wins over `.s`.
|
||||||
|
for length in (2..=SUPPLEMENTAL.len()).rev() {
|
||||||
|
let candidate = &SUPPLEMENTAL[..length];
|
||||||
|
if let Some(head) = stem.strip_suffix(candidate) {
|
||||||
|
return head.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stem.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a trailing `(N)` from after the extension to before it: Google writes
|
||||||
|
/// the sidecar of `IMG_1234(1).jpg` as `IMG_1234.jpg(1).json`.
|
||||||
|
fn unswap_duplicate_marker(stem: &str) -> Option<String> {
|
||||||
|
let marker_start = stem.rfind('(')?;
|
||||||
|
let marker = &stem[marker_start..];
|
||||||
|
if !marker.ends_with(')')
|
||||||
|
|| !marker[1..marker.len() - 1]
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let head = &stem[..marker_start];
|
||||||
|
let dot = head.rfind('.')?;
|
||||||
|
Some(format!("{}{}{}", &head[..dot], marker, &head[dot..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
|
||||||
|
///
|
||||||
|
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, then a
|
||||||
|
/// prefix match for the case where truncation cut into the photo name itself.
|
||||||
|
fn find_sidecar<'a>(
|
||||||
|
file_name: &str,
|
||||||
|
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
||||||
|
) -> Option<&'a Vec<u8>> {
|
||||||
|
let mut prefix_match: Option<&'a Vec<u8>> = None;
|
||||||
|
let mut prefix_len = 0usize;
|
||||||
|
|
||||||
|
for (sidecar_name, body) in in_folder {
|
||||||
|
let stem = strip_sidecar_suffix(sidecar_name);
|
||||||
|
if stem == file_name {
|
||||||
|
return Some(body);
|
||||||
|
}
|
||||||
|
if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) {
|
||||||
|
return Some(body);
|
||||||
|
}
|
||||||
|
// Truncated: the stem is a prefix of the photo's name. Keep the longest
|
||||||
|
// such prefix — a folder holding `IMG_1.jpg` and `IMG_12.jpg` must not
|
||||||
|
// let the shorter stem claim the longer photo.
|
||||||
|
if !stem.is_empty() && file_name.starts_with(&stem) && stem.len() > prefix_len {
|
||||||
|
prefix_len = stem.len();
|
||||||
|
prefix_match = Some(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix_match
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(archive: usize, path: &str, size: u64) -> RawEntry {
|
||||||
|
RawEntry {
|
||||||
|
archive,
|
||||||
|
entry: path.to_string(),
|
||||||
|
relative: strip_export_prefix(path).unwrap().to_string(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidecar(map: &mut HashMap<String, Vec<u8>>, path: &str, body: &str) {
|
||||||
|
let relative = strip_export_prefix(path).unwrap().to_string();
|
||||||
|
map.insert(relative, body.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_export_root_and_localized_service_folder() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_export_prefix("Takeout/Google Fotos/Island 2019/IMG_1.jpg"),
|
||||||
|
Some("Island 2019/IMG_1.jpg")
|
||||||
|
);
|
||||||
|
// Already unwrapped by the user: no `Takeout/` root left.
|
||||||
|
assert_eq!(
|
||||||
|
strip_export_prefix("Google Photos/IMG_1.jpg"),
|
||||||
|
Some("IMG_1.jpg")
|
||||||
|
);
|
||||||
|
// Nothing below a service folder is nothing to import.
|
||||||
|
assert_eq!(strip_export_prefix("archive_browser.html"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_folder_becomes_an_album_year_folder_does_not() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Iceland 2019/metadata.json",
|
||||||
|
r#"{"title":"Iceland 2019"}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Iceland 2019/IMG_1.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_2.jpg", 20),
|
||||||
|
],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.albums, vec!["Iceland 2019"]);
|
||||||
|
assert_eq!(scan.photos[0].bucket, Bucket::Album("Iceland 2019".into()));
|
||||||
|
assert_eq!(scan.photos[1].bucket, Bucket::Timeline);
|
||||||
|
assert_eq!(scan.total_bytes(), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trashed_photos_are_skipped_and_counted() {
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Papierkorb/IMG_1.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Photos/Bin/IMG_2.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_3.jpg", 10),
|
||||||
|
],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.skipped_trashed, 2);
|
||||||
|
assert_eq!(scan.photos.len(), 1);
|
||||||
|
assert_eq!(scan.photos[0].name, "IMG_3.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_media_entries_are_ignored() {
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Photos/Photos from 2019/notes.txt", 10),
|
||||||
|
],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos.len(), 1);
|
||||||
|
assert_eq!(scan.skipped_other, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_supplies_capture_time_and_favourite() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json",
|
||||||
|
r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1560000000"},"favorited":true}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_560_000_000));
|
||||||
|
assert!(scan.photos[0].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creation_time_is_the_fallback_capture_time() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.json",
|
||||||
|
r#"{"creationTime":{"timestamp":"1500000000"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_supplemental_suffixes_still_match() {
|
||||||
|
// Google caps the sidecar name at 51 chars, cutting into the suffix.
|
||||||
|
assert_eq!(
|
||||||
|
strip_sidecar_suffix("IMG_1.jpg.supplemental-me.json"),
|
||||||
|
"IMG_1.jpg"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
strip_sidecar_suffix("IMG_1.jpg.supplemental-metadata.json"),
|
||||||
|
"IMG_1.jpg"
|
||||||
|
);
|
||||||
|
assert_eq!(strip_sidecar_suffix("IMG_1.jpg.json"), "IMG_1.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_marker_after_the_extension_is_moved_back() {
|
||||||
|
assert_eq!(
|
||||||
|
unswap_duplicate_marker("IMG_1.jpg(1)").as_deref(),
|
||||||
|
Some("IMG_1(1).jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(unswap_duplicate_marker("IMG_1.jpg"), None);
|
||||||
|
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg(1).json",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"1600000000"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1(1).jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_600_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.json",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
||||||
|
);
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_12.json",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_12345.jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/a_very_long_name_that_google_.jpg.json",
|
||||||
|
r#"{"title":"a_very_long_name_that_google_truncated.jpg"}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/a_very_long_name_that_google_.jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scan.photos[0].name,
|
||||||
|
"a_very_long_name_that_google_truncated.jpg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_with_a_different_extension_is_not_trusted() {
|
||||||
|
// Motion photos name their `.MP` part after the `.jpg`; taking the title
|
||||||
|
// verbatim would upload the video under an image name.
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.mp4.json",
|
||||||
|
r#"{"title":"IMG_1.jpg"}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Photos/Photos from 2019/IMG_1.mp4",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].name, "IMG_1.mp4");
|
||||||
|
assert_eq!(scan.photos[0].media_type, "video/mp4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_album_split_across_archives_stays_one_album() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Photos/Iceland 2019/metadata.json",
|
||||||
|
r#"{"title":"Iceland 2019"}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Photos/Iceland 2019/IMG_1.jpg", 10),
|
||||||
|
entry(1, "Takeout/Google Photos/Iceland 2019/IMG_2.jpg", 10),
|
||||||
|
],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.albums, vec!["Iceland 2019"]);
|
||||||
|
assert_eq!(scan.photos.len(), 2);
|
||||||
|
assert_eq!(scan.photos[1].archive, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_bucket_imports_to_the_timeline() {
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(0, "Takeout/Google Photos/Archive/IMG_1.jpg", 10)],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos[0].bucket, Bucket::Archived);
|
||||||
|
assert!(scan.albums.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a zip holding `files` and return its path. No `tempfile`
|
||||||
|
/// dev-dependency: the daemon crates deliberately carry none, and one
|
||||||
|
/// uniquely named file under the temp dir is all this needs.
|
||||||
|
fn write_zip(tag: &str, files: &[(&str, &[u8])]) -> PathBuf {
|
||||||
|
use std::io::Write;
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-takeout-test-{tag}-{}.zip",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let file = std::fs::File::create(&path).unwrap();
|
||||||
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
|
let options: zip::write::FileOptions<'_, ()> =
|
||||||
|
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||||
|
for (name, body) in files {
|
||||||
|
zip.start_file(*name, options).unwrap();
|
||||||
|
zip.write_all(body).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scans_a_two_part_export_where_the_sidecar_is_in_the_other_part() {
|
||||||
|
// The case that makes the whole set one scan: part 1 has the photo,
|
||||||
|
// part 2 has its metadata and the album title.
|
||||||
|
let first = write_zip(
|
||||||
|
"part1",
|
||||||
|
&[(
|
||||||
|
"Takeout/Google Photos/Iceland 2019/IMG_1.jpg",
|
||||||
|
b"the photo bytes",
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
let second = write_zip(
|
||||||
|
"part2",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"Takeout/Google Photos/Iceland 2019/metadata.json",
|
||||||
|
br#"{"title":"Iceland 2019"}"#.as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Takeout/Google Photos/Iceland 2019/IMG_1.jpg.supplemental-metadata.json",
|
||||||
|
br#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1560000000"}}"#
|
||||||
|
.as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Takeout/Google Photos/Trash/IMG_9.jpg",
|
||||||
|
b"deleted".as_slice(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let scan = scan_archives(&[first.clone(), second.clone()]).unwrap();
|
||||||
|
let _ = std::fs::remove_file(&first);
|
||||||
|
let _ = std::fs::remove_file(&second);
|
||||||
|
|
||||||
|
assert_eq!(scan.photos.len(), 1);
|
||||||
|
assert_eq!(scan.photos[0].name, "IMG_1.jpg");
|
||||||
|
assert_eq!(scan.photos[0].archive, 0, "the photo is in part 1");
|
||||||
|
assert_eq!(scan.photos[0].size, "the photo bytes".len() as u64);
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_560_000_000));
|
||||||
|
assert_eq!(scan.photos[0].bucket, Bucket::Album("Iceland 2019".into()));
|
||||||
|
assert_eq!(scan.albums, vec!["Iceland 2019"]);
|
||||||
|
assert_eq!(scan.skipped_trashed, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_is_not_a_zip_is_a_readable_error() {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-takeout-not-a-zip-{}", std::process::id()));
|
||||||
|
std::fs::write(&path, b"not a zip at all").unwrap();
|
||||||
|
let error = scan_archives(std::slice::from_ref(&path)).unwrap_err();
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
assert!(
|
||||||
|
error.message.contains("not a readable zip"),
|
||||||
|
"unexpected message: {}",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "FUSE on-demand filesystem for Proton Drive"
|
description = "FUSE on-demand filesystem for Proton Drive"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pdfs-core.workspace = true
|
pdfs-core.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -16,6 +20,7 @@ tracing.workspace = true
|
|||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
notify = "8"
|
notify = "8"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write as _};
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
/// the mount root.
|
/// the mount root.
|
||||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
if p.is_absolute() {
|
let relative = if p.is_absolute() {
|
||||||
p.strip_prefix(mountpoint)
|
p.strip_prefix(mountpoint)
|
||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||||
} else {
|
} else {
|
||||||
Ok(p.to_path_buf())
|
Ok(p.to_path_buf())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
if relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(relative)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +329,34 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
items: core.photo_thumbs(&parsed),
|
items: core.photo_thumbs(&parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
||||||
|
match core.file_thumbs(&items, generation) {
|
||||||
|
Some(items) => CtlResponse::Thumbs { items },
|
||||||
|
None => CtlResponse::FileThumbsStale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration {
|
||||||
|
generation: core.reserve_file_thumb_generation(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CancelFileThumbs { generation }) => {
|
||||||
|
core.cancel_file_thumbs(generation);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "thumbnail requests cancelled".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.start_thumbnail_build(rel) {
|
||||||
|
Ok(status) => CtlResponse::ThumbnailBuild { status },
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
},
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild {
|
||||||
|
status: core.cancel_thumbnail_build(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
|
||||||
|
status: core.thumbnail_build_status(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||||
Some(u) => match core.open_photo(&u) {
|
Some(u) => match core.open_photo(&u) {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
Ok(p) => CtlResponse::FilePath {
|
||||||
@@ -329,6 +366,51 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
// The scan alone walks every archive's central directory, so even the
|
||||||
|
// dry run goes to a background thread and is read back through
|
||||||
|
// `ImportStatus` rather than answered inline.
|
||||||
|
Ok(CtlRequest::ImportTakeout { archives, dry_run }) => {
|
||||||
|
if archives.is_empty() {
|
||||||
|
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
|
||||||
|
} else {
|
||||||
|
let paths: Vec<PathBuf> = archives.into_iter().map(PathBuf::from).collect();
|
||||||
|
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) => {
|
||||||
|
let (running, summary) = crate::takeout::import_status();
|
||||||
|
CtlResponse::ImportStatus { running, summary }
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::CancelImport) => {
|
||||||
|
if crate::takeout::cancel_import() {
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "stopping the import".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CtlResponse::error(CoreError::invalid("no import is running"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::UploadPhoto {
|
Ok(CtlRequest::UploadPhoto {
|
||||||
name,
|
name,
|
||||||
media_type,
|
media_type,
|
||||||
@@ -1176,4 +1258,29 @@ mod request_limit_tests {
|
|||||||
std::io::ErrorKind::InvalidData
|
std::io::ErrorKind::InvalidData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_paths_inside_the_mountpoint() {
|
||||||
|
let mountpoint = Path::new("/mnt/proton");
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "Photos/2026").unwrap(),
|
||||||
|
Path::new("Photos/2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(),
|
||||||
|
Path::new("Photos/2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "/mnt/proton").unwrap(),
|
||||||
|
Path::new("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_paths_that_escape_the_mountpoint() {
|
||||||
|
let mountpoint = Path::new("/mnt/proton");
|
||||||
|
assert!(rel_to_mount(mountpoint, "../escape").is_err());
|
||||||
|
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
||||||
|
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+92
-14
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||||
TransferDirection,
|
ThumbnailBuildStatus, TransferDirection,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{
|
use pdfs_core::db::{
|
||||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||||
@@ -96,6 +96,7 @@ mod shutdown;
|
|||||||
mod state;
|
mod state;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
mod takeout;
|
||||||
mod transfers;
|
mod transfers;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod r#virtual;
|
mod r#virtual;
|
||||||
@@ -373,6 +374,45 @@ impl SearchRoots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Negative thumbnail knowledge has two different authorities. A remote miss
|
||||||
|
/// only says Proton has no thumbnail to download; it must never suppress local
|
||||||
|
/// generation. A local miss says this exact file revision was successfully
|
||||||
|
/// inspected and could not be decoded. Both maps are bounded independently.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ThumbnailMissCaches {
|
||||||
|
remote: HashMap<(NodeUid, i32), i64>,
|
||||||
|
local: HashMap<(NodeUid, i32), i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailMissCaches {
|
||||||
|
fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||||
|
self.remote.get(key) == Some(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||||
|
self.local.get(key) == Some(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||||
|
Self::remember(&mut self.remote, key, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||||
|
Self::remember(&mut self.local, key, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_local(&mut self, key: &(NodeUid, i32)) {
|
||||||
|
self.local.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) {
|
||||||
|
if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) {
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
|
map.insert(key, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
||||||
/// root itself resolves to the empty path; an empty root is the mount itself
|
/// root itself resolves to the empty path; an empty root is the mount itself
|
||||||
/// and covers everything.
|
/// and covers everything.
|
||||||
@@ -510,8 +550,22 @@ struct Core {
|
|||||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||||
/// twice.
|
/// twice.
|
||||||
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by
|
/// Global permit pool for locally generated thumbnails. The old per-request
|
||||||
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
|
/// limit multiplied when the GUI sent several batches, allowing dozens of
|
||||||
|
/// full-size images to download at once.
|
||||||
|
thumb_gen_budget: Arc<tokio::sync::Semaphore>,
|
||||||
|
/// Current ordinary-file listing generation and a wake-up for tasks waiting
|
||||||
|
/// on a permit or a download when that listing is abandoned.
|
||||||
|
file_thumb_generation: Arc<AtomicU64>,
|
||||||
|
file_thumb_cancel: Arc<tokio::sync::Notify>,
|
||||||
|
/// Progress of the explicit recursive build started from the Files toolbar.
|
||||||
|
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
|
||||||
|
/// Cancellation for the explicit recursive build. Separate from ordinary
|
||||||
|
/// listing cancellation because the two jobs have independent lifetimes.
|
||||||
|
thumbnail_build_cancelled: Arc<AtomicBool>,
|
||||||
|
thumbnail_build_cancel: Arc<tokio::sync::Notify>,
|
||||||
|
/// Negative thumbnail knowledge, separated by whether the remote lacked a
|
||||||
|
/// thumbnail or local decoding proved this revision unsupported.
|
||||||
///
|
///
|
||||||
/// Absence has to be cached or it costs a round trip every time it is asked
|
/// Absence has to be cached or it costs a round trip every time it is asked
|
||||||
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
||||||
@@ -519,7 +573,7 @@ struct Core {
|
|||||||
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
||||||
/// is the validity tag — a new revision may well have a thumbnail — matching
|
/// is the validity tag — a new revision may well have a thumbnail — matching
|
||||||
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
||||||
no_thumbnail: Arc<Mutex<HashMap<(NodeUid, i32), i64>>>,
|
thumbnail_misses: Arc<Mutex<ThumbnailMissCaches>>,
|
||||||
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
||||||
///
|
///
|
||||||
/// `df` and the free-space preflight of every file manager and installer ask
|
/// `df` and the free-space preflight of every file manager and installer ask
|
||||||
@@ -3579,7 +3633,7 @@ impl Core {
|
|||||||
// "This file has no thumbnail" is an answer worth remembering: without
|
// "This file has no thumbnail" is an answer worth remembering: without
|
||||||
// it every listing pays a round trip per file to be told nothing (B5).
|
// it every listing pays a round trip per file to be told nothing (B5).
|
||||||
let key = (uid.clone(), ttype.as_i32());
|
let key = (uid.clone(), ttype.as_i32());
|
||||||
if self.no_thumbnail.lock().get(&key) == Some(&mtime) {
|
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let bytes = self
|
let bytes = self
|
||||||
@@ -3596,15 +3650,7 @@ impl Core {
|
|||||||
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut misses = self.no_thumbnail.lock();
|
self.thumbnail_misses.lock().remember_remote(key, mtime);
|
||||||
// Bounded by clearing rather than by LRU: the entries are two
|
|
||||||
// words each, the cap is far above any plausible working set,
|
|
||||||
// and re-learning a miss costs one round trip. Not worth a
|
|
||||||
// second data structure to order them.
|
|
||||||
if misses.len() >= MAX_THUMBNAIL_MISSES {
|
|
||||||
misses.clear();
|
|
||||||
}
|
|
||||||
misses.insert(key, mtime);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
@@ -7545,6 +7591,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod thumbnail_miss_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_misses_never_suppress_local_generation() {
|
||||||
|
let uid = parse_uid("volume~node").expect("uid");
|
||||||
|
let key = (uid, ThumbnailType::Thumbnail.as_i32());
|
||||||
|
let mut misses = ThumbnailMissCaches::default();
|
||||||
|
|
||||||
|
misses.remember_remote(key.clone(), 42);
|
||||||
|
assert!(misses.remote_contains(&key, 42));
|
||||||
|
assert!(!misses.local_contains(&key, 42));
|
||||||
|
|
||||||
|
misses.remember_local(key.clone(), 42);
|
||||||
|
assert!(misses.local_contains(&key, 42));
|
||||||
|
misses.forget_local(&key);
|
||||||
|
assert!(!misses.local_contains(&key, 42));
|
||||||
|
assert!(misses.remote_contains(&key, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_miss_cache_is_bounded_on_gui_only_paths() {
|
||||||
|
let mut misses = ThumbnailMissCaches::default();
|
||||||
|
for index in 0..=MAX_THUMBNAIL_MISSES {
|
||||||
|
let uid = parse_uid(&format!("volume~node-{index}")).expect("uid");
|
||||||
|
misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42);
|
||||||
|
}
|
||||||
|
assert_eq!(misses.local.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod search_root_tests {
|
mod search_root_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -389,7 +389,15 @@ pub fn mount(
|
|||||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||||
|
super::photos::THUMB_GEN_CONCURRENCY,
|
||||||
|
)),
|
||||||
|
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||||
|
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||||
quota: Arc::new(Mutex::new(None)),
|
quota: Arc::new(Mutex::new(None)),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
size_waiters: Arc::new(Default::default()),
|
size_waiters: Arc::new(Default::default()),
|
||||||
|
|||||||
+982
-38
File diff suppressed because it is too large.
Load diff
@@ -609,6 +609,7 @@ impl Core {
|
|||||||
items.push(SharedItem {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
|
modified: n.modification_time,
|
||||||
name: n.name,
|
name: n.name,
|
||||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||||
member_count: members,
|
member_count: members,
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
//! Importing a Google Photos Takeout export into Proton Photos.
|
||||||
|
//!
|
||||||
|
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
|
||||||
|
//! talks to Proton. One import runs on its own thread and moves through four
|
||||||
|
//! phases, reporting into the transfer registry as a job so the GUI's transfer
|
||||||
|
//! list shows it without a bespoke widget:
|
||||||
|
//!
|
||||||
|
//! 1. **Scan** — read the archives' central directories and sidecars.
|
||||||
|
//! 2. **Deduplicate against the account** — ask the photos volume which of the
|
||||||
|
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
|
||||||
|
//! those files, then confirm with the content digest. A library that is
|
||||||
|
//! already half-synced by Proton Photos on a phone therefore costs one cheap
|
||||||
|
//! request per 150 names and reads no media at all for the misses.
|
||||||
|
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
|
||||||
|
//! no extraction to disk, so the import needs no scratch space.
|
||||||
|
//! 4. **Albums** — create the albums the export names that the account lacks,
|
||||||
|
//! then add every photo of each album, the ones that were already on the
|
||||||
|
//! account included.
|
||||||
|
//!
|
||||||
|
//! Re-running an interrupted import is the resume story: phase 2 finds
|
||||||
|
//! everything the previous run uploaded and skips it. Nothing is persisted
|
||||||
|
//! between runs.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// The one import this daemon may be running, and how the last one ended.
|
||||||
|
///
|
||||||
|
/// Process-global rather than a [`Core`] field because it must be *one* import
|
||||||
|
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
|
||||||
|
/// hold one), and because a front-end asking for the result after the import
|
||||||
|
/// finished must still find it.
|
||||||
|
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ImportState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<ImportSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an import is running, and the last one's report.
|
||||||
|
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
|
||||||
|
let state = IMPORT.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running import to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_import() -> bool {
|
||||||
|
match &IMPORT.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to import, after the copies Google keeps per album are folded into
|
||||||
|
/// a single upload that joins several albums.
|
||||||
|
struct Planned {
|
||||||
|
photo: TakeoutPhoto,
|
||||||
|
/// Every album this photo belongs to, by export title.
|
||||||
|
albums: Vec<String>,
|
||||||
|
/// Filled in phase 2/3: where the photo ended up on the account.
|
||||||
|
uid: Option<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold the scan into one entry per distinct photo.
|
||||||
|
///
|
||||||
|
/// Google stores a full copy of a photo in every album folder it appears in, so
|
||||||
|
/// the same file is in the export two or three times over. They are folded on
|
||||||
|
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
|
||||||
|
/// and the album memberships of the copies are unioned onto the survivor. Two
|
||||||
|
/// genuinely different photos sharing both a name and an exact size would be
|
||||||
|
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
|
||||||
|
/// filed under an extra album rather than lost data.
|
||||||
|
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
|
||||||
|
let mut order: Vec<(String, u64)> = Vec::new();
|
||||||
|
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
|
||||||
|
|
||||||
|
for photo in &scan.photos {
|
||||||
|
let key = (photo.name.clone(), photo.size);
|
||||||
|
let entry = by_key.entry(key.clone()).or_insert_with(|| {
|
||||||
|
order.push(key.clone());
|
||||||
|
Planned {
|
||||||
|
photo: photo.clone(),
|
||||||
|
albums: Vec::new(),
|
||||||
|
uid: None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Bucket::Album(title) = &photo.bucket
|
||||||
|
&& !entry.albums.iter().any(|existing| existing == title)
|
||||||
|
{
|
||||||
|
entry.albums.push(title.clone());
|
||||||
|
}
|
||||||
|
// A copy that carries a capture time wins over one that does not: the
|
||||||
|
// album copy and the year-folder copy do not always both have a sidecar.
|
||||||
|
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
|
||||||
|
entry.photo.capture_time = photo.capture_time;
|
||||||
|
}
|
||||||
|
entry.photo.favorite |= photo.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
order
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| by_key.remove(&key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one archive and stream `entry` through `sink`.
|
||||||
|
fn read_entry<R>(
|
||||||
|
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||||
|
entry: &str,
|
||||||
|
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
|
||||||
|
) -> CoreResult<R> {
|
||||||
|
let mut file = archive
|
||||||
|
.by_name(entry)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
|
||||||
|
Ok(sink(&mut file))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||||
|
/// the wire is allowed to finish, so a cancelled import never leaves a
|
||||||
|
/// half-written revision behind.
|
||||||
|
pub(crate) fn import_takeout(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<ImportSummary> {
|
||||||
|
let job = self.transfers.begin_job("Importing Google Photos");
|
||||||
|
job.detail("reading archives");
|
||||||
|
|
||||||
|
let scan = scan_archives(&archives)?;
|
||||||
|
let mut planned = plan(&scan);
|
||||||
|
let mut summary = ImportSummary {
|
||||||
|
found: planned.len(),
|
||||||
|
skipped_trashed: scan.skipped_trashed,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
job.set_total(planned.len() as u64);
|
||||||
|
info!(
|
||||||
|
archives = archives.len(),
|
||||||
|
photos = summary.found,
|
||||||
|
albums = scan.albums.len(),
|
||||||
|
trashed = summary.skipped_trashed,
|
||||||
|
"takeout scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
let photos = self.photos();
|
||||||
|
if self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.get_photos_root())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: which of these are already on the account.
|
||||||
|
job.detail("checking for photos you already have");
|
||||||
|
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: upload what is left, one archive at a time so each zip's
|
||||||
|
// central directory is parsed once.
|
||||||
|
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
|
||||||
|
|
||||||
|
// Phase 4: albums. Attempted even after a cancel — the photos that did
|
||||||
|
// upload should still land in their albums.
|
||||||
|
if !scan.albums.is_empty() {
|
||||||
|
job.detail("sorting photos into albums");
|
||||||
|
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
|
||||||
|
warn!(error = %e, "takeout album linking failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in `uid` for every planned photo the account already holds.
|
||||||
|
///
|
||||||
|
/// Two passes on purpose: the name-only query is one request per 150 photos
|
||||||
|
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
|
||||||
|
/// handful of names that actually collide.
|
||||||
|
fn mark_existing(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
|
||||||
|
let collisions = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.name_collisions(&names))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
// Hash the colliding files, grouped by archive so each zip opens once.
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, planned) in planned.iter().enumerate() {
|
||||||
|
if collisions.get(index).copied().unwrap_or(false) {
|
||||||
|
by_archive
|
||||||
|
.entry(planned.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept as a pair list, not a map: the duplicate lookup answers in input
|
||||||
|
// order, so the order these were built in is what maps answers back to
|
||||||
|
// planned photos.
|
||||||
|
let mut digests: Vec<(usize, String)> = Vec::new();
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
for index in indices {
|
||||||
|
let entry = planned[index].photo.entry.clone();
|
||||||
|
match read_entry(&mut archive, &entry, sha1_of) {
|
||||||
|
Ok(Ok(digest)) => digests.push((index, digest)),
|
||||||
|
Ok(Err(e)) | Err(e) => {
|
||||||
|
warn!(entry, error = %e, "cannot hash takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digests.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<(String, String)> = digests
|
||||||
|
.iter()
|
||||||
|
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
|
||||||
|
.collect();
|
||||||
|
let matches = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.find_duplicates_many(&items))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
for (slot, (index, _)) in digests.iter().enumerate() {
|
||||||
|
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
|
||||||
|
planned[*index].uid = Some(uid.clone());
|
||||||
|
summary.duplicates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload every planned photo that is not already on the account.
|
||||||
|
fn upload_planned(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
job: &crate::transfers::JobGuard,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let mut favorites: Vec<NodeUid> = Vec::new();
|
||||||
|
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, item) in planned.iter().enumerate() {
|
||||||
|
if item.uid.is_none() {
|
||||||
|
by_archive
|
||||||
|
.entry(item.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
|
||||||
|
for index in indices {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let photo = planned[index].photo.clone();
|
||||||
|
job.detail(photo.name.clone());
|
||||||
|
|
||||||
|
let guard = self.transfers.begin(
|
||||||
|
photo.name.clone(),
|
||||||
|
"",
|
||||||
|
TransferDirection::Upload,
|
||||||
|
photo.size,
|
||||||
|
);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: photo.capture_time,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
||||||
|
let counting = CountingReader::new(reader, &guard);
|
||||||
|
self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&photo.name,
|
||||||
|
&photo.media_type,
|
||||||
|
counting,
|
||||||
|
photo.size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
drop(guard);
|
||||||
|
job.step();
|
||||||
|
|
||||||
|
match uploaded {
|
||||||
|
Ok(Ok(uid)) => {
|
||||||
|
summary.uploaded += 1;
|
||||||
|
summary.bytes += photo.size;
|
||||||
|
if photo.favorite {
|
||||||
|
favorites.push(uid.clone());
|
||||||
|
}
|
||||||
|
planned[index].uid = Some(uid);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "takeout photo upload failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "cannot read takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favourites are a separate call: the upload seal takes classification
|
||||||
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
|
// volume's dedicated favourite endpoint.
|
||||||
|
if !favorites.is_empty() {
|
||||||
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
|
.into_iter()
|
||||||
|
.map(|node_uid| PhotoTagsUpdate {
|
||||||
|
node_uid,
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
|
warn!(error = %e, "marking imported favourites failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the albums the export names that the account lacks, then add each
|
||||||
|
/// album's photos to it.
|
||||||
|
fn link_albums(
|
||||||
|
&self,
|
||||||
|
planned: &[Planned],
|
||||||
|
albums: &[String],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
|
||||||
|
// Existing albums by decrypted name, so a second import of the same
|
||||||
|
// export adds to the album it made the first time instead of a twin.
|
||||||
|
let mut existing: HashMap<String, NodeUid> = HashMap::new();
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_album_node_uids())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
|
||||||
|
if !uids.is_empty() {
|
||||||
|
let albums_nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
|
||||||
|
for node in albums_nodes {
|
||||||
|
existing.entry(node.name.clone()).or_insert(node.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for title in albums {
|
||||||
|
let members: Vec<NodeUid> = planned
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.albums.iter().any(|album| album == title))
|
||||||
|
.filter_map(|item| item.uid.clone())
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let album_uid = match existing.get(title) {
|
||||||
|
Some(uid) => uid.clone(),
|
||||||
|
None => match self.rt.block_on(photos.create_album(title)) {
|
||||||
|
Ok(uid) => {
|
||||||
|
summary.albums_created += 1;
|
||||||
|
existing.insert(title.clone(), uid.clone());
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, error = %e, "cannot create album");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, &members))
|
||||||
|
{
|
||||||
|
Ok(outcomes) => {
|
||||||
|
for (uid, outcome) in outcomes {
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => summary.album_links += 1,
|
||||||
|
// A photo already in the album is the expected
|
||||||
|
// outcome of a re-run, not a failure worth counting.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, %uid, error = %e, "cannot add photo to album")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(album = title, error = %e, "album addition failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an import on a background thread, logging its outcome to the activity
|
||||||
|
/// feed. Returns immediately.
|
||||||
|
///
|
||||||
|
/// Refuses while one is already running: two imports of the same export
|
||||||
|
/// would race each other's duplicate detection and upload everything twice.
|
||||||
|
pub(crate) fn spawn_takeout_import(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"an import is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.import_takeout(archives, dry_run, cancel);
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok(summary) => {
|
||||||
|
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
|
||||||
|
if summary.duplicates > 0 {
|
||||||
|
parts.push(format!("{} already there", summary.duplicates));
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 {
|
||||||
|
parts.push(format!("{} albums", summary.albums_created));
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
parts.push(format!("{} failed", summary.failed));
|
||||||
|
}
|
||||||
|
if summary.cancelled {
|
||||||
|
parts.push("cancelled".to_string());
|
||||||
|
}
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"Google Photos import",
|
||||||
|
parts.join(" · "),
|
||||||
|
summary.failed == 0 && !summary.cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "takeout import failed");
|
||||||
|
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one of the import's archives by index.
|
||||||
|
fn open_archive(
|
||||||
|
archives: &[PathBuf],
|
||||||
|
index: usize,
|
||||||
|
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
|
||||||
|
let path = archives
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
zip::ZipArchive::new(std::io::BufReader::new(file))
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||||
|
/// photo duplicate detection is keyed on.
|
||||||
|
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = reader
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(hex_lower(&hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_lower(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::takeout::TakeoutPhoto;
|
||||||
|
|
||||||
|
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
|
||||||
|
TakeoutPhoto {
|
||||||
|
archive: 0,
|
||||||
|
entry: format!("Takeout/Google Photos/x/{name}"),
|
||||||
|
name: name.to_string(),
|
||||||
|
size,
|
||||||
|
media_type: "image/jpeg".to_string(),
|
||||||
|
capture_time: None,
|
||||||
|
favorite: false,
|
||||||
|
bucket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_copies_of_one_photo_fold_into_a_single_upload() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
|
||||||
|
photo("IMG_2.jpg", 200, Bucket::Timeline),
|
||||||
|
],
|
||||||
|
albums: vec!["Iceland".into(), "Best of".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let planned = plan(&scan);
|
||||||
|
assert_eq!(planned.len(), 2, "the three copies are one upload");
|
||||||
|
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
|
||||||
|
assert!(planned[1].albums.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_name_different_size_stays_two_photos() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(plan(&scan).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_from_any_copy_survives_the_fold() {
|
||||||
|
// Only the album copy carries a sidecar; the fold must not lose it.
|
||||||
|
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
|
||||||
|
bare.capture_time = None;
|
||||||
|
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
|
||||||
|
rich.capture_time = Some(1_560_000_000);
|
||||||
|
rich.favorite = true;
|
||||||
|
|
||||||
|
let planned = plan(&TakeoutScan {
|
||||||
|
photos: vec![bare, rich],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
|
||||||
|
assert!(planned[0].photo.favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha1_matches_the_known_digest_of_abc() {
|
||||||
|
let mut reader: &[u8] = b"abc";
|
||||||
|
assert_eq!(
|
||||||
|
sha1_of(&mut reader).unwrap(),
|
||||||
|
"a9993e364706816aba3e25717850c26c9cd0d89d"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "GTK4 desktop app, tray, and search launcher for the Proton Drive Linux client"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pdfs-tray"
|
name = "pdfs-tray"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
|
println!(
|
||||||
|
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
|
||||||
|
);
|
||||||
println!(
|
println!(
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
|
||||||
|
symbolic fill lets GTK recolour it with the surrounding control. -->
|
||||||
|
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 598 B |
@@ -4,6 +4,7 @@
|
|||||||
icon-theme resource path at startup, so they resolve by name (and recolour
|
icon-theme resource path at startup, so they resolve by name (and recolour
|
||||||
with the theme) exactly like a stock symbolic icon. -->
|
with the theme) exactly like a stock symbolic icon. -->
|
||||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
+151
-15
@@ -14,10 +14,12 @@ use pages::photos::*;
|
|||||||
use pages::shared::*;
|
use pages::shared::*;
|
||||||
use pages::shared_by_me::*;
|
use pages::shared_by_me::*;
|
||||||
use pages::status::*;
|
use pages::status::*;
|
||||||
|
use pages::takeout::*;
|
||||||
use pages::trash::*;
|
use pages::trash::*;
|
||||||
use pages::verify::*;
|
use pages::verify::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
|
use widgets::thumbnails::*;
|
||||||
use widgets::versions_dialog::*;
|
use widgets::versions_dialog::*;
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
@@ -46,10 +48,10 @@ use pdfs_core::config::AppDirs;
|
|||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||||
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope,
|
||||||
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind,
|
||||||
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection,
|
||||||
send,
|
TransferItem, pending_summary, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
@@ -106,6 +108,10 @@ struct Ui {
|
|||||||
/// The sidebar/content split. Collapsed while signed out, so the login page
|
/// The sidebar/content split. Collapsed while signed out, so the login page
|
||||||
/// owns the whole window and no destination is reachable without a session.
|
/// owns the whole window and no destination is reachable without a session.
|
||||||
nav: adw::NavigationSplitView,
|
nav: adw::NavigationSplitView,
|
||||||
|
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
||||||
|
/// One cache and request queue serves Files, search, Shared and Trash, so
|
||||||
|
/// the same image is downloaded and decoded only once.
|
||||||
|
pub(crate) file_thumbs: FileThumbnailState,
|
||||||
|
|
||||||
// Per-page state. Each page module owns its own struct; `Ui` keeps only
|
// Per-page state. Each page module owns its own struct; `Ui` keeps only
|
||||||
// what more than one page genuinely shares.
|
// what more than one page genuinely shares.
|
||||||
@@ -120,6 +126,7 @@ struct Ui {
|
|||||||
pub(crate) devices: DevicesState,
|
pub(crate) devices: DevicesState,
|
||||||
pub(crate) locations: LocationsState,
|
pub(crate) locations: LocationsState,
|
||||||
pub(crate) activity: ActivityState,
|
pub(crate) activity: ActivityState,
|
||||||
|
pub(crate) takeout: TakeoutState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ui {
|
impl Ui {
|
||||||
@@ -205,10 +212,15 @@ fn load_proton_theme() {
|
|||||||
.file-grid {{ padding: 6px; }}\n\
|
.file-grid {{ padding: 6px; }}\n\
|
||||||
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
|
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
|
||||||
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
|
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
|
||||||
|
.file-thumbnail {{ border-radius: 7px; }}\n\
|
||||||
.file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
|
.file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
|
||||||
.badge-pinned {{ color: #f5c211; }}\n\
|
.badge-pinned {{ color: #f5c211; }}\n\
|
||||||
.badge-cached {{ color: #2ec27e; }}\n\
|
.badge-cached {{ color: #2ec27e; }}\n\
|
||||||
.badge-cloud {{ color: #9aa0a6; }}\n\
|
.badge-cloud {{ color: #9aa0a6; }}\n\
|
||||||
|
.browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\
|
||||||
|
scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\
|
||||||
|
scale.browser-status-meter trough {{ min-height: 6px; }}\n\
|
||||||
|
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\
|
||||||
.photo-viewer-window {{ background-color: #111014; }}\n\
|
.photo-viewer-window {{ background-color: #111014; }}\n\
|
||||||
.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; }}\n\
|
.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; }}\n\
|
||||||
.viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
|
.viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
|
||||||
@@ -246,7 +258,10 @@ fn load_proton_theme() {
|
|||||||
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
||||||
.navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\
|
.navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\
|
||||||
.navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\
|
.navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\
|
||||||
.file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n"
|
.file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n\
|
||||||
|
.bulk-bar {{ padding: 6px 8px; border-radius: 12px; background: alpha({PROTON_PURPLE}, 0.12); }}\n\
|
||||||
|
.dropzone {{ border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }}\n\
|
||||||
|
.dropzone-active {{ border-color: {PROTON_PURPLE}; background: alpha({PROTON_PURPLE}, 0.10); }}\n"
|
||||||
);
|
);
|
||||||
let provider = gtk4::CssProvider::new();
|
let provider = gtk4::CssProvider::new();
|
||||||
provider.load_from_string(&css);
|
provider.load_from_string(&css);
|
||||||
@@ -285,6 +300,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
let (locations_page, locations_widgets) = build_locations_page();
|
let (locations_page, locations_widgets) = build_locations_page();
|
||||||
let (activity_page, activity_widgets) = build_activity_page();
|
let (activity_page, activity_widgets) = build_activity_page();
|
||||||
let (trash_page, trash_widgets) = build_trash_page();
|
let (trash_page, trash_widgets) = build_trash_page();
|
||||||
|
let (takeout_page, takeout_widgets) = build_takeout_page();
|
||||||
stack.add_named(&login_page, Some("login"));
|
stack.add_named(&login_page, Some("login"));
|
||||||
stack.add_named(&main_page, Some("main"));
|
stack.add_named(&main_page, Some("main"));
|
||||||
stack.add_named(&browser_page, Some("browser"));
|
stack.add_named(&browser_page, Some("browser"));
|
||||||
@@ -295,6 +311,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
stack.add_named(&locations_page, Some("locations"));
|
stack.add_named(&locations_page, Some("locations"));
|
||||||
stack.add_named(&activity_page, Some("activity"));
|
stack.add_named(&activity_page, Some("activity"));
|
||||||
stack.add_named(&trash_page, Some("trash"));
|
stack.add_named(&trash_page, Some("trash"));
|
||||||
|
stack.add_named(&takeout_page, Some("takeout"));
|
||||||
|
|
||||||
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
||||||
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
||||||
@@ -338,6 +355,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
|
file_thumbs: FileThumbnailState::new(),
|
||||||
login: LoginState {
|
login: LoginState {
|
||||||
email: login_widgets.0,
|
email: login_widgets.0,
|
||||||
password: login_widgets.1,
|
password: login_widgets.1,
|
||||||
@@ -362,6 +380,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
budget_row: main_widgets.budget_row.clone(),
|
budget_row: main_widgets.budget_row.clone(),
|
||||||
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
||||||
settings_suppress: Cell::new(false),
|
settings_suppress: Cell::new(false),
|
||||||
|
budget_source: RefCell::new(None),
|
||||||
|
pins_expanded: Cell::new(false),
|
||||||
pins_group: main_widgets.pins_group.clone(),
|
pins_group: main_widgets.pins_group.clone(),
|
||||||
pin_rows: RefCell::new(Vec::new()),
|
pin_rows: RefCell::new(Vec::new()),
|
||||||
pins_state: RefCell::new(None),
|
pins_state: RefCell::new(None),
|
||||||
@@ -381,7 +401,29 @@ fn build_window(app: &adw::Application) {
|
|||||||
new_folder: browser_widgets.new_folder.clone(),
|
new_folder: browser_widgets.new_folder.clone(),
|
||||||
upload: browser_widgets.upload.clone(),
|
upload: browser_widgets.upload.clone(),
|
||||||
upload_folder: browser_widgets.upload_folder.clone(),
|
upload_folder: browser_widgets.upload_folder.clone(),
|
||||||
|
build_thumbnails: browser_widgets.build_thumbnails.clone(),
|
||||||
|
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
|
||||||
|
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
|
||||||
|
thumbnail_status: browser_widgets.thumbnail_status.clone(),
|
||||||
|
thumbnail_poll: RefCell::new(None),
|
||||||
|
thumbnail_build_running: Cell::new(false),
|
||||||
|
thumbnail_cancel_pending: Cell::new(false),
|
||||||
search_source: RefCell::new(None),
|
search_source: RefCell::new(None),
|
||||||
|
load_generation: Cell::new(0),
|
||||||
|
views: browser_widgets.views.clone(),
|
||||||
|
bulk: browser_widgets.bulk.clone(),
|
||||||
|
bulk_label: browser_widgets.bulk_label.clone(),
|
||||||
|
bulk_trash: browser_widgets.bulk_trash.clone(),
|
||||||
|
bulk_pin: browser_widgets.bulk_pin.clone(),
|
||||||
|
bulk_unpin: browser_widgets.bulk_unpin.clone(),
|
||||||
|
empty_actions: browser_widgets.empty_actions.clone(),
|
||||||
|
summary: browser_widgets.summary.clone(),
|
||||||
|
zoom: browser_widgets.zoom.clone(),
|
||||||
|
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
|
||||||
|
grid_tiles: RefCell::new(Vec::new()),
|
||||||
|
quota_box: browser_widgets.quota_box.clone(),
|
||||||
|
quota: browser_widgets.quota.clone(),
|
||||||
|
quota_text: browser_widgets.quota_text.clone(),
|
||||||
},
|
},
|
||||||
details: DetailsState {
|
details: DetailsState {
|
||||||
details: browser_widgets.details,
|
details: browser_widgets.details,
|
||||||
@@ -407,6 +449,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
retry: gallery_widgets.retry.clone(),
|
retry: gallery_widgets.retry.clone(),
|
||||||
more: gallery_widgets.more.clone(),
|
more: gallery_widgets.more.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
|
import: gallery_widgets.import.clone(),
|
||||||
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||||
title: gallery_widgets.title.clone(),
|
title: gallery_widgets.title.clone(),
|
||||||
subtitle: gallery_widgets.subtitle.clone(),
|
subtitle: gallery_widgets.subtitle.clone(),
|
||||||
albums: gallery_widgets.albums.clone(),
|
albums: gallery_widgets.albums.clone(),
|
||||||
@@ -494,6 +538,24 @@ fn build_window(app: &adw::Application) {
|
|||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
key: RefCell::new(None),
|
key: RefCell::new(None),
|
||||||
},
|
},
|
||||||
|
takeout: TakeoutState {
|
||||||
|
archives: RefCell::new(Vec::new()),
|
||||||
|
list_group: takeout_widgets.list_group.clone(),
|
||||||
|
rows: RefCell::new(Vec::new()),
|
||||||
|
dropzone: takeout_widgets.dropzone.clone(),
|
||||||
|
scan_button: takeout_widgets.scan_button.clone(),
|
||||||
|
import_button: takeout_widgets.import_button.clone(),
|
||||||
|
cancel_button: takeout_widgets.cancel_button.clone(),
|
||||||
|
clear_button: takeout_widgets.clear_button.clone(),
|
||||||
|
progress_group: takeout_widgets.progress_group.clone(),
|
||||||
|
progress_label: takeout_widgets.progress_label.clone(),
|
||||||
|
progress_bar: takeout_widgets.progress_bar.clone(),
|
||||||
|
summary_group: takeout_widgets.summary_group.clone(),
|
||||||
|
summary_rows: RefCell::new(Vec::new()),
|
||||||
|
inflight: Cell::new(false),
|
||||||
|
running: Cell::new(false),
|
||||||
|
dry_run: Cell::new(false),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
wire_logout(&ui, &main_widgets.logout_button);
|
wire_logout(&ui, &main_widgets.logout_button);
|
||||||
@@ -501,18 +563,31 @@ fn build_window(app: &adw::Application) {
|
|||||||
&ui,
|
&ui,
|
||||||
&main_widgets.purge_button,
|
&main_widgets.purge_button,
|
||||||
&main_widgets.mountpoint_button,
|
&main_widgets.mountpoint_button,
|
||||||
|
&main_widgets.import_row,
|
||||||
);
|
);
|
||||||
wire_sidebar(&ui);
|
wire_sidebar(&ui);
|
||||||
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
|
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
|
||||||
|
wire_bulk(
|
||||||
|
&ui,
|
||||||
|
&browser_widgets.bulk_clear,
|
||||||
|
&browser_widgets.empty_upload,
|
||||||
|
&browser_widgets.empty_new_folder,
|
||||||
|
);
|
||||||
wire_browser_actions(
|
wire_browser_actions(
|
||||||
&ui,
|
&ui,
|
||||||
&browser_widgets.new_folder,
|
&browser_widgets.new_folder,
|
||||||
&browser_widgets.upload,
|
&browser_widgets.upload,
|
||||||
&browser_widgets.upload_folder,
|
&browser_widgets.upload_folder,
|
||||||
|
&browser_widgets.build_thumbnails,
|
||||||
);
|
);
|
||||||
wire_details(&ui);
|
wire_details(&ui);
|
||||||
wire_search(&ui);
|
wire_search(&ui);
|
||||||
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
||||||
|
wire_gallery_empty(
|
||||||
|
&ui,
|
||||||
|
&gallery_widgets.empty_upload,
|
||||||
|
&gallery_widgets.empty_import,
|
||||||
|
);
|
||||||
wire_albums(&ui);
|
wire_albums(&ui);
|
||||||
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||||
@@ -520,6 +595,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||||
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||||
wire_activity(&ui, &activity_widgets.retry);
|
wire_activity(&ui, &activity_widgets.retry);
|
||||||
|
wire_takeout(&ui, &takeout_widgets);
|
||||||
wire_refresh(
|
wire_refresh(
|
||||||
&ui,
|
&ui,
|
||||||
&[
|
&[
|
||||||
@@ -539,6 +615,10 @@ fn build_window(app: &adw::Application) {
|
|||||||
// network round-trip only happens on demand rather than on every refresh.
|
// network round-trip only happens on demand rather than on every refresh.
|
||||||
let ui_nav = ui.clone();
|
let ui_nav = ui.clone();
|
||||||
stack.connect_visible_child_name_notify(move |st| {
|
stack.connect_visible_child_name_notify(move |st| {
|
||||||
|
// Rows from the page being left must not keep full-size image downloads
|
||||||
|
// alive in the daemon. The page being entered establishes a fresh
|
||||||
|
// thumbnail generation as it paints.
|
||||||
|
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") => load_browser(&ui_nav),
|
Some("browser") => load_browser(&ui_nav),
|
||||||
@@ -559,6 +639,9 @@ fn build_window(app: &adw::Application) {
|
|||||||
// every visit to stay live.
|
// every visit to stay live.
|
||||||
Some("activity") => load_activity(&ui_nav),
|
Some("activity") => load_activity(&ui_nav),
|
||||||
Some("trash") => load_trash(&ui_nav),
|
Some("trash") => load_trash(&ui_nav),
|
||||||
|
// The import runs in the daemon and outlives this page, so arriving
|
||||||
|
// here asks straight away whether one is in flight.
|
||||||
|
Some("takeout") => refresh_takeout(&ui_nav),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -644,6 +727,17 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
(page, list)
|
(page, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sidebar destination a page belongs under. Most pages are their own
|
||||||
|
/// destination; a sub-page reached from one (Import, off Settings) has no row of
|
||||||
|
/// its own and answers with its parent, so the sidebar highlights where the user
|
||||||
|
/// came from rather than clearing — which would read as "nowhere".
|
||||||
|
fn destination_of(page: &str) -> &str {
|
||||||
|
match page {
|
||||||
|
"takeout" => "main",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Selecting a sidebar row navigates the page stack. The reverse direction (stack
|
/// Selecting a sidebar row navigates the page stack. The reverse direction (stack
|
||||||
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
|
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
|
||||||
/// only ever writes the stack.
|
/// only ever writes the stack.
|
||||||
@@ -654,7 +748,11 @@ fn wire_sidebar(ui: &Rc<Ui>) {
|
|||||||
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
|
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if ui_row.stack.visible_child_name().as_deref() != Some(*page) {
|
// Compared by *destination*, not by page: while a sub-page is open its
|
||||||
|
// parent's row is the selected one, and re-asserting it here would kick
|
||||||
|
// the user off the sub-page the moment they arrived.
|
||||||
|
let current = ui_row.stack.visible_child_name();
|
||||||
|
if current.as_deref().map(destination_of) != Some(*page) {
|
||||||
ui_row.stack.set_visible_child_name(page);
|
ui_row.stack.set_visible_child_name(page);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -755,6 +853,7 @@ fn sync_sidebar(ui: &Rc<Ui>) {
|
|||||||
let Some(current) = ui.stack.visible_child_name() else {
|
let Some(current) = ui.stack.visible_child_name() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let current = destination_of(¤t);
|
||||||
let index = DESTINATIONS
|
let index = DESTINATIONS
|
||||||
.iter()
|
.iter()
|
||||||
.position(|(page, _, _)| *page == current);
|
.position(|(page, _, _)| *page == current);
|
||||||
@@ -776,6 +875,22 @@ fn toast(ui: &Rc<Ui>, message: &str) {
|
|||||||
ui.toasts.add_toast(adw::Toast::new(message));
|
ui.toasts.add_toast(adw::Toast::new(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
||||||
|
/// the outcome landed on.
|
||||||
|
///
|
||||||
|
/// Given a longer timeout than a plain toast: an action the user has to notice,
|
||||||
|
/// read and reach is not the same ask as a line of confirmation they can ignore.
|
||||||
|
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
||||||
|
let toast = adw::Toast::builder()
|
||||||
|
.title(message)
|
||||||
|
.button_label(label)
|
||||||
|
.timeout(8)
|
||||||
|
.build();
|
||||||
|
let ui_action = ui.clone();
|
||||||
|
toast.connect_button_clicked(move |_| action(&ui_action));
|
||||||
|
ui.toasts.add_toast(toast);
|
||||||
|
}
|
||||||
|
|
||||||
/// Show a toast for a failure. Same surface as [`toast`], but the message is
|
/// Show a toast for a failure. Same surface as [`toast`], but the message is
|
||||||
/// prefixed with what was being attempted, since a bare daemon error ("no such
|
/// prefixed with what was being attempted, since a bare daemon error ("no such
|
||||||
/// file") reads as noise without it.
|
/// file") reads as noise without it.
|
||||||
@@ -830,7 +945,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
|||||||
fn build_primary_menu() -> gtk4::MenuButton {
|
fn build_primary_menu() -> gtk4::MenuButton {
|
||||||
let menu = gio::Menu::new();
|
let menu = gio::Menu::new();
|
||||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||||
menu.append(Some("About Proton Drive"), Some("win.about"));
|
menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
|
||||||
gtk4::MenuButton::builder()
|
gtk4::MenuButton::builder()
|
||||||
.icon_name("open-menu-symbolic")
|
.icon_name("open-menu-symbolic")
|
||||||
.tooltip_text("Main menu")
|
.tooltip_text("Main menu")
|
||||||
@@ -849,11 +964,17 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
|
|||||||
let win = window.clone();
|
let win = window.clone();
|
||||||
about.connect_activate(move |_, _| {
|
about.connect_activate(move |_, _| {
|
||||||
let dialog = adw::AboutDialog::builder()
|
let dialog = adw::AboutDialog::builder()
|
||||||
.application_name("Proton Drive")
|
.application_name("Proton Drive for Linux")
|
||||||
.application_icon("folder-remote-symbolic")
|
.application_icon("io.narl.proton-drive-linux")
|
||||||
.version(pdfs_core::config::APP_VERSION)
|
.version(pdfs_core::config::APP_VERSION)
|
||||||
.developer_name("proton-drive-linux")
|
.developer_name("Nils Pukropp")
|
||||||
.comments("On-demand Proton Drive sync for Linux.")
|
.website("https://github.com/narrrl/proton-drive-linux")
|
||||||
|
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||||
|
.license_type(gtk4::License::MitX11)
|
||||||
|
.comments(
|
||||||
|
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
|
||||||
|
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
dialog.present(Some(&win));
|
dialog.present(Some(&win));
|
||||||
});
|
});
|
||||||
@@ -913,7 +1034,13 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
// browser-only bindings.
|
// browser-only bindings.
|
||||||
gtk4::gdk::Key::F5 => reload_current_page(&ui),
|
gtk4::gdk::Key::F5 => reload_current_page(&ui),
|
||||||
gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui),
|
gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui),
|
||||||
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl && on_browser => {
|
// Search lives on Files, but the shortcut works from anywhere:
|
||||||
|
// wanting to find a file is not a reason to first have to remember
|
||||||
|
// which page owns the search box.
|
||||||
|
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl => {
|
||||||
|
if !on_browser {
|
||||||
|
ui.stack.set_visible_child_name("browser");
|
||||||
|
}
|
||||||
ui.browser.search.grab_focus();
|
ui.browser.search.grab_focus();
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
||||||
@@ -923,12 +1050,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
prompt_rename(&ui, &entry);
|
prompt_rename(&ui, &entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Acts on the whole selection: Delete with five files highlighted
|
||||||
|
// means those five, not whichever one the details pane happens to
|
||||||
|
// be describing.
|
||||||
gtk4::gdk::Key::Delete if on_browser => {
|
gtk4::gdk::Key::Delete if on_browser => {
|
||||||
if let Some(entry) = selected_entry(&ui) {
|
let entries = selected_entries(&ui);
|
||||||
prompt_delete(&ui, &entry);
|
if !entries.is_empty() {
|
||||||
|
prompt_delete_many(&ui, entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::Escape if on_browser && ui.browser.split.shows_sidebar() => {
|
gtk4::gdk::Key::Escape
|
||||||
|
if on_browser
|
||||||
|
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
|
||||||
|
{
|
||||||
|
clear_selection(&ui);
|
||||||
|
sync_bulk_bar(&ui);
|
||||||
hide_details(&ui);
|
hide_details(&ui);
|
||||||
}
|
}
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -9,5 +9,6 @@ pub(crate) mod photos;
|
|||||||
pub(crate) mod shared;
|
pub(crate) mod shared;
|
||||||
pub(crate) mod shared_by_me;
|
pub(crate) mod shared_by_me;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
|
pub(crate) mod takeout;
|
||||||
pub(crate) mod trash;
|
pub(crate) mod trash;
|
||||||
pub(crate) mod verify;
|
pub(crate) mod verify;
|
||||||
@@ -19,6 +19,12 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
pub(crate) upload: gtk4::Button,
|
pub(crate) upload: gtk4::Button,
|
||||||
|
/// Opens the Google Photos Takeout import chooser.
|
||||||
|
pub(crate) import: gtk4::Button,
|
||||||
|
/// 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
|
||||||
|
/// status: a load error is not the moment to offer an upload.
|
||||||
|
pub(crate) empty_actions: gtk4::Box,
|
||||||
/// "Photos", or the album's name while one is open.
|
/// "Photos", or the album's name while one is open.
|
||||||
pub(crate) title: gtk4::Label,
|
pub(crate) title: gtk4::Label,
|
||||||
/// "1,204 photos" under the page title.
|
/// "1,204 photos" under the page title.
|
||||||
@@ -177,6 +183,10 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) scroll: gtk4::ScrolledWindow,
|
pub(crate) scroll: gtk4::ScrolledWindow,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) upload: gtk4::Button,
|
pub(crate) upload: gtk4::Button,
|
||||||
|
pub(crate) import: gtk4::Button,
|
||||||
|
pub(crate) empty_actions: gtk4::Box,
|
||||||
|
pub(crate) empty_upload: gtk4::Button,
|
||||||
|
pub(crate) empty_import: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
/// The Albums grid, its own status page and the stack between them, plus the
|
/// The Albums grid, its own status page and the stack between them, plus the
|
||||||
/// Photos/Albums switcher and the back button out of an album.
|
/// Photos/Albums switcher and the back button out of an album.
|
||||||
@@ -227,10 +237,29 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
retry.add_css_class("suggested-action");
|
retry.add_css_class("suggested-action");
|
||||||
retry.set_visible(false);
|
retry.set_visible(false);
|
||||||
|
|
||||||
|
// The empty-timeline state's two ways forward. `StatusPage` takes one child,
|
||||||
|
// so Retry and these share a box and each is shown only for its own state.
|
||||||
|
let empty_upload = gtk4::Button::builder().label("Upload photos").build();
|
||||||
|
empty_upload.add_css_class("pill");
|
||||||
|
empty_upload.add_css_class("suggested-action");
|
||||||
|
let empty_import = gtk4::Button::builder()
|
||||||
|
.label("Import from Google Photos")
|
||||||
|
.build();
|
||||||
|
empty_import.add_css_class("pill");
|
||||||
|
let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
empty_actions.set_halign(gtk4::Align::Center);
|
||||||
|
empty_actions.set_visible(false);
|
||||||
|
empty_actions.append(&empty_upload);
|
||||||
|
empty_actions.append(&empty_import);
|
||||||
|
|
||||||
|
let status_child = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
status_child.append(&retry);
|
||||||
|
status_child.append(&empty_actions);
|
||||||
|
|
||||||
let status = adw::StatusPage::builder()
|
let status = adw::StatusPage::builder()
|
||||||
.icon_name("image-x-generic-symbolic")
|
.icon_name("image-x-generic-symbolic")
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&retry)
|
.child(&status_child)
|
||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
@@ -288,12 +317,23 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
upload.add_css_class("pill");
|
upload.add_css_class("pill");
|
||||||
upload.add_css_class("suggested-action");
|
upload.add_css_class("suggested-action");
|
||||||
|
|
||||||
|
// Importing an export is a rarer, heavier action than adding one photo, so
|
||||||
|
// it sits beside Upload as a plain button rather than a second accent one.
|
||||||
|
let import = gtk4::Button::builder()
|
||||||
|
.icon_name("folder-download-symbolic")
|
||||||
|
.tooltip_text("Import a Google Photos Takeout export")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
import.add_css_class("flat");
|
||||||
|
import.add_css_class("circular");
|
||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
|
||||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
header_box.append(&back);
|
header_box.append(&back);
|
||||||
header_box.append(&titles);
|
header_box.append(&titles);
|
||||||
header_box.append(&refresh);
|
header_box.append(&refresh);
|
||||||
|
header_box.append(&import);
|
||||||
header_box.append(&upload);
|
header_box.append(&upload);
|
||||||
|
|
||||||
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
||||||
@@ -428,6 +468,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
scroll,
|
scroll,
|
||||||
retry,
|
retry,
|
||||||
upload,
|
upload,
|
||||||
|
import,
|
||||||
|
empty_actions,
|
||||||
|
empty_upload,
|
||||||
|
empty_import,
|
||||||
refresh,
|
refresh,
|
||||||
tabs,
|
tabs,
|
||||||
favorites_btn,
|
favorites_btn,
|
||||||
@@ -554,6 +598,15 @@ pub(crate) fn thousands(n: usize) -> String {
|
|||||||
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
||||||
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
||||||
/// in the in-app lightbox.
|
/// in the in-app lightbox.
|
||||||
|
/// Wire the empty-timeline buttons. Upload re-fires the header's own Upload
|
||||||
|
/// rather than duplicating its file-chooser, so the two can't drift apart.
|
||||||
|
pub(crate) fn wire_gallery_empty(ui: &Rc<Ui>, upload: >k4::Button, import: >k4::Button) {
|
||||||
|
let ui_upload = ui.clone();
|
||||||
|
upload.connect_clicked(move |_| ui_upload.gallery.upload.emit_clicked());
|
||||||
|
let ui_import = ui.clone();
|
||||||
|
import.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::ScrolledWindow) {
|
pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::ScrolledWindow) {
|
||||||
let factory = gtk4::SignalListItemFactory::new();
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
factory.connect_setup(|_, item| {
|
factory.connect_setup(|_, item| {
|
||||||
@@ -729,6 +782,14 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
load_gallery(&ui_dates, false);
|
load_gallery(&ui_dates, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The import is a staged, hours-long migration, so the button hands over to
|
||||||
|
// the Import page rather than opening a file chooser here: the archives need
|
||||||
|
// reviewing before anything is sent, and the run needs somewhere to report.
|
||||||
|
let ui_import = ui.clone();
|
||||||
|
ui.gallery
|
||||||
|
.import
|
||||||
|
.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
||||||
|
|
||||||
let ui_upload = ui.clone();
|
let ui_upload = ui.clone();
|
||||||
ui.gallery.upload.connect_clicked(move |_| {
|
ui.gallery.upload.connect_clicked(move |_| {
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
@@ -1460,6 +1521,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
||||||
|
// Only the whole-timeline empty state: an empty *album* is
|
||||||
|
// filled by adding existing photos to it, not by uploading
|
||||||
|
// into it, so the buttons would point the wrong way.
|
||||||
|
ui.gallery
|
||||||
|
.empty_actions
|
||||||
|
.set_visible(ui.gallery.album.borrow().is_none());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.content.set_visible_child_name("timeline");
|
ui.gallery.content.set_visible_child_name("timeline");
|
||||||
@@ -1502,6 +1569,9 @@ pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
|||||||
ui.gallery.status.set_title(title);
|
ui.gallery.status.set_title(title);
|
||||||
ui.gallery.status.set_description(Some(description));
|
ui.gallery.status.set_description(Some(description));
|
||||||
ui.gallery.retry.set_visible(retry);
|
ui.gallery.retry.set_visible(retry);
|
||||||
|
// Only the empty timeline offers a way to fill it; every other status turns
|
||||||
|
// those buttons back off.
|
||||||
|
ui.gallery.empty_actions.set_visible(false);
|
||||||
ui.gallery.more.set_visible(false);
|
ui.gallery.more.set_visible(false);
|
||||||
ui.gallery.content.set_visible_child_name("status");
|
ui.gallery.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
|||||||
if ui.shared.inflight.get() {
|
if ui.shared.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
let current = ui.shared.nav.borrow().last().cloned();
|
let current = ui.shared.nav.borrow().last().cloned();
|
||||||
if let Some((uid, _)) = current {
|
if let Some((uid, _)) = current {
|
||||||
load_shared_folder(ui, uid);
|
load_shared_folder(ui, uid);
|
||||||
@@ -308,11 +309,7 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
if let Some(badge) = role_badge(&entry.role) {
|
if let Some(badge) = role_badge(&entry.role) {
|
||||||
row.add_suffix(&badge);
|
row.add_suffix(&badge);
|
||||||
}
|
}
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||||
"folder-symbolic"
|
|
||||||
} else {
|
|
||||||
"text-x-generic-symbolic"
|
|
||||||
}));
|
|
||||||
let ui_act = ui.clone();
|
let ui_act = ui.clone();
|
||||||
let uid = entry.uid.clone();
|
let uid = entry.uid.clone();
|
||||||
let name = entry.name.clone();
|
let name = entry.name.clone();
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
|||||||
if ui.shared_by_me.inflight.get() {
|
if ui.shared_by_me.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
ui.shared_by_me.inflight.set(true);
|
ui.shared_by_me.inflight.set(true);
|
||||||
shared_by_me_status(
|
shared_by_me_status(
|
||||||
ui,
|
ui,
|
||||||
@@ -200,15 +201,12 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
ui.shared_by_me.content.set_visible_child_name("list");
|
ui.shared_by_me.content.set_visible_child_name("list");
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
|
let entry = shared_item_as_entry(item);
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&item.name)
|
.title(&item.name)
|
||||||
.subtitle(shared_item_summary(item))
|
.subtitle(shared_item_summary(item))
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if item.is_dir {
|
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
||||||
"folder-symbolic"
|
|
||||||
} else {
|
|
||||||
"text-x-generic-symbolic"
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Copy / open the public link, when there is one with a recovered URL.
|
// Copy / open the public link, when there is one with a recovered URL.
|
||||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
||||||
@@ -245,7 +243,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
.build();
|
.build();
|
||||||
manage.add_css_class("flat");
|
manage.add_css_class("flat");
|
||||||
let ui_manage = ui.clone();
|
let ui_manage = ui.clone();
|
||||||
let entry = shared_item_as_entry(item);
|
|
||||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||||
row.add_suffix(&manage);
|
row.add_suffix(&manage);
|
||||||
|
|
||||||
@@ -289,7 +286,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
is_dir: item.is_dir,
|
is_dir: item.is_dir,
|
||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: item.modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ pub(crate) struct StatusState {
|
|||||||
/// Set while a settings widget is being populated programmatically, so its
|
/// Set while a settings widget is being populated programmatically, so its
|
||||||
/// change handler skips the IPC/systemd side effect.
|
/// change handler skips the IPC/systemd side effect.
|
||||||
pub(crate) settings_suppress: Cell<bool>,
|
pub(crate) settings_suppress: Cell<bool>,
|
||||||
|
/// Pending debounce for the cache-budget editor. Each `+` click is a value
|
||||||
|
/// change, and firing a `SetCacheBudget` (and a toast) per click means a
|
||||||
|
/// stack of toasts and a series of caps the user never asked to apply — so
|
||||||
|
/// only the value they settle on is sent.
|
||||||
|
pub(crate) budget_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) pins_group: adw::PreferencesGroup,
|
pub(crate) pins_group: adw::PreferencesGroup,
|
||||||
|
/// Whether the pin list is showing every pin or only the first
|
||||||
|
/// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below
|
||||||
|
/// it — including the Developer group — off the end of the page.
|
||||||
|
pub(crate) pins_expanded: Cell<bool>,
|
||||||
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
|
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
|
||||||
/// diff against them and only rebuild when the pin set actually changes.
|
/// diff against them and only rebuild when the pin set actually changes.
|
||||||
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
|
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
|
||||||
@@ -104,6 +113,8 @@ pub(crate) struct MainWidgets {
|
|||||||
/// which is where the folder is changed.
|
/// which is where the folder is changed.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
pub(crate) mountpoint_button: gtk4::Button,
|
||||||
|
/// Opens the Google Photos Takeout import page.
|
||||||
|
pub(crate) import_row: adw::ActionRow,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main (logged-in) page: a libadwaita settings surface — account header,
|
/// The main (logged-in) page: a libadwaita settings surface — account header,
|
||||||
@@ -233,6 +244,22 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
mountpoint_row.add_suffix(&mountpoint_button);
|
mountpoint_row.add_suffix(&mountpoint_button);
|
||||||
system_group.add(&mountpoint_row);
|
system_group.add(&mountpoint_row);
|
||||||
|
|
||||||
|
// Import: a one-off migration rather than a setting, so it is a doorway to
|
||||||
|
// its own page rather than controls inlined here — staging a set of archives
|
||||||
|
// and watching an hours-long upload needs the room.
|
||||||
|
let import_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Import")
|
||||||
|
.description("Bring a photo library from another service into Proton Photos.")
|
||||||
|
.build();
|
||||||
|
let import_row = adw::ActionRow::builder()
|
||||||
|
.title("Import from Google Photos")
|
||||||
|
.subtitle("Add a Google Takeout export to your timeline.")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
import_row.add_prefix(>k4::Image::from_icon_name("folder-download-symbolic"));
|
||||||
|
import_row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
import_group.add(&import_row);
|
||||||
|
|
||||||
// Pins group: filled in by refresh.
|
// Pins group: filled in by refresh.
|
||||||
let pins_group = adw::PreferencesGroup::builder()
|
let pins_group = adw::PreferencesGroup::builder()
|
||||||
.title("Pinned files")
|
.title("Pinned files")
|
||||||
@@ -265,6 +292,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
inner.append(&transfers_group);
|
inner.append(&transfers_group);
|
||||||
inner.append(&storage_group);
|
inner.append(&storage_group);
|
||||||
inner.append(&system_group);
|
inner.append(&system_group);
|
||||||
|
inner.append(&import_group);
|
||||||
inner.append(&pins_group);
|
inner.append(&pins_group);
|
||||||
inner.append(&dev_group);
|
inner.append(&dev_group);
|
||||||
|
|
||||||
@@ -292,10 +320,20 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
purge_button,
|
purge_button,
|
||||||
mountpoint_row,
|
mountpoint_row,
|
||||||
mountpoint_button,
|
mountpoint_button,
|
||||||
|
import_row,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many pins the Settings list shows before collapsing the rest behind a
|
||||||
|
/// "Show all" row. Enough to recognise the list at a glance; short enough that
|
||||||
|
/// the groups below it stay reachable.
|
||||||
|
pub(crate) const PINS_COLLAPSED: usize = 6;
|
||||||
|
|
||||||
|
/// How long the cache-budget editor waits after the last change before applying
|
||||||
|
/// it, so a run of `+` clicks is one request rather than one per click.
|
||||||
|
pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600);
|
||||||
|
|
||||||
/// Bytes per GiB, for the cache-budget editor's unit conversion.
|
/// Bytes per GiB, for the cache-budget editor's unit conversion.
|
||||||
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||||
|
|
||||||
@@ -308,6 +346,7 @@ pub(crate) fn wire_settings(
|
|||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
purge_button: >k4::Button,
|
purge_button: >k4::Button,
|
||||||
mountpoint_button: >k4::Button,
|
mountpoint_button: >k4::Button,
|
||||||
|
import_row: &adw::ActionRow,
|
||||||
) {
|
) {
|
||||||
let config = ui.dirs.load_config();
|
let config = ui.dirs.load_config();
|
||||||
|
|
||||||
@@ -329,14 +368,23 @@ pub(crate) fn wire_settings(
|
|||||||
if ui_budget.status.settings_suppress.get() {
|
if ui_budget.status.settings_suppress.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Replace any pending apply, so only the value the user stops on is sent.
|
||||||
|
if let Some(src) = ui_budget.status.budget_source.borrow_mut().take() {
|
||||||
|
src.remove();
|
||||||
|
}
|
||||||
let bytes = (row.value() * GIB).round() as u64;
|
let bytes = (row.value() * GIB).round() as u64;
|
||||||
|
let ui_fire = ui_budget.clone();
|
||||||
|
let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || {
|
||||||
|
ui_fire.status.budget_source.borrow_mut().take();
|
||||||
settings_request(
|
settings_request(
|
||||||
&ui_budget,
|
&ui_fire,
|
||||||
Request::SetCacheBudget { bytes },
|
Request::SetCacheBudget { bytes },
|
||||||
"Cache budget updated",
|
"Cache budget updated",
|
||||||
"Couldn't set cache budget",
|
"Couldn't set cache budget",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
*ui_budget.status.budget_source.borrow_mut() = Some(src);
|
||||||
|
});
|
||||||
|
|
||||||
// Purge: confirm, then drop all unpinned cached content via the daemon.
|
// Purge: confirm, then drop all unpinned cached content via the daemon.
|
||||||
let ui_purge = ui.clone();
|
let ui_purge = ui.clone();
|
||||||
@@ -382,6 +430,10 @@ pub(crate) fn wire_settings(
|
|||||||
// local path; this button is the way there.
|
// local path; this button is the way there.
|
||||||
let ui_mp = ui.clone();
|
let ui_mp = ui.clone();
|
||||||
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
||||||
|
|
||||||
|
// Import: a doorway, not a setting — everything about it lives on its page.
|
||||||
|
let ui_import = ui.clone();
|
||||||
|
import_row.connect_activated(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||||
@@ -513,8 +565,14 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
refresh_transfers(ui);
|
refresh_transfers(ui);
|
||||||
// Both of these pages show work as it happens, so they follow the tick while
|
// Both of these pages show work as it happens, so they follow the tick while
|
||||||
// they are on screen. Every other page loads on navigation only.
|
// they are on screen. Every other page loads on navigation only.
|
||||||
|
// A Takeout import outlives the page that started it, so it is polled
|
||||||
|
// wherever the user has navigated to — otherwise leaving the page means
|
||||||
|
// never being told it finished.
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
|
||||||
|
refresh_takeout(ui);
|
||||||
|
}
|
||||||
match ui.stack.visible_child_name().as_deref() {
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
Some("main") => refresh_quota(ui),
|
Some("main" | "browser") => refresh_quota(ui),
|
||||||
Some("locations") => refresh_locations(ui),
|
Some("locations") => refresh_locations(ui),
|
||||||
Some("activity") => refresh_activity(ui),
|
Some("activity") => refresh_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -522,13 +580,11 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How long a quota reading stays fresh. Account storage barely moves, so the
|
/// How long a quota reading stays fresh. Account storage barely moves, so the
|
||||||
/// Settings tick refetches it only this often rather than every 2s.
|
/// active-page tick refetches it only this often rather than every 2s.
|
||||||
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Fetch the account quota (if the last reading is stale) and paint the Account
|
/// Fetch the account quota (if the last reading is stale) and paint both the
|
||||||
/// storage group. Runs only while Settings is on screen. The group stays hidden
|
/// Settings storage group and Files status bar. A failed fetch leaves the last
|
||||||
/// until a reading lands, so an account the API can't report — or a still-starting
|
|
||||||
/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last
|
|
||||||
/// good reading in place.
|
/// good reading in place.
|
||||||
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||||
if ui.status.quota_inflight.get() {
|
if ui.status.quota_inflight.get() {
|
||||||
@@ -551,26 +607,69 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
|||||||
})) = result
|
})) = result
|
||||||
{
|
{
|
||||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||||
let used = used_space.max(0) as u64;
|
paint_account_quota(&ui, max_space, used_space);
|
||||||
if max_space > 0 {
|
ui.status.quota_group.set_visible(true);
|
||||||
let total = max_space as u64;
|
} else if ui.status.quota_checked_at.get().is_none() {
|
||||||
let fraction = (used as f64 / total as f64).min(1.0);
|
// Match Dolphin: capacity information does not occupy the bar until
|
||||||
|
// the backing observer has real figures.
|
||||||
|
ui.browser.quota_box.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
|
||||||
|
let (fraction, text) = quota_display(max_space, used_space);
|
||||||
ui.status.quota_bar.set_fraction(fraction);
|
ui.status.quota_bar.set_fraction(fraction);
|
||||||
|
ui.status.quota_label.set_text(&text);
|
||||||
|
if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) {
|
||||||
|
ui.browser.quota.set_fraction(fraction);
|
||||||
|
ui.browser.quota.set_tooltip_text(Some(&tooltip));
|
||||||
|
ui.browser.quota_text.set_label(&free_text);
|
||||||
|
ui.browser.quota_text.set_tooltip_text(Some(&tooltip));
|
||||||
|
ui.browser.quota_box.set_visible(true);
|
||||||
|
} else {
|
||||||
|
ui.browser.quota_box.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||||
|
let used = used_space.max(0) as u64;
|
||||||
|
if max_space <= 0 {
|
||||||
|
return (0.0, format!("{} used", human_bytes(used)));
|
||||||
|
}
|
||||||
|
let total = max_space as u64;
|
||||||
|
let fraction = (used as f64 / total as f64).clamp(0.0, 1.0);
|
||||||
let pct = (fraction * 100.0).round() as u64;
|
let pct = (fraction * 100.0).round() as u64;
|
||||||
ui.status.quota_label.set_text(&format!(
|
(
|
||||||
|
fraction,
|
||||||
|
format!(
|
||||||
"{} of {} used ({pct}%)",
|
"{} of {} used ({pct}%)",
|
||||||
human_bytes(used),
|
human_bytes(used),
|
||||||
human_bytes(total)
|
human_bytes(total)
|
||||||
));
|
),
|
||||||
} else {
|
)
|
||||||
ui.status.quota_bar.set_fraction(0.0);
|
}
|
||||||
ui.status
|
|
||||||
.quota_label
|
/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the
|
||||||
.set_text(&format!("{} used", human_bytes(used)));
|
/// full free/total/percentage sentence is a tooltip rather than inline bar text.
|
||||||
|
fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> {
|
||||||
|
if max_space <= 0 {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
ui.status.quota_group.set_visible(true);
|
let total = max_space as u64;
|
||||||
}
|
let used = (used_space.max(0) as u64).min(total);
|
||||||
});
|
let free = total.saturating_sub(used);
|
||||||
|
let fraction = used as f64 / total as f64;
|
||||||
|
let pct = (fraction * 100.0).round() as u64;
|
||||||
|
Some((
|
||||||
|
fraction,
|
||||||
|
format!("{} free", human_bytes(free)),
|
||||||
|
format!(
|
||||||
|
"{} free out of {} ({pct}% used)",
|
||||||
|
human_bytes(free),
|
||||||
|
human_bytes(total)
|
||||||
|
),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record the mount state seen by the last status poll: gate every control that
|
/// Record the mount state seen by the last status poll: gate every control that
|
||||||
@@ -584,6 +683,11 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
|||||||
ui.browser.new_folder.set_sensitive(mounted);
|
ui.browser.new_folder.set_sensitive(mounted);
|
||||||
ui.browser.upload.set_sensitive(mounted);
|
ui.browser.upload.set_sensitive(mounted);
|
||||||
ui.browser.upload_folder.set_sensitive(mounted);
|
ui.browser.upload_folder.set_sensitive(mounted);
|
||||||
|
ui.browser.build_thumbnails.set_sensitive(
|
||||||
|
mounted
|
||||||
|
&& (!ui.browser.thumbnail_build_running.get()
|
||||||
|
|| !ui.browser.thumbnail_cancel_pending.get()),
|
||||||
|
);
|
||||||
ui.gallery.upload.set_sensitive(mounted);
|
ui.gallery.upload.set_sensitive(mounted);
|
||||||
ui.details.details.pin_row.set_sensitive(mounted);
|
ui.details.details.pin_row.set_sensitive(mounted);
|
||||||
ui.details.details.rename_button.set_sensitive(mounted);
|
ui.details.details.rename_button.set_sensitive(mounted);
|
||||||
@@ -653,6 +757,10 @@ 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() && previous > 0 {
|
if items.is_empty() && previous > 0 {
|
||||||
let files = if previous == 1 {
|
let files = if previous == 1 {
|
||||||
@@ -808,11 +916,14 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
|||||||
(used as f64 / budget as f64).min(1.0)
|
(used as f64 / budget as f64).min(1.0)
|
||||||
};
|
};
|
||||||
ui.status.cache_bar.set_fraction(fraction);
|
ui.status.cache_bar.set_fraction(fraction);
|
||||||
ui.status.cache_label.set_text(&format!(
|
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
|
||||||
"{} of {} used",
|
// used" reads as a broken read-out, and there is no fraction to
|
||||||
human_bytes(used),
|
// draw against no limit.
|
||||||
human_bytes(budget)
|
ui.status.cache_label.set_text(&if budget == 0 {
|
||||||
));
|
format!("{} cached — no limit set", human_bytes(used))
|
||||||
|
} else {
|
||||||
|
format!("{} of {} used", human_bytes(used), human_bytes(budget))
|
||||||
|
});
|
||||||
repaint_pins(&ui, &pins, true);
|
repaint_pins(&ui, &pins, true);
|
||||||
}
|
}
|
||||||
// Daemon unreachable (still starting, or down): report not-mounted and
|
// Daemon unreachable (still starting, or down): report not-mounted and
|
||||||
@@ -865,7 +976,15 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for pin in pins {
|
// Only the first page of pins is rendered; the rest sit behind the row
|
||||||
|
// below, so a device with hundreds of pins doesn't bury the groups after it.
|
||||||
|
let expanded = ui.status.pins_expanded.get();
|
||||||
|
let shown = if expanded {
|
||||||
|
pins.len()
|
||||||
|
} else {
|
||||||
|
pins.len().min(PINS_COLLAPSED)
|
||||||
|
};
|
||||||
|
for pin in &pins[..shown] {
|
||||||
let name = Path::new(&pin.path)
|
let name = Path::new(&pin.path)
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
@@ -903,4 +1022,70 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
unpin: Some(unpin),
|
unpin: Some(unpin),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pins.len() > PINS_COLLAPSED {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(if expanded {
|
||||||
|
"Show fewer".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Show all {} pinned files", pins.len())
|
||||||
|
})
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(>k4::Image::from_icon_name(if expanded {
|
||||||
|
"go-up-symbolic"
|
||||||
|
} else {
|
||||||
|
"go-down-symbolic"
|
||||||
|
}));
|
||||||
|
let ui_more = ui.clone();
|
||||||
|
row.connect_activated(move |_| {
|
||||||
|
ui_more
|
||||||
|
.status
|
||||||
|
.pins_expanded
|
||||||
|
.set(!ui_more.status.pins_expanded.get());
|
||||||
|
// The diff guard compares pin *paths*; the same paths render
|
||||||
|
// differently now, so the baseline has to be dropped for the next
|
||||||
|
// repaint to actually rebuild.
|
||||||
|
*ui_more.status.pins_state.borrow_mut() = None;
|
||||||
|
refresh(&ui_more);
|
||||||
|
});
|
||||||
|
ui.status.pins_group.add(&row);
|
||||||
|
ui.status
|
||||||
|
.pin_rows
|
||||||
|
.borrow_mut()
|
||||||
|
.push(PinRow { row, unpin: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{quota_display, quota_status_display};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_display_reports_used_total_and_percentage() {
|
||||||
|
let gib = 1024_i64.pow(3);
|
||||||
|
let (fraction, text) = quota_display(4 * gib, gib);
|
||||||
|
assert!((fraction - 0.25).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_display_clamps_bad_api_values() {
|
||||||
|
assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string()));
|
||||||
|
assert_eq!(quota_display(100, 150).0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_status_display_matches_dolphin_wording() {
|
||||||
|
let gib = 1024_i64.pow(3);
|
||||||
|
assert_eq!(
|
||||||
|
quota_status_display(4 * gib, gib),
|
||||||
|
Some((
|
||||||
|
0.25,
|
||||||
|
"3.0 GiB free".to_string(),
|
||||||
|
"3.0 GiB free out of 4.0 GiB (25% used)".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(quota_status_display(0, 0), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,779 @@
|
|||||||
|
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
||||||
|
//! Photos.
|
||||||
|
//!
|
||||||
|
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
|
||||||
|
//! import button), because it is a one-off migration rather than something the
|
||||||
|
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
||||||
|
//! for two reasons: an export is a *set* of archives that has to be staged and
|
||||||
|
//! checked before anything is sent, and the import that follows runs for hours,
|
||||||
|
//! so its progress needs somewhere to live that the user can navigate away from
|
||||||
|
//! and come back to.
|
||||||
|
//!
|
||||||
|
//! Archives arrive either by drag-and-drop onto the page or through the file
|
||||||
|
//! chooser; both funnel into [`add_archives`], which is where the `.zip` filter
|
||||||
|
//! and de-duplication live. Nothing is sent until the user presses Scan or
|
||||||
|
//! Import — staging is local, so a mis-drop costs nothing.
|
||||||
|
//!
|
||||||
|
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||||
|
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
|
||||||
|
//! and reads live progress off the import's job in the transfer snapshot the
|
||||||
|
//! Settings tick already fetches (see [`takeout_progress`]).
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||||
|
/// Matching on it is how this page borrows the progress the Settings tick is
|
||||||
|
/// already polling instead of adding a second poll of its own.
|
||||||
|
const IMPORT_JOB: &str = "Importing Google Photos";
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// this back when the list is emptied again.
|
||||||
|
/// Where the staged set is remembered between runs, under the app's state
|
||||||
|
/// directory. Staging an export means picking dozens of files out of a file
|
||||||
|
/// chooser; losing that to a window close (or a crash mid-import) and having to
|
||||||
|
/// redo it is the kind of small cruelty that makes a migration not get finished.
|
||||||
|
fn staged_path(ui: &Rc<Ui>) -> PathBuf {
|
||||||
|
ui.dirs.state_dir().join("takeout-staged.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the remembered set, dropping any archive that has since been moved or
|
||||||
|
/// deleted — a stale path would only fail at launch, and silently is worse.
|
||||||
|
fn load_staged(ui: &Rc<Ui>) -> Vec<PathBuf> {
|
||||||
|
let Ok(text) = std::fs::read_to_string(staged_path(ui)) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let paths: Vec<String> = serde_json::from_str(&text).unwrap_or_default();
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember the staged set. Best-effort: failing to write it costs the user a
|
||||||
|
/// re-pick next time, which is not worth interrupting them over now.
|
||||||
|
fn save_staged(ui: &Rc<Ui>) {
|
||||||
|
let paths: Vec<String> = ui
|
||||||
|
.takeout
|
||||||
|
.archives
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.collect();
|
||||||
|
let path = staged_path(ui);
|
||||||
|
let written = serde_json::to_string(&paths)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|text| std::fs::write(&path, text).map_err(|e| e.to_string()));
|
||||||
|
if let Err(e) = written {
|
||||||
|
tracing::warn!("cannot remember staged Takeout archives: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \
|
||||||
|
re-running an interrupted import is safe.";
|
||||||
|
|
||||||
|
pub(crate) struct TakeoutState {
|
||||||
|
/// Archives staged for this import, in the order they were added. Sent as
|
||||||
|
/// one set: Google splits an export across numbered zips and routinely puts
|
||||||
|
/// a photo in one part and its metadata sidecar in another, so a per-file
|
||||||
|
/// import would lose album titles and capture times.
|
||||||
|
pub(crate) archives: RefCell<Vec<PathBuf>>,
|
||||||
|
/// The staged-archive list and the rows currently rendered in it.
|
||||||
|
pub(crate) list_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) rows: RefCell<Vec<adw::PreferencesRow>>,
|
||||||
|
/// The drop zone, whose `dropzone-active` class follows the drag.
|
||||||
|
pub(crate) dropzone: gtk4::Box,
|
||||||
|
pub(crate) scan_button: gtk4::Button,
|
||||||
|
pub(crate) import_button: gtk4::Button,
|
||||||
|
pub(crate) cancel_button: gtk4::Button,
|
||||||
|
pub(crate) clear_button: gtk4::Button,
|
||||||
|
/// Progress group, hidden unless an import is running.
|
||||||
|
pub(crate) progress_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) progress_label: gtk4::Label,
|
||||||
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
|
/// Report group for the last finished run, hidden until there is one.
|
||||||
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
|
||||||
|
/// can't stack worker threads on the 2s tick.
|
||||||
|
pub(crate) inflight: Cell<bool>,
|
||||||
|
/// Whether the daemon reported an import running on the last poll.
|
||||||
|
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.
|
||||||
|
pub(crate) struct TakeoutWidgets {
|
||||||
|
pub(crate) list_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) dropzone: gtk4::Box,
|
||||||
|
/// The whole page, which is the drop target: a drag released anywhere on it
|
||||||
|
/// counts, not only inside the dashed rectangle.
|
||||||
|
pub(crate) page: gtk4::Widget,
|
||||||
|
pub(crate) choose_button: gtk4::Button,
|
||||||
|
pub(crate) scan_button: gtk4::Button,
|
||||||
|
pub(crate) import_button: gtk4::Button,
|
||||||
|
pub(crate) cancel_button: gtk4::Button,
|
||||||
|
pub(crate) clear_button: gtk4::Button,
|
||||||
|
pub(crate) back_button: gtk4::Button,
|
||||||
|
pub(crate) progress_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) progress_label: gtk4::Label,
|
||||||
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||||
|
// Header: a back button out to Settings, since this page is reached from
|
||||||
|
// there and has no sidebar row of its own to navigate back with.
|
||||||
|
let back_button = gtk4::Button::builder()
|
||||||
|
.icon_name("go-previous-symbolic")
|
||||||
|
.tooltip_text("Back to Settings")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
back_button.add_css_class("flat");
|
||||||
|
|
||||||
|
let title = gtk4::Label::builder()
|
||||||
|
.label("Import from Google Photos")
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
title.add_css_class("title-2");
|
||||||
|
let subtitle = gtk4::Label::builder()
|
||||||
|
.label("Add a Google Takeout export to your Proton Photos timeline.")
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
subtitle.add_css_class("dim-label");
|
||||||
|
|
||||||
|
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||||
|
titles.set_hexpand(true);
|
||||||
|
titles.append(&title);
|
||||||
|
titles.append(&subtitle);
|
||||||
|
|
||||||
|
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
header.append(&back_button);
|
||||||
|
header.append(&titles);
|
||||||
|
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||||
|
|
||||||
|
// Drop zone. It is a hint, not the only way in — the button under it does
|
||||||
|
// the same thing for anyone not dragging, and the drop target covers the
|
||||||
|
// whole page so a drag released just outside the rectangle still lands.
|
||||||
|
let drop_icon = gtk4::Image::from_icon_name("folder-download-symbolic");
|
||||||
|
drop_icon.set_pixel_size(48);
|
||||||
|
drop_icon.add_css_class("dim-label");
|
||||||
|
let drop_title = gtk4::Label::builder()
|
||||||
|
.label("Drop your Takeout .zip files here")
|
||||||
|
.build();
|
||||||
|
drop_title.add_css_class("title-4");
|
||||||
|
let drop_hint = gtk4::Label::builder()
|
||||||
|
.label("Add every part of the export at once — a photo and its metadata often sit in different parts.")
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk4::Justification::Center)
|
||||||
|
.max_width_chars(48)
|
||||||
|
.build();
|
||||||
|
drop_hint.add_css_class("dim-label");
|
||||||
|
let choose_button = gtk4::Button::builder()
|
||||||
|
.label("Choose files…")
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
choose_button.add_css_class("pill");
|
||||||
|
|
||||||
|
let dropzone = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||||
|
dropzone.set_halign(gtk4::Align::Fill);
|
||||||
|
dropzone.add_css_class("dropzone");
|
||||||
|
dropzone.append(&drop_icon);
|
||||||
|
dropzone.append(&drop_title);
|
||||||
|
dropzone.append(&drop_hint);
|
||||||
|
dropzone.append(&choose_button);
|
||||||
|
|
||||||
|
// Staged archives. Filled by `repaint_archives`, which owns the description
|
||||||
|
// too (it becomes the count + total once there is something to count); the
|
||||||
|
// group carries a placeholder row while empty so it never renders as a bare
|
||||||
|
// title.
|
||||||
|
let list_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Archives to import")
|
||||||
|
.description(EMPTY_LIST_DESCRIPTION)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let clear_button = gtk4::Button::builder()
|
||||||
|
.label("Clear")
|
||||||
|
.tooltip_text("Remove every staged archive")
|
||||||
|
.build();
|
||||||
|
clear_button.add_css_class("flat");
|
||||||
|
|
||||||
|
// Actions. Scan is a dry run: it reads the archives' directories and reports
|
||||||
|
// what an import *would* do, without uploading a byte. It is offered first
|
||||||
|
// because the import behind it is hours long and hard to take back.
|
||||||
|
let scan_button = gtk4::Button::builder()
|
||||||
|
.label("Scan")
|
||||||
|
.tooltip_text("Check the archives and report what would be imported, without uploading")
|
||||||
|
.build();
|
||||||
|
scan_button.add_css_class("pill");
|
||||||
|
let import_button = gtk4::Button::builder().label("Import").build();
|
||||||
|
import_button.add_css_class("pill");
|
||||||
|
import_button.add_css_class("suggested-action");
|
||||||
|
let cancel_button = gtk4::Button::builder()
|
||||||
|
.label("Stop import")
|
||||||
|
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop")
|
||||||
|
.build();
|
||||||
|
cancel_button.add_css_class("pill");
|
||||||
|
cancel_button.add_css_class("destructive-action");
|
||||||
|
cancel_button.set_visible(false);
|
||||||
|
|
||||||
|
// Clear sits apart from the launch buttons, on the other end of the row: it
|
||||||
|
// undoes staging, and putting it next to Import invites the wrong click.
|
||||||
|
let actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
spacer.set_hexpand(true);
|
||||||
|
actions.append(&clear_button);
|
||||||
|
actions.append(&spacer);
|
||||||
|
actions.append(&cancel_button);
|
||||||
|
actions.append(&scan_button);
|
||||||
|
actions.append(&import_button);
|
||||||
|
|
||||||
|
// Progress: the import's job from the transfer snapshot, so this page shows
|
||||||
|
// the same numbers the Settings Activity list does.
|
||||||
|
let progress_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Import in progress")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
progress_box.set_margin_top(8);
|
||||||
|
progress_box.set_margin_bottom(8);
|
||||||
|
let progress_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||||
|
progress_label.add_css_class("dim-label");
|
||||||
|
let progress_bar = gtk4::ProgressBar::new();
|
||||||
|
progress_box.append(&progress_label);
|
||||||
|
progress_box.append(&progress_bar);
|
||||||
|
progress_group.add(
|
||||||
|
&adw::PreferencesRow::builder()
|
||||||
|
.activatable(false)
|
||||||
|
.child(&progress_box)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Report for the last finished run, kept on screen after it ends: an import
|
||||||
|
// outlives the window being on this page, and "what happened" is the whole
|
||||||
|
// reason to come back to it.
|
||||||
|
let summary_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Last import")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
groups.append(&dropzone);
|
||||||
|
groups.append(&list_group);
|
||||||
|
groups.append(&actions);
|
||||||
|
groups.append(&progress_group);
|
||||||
|
groups.append(&summary_group);
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(640)
|
||||||
|
.child(&groups)
|
||||||
|
.build();
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
page.set_margin_top(18);
|
||||||
|
page.set_margin_bottom(18);
|
||||||
|
page.set_margin_start(18);
|
||||||
|
page.set_margin_end(18);
|
||||||
|
page.append(&header_clamp);
|
||||||
|
page.append(&scroll);
|
||||||
|
|
||||||
|
let widgets = TakeoutWidgets {
|
||||||
|
list_group: list_group.clone(),
|
||||||
|
dropzone: dropzone.clone(),
|
||||||
|
page: page.clone().upcast(),
|
||||||
|
choose_button,
|
||||||
|
scan_button: scan_button.clone(),
|
||||||
|
import_button: import_button.clone(),
|
||||||
|
cancel_button: cancel_button.clone(),
|
||||||
|
clear_button,
|
||||||
|
back_button,
|
||||||
|
progress_group: progress_group.clone(),
|
||||||
|
progress_label: progress_label.clone(),
|
||||||
|
progress_bar: progress_bar.clone(),
|
||||||
|
summary_group: summary_group.clone(),
|
||||||
|
};
|
||||||
|
(page.upcast(), widgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||||
|
// Drag-and-drop. The target sits on the whole page rather than the dashed
|
||||||
|
// rectangle, so a drag released anywhere counts; the rectangle only lights
|
||||||
|
// up to say where the drop is understood.
|
||||||
|
let target = gtk4::DropTarget::new(
|
||||||
|
gtk4::gdk::FileList::static_type(),
|
||||||
|
gtk4::gdk::DragAction::COPY,
|
||||||
|
);
|
||||||
|
let ui_enter = ui.clone();
|
||||||
|
target.connect_enter(move |_, _, _| {
|
||||||
|
ui_enter.takeout.dropzone.add_css_class("dropzone-active");
|
||||||
|
gtk4::gdk::DragAction::COPY
|
||||||
|
});
|
||||||
|
let ui_leave = ui.clone();
|
||||||
|
target.connect_leave(move |_| {
|
||||||
|
ui_leave
|
||||||
|
.takeout
|
||||||
|
.dropzone
|
||||||
|
.remove_css_class("dropzone-active");
|
||||||
|
});
|
||||||
|
let ui_drop = ui.clone();
|
||||||
|
target.connect_drop(move |_, value, _, _| {
|
||||||
|
ui_drop.takeout.dropzone.remove_css_class("dropzone-active");
|
||||||
|
let Ok(files) = value.get::<gtk4::gdk::FileList>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let paths: Vec<PathBuf> = files.files().iter().filter_map(|f| f.path()).collect();
|
||||||
|
add_archives(&ui_drop, paths);
|
||||||
|
true
|
||||||
|
});
|
||||||
|
widgets.page.add_controller(target);
|
||||||
|
|
||||||
|
let ui_choose = ui.clone();
|
||||||
|
widgets.choose_button.connect_clicked(move |_| {
|
||||||
|
// Multi-select on purpose: the whole export goes in one request.
|
||||||
|
let dialog = gtk4::FileDialog::builder()
|
||||||
|
.title("Select your Google Takeout archives")
|
||||||
|
.build();
|
||||||
|
let filter = gtk4::FileFilter::new();
|
||||||
|
filter.set_name(Some("Takeout archives"));
|
||||||
|
filter.add_pattern("*.zip");
|
||||||
|
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
||||||
|
filters.append(&filter);
|
||||||
|
dialog.set_filters(Some(&filters));
|
||||||
|
|
||||||
|
let ui = ui_choose.clone();
|
||||||
|
dialog.open_multiple(
|
||||||
|
ui_window(&ui_choose).as_ref(),
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
move |res| {
|
||||||
|
let Ok(files) = res else { return };
|
||||||
|
let paths: Vec<PathBuf> = (0..files.n_items())
|
||||||
|
.filter_map(|index| files.item(index))
|
||||||
|
.filter_map(|object| object.downcast::<gio::File>().ok())
|
||||||
|
.filter_map(|file| file.path())
|
||||||
|
.collect();
|
||||||
|
add_archives(&ui, paths);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_clear = ui.clone();
|
||||||
|
widgets.clear_button.connect_clicked(move |_| {
|
||||||
|
ui_clear.takeout.archives.borrow_mut().clear();
|
||||||
|
repaint_archives(&ui_clear);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_scan = ui.clone();
|
||||||
|
widgets
|
||||||
|
.scan_button
|
||||||
|
.connect_clicked(move |_| start_import(&ui_scan, true));
|
||||||
|
|
||||||
|
// The import itself is confirmed: it uploads a whole photo library, which is
|
||||||
|
// hours of bandwidth and a large change to the account's timeline.
|
||||||
|
let ui_import = ui.clone();
|
||||||
|
widgets.import_button.connect_clicked(move |_| {
|
||||||
|
let count = ui_import.takeout.archives.borrow().len();
|
||||||
|
let ui = ui_import.clone();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Import this export?")
|
||||||
|
.body(format!(
|
||||||
|
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
|
||||||
|
appear in the export. This can take hours; you can stop it at any point and \
|
||||||
|
what has been uploaded stays. Photos you already have are skipped.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("import", "Import");
|
||||||
|
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp == "import" {
|
||||||
|
start_import(&ui, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(&ui_import).as_ref());
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_cancel = ui.clone();
|
||||||
|
widgets.cancel_button.connect_clicked(move |_| {
|
||||||
|
let ui = ui_cancel.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::CancelImport);
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't stop the import", &message, kind);
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't stop the import",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_back = ui.clone();
|
||||||
|
widgets
|
||||||
|
.back_button
|
||||||
|
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
|
||||||
|
|
||||||
|
// Restore whatever was staged when the window last closed, before the first
|
||||||
|
// paint, so the page comes up as the user left it.
|
||||||
|
*ui.takeout.archives.borrow_mut() = load_staged(ui);
|
||||||
|
repaint_archives(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage `paths`, keeping only `.zip` files and skipping ones already staged.
|
||||||
|
///
|
||||||
|
/// Both the chooser and the drop target land here, so the filter is stated once.
|
||||||
|
/// A dropped folder (an already-extracted export) is rejected rather than walked:
|
||||||
|
/// the scan reads the archives' central directories, and an extracted tree has
|
||||||
|
/// none — silently importing half of it would be worse than saying no.
|
||||||
|
pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||||
|
let mut added = 0usize;
|
||||||
|
let mut rejected = 0usize;
|
||||||
|
{
|
||||||
|
let mut staged = ui.takeout.archives.borrow_mut();
|
||||||
|
for path in paths {
|
||||||
|
let is_zip = path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip"));
|
||||||
|
if !is_zip || !path.is_file() {
|
||||||
|
rejected += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if staged.contains(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
staged.push(path);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
// Numbered parts drop in whatever order the file manager hands them
|
||||||
|
// over; sorting keeps the list readable and the request stable.
|
||||||
|
staged.sort();
|
||||||
|
}
|
||||||
|
repaint_archives(ui);
|
||||||
|
if rejected > 0 {
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||||
|
);
|
||||||
|
} else if added > 0 {
|
||||||
|
toast(ui, &format!("Added {added} archive(s)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the staged-archive list and re-derive which actions are available.
|
||||||
|
pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||||
|
for row in ui.takeout.rows.borrow_mut().drain(..) {
|
||||||
|
ui.takeout.list_group.remove(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let staged = ui.takeout.archives.borrow().clone();
|
||||||
|
let mut rows: Vec<adw::PreferencesRow> = Vec::new();
|
||||||
|
if staged.is_empty() {
|
||||||
|
ui.takeout
|
||||||
|
.list_group
|
||||||
|
.set_description(Some(EMPTY_LIST_DESCRIPTION));
|
||||||
|
let row = dim_row("No archives added yet.");
|
||||||
|
ui.takeout.list_group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
} else {
|
||||||
|
let mut total = 0u64;
|
||||||
|
for path in &staged {
|
||||||
|
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
total += size;
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(
|
||||||
|
path.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.display().to_string()),
|
||||||
|
)
|
||||||
|
.subtitle(human_bytes(size))
|
||||||
|
.build();
|
||||||
|
let remove = gtk4::Button::builder()
|
||||||
|
.icon_name("edit-delete-symbolic")
|
||||||
|
.tooltip_text("Remove from this import")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
remove.add_css_class("flat");
|
||||||
|
let ui_remove = ui.clone();
|
||||||
|
let target = path.clone();
|
||||||
|
remove.connect_clicked(move |_| {
|
||||||
|
ui_remove
|
||||||
|
.takeout
|
||||||
|
.archives
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|p| *p != target);
|
||||||
|
repaint_archives(&ui_remove);
|
||||||
|
});
|
||||||
|
row.add_suffix(&remove);
|
||||||
|
ui.takeout.list_group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
}
|
||||||
|
ui.takeout.list_group.set_description(Some(&format!(
|
||||||
|
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||||
|
staged.len(),
|
||||||
|
human_bytes(total)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
*ui.takeout.rows.borrow_mut() = rows;
|
||||||
|
|
||||||
|
save_staged(ui);
|
||||||
|
sync_takeout_actions(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable exactly the actions that make sense right now: nothing to launch
|
||||||
|
/// without archives, nothing to launch while a run is in flight, and Stop only
|
||||||
|
/// while there is something to stop.
|
||||||
|
fn sync_takeout_actions(ui: &Rc<Ui>) {
|
||||||
|
let has_files = !ui.takeout.archives.borrow().is_empty();
|
||||||
|
let running = ui.takeout.running.get();
|
||||||
|
ui.takeout.scan_button.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout
|
||||||
|
.import_button
|
||||||
|
.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout.clear_button.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout.cancel_button.set_visible(running);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the staged set to the daemon. It acks immediately and works in the
|
||||||
|
/// background — an export is far past any socket timeout — so success here means
|
||||||
|
/// "started", and the page switches over to reporting progress.
|
||||||
|
fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
||||||
|
let archives: Vec<String> = ui
|
||||||
|
.takeout
|
||||||
|
.archives
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.collect();
|
||||||
|
if archives.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.takeout.dry_run.set(dry_run);
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ImportTakeout { archives, dry_run },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
ui.takeout.running.set(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 {
|
||||||
|
"Reading archives…"
|
||||||
|
} else {
|
||||||
|
"Starting import…"
|
||||||
|
});
|
||||||
|
sync_takeout_actions(&ui);
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
if dry_run {
|
||||||
|
"Scanning the export…"
|
||||||
|
} else {
|
||||||
|
"Import started — you can leave this page, it keeps running."
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't start the import", &message, kind);
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't start the import",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the daemon for the import's state while the page is on screen, and paint
|
||||||
|
/// the report once a run has finished. Cheap and inflight-guarded, like the other
|
||||||
|
/// tick polls.
|
||||||
|
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||||
|
if ui.takeout.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.takeout.inflight.set(true);
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ImportStatus);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.takeout.inflight.set(false);
|
||||||
|
let Ok(Ok(Response::ImportStatus { running, summary })) = result else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let was_running = ui.takeout.running.replace(running);
|
||||||
|
if running {
|
||||||
|
ui.takeout.progress_group.set_visible(true);
|
||||||
|
} else {
|
||||||
|
ui.takeout.progress_group.set_visible(false);
|
||||||
|
// An import runs for hours, so its ending is announced rather than
|
||||||
|
// left to be discovered: a toast for whoever is looking at the
|
||||||
|
// window, and a desktop notification for whoever is not.
|
||||||
|
if was_running {
|
||||||
|
let dry = ui.takeout.dry_run.get();
|
||||||
|
let headline = if dry {
|
||||||
|
"Scan finished"
|
||||||
|
} else {
|
||||||
|
"Import finished"
|
||||||
|
};
|
||||||
|
toast(&ui, headline);
|
||||||
|
if !dry {
|
||||||
|
let body = match &summary {
|
||||||
|
Some(s) if s.failed > 0 => {
|
||||||
|
format!("{} photos added, {} failed.", s.uploaded, s.failed)
|
||||||
|
}
|
||||||
|
Some(s) if s.cancelled => {
|
||||||
|
format!("Stopped after {} photos.", s.uploaded)
|
||||||
|
}
|
||||||
|
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded),
|
||||||
|
None => "The Google Photos import has finished.".to_string(),
|
||||||
|
};
|
||||||
|
notify("takeout-import", headline, &body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if was_running != running {
|
||||||
|
sync_takeout_actions(&ui);
|
||||||
|
}
|
||||||
|
match summary {
|
||||||
|
Some(summary) => repaint_summary(&ui, &summary),
|
||||||
|
None => ui.takeout.summary_group.set_visible(false),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the last run's report.
|
||||||
|
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||||
|
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
|
||||||
|
ui.takeout.summary_group.remove(&row);
|
||||||
|
}
|
||||||
|
let dry = ui.takeout.dry_run.get();
|
||||||
|
ui.takeout
|
||||||
|
.summary_group
|
||||||
|
.set_title(if dry { "Scan result" } else { "Last import" });
|
||||||
|
ui.takeout.summary_group.set_description(Some(if dry {
|
||||||
|
"Nothing was uploaded — this is what an import would do."
|
||||||
|
} else if summary.cancelled {
|
||||||
|
"Stopped early. Everything uploaded so far is in your timeline; run it again to \
|
||||||
|
continue where it left off."
|
||||||
|
} else {
|
||||||
|
"Finished."
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let mut lines: Vec<(String, String)> = vec![
|
||||||
|
("Photos in the export".into(), summary.found.to_string()),
|
||||||
|
(
|
||||||
|
if dry { "Would upload" } else { "Uploaded" }.into(),
|
||||||
|
summary.uploaded.to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if summary.duplicates > 0 {
|
||||||
|
lines.push((
|
||||||
|
"Already in Proton Photos".into(),
|
||||||
|
summary.duplicates.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 {
|
||||||
|
lines.push((
|
||||||
|
if dry {
|
||||||
|
"Albums to create"
|
||||||
|
} else {
|
||||||
|
"Albums created"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
summary.albums_created.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.album_links > 0 {
|
||||||
|
lines.push((
|
||||||
|
"Photos filed into albums".into(),
|
||||||
|
summary.album_links.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.skipped_trashed > 0 {
|
||||||
|
lines.push((
|
||||||
|
"Skipped (in Google's trash)".into(),
|
||||||
|
summary.skipped_trashed.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.bytes > 0 {
|
||||||
|
lines.push((
|
||||||
|
if dry {
|
||||||
|
"Data to upload"
|
||||||
|
} else {
|
||||||
|
"Data uploaded"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
human_bytes(summary.bytes),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
lines.push(("Failed".into(), summary.failed.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (title, value) in lines {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(title)
|
||||||
|
.subtitle(value)
|
||||||
|
.build();
|
||||||
|
row.add_css_class("property");
|
||||||
|
ui.takeout.summary_group.add(&row);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
*ui.takeout.summary_rows.borrow_mut() = rows;
|
||||||
|
ui.takeout.summary_group.set_visible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the import's progress from the transfer snapshot the Settings tick
|
||||||
|
/// already fetched, so this page costs no extra poll. 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));
|
||||||
|
ui.takeout
|
||||||
|
.progress_label
|
||||||
|
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail));
|
||||||
|
} else {
|
||||||
|
ui.takeout.progress_bar.pulse();
|
||||||
|
ui.takeout
|
||||||
|
.progress_label
|
||||||
|
.set_text(if job.detail.is_empty() {
|
||||||
|
"Working…"
|
||||||
|
} else {
|
||||||
|
&job.detail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
|||||||
factory.connect_setup(move |_, item| {
|
factory.connect_setup(move |_, item| {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
|
||||||
let icon = gtk4::Image::builder().pixel_size(32).build();
|
let thumbnail = file_thumbnail_widget(40, 32);
|
||||||
let name = gtk4::Label::builder()
|
let name = gtk4::Label::builder()
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||||
@@ -169,23 +169,25 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
|||||||
row.set_margin_bottom(6);
|
row.set_margin_bottom(6);
|
||||||
row.set_margin_start(6);
|
row.set_margin_start(6);
|
||||||
row.set_margin_end(6);
|
row.set_margin_end(6);
|
||||||
row.append(&icon);
|
row.append(&thumbnail);
|
||||||
row.append(&text);
|
row.append(&text);
|
||||||
row.append(&restore);
|
row.append(&restore);
|
||||||
row.append(&purge);
|
row.append(&purge);
|
||||||
item.set_child(Some(&row));
|
item.set_child(Some(&row));
|
||||||
});
|
});
|
||||||
factory.connect_bind(|_, item| {
|
factory.connect_bind({
|
||||||
|
let ui = ui.clone();
|
||||||
|
move |_, item| {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
let Some(entry) = bound_entry(item) else {
|
let Some(entry) = bound_entry(item) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
|
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||||
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
|
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||||
@@ -199,6 +201,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
|||||||
};
|
};
|
||||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
list.set_factory(Some(&factory));
|
list.set_factory(Some(&factory));
|
||||||
|
|
||||||
@@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
|||||||
|
|
||||||
/// Fetch the trash listing and repaint the page.
|
/// Fetch the trash listing and repaint the page.
|
||||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
// Drop the old rows first: a stale row here would offer Restore on something
|
// Drop the old rows first: a stale row here would offer Restore on something
|
||||||
// that may already be gone.
|
// that may already be gone.
|
||||||
ui.trash.model.remove_all();
|
ui.trash.model.remove_all();
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ pub(crate) struct DetailsState {
|
|||||||
pub(crate) details_suppress: Cell<bool>,
|
pub(crate) details_suppress: Cell<bool>,
|
||||||
/// The grid's and list's selection models. Both wrap the one browser model, so
|
/// The grid's and list's selection models. Both wrap the one browser model, so
|
||||||
/// a selection in either drives the details pane.
|
/// a selection in either drives the details pane.
|
||||||
pub(crate) grid_selection: gtk4::SingleSelection,
|
pub(crate) grid_selection: gtk4::MultiSelection,
|
||||||
pub(crate) list_selection: gtk4::SingleSelection,
|
pub(crate) list_selection: gtk4::MultiSelection,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The widgets in the browser's details pane that a selection repaints.
|
/// The widgets in the browser's details pane that a selection repaints.
|
||||||
@@ -158,24 +158,22 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
|||||||
ui.details.list_selection.clone(),
|
ui.details.list_selection.clone(),
|
||||||
] {
|
] {
|
||||||
let ui_sel = ui.clone();
|
let ui_sel = ui.clone();
|
||||||
selection.connect_selection_changed(move |sel, _, _| {
|
selection.connect_selection_changed(move |_, _, _| {
|
||||||
match entry_at(sel.model().as_ref(), sel.selected()) {
|
// The details pane describes *one* entry. A batch is the bulk bar's
|
||||||
Some(entry) => show_details(&ui_sel, &entry),
|
// business, so the pane steps aside rather than picking a member of
|
||||||
None => hide_details(&ui_sel),
|
// the selection to speak for the rest.
|
||||||
|
let entries = selected_entries(&ui_sel);
|
||||||
|
match entries.as_slice() {
|
||||||
|
[entry] => show_details(&ui_sel, entry),
|
||||||
|
_ => hide_details(&ui_sel),
|
||||||
}
|
}
|
||||||
|
sync_bulk_bar(&ui_sel);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let ui_close = ui.clone();
|
let ui_close = ui.clone();
|
||||||
ui.details.details.close_button.connect_clicked(move |_| {
|
ui.details.details.close_button.connect_clicked(move |_| {
|
||||||
ui_close
|
clear_selection(&ui_close);
|
||||||
.details
|
|
||||||
.grid_selection
|
|
||||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
|
||||||
ui_close
|
|
||||||
.details
|
|
||||||
.list_selection
|
|
||||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
|
||||||
hide_details(&ui_close);
|
hide_details(&ui_close);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,8 +287,9 @@ pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
|||||||
*ui.details.details_entry.borrow_mut() = None;
|
*ui.details.details_entry.borrow_mut() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entry highlighted in whichever browser view is on screen, if any. Backs the
|
/// The single entry the details pane is showing, if any. Backs the actions that
|
||||||
/// F2 / Delete shortcuts.
|
/// only make sense on one entry (rename, versions); the batch-capable ones ask
|
||||||
|
/// [`selected_entries`] instead.
|
||||||
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||||
ui.details.details_entry.borrow().clone()
|
ui.details.details_entry.borrow().clone()
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
|
pub(crate) mod thumbnails;
|
||||||
pub(crate) mod versions_dialog;
|
pub(crate) mod versions_dialog;
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! Shared thumbnails for ordinary Drive image files.
|
||||||
|
//!
|
||||||
|
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
|
||||||
|
//! File listings need a smaller, reusable surface: one loader backs My files,
|
||||||
|
//! search, Shared and Trash, with modification-time cache keys
|
||||||
|
//! so a replaced image never flashes its previous contents.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
|
||||||
|
|
||||||
|
const THUMB_BATCH: usize = 32;
|
||||||
|
const THUMB_CACHE_MAX: usize = 256;
|
||||||
|
const THUMB_MISSING_MAX: usize = 2048;
|
||||||
|
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
||||||
|
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
pub(crate) struct FileThumbnailState {
|
||||||
|
textures: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||||
|
texture_order: RefCell<VecDeque<String>>,
|
||||||
|
missing: RefCell<HashSet<String>>,
|
||||||
|
missing_order: RefCell<VecDeque<String>>,
|
||||||
|
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||||
|
queue: RefCell<VecDeque<FileThumbRequest>>,
|
||||||
|
inflight: Cell<bool>,
|
||||||
|
source: RefCell<Option<glib::SourceId>>,
|
||||||
|
generation: Cell<u64>,
|
||||||
|
generation_loading: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ReplyDisposition {
|
||||||
|
Ignore,
|
||||||
|
Load,
|
||||||
|
Retry,
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_disposition(wanted: bool, has_path: bool, pending: bool) -> ReplyDisposition {
|
||||||
|
if !wanted {
|
||||||
|
ReplyDisposition::Ignore
|
||||||
|
} else if has_path {
|
||||||
|
ReplyDisposition::Load
|
||||||
|
} else if pending {
|
||||||
|
ReplyDisposition::Retry
|
||||||
|
} else {
|
||||||
|
ReplyDisposition::Missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileThumbnailState {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
textures: RefCell::new(HashMap::new()),
|
||||||
|
texture_order: RefCell::new(VecDeque::new()),
|
||||||
|
missing: RefCell::new(HashSet::new()),
|
||||||
|
missing_order: RefCell::new(VecDeque::new()),
|
||||||
|
wanted: RefCell::new(HashMap::new()),
|
||||||
|
queue: RefCell::new(VecDeque::new()),
|
||||||
|
inflight: Cell::new(false),
|
||||||
|
source: RefCell::new(None),
|
||||||
|
generation: Cell::new(0),
|
||||||
|
generation_loading: Cell::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_texture(&self, key: &str, texture: gtk4::gdk::Texture) {
|
||||||
|
let mut textures = self.textures.borrow_mut();
|
||||||
|
let mut order = self.texture_order.borrow_mut();
|
||||||
|
if textures.insert(key.to_string(), texture).is_none() {
|
||||||
|
order.push_back(key.to_string());
|
||||||
|
}
|
||||||
|
while order.len() > THUMB_CACHE_MAX {
|
||||||
|
if let Some(old) = order.pop_front() {
|
||||||
|
textures.remove(&old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_missing(&self, key: String) {
|
||||||
|
let mut missing = self.missing.borrow_mut();
|
||||||
|
let mut order = self.missing_order.borrow_mut();
|
||||||
|
if missing.insert(key.clone()) {
|
||||||
|
order.push_back(key);
|
||||||
|
}
|
||||||
|
while order.len() > THUMB_MISSING_MAX {
|
||||||
|
if let Some(old) = order.pop_front() {
|
||||||
|
missing.remove(&old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
||||||
|
/// listing being left. Advancing a wire generation lets the daemon cancel work
|
||||||
|
/// even when an older request reaches it after this one.
|
||||||
|
pub(crate) fn cancel_file_thumbnails(ui: &Rc<Ui>) {
|
||||||
|
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
ui.file_thumbs.queue.borrow_mut().clear();
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().clear();
|
||||||
|
let generation = ui.file_thumbs.generation.replace(0);
|
||||||
|
// The request is useful even when its reply is not. `spawn_request` owns the
|
||||||
|
// socket round-trip on a worker thread, so dropping the receiver is safe.
|
||||||
|
if generation != 0 {
|
||||||
|
drop(spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::CancelFileThumbs {
|
||||||
|
generation: generation.saturating_add(1),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A square thumbnail surface with a generic file icon underneath the picture.
|
||||||
|
/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry.
|
||||||
|
pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Overlay {
|
||||||
|
let fallback = gtk4::Image::builder().pixel_size(fallback_size).build();
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.width_request(size)
|
||||||
|
.height_request(size)
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
picture.add_css_class("file-thumbnail");
|
||||||
|
|
||||||
|
let overlay = gtk4::Overlay::new();
|
||||||
|
overlay.set_width_request(size);
|
||||||
|
overlay.set_height_request(size);
|
||||||
|
overlay.set_halign(gtk4::Align::Center);
|
||||||
|
overlay.set_valign(gtk4::Align::Center);
|
||||||
|
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||||
|
overlay.set_child(Some(&fallback));
|
||||||
|
overlay.add_overlay(&picture);
|
||||||
|
overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
|
||||||
|
/// these requests in place and rebinding the model is enough to implement the
|
||||||
|
/// status-bar zoom without downloading or decoding an image again.
|
||||||
|
pub(crate) fn resize_file_thumbnail(widget: >k4::Overlay, size: i32, fallback_size: i32) {
|
||||||
|
widget.set_width_request(size);
|
||||||
|
widget.set_height_request(size);
|
||||||
|
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
fallback.set_pixel_size(fallback_size);
|
||||||
|
if let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() {
|
||||||
|
picture.set_width_request(size);
|
||||||
|
picture.set_height_request(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a thumbnail surface to one entry, painting a cached texture immediately
|
||||||
|
/// or queueing an on-demand daemon request. Non-images retain the generic icon.
|
||||||
|
pub(crate) fn bind_file_thumbnail(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
widget: >k4::Overlay,
|
||||||
|
entry: &DirEntry,
|
||||||
|
symbolic_fallback: bool,
|
||||||
|
) {
|
||||||
|
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// The browser may add a status badge after the picture. Locate the picture
|
||||||
|
// immediately after the base fallback instead of assuming it is the last
|
||||||
|
// overlay child.
|
||||||
|
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let fallback_name = if symbolic_fallback {
|
||||||
|
format!("{}-symbolic", icon_base_for(entry))
|
||||||
|
} else {
|
||||||
|
icon_base_for(entry).to_string()
|
||||||
|
};
|
||||||
|
fallback.set_icon_name(Some(&fallback_name));
|
||||||
|
fallback.set_visible(true);
|
||||||
|
picture.set_visible(false);
|
||||||
|
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
|
|
||||||
|
// The widget name is a cheap binding token. List factories recycle their
|
||||||
|
// children; a reply for the previous entry must not paint into the new one.
|
||||||
|
let key = thumbnail_key(&entry.uid, entry.modified);
|
||||||
|
picture.set_widget_name(&key);
|
||||||
|
remove_waiter(ui, &picture);
|
||||||
|
|
||||||
|
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
|
||||||
|
paint(&picture, &key, texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ui.file_thumbs.missing.borrow().contains(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.file_thumbs
|
||||||
|
.wanted
|
||||||
|
.borrow_mut()
|
||||||
|
.entry(key)
|
||||||
|
.or_default()
|
||||||
|
.push(picture);
|
||||||
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
|
queue.retain(|item| item.uid != entry.uid);
|
||||||
|
queue.push_back(FileThumbRequest {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
modified: entry.modified,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
});
|
||||||
|
drop(queue);
|
||||||
|
schedule_file_thumbs(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and immediately bind a thumbnail for a non-recycled row.
|
||||||
|
pub(crate) fn file_thumbnail(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
entry: &DirEntry,
|
||||||
|
size: i32,
|
||||||
|
fallback_size: i32,
|
||||||
|
symbolic_fallback: bool,
|
||||||
|
) -> gtk4::Overlay {
|
||||||
|
let widget = file_thumbnail_widget(size, fallback_size);
|
||||||
|
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the file name denotes an image format for which Proton or the local
|
||||||
|
/// decoder can reasonably provide a thumbnail.
|
||||||
|
pub(crate) fn is_image_name(name: &str) -> bool {
|
||||||
|
is_thumbnail_image_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thumbnail_key(uid: &str, modified: i64) -> String {
|
||||||
|
format!("{uid}:{modified}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_waiter(ui: &Rc<Ui>, picture: >k4::Picture) {
|
||||||
|
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
|
||||||
|
wanted.retain(|_, pictures| {
|
||||||
|
pictures.retain(|candidate| candidate != picture);
|
||||||
|
!pictures.is_empty()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) {
|
||||||
|
if picture.widget_name() != key {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
picture.set_paintable(Some(texture));
|
||||||
|
picture.set_visible(true);
|
||||||
|
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
|
||||||
|
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
|
||||||
|
{
|
||||||
|
fallback.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_file_thumbs(ui: &Rc<Ui>) {
|
||||||
|
if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ui.file_thumbs.generation.get() == 0 {
|
||||||
|
reserve_file_thumb_generation(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
let ui_flush = ui.clone();
|
||||||
|
let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || {
|
||||||
|
ui_flush.file_thumbs.source.borrow_mut().take();
|
||||||
|
flush_file_thumbs(&ui_flush);
|
||||||
|
});
|
||||||
|
*ui.file_thumbs.source.borrow_mut() = Some(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reserve_file_thumb_generation(ui: &Rc<Ui>) {
|
||||||
|
if ui.file_thumbs.generation_loading.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ReserveFileThumbGeneration,
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.file_thumbs.generation_loading.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => {
|
||||||
|
ui.file_thumbs.generation.set(generation);
|
||||||
|
schedule_file_thumbs(&ui);
|
||||||
|
}
|
||||||
|
_ => retry_file_thumb_generation(&ui),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_file_thumb_generation(ui: &Rc<Ui>) {
|
||||||
|
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
let ui_retry = ui.clone();
|
||||||
|
let source = glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||||
|
ui_retry.file_thumbs.source.borrow_mut().take();
|
||||||
|
schedule_file_thumbs(&ui_retry);
|
||||||
|
});
|
||||||
|
*ui.file_thumbs.source.borrow_mut() = Some(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_file_thumbs(ui: &Rc<Ui>) {
|
||||||
|
if ui.file_thumbs.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let items: Vec<FileThumbRequest> = {
|
||||||
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
|
let wanted = ui.file_thumbs.wanted.borrow();
|
||||||
|
let mut batch = Vec::new();
|
||||||
|
while batch.len() < THUMB_BATCH {
|
||||||
|
let Some(item) = queue.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if wanted.contains_key(&thumbnail_key(&item.uid, item.modified)) {
|
||||||
|
batch.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch
|
||||||
|
};
|
||||||
|
if items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.file_thumbs.inflight.set(true);
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::FileThumbs {
|
||||||
|
items: items.clone(),
|
||||||
|
generation: ui.file_thumbs.generation.get(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.file_thumbs.inflight.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Thumbs { items: replies })) => {
|
||||||
|
let mut requested: HashMap<String, FileThumbRequest> = items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|item| (item.uid.clone(), item))
|
||||||
|
.collect();
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
for reply in replies {
|
||||||
|
let Some(request) = requested.remove(&reply.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = thumbnail_key(&request.uid, request.modified);
|
||||||
|
let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key);
|
||||||
|
match reply_disposition(wanted, reply.path.is_some(), reply.pending) {
|
||||||
|
ReplyDisposition::Ignore => continue,
|
||||||
|
ReplyDisposition::Load => {
|
||||||
|
let Some(path) = reply.path else { continue };
|
||||||
|
match gtk4::gdk::Texture::from_filename(&path) {
|
||||||
|
Ok(texture) => {
|
||||||
|
ui.file_thumbs.store_texture(&key, texture.clone());
|
||||||
|
if let Some(pictures) =
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
||||||
|
{
|
||||||
|
for picture in pictures {
|
||||||
|
paint(&picture, &key, &texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("cannot decode file thumbnail {path}: {e}");
|
||||||
|
ui.file_thumbs.store_missing(key.clone());
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReplyDisposition::Retry => pending.push(request),
|
||||||
|
ReplyDisposition::Missing => {
|
||||||
|
ui.file_thumbs.store_missing(key.clone());
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.extend(requested.into_values());
|
||||||
|
if !pending.is_empty() {
|
||||||
|
retry_file_thumbs(&ui, pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::FileThumbsStale)) => {
|
||||||
|
ui.file_thumbs.generation.set(0);
|
||||||
|
retry_file_thumbs(&ui, items);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
tracing::debug!("file thumbnails failed: {message}");
|
||||||
|
retry_file_thumbs(&ui, items);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
|
||||||
|
tracing::debug!("file thumbnails: no reply");
|
||||||
|
retry_file_thumbs(&ui, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schedule_file_thumbs(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||||
|
let wanted = ui.file_thumbs.wanted.borrow();
|
||||||
|
let retry: Vec<FileThumbRequest> = items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| wanted.contains_key(&thumbnail_key(&item.uid, item.modified)))
|
||||||
|
.collect();
|
||||||
|
drop(wanted);
|
||||||
|
ui.file_thumbs.queue.borrow_mut().extend(retry);
|
||||||
|
schedule_file_thumbs(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ReplyDisposition, is_image_name, reply_disposition};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_extensions_are_case_insensitive_and_specific() {
|
||||||
|
for name in [
|
||||||
|
"photo.jpg",
|
||||||
|
"SCAN.PNG",
|
||||||
|
"animation.Gif",
|
||||||
|
"archive.tiff",
|
||||||
|
"camera.nef",
|
||||||
|
"negative.CR3",
|
||||||
|
"camera.RAW",
|
||||||
|
] {
|
||||||
|
assert!(is_image_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in [
|
||||||
|
"notes.txt",
|
||||||
|
"movie.mp4",
|
||||||
|
"image.jpg.zip",
|
||||||
|
"jpg",
|
||||||
|
"camera.heic",
|
||||||
|
"frame.avif",
|
||||||
|
"graphic.svg",
|
||||||
|
"camera.rw",
|
||||||
|
] {
|
||||||
|
assert!(!is_image_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_listing_replies_cannot_become_permanent_misses() {
|
||||||
|
assert_eq!(
|
||||||
|
reply_disposition(false, false, false),
|
||||||
|
ReplyDisposition::Ignore
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reply_disposition(true, false, false),
|
||||||
|
ReplyDisposition::Missing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,68 @@ release ships. Migrations are forward-only — a database written by a newer cli
|
|||||||
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
||||||
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||||
|
|
||||||
|
## [1.9.0] — 2026-08-18
|
||||||
|
|
||||||
|
Google Photos import, and a pass over the desktop app's interaction gaps. No schema change.
|
||||||
|
Schema: **28**. Builds against the published `proton-sdk` / `proton-drive-rs` **0.6.1** — the album
|
||||||
|
writes this release needs are in the registry crates, so the local `[patch.crates-io]` shim the
|
||||||
|
feature was developed against is gone and no local path dependency remains.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Import a Google Photos Takeout export into Proton Photos** — `pdfs import-google-photos
|
||||||
|
takeout-*.zip` (add `--dry-run` to see what it would do, `--wait` to follow it), or the import
|
||||||
|
button in the Photos header, which multi-selects the export's zips. Albums in the export are
|
||||||
|
recreated as Proton albums and their photos filed into them; year folders and `Archive/` import
|
||||||
|
into the timeline; `Trash/` is skipped. Capture times and favourites come from Google's per-photo
|
||||||
|
JSON sidecars, including the truncated and `(N)`-swapped sidecar names Google produces.
|
||||||
|
- Photos already on the account are **not re-uploaded**: the import asks the photos volume which of
|
||||||
|
the export's names exist (batched, 150 per request), reads and SHA-1s only those files, and skips
|
||||||
|
the ones whose content digest matches too — Proton's own duplicate rule. A library already partly
|
||||||
|
synced by Proton Photos on a phone therefore imports only what is missing, and an interrupted
|
||||||
|
import resumes by being run again. Google keeps a full copy of a photo in every album folder it
|
||||||
|
appears in; those fold into one upload that joins several albums.
|
||||||
|
- The import streams each photo straight out of its zip, so an export needs no scratch space to
|
||||||
|
unpack into, and reports as a job in `pdfs transfers` and the GUI's activity view. `pdfs
|
||||||
|
import-status` shows the counts, `pdfs cancel-import` stops it — a cancelled run still files what
|
||||||
|
it uploaded into its albums.
|
||||||
|
- **An Import page in the GUI** (Settings → *Import from Google Photos*, or the import button in
|
||||||
|
the Photos header). Archives are staged by drag-and-drop onto the page or through the file
|
||||||
|
chooser, listed with their sizes so the set can be checked before anything is sent, and launched
|
||||||
|
as either a Scan (dry run) or a confirmed Import. The page shows live progress off the import's
|
||||||
|
job, keeps the finished run's report on screen, and can stop a run in flight; because the import
|
||||||
|
outlives the page, it is polled wherever the user navigates and its completion is reported as a
|
||||||
|
desktop notification.
|
||||||
|
|
||||||
|
- SDK (`proton-sdk-rs` **0.6.1**): album **writes** — `ProtonPhotosClient::create_album` and
|
||||||
|
`add_photos_to_album`, ported from the TypeScript SDK (upstream C# has no album write API), plus
|
||||||
|
batch duplicate detection (`find_duplicates_many`, `name_collisions`). The workspace deps moved
|
||||||
|
`"0.6.0"` → `"0.6.1"` and `Cargo.lock` re-resolved to the registry crates. Adding a photo from an
|
||||||
|
album *shared with us* (a different volume) is still unported and fails that photo's outcome.
|
||||||
|
- **Multi-select in Files.** Both views select a batch (click-drag, Ctrl-click, Shift-click); a bar
|
||||||
|
slides in reporting the count and offering Move to Trash and offline on/off for the whole
|
||||||
|
selection. Delete acts on everything selected, Esc clears it, and right-clicking inside a
|
||||||
|
selection offers the batch actions rather than the one row under the pointer. Actions that need a
|
||||||
|
single subject — rename, move, share, revisions — stay single-entry, and the details pane steps
|
||||||
|
aside for a batch instead of speaking for it.
|
||||||
|
- **Undo on trash.** "Moved *X* to Trash" now carries an Undo that restores the batch, single files
|
||||||
|
included.
|
||||||
|
- **The empty states are a place to act**: an empty folder offers Upload files / New folder, and an
|
||||||
|
empty photo timeline offers Upload photos / Import from Google Photos, instead of describing what
|
||||||
|
the user could go and do elsewhere.
|
||||||
|
- **Ctrl+F works from any page**, navigating to Files and focusing the search box, rather than being
|
||||||
|
silently inert everywhere but Files.
|
||||||
|
- Staged Takeout archives survive closing the window; archives that have since been moved or deleted
|
||||||
|
are dropped from the restored set.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Settings showed the local cache as "X of 0 B used" when no cache budget was set. An unset budget
|
||||||
|
means *unlimited*, so it now reads "X cached — no limit set" rather than implying a zero cap.
|
||||||
|
- The cache-budget editor fired a `SetCacheBudget` round-trip — and a toast — on every step, so a
|
||||||
|
run of `+` clicks applied a series of caps the user never asked for and stacked a column of
|
||||||
|
toasts. It now waits for the value to settle.
|
||||||
|
- A long pin list pushed everything below it off the end of Settings. It shows the first six with a
|
||||||
|
"Show all N" row for the rest.
|
||||||
|
|
||||||
## [1.8.2] — 2026-08-17
|
## [1.8.2] — 2026-08-17
|
||||||
|
|
||||||
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
|
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@ Consolidated from the historical feature roadmap; current bug status lives in [`
|
|||||||
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
||||||
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
||||||
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
||||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Support creating and editing Proton Photo Albums (album *writes* are unported in the SDK too) and create an auto-upload Pictures folder pipeline. Exif display, the date-based timeline scrubber and favourites are implemented.
|
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favourites are implemented.
|
||||||
|
* **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending.
|
||||||
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
||||||
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
||||||
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
|
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#8B72FF"/>
|
||||||
|
<stop offset="1" stop-color="#5A38E8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
|
||||||
|
|
||||||
|
<!-- cloud: the remote drive (symmetric about x=64) -->
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<circle cx="64" cy="42" r="20"/>
|
||||||
|
<circle cx="44" cy="52" r="14"/>
|
||||||
|
<circle cx="84" cy="52" r="14"/>
|
||||||
|
<rect x="32" y="50" width="64" height="16" rx="8"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- pull: files arrive only when opened -->
|
||||||
|
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M64 75v17"/>
|
||||||
|
<path d="M53 84l11 11 11-11"/>
|
||||||
|
<path d="M45 108h38" stroke-opacity="0.55"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 990 B |
+4
-4
@@ -9,13 +9,13 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.8.2
|
pkgver=1.9.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand 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')
|
||||||
url="https://github.com/narl/proton-drive-linux"
|
url="https://github.com/narrrl/proton-drive-linux"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0')
|
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
|
||||||
makedepends=('cargo' 'git')
|
makedepends=('cargo' 'git')
|
||||||
provides=('pdfs')
|
provides=('pdfs')
|
||||||
# Files-on-demand mount + keyring need these at runtime.
|
# Files-on-demand mount + keyring need these at runtime.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Proton Drive (Tray)
|
Name=Proton Drive for Linux (Tray)
|
||||||
GenericName=Cloud Drive Tray
|
GenericName=Cloud Drive Tray
|
||||||
Comment=Proton Drive system tray icon
|
Comment=Proton Drive status and quick actions (unofficial client)
|
||||||
Exec=pdfs-tray
|
Exec=pdfs-tray
|
||||||
Icon=io.narl.proton-drive-linux
|
Icon=io.narl.proton-drive-linux
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Network;FileTransfer;GTK;
|
Categories=Network;FileTransfer;GTK;
|
||||||
Keywords=proton;drive;cloud;sync;storage;
|
Keywords=proton;drive;cloud;sync;storage;files;backup;
|
||||||
NoDisplay=true
|
NoDisplay=true
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Proton Drive
|
Name=Proton Drive for Linux
|
||||||
GenericName=Cloud Drive
|
GenericName=Cloud Drive
|
||||||
Comment=Proton Drive files-on-demand for Linux
|
Comment=Files-on-demand Proton Drive (unofficial client)
|
||||||
Exec=pdfs-app
|
Exec=pdfs-app
|
||||||
Icon=io.narl.proton-drive-linux
|
Icon=io.narl.proton-drive-linux
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Network;FileTransfer;GTK;
|
Categories=Network;FileTransfer;GTK;
|
||||||
Keywords=proton;drive;cloud;sync;storage;
|
Keywords=proton;drive;cloud;sync;storage;files;backup;
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
SingleMainWindow=true
|
SingleMainWindow=true
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#8b6dff"/>
|
<stop offset="0" stop-color="#8B72FF"/>
|
||||||
<stop offset="1" stop-color="#6d4aff"/>
|
<stop offset="1" stop-color="#5A38E8"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
|
|
||||||
<!-- cloud -->
|
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
|
||||||
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
|
|
||||||
<!-- down arrow (on-demand pull) -->
|
<!-- cloud: the remote drive (symmetric about x=64) -->
|
||||||
<path d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<g fill="#FFFFFF">
|
||||||
|
<circle cx="64" cy="42" r="20"/>
|
||||||
|
<circle cx="44" cy="52" r="14"/>
|
||||||
|
<circle cx="84" cy="52" r="14"/>
|
||||||
|
<rect x="32" y="50" width="64" height="16" rx="8"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- pull: files arrive only when opened -->
|
||||||
|
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M64 75v17"/>
|
||||||
|
<path d="M53 84l11 11 11-11"/>
|
||||||
|
<path d="M45 108h38" stroke-opacity="0.55"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 990 B |
@@ -12,9 +12,9 @@
|
|||||||
Name: proton-drive-linux
|
Name: proton-drive-linux
|
||||||
Version: %{version}
|
Version: %{version}
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
|
Summary: Unofficial Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
|
||||||
License: MIT
|
License: MIT
|
||||||
URL: https://github.com/narl/proton-drive-linux
|
URL: https://github.com/narrrl/proton-drive-linux
|
||||||
ExclusiveArch: x86_64
|
ExclusiveArch: x86_64
|
||||||
|
|
||||||
BuildRequires: cargo
|
BuildRequires: cargo
|
||||||
@@ -33,6 +33,7 @@ Requires: gtk4
|
|||||||
Requires: libadwaita
|
Requires: libadwaita
|
||||||
Requires: libsecret
|
Requires: libsecret
|
||||||
Requires: webkitgtk6.0
|
Requires: webkitgtk6.0
|
||||||
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
|
||||||
# DE-specific; do not Require a single desktop environment.
|
# DE-specific; do not Require a single desktop environment.
|
||||||
@@ -43,8 +44,9 @@ Recommends: kwallet
|
|||||||
Provides: pdfs = %{version}-%{release}
|
Provides: pdfs = %{version}-%{release}
|
||||||
|
|
||||||
%description
|
%description
|
||||||
Unofficial Proton Drive client featuring a FUSE files-on-demand mount,
|
Files-on-demand Proton Drive for the Linux desktop: a FUSE virtual mount with
|
||||||
CLI, GTK4/Libadwaita GUI, system tray, and search launcher.
|
block-level caching, a scriptable CLI, and a GTK4/Libadwaita desktop app with
|
||||||
|
system tray and search launcher. Unofficial and not affiliated with Proton AG.
|
||||||
|
|
||||||
%prep
|
%prep
|
||||||
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
|
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Proton Drive Files-On-Demand mount
|
Description=Proton Drive Files-On-Demand mount
|
||||||
Documentation=https://github.com/narl/proton-drive-linux
|
Documentation=https://github.com/narrrl/proton-drive-linux
|
||||||
# Tie the mount to the desktop session: start after it is up and stop with it.
|
# Tie the mount to the desktop session: start after it is up and stop with it.
|
||||||
After=graphical-session.target
|
After=graphical-session.target
|
||||||
PartOf=graphical-session.target
|
PartOf=graphical-session.target
|
||||||
|
|||||||
Reference in new issue
Block a user