Compare commits

..
21 Commits
Author SHA1 Message Date
nvrl 5109427c32 chore(release): 2.2.1 2026-09-24 19:49:42 +02:00
nvrl e2c4dac8df feat(gui): legal links and disclaimer in the About dialog
Add a copyright line with contact address, links to the source code and
to Proton's terms of service and privacy policy, a Legal section with the
full non-affiliation, trademark and no-warranty disclaimer, and a license
credit for proton-sdk-rs. Translated into every shipped language.
2026-09-24 19:49:42 +02:00
nvrl ebeb9cdbef chore(aur): refresh -git pkgver in update.sh and bump to 2.2.0.r1.g82e9654
makepkg --printsrcinfo does not run pkgver(), so the -git .SRCINFO kept
the version from when the package was created and push.sh never had a
change to publish. update.sh now runs makepkg -od --noprepare first so
pkgver() writes the current git describe version into the PKGBUILD.
2026-09-24 19:49:42 +02:00
Nils Pukropp 82e96543ac chore(aur): update packages to 2.2.0 2026-09-23 18:48:21 +00:00
nvrl 96c77e96cb chore(release): 2.2.0 2026-09-23 20:29:15 +02:00
nvrl 90e110e893 feat(fuse,core): thumbnails for videos and for our own uploads
Videos without a server thumbnail fell through to the local fallback,
which downloaded the whole file, failed to decode it as an image and
wrote the video off for good. Grab one frame with ffmpeg instead, a
second in or from the start for shorter clips. Probe for ffmpeg before
downloading, bound every run, and skip videos over 256 MiB.
Migration V32 gives the videos written off so far another try.

Those videos had no server thumbnail because our uploads never sent one.
Every upload path (bulk upload, drained mount writes, conflict copies,
new revisions and folder sync) now attaches a thumbnail rendered from
the local file, kept under the 60 KiB Proton's own clients use. Files
that are not images or videos, or cannot be decoded, upload bare as
before; a .ts file only counts as video with an MPEG-TS sync byte.

ffmpeg is a new optional dependency in the PKGBUILDs.
2026-09-23 20:29:15 +02:00
nvrl 0b7236bd4a fix(gui): steady the photo gallery and fill the details panel
The month scrubber sat under the overlay scrollbar, which widens under
the pointer, so the two collided. Keep the scrubber clear of it.

The gallery jumped when thumbnails loaded, on zoom and on tab switches.
Relayout anchored on the first bound row, but ListView keeps off-screen
rows bound with stale allocations, and scroll_to on an off-screen row
aligns it to the top. Anchor on the first mapped row that is actually
visible instead.

The viewer's details panel opened empty: the title's hexpand propagated
up to the revealer, which then took all the width. Set hexpand on the
revealer explicitly.

EXIF details read like a camera: Make, Model and Lens lose their EXIF
quotes, a model that already names its maker is not repeated, shutter
speeds are rounded (1/19 s), and ISO is labelled.
2026-09-23 20:29:15 +02:00
Nils Pukropp 2fa5eaa75b chore(aur): update packages to 2.1.1 2026-09-23 16:44:58 +00:00
Nils Pukropp dc9ea9f541 chore(aur): update packages to 2.1.0 2026-09-23 16:29:57 +00:00
Nils Pukropp b13d9d9f38 chore(aur): update packages to 2.1.1 2026-09-23 16:17:09 +00:00
nvrl b696adefc2 chore(release): 2.1.1 2026-09-23 18:00:03 +02:00
nvrl 519ec1af07 fix(fuse): stop a superseded upload from spinning its drain thread
A write that lands while a revision is on the wire cancels that upload
through the flag CountingReader checks before each read. The cancel was
reported as ErrorKind::Interrupted, which every read loop retries,
including the SDK's read_full_block. The flag never clears, so the drain
thread spun at 100% CPU forever and the transfer stayed listed as
uploading at 0 bytes until the daemon restarted. A growing browser
download (.crdownload) triggers it naturally.

Report the cancel as ErrorKind::Other so the SDK fails the upload;
drain_revision already treats a cancelled upload as abandoned, not
failed. Tracked as B98.
2026-09-23 17:58:06 +02:00
Nils Pukropp 4820ea1236 chore(aur): update packages to 2.1.0 2026-09-23 09:23:32 +00:00
nvrl a9d6e0280d ci(aur): publish the AUR packages from CI
A release tag now updates and pushes all three AUR packages once the
GitHub release exists, then commits the refreshed recipes back to main.
A push to main that changes the -git recipe publishes that package
alone; plain upstream commits need no AUR push because its pkgver()
follows git at build time.

Needs the AUR_SSH_PRIVATE_KEY repository secret.
2026-09-23 11:02:30 +02:00
nvrl 3e4c756361 docs: refresh the README screenshots
Retake the login, files, photos and computers screenshots, split the
search launcher into its GTK and fzf front ends, and add the Activity
page.
2026-09-23 10:57:48 +02:00
nvrl 38126ff2b3 feat(gui): translate the Photos filter tabs, subtitle and filtered empty states
The All/Photos/Videos/Raw tabs, the "12 photos" subtitle and the
"No favorite videos in June 2024" heading were still built from English
literals. Each heading is now a whole translatable sentence with a
{month} placeholder, and the subtitle uses plural forms.

Also fill in the French "Documents" search chip, which was left
untranslated because it matches the English word.
2026-09-23 10:41:30 +02:00
nvrl 9529f2124b feat(gui): translate the Photos empty states
The empty timeline and empty favorites texts go through gettext and are
translated in every catalog.
2026-09-23 10:37:19 +02:00
nvrl 77b8fd65be chore(release): 2.1.0 2026-09-23 08:08:16 +02:00
nvrl d9064ef880 feat(core,gui): gettext translations and language preference
The app, the tray and the search prompt route every user-visible string
through GNU gettext in the `pdfs` text domain, with named placeholders and
plural forms. po/ holds the template, update and build scripts, and catalogs
for de, en_GB, es, fr, it, nl, pl and pt_BR. Preferences gains a Language row
that overrides the system language from the next start (config `language`).

Packages compile the catalogs into /usr/share/locale and the desktop files
carry translated descriptions and keywords. docs/TRANSLATING.md describes
the workflow.
2026-09-23 08:07:50 +02:00
nvrl 66ab1f5b16 chore(aur): update packages to 2.0.0 2026-09-23 04:31:13 +02:00
nvrl 56161f649e docs: use the new app icon in the README 2026-09-23 04:22:16 +02:00
77 changed files with 47937 additions and 1744 deletions

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
name: AUR
on:
# The -git package computes its pkgver from git at build time, so a new commit
# upstream needs no AUR push. Only a changed -git recipe is published.
push:
branches: [main]
paths:
- 'packaging/aur/proton-drive-for-linux-git/**'
# Called by release.yml once the GitHub release and its assets exist.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
AUR_SSH_PRIVATE_KEY:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
required: false
type: string
permissions:
contents: write
concurrency:
group: aur
cancel-in-progress: false
jobs:
publish:
name: Publish AUR packages
runs-on: ubuntu-24.04
container: archlinux:base-devel
env:
# makepkg refuses to run as root, so the packaging steps run as `builder`.
HOME: /home/builder
VERSION: ${{ inputs.version }}
steps:
- name: Install packaging tools
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
- name: Checkout repository
uses: actions/checkout@v4
with:
# The updated recipes are committed back to main, not to the tag.
ref: main
- name: Create build user
run: |
useradd --create-home builder
chown -R builder: "$GITHUB_WORKSPACE"
- name: Configure AUR access
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
install -d -m700 -o builder -g builder "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
cat > "$HOME/.ssh/config" <<'EOF'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
EOF
# Pin the host key to the fingerprint published on aur.archlinux.org.
ssh-keyscan -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts"
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
chown -R builder: "$HOME/.ssh"
chmod 600 "$HOME/.ssh/aur"
runuser -u builder -- git config --global user.name 'Nils Pukropp'
runuser -u builder -- git config --global user.email 'contact@narl.io'
- name: Update release packages
if: env.VERSION != ''
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
- name: Refresh the -git metadata
if: env.VERSION == ''
working-directory: packaging/aur/proton-drive-for-linux-git
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
- name: Publish to the AUR
run: |
if [ -n "$VERSION" ]; then
runuser -u builder -- packaging/aur/push.sh
else
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
fi
- name: Commit the updated recipes to main
if: env.VERSION != ''
run: |
runuser -u builder -- git add packaging/aur
if ! runuser -u builder -- git diff --cached --quiet; then
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
runuser -u builder -- git push origin HEAD:main
fi
+13
View File
@@ -31,6 +31,7 @@ jobs:
libsecret-1-dev \ libsecret-1-dev \
libdbus-1-dev \ libdbus-1-dev \
desktop-file-utils \ desktop-file-utils \
gettext \
rpm rpm
- name: Set up Rust - name: Set up Rust
@@ -84,6 +85,7 @@ jobs:
cp target/release/pdfs-tray dist/binaries/ cp target/release/pdfs-tray dist/binaries/
cp target/release/pdfs-app dist/binaries/ cp target/release/pdfs-app dist/binaries/
cp target/release/pdfs-prompt dist/binaries/ cp target/release/pdfs-prompt dist/binaries/
po/build.sh dist/binaries/locale
# Create tarball # Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries . tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
@@ -110,6 +112,7 @@ jobs:
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/ cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
cp packaging/proton-drive.service deb/usr/lib/systemd/user/ cp packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/ cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale
# Calculate Installed-Size in KB for the control file # Calculate Installed-Size in KB for the control file
INSTALLED_SIZE=$(du -sk deb | cut -f1) INSTALLED_SIZE=$(du -sk deb | cut -f1)
@@ -199,3 +202,13 @@ jobs:
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
aur:
name: Publish AUR packages
needs: build-release
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/aur.yml
with:
version: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
Generated
+100 -4
View File
@@ -467,6 +467,12 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
] ]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -1814,6 +1820,26 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gettext-rs"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf"
dependencies = [
"gettext-sys",
"locale_config",
]
[[package]]
name = "gettext-sys"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9"
dependencies = [
"cc",
"temp-dir",
]
[[package]] [[package]]
name = "ghash" name = "ghash"
version = "0.5.1" version = "0.5.1"
@@ -2829,6 +2855,19 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "locale_config"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934"
dependencies = [
"lazy_static",
"objc",
"objc-foundation",
"regex",
"winapi",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2868,6 +2907,15 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -3189,6 +3237,35 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]] [[package]]
name = "ocb3" name = "ocb3"
version = "0.1.0" version = "0.1.0"
@@ -3372,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "2.0.0" version = "2.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3387,7 +3464,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "2.0.0" version = "2.2.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dbus-secret-service-keyring-store", "dbus-secret-service-keyring-store",
@@ -3411,7 +3488,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "2.0.0" version = "2.2.1"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"fuser", "fuser",
@@ -3431,10 +3508,11 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "2.0.0" version = "2.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
"gettext-rs",
"gtk4", "gtk4",
"kamadak-exif", "kamadak-exif",
"ksni", "ksni",
@@ -4055,6 +4133,18 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.18" version = "0.4.18"
@@ -4796,6 +4886,12 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "temp-dir"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964"
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "2.0.0" version = "2.2.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
+11 -3
View File
@@ -55,7 +55,7 @@ pdfs-app # the desktop app
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd) - [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) · **Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) · [Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) · [Translating](docs/TRANSLATING.md) ·
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) · [Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md) [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
@@ -348,11 +348,15 @@ Version 1.0 strengthens the boundaries around local-only data and destructive re
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td> <td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
</tr> </tr>
<tr> <tr>
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td> <td align="center" width="50%"><img src="images/restore_devices.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td> <td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
</tr> </tr>
<tr> <tr>
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td> <td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search Launcher Prompt (GTK)" width="100%"><br><sub><b>Search Launcher Prompt (GTK)</b></sub></td>
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search Launcher Prompt (fzf)" width="100%"><br><sub><b>Search Launcher Prompt (fzf)</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/activity.png" alt="Activity" width="100%"><br><sub><b>Activity</b></sub></td>
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td> <td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
</tr> </tr>
</table> </table>
@@ -499,6 +503,10 @@ Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
vulnerability privately. vulnerability privately.
The app, tray and search prompt are available in English, German, Spanish, French, Italian, Dutch,
Polish and Brazilian Portuguese. [docs/TRANSLATING.md](docs/TRANSLATING.md) explains how to improve
a translation or add a language.
## License ## License
Released under the [MIT License](LICENSE). Released under the [MIT License](LICENSE).
+12
View File
@@ -142,6 +142,11 @@ pub struct AppConfig {
/// back on. Defaulted for configs predating the field. /// back on. Defaulted for configs predating the field.
#[serde(default)] #[serde(default)]
pub tray_hidden: bool, pub tray_hidden: bool,
/// Language for the desktop app, tray and search prompt, as a gettext code
/// such as `de` or `pt_BR`. `None` means "follow the system locale".
/// Defaulted for configs predating the field.
#[serde(default)]
pub language: Option<String>,
} }
/// The My Files page's view choices, remembered between runs. /// The My Files page's view choices, remembered between runs.
@@ -214,6 +219,7 @@ impl Default for AppConfig {
proton_accent: None, proton_accent: None,
files_view: FilesView::default(), files_view: FilesView::default(),
tray_hidden: false, tray_hidden: false,
language: None,
} }
} }
} }
@@ -568,6 +574,7 @@ mod tests {
folders_first: false, folders_first: false,
}, },
tray_hidden: true, tray_hidden: true,
language: Some("de".to_string()),
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap();
@@ -575,6 +582,7 @@ mod tests {
assert_eq!(decoded.user_agent, "test-agent/1.0"); assert_eq!(decoded.user_agent, "test-agent/1.0");
assert_eq!(decoded.proton_accent, Some(true)); assert_eq!(decoded.proton_accent, Some(true));
assert!(decoded.tray_hidden); assert!(decoded.tray_hidden);
assert_eq!(decoded.language.as_deref(), Some("de"));
assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None); assert_eq!(decoded.download_limit, None);
assert_eq!(decoded.files_view, config.files_view); assert_eq!(decoded.files_view, config.files_view);
@@ -661,6 +669,10 @@ mod tests {
let decoded: AppConfig = serde_json::from_str(json).unwrap(); let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert!(decoded.ignore_patterns.is_none()); assert!(decoded.ignore_patterns.is_none());
assert!(!decoded.tray_hidden, "an old config keeps the tray icon"); assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
assert!(
decoded.language.is_none(),
"an old config follows the system"
);
assert_eq!( assert_eq!(
decoded.resolved_ignore_patterns().len(), decoded.resolved_ignore_patterns().len(),
DEFAULT_IGNORE_PATTERNS.len(), DEFAULT_IGNORE_PATTERNS.len(),
+22 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 31; pub(super) const SCHEMA_VERSION: i64 = 32;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -270,6 +270,20 @@ impl Db {
tx.execute_batch(MIGRATION_V31)?; tx.execute_batch(MIGRATION_V31)?;
} }
} }
if current < 32 {
// Data only, so safe to repeat; each table is guarded for a very
// old fixture that never had it.
for table in ["photos", "album_photos"] {
let has_table: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, i64>(0),
)? > 0;
if has_table {
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
}
}
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -952,3 +966,10 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at); CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL; UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
"; ";
/// V32: give videos written off as un-thumbnailable another chance.
///
/// Before local video thumbnails, the fallback fed a video's bytes to the image
/// decoder, which could only fail, and that failure was recorded as the
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
+42
View File
@@ -4235,6 +4235,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
/// A database written before grouping existed opens, keeps its timeline, and /// A database written before grouping existed opens, keeps its timeline, and
/// shows every photo — a row with no `group_key` is its own group, which is how /// shows every photo — a row with no `group_key` is its own group, which is how
/// the gallery behaved before the column. /// the gallery behaved before the column.
#[test]
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v31-fixture-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
{
let db = Db::open(&path).unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("VID-20260804-WA0023.mp4".into()),
..TimelineRow::new("video", 300)
},
TimelineRow {
name: Some("broken.jpg".into()),
..TimelineRow::new("photo", 200)
},
])
.unwrap();
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
.unwrap();
}
let db = Db::open(&path).unwrap();
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
assert_eq!(
state("photo"),
THUMB_NONE,
"an undecodable photo stays written off"
);
drop(db);
let _ = std::fs::remove_file(&path);
}
#[test] #[test]
fn migration_v30_leaves_a_v29_timeline_intact() { fn migration_v30_leaves_a_v29_timeline_intact() {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
+6 -3
View File
@@ -945,6 +945,7 @@ impl Core {
if !meta.complete { if !meta.complete {
return Err("queued create holds an incomplete blob".into()); return Err("queued create holds an incomplete blob".into());
} }
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
let guard = self let guard = self
.transfers .transfers
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len); .begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
@@ -955,7 +956,7 @@ impl Core {
media_type_for(name), media_type_for(name),
reader, reader,
meta.len as i64, meta.len as i64,
Vec::new(), thumbnails,
None, None,
false, false,
))?; ))?;
@@ -1145,6 +1146,7 @@ impl Core {
} }
let alt = conflict_name(&name, now_secs()); let alt = conflict_name(&name, now_secs());
let thumbnails = self.upload_thumbnails(blob, &alt);
let guard = let guard =
self.transfers self.transfers
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len); .begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1155,7 +1157,7 @@ impl Core {
media_type_for(&alt), media_type_for(&alt),
reader, reader,
meta.len as i64, meta.len as i64,
Vec::new(), thumbnails,
None, None,
false, false,
))?; ))?;
@@ -1438,6 +1440,7 @@ impl Core {
} }
let name = self.node_name(&uid); let name = self.node_name(&uid);
let thumbnails = self.upload_thumbnails(&blob, &name);
let guard = let guard =
self.transfers self.transfers
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len); .begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1451,7 +1454,7 @@ impl Core {
&uid, &uid,
reader, reader,
meta.len as i64, meta.len as i64,
Vec::new(), thumbnails,
None, None,
)); ));
let took = started.elapsed(); let took = started.elapsed();
+484 -58
View File
@@ -12,8 +12,8 @@ use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _; use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::Once;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Once, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine as _; use base64::Engine as _;
@@ -25,8 +25,8 @@ use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
use tracing::{info, warn}; use tracing::{debug, info, warn};
use super::{ use super::{
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
@@ -55,6 +55,20 @@ const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100);
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
static EXIFTOOL_MISSING_WARNING: Once = Once::new(); static EXIFTOOL_MISSING_WARNING: Once = Once::new();
static FFMPEG_MISSING_WARNING: Once = Once::new();
/// Largest video downloaded whole to grab one frame for its thumbnail. The
/// download lands in memory, so a long 4K recording is left without one.
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// A single frame is seconds of work even for 4K; this only stops a wedged
/// ffmpeg from holding a thumbnail permit.
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
/// this, stepping the JPEG quality down until it fits.
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
/// JPEG qualities tried, in order, for an upload thumbnail over that size.
const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
/// Largest still image read whole to render the thumbnail its upload carries.
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no /// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn. /// pixels are decoded. `None` when the format is unknown or the header is torn.
@@ -90,7 +104,8 @@ struct RawPreviewUnavailable;
/// the photo will never have a thumbnail, and only that verdict is persisted. /// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt { enum ThumbAttempt {
Made(GeneratedThumb), Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for. Permanent. /// Decoded nothing — a format this build has no decoder for, or a video
/// too large to fetch for one frame. Permanent.
Undecodable, Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time. /// The photo couldn't be downloaded. Transient: try again next time.
Unavailable, Unavailable,
@@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool(
} }
None => return ScaleAttempt::Undecodable, None => return ScaleAttempt::Undecodable,
}; };
encode_thumb(&image)
}
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
let (width, height) = (image.width(), image.height()); let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 { if width == 0 || height == 0 {
return ScaleAttempt::Undecodable; return ScaleAttempt::Undecodable;
@@ -268,9 +288,69 @@ impl Drop for RawTempFile {
} }
} }
/// Run one bounded exiftool JSON query while draining stdout concurrently. The /// How a bounded helper run failed.
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// pipe buffer long before the child exits. enum ToolError {
/// The helper isn't installed.
Missing,
/// It couldn't be started or read, or overran its budget and was killed.
Unavailable,
/// It ran and exited non-zero.
Failed,
}
/// Run a helper to completion within `timeout` and return its stdout, drained
/// concurrently. The latter matters for exiftool's full-size `JpgFromRaw`
/// fallbacks and for ffmpeg's frames, either of which can exceed a pipe buffer
/// long before the child exits.
fn run_bounded(mut command: Command, timeout: Duration) -> Result<Vec<u8>, ToolError> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ToolError::Missing
} else {
ToolError::Unavailable
}
})?;
let Some(mut stdout) = child.stdout.take() else {
let _ = child.kill();
let _ = child.wait();
return Err(ToolError::Unavailable);
};
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
stdout.read_to_end(&mut output).map(|_| output)
});
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status.success()),
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) | Err(_) => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let output = reader
.join()
.ok()
.and_then(Result::ok)
.ok_or(ToolError::Unavailable)?;
match status {
Some(true) => Ok(output),
Some(false) => Err(ToolError::Failed),
None => Err(ToolError::Unavailable),
}
}
/// Run one bounded exiftool JSON query.
fn exiftool_query( fn exiftool_query(
exiftool: &OsStr, exiftool: &OsStr,
path: &Path, path: &Path,
@@ -280,58 +360,172 @@ fn exiftool_query(
let mut command = Command::new(exiftool); let mut command = Command::new(exiftool);
command.args(["-j", "-b", "-n"]); command.args(["-j", "-b", "-n"]);
command.args(tags); command.args(tags);
command command.arg(path);
.arg(path) let output = run_bounded(command, timeout).map_err(|error| {
.stdout(Stdio::piped()) if error == ToolError::Missing {
.stderr(Stdio::null()); EXIFTOOL_MISSING_WARNING.call_once(|| {
let mut child = match command.spawn() { warn!(
Ok(child) => child, "exiftool is not installed; camera RAW thumbnails are unavailable \
Err(error) => { (Debian/Ubuntu: libimage-exiftool-perl)"
if error.kind() == std::io::ErrorKind::NotFound { );
EXIFTOOL_MISSING_WARNING.call_once(|| { });
warn!(
"exiftool is not installed; camera RAW thumbnails are unavailable \
(Debian/Ubuntu: libimage-exiftool-perl)"
);
});
}
return Err(RawPreviewUnavailable);
} }
}; RawPreviewUnavailable
let Some(mut stdout) = child.stdout.take() else { })?;
let _ = child.kill();
let _ = child.wait();
return Err(RawPreviewUnavailable);
};
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
stdout.read_to_end(&mut output).map(|_| output)
});
let started = Instant::now();
let success = loop {
match child.try_wait() {
Ok(Some(status)) => break status.success(),
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) | Err(_) => {
let _ = child.kill();
let _ = child.wait();
break false;
}
}
};
let output = reader
.join()
.ok()
.and_then(Result::ok)
.ok_or(RawPreviewUnavailable)?;
if !success {
return Err(RawPreviewUnavailable);
}
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable) serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
} }
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
/// than a picture the image decoder can scale.
fn is_video_name(name: &str) -> bool {
PhotoKind::classify(Some(name), None) == PhotoKind::Video
}
/// Whether ffmpeg can be run at all. Asked once, before a video is downloaded:
/// without ffmpeg every scroll past a video would fetch it whole for nothing.
fn ffmpeg_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
let mut command = Command::new("ffmpeg");
command.arg("-version");
let available = run_bounded(command, Duration::from_secs(5)).is_ok();
if !available {
warn_ffmpeg_missing();
}
available
})
}
fn warn_ffmpeg_missing() {
FFMPEG_MISSING_WARNING.call_once(|| {
warn!("ffmpeg is not installed; video thumbnails are unavailable");
});
}
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
/// staging copy. See [`video_file_thumbnail`].
fn video_thumbnail(
bytes: &[u8],
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
return ScaleAttempt::Unavailable;
};
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// A thumbnail from one frame of the video at `path`. A second in, past the
/// black lead-in most clips open on, and the first frame for a clip shorter than
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
/// upright.
///
/// Missing or wedged ffmpeg is retryable; a file ffmpeg can take no frame from
/// is the permanent verdict.
fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> ScaleAttempt {
for seek in ["1", "0"] {
let mut command = Command::new(ffmpeg);
command.args(["-nostdin", "-loglevel", "error", "-ss", seek, "-i"]);
command.arg(path);
command.args(["-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-"]);
match run_bounded(command, timeout) {
Ok(frame) if !frame.is_empty() => {
return match image::load_from_memory(&frame) {
Ok(image) => encode_thumb(&image),
Err(_) => ScaleAttempt::Undecodable,
};
}
// No frame that far in: try from the start.
Ok(_) | Err(ToolError::Failed) => continue,
Err(ToolError::Missing) => {
warn_ffmpeg_missing();
return ScaleAttempt::Unavailable;
}
Err(ToolError::Unavailable) => return ScaleAttempt::Unavailable,
}
}
ScaleAttempt::Undecodable
}
/// The thumbnail to attach to an upload of the local file at `path`, which is
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
/// the gallery has to download the whole file to make its own.
///
/// Best effort: a file that isn't an image or a video, is too large to read, or
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
/// CPU-bound, so async callers run it on the blocking pool.
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
if is_video_name(name) && !ffmpeg_available() {
return Vec::new();
}
upload_thumbnails_with(path, name, staging_dir, OsStr::new("ffmpeg"))
}
fn upload_thumbnails_with(
path: &Path,
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
) -> Vec<Thumbnail> {
let attempt = if is_video_name(name) {
if !is_video_file(path, name) {
return Vec::new();
}
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
} else if is_thumbnail_image_name(name) {
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
return Vec::new();
};
scale_thumbnail(&bytes, name, staging_dir)
} else {
return Vec::new();
};
let ScaleAttempt::Made(thumb) = attempt else {
debug!(name, "uploading without a thumbnail; none could be made");
return Vec::new();
};
fit_upload_thumb(thumb.bytes)
.and_then(|bytes| Thumbnail::new(ThumbnailType::Thumbnail, bytes).ok())
.into_iter()
.collect()
}
/// `.ts` is TypeScript as often as it is an MPEG transport stream, so a source
/// tree must not cost an ffmpeg run per file. A transport stream opens on its
/// 0x47 sync byte; every other video extension is taken at its word.
fn is_video_file(path: &Path, name: &str) -> bool {
if !name.to_ascii_lowercase().ends_with(".ts") {
return true;
}
let mut first = [0u8; 1];
std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut first))
.is_ok_and(|()| first[0] == 0x47)
}
/// Re-encode a thumbnail at falling quality until it fits
/// [`UPLOAD_THUMB_MAX_BYTES`], and at half the size if even the lowest quality
/// doesn't. `None` if nothing fits.
fn fit_upload_thumb(bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.len() <= UPLOAD_THUMB_MAX_BYTES {
return Some(bytes);
}
let image = image::load_from_memory(&bytes).ok()?;
let half = image.thumbnail(image.width() / 2, image.height() / 2);
[image.to_rgb8(), half.to_rgb8()].iter().find_map(|image| {
UPLOAD_THUMB_QUALITIES.iter().find_map(|&quality| {
let mut smaller = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut smaller, quality)
.encode_image(image)
.ok()?;
(smaller.len() <= UPLOAD_THUMB_MAX_BYTES).then_some(smaller)
})
})
}
fn exiftool_object( fn exiftool_object(
value: &serde_json::Value, value: &serde_json::Value,
) -> Option<&serde_json::Map<String, serde_json::Value>> { ) -> Option<&serde_json::Map<String, serde_json::Value>> {
@@ -418,6 +612,24 @@ struct PhotoMeta {
} }
impl Core { impl Core {
/// [`upload_thumbnails`] for a file about to upload, staging any RAW copy
/// under this cache. Blocking.
pub(crate) fn upload_thumbnails(&self, path: &Path, name: &str) -> Vec<Thumbnail> {
upload_thumbnails(path, name, self.cache.raw_thumbnail_staging_dir())
}
/// [`Core::upload_thumbnails`] on the blocking pool, for async callers.
pub(crate) async fn upload_thumbnails_async(
&self,
path: PathBuf,
name: String,
) -> Vec<Thumbnail> {
let staging_dir = self.cache.raw_thumbnail_staging_dir().to_path_buf();
tokio::task::spawn_blocking(move || upload_thumbnails(&path, &name, &staging_dir))
.await
.unwrap_or_default()
}
pub(crate) fn photos_timeline( pub(crate) fn photos_timeline(
&self, &self,
offset: usize, offset: usize,
@@ -973,6 +1185,10 @@ impl Core {
if !core.thumb_job_current(job) { if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
let video = is_video_name(&name);
if video && !ffmpeg_available() {
return (uid, ThumbAttempt::Unavailable);
}
// One shared permit pool bounds all thumbnail batches, // One shared permit pool bounds all thumbnail batches,
// rather than multiplying the limit by every control // rather than multiplying the limit by every control
@@ -1019,6 +1235,19 @@ impl Core {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
// A video is downloaded whole for one frame, so a long
// recording is not worth it — and would not fit in memory.
if video {
match core.photos().get_node(&uid).await {
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
return (uid, ThumbAttempt::Undecodable);
}
Ok(Some(_)) => {}
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
}
}
let download_uid = uid.clone(); let download_uid = uid.clone();
let download = async move { client.download_file(&download_uid).await }; let download = async move { client.download_file(&download_uid).await };
tokio::pin!(download); tokio::pin!(download);
@@ -1072,7 +1301,17 @@ impl Core {
// stall the runtime's worker; hand it to the blocking pool. // stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let made = tokio::task::spawn_blocking(move || { let made = tokio::task::spawn_blocking(move || {
scale_thumbnail(&bytes, &name, &staging_dir) if video {
video_thumbnail(
&bytes,
&name,
&staging_dir,
OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT,
)
} else {
scale_thumbnail(&bytes, &name, &staging_dir)
}
}) })
.await .await
.unwrap_or(ScaleAttempt::Unavailable); .unwrap_or(ScaleAttempt::Unavailable);
@@ -1592,10 +1831,12 @@ impl Core {
#[cfg(test)] #[cfg(test)]
mod thumb_tests { mod thumb_tests {
use super::{ use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail, RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
scale_thumbnail_with_exiftool, thumbnail_build_may_start, fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
}; };
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use proton_drive_rs::ThumbnailType;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
use std::time::Duration; use std::time::Duration;
@@ -1758,6 +1999,103 @@ mod thumb_tests {
assert!(matches!(attempt, ScaleAttempt::Unavailable)); assert!(matches!(attempt, ScaleAttempt::Unavailable));
} }
/// An executable shell script standing in for ffmpeg, in its own directory
/// under `tmp` so parallel tests never share one.
fn fake_ffmpeg(tmp: &std::path::Path, body: &str) -> std::path::PathBuf {
std::fs::create_dir_all(tmp).unwrap();
let path = tmp.join("ffmpeg");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
fn scratch(test: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
}
#[test]
fn a_video_frame_becomes_a_thumbnail() {
let tmp = scratch("video-frame");
let frame = tmp.join("frame.jpg");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let ScaleAttempt::Made(thumb) = video_thumbnail(
b"video bytes",
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
Duration::from_secs(10),
) else {
panic!("a frame scales");
};
assert!((thumb.ratio - 1080.0 / 1920.0).abs() < 1e-6);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
let tmp = scratch("video-broken");
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let attempt = video_thumbnail(
b"not a video",
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
Duration::from_secs(10),
);
assert!(matches!(attempt, ScaleAttempt::Undecodable));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn missing_ffmpeg_is_retryable() {
let attempt = video_thumbnail(
b"video bytes",
"clip.mp4",
&std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
Duration::from_millis(10),
);
assert!(matches!(attempt, ScaleAttempt::Unavailable));
}
/// Optional real-ffmpeg check, like the RAW fixture harness above.
#[test]
#[ignore = "set PDFS_VIDEO_FIXTURE to a video file; needs ffmpeg"]
fn real_video_fixture_yields_a_frame() {
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy();
let bytes = std::fs::read(&path).unwrap();
let ScaleAttempt::Made(thumb) = video_thumbnail(
&bytes,
&name,
&std::env::temp_dir(),
OsStr::new("ffmpeg"),
Duration::from_secs(30),
) else {
panic!("a frame from the video");
};
assert!(thumb.ratio > 0.0);
}
#[test]
fn videos_are_told_apart_by_name() {
for name in [
"VID-20260804-WA0023.mp4",
"PXL_1.TS.mp4",
"clip.MOV",
"a.webm",
] {
assert!(is_video_name(name), "{name}");
}
for name in ["photo.jpg", "shot.NEF", "notes.txt", "mp4"] {
assert!(!is_video_name(name), "{name}");
}
}
#[test] #[test]
fn a_running_build_only_accepts_the_same_root() { fn a_running_build_only_accepts_the_same_root() {
let status = ThumbnailBuildStatus { let status = ThumbnailBuildStatus {
@@ -1771,4 +2109,92 @@ mod thumb_tests {
let idle = ThumbnailBuildStatus::default(); let idle = ThumbnailBuildStatus::default();
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap()); assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
} }
#[test]
fn an_uploaded_photo_carries_its_thumbnail() {
let tmp = scratch("upload-photo");
std::fs::create_dir_all(&tmp).unwrap();
let photo = tmp.join("staged-blob");
std::fs::write(&photo, jpeg(4000, 3000)).unwrap();
let thumbnails = upload_thumbnails_with(&photo, "IMG_0001.jpg", &tmp, OsStr::new("ffmpeg"));
let _ = std::fs::remove_dir_all(&tmp);
let [thumbnail] = thumbnails.as_slice() else {
panic!("one thumbnail, got {}", thumbnails.len());
};
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
assert!(thumbnail.content.len() <= UPLOAD_THUMB_MAX_BYTES);
assert_eq!(ratio_of(&thumbnail.content), Some(4.0 / 3.0));
}
#[test]
fn an_uploaded_video_carries_a_frame() {
let tmp = scratch("upload-video");
let frame = tmp.join("frame.jpg");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
std::fs::write(tmp.join("clip"), b"video bytes").unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let thumbnails = upload_thumbnails_with(
&tmp.join("clip"),
"VID-WA0001.mp4",
&tmp,
ffmpeg.as_os_str(),
);
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(thumbnails.len(), 1);
assert!(ratio_of(&thumbnails[0].content).is_some_and(|ratio| ratio < 1.0));
}
#[test]
fn other_files_upload_without_a_thumbnail() {
let tmp = scratch("upload-other");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("file"), b"export const answer = 42;\n").unwrap();
// An ffmpeg that would hand back a frame, to prove it is never asked.
std::fs::write(tmp.join("frame.jpg"), jpeg(64, 64)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", tmp.join("frame.jpg").display()));
for name in ["notes.txt", "answer.ts", "broken.jpg"] {
assert!(
upload_thumbnails_with(&tmp.join("file"), name, &tmp, ffmpeg.as_os_str())
.is_empty(),
"{name} uploads bare"
);
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn an_oversized_thumbnail_is_squeezed_under_the_upload_limit() {
// Noise is the worst case for JPEG: at the gallery's quality a 512 px
// square of it lands far over the limit.
let mut seed = 0x2545_f491_u32;
let noise = image::RgbImage::from_fn(THUMB_EDGE, THUMB_EDGE, |_, _| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let [r, g, b, _] = seed.to_le_bytes();
image::Rgb([r, g, b])
});
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, super::THUMB_QUALITY)
.encode_image(&noise)
.unwrap();
assert!(
bytes.len() > UPLOAD_THUMB_MAX_BYTES,
"the fixture is oversized"
);
let fitted = fit_upload_thumb(bytes).expect("noise fits at half size");
assert!(fitted.len() <= UPLOAD_THUMB_MAX_BYTES);
assert_eq!(ratio_of(&fitted), Some(1.0), "shrinking keeps the shape");
let small = jpeg(64, 64);
assert_eq!(
fit_upload_thumb(small.clone()),
Some(small),
"a small one is untouched"
);
}
} }
+8 -2
View File
@@ -1226,6 +1226,9 @@ impl Core {
// Taken from the descriptor being streamed, so it names the content that // Taken from the descriptor being streamed, so it names the content that
// actually goes up rather than whatever is at the path when we finish. // actually goes up rather than whatever is at the path when we finish.
let streamed = LocalSig::from(&meta); let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), name.to_string())
.await;
// Count the bytes as they are read, so this upload shows up in // Count the bytes as they are read, so this upload shows up in
// `GetQueueStatus` next to a manual one. The uid isn't known until the // `GetQueueStatus` next to a manual one. The uid isn't known until the
// draft is sealed, so the transfer registers without one. // draft is sealed, so the transfer registers without one.
@@ -1246,7 +1249,7 @@ impl Core {
crate::media_type_for(name), crate::media_type_for(name),
reader, reader,
meta.len() as i64, meta.len() as i64,
Vec::new(), thumbnails,
Some(mtime), Some(mtime),
false, false,
) )
@@ -1274,6 +1277,9 @@ impl Core {
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?; let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
let mtime = system_mtime(&meta); let mtime = system_mtime(&meta);
let streamed = LocalSig::from(&meta); let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
.await;
let reader = OwnedCountingReader::new( let reader = OwnedCountingReader::new(
file, file,
self.transfers.begin( self.transfers.begin(
@@ -1284,7 +1290,7 @@ impl Core {
), ),
); );
self.client self.client
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime)) .upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
.await .await
.map_err(|e| format!("upload revision {rel}: {e}"))?; .map_err(|e| format!("upload revision {rel}: {e}"))?;
warn_if_torn(rel, &path, streamed); warn_if_torn(rel, &path, streamed);
+18 -5
View File
@@ -389,11 +389,11 @@ impl<R: Read> Read for CountingReader<'_, R> {
&& cancel.load(Ordering::Relaxed) && cancel.load(Ordering::Relaxed)
{ {
// Not `Ok(0)`: a short read is how this reader says "end of file", // Not `Ok(0)`: a short read is how this reader says "end of file",
// and the SDK would seal a truncated revision from it. // and the SDK would seal a truncated revision from it. Not
return Err(std::io::Error::new( // `Interrupted` either: every reader loop, the SDK's included,
std::io::ErrorKind::Interrupted, // retries that kind, and this flag never clears, so the drain
"upload superseded by a newer write", // thread spun forever with the transfer stuck at 0 bytes (B98).
)); return Err(std::io::Error::other("upload superseded by a newer write"));
} }
let n = self.inner.read(buf)?; let n = self.inner.read(buf)?;
self.guard.add(n as u64); self.guard.add(n as u64);
@@ -433,4 +433,17 @@ mod tests {
assert_eq!(limit.charge(1500, now), Duration::from_millis(500)); assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
assert_eq!(limit.charge(1500, now), Duration::from_secs(2)); assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
} }
#[test]
fn a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry() {
let reg = TransferRegistry::new();
let guard = reg.begin("f", "", TransferDirection::Upload, 4);
let cancel = Arc::new(AtomicBool::new(true));
let mut reader = CountingReader::new(&b"data"[..], &guard).with_cancel(cancel);
// `read_exact` retries `Interrupted` the way the SDK's block loop does,
// so this hangs rather than fails if the cancel asks for a retry.
let err = reader.read_exact(&mut [0; 4]).unwrap_err();
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
assert_eq!(guard.entry.done.load(Ordering::Relaxed), 0);
}
} }
+4 -1
View File
@@ -69,6 +69,9 @@ async fn run_uploads(
format!("destination access denied: errno {}", error.code()), format!("destination access denied: errno {}", error.code()),
)); ));
} }
let thumbnails = core
.upload_thumbnails_async(t.path.clone(), t.name.clone())
.await;
match core match core
.client .client
.upload_file_from( .upload_file_from(
@@ -77,7 +80,7 @@ async fn run_uploads(
media_type_for(&t.name), media_type_for(&t.name),
reader, reader,
t.size as i64, t.size as i64,
Vec::new(), thumbnails,
mtime, mtime,
false, false,
) )
+2
View File
@@ -35,3 +35,5 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2" async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" } exif = { version = "0.6", package = "kamadak-exif" }
webkit6 = "0.6" webkit6 = "0.6"
# The system libintl (glibc) rather than a bundled gettext build.
gettext-rs = { version = "0.7", features = ["gettext-system"] }
+156 -89
View File
@@ -1,5 +1,7 @@
#[path = "../activation.rs"] #[path = "../activation.rs"]
pub(crate) mod activation; pub(crate) mod activation;
#[path = "../i18n.rs"]
pub(crate) mod i18n;
pub(crate) mod pages; pub(crate) mod pages;
pub(crate) mod widgets; pub(crate) mod widgets;
@@ -23,6 +25,8 @@ use widgets::share_dialog::*;
use widgets::thumbnails::*; use widgets::thumbnails::*;
use widgets::versions_dialog::*; use widgets::versions_dialog::*;
pub(crate) use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
@@ -52,8 +56,7 @@ use pdfs_core::control::{
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, send,
pending_summary, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -195,6 +198,7 @@ fn main() -> glib::ExitCode {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) )
.init(); .init();
i18n::init();
// The tray launches the app with arguments ("--page locations", // The tray launches the app with arguments ("--page locations",
// "--confirm-stop"), which a running instance must receive too. // "--confirm-stop"), which a running instance must receive too.
@@ -444,6 +448,7 @@ fn build_window(app: &adw::Application) {
mountpoint_row: main_widgets.mountpoint_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(),
accent_row: main_widgets.accent_row.clone(), accent_row: main_widgets.accent_row.clone(),
tray_row: main_widgets.tray_row.clone(), tray_row: main_widgets.tray_row.clone(),
language_row: main_widgets.language_row.clone(),
settings_suppress: Cell::new(false), settings_suppress: Cell::new(false),
budget_source: RefCell::new(None), budget_source: RefCell::new(None),
limit_source: RefCell::new(None), limit_source: RefCell::new(None),
@@ -819,14 +824,35 @@ fn build_window(app: &adw::Application) {
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's /// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
/// sync, set off by a separator. /// sync, set off by a separator.
const DESTINATIONS: [(&str, &str, &str); 8] = [ const DESTINATIONS: [(&str, &str, &str); 8] = [
("browser", "My files", "folder-symbolic"), ("browser", gettext_noop("My files"), "folder-symbolic"),
("gallery", "Photos", "image-x-generic-symbolic"), (
("shared", "Shared with me", "system-users-symbolic"), "gallery",
("sharedbyme", "Shared by me", "emblem-shared-symbolic"), gettext_noop("Photos"),
("devices", "Computers", "computer-symbolic"), "image-x-generic-symbolic",
("trash", "Trash", "user-trash-symbolic"), ),
("locations", "Sync", "emblem-synchronizing-symbolic"), (
("activity", "Activity", "document-open-recent-symbolic"), "shared",
gettext_noop("Shared with me"),
"system-users-symbolic",
),
(
"sharedbyme",
gettext_noop("Shared by me"),
"emblem-shared-symbolic",
),
("devices", gettext_noop("Computers"), "computer-symbolic"),
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
// Translators: sidebar entry for the page listing this computer's synced folders.
(
"locations",
gettext_noop("Sync"),
"emblem-synchronizing-symbolic",
),
(
"activity",
gettext_noop("Activity"),
"document-open-recent-symbolic",
),
]; ];
/// Index of the first row in the sidebar's sync section. /// Index of the first row in the sidebar's sync section.
@@ -842,7 +868,7 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
for (_, label, icon) in DESTINATIONS { for (_, label, icon) in DESTINATIONS {
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
row_box.append(&gtk4::Image::from_icon_name(icon)); row_box.append(&gtk4::Image::from_icon_name(icon));
row_box.append(&gtk4::Label::new(Some(label))); row_box.append(&gtk4::Label::new(Some(&gettext(label))));
let row = gtk4::ListBoxRow::builder().child(&row_box).build(); let row = gtk4::ListBoxRow::builder().child(&row_box).build();
list.append(&row); list.append(&row);
} }
@@ -970,7 +996,7 @@ fn page_fresh(loaded_at: &Cell<Option<Instant>>) -> bool {
fn refresh_button() -> gtk4::Button { fn refresh_button() -> gtk4::Button {
let button = gtk4::Button::builder() let button = gtk4::Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text("Refresh (F5)") .tooltip_text(gettext("Refresh (F5)"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
button.add_css_class("flat"); button.add_css_class("flat");
@@ -1110,12 +1136,6 @@ fn sync_sidebar(ui: &Rc<Ui>) {
} }
} }
/// "1 item" / "5 items": a count with the noun form that agrees with it, so
/// no user-facing string has to fall back to "item(s)".
fn count_noun(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// Show a transient toast. Non-blocking by design: an action's outcome is /// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep /// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands. /// working while a slow upload lands.
@@ -1147,7 +1167,10 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
let message = if detail.is_empty() { let message = if detail.is_empty() {
what.to_string() what.to_string()
} else { } else {
format!("{what}: {detail}") // Translators: a failed action's toast. {what} says what failed, such as
// "Couldn't rename"; {detail} is the reason, usually untranslated text
// from the mount service.
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
}; };
tracing::warn!("{message}"); tracing::warn!("{message}");
let toast = adw::Toast::builder().title(&message).timeout(6).build(); let toast = adw::Toast::builder().title(&message).timeout(6).build();
@@ -1166,15 +1189,34 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
/// used where the class carries no better wording than the caller already has. /// used where the class carries no better wording than the caller already has.
fn error_headline(kind: ErrorKind, fallback: &str) -> &str { fn error_headline(kind: ErrorKind, fallback: &str) -> &str {
match kind { match kind {
ErrorKind::Offline => "You're offline", ErrorKind::Offline => static_gettext(gettext_noop("You're offline")),
ErrorKind::NotFound => "That's not there any more", ErrorKind::NotFound => static_gettext(gettext_noop("That's not there any more")),
ErrorKind::Denied => "You don't have access to that", ErrorKind::Denied => static_gettext(gettext_noop("You don't have access to that")),
ErrorKind::Conflict => "Something changed this first", ErrorKind::Conflict => static_gettext(gettext_noop("Something changed this first")),
ErrorKind::Quota => "Your Proton Drive is full", ErrorKind::Quota => static_gettext(gettext_noop("Your Proton Drive is full")),
ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback, ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback,
} }
} }
thread_local! {
/// Translations handed out by [`static_gettext`], one per msgid.
static STATIC_TRANSLATIONS: RefCell<HashMap<&'static str, &'static str>> =
RefCell::new(HashMap::new());
}
/// [`gettext`] for a helper that has to return `&'static str`. The language is
/// fixed for the life of the process, so each msgid is translated once and the
/// result kept; the set of msgids is a handful of literals, so the kept strings
/// are bounded.
fn static_gettext(msgid: &'static str) -> &'static str {
STATIC_TRANSLATIONS.with(|cache| {
*cache
.borrow_mut()
.entry(msgid)
.or_insert_with(|| Box::leak(gettext(msgid).into_boxed_str()))
})
}
/// Report a failed request, letting its class pick the wording. /// Report a failed request, letting its class pick the wording.
/// ///
/// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown: /// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown:
@@ -1192,12 +1234,15 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
/// belong on any one page. /// belong on any one page.
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("Preferences"), Some("win.preferences")); menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
menu.append(Some("About Proton Drive for Linux"), Some("win.about")); menu.append(
Some(&gettext("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(gettext("Main menu"))
.primary(true) .primary(true)
.menu_model(&menu) .menu_model(&menu)
.build() .build()
@@ -1243,20 +1288,51 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let ui_about = ui.clone(); let ui_about = ui.clone();
about.connect_activate(move |_, _| { about.connect_activate(move |_, _| {
let dialog = adw::AboutDialog::builder() let dialog = adw::AboutDialog::builder()
.application_name("Proton Drive for Linux") .application_name(gettext("Proton Drive for Linux"))
.application_icon("io.narl.proton-drive-linux") .application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION) .version(pdfs_core::config::APP_VERSION)
.developer_name("Nils Pukropp") .developer_name("Nils Pukropp")
.website("https://github.com/narrrl/proton-drive-linux") .website("https://github.com/narrrl/proton-drive-linux")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues") .issue_url("https://github.com/narrrl/proton-drive-linux/issues")
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
.license_type(gtk4::License::MitX11) .license_type(gtk4::License::MitX11)
.comments( .comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
)
.debug_info(debug_info(&ui_about)) .debug_info(debug_info(&ui_about))
.debug_info_filename("proton-drive-linux-debug.txt") .debug_info_filename("proton-drive-linux-debug.txt")
.build(); .build();
dialog.add_link(
&gettext("Source Code"),
"https://github.com/narrrl/proton-drive-linux",
);
dialog.add_link(
&gettext("Proton Terms of Service"),
"https://proton.me/legal/terms",
);
dialog.add_link(
&gettext("Proton Privacy Policy"),
"https://proton.me/legal/privacy",
);
// The text is Pango markup: an ampersand must be written as "&amp;".
dialog.add_legal_section(
&gettext("Disclaimer"),
None,
gtk4::License::Custom,
Some(&gettext(
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
)),
);
dialog.add_legal_section(
"proton-sdk-rs",
Some("© 2026 Nils Pukropp (contact@narl.io)"),
gtk4::License::MitX11,
None,
);
// Translators: replace with your name(s) and email address(es), one
// per line, to be credited in the About dialog.
let credits = gettext("translator-credits");
if credits != "translator-credits" {
dialog.set_translator_credits(&credits);
}
dialog.present(Some(&win)); dialog.present(Some(&win));
}); });
window.add_action(&about); window.add_action(&about);
@@ -1284,14 +1360,11 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
let ui_stop = ui.clone(); let ui_stop = ui.clone();
confirm_stop.connect_activate(move |_, _| { confirm_stop.connect_activate(move |_, _| {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Stop Proton Drive?") .heading(gettext("Stop Proton Drive?"))
.body( .body(gettext("The drive unmounts and nothing syncs until you connect again. Proton Drive starts again at your next login."))
"The drive unmounts and nothing syncs until you connect again. \
Proton Drive starts again at your next login.",
)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", "Stop"); dialog.add_response("stop", &pgettext("verb", "Stop"));
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -1308,8 +1381,8 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
} else { } else {
toast_error( toast_error(
&ui, &ui,
"Couldn't stop Proton Drive", &gettext("Couldn't stop Proton Drive"),
"The mount service did not stop.", &gettext("The mount service did not stop."),
); );
} }
}); });
@@ -1345,53 +1418,55 @@ fn debug_info(ui: &Rc<Ui>) -> String {
fn show_shortcuts(window: &adw::ApplicationWindow) { fn show_shortcuts(window: &adw::ApplicationWindow) {
const GROUPS: [(&str, &[(&str, &str)]); 4] = [ const GROUPS: [(&str, &[(&str, &str)]); 4] = [
( (
"General", gettext_noop("General"),
&[ &[
("<Control>f", "Search Drive"), ("<Control>f", gettext_noop("Search Drive")),
("F5", "Refresh"), ("F5", gettext_noop("Refresh")),
("<Control>comma", "Preferences"), ("<Control>comma", gettext_noop("Preferences")),
("<Control>question", "Keyboard shortcuts"), ("<Control>question", gettext_noop("Keyboard shortcuts")),
], ],
), ),
( (
"Files", gettext_noop("Files"),
&[ &[
("<Control>n", "New folder"), ("<Control>n", gettext_noop("New folder")),
("<Control>u", "Upload files"), ("<Control>u", gettext_noop("Upload files")),
("<Alt>Left <Alt>Right", "Back / forward"), ("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
("<Alt>Up", "Parent folder"), ("<Alt>Up", gettext_noop("Parent folder")),
("<Control>1 <Control>2", "Grid / list"), ("<Control>1 <Control>2", gettext_noop("Grid / list")),
("F2", "Rename"), ("F2", gettext_noop("Rename")),
("Delete", "Move to Trash"), ("Delete", gettext_noop("Move to Trash")),
("Escape", "Clear the selection"), ("Escape", gettext_noop("Clear the selection")),
], ],
), ),
( (
"Photos", gettext_noop("Photos"),
&[ &[
("<Control>plus", "Larger thumbnails"), ("<Control>plus", gettext_noop("Larger thumbnails")),
("<Control>minus", "Smaller thumbnails"), ("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", "Reset thumbnail size"), ("<Control>0", gettext_noop("Reset thumbnail size")),
("<Control>a", "Select all"), ("<Control>a", gettext_noop("Select all")),
], ],
), ),
( (
"Photo Viewer", gettext_noop("Photo Viewer"),
&[ &[
("Left Right", "Previous / next photo"), ("Left Right", gettext_noop("Previous / next photo")),
("Home End", "First / last photo"), ("Home End", gettext_noop("First / last photo")),
("i", "Show details"), ("i", gettext_noop("Show details")),
("f", "Fullscreen"), ("f", gettext_noop("Fullscreen")),
("Delete", "Move to Trash"), ("Delete", gettext_noop("Move to Trash")),
("Escape", "Close"), ("Escape", gettext_noop("Close")),
], ],
), ),
]; ];
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
for (title, keys) in GROUPS { for (title, keys) in GROUPS {
let group = adw::PreferencesGroup::builder().title(title).build(); let group = adw::PreferencesGroup::builder()
.title(gettext(title))
.build();
for (accel, action) in keys { for (accel, action) in keys {
let row = adw::ActionRow::builder().title(*action).build(); let row = adw::ActionRow::builder().title(gettext(action)).build();
row.add_suffix( row.add_suffix(
&gtk4::ShortcutLabel::builder() &gtk4::ShortcutLabel::builder()
.accelerator(*accel) .accelerator(*accel)
@@ -1404,7 +1479,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
} }
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts") .title(gettext("Keyboard Shortcuts"))
.content_width(460) .content_width(460)
.content_height(620) .content_height(620)
.child(&{ .child(&{
@@ -1484,7 +1559,7 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
// Renaming is one name at a time; saying so beats a key that // Renaming is one name at a time; saying so beats a key that
// silently does nothing with several items highlighted. // silently does nothing with several items highlighted.
if selected_entries(&ui).len() > 1 { if selected_entries(&ui).len() > 1 {
toast(&ui, "Select a single item to rename it"); toast(&ui, &gettext("Select a single item to rename it"));
} else if let Some(entry) = selected_entry(&ui) { } else if let Some(entry) = selected_entry(&ui) {
prompt_rename(&ui, &entry); prompt_rename(&ui, &entry);
} }
@@ -1561,7 +1636,7 @@ fn confirm_destructive(
.heading(heading) .heading(heading)
.body(body) .body(body)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("confirm", action); dialog.add_response("confirm", action);
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
@@ -1608,23 +1683,15 @@ pub(crate) fn open_named_path(path: &str, name: &str) {
pdfs_core::opener::open_default_named(Path::new(path), name, false); pdfs_core::opener::open_default_named(Path::new(path), name, false);
} }
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`). /// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
/// `512 bytes` below one KiB).
///
/// GLib does the formatting, so the units, the plural of "bytes" and the
/// decimal separator follow the user's language from GLib's own catalog. GLib
/// separates number and unit with a no-break space; it is turned back into a
/// plain space so the text matches what the rest of the UI builds around it.
fn human_bytes(bytes: u64) -> String { fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; glib::format_size_full(bytes, glib::FormatSizeFlags::IEC_UNITS).replace('\u{a0}', " ")
if bytes == 0 {
return "0 B".into();
}
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
} }
#[cfg(test)] #[cfg(test)]
+87 -64
View File
@@ -56,13 +56,13 @@ impl ActivityFilter {
ActivityFilter::Problems, ActivityFilter::Problems,
]; ];
fn label(self) -> &'static str { fn label(self) -> String {
match self { match self {
ActivityFilter::All => "All", ActivityFilter::All => pgettext("activity filter", "All"),
ActivityFilter::Transfers => "Transfers", ActivityFilter::Transfers => pgettext("activity filter", "Transfers"),
ActivityFilter::Changes => "Changes", ActivityFilter::Changes => pgettext("activity filter", "Changes"),
ActivityFilter::Sharing => "Sharing", ActivityFilter::Sharing => pgettext("activity filter", "Sharing"),
ActivityFilter::Problems => "Problems", ActivityFilter::Problems => pgettext("activity filter", "Problems"),
} }
} }
@@ -95,7 +95,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
bar.set_halign(gtk4::Align::Start); bar.set_halign(gtk4::Align::Start);
let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new(); let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new();
for filter in ActivityFilter::ALL { for filter in ActivityFilter::ALL {
let button = gtk4::ToggleButton::with_label(filter.label()); let button = gtk4::ToggleButton::with_label(&filter.label());
if let Some((_, first)) = filters.first() { if let Some((_, first)) = filters.first() {
button.set_group(Some(first)); button.set_group(Some(first));
} else { } else {
@@ -106,7 +106,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
} }
let attention = adw::PreferencesGroup::builder() let attention = adw::PreferencesGroup::builder()
.title("Needs attention") .title(gettext("Needs attention"))
.visible(false) .visible(false)
.build(); .build();
let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24); let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
@@ -131,7 +131,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
.build(); .build();
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -150,7 +150,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
content.add_named(&scroll, Some("list")); content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status")); content.add_named(&status, Some("status"));
let (frame, header, _) = page_frame("Activity", &content); let (frame, header, _) = page_frame(&gettext("Activity"), &content);
header.pack_end(&refresh); header.pack_end(&refresh);
( (
@@ -245,8 +245,8 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
activity_status( activity_status(
ui, ui,
"document-open-recent-symbolic", "document-open-recent-symbolic",
"Loading…", &gettext("Loading…"),
"Reading recent activity.", &gettext("Reading recent activity."),
false, false,
); );
ui.busy_begin(); ui.busy_begin();
@@ -264,15 +264,15 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
Ok(Ok(Response::Error { message, .. })) => activity_status( Ok(Ok(Response::Error { message, .. })) => activity_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't read activity", &gettext("Couldn't read activity"),
&message, &message,
false, false,
), ),
Ok(Ok(_)) => activity_status( Ok(Ok(_)) => activity_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't read activity", &gettext("Couldn't read activity"),
"Unexpected reply from the mount service.", &gettext("Unexpected reply from the mount service."),
false, false,
), ),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui), Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
@@ -286,8 +286,8 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
activity_status( activity_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -295,8 +295,8 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
activity_status( activity_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
@@ -416,19 +416,23 @@ pub(crate) fn collapse_feed(items: &[ActivityEntry]) -> Vec<FeedRow<'_>> {
pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String { pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String {
let today = now.ymd(); let today = now.ymd();
if at.ymd() == today { if at.ymd() == today {
return "Today".to_string(); return gettext("Today");
} }
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) { if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
return "Yesterday".to_string(); return gettext("Yesterday");
} }
let format = if now.difference(at).as_seconds() < 6 * 86_400 { let format = if now.difference(at).as_seconds() < 6 * 86_400 {
"%A" "%A".to_string()
} else if at.year() == now.year() { } else if at.year() == now.year() {
"%A, %B %-d" // Translators: strftime format for a day heading in this year, such as "Wednesday, September 23".
gettext("%A, %B %-d")
} else { } else {
"%B %-d, %Y" // Translators: strftime format for a day heading in an earlier year, such as "December 31, 2025".
gettext("%B %-d, %Y")
}; };
at.format(format).map(|s| s.to_string()).unwrap_or_default() at.format(&format)
.map(|s| s.to_string())
.unwrap_or_default()
} }
/// Rebuild the page from the kept feed and conflict list, unless it already /// Rebuild the page from the kept feed and conflict list, unless it already
@@ -447,8 +451,8 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
activity_status( activity_status(
ui, ui,
"document-open-recent-symbolic", "document-open-recent-symbolic",
"Nothing yet", &gettext("Nothing yet"),
"Uploads, moves, shares and other changes appear here as they happen.", &gettext("Uploads, moves, shares and other changes appear here as they happen."),
false, false,
); );
return; return;
@@ -461,12 +465,12 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
state.attention.remove(&row); state.attention.remove(&row);
} }
state.attention.set_visible(!open.is_empty()); state.attention.set_visible(!open.is_empty());
state.attention.set_description(Some(&match open.len() { state.attention.set_description(Some(&ngettext_f(
1 => "A file was changed in two places at once. Choose which version to keep.".to_string(), "A file was changed in two places at once. Choose which version to keep.",
n => { "{n} files were changed in two places at once. Choose which versions to keep.",
format!("{n} files were changed in two places at once. Choose which versions to keep.") open.len() as u64,
} &[],
})); )));
let mut attention_rows = state.attention_rows.borrow_mut(); let mut attention_rows = state.attention_rows.borrow_mut();
for conflict in open { for conflict in open {
let row = conflict_row(ui, conflict); let row = conflict_row(ui, conflict);
@@ -485,8 +489,8 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
if rows.is_empty() { if rows.is_empty() {
let empty = adw::StatusPage::builder() let empty = adw::StatusPage::builder()
.icon_name("edit-find-symbolic") .icon_name("edit-find-symbolic")
.title("No matching activity") .title(gettext("No matching activity"))
.description("Nothing in the recent log fits this filter.") .description(gettext("Nothing in the recent log fits this filter."))
.build(); .build();
empty.add_css_class("compact"); empty.add_css_class("compact");
state.days.append(&empty); state.days.append(&empty);
@@ -519,11 +523,7 @@ fn feed_row(
open: &[ConflictInfo], open: &[ConflictInfo],
) -> adw::ActionRow { ) -> adw::ActionRow {
let a = row.entry; let a = row.entry;
let title = match a.kind { let title = activity_title(a.kind, &a.target);
// The target of an empty is a count, not a name to lead with a verb.
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
kind => format!("{} {}", activity_verb(kind), a.target),
};
// A logged conflict names the copy; it is still open while the daemon // A logged conflict names the copy; it is still open while the daemon
// still lists a copy by that name. // still lists a copy by that name.
let pending = (a.kind == ActivityKind::Conflict) let pending = (a.kind == ActivityKind::Conflict)
@@ -538,14 +538,17 @@ fn feed_row(
subtitle.push(capitalize(&a.detail)); subtitle.push(capitalize(&a.detail));
} }
if row.count > 1 { if row.count > 1 {
subtitle.push(format!( let since = activity_time(row.first);
"{} times since {}", // Translators: {time} is a date and time such as "Sep 21, 14:05".
row.count, subtitle.push(ngettext_f(
activity_time(row.first) "{n} time since {time}",
"{n} times since {time}",
row.count as u64,
&[("time", &since)],
)); ));
} }
if resolved { if resolved {
subtitle.push("Resolved".to_string()); subtitle.push(pgettext("state", "Resolved"));
} }
let widget = adw::ActionRow::builder() let widget = adw::ActionRow::builder()
.title(title) .title(title)
@@ -573,8 +576,10 @@ fn feed_row(
} }
widget.add_prefix(&icon); widget.add_prefix(&icon);
// Translators: strftime format for the time of day in the activity feed, such as "14:05".
let time_format = gettext("%H:%M");
let time = gtk4::Label::new(Some( let time = gtk4::Label::new(Some(
&at.and_then(|at| at.format("%H:%M").ok()) &at.and_then(|at| at.format(&time_format).ok())
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_default(), .unwrap_or_default(),
)); ));
@@ -587,7 +592,7 @@ fn feed_row(
if let Some(conflict) = pending { if let Some(conflict) = pending {
let resolve = gtk4::Button::builder() let resolve = gtk4::Button::builder()
.label("Resolve…") .label(gettext("Resolve…"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let ui = ui.clone(); let ui = ui.clone();
@@ -598,23 +603,40 @@ fn feed_row(
widget widget
} }
/// A human verb for an activity kind, used as the row title's lead word. /// The row title for an activity: what happened to `target`, as one sentence
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str { /// per kind so translators can place the name where their language needs it.
pub(crate) fn activity_title(kind: ActivityKind, target: &str) -> String {
let args = [("name", target)];
match kind { match kind {
ActivityKind::Upload => "Uploaded", // Translators: {name} is a file or folder name.
ActivityKind::Download => "Downloaded", ActivityKind::Upload => gettext_f("Uploaded {name}", &args),
ActivityKind::Sync => "Synced", // Translators: {name} is a file or folder name.
ActivityKind::Rename => "Renamed", ActivityKind::Download => gettext_f("Downloaded {name}", &args),
ActivityKind::Move => "Moved", // Translators: {name} is a file or folder name.
ActivityKind::CreateFolder => "Created folder", ActivityKind::Sync => gettext_f("Synced {name}", &args),
ActivityKind::Trash => "Trashed", // Translators: {name} is a file or folder name.
ActivityKind::Restore => "Restored", ActivityKind::Rename => gettext_f("Renamed {name}", &args),
ActivityKind::DeleteForever => "Deleted", // Translators: {name} is a file or folder name.
ActivityKind::EmptyTrash => "Emptied Trash", ActivityKind::Move => gettext_f("Moved {name}", &args),
ActivityKind::Share => "Shared", // Translators: {name} is a folder name.
ActivityKind::PublicLink => "Created a link to", ActivityKind::CreateFolder => gettext_f("Created folder {name}", &args),
ActivityKind::Unshare => "Unshared", // Translators: {name} is a file or folder name.
ActivityKind::Conflict => "Conflict", ActivityKind::Trash => gettext_f("Trashed {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Restore => gettext_f("Restored {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::DeleteForever => gettext_f("Deleted {name}", &args),
// The target of an empty is a count, not a name to lead with a verb.
// Translators: {count} is the number of items removed from the Trash.
ActivityKind::EmptyTrash => gettext_f("Emptied Trash ({count})", &[("count", target)]),
// Translators: {name} is a file or folder name.
ActivityKind::Share => gettext_f("Shared {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::PublicLink => gettext_f("Created a link to {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Unshare => gettext_f("Unshared {name}", &args),
// Translators: {name} is the name of the conflict copy.
ActivityKind::Conflict => gettext_f("Conflict {name}", &args),
} }
} }
@@ -639,7 +661,8 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
/// Format an epoch-seconds timestamp for the Activity feed, in local time. /// Format an epoch-seconds timestamp for the Activity feed, in local time.
pub(crate) fn activity_time(secs: i64) -> String { pub(crate) fn activity_time(secs: i64) -> String {
glib::DateTime::from_unix_local(secs) glib::DateTime::from_unix_local(secs)
.and_then(|dt| dt.format("%b %-d, %H:%M")) // Translators: strftime format for a date and time in the activity feed, such as "Sep 23, 14:05".
.and_then(|dt| dt.format(&gettext("%b %-d, %H:%M")))
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_default() .unwrap_or_default()
} }
+104 -70
View File
@@ -40,8 +40,8 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
albums_status( albums_status(
ui, ui,
"view-grid-symbolic", "view-grid-symbolic",
"Loading albums…", &gettext("Loading albums…"),
"Reading your Proton Drive albums.", &gettext("Reading your Proton Drive albums."),
); );
} }
@@ -56,33 +56,33 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
})) => albums_status( })) => albums_status(
&ui, &ui,
"image-missing-symbolic", "image-missing-symbolic",
"No photo library", &gettext("No photo library"),
"This Proton account doesn't have Photos enabled.", &gettext("This Proton account doesn't have Photos enabled."),
), ),
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status( Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
&ui, &ui,
"view-grid-symbolic", "view-grid-symbolic",
"No albums", &gettext("No albums"),
"Create one with New Album, or in Proton Photos.", &gettext("Create one with New Album, or in Proton Photos."),
), ),
Ok(Ok(Response::Albums { items, .. })) => { Ok(Ok(Response::Albums { items, .. })) => {
fill_albums(&ui, &items); fill_albums(&ui, &items);
ui.gallery.albums_stack.set_visible_child_name("grid"); ui.gallery.albums_stack.set_visible_child_name("grid");
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't load albums", &message, kind); toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
albums_status( albums_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't load albums", &gettext("Couldn't load albums"),
&message, &message,
); );
} }
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status( Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
&ui, &ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service didn't respond.", &gettext("The Proton Drive mount service didn't respond."),
), ),
} }
}); });
@@ -102,9 +102,12 @@ fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
} }
*ui.gallery.album_list.borrow_mut() = albums.to_vec(); *ui.gallery.album_list.borrow_mut() = albums.to_vec();
if ui.gallery.album.borrow().is_none() { if ui.gallery.album.borrow().is_none() {
ui.gallery ui.gallery.title.set_subtitle(&ngettext_f(
.title "{n} album",
.set_subtitle(&count_noun(albums.len(), "album", "albums")); "{n} albums",
albums.len() as u64,
&[],
));
} }
for album in albums { for album in albums {
ui.gallery.albums.append(&album_card(ui, album)); ui.gallery.albums.append(&album_card(ui, album));
@@ -199,14 +202,18 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::Widget, x: f64, y: f64) { fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::Widget, x: f64, y: f64) {
let mut menu = ActionMenu::new(); let mut menu = ActionMenu::new();
let (ui_c, album_c) = (ui.clone(), album.clone()); let (ui_c, album_c) = (ui.clone(), album.clone());
menu.item("Open", move || open_album(&ui_c, album_c.clone())); menu.item(&pgettext("verb", "Open"), move || {
open_album(&ui_c, album_c.clone())
});
if !album.shared { if !album.shared {
menu.section(); menu.section();
let (ui_c, album_c) = (ui.clone(), album.clone()); let (ui_c, album_c) = (ui.clone(), album.clone());
menu.item("Rename…", move || prompt_rename_album(&ui_c, &album_c)); menu.item(&gettext("Rename…"), move || {
prompt_rename_album(&ui_c, &album_c)
});
menu.section(); menu.section();
let (ui_c, album_c) = (ui.clone(), album.clone()); let (ui_c, album_c) = (ui.clone(), album.clone());
menu.item("Delete Album…", move || { menu.item(&gettext("Delete Album…"), move || {
confirm_delete_album(&ui_c, &album_c) confirm_delete_album(&ui_c, &album_c)
}); });
} }
@@ -226,13 +233,13 @@ fn prompt_album_name(
let dialog = adw::AlertDialog::builder().heading(heading).build(); let dialog = adw::AlertDialog::builder().heading(heading).build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder() let row = adw::EntryRow::builder()
.title("Album name") .title(gettext("Album name"))
.activates_default(true) .activates_default(true)
.build(); .build();
row.set_text(current); row.set_text(current);
group.add(&row); group.add(&row);
dialog.set_extra_child(Some(&group)); dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("ok", action); dialog.add_response("ok", action);
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
dialog.set_response_enabled("ok", !current.trim().is_empty()); dialog.set_response_enabled("ok", !current.trim().is_empty());
@@ -254,7 +261,8 @@ fn prompt_album_name(
/// Ask for a name and create an album, then add `photos` to it, if any. /// Ask for a name and create an album, then add `photos` to it, if any.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) { pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
let ui_c = ui.clone(); let ui_c = ui.clone();
prompt_album_name(ui, "New Album", "Create", "", move |name| { let (heading, action) = (gettext("New Album"), gettext("Create"));
prompt_album_name(ui, &heading, &action, "", move |name| {
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name }); let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
let ui = ui_c.clone(); let ui = ui_c.clone();
let photos = photos.clone(); let photos = photos.clone();
@@ -265,19 +273,19 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
match reply { match reply {
Ok(Ok(Response::AlbumCreated { uid })) => { Ok(Ok(Response::AlbumCreated { uid })) => {
if photos.is_empty() { if photos.is_empty() {
toast(&ui, "Album created"); toast(&ui, &gettext("Album created"));
reload_album_grid(&ui); reload_album_grid(&ui);
} else { } else {
add_to_album(&ui, uid, photos); add_to_album(&ui, uid, photos);
} }
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't create the album", &message, kind) toast_failure(&ui, &gettext("Couldn't create the album"), &message, kind)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't create the album", &gettext("Couldn't create the album"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -286,15 +294,16 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) { fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone()); let (ui_c, uid) = (ui.clone(), album.uid.clone());
prompt_album_name(ui, "Rename Album", "Rename", &album.name, move |name| { let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
run_album_request( run_album_request(
&ui_c, &ui_c,
Request::RenameAlbum { Request::RenameAlbum {
uid: uid.clone(), uid: uid.clone(),
name, name,
}, },
"Album renamed", gettext("Album renamed"),
"Couldn't rename the album", gettext("Couldn't rename the album"),
); );
}); });
} }
@@ -304,12 +313,13 @@ fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone()); let (ui_c, uid) = (ui.clone(), album.uid.clone());
confirm_destructive( confirm_destructive(
&win, &win,
"Delete Album?", &gettext("Delete Album?"),
&format!( // Translators: {name} is the album's name.
"“{}” is deleted. Its photos stay in your timeline.", &gettext_f(
album.name "“{name}” is deleted. Its photos stay in your timeline.",
&[("name", &album.name)],
), ),
"Delete", &gettext("Delete"),
move || { move || {
run_album_request( run_album_request(
&ui_c, &ui_c,
@@ -317,15 +327,15 @@ fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
uid: uid.clone(), uid: uid.clone(),
delete_photos: false, delete_photos: false,
}, },
"Album deleted", gettext("Album deleted"),
"Couldn't delete the album", gettext("Couldn't delete the album"),
) )
}, },
); );
} }
/// Send an album rename or delete and refresh the grid once it lands. /// Send an album rename or delete and refresh the grid once it lands.
fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed: &'static str) { fn run_album_request(ui: &Rc<Ui>, request: Request, done: String, failed: String) {
let rx = spawn_request(ui.dirs.control_socket(), request); let rx = spawn_request(ui.dirs.control_socket(), request);
let ui = ui.clone(); let ui = ui.clone();
ui.busy_begin(); ui.busy_begin();
@@ -334,11 +344,13 @@ fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed:
ui.busy_end(); ui.busy_end();
match reply { match reply {
Ok(Ok(Response::Ok { .. })) => { Ok(Ok(Response::Ok { .. })) => {
toast(&ui, done); toast(&ui, &done);
reload_album_grid(&ui); reload_album_grid(&ui);
} }
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), Ok(Ok(Response::Error { message, kind })) => {
_ => toast_error(&ui, failed, "The mount service didn't respond."), toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
} }
}); });
} }
@@ -360,14 +372,14 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
items.into_iter().filter(|album| !album.shared).collect() items.into_iter().filter(|album| !album.shared).collect()
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't load albums", &message, kind); toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
return; return;
} }
_ => { _ => {
toast_error( toast_error(
&ui, &ui,
"Couldn't load albums", &gettext("Couldn't load albums"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
); );
return; return;
} }
@@ -396,15 +408,20 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
.child(&list) .child(&list)
.build(); .build();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Add to Album") .heading(gettext("Add to Album"))
.body(match photos.len() { .body(match photos.len() {
1 => "Choose an album for this photo.".to_string(), 1 => gettext("Choose an album for this photo."),
n => format!("Choose an album for {n} photos."), n => ngettext_f(
"Choose an album for {n} photo.",
"Choose an album for {n} photos.",
n as u64,
&[],
),
}) })
.extra_child(&scroll) .extra_child(&scroll)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("new", "New Album…"); dialog.add_response("new", &gettext("New Album…"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone()); let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone());
@@ -435,25 +452,31 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
match reply { match reply {
Ok(Ok(Response::AlbumChanged { changed, failed })) => { Ok(Ok(Response::AlbumChanged { changed, failed })) => {
if let Some(failure) = failed.first() { if let Some(failure) = failed.first() {
toast_error(&ui, "Some photos couldn't be added", &failure.message); toast_error(
&ui,
&gettext("Some photos couldn't be added"),
&failure.message,
);
} else { } else {
toast( toast(
&ui, &ui,
&format!( &ngettext_f(
"Added {} to the album", "Added {n} photo to the album",
count_noun(changed.len(), "photo", "photos") "Added {n} photos to the album",
changed.len() as u64,
&[],
), ),
); );
} }
reload_album_grid(&ui); reload_album_grid(&ui);
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't add to the album", &message, kind) toast_failure(&ui, &gettext("Couldn't add to the album"), &message, kind)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't add to the album", &gettext("Couldn't add to the album"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -487,7 +510,7 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
restore_photos(&ui, kept); restore_photos(&ui, kept);
toast_error( toast_error(
&ui, &ui,
"Some photos couldn't be removed from the album", &gettext("Some photos couldn't be removed from the album"),
&failed[0].message, &failed[0].message,
); );
} }
@@ -503,22 +526,29 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
drop(open); drop(open);
toast( toast(
&ui, &ui,
&format!( &ngettext_f(
"Removed {} from the album", "Removed {n} photo from the album",
count_noun(changed.len(), "photo", "photos") "Removed {n} photos from the album",
changed.len() as u64,
&[],
), ),
); );
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
restore_photos(&ui, removed); restore_photos(&ui, removed);
toast_failure(&ui, "Couldn't remove from the album", &message, kind) toast_failure(
&ui,
&gettext("Couldn't remove from the album"),
&message,
kind,
)
} }
_ => { _ => {
restore_photos(&ui, removed); restore_photos(&ui, removed);
toast_error( toast_error(
&ui, &ui,
"Couldn't remove from the album", &gettext("Couldn't remove from the album"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
) )
} }
} }
@@ -527,14 +557,16 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
/// "12 photos", plus where the album came from when it isn't ours. /// "12 photos", plus where the album came from when it isn't ours.
fn album_subtitle(album: &AlbumInfo) -> String { fn album_subtitle(album: &AlbumInfo) -> String {
let photos = match album.photo_count { let count = album.photo_count as u64;
1 => "1 photo".to_string(),
n => format!("{n} photos"),
};
if album.shared { if album.shared {
format!("{photos} · shared with me") ngettext_f(
"{n} photo · shared with me",
"{n} photos · shared with me",
count,
&[],
)
} else { } else {
photos ngettext_f("{n} photo", "{n} photos", count, &[])
} }
} }
@@ -588,7 +620,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
if ui.gallery.album.borrow_mut().take().is_none() { if ui.gallery.album.borrow_mut().take().is_none() {
return; return;
} }
ui.gallery.title.set_title("Photos"); ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.set_visible(true); ui.gallery.view_switch.set_visible(true);
ui.gallery.upload.set_visible(true); ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false); ui.gallery.back.set_visible(false);
@@ -638,9 +670,11 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
ui.gallery.back.clone().connect_clicked(move |_| { ui.gallery.back.clone().connect_clicked(move |_| {
show_album_grid(&ui_back); show_album_grid(&ui_back);
let count = ui_back.gallery.album_list.borrow().len(); let count = ui_back.gallery.album_list.borrow().len();
ui_back ui_back.gallery.title.set_subtitle(&ngettext_f(
.gallery "{n} album",
.title "{n} albums",
.set_subtitle(&count_noun(count, "album", "albums")); count as u64,
&[],
));
}); });
} }
File diff suppressed because it is too large. Load diff
+182 -131
View File
@@ -52,23 +52,23 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
// themselves are local paths, so they live on the Sync page; keeping a // themselves are local paths, so they live on the Sync page; keeping a
// second copy of that list here would be two places to change the same mode. // second copy of that list here would be two places to change the same mode.
let sync_group = adw::PreferencesGroup::builder() let sync_group = adw::PreferencesGroup::builder()
.title("This computer") .title(gettext("This computer"))
.description("The computer this machine backs up as.") .description(gettext("The computer this machine backs up as."))
.build(); .build();
// Rename control for *this* machine's device, in the section header. The // Rename control for *this* machine's device, in the section header. The
// current device is filtered out of "Other computers", so this is the only // current device is filtered out of "Other computers", so this is the only
// place it can be renamed; insensitive until the device list identifies it. // place it can be renamed; insensitive until the device list identifies it.
let rename_this = gtk4::Button::builder() let rename_this = gtk4::Button::builder()
.icon_name("document-edit-symbolic") .icon_name("document-edit-symbolic")
.tooltip_text("Rename this computer") .tooltip_text(gettext("Rename this computer"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.sensitive(false) .sensitive(false)
.build(); .build();
rename_this.add_css_class("flat"); rename_this.add_css_class("flat");
sync_group.set_header_suffix(Some(&rename_this)); sync_group.set_header_suffix(Some(&rename_this));
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Other computers") .title(gettext("Other computers"))
.description("Other computers backing up to this account.") .description(gettext("Other computers backing up to this account."))
.build(); .build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
@@ -81,7 +81,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
.build(); .build();
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -106,7 +106,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
inner.set_margin_start(18); inner.set_margin_start(18);
inner.set_margin_end(18); inner.set_margin_end(18);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Computers", &inner); let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
header.pack_end(&refresh); header.pack_end(&refresh);
( (
@@ -158,8 +158,8 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
devices_status( devices_status(
ui, ui,
"computer-symbolic", "computer-symbolic",
"Loading…", &gettext("Loading…"),
"Reading your computers.", &gettext("Reading your computers."),
false, false,
); );
ui.busy_begin(); ui.busy_begin();
@@ -206,8 +206,8 @@ pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
devices_status( devices_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -215,8 +215,8 @@ pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
devices_status( devices_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
@@ -249,11 +249,19 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone())); *ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
ui.devices ui.devices
.sync_group .sync_group
.set_description(Some(&format!("Backing up as “{}”.", me.name))); // Translators: {name} is the name of this computer's device.
.set_description(Some(&gettext_f(
"Backing up as “{name}”.",
&[("name", &me.name)],
)));
ui.devices.rename_this.set_sensitive(true); ui.devices.rename_this.set_sensitive(true);
ui.devices ui.devices
.rename_this .rename_this
.set_tooltip_text(Some(&format!("Rename this computer ({})", me.name))); // Translators: {name} is the name of this computer's device.
.set_tooltip_text(Some(&gettext_f(
"Rename this computer ({name})",
&[("name", &me.name)],
)));
} }
None => { None => {
*ui.devices.this_device.borrow_mut() = None; *ui.devices.this_device.borrow_mut() = None;
@@ -263,8 +271,8 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect(); let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
if others.is_empty() { if others.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("No other computers") .title(gettext("No other computers"))
.subtitle("Desktop apps syncing to this account appear here.") .subtitle(gettext("Desktop apps syncing to this account appear here."))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
ui.devices.group.add(&row); ui.devices.group.add(&row);
@@ -288,21 +296,21 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone()); let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
row.add_suffix(&more_menu_button(vec![ row.add_suffix(&more_menu_button(vec![
( (
"Restore to This Computer…", &gettext("Restore to This Computer…"),
Box::new(move || { Box::new(move || {
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone()))) prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone())))
}), }),
), ),
( (
"Rename…", &gettext("Rename…"),
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)), Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
), ),
( (
"Use This Computer's Identity…", &gettext("Use This Computer's Identity…"),
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)), Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
), ),
( (
"Remove Computer…", &gettext("Remove Computer…"),
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)), Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
), ),
])); ]));
@@ -317,10 +325,19 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
/// "never" is the fact that tells the user this computer isn't backing anything up. /// "never" is the fact that tells the user this computer isn't backing anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String { pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
match dev.last_sync { match dev.last_sync {
Some(secs) if secs > 0 => { // Translators: {platform} is the device's platform (such as "Linux"), {time} a date and time.
format!("{} · last synced {}", dev.device_type, activity_time(secs)) Some(secs) if secs > 0 => gettext_f(
} "{platform} · last synced {time}",
_ => format!("{} · never synced", dev.device_type), &[
("platform", &dev.device_type),
("time", &activity_time(secs)),
],
),
// Translators: {platform} is the device's platform, such as "Linux".
_ => gettext_f(
"{platform} · never synced",
&[("platform", &dev.device_type)],
),
} }
} }
@@ -335,13 +352,13 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
ui.devices.sync_group.remove(&row); ui.devices.sync_group.remove(&row);
} }
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("This computer") .title(gettext("This computer"))
.subtitle(this_computer_subtitle(folders)) .subtitle(this_computer_subtitle(folders))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let manage = gtk4::Button::builder() let manage = gtk4::Button::builder()
.label("Open Sync") .label(gettext("Open Sync"))
.tooltip_text("Manage this computer's folders and mountpoint") .tooltip_text(gettext("Manage this computer's folders and mountpoint"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
manage.add_css_class("flat"); manage.add_css_class("flat");
@@ -350,7 +367,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
row.add_suffix(&manage); row.add_suffix(&manage);
let ui_rs = ui.clone(); let ui_rs = ui.clone();
row.add_suffix(&more_menu_button(vec![( row.add_suffix(&more_menu_button(vec![(
"Restore Folders…", &gettext("Restore Folders…"),
Box::new(move || prompt_restore_folders(&ui_rs, None)), Box::new(move || prompt_restore_folders(&ui_rs, None)),
)])); )]));
ui.devices.sync_group.add(&row); ui.devices.sync_group.add(&row);
@@ -361,30 +378,41 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
/// attention — the one fact worth surfacing away from the folder list itself. /// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String { pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
if folders.is_empty() { if folders.is_empty() {
return "No folders backed up yet — add one on the Sync page.".to_string(); return gettext("No folders backed up yet — add one on the Sync page.");
} }
let count = match folders.len() { let count = ngettext_f(
1 => "1 folder backed up".to_string(), "{n} folder backed up",
n => format!("{n} folders backed up"), "{n} folders backed up",
}; folders.len() as u64,
&[],
);
let attention = folders let attention = folders
.iter() .iter()
.filter(|f| f.state == "error" || f.state == "conflict") .filter(|f| f.state == "error" || f.state == "conflict")
.count(); .count();
match attention { match attention {
0 => format!("{count} · manage them on the Sync page"), // Translators: {folders} is "N folders backed up".
1 => format!("{count} · 1 needs attention"), 0 => gettext_f(
n => format!("{count} · {n} need attention"), "{folders} · manage them on the Sync page",
&[("folders", &count)],
),
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
n => ngettext_f(
"{folders} · {n} needs attention",
"{folders} · {n} need attention",
n as u64,
&[("folders", &count)],
),
} }
} }
/// Human label for a synced folder's `state` column. /// Human label for a synced folder's `state` column.
pub(crate) fn sync_state_label(state: &str) -> &str { pub(crate) fn sync_state_label(state: &str) -> String {
match state { match state {
"syncing" => "syncing…", "syncing" => gettext("syncing…"),
"error" => "sync error", "error" => gettext("sync error"),
"conflict" => "needs attention", "conflict" => gettext("needs attention"),
_ => "up to date", _ => gettext("up to date"),
} }
} }
@@ -396,18 +424,30 @@ pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase { match p.phase {
// Before the first pass finishes there is no estimate, so the count would // Before the first pass finishes there is no estimate, so the count would
// be "checked 12 of 12" — worse than saying nothing. // be "checked 12 of 12" — worse than saying nothing.
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(), SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
SyncPhase::Scanning => format!( // Translators: {done} and {total} count folders and files checked so far.
"checking for changes — {} of {}", SyncPhase::Scanning => gettext_f(
p.done, "checking for changes — {done} of {total}",
p.total.max(p.done) &[
("done", &p.done.to_string()),
("total", &p.total.max(p.done).to_string()),
],
), ),
SyncPhase::Applying => { SyncPhase::Applying => {
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1)); let done = (p.done + 1).to_string();
let total = p.total.max(p.done + 1).to_string();
if p.current.is_empty() { if p.current.is_empty() {
format!("syncing {count}") // Translators: {done} and {total} count the changes applied so far.
gettext_f(
"syncing {done} of {total}",
&[("done", &done), ("total", &total)],
)
} else { } else {
format!("syncing {} — {count}", p.current) // Translators: {file} is the path being synced; {done} and {total} count the changes applied so far.
gettext_f(
"syncing {file} — {done} of {total}",
&[("file", &p.current), ("done", &done), ("total", &total)],
)
} }
} }
} }
@@ -417,7 +457,7 @@ pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) { pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
.title("Add Folder to Sync") .title(gettext("Add Folder to Sync"))
.build(); .build();
let ui = ui.clone(); let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
@@ -441,15 +481,15 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
// or too late (row flashes in). Instead let the periodic // or too late (row flashes in). Instead let the periodic
// `refresh_locations` tick pick the row up whenever it // `refresh_locations` tick pick the row up whenever it
// actually lands, which is what keeps a running pass live too. // actually lands, which is what keeps a running pass live too.
toast(&ui, "Syncing folder…"); toast(&ui, &gettext("Syncing folder…"));
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't add folder", &message, kind) toast_failure(&ui, &gettext("Couldn't add folder"), &message, kind)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't add folder", &gettext("Couldn't add folder"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -480,13 +520,16 @@ pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String
Ok(Ok(Response::RestorableFolders { items })) => { Ok(Ok(Response::RestorableFolders { items })) => {
show_restore_picker(&ui, items, device) show_restore_picker(&ui, items, device)
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => toast_failure(
toast_failure(&ui, "Couldn't list folders to restore", &message, kind) &ui,
} &gettext("Couldn't list folders to restore"),
&message,
kind,
),
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't list folders to restore", &gettext("Couldn't list folders to restore"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -510,9 +553,9 @@ fn show_restore_picker(
} }
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.description( .description(gettext(
"Tick the folders to sync to this machine, and adjust where each one should live.", "Tick the folders to sync to this machine, and adjust where each one should live.",
) ))
.build(); .build();
// Held so the response handler can read back what the user ticked and typed. // Held so the response handler can read back what the user ticked and typed.
let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new(); let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new();
@@ -534,14 +577,14 @@ fn show_restore_picker(
.child(&group) .child(&group)
.build(); .build();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Restore folders") .heading(gettext("Restore folders"))
.body(restore_picker_body( .body(restore_picker_body(
device.as_ref().map(|(_, n)| n.as_str()), device.as_ref().map(|(_, n)| n.as_str()),
)) ))
.extra_child(&scroll) .extra_child(&scroll)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("restore", "Restore"); dialog.add_response("restore", &gettext("Restore"));
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("restore")); dialog.set_default_response(Some("restore"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -574,8 +617,8 @@ fn show_restore_picker(
}, },
None => Request::RestoreSyncFolders { items }, None => Request::RestoreSyncFolders { items },
}, },
"Restoring folders…", &gettext("Restoring folders…"),
"Couldn't restore folders", &gettext("Couldn't restore folders"),
); );
}); });
dialog.present(win.as_ref()); dialog.present(win.as_ref());
@@ -584,19 +627,24 @@ fn show_restore_picker(
/// The restore picker's body: whose backup the folders come from. /// The restore picker's body: whose backup the folders come from.
pub(crate) fn restore_picker_body(device: Option<&str>) -> String { pub(crate) fn restore_picker_body(device: Option<&str>) -> String {
match device { match device {
Some(name) => format!( // Translators: {name} is the name of another computer.
"These folders are backed up from “{name}”. Restored folders keep syncing with \ Some(name) => gettext_f(
that computer's copy." "These folders are backed up from “{name}”. Restored folders keep syncing with that computer's copy.",
&[("name", name)],
), ),
None => "These folders are backed up under this computer in Proton Drive.".to_string(), None => gettext("These folders are backed up under this computer in Proton Drive."),
} }
} }
/// The toast when every folder of a device is already synced here. /// The toast when every folder of a device is already synced here.
pub(crate) fn nothing_to_restore(device: Option<&str>) -> String { pub(crate) fn nothing_to_restore(device: Option<&str>) -> String {
match device { match device {
Some(name) => format!("Nothing to restore — “{name}”'s folders are all synced here."), // Translators: {name} is the name of another computer.
None => "Nothing to restore — this computer's folders are all synced here.".to_string(), Some(name) => gettext_f(
"Nothing to restore — “{name}”'s folders are all synced here.",
&[("name", name)],
),
None => gettext("Nothing to restore — this computer's folders are all synced here."),
} }
} }
@@ -619,19 +667,19 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
// pass, hence "will". // pass, hence "will".
Ok(Ok(Response::Ok { .. })) => toast( Ok(Ok(Response::Ok { .. })) => toast(
&ui, &ui,
if mode == "ondemand" { &if mode == "ondemand" {
"Folder will switch to on-demand" gettext("Folder will switch to on-demand")
} else { } else {
"Folder will download and stay synced" gettext("Folder will download and stay synced")
}, },
), ),
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't change mode", &message, kind) toast_failure(&ui, &gettext("Couldn't change mode"), &message, kind)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't change mode", &gettext("Couldn't change mode"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
reload_sync_pages(&ui); reload_sync_pages(&ui);
@@ -660,36 +708,36 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
// nasty surprise if the dialog claimed the local files were safe, and is // nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download. // recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand { let body = if ondemand {
format!( // Translators: {path} is a local folder path.
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \ gettext_f(
Drive, not on this disk, so the folder will be empty once it is unmounted. To \ "Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton Drive, not on this disk, so the folder will be empty once it is unmounted. To keep a local copy, cancel, turn off On-demand, and wait for the download to finish before removing it.",
keep a local copy, cancel, turn off On-demand, and wait for the download to \ &[("path", path)],
finish before removing it."
) )
} else { } else {
format!( // Translators: {path} is a local folder path.
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \ gettext_f(
being synced. Choose whether to also delete the copy in Proton Drive." "Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop being synced. Choose whether to also delete the copy in Proton Drive.",
&[("path", path)],
) )
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Stop syncing folder") .heading(gettext("Stop syncing folder"))
.body(body) .body(body)
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let delete_remote = adw::SwitchRow::builder() let delete_remote = adw::SwitchRow::builder()
.title("Also delete from Proton Drive") .title(gettext("Also delete from Proton Drive"))
.subtitle(if ondemand { .subtitle(if ondemand {
"Deletes the only copy of these files." gettext("Deletes the only copy of these files.")
} else { } else {
"The local copy is unaffected." gettext("The local copy is unaffected.")
}) })
.active(false) .active(false)
.build(); .build();
group.add(&delete_remote); group.add(&delete_remote);
dialog.set_extra_child(Some(&group)); dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", "Stop Syncing"); dialog.add_response("remove", &gettext("Stop Syncing"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -702,8 +750,8 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
id, id,
delete_remote: delete_remote.is_active(), delete_remote: delete_remote.is_active(),
}, },
"Stopped syncing folder", &gettext("Stopped syncing folder"),
"Couldn't stop syncing the folder", &gettext("Couldn't stop syncing the folder"),
); );
} }
}); });
@@ -714,19 +762,20 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) { pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Rename Computer") .heading(gettext("Rename Computer"))
.body(format!("Rename “{current}”.")) // Translators: {name} is the computer's current name.
.body(gettext_f("Rename “{name}”.", &[("name", current)]))
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder() let row = adw::EntryRow::builder()
.title("New name") .title(gettext("New name"))
.activates_default(true) .activates_default(true)
.build(); .build();
row.set_text(current); row.set_text(current);
group.add(&row); group.add(&row);
dialog.set_extra_child(Some(&group)); dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("rename", "Rename"); dialog.add_response("rename", &gettext("Rename"));
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename")); dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -738,7 +787,11 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
} }
let name = row.text().trim().to_string(); let name = row.text().trim().to_string();
if name.is_empty() { if name.is_empty() {
toast_error(&ui, "Couldn't rename the computer", "A name is required."); toast_error(
&ui,
&gettext("Couldn't rename the computer"),
&gettext("A name is required."),
);
return; return;
} }
run_devices_mutation( run_devices_mutation(
@@ -747,8 +800,8 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
uid: uid.clone(), uid: uid.clone(),
name, name,
}, },
"Computer renamed", &gettext("Computer renamed"),
"Couldn't rename the computer", &gettext("Couldn't rename the computer"),
); );
}); });
dialog.present(win.as_ref()); dialog.present(win.as_ref());
@@ -759,21 +812,22 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) { pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let confirm = adw::EntryRow::builder() let confirm = adw::EntryRow::builder()
.title(format!("Type “{name}” to confirm")) // Translators: {name} is the computer's name, which the user must type.
.title(gettext_f("Type “{name}” to confirm", &[("name", name)]))
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
group.add(&confirm); group.add(&confirm);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Remove Computer") .heading(gettext("Remove Computer"))
.body(format!( // Translators: {name} is the name of the computer being removed.
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \ .body(gettext_f(
deleted along with it. The files on that computer itself are not touched — but \ "Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
this cannot be undone from here." &[("name", name)],
)) ))
.extra_child(&group) .extra_child(&group)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", "Remove"); dialog.add_response("remove", &gettext("Remove"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_response_enabled("remove", false); dialog.set_response_enabled("remove", false);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
@@ -790,8 +844,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
run_devices_mutation( run_devices_mutation(
&ui, &ui,
Request::DeleteDevice { uid: uid.clone() }, Request::DeleteDevice { uid: uid.clone() },
"Computer removed", &gettext("Computer removed"),
"Couldn't remove the computer", &gettext("Couldn't remove the computer"),
); );
} }
}); });
@@ -808,16 +862,15 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) { pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Use this computer's identity") .heading(gettext("Use this computer's identity"))
.body(format!( // Translators: {name} is the name of the other computer whose identity is adopted.
"Treat this machine as “{name}”?\n\nNew synced folders are created under that \ .body(gettext_f(
computer in Proton Drive, and this machine keeps that identity even if its hostname \ "Treat this machine as “{name}”?\n\nNew synced folders are created under that computer in Proton Drive, and this machine keeps that identity even if its hostname changes. Folders already synced here are not moved.\n\nUse “Restore folders” afterwards to bring back what “{name}” was syncing.",
changes. Folders already synced here are not moved.\n\nUse “Restore folders” \ &[("name", name)],
afterwards to bring back what “{name}” was syncing."
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("adopt", "Use identity"); dialog.add_response("adopt", &gettext("Use identity"));
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -830,8 +883,8 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
Request::AdoptDevice { Request::AdoptDevice {
uid: Some(uid.clone()), uid: Some(uid.clone()),
}, },
"Identity adopted", &gettext("Identity adopted"),
"Couldn't adopt that computer", &gettext("Couldn't adopt that computer"),
); );
} }
}); });
@@ -839,16 +892,12 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
} }
/// Run a mutation raised from Computers or Locations and reload the page on success. /// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation( pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin(); ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req); let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone(); let ui = ui.clone();
let done = done.to_string(); let done = done.to_string();
let failed = failed.to_string();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
@@ -857,8 +906,10 @@ pub(crate) fn run_devices_mutation(
reload_sync_pages(&ui); reload_sync_pages(&ui);
toast(&ui, &done); toast(&ui, &done);
} }
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), Ok(Ok(Response::Error { message, kind })) => {
_ => toast_error(&ui, failed, "The mount service didn't respond."), toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
} }
}); });
} }
+188 -141
View File
@@ -86,22 +86,23 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
let add_folder = gtk4::Button::builder() let add_folder = gtk4::Button::builder()
.child( .child(
&adw::ButtonContent::builder() &adw::ButtonContent::builder()
.label("Add Folder") .label(gettext("Add Folder"))
.icon_name("list-add-symbolic") .icon_name("list-add-symbolic")
.build(), .build(),
) )
.tooltip_text("Back a local folder up to this computer's Proton Drive device") .tooltip_text(gettext(
"Back a local folder up to this computer's Proton Drive device",
))
.build(); .build();
let refresh = refresh_button(); let refresh = refresh_button();
let transfers_group = build_transfers_group(); let transfers_group = build_transfers_group();
let (card, card_icon, card_row, pause) = build_sync_card(); let (card, card_icon, card_row, pause) = build_sync_card();
let (queue_group, retry_all) = build_queue_group(); let (queue_group, retry_all) = build_queue_group();
let conflicts_group = adw::PreferencesGroup::builder() let conflicts_group = adw::PreferencesGroup::builder()
.title("Conflicts") .title(gettext("Conflicts"))
.description( .description(gettext(
"Files changed in two places at once. Both versions were kept; choose which \ "Files changed in two places at once. Both versions were kept; choose which one stays.",
one stays.", ))
)
.visible(false) .visible(false)
.build(); .build();
@@ -109,12 +110,8 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
// on-demand switch removes the local copy, which is not a thing to discover // on-demand switch removes the local copy, which is not a thing to discover
// after the fact. // after the fact.
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("On this computer") .title(gettext("On this computer"))
.description( .description(gettext("Where Proton Drive lives on this machine. Synced folders keep a full copy on this disk; on-demand folders keep the files in Proton Drive only and fetch them as you open them."))
"Where Proton Drive lives on this machine. Synced folders keep a full copy on \
this disk; on-demand folders keep the files in Proton Drive only and fetch \
them as you open them.",
)
.build(); .build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
@@ -134,7 +131,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.build(); .build();
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -142,7 +139,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
retry.set_visible(false); retry.set_visible(false);
let status = adw::StatusPage::builder() let status = adw::StatusPage::builder()
.icon_name("drive-harddisk-symbolic") .icon_name("drive-harddisk-symbolic")
.title("Loading…") .title(gettext("Loading…"))
.child(&retry) .child(&retry)
.build(); .build();
// A bare `StatusPage` asks for more width than a half-screen window has, and // A bare `StatusPage` asks for more width than a half-screen window has, and
@@ -165,7 +162,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
page.set_margin_start(18); page.set_margin_start(18);
page.set_margin_end(18); page.set_margin_end(18);
page.append(&content); page.append(&content);
let (frame, header, _) = page_frame("Sync", &page); let (frame, header, _) = page_frame(&pgettext("page", "Sync"), &page);
header.pack_start(&add_folder); header.pack_start(&add_folder);
header.pack_end(&refresh); header.pack_end(&refresh);
@@ -219,24 +216,29 @@ fn build_sync_card() -> (
.build(); .build();
icon.add_css_class("sidebar-status"); icon.add_css_class("sidebar-status");
let menu = gio::Menu::new(); let menu = gio::Menu::new();
menu.append(Some("Pause for 1 Hour"), Some("sync.pause-for(int64 3600)"));
menu.append( menu.append(
Some("Pause for 8 Hours"), Some(&gettext("Pause for 1 Hour")),
Some("sync.pause-for(int64 3600)"),
);
menu.append(
Some(&gettext("Pause for 8 Hours")),
Some("sync.pause-for(int64 28800)"), Some("sync.pause-for(int64 28800)"),
); );
menu.append( menu.append(
Some("Pause for 24 Hours"), Some(&gettext("Pause for 24 Hours")),
Some("sync.pause-for(int64 86400)"), Some("sync.pause-for(int64 86400)"),
); );
let pause = adw::SplitButton::builder() let pause = adw::SplitButton::builder()
.label("Pause") .label(pgettext("verb", "Pause"))
.menu_model(&menu) .menu_model(&menu)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.tooltip_text("Stop uploading until you resume. Files still open as usual.") .tooltip_text(gettext(
.dropdown_tooltip("Pause for a while") "Stop uploading until you resume. Files still open as usual.",
))
.dropdown_tooltip(gettext("Pause for a while"))
.build(); .build();
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Checking…") .title(gettext("Checking…"))
.title_lines(1) .title_lines(1)
.subtitle_lines(2) .subtitle_lines(2)
.build(); .build();
@@ -269,12 +271,15 @@ pub(crate) fn paint_sync_card(
card.row.set_title(title); card.row.set_title(title);
card.row.set_subtitle(detail.unwrap_or_default()); card.row.set_subtitle(detail.unwrap_or_default());
card.paused.set(paused); card.paused.set(paused);
card.pause card.pause.set_label(&if paused {
.set_label(if paused { "Resume" } else { "Pause" }); pgettext("verb", "Resume")
card.pause.set_tooltip_text(Some(if paused {
"Upload everything that waited while sync was paused"
} else { } else {
"Stop uploading until you resume. Files still open as usual." pgettext("verb", "Pause")
});
card.pause.set_tooltip_text(Some(&if paused {
gettext("Upload everything that waited while sync was paused")
} else {
gettext("Stop uploading until you resume. Files still open as usual.")
})); }));
if paused { if paused {
card.pause.add_css_class("suggested-action"); card.pause.add_css_class("suggested-action");
@@ -320,21 +325,21 @@ fn set_sync_paused(ui: &Rc<Ui>, paused: bool, until: Option<i64>) {
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let what = if paused { let what = if paused {
"Couldn't pause sync" gettext("Couldn't pause sync")
} else { } else {
"Couldn't resume sync" gettext("Couldn't resume sync")
}; };
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast( Ok(Ok(Response::Ok { .. })) => toast(
&ui, &ui,
match (paused, until) { &match (paused, until) {
(false, _) => "Sync resumed", (false, _) => gettext("Sync resumed"),
(true, None) => "Sync paused until you resume", (true, None) => gettext("Sync paused until you resume"),
(true, Some(_)) => "Sync paused", (true, Some(_)) => gettext("Sync paused"),
}, },
), ),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, what, &message, kind), Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, &what, &message, kind),
_ => toast_error(&ui, what, "The mount service didn't respond."), _ => toast_error(&ui, &what, &gettext("The mount service didn't respond.")),
} }
ui.locations.card.pause.set_sensitive(true); ui.locations.card.pause.set_sensitive(true);
refresh_status(&ui); refresh_status(&ui);
@@ -344,15 +349,17 @@ fn set_sync_paused(ui: &Rc<Ui>, paused: bool, until: Option<i64>) {
/// The queue list, hidden while nothing waits. /// The queue list, hidden while nothing waits.
fn build_queue_group() -> (adw::PreferencesGroup, gtk4::Button) { fn build_queue_group() -> (adw::PreferencesGroup, gtk4::Button) {
let retry_all = gtk4::Button::builder() let retry_all = gtk4::Button::builder()
.label("Retry All") .label(gettext("Retry All"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.tooltip_text("Try every failed change again now") .tooltip_text(gettext("Try every failed change again now"))
.visible(false) .visible(false)
.build(); .build();
retry_all.add_css_class("flat"); retry_all.add_css_class("flat");
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Waiting to Upload") .title(gettext("Waiting to Upload"))
.description("Changes made on this computer that are not on Proton Drive yet.") .description(gettext(
"Changes made on this computer that are not on Proton Drive yet.",
))
.header_suffix(&retry_all) .header_suffix(&retry_all)
.visible(false) .visible(false)
.build(); .build();
@@ -406,13 +413,11 @@ fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
.set_visible(items.iter().any(|op| op.attempts > 0 && !op.parked)); .set_visible(items.iter().any(|op| op.attempts > 0 && !op.parked));
let hidden = items.len().saturating_sub(QUEUE_ROWS_SHOWN); let hidden = items.len().saturating_sub(QUEUE_ROWS_SHOWN);
queue.group.set_description(Some(&if hidden > 0 { queue.group.set_description(Some(&if hidden > 0 {
format!( let shown = QUEUE_ROWS_SHOWN.to_string();
"Changes made on this computer that are not on Proton Drive yet. \ // Translators: {shown} is how many rows are listed (50), {n} the total number of queued changes.
Showing the first {QUEUE_ROWS_SHOWN} of {}.", ngettext_f("Changes made on this computer that are not on Proton Drive yet. Showing the first {shown} of {n}.", "Changes made on this computer that are not on Proton Drive yet. Showing the first {shown} of {n}.", items.len() as u64, &[("shown", &shown)])
items.len()
)
} else { } else {
"Changes made on this computer that are not on Proton Drive yet.".to_string() gettext("Changes made on this computer that are not on Proton Drive yet.")
})); }));
let now = glib::DateTime::now_utc() let now = glib::DateTime::now_utc()
.map(|now| now.to_unix()) .map(|now| now.to_unix())
@@ -427,26 +432,36 @@ fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow { fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
let (icon, action) = match op.kind.as_str() { let (icon, action) = match op.kind.as_str() {
"revision" => ("pdfs-upload-symbolic", "Upload changes"), "revision" => ("pdfs-upload-symbolic", gettext("Upload changes")),
"create" => ("pdfs-upload-symbolic", "Upload new file"), "create" => ("pdfs-upload-symbolic", gettext("Upload new file")),
"mkdir" => ("folder-new-symbolic", "Create folder"), "mkdir" => ("folder-new-symbolic", gettext("Create folder")),
"rename" => ("document-edit-symbolic", "Rename or move"), "rename" => ("document-edit-symbolic", gettext("Rename or move")),
"trash" => ("user-trash-symbolic", "Move to trash"), "trash" => ("user-trash-symbolic", gettext("Move to trash")),
_ => ("emblem-synchronizing-symbolic", "Change"), // Translators: a queued change of an unknown kind.
_ => ("emblem-synchronizing-symbolic", pgettext("noun", "Change")),
}; };
let state = match (op.parked, op.next_attempt_at) { let state = match (op.parked, op.next_attempt_at) {
(true, _) => "waiting for the app writing it to finish".to_string(), (true, _) => gettext("waiting for the app writing it to finish"),
(false, Some(at)) if at > now && op.attempts > 0 => { (false, Some(at)) if at > now && op.attempts > 0 => {
format!("retrying {}", clock_time(at)) // Translators: {time} is when the next attempt happens, such as "14:30".
gettext_f("retrying {time}", &[("time", &clock_time(at))])
} }
(false, _) if ui.locations.card.paused.get() => "waiting for sync to resume".to_string(), (false, _) if ui.locations.card.paused.get() => gettext("waiting for sync to resume"),
(false, _) => "up next".to_string(), (false, _) => gettext("up next"),
}; };
let mut subtitle = format!("{action} · {state}"); // Translators: a queued change's kind and its state, such as "Upload changes · up next".
let mut subtitle = gettext_f(
"{action} · {state}",
&[("action", &action), ("state", &state)],
);
if let Some(error) = &op.last_error { if let Some(error) = &op.last_error {
subtitle.push_str(&format!( subtitle.push('\n');
"\nFailed {}: {error}", // Translators: {n} is how many attempts failed, {error} the last error message.
count_noun(op.attempts.max(0) as usize, "time", "times") subtitle.push_str(&ngettext_f(
"Failed {n} time: {error}",
"Failed {n} times: {error}",
op.attempts.max(0) as u64,
&[("error", error)],
)); ));
} }
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
@@ -469,7 +484,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
if waiting { if waiting {
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text("Retry now") .tooltip_text(gettext("Retry now"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("flat"); retry.add_css_class("flat");
@@ -488,14 +503,18 @@ fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => { Ok(Ok(Response::Ok { .. })) => {
toast(&ui, "Retrying now…"); toast(&ui, &gettext("Retrying now…"));
ui.locations.queue.painted.borrow_mut().clear(); ui.locations.queue.painted.borrow_mut().clear();
refresh_queue(&ui); refresh_queue(&ui);
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't retry", &message, kind) toast_failure(&ui, &gettext("Couldn't retry"), &message, kind)
} }
_ => toast_error(&ui, "Couldn't retry", "The mount service didn't respond."), _ => toast_error(
&ui,
&gettext("Couldn't retry"),
&gettext("The mount service didn't respond."),
),
} }
}); });
} }
@@ -565,20 +584,27 @@ fn repaint_conflicts(ui: &Rc<Ui>, items: &[ConflictInfo]) {
/// "12.3 MB, changed at 14:30": one side of a conflict. /// "12.3 MB, changed at 14:30": one side of a conflict.
fn conflict_side(size: u64, modified: i64) -> String { fn conflict_side(size: u64, modified: i64) -> String {
format!( // Translators: one side of a conflict; {size} is a file size such as "12.3 MB", {time} when it was changed, such as "14:30".
"{}, changed {}", gettext_f(
glib::format_size(size), "{size}, changed {time}",
clock_time(modified) &[
("size", &glib::format_size(size)),
("time", &clock_time(modified)),
],
) )
} }
pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow { pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
let verdict = if !conflict.original_exists { let verdict = if !conflict.original_exists {
"The original is gone; only this copy is left".to_string() gettext("The original is gone; only this copy is left")
} else if conflict.identical { } else if conflict.identical {
"Same content as the original".to_string() gettext("Same content as the original")
} else { } else {
format!("Differs from {}", file_name(&conflict.original_path)) // Translators: {name} is the original file's name.
gettext_f(
"Differs from {name}",
&[("name", file_name(&conflict.original_path))],
)
}; };
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(glib::markup_escape_text(&conflict.path).as_str()) .title(glib::markup_escape_text(&conflict.path).as_str())
@@ -608,36 +634,45 @@ pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
let copy_name = file_name(&conflict.path).to_string(); let copy_name = file_name(&conflict.path).to_string();
let original_name = file_name(&conflict.original_path).to_string(); let original_name = file_name(&conflict.original_path).to_string();
let body = match (conflict.original_size, conflict.original_modified) { let body = match (conflict.original_size, conflict.original_modified) {
(Some(size), Some(modified)) if conflict.original_exists => format!( (Some(size), Some(modified)) if conflict.original_exists => {
"“{original_name}” was changed here and elsewhere at the same time.\n\n\ let original = conflict_side(size, modified);
Original: {}\nCopy: {}\n\n\ let copy = conflict_side(conflict.size, conflict.modified);
Whatever is not kept goes to Trash, where it can be restored.", // Translators: {name} is the file name; {original} and {copy} each read like "12.3 MB, changed 14:30".
conflict_side(size, modified), gettext_f(
conflict_side(conflict.size, conflict.modified), "“{name}” was changed here and elsewhere at the same time.\n\nOriginal: {original}\nCopy: {copy}\n\nWhatever is not kept goes to Trash, where it can be restored.",
), &[
_ => format!( ("name", &original_name),
"The original “{original_name}” no longer exists; only the copy is left \ ("original", &original),
({}).\n\nKeep the copy to give it the original name back.", ("copy", &copy),
conflict_side(conflict.size, conflict.modified), ],
), )
}
_ => {
let copy = conflict_side(conflict.size, conflict.modified);
// Translators: {name} is the file name; {copy} reads like "12.3 MB, changed 14:30".
gettext_f(
"The original “{name}” no longer exists; only the copy is left ({copy}).\n\nKeep the copy to give it the original name back.",
&[("name", &original_name), ("copy", &copy)],
)
}
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Resolve conflict") .heading(gettext("Resolve conflict"))
.body(body) .body(body)
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let name_row = adw::EntryRow::builder() let name_row = adw::EntryRow::builder()
.title("Name for the copy, if keeping both") .title(gettext("Name for the copy, if keeping both"))
.build(); .build();
name_row.set_text(&copy_name); name_row.set_text(&copy_name);
group.add(&name_row); group.add(&name_row);
dialog.set_extra_child(Some(&group)); dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
if conflict.original_exists { if conflict.original_exists {
dialog.add_response("original", "Keep Original"); dialog.add_response("original", &gettext("Keep Original"));
dialog.add_response("both", "Keep Both"); dialog.add_response("both", &gettext("Keep Both"));
} }
dialog.add_response("copy", "Keep Copy"); dialog.add_response("copy", &gettext("Keep Copy"));
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
// Keeping both is a rename, so it needs a name that is neither side's. // Keeping both is a rename, so it needs a name that is neither side's.
@@ -692,13 +727,16 @@ fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
refresh_conflicts(&ui, true); refresh_conflicts(&ui, true);
refresh_activity_conflicts(&ui, true); refresh_activity_conflicts(&ui, true);
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => toast_failure(
toast_failure(&ui, "Couldn't resolve the conflict", &message, kind) &ui,
} &gettext("Couldn't resolve the conflict"),
&message,
kind,
),
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't resolve the conflict", &gettext("Couldn't resolve the conflict"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -737,8 +775,8 @@ pub(crate) fn load_locations(ui: &Rc<Ui>) {
locations_status( locations_status(
ui, ui,
"drive-harddisk-symbolic", "drive-harddisk-symbolic",
"Loading…", &gettext("Loading…"),
"Reading this computer's Proton Drive locations.", &gettext("Reading this computer's Proton Drive locations."),
false, false,
); );
ui.busy_begin(); ui.busy_begin();
@@ -759,7 +797,7 @@ pub(crate) fn load_locations(ui: &Rc<Ui>) {
locations_status( locations_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Unavailable", &gettext("Unavailable"),
&message, &message,
true, true,
); );
@@ -803,8 +841,8 @@ pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
locations_status( locations_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -812,8 +850,8 @@ pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
locations_status( locations_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
@@ -830,8 +868,10 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
} }
if items.is_empty() { if items.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("No locations") .title(gettext("No locations"))
.subtitle("The mount service hasn't reported a mountpoint yet.") .subtitle(gettext(
"The mount service hasn't reported a mountpoint yet.",
))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name("drive-harddisk-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("drive-harddisk-symbolic"));
ui.locations.group.add(&row); ui.locations.group.add(&row);
@@ -855,7 +895,7 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
// visible; saying so on the row is cheaper than letting the user find out // visible; saying so on the row is cheaper than letting the user find out
// from a save dialog. // from a save dialog.
if spec.access == MountAccess::Ro { if spec.access == MountAccess::Ro {
let badge = gtk4::Label::new(Some("Read-only")); let badge = gtk4::Label::new(Some(&gettext("Read-only")));
badge.add_css_class("dim-label"); badge.add_css_class("dim-label");
badge.add_css_class("caption"); badge.add_css_class("caption");
badge.set_valign(gtk4::Align::Center); badge.set_valign(gtk4::Align::Center);
@@ -887,7 +927,7 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
let open = gtk4::Button::builder() let open = gtk4::Button::builder()
.icon_name("folder-open-symbolic") .icon_name("folder-open-symbolic")
.tooltip_text("Open this folder") .tooltip_text(gettext("Open this folder"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
open.add_css_class("flat"); open.add_css_class("flat");
@@ -906,8 +946,10 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
/// [`prompt_mountpoint`] dialog. /// [`prompt_mountpoint`] dialog.
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) { fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
let change = gtk4::Button::builder() let change = gtk4::Button::builder()
.label("Change") .label(pgettext("verb", "Change"))
.tooltip_text("Choose a different folder for the Proton Drive mount") .tooltip_text(gettext(
"Choose a different folder for the Proton Drive mount",
))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
change.add_css_class("flat"); change.add_css_class("flat");
@@ -923,9 +965,12 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
let (icon, tooltip) = match spec.paused { let (icon, tooltip) = match spec.paused {
true => ( true => (
"media-playback-start-symbolic", "media-playback-start-symbolic",
"Resume syncing this folder", gettext("Resume syncing this folder"),
),
false => (
"media-playback-pause-symbolic",
gettext("Pause syncing this folder"),
), ),
false => ("media-playback-pause-symbolic", "Pause syncing this folder"),
}; };
let pause = gtk4::Button::builder() let pause = gtk4::Button::builder()
.icon_name(icon) .icon_name(icon)
@@ -942,7 +987,7 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
if spec.mode != MountMode::OnDemand && !spec.paused { if spec.mode != MountMode::OnDemand && !spec.paused {
let sync_now = gtk4::Button::builder() let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now") .tooltip_text(gettext("Sync this folder now"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
sync_now.add_css_class("flat"); sync_now.add_css_class("flat");
@@ -957,11 +1002,7 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
// a spurious request. // a spurious request.
let target = spec.pending_mode.unwrap_or(spec.mode); let target = spec.pending_mode.unwrap_or(spec.mode);
let ondemand = gtk4::Switch::builder() let ondemand = gtk4::Switch::builder()
.tooltip_text( .tooltip_text(gettext("On-demand: free this disk by keeping the files in Proton Drive only, fetching each as you open it. Turn off to download them back and keep a full local copy."))
"On-demand: free this disk by keeping the files in Proton Drive only, \
fetching each as you open it. Turn off to download them back and keep a \
full local copy.",
)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.active(target == MountMode::OnDemand) .active(target == MountMode::OnDemand)
// The daemon refuses a mode switch on a paused folder. // The daemon refuses a mode switch on a paused folder.
@@ -985,7 +1026,7 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("media-playback-stop-symbolic") .icon_name("media-playback-stop-symbolic")
.tooltip_text("Stop syncing this folder") .tooltip_text(gettext("Stop syncing this folder"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
@@ -1002,14 +1043,12 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
/// space by removing the local copies, which is not something to do by accident. /// space by removing the local copies, which is not something to do by accident.
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) { fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Make Folder On-Demand?") .heading(gettext("Make Folder On-Demand?"))
.body(format!( // Translators: {path} is a local folder path.
"Files in {path} will be removed from this computer and kept in Proton \ .body(gettext_f("Files in {path} will be removed from this computer and kept in Proton Drive only. Each file downloads again when you open it.", &[("path", path)]))
Drive only. Each file downloads again when you open it."
))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("ondemand", "Make On-Demand"); dialog.add_response("ondemand", &gettext("Make On-Demand"));
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -1034,19 +1073,21 @@ fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let failed = match paused { let failed = match paused {
true => "Couldn't pause the folder", true => gettext("Couldn't pause the folder"),
false => "Couldn't resume the folder", false => gettext("Couldn't resume the folder"),
}; };
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast( Ok(Ok(Response::Ok { .. })) => toast(
&ui, &ui,
match paused { &match paused {
true => "Folder paused", true => gettext("Folder paused"),
false => "Folder resumed", false => gettext("Folder resumed"),
}, },
), ),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), Ok(Ok(Response::Error { message, kind })) => {
_ => toast_error(&ui, failed, "The mount service didn't respond."), toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
} }
refresh_locations(&ui); refresh_locations(&ui);
}); });
@@ -1058,11 +1099,15 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"), Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't sync", &message, kind) toast_failure(&ui, &gettext("Couldn't sync"), &message, kind)
} }
_ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."), _ => toast_error(
&ui,
&gettext("Couldn't sync"),
&gettext("The mount service didn't respond."),
),
} }
}); });
} }
@@ -1085,21 +1130,23 @@ pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
pub(crate) fn location_subtitle(spec: &MountSpec) -> String { pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
match &spec.kind { match &spec.kind {
MountKind::MyFiles => parts.push("My files".to_string()), MountKind::MyFiles => parts.push(gettext("My files")),
MountKind::Shared { .. } => parts.push("Shared folder".to_string()), MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) { MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()), (Some(MountMode::OnDemand), _) => parts.push(gettext("Going on-demand")),
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()), (Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => { (Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
parts.push("On-demand".to_string()) // Translators: a folder mode: files are kept online and fetched when opened.
parts.push(pgettext("folder mode", "On-demand"))
} }
(Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()), // Translators: a folder mode: a full copy is kept on this computer.
(Some(MountMode::Unknown) | None, _) => parts.push(pgettext("folder mode", "Synced")),
}, },
} }
if matches!(spec.kind, MountKind::Device { .. }) { if matches!(spec.kind, MountKind::Device { .. }) {
parts.push(match &spec.progress { parts.push(match &spec.progress {
Some(p) => sync_progress_label(p), Some(p) => sync_progress_label(p),
None if spec.paused => "paused".to_string(), None if spec.paused => pgettext("state", "paused"),
None => sync_state_label(&spec.state).to_string(), None => sync_state_label(&spec.state).to_string(),
}); });
} }
@@ -1109,7 +1156,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|| spec.mode == MountMode::OnDemand || spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand); || spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted { if expects_session && !spec.mounted {
parts.push("not mounted".to_string()); parts.push(gettext("not mounted"));
} }
parts.join(" · ") parts.join(" · ")
} }
+43 -33
View File
@@ -21,19 +21,23 @@ const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
/// only when the account actually requires it. /// only when the account actually requires it.
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) { pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Sign in to Proton") .title(gettext("Sign in to Proton"))
.description("Use your Proton account to connect Drive.") .description(gettext("Use your Proton account to connect Drive."))
.build(); .build();
let email = adw::EntryRow::builder().title("Email or username").build(); let email = adw::EntryRow::builder()
let password = adw::PasswordEntryRow::builder().title("Password").build(); .title(gettext("Email or username"))
.build();
let password = adw::PasswordEntryRow::builder()
.title(gettext("Password"))
.build();
group.add(&email); group.add(&email);
group.add(&password); group.add(&password);
let login_spinner = gtk4::Spinner::builder().visible(false).build(); let login_spinner = gtk4::Spinner::builder().visible(false).build();
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
button_content.append(&login_spinner); button_content.append(&login_spinner);
button_content.append(&gtk4::Label::new(Some("Sign in"))); button_content.append(&gtk4::Label::new(Some(&gettext("Sign in"))));
let login_button = gtk4::Button::builder() let login_button = gtk4::Button::builder()
.child(&button_content) .child(&button_content)
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
@@ -68,10 +72,13 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
inner.append(&login_status); inner.append(&login_status);
let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
links.set_halign(gtk4::Align::Center); links.set_halign(gtk4::Align::Center);
links.append(&gtk4::LinkButton::with_label(SIGNUP_URL, "Create account")); links.append(&gtk4::LinkButton::with_label(
SIGNUP_URL,
&gettext("Create account"),
));
links.append(&gtk4::LinkButton::with_label( links.append(&gtk4::LinkButton::with_label(
RESET_PASSWORD_URL, RESET_PASSWORD_URL,
"Forgot password?", &gettext("Forgot password?"),
)); ));
inner.append(&links); inner.append(&links);
@@ -115,12 +122,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
if username.is_empty() || password.is_empty() { if username.is_empty() || password.is_empty() {
ui.login ui.login
.login_status .login_status
.set_text("Enter your email and password."); .set_text(&gettext("Enter your email and password."));
return; return;
} }
set_signing_in(&ui, true); set_signing_in(&ui, true);
ui.login.login_status.set_text("Signing in…"); ui.login.login_status.set_text(&gettext("Signing in…"));
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password); let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e. // Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
@@ -145,7 +152,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
ui_hv ui_hv
.login .login
.login_status .login_status
.set_text("Complete the verification to continue…"); .set_text(&gettext("Complete the verification to continue…"));
prompt_human_verification(&ui_hv, &url, token_tx); prompt_human_verification(&ui_hv, &url, token_tx);
} }
}); });
@@ -155,7 +162,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
let result = rx let result = rx
.recv() .recv()
.await .await
.unwrap_or_else(|_| Err("login cancelled".into())); .unwrap_or_else(|_| Err(gettext("login cancelled")));
set_signing_in(&ui, false); set_signing_in(&ui, false);
match result { match result {
Ok(()) => { Ok(()) => {
@@ -189,28 +196,28 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
use pdfs_core::proton_sdk::ProtonError; use pdfs_core::proton_sdk::ProtonError;
match error { match error {
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => { pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
"Too many sign-in attempts. Wait a few minutes and try again.".to_string() gettext("Too many sign-in attempts. Wait a few minutes and try again.")
} }
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => { pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
api.message.clone() api.message.clone()
} }
pdfs_core::Error::Proton(ProtonError::Transport(_)) => { pdfs_core::Error::Proton(ProtonError::Transport(_)) => {
"Couldn't reach Proton. Check your internet connection and try again.".to_string() gettext("Couldn't reach Proton. Check your internet connection and try again.")
}
pdfs_core::Error::Keyring(_) => {
"Signed in, but the session couldn't be saved to the system keyring. Make sure a \
keyring (GNOME Keyring or KWallet) is running and unlocked."
.to_string()
} }
pdfs_core::Error::Keyring(_) => gettext(
"Signed in, but the session couldn't be saved to the system keyring. Make sure a keyring (GNOME Keyring or KWallet) is running and unlocked.",
),
pdfs_core::Error::Other(message) if message.contains("two-factor") => { pdfs_core::Error::Other(message) if message.contains("two-factor") => {
"Sign-in cancelled: no two-factor code was entered.".to_string() gettext("Sign-in cancelled: no two-factor code was entered.")
} }
pdfs_core::Error::Other(message) if message.contains("verification") => { pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new \ "Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
one." ),
.to_string() other => {
let error = other.to_string();
// Translators: {error} is an error message, usually in English.
gettext_f("Sign-in failed: {error}", &[("error", &error)])
} }
other => format!("Sign-in failed: {other}"),
} }
} }
@@ -289,20 +296,22 @@ pub(crate) fn spawn_login(
/// the worker reads as a cancelled login. /// the worker reads as a cancelled login.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) { pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Two-factor authentication") .heading(gettext("Two-factor authentication"))
.body("Enter the code from your authenticator app, or one of your recovery codes.") .body(gettext(
"Enter the code from your authenticator app, or one of your recovery codes.",
))
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let entry = adw::EntryRow::builder() let entry = adw::EntryRow::builder()
.title("Authentication code") .title(gettext("Authentication code"))
.activates_default(true) .activates_default(true)
.build(); .build();
group.add(&entry); group.add(&entry);
dialog.set_extra_child(Some(&group)); dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("confirm", "Confirm"); dialog.add_response("confirm", &gettext("Confirm"));
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("confirm")); dialog.set_default_response(Some("confirm"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -330,10 +339,11 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
let ui = ui.clone(); let ui = ui.clone();
confirm_destructive( confirm_destructive(
&window, &window,
"Sign Out?", &gettext("Sign Out?"),
"Proton Drive will disconnect and stop syncing until you sign in again. \ &gettext(
Files already on this computer stay where they are.", "Proton Drive will disconnect and stop syncing until you sign in again. Files already on this computer stay where they are.",
"Sign Out", ),
&gettext("Sign Out"),
move || { move || {
service::disable_stop(); service::disable_stop();
if let Err(e) = auth::logout() { if let Err(e) = auth::logout() {
+180 -58
View File
@@ -81,9 +81,10 @@ pub(crate) struct Viewer {
/// label/value pairs for the details panel. /// label/value pairs for the details panel.
#[derive(Default)] #[derive(Default)]
pub(crate) struct ExifInfo { pub(crate) struct ExifInfo {
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no /// `("Camera", "Apple iPhone 15")` and friends, the label already
/// EXIF at all, which is normal for screenshots and re-encoded images. /// translated; empty when the file has no EXIF at all, which is normal for
pub(crate) fields: Vec<(&'static str, String)>, /// screenshots and re-encoded images.
pub(crate) fields: Vec<(String, String)>,
/// Decimal degrees, if the photo is geotagged. /// Decimal degrees, if the photo is geotagged.
pub(crate) coords: Option<(f64, f64)>, pub(crate) coords: Option<(f64, f64)>,
} }
@@ -146,7 +147,7 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
if let Some(next) = next_group_member(&items, &uid) { if let Some(next) = next_group_member(&items, &uid) {
viewer viewer
.group_btn .group_btn
.set_tooltip_text(Some(&format!("Show {}", member_label(next)))); .set_tooltip_text(Some(&member_tooltip(next)));
} }
*viewer.group.borrow_mut() = items; *viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true); viewer.group_btn.set_visible(true);
@@ -173,6 +174,21 @@ pub(crate) fn member_label(item: &PhotoItem) -> String {
} }
} }
/// The group switch's tooltip, naming the file it will show next.
fn member_tooltip(item: &PhotoItem) -> String {
match item.name.as_deref() {
Some(name) if !name.is_empty() => {
// Translators: tooltip of the button that switches to another file of the same photo; {name} is a file name.
gettext_f("Show {name}", &[("name", &member_label(item))])
}
_ => match item.kind {
PhotoKind::Raw => gettext("Show the raw file"),
PhotoKind::Video => gettext("Show the video"),
PhotoKind::Photo => gettext("Show the photo"),
},
}
}
/// The gallery's entry for `uid`, or the shot member it names when the group /// The gallery's entry for `uid`, or the shot member it names when the group
/// switch has left the timeline's own photo (a RAW is not in the timeline). /// switch has left the timeline's own photo (a RAW is not in the timeline).
fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem> { fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem> {
@@ -269,12 +285,12 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to load texture for {path}: {e}"); tracing::error!("Failed to load texture for {path}: {e}");
fail("Couldn't render this photo."); fail(&gettext("Couldn't render this photo."));
} }
}, },
Ok(Ok(Response::Error { message, .. })) => fail(&message), Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail("Unexpected reply from the mount service."), Ok(Ok(_)) => fail(&gettext("Unexpected reply from the mount service.")),
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."), Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
} }
}); });
} }
@@ -326,7 +342,7 @@ pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
viewer.info_rows.remove(&row); viewer.info_rows.remove(&row);
} }
let label = gtk4::Label::builder() let label = gtk4::Label::builder()
.label("Reading photo details…") .label(gettext("Reading photo details…"))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.build(); .build();
label.add_css_class("dim-label"); label.add_css_class("dim-label");
@@ -340,15 +356,16 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
viewer.info_rows.remove(&row); viewer.info_rows.remove(&row);
} }
let mut fields: Vec<(&str, String)> = Vec::new(); let mut fields: Vec<(String, String)> = Vec::new();
if let Ok(meta) = std::fs::metadata(path) { if let Ok(meta) = std::fs::metadata(path) {
fields.push(("Size", human_bytes(meta.len()))); // Translators: label of a photo's file size in the details panel.
fields.push((pgettext("property", "Size"), human_bytes(meta.len())));
} }
fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone()))); fields.extend(info.fields);
if fields.is_empty() { if fields.is_empty() {
let label = gtk4::Label::builder() let label = gtk4::Label::builder()
.label("This photo carries no metadata.") .label(gettext("This photo carries no metadata."))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.wrap(true) .wrap(true)
.build(); .build();
@@ -360,7 +377,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
group.add_css_class("boxed-list"); group.add_css_class("boxed-list");
for (label, value) in fields { for (label, value) in fields {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(label) .title(label.as_str())
.subtitle(value) .subtitle(value)
.subtitle_selectable(true) .subtitle_selectable(true)
.build(); .build();
@@ -378,7 +395,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
/// disk. Anything missing is simply left out — phone screenshots and re-encoded /// disk. Anything missing is simply left out — phone screenshots and re-encoded
/// images legitimately carry no EXIF at all. /// images legitimately carry no EXIF at all.
pub(crate) fn read_exif(path: &str) -> ExifInfo { pub(crate) fn read_exif(path: &str) -> ExifInfo {
let mut fields: Vec<(&'static str, String)> = Vec::new(); let mut fields: Vec<(String, String)> = Vec::new();
let mut coords = None; let mut coords = None;
let reader = match std::fs::File::open(path) { let reader = match std::fs::File::open(path) {
@@ -402,52 +419,112 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
.get_field(tag, exif::In::PRIMARY) .get_field(tag, exif::In::PRIMARY)
.map(|f| f.display_value().with_unit(&reader).to_string()) .map(|f| f.display_value().with_unit(&reader).to_string())
}; };
// Text tags, without the quotes `display_value` wraps each string in and
// the NUL padding some cameras leave behind.
let ascii = |tag: exif::Tag| {
let field = reader.get_field(tag, exif::In::PRIMARY)?;
let exif::Value::Ascii(parts) = &field.value else {
return None;
};
let text = parts
.iter()
.map(|part| String::from_utf8_lossy(part).into_owned())
.collect::<Vec<_>>()
.join(" ");
let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
(!text.is_empty()).then(|| text.to_string())
};
if let Some(size) = field(exif::Tag::PixelXDimension) if let Some(size) = field(exif::Tag::PixelXDimension)
.zip(field(exif::Tag::PixelYDimension)) .zip(field(exif::Tag::PixelYDimension))
.map(|(w, h)| format!("{w} × {h}")) // Translators: a photo's pixel dimensions, such as "4032 × 3024".
.map(|(w, h)| gettext_f("{width} × {height}", &[("width", &w), ("height", &h)]))
{ {
fields.push(("Dimensions", size)); // Translators: label of a photo's pixel dimensions in the details panel.
fields.push((gettext("Dimensions"), size));
} }
if let Some(taken) = field(exif::Tag::DateTimeOriginal) { if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) {
fields.push(("Taken", taken)); // Translators: label of when a photo was taken, in the details panel.
fields.push((pgettext("photo property", "Taken"), taken));
} }
let camera = [exif::Tag::Make, exif::Tag::Model] if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
.iter() // Translators: label of the camera model in a photo's details panel.
.filter_map(|tag| field(*tag)) fields.push((gettext("Camera"), camera));
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
fields.push(("Camera", camera));
} }
if let Some(lens) = field(exif::Tag::LensModel) { if let Some(lens) = ascii(exif::Tag::LensModel) {
fields.push(("Lens", lens)); // Translators: label of the lens model in a photo's details panel.
fields.push((gettext("Lens"), lens));
} }
let shutter = reader
.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY)
.and_then(|f| match &f.value {
exif::Value::Rational(r) => r.first().map(|r| r.to_f64()),
_ => None,
})
.filter(|seconds| seconds.is_finite() && *seconds > 0.0)
.map(format_shutter);
let iso = reader
.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
// Translators: a photo's ISO sensitivity, such as "ISO 400".
.map(|n| gettext_f("ISO {value}", &[("value", &n.to_string())]));
let exposure: Vec<String> = [ let exposure: Vec<String> = [
exif::Tag::FNumber, field(exif::Tag::FNumber),
exif::Tag::ExposureTime, shutter,
exif::Tag::PhotographicSensitivity, iso,
exif::Tag::FocalLength, field(exif::Tag::FocalLength),
] ]
.iter() .into_iter()
.filter_map(|tag| field(*tag)) .flatten()
.collect(); .collect();
if !exposure.is_empty() { if !exposure.is_empty() {
fields.push(("Exposure", exposure.join(" · "))); // Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel.
fields.push((gettext("Exposure"), exposure.join(" · ")));
} }
if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)
&& let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) && let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)
{ {
fields.push(("Location", format!("{lat:.5}, {lon:.5}"))); let (lat_text, lon_text) = (format!("{lat:.5}"), format!("{lon:.5}"));
// Translators: label of where a photo was taken, in the details panel.
let label = pgettext("photo property", "Location");
// Translators: GPS coordinates in decimal degrees, such as "52.52000, 13.40500".
let value = gettext_f(
"{latitude}, {longitude}",
&[("latitude", &lat_text), ("longitude", &lon_text)],
);
fields.push((label, value));
coords = Some((lat, lon)); coords = Some((lat, lon));
} }
ExifInfo { fields, coords } ExifInfo { fields, coords }
} }
/// "Google Pixel 6" from Make "Google" and Model "Pixel 6", but just "Canon EOS
/// R5" where the model already names the maker.
fn camera_name(make: Option<String>, model: Option<String>) -> Option<String> {
match (make, model) {
(Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => {
Some(model)
}
(Some(make), Some(model)) => Some(format!("{make} {model}")),
(make, model) => make.or(model),
}
}
/// A shutter speed the way cameras print it: "1/250 s" below a second, "2.5 s"
/// from one up.
fn format_shutter(seconds: f64) -> String {
if seconds >= 0.95 {
let text = format!("{seconds:.1}");
format!("{} s", text.strip_suffix(".0").unwrap_or(&text))
} else {
format!("1/{} s", (1.0 / seconds).round())
}
}
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to /// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
/// decimal degrees, negating for the S/W hemispheres. /// decimal degrees, negating for the S/W hemispheres.
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> { pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
@@ -605,7 +682,7 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
/// Copy the photo on screen to where the user picks, under its Drive name. /// Copy the photo on screen to where the user picks, under its Drive name.
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &str, name: &str) { pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &str, name: &str) {
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
.title("Save a Copy") .title(gettext("Save a Copy"))
.initial_name(name) .initial_name(name)
.build(); .build();
let source = source.to_string(); let source = source.to_string();
@@ -625,10 +702,15 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &st
|| dest.display().to_string(), || dest.display().to_string(),
|name| name.to_string_lossy().into_owned(), |name| name.to_string_lossy().into_owned(),
); );
toast(&ui, &format!("Saved “{saved}”")); // Translators: {name} is the saved file's name.
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
} }
Ok(Err(e)) => toast_error(&ui, "Couldn't save the copy", &e.to_string()), Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => toast_error(&ui, "Couldn't save the copy", "The copy was interrupted."), Err(_) => toast_error(
&ui,
&gettext("Couldn't save the copy"),
&gettext("The copy was interrupted."),
),
} }
}); });
}); });
@@ -749,9 +831,21 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<usize>) -> String { pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<usize>) -> String {
let (at, loaded) = (index as usize + 1, loaded as usize); let (at, loaded) = (index as usize + 1, loaded as usize);
match total { match total {
_ if !more => format!("{} of {}", thousands(at), thousands(loaded)), // Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
Some(total) if total > loaded => format!("{} of {}", thousands(at), thousands(total)), _ if !more => gettext_f(
_ => format!("{} of {}+", thousands(at), thousands(loaded)), "{position} of {total}",
&[("position", &thousands(at)), ("total", &thousands(loaded))],
),
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
Some(total) if total > loaded => gettext_f(
"{position} of {total}",
&[("position", &thousands(at)), ("total", &thousands(total))],
),
// Translators: the lightbox counter while more photos are still loading, such as "12 of 200+"; {loaded} is how many are loaded so far.
_ => gettext_f(
"{position} of {loaded}+",
&[("position", &thousands(at)), ("loaded", &thousands(loaded))],
),
} }
} }
@@ -777,7 +871,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap(); let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder() let window = gtk4::Window::builder()
.title("Photo") .title(gettext("Photo"))
.modal(true) .modal(true)
.transient_for(&parent) .transient_for(&parent)
.default_width(1100) .default_width(1100)
@@ -824,7 +918,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let prev_btn = gtk4::Button::builder() let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic") .icon_name("go-previous-symbolic")
.tooltip_text("Previous (←)") .tooltip_text(gettext("Previous (←)"))
.build(); .build();
prev_btn.add_css_class("circular"); prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat"); prev_btn.add_css_class("flat");
@@ -838,7 +932,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let next_btn = gtk4::Button::builder() let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic") .icon_name("go-next-symbolic")
.tooltip_text("Next (→)") .tooltip_text(gettext("Next (→)"))
.build(); .build();
next_btn.add_css_class("circular"); next_btn.add_css_class("circular");
next_btn.add_css_class("flat"); next_btn.add_css_class("flat");
@@ -876,7 +970,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_toggle = gtk4::ToggleButton::builder() let info_toggle = gtk4::ToggleButton::builder()
.icon_name("info-outline-symbolic") .icon_name("info-outline-symbolic")
.tooltip_text("Details (i)") .tooltip_text(gettext("Details (i)"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
info_toggle.add_css_class("flat"); info_toggle.add_css_class("flat");
@@ -884,7 +978,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let favorite_btn = gtk4::ToggleButton::builder() let favorite_btn = gtk4::ToggleButton::builder()
.icon_name("non-starred-symbolic") .icon_name("non-starred-symbolic")
.tooltip_text("Favorite") // Translators: tooltip of the star button that marks a photo as a favorite.
.tooltip_text(pgettext("verb", "Favorite"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
favorite_btn.add_css_class("flat"); favorite_btn.add_css_class("flat");
@@ -892,13 +987,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// Only shown for a shot stored as more than one file; `load_photo_group` // Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo. // decides that per photo.
let group_btn = action("view-paged-symbolic", "Other files of this photo"); let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
group_btn.set_visible(false); group_btn.set_visible(false);
let download_btn = action("document-save-symbolic", "Save a Copy…"); let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)"); let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
let open_ext_btn = action("document-open-symbolic", "Open With…"); let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
let close_btn = action("window-close-symbolic", "Close (Esc)"); let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
close_btn.add_css_class("viewer-close-btn"); close_btn.add_css_class("viewer-close-btn");
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
@@ -921,7 +1016,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_map = gtk4::Button::builder() let info_map = gtk4::Button::builder()
.child( .child(
&adw::ButtonContent::builder() &adw::ButtonContent::builder()
.label("Show on map") .label(gettext("Show on map"))
.icon_name("map-symbolic") .icon_name("map-symbolic")
.build(), .build(),
) )
@@ -931,7 +1026,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_map.set_visible(false); info_map.set_visible(false);
let info_title = gtk4::Label::builder() let info_title = gtk4::Label::builder()
.label("Details") .label(gettext("Details"))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.hexpand(true) .hexpand(true)
.build(); .build();
@@ -939,7 +1034,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_close = gtk4::Button::builder() let info_close = gtk4::Button::builder()
.icon_name("window-close-symbolic") .icon_name("window-close-symbolic")
.tooltip_text("Hide details") .tooltip_text(gettext("Hide details"))
.build(); .build();
info_close.add_css_class("flat"); info_close.add_css_class("flat");
info_close.add_css_class("circular"); info_close.add_css_class("circular");
@@ -970,8 +1065,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_panel.set_width_request(320); info_panel.set_width_request(320);
info_panel.append(&info_scroll); info_panel.append(&info_scroll);
// Set explicitly: the title's hexpand would otherwise propagate up and have
// the revealer take half the window even while it is closed.
let info_revealer = gtk4::Revealer::builder() let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft) .transition_type(gtk4::RevealerTransitionType::SlideLeft)
.hexpand(false)
.child(&info_panel) .child(&info_panel)
.build(); .build();
@@ -1193,10 +1291,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
None None
} }
Ok(Ok(Response::Error { message, .. })) => Some(message), Ok(Ok(Response::Error { message, .. })) => Some(message),
_ => Some("The mount service didn't respond.".to_string()), _ => Some(gettext("The mount service didn't respond.")),
}; };
if let Some(detail) = failed { if let Some(detail) = failed {
toast_error(&ui_result, "Couldn't change the favorite", &detail); toast_error(
&ui_result,
&gettext("Couldn't change the favorite"),
&detail,
);
// The server refused, so the button must go back to describing // The server refused, so the button must go back to describing
// what is actually stored — without firing this handler again. // what is actually stored — without firing this handler again.
if *viewer_result.uid.borrow() == uid { if *viewer_result.uid.borrow() == uid {
@@ -1372,7 +1474,27 @@ pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{named_link, position_label}; use super::{camera_name, format_shutter, named_link, position_label};
#[test]
fn shutter_speeds_read_like_a_camera() {
assert_eq!(format_shutter(1.0 / 18.869_704_689_121_615), "1/19 s");
assert_eq!(format_shutter(0.001), "1/1000 s");
assert_eq!(format_shutter(2.5), "2.5 s");
assert_eq!(format_shutter(1.0), "1 s");
}
#[test]
fn camera_name_does_not_repeat_the_maker() {
let s = |v: &str| Some(v.to_string());
assert_eq!(camera_name(s("Google"), s("Pixel 6")), s("Google Pixel 6"));
assert_eq!(
camera_name(s("Canon"), s("Canon EOS R5")),
s("Canon EOS R5")
);
assert_eq!(camera_name(None, s("iPhone 15")), s("iPhone 15"));
assert_eq!(camera_name(None, None), None);
}
#[test] #[test]
fn the_counter_counts_the_library_while_pages_are_left() { fn the_counter_counts_the_library_while_pages_are_left() {
+237 -151
View File
@@ -157,10 +157,12 @@ pub(crate) struct GalleryState {
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>, pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>, pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
/// The rows currently realised by the ListView, as row index -> the uid of /// The rows currently realised by the ListView, as row index -> the uid of
/// its first photo (absent for a heading row). A resize or a zoom step /// its first photo (absent for a heading row) and the row's widget. A
/// changes how many tiles fit per row, so the row model has to be rebuilt — /// resize or a zoom step changes how many tiles fit per row, so the row
/// this is what lets the rebuild put the user back where they were. /// model has to be rebuilt — this is what lets the rebuild put the user
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>, /// back where they were. The ListView realises rows beyond both edges of
/// the viewport, so the widget is what tells which of them is on screen.
pub(crate) bound: RefCell<BTreeMap<u32, (Option<String>, gtk4::Box)>>,
/// The ListView itself, so a rebuild can scroll back to the row the user was /// The ListView itself, so a rebuild can scroll back to the row the user was
/// looking at. /// looking at.
pub(crate) list: gtk4::ListView, pub(crate) list: gtk4::ListView,
@@ -380,7 +382,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// Shown only when a load failed because the mount is down; restarts it. // Shown only when a load failed because the mount is down; restarts it.
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -389,11 +391,13 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// The empty-timeline state's two ways forward. `StatusPage` takes one child, // 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. // 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(); let empty_upload = gtk4::Button::builder()
.label(gettext("Upload photos"))
.build();
empty_upload.add_css_class("pill"); empty_upload.add_css_class("pill");
empty_upload.add_css_class("suggested-action"); empty_upload.add_css_class("suggested-action");
let empty_import = gtk4::Button::builder() let empty_import = gtk4::Button::builder()
.label("Import from Google Photos") .label(gettext("Import from Google Photos"))
.build(); .build();
empty_import.add_css_class("pill"); empty_import.add_css_class("pill");
let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
@@ -432,8 +436,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.margin_top(12) .margin_top(12)
.margin_bottom(12) .margin_bottom(12)
.margin_end(4) // Clear of the overlay scrollbar, which widens under the pointer.
.tooltip_text("Jump to a month") .margin_end(18)
.tooltip_text(gettext("Jump to a month"))
.visible(false) .visible(false)
.build(); .build();
scrubber.add_css_class("photo-scrubber"); scrubber.add_css_class("photo-scrubber");
@@ -450,7 +455,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// it — the Albums toggle is what leaves the grid itself. // it — the Albums toggle is what leaves the grid itself.
let back = gtk4::Button::builder() let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic") .icon_name("go-previous-symbolic")
.tooltip_text("Back to albums") .tooltip_text(gettext("Back to albums"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.visible(false) .visible(false)
.build(); .build();
@@ -462,11 +467,11 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let upload = gtk4::Button::builder() let upload = gtk4::Button::builder()
.child( .child(
&adw::ButtonContent::builder() &adw::ButtonContent::builder()
.label("Upload") .label(pgettext("verb", "Upload"))
.icon_name("list-add-symbolic") .icon_name("list-add-symbolic")
.build(), .build(),
) )
.tooltip_text("Upload photos") .tooltip_text(gettext("Upload photos"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
upload.add_css_class("suggested-action"); upload.add_css_class("suggested-action");
@@ -475,7 +480,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// it sits beside Upload as a plain button rather than a second accent one. // it sits beside Upload as a plain button rather than a second accent one.
let import = gtk4::Button::builder() let import = gtk4::Button::builder()
.icon_name("folder-download-symbolic") .icon_name("folder-download-symbolic")
.tooltip_text("Import a Google Photos Takeout export") .tooltip_text(gettext("Import a Google Photos Takeout export"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
import.add_css_class("flat"); import.add_css_class("flat");
@@ -485,7 +490,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// Picking photos is a mode, so its control is a toggle rather than a button. // Picking photos is a mode, so its control is a toggle rather than a button.
let select_btn = gtk4::ToggleButton::builder() let select_btn = gtk4::ToggleButton::builder()
.icon_name("selection-mode-symbolic") .icon_name("selection-mode-symbolic")
.tooltip_text("Select photos") .tooltip_text(gettext("Select photos"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
select_btn.add_css_class("flat"); select_btn.add_css_class("flat");
@@ -494,24 +499,24 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// What the selection can do, revealed with the mode. A revealer rather than // What the selection can do, revealed with the mode. A revealer rather than
// a hidden box so the grid slides down instead of jumping. // a hidden box so the grid slides down instead of jumping.
let select_label = gtk4::Label::builder() let select_label = gtk4::Label::builder()
.label("Select photos") .label(gettext("Select photos"))
.hexpand(true) .hexpand(true)
.xalign(0.0) .xalign(0.0)
.build(); .build();
let select_trash = gtk4::Button::builder() let select_trash = gtk4::Button::builder()
.label("Move to Trash") .label(gettext("Move to Trash"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.sensitive(false) .sensitive(false)
.build(); .build();
select_trash.add_css_class("destructive-action"); select_trash.add_css_class("destructive-action");
let select_album = gtk4::Button::builder() let select_album = gtk4::Button::builder()
.label("Add to Album…") .label(gettext("Add to Album…"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.sensitive(false) .sensitive(false)
.build(); .build();
let select_done = gtk4::Button::builder() let select_done = gtk4::Button::builder()
.icon_name("window-close-symbolic") .icon_name("window-close-symbolic")
.tooltip_text("Leave selection (Esc)") .tooltip_text(gettext("Leave selection (Esc)"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
select_done.add_css_class("flat"); select_done.add_css_class("flat");
@@ -532,10 +537,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// control: exactly one is active, and flipping it reloads the timeline // control: exactly one is active, and flipping it reloads the timeline
// filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts // filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts
// once a page lands. // once a page lands.
let tab_labels = ["All", "Photos", "Videos", "Raw"];
let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| { let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| {
gtk4::ToggleButton::builder() gtk4::ToggleButton::builder()
.label(tab_labels[i]) .label(tab_label(i))
.active(i == 0) .active(i == 0)
.build() .build()
}); });
@@ -556,16 +560,16 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// that would silently drop the kind the user picked. // that would silently drop the kind the user picked.
let favorites_btn = gtk4::ToggleButton::builder() let favorites_btn = gtk4::ToggleButton::builder()
.icon_name("starred-symbolic") .icon_name("starred-symbolic")
.tooltip_text("Show only favorites") .tooltip_text(gettext("Show only favorites"))
.build(); .build();
favorites_btn.add_css_class("pill"); favorites_btn.add_css_class("pill");
// Date jump: "All dates" plus a row per month, filled in once the timeline's // Date jump: "All dates" plus a row per month, filled in once the timeline's
// months are known (see [`refresh_photo_months`]). Pushed to the far end of // months are known (see [`refresh_photo_months`]). Pushed to the far end of
// the filter row, opposite the kind toggles. // the filter row, opposite the kind toggles.
let dates = gtk4::DropDown::from_strings(&["All dates"]); let dates = gtk4::DropDown::from_strings(&[gettext("All dates").as_str()]);
dates.add_css_class("pill"); dates.add_css_class("pill");
dates.set_tooltip_text(Some("Jump to a month")); dates.set_tooltip_text(Some(&gettext("Jump to a month")));
// The kind toggles and the date jump travel together: they filter the // The kind toggles and the date jump travel together: they filter the
// timeline, and neither applies to the album grid or to an open album. // timeline, and neither applies to the album grid or to an open album.
@@ -583,10 +587,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// it sits above the filter row rather than beside the kind toggles. // it sits above the filter row rather than beside the kind toggles.
// "Timeline", not "Photos": the kind filter below has a Photos tab too. // "Timeline", not "Photos": the kind filter below has a Photos tab too.
let photos_btn = gtk4::ToggleButton::builder() let photos_btn = gtk4::ToggleButton::builder()
.label("Timeline") .label(gettext("Timeline"))
.active(true) .active(true)
.build(); .build();
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build(); let albums_btn = gtk4::ToggleButton::builder()
.label(gettext("Albums"))
.build();
albums_btn.set_group(Some(&photos_btn)); albums_btn.set_group(Some(&photos_btn));
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
view_switch.add_css_class("linked"); view_switch.add_css_class("linked");
@@ -634,7 +640,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
albums_stack.add_named(&albums_status, Some("status")); albums_stack.add_named(&albums_status, Some("status"));
let new_album = gtk4::Button::builder() let new_album = gtk4::Button::builder()
.icon_name("list-add-symbolic") .icon_name("list-add-symbolic")
.label("New Album…") .label(gettext("New Album…"))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.action_name("win.new-album") .action_name("win.new-album")
.build(); .build();
@@ -651,8 +657,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
content.add_named(&albums_page, Some("albums")); content.add_named(&albums_page, Some("albums"));
let import_banner = adw::Banner::builder() let import_banner = adw::Banner::builder()
.title("Importing from Google Photos…") .title(gettext("Importing from Google Photos…"))
.button_label("View") .button_label(pgettext("verb", "View"))
.action_name("win.show-import") .action_name("win.show-import")
.build(); .build();
@@ -668,7 +674,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&import_banner); body.append(&import_banner);
body.append(&inner); body.append(&inner);
let (frame, header, title) = page_frame("Photos", &body); let (frame, header, title) = page_frame(&gettext("Photos"), &body);
header.pack_start(&back); header.pack_start(&back);
header.pack_start(&upload); header.pack_start(&upload);
header.pack_end(&refresh); header.pack_end(&refresh);
@@ -736,21 +742,16 @@ pub(crate) fn month_range(year: i32, month: i32) -> Option<(i64, i64)> {
Some((start.to_unix(), end.to_unix())) Some((start.to_unix(), end.to_unix()))
} }
/// English month names, indexed 1..=12. /// "June 2024": a calendar month in the user's language, for the date jump
pub(crate) const MONTH_NAMES: [&str; 12] = [ /// and the scrubber. The month name comes from glib, so it follows the locale.
"January", pub(crate) fn month_label(year: i32, month: i32) -> String {
"February", // Translators: strftime format for a month on its own, such as "June 2024". %OB is the month name in its standalone (nominative) form; use %B if your language has no separate form.
"March", let format = gettext("%OB %Y");
"April", glib::DateTime::from_local(year, month, 1, 0, 0, 0.0)
"May", .and_then(|date| date.format(&format))
"June", .map(|label| label.to_string())
"July", .unwrap_or_else(|_| format!("{year}-{month:02}"))
"August", }
"September",
"October",
"November",
"December",
];
/// Rebuild the date-jump dropdown for the active kind: ask the daemon which /// Rebuild the date-jump dropdown for the active kind: ask the daemon which
/// months the timeline spans and turn them into "Month YYYY (count)" rows, each /// months the timeline spans and turn them into "Month YYYY (count)" rows, each
@@ -769,15 +770,15 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else { let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else {
return; return;
}; };
let mut labels = vec!["All dates".to_string()]; let mut labels = vec![gettext("All dates")];
let mut ranges: Vec<Option<(i64, i64)>> = vec![None]; let mut ranges: Vec<Option<(i64, i64)>> = vec![None];
fill_scrubber(&ui, &months); fill_scrubber(&ui, &months);
for m in months { for m in months {
let name = MONTH_NAMES let count = m.count.to_string();
.get((m.month - 1) as usize) let month = month_label(m.year, m.month);
.copied() // Translators: a row of the date jump; {month} is a month such as "June 2024", {count} how many photos it holds.
.unwrap_or("?"); let label = gettext_f("{month} ({count})", &[("month", &month), ("count", &count)]);
labels.push(format!("{name} {} ({})", m.year, m.count)); labels.push(label);
ranges.push(month_range(m.year, m.month)); ranges.push(month_range(m.year, m.month));
} }
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect(); let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
@@ -803,13 +804,24 @@ pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
let (photos, videos, raw) = counts; let (photos, videos, raw) = counts;
let totals = [photos + videos + raw, photos, videos, raw]; let totals = [photos + videos + raw, photos, videos, raw];
for (index, tab) in ui.gallery.tabs.iter().enumerate() { for (index, tab) in ui.gallery.tabs.iter().enumerate() {
let name = ["All", "Photos", "Videos", "Raw"][index];
let n = totals[index]; let n = totals[index];
tab.set_label(&format!("{name} {}", thousands(n))); tab.set_label(&format!("{} {}", tab_label(index), thousands(n)));
tab.set_sensitive(n > 0 || tab.is_active()); tab.set_sensitive(n > 0 || tab.is_active());
} }
} }
/// The name of filter tab `index`: All, Photos, Videos or Raw.
fn tab_label(index: usize) -> String {
match index {
// Translators: a Photos filter tab that shows photos, videos and raw files together.
0 => pgettext("photo filter", "All"),
1 => pgettext("photo filter", "Photos"),
2 => pgettext("photo filter", "Videos"),
// Translators: a Photos filter tab for raw camera files.
_ => pgettext("photo filter", "Raw"),
}
}
/// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run /// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run
/// of digits in a tab label is unreadable at a glance. /// of digits in a tab label is unreadable at a glance.
pub(crate) fn thousands(n: usize) -> String { pub(crate) fn thousands(n: usize) -> String {
@@ -891,7 +903,7 @@ pub(crate) fn wire_gallery(
.gallery .gallery
.bound .bound
.borrow_mut() .borrow_mut()
.insert(item.position(), row.anchor()); .insert(item.position(), (row.anchor(), row_box.clone()));
}); });
// ListView recycles row widgets, so a scrolled-away row must give up its // ListView recycles row widgets, so a scrolled-away row must give up its
@@ -1079,11 +1091,11 @@ pub(crate) fn wire_gallery(
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()
.title("Select Photo to Upload") .title(gettext("Select Photo to Upload"))
.build(); .build();
let filter = gtk4::FileFilter::new(); let filter = gtk4::FileFilter::new();
filter.set_name(Some("Images")); filter.set_name(Some(&gettext("Images")));
filter.add_mime_type("image/*"); filter.add_mime_type("image/*");
let filters = gio::ListStore::new::<gtk4::FileFilter>(); let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter); filters.append(&filter);
@@ -1139,16 +1151,21 @@ pub(crate) fn wire_gallery(
Ok(Ok(Response::Ok { message })) => { Ok(Ok(Response::Ok { message })) => {
tracing::info!("Photo uploaded: {message}"); tracing::info!("Photo uploaded: {message}");
load_gallery(&ui_clone, false); load_gallery(&ui_clone, false);
toast(&ui_clone, "Photo uploaded"); toast(&ui_clone, &gettext("Photo uploaded"));
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui_clone, "Couldn't upload photo", &message, kind); toast_failure(
&ui_clone,
&gettext("Couldn't upload photo"),
&message,
kind,
);
} }
_ => { _ => {
toast_error( toast_error(
&ui_clone, &ui_clone,
"Couldn't upload photo", &gettext("Couldn't upload photo"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
); );
} }
} }
@@ -1361,7 +1378,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
if tile.photo.has_raw || tile.photo.group_size > 1 { if tile.photo.has_raw || tile.photo.group_size > 1 {
let badge = gtk4::Label::builder() let badge = gtk4::Label::builder()
.label(if tile.photo.has_raw { .label(if tile.photo.has_raw {
"RAW".to_string() // Translators: badge on a photo tile whose shot includes a raw camera file.
gettext("RAW")
} else { } else {
format!("{}", tile.photo.group_size) format!("{}", tile.photo.group_size)
}) })
@@ -1491,9 +1509,9 @@ fn toggle_selected(ui: &Rc<Ui>, uid: &str) {
fn sync_selection_bar(ui: &Rc<Ui>) { fn sync_selection_bar(ui: &Rc<Ui>) {
let count = ui.gallery.selected.borrow().len(); let count = ui.gallery.selected.borrow().len();
ui.gallery.select_label.set_label(&match count { ui.gallery.select_label.set_label(&match count {
0 => "Select photos".to_string(), 0 => gettext("Select photos"),
1 => "1 selected".to_string(), // Translators: {n} is how many items are selected: files and folders in My Files, photos here.
n => format!("{n} selected"), n => ngettext_f("{n} selected", "{n} selected", n as u64, &[]),
}); });
ui.gallery.select_trash.set_sensitive(count > 0); ui.gallery.select_trash.set_sensitive(count > 0);
ui.gallery.select_album.set_sensitive(count > 0); ui.gallery.select_album.set_sensitive(count > 0);
@@ -1535,17 +1553,33 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
// find out from the trash. // find out from the trash.
let files = selected_file_count(ui, &uids); let files = selected_file_count(ui, &uids);
let body = match (uids.len(), files) { let body = match (uids.len(), files) {
(1, 1) => "Move this photo to Trash?".to_string(), (1, 1) => gettext("Move this photo to Trash?"),
(1, files) => format!("Move this photo to Trash? It is stored as {files} files."), (1, files) => ngettext_f(
(n, files) if files == n => format!("Move {n} photos to Trash?"), "Move this photo to Trash? It is stored as {n} file.",
(n, files) => format!("Move {n} photos to Trash? They are stored as {files} files."), "Move this photo to Trash? It is stored as {n} files.",
files as u64,
&[],
),
(n, files) if files == n => ngettext_f(
"Move {n} photo to Trash?",
"Move {n} photos to Trash?",
n as u64,
&[],
),
// Translators: {n} is how many photos are selected, {files} how many files they are stored as (always more than {n}).
(n, files) => ngettext_f(
"Move {n} photo to Trash? They are stored as {files} files.",
"Move {n} photos to Trash? They are stored as {files} files.",
n as u64,
&[("files", &files.to_string())],
),
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Move to Trash") .heading(gettext("Move to Trash"))
.body(body) .body(body)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("trash", "Move to Trash"); dialog.add_response("trash", &gettext("Move to Trash"));
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -1592,32 +1626,38 @@ pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
let message = failed let message = failed
.first() .first()
.map(|f| f.message.clone()) .map(|f| f.message.clone())
.unwrap_or_else(|| "The server refused.".to_string()); .unwrap_or_else(|| gettext("The server refused."));
toast_error(&ui, "Some photos couldn't be moved to Trash", &message); toast_error(
&ui,
&gettext("Some photos couldn't be moved to Trash"),
&message,
);
} }
if trashed.is_empty() { if trashed.is_empty() {
return; return;
} }
let count = trashed.len(); let count = trashed.len();
let message = match count { let message = ngettext_f(
1 => "Moved 1 photo to Trash".to_string(), "Moved {n} photo to Trash",
n => format!("Moved {n} photos to Trash"), "Moved {n} photos to Trash",
}; count as u64,
toast_action(&ui, &message, "Undo", move |ui| { &[],
);
toast_action(&ui, &message, &gettext("Undo"), move |ui| {
restore_uids(ui, trashed.clone(), count); restore_uids(ui, trashed.clone(), count);
load_gallery(ui, false); load_gallery(ui, false);
}); });
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
restore_photos(&ui, removed.clone()); restore_photos(&ui, removed.clone());
toast_failure(&ui, "Couldn't move to Trash", &message, kind); toast_failure(&ui, &gettext("Couldn't move to Trash"), &message, kind);
} }
_ => { _ => {
restore_photos(&ui, removed.clone()); restore_photos(&ui, removed.clone());
toast_error( toast_error(
&ui, &ui,
"Couldn't move to Trash", &gettext("Couldn't move to Trash"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
); );
} }
} }
@@ -1967,7 +2007,7 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
/// rebuilt. What that would cost the user is their scroll position, so the /// rebuilt. What that would cost the user is their scroll position, so the
/// topmost realised row's first photo is remembered and scrolled back to. /// topmost realised row's first photo is remembered and scrolled back to.
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) { pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned(); let anchor = top_anchor(ui);
repaint_gallery(ui); repaint_gallery(ui);
let Some(anchor) = anchor else { return }; let Some(anchor) = anchor else { return };
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else { let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
@@ -1978,6 +2018,30 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
.scroll_to(row, gtk4::ListScrollFlags::empty(), None); .scroll_to(row, gtk4::ListScrollFlags::empty(), None);
} }
/// The first photo of the topmost photo row that reaches into the viewport.
///
/// Not simply the first bound row: the ListView keeps rows bound well outside
/// the viewport (unmapped, with their last allocation), and scrolling one of
/// those "back" to the top is a jump. Falls back to the first bound photo row
/// while the list isn't on screen.
fn top_anchor(ui: &Rc<Ui>) -> Option<String> {
let list = &ui.gallery.list;
let bound = ui.gallery.bound.borrow();
let mut photo_rows = bound
.values()
.filter_map(|(uid, row)| uid.as_ref().map(|uid| (uid, row)));
let first = photo_rows.clone().next().map(|(uid, _)| uid.clone());
photo_rows
.find(|(_, row)| {
row.is_mapped()
&& row
.compute_bounds(list)
.is_some_and(|rect| rect.y() + rect.height() > 0.0)
})
.map(|(uid, _)| uid.clone())
.or(first)
}
/// Which row of the rendered model holds `uid`, if any. /// Which row of the rendered model holds `uid`, if any.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> { fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(0..store.n_items()).find(|i| { (0..store.n_items()).find(|i| {
@@ -2084,11 +2148,6 @@ pub(crate) fn update_gallery_subtitle(ui: &Rc<Ui>) {
} }
// The noun tracks the active filter, so a Videos tab doesn't count "photos". // The noun tracks the active filter, so a Videos tab doesn't count "photos".
let kind = ui.gallery.kind.get(); let kind = ui.gallery.kind.get();
let (one, many) = match kind {
Some(PhotoKind::Video) => ("video", "videos"),
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
_ => ("photo", "photos"),
};
// The whole library for this filter, not the page count — the subtitle sits // The whole library for this filter, not the page count — the subtitle sits
// next to tabs carrying the same totals, and the two disagreeing reads as a // next to tabs carrying the same totals, and the two disagreeing reads as a
// bug. A date jump is the exception: there the window is the subject. // bug. A date jump is the exception: there the window is the subject.
@@ -2104,9 +2163,16 @@ pub(crate) fn update_gallery_subtitle(ui: &Rc<Ui>) {
if loaded == 0 { if loaded == 0 {
return; return;
} }
ui.gallery.title.set_subtitle(&match total { let count = thousands(total);
1 => format!("1 {one}"), let args = [("count", count.as_str())];
n => format!("{} {many}", thousands(n)), let n = total as u64;
ui.gallery.title.set_subtitle(&match kind {
// Translators: the Photos page subtitle, such as "12 videos".
Some(PhotoKind::Video) => ngettext_f("{count} video", "{count} videos", n, &args),
// Translators: the Photos page subtitle, such as "12 raw photos".
Some(PhotoKind::Raw) => ngettext_f("{count} raw photo", "{count} raw photos", n, &args),
// Translators: the Photos page subtitle, such as "1,204 photos".
_ => ngettext_f("{count} photo", "{count} photos", n, &args),
}); });
} }
@@ -2133,7 +2199,7 @@ pub(crate) fn group_photos(model: &gio::ListStore) -> Vec<PhotoGroup> {
/// Section heading for a capture time: "Today", "Yesterday", or the local date. /// Section heading for a capture time: "Today", "Yesterday", or the local date.
pub(crate) fn day_heading(secs: i64) -> String { pub(crate) fn day_heading(secs: i64) -> String {
let Ok(date) = glib::DateTime::from_unix_local(secs) else { let Ok(date) = glib::DateTime::from_unix_local(secs) else {
return "Unknown date".into(); return gettext("Unknown date");
}; };
let same_day = |other: &glib::DateTime| { let same_day = |other: &glib::DateTime| {
other.year() == date.year() other.year() == date.year()
@@ -2142,17 +2208,19 @@ pub(crate) fn day_heading(secs: i64) -> String {
}; };
if let Ok(now) = glib::DateTime::now_local() { if let Ok(now) = glib::DateTime::now_local() {
if same_day(&now) { if same_day(&now) {
return "Today".into(); return gettext("Today");
} }
if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400) if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400)
&& same_day(&yesterday) && same_day(&yesterday)
{ {
return "Yesterday".into(); return gettext("Yesterday");
} }
} }
date.format("%-d %B %Y") // Translators: strftime format for a day heading in the photo timeline, such as "3 June 2026".
let format = gettext("%-d %B %Y");
date.format(&format)
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|_| "Unknown date".into()) .unwrap_or_else(|_| gettext("Unknown date"))
} }
pub(crate) fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option<u32> { pub(crate) fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option<u32> {
@@ -2169,20 +2237,24 @@ pub(crate) fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option<u32>
pub(crate) fn format_capture_time(secs: i64) -> String { pub(crate) fn format_capture_time(secs: i64) -> String {
let date = glib::DateTime::from_unix_local(secs); let date = glib::DateTime::from_unix_local(secs);
// Translators: strftime format for a photo's capture date and time, such as "2026-06-03 14:05:09".
let format = gettext("%Y-%m-%d %H:%M:%S");
match date { match date {
Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") { Ok(d) => match d.format(&format) {
Ok(s) => s.to_string(), Ok(s) => s.to_string(),
Err(_) => "Unknown Date".to_string(), Err(_) => gettext("Unknown Date"),
}, },
Err(_) => "Unknown Date".to_string(), Err(_) => gettext("Unknown Date"),
} }
} }
/// The capture time as a tile caption: the clock time alone, since the day is /// The capture time as a tile caption: the clock time alone, since the day is
/// already the section heading right above it. /// already the section heading right above it.
pub(crate) fn short_capture_time(secs: i64) -> String { pub(crate) fn short_capture_time(secs: i64) -> String {
// Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05".
let format = gettext("%H:%M");
glib::DateTime::from_unix_local(secs) glib::DateTime::from_unix_local(secs)
.and_then(|d| d.format("%H:%M")) .and_then(|d| d.format(&format))
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_default() .unwrap_or_default()
} }
@@ -2196,29 +2268,45 @@ pub(crate) fn empty_timeline_text(
favorites: bool, favorites: bool,
month: Option<&str>, month: Option<&str>,
) -> (String, String) { ) -> (String, String) {
let what = match kind { let args = [("month", month.unwrap_or_default())];
None => "photos", let video = kind == Some(PhotoKind::Video);
Some(PhotoKind::Photo) => "photos", let raw = kind == Some(PhotoKind::Raw);
Some(PhotoKind::Video) => "videos",
Some(PhotoKind::Raw) => "raw files",
};
let when = month.map(|m| format!(" in {m}")).unwrap_or_default();
match (favorites, kind, month) { match (favorites, kind, month) {
(false, None, None) => ( (false, None, None) => (
"No photos yet".to_string(), gettext("No photos yet"),
"Photos you upload to Proton Drive appear here.".to_string(), gettext("Photos you upload to Proton Drive appear here."),
), ),
(true, None, None) => ( (true, None, None) => (
"No favorites yet".to_string(), gettext("No favorites yet"),
"Star a photo in the viewer or from its menu to find it here.".to_string(), gettext("Star a photo in the viewer or from its menu to find it here."),
), ),
(true, _, _) => ( (true, _, _) => (
format!("No favorite {what}{when}"), match (video, raw, month.is_some()) {
"Turn off the favorites filter to see everything.".to_string(), // Translators: {month} is a month and year, such as "June 2024".
(true, _, true) => gettext_f("No favorite videos in {month}", &args),
(true, _, false) => gettext("No favorite videos"),
// Translators: {month} is a month and year, such as "June 2024".
(_, true, true) => gettext_f("No favorite raw files in {month}", &args),
(_, true, false) => gettext("No favorite raw files"),
// Translators: {month} is a month and year, such as "June 2024".
(_, _, true) => gettext_f("No favorite photos in {month}", &args),
(_, _, false) => gettext("No favorite photos"),
},
gettext("Turn off the favorites filter to see everything."),
), ),
(false, _, _) => ( (false, _, _) => (
format!("No {what}{when}"), match (video, raw, month.is_some()) {
"Try another filter or month.".to_string(), // Translators: {month} is a month and year, such as "June 2024".
(true, _, true) => gettext_f("No videos in {month}", &args),
(true, _, false) => gettext("No videos"),
// Translators: {month} is a month and year, such as "June 2024".
(_, true, true) => gettext_f("No raw files in {month}", &args),
(_, true, false) => gettext("No raw files"),
// Translators: {month} is a month and year, such as "June 2024".
(_, _, true) => gettext_f("No photos in {month}", &args),
(_, _, false) => gettext("No photos"),
},
gettext("Try another filter or month."),
), ),
} }
} }
@@ -2257,13 +2345,9 @@ pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
/// "March 2024" for the scrubber's month `index`. /// "March 2024" for the scrubber's month `index`.
fn scrubber_label(months: &[PhotoMonth], index: usize) -> String { fn scrubber_label(months: &[PhotoMonth], index: usize) -> String {
months.get(index).map_or_else(String::new, |m| { months
let name = MONTH_NAMES .get(index)
.get((m.month - 1) as usize) .map_or_else(String::new, |m| month_label(m.year, m.month))
.copied()
.unwrap_or("?");
format!("{name} {}", m.year)
})
} }
/// Move the knob to the month at the top of the timeline, unless the user is /// Move the knob to the month at the top of the timeline, unless the user is
@@ -2272,7 +2356,7 @@ fn sync_scrubber_position(ui: &Rc<Ui>) {
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() { if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
return; return;
} }
let Some(uid) = ui.gallery.bound.borrow().values().flatten().next().cloned() else { let Some(uid) = top_anchor(ui) else {
return; return;
}; };
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid) let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
@@ -2443,35 +2527,37 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64
let mut menu = ActionMenu::new(); let mut menu = ActionMenu::new();
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
let label = if photo.kind == PhotoKind::Video { let label = if photo.kind == PhotoKind::Video {
"Play" pgettext("verb", "Play")
} else { } else {
"Open" pgettext("verb", "Open")
}; };
menu.item(label, move || open_photo_viewer(&ui_c, uid.clone())); menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone()));
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.toggle("Favorite", photo.favorite, move |favorite| { // Translators: a check item in a photo's menu, on while the photo is a favorite.
let favorite_label = pgettext("state", "Favorite");
menu.toggle(&favorite_label, photo.favorite, move |favorite| {
set_photo_favorite(&ui_c, uid.clone(), favorite) set_photo_favorite(&ui_c, uid.clone(), favorite)
}); });
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.item("Select", move || { menu.item(&pgettext("verb", "Select"), move || {
set_selection_mode(&ui_c, true); set_selection_mode(&ui_c, true);
toggle_selected(&ui_c, &uid); toggle_selected(&ui_c, &uid);
}); });
menu.section(); menu.section();
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.item("Add to Album…", move || { menu.item(&gettext("Add to Album…"), move || {
prompt_add_to_album(&ui_c, vec![uid.clone()]) prompt_add_to_album(&ui_c, vec![uid.clone()])
}); });
let open_album = ui.gallery.album.borrow().clone(); let open_album = ui.gallery.album.borrow().clone();
if let Some(album) = open_album.filter(|album| !album.shared) { if let Some(album) = open_album.filter(|album| !album.shared) {
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.item("Remove from Album", move || { menu.item(&gettext("Remove from Album"), move || {
remove_from_album(&ui_c, &album, vec![uid.clone()]) remove_from_album(&ui_c, &album, vec![uid.clone()])
}); });
} }
menu.section(); menu.section();
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.item("Move to Trash…", move || { menu.item(&gettext("Move to Trash…"), move || {
confirm_trash_photos(&ui_c, vec![uid.clone()]) confirm_trash_photos(&ui_c, vec![uid.clone()])
}); });
menu.popup_at(anchor, x, y); menu.popup_at(anchor, x, y);
@@ -2494,20 +2580,20 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
repaint_gallery(&ui); repaint_gallery(&ui);
toast( toast(
&ui, &ui,
if favorite { &if favorite {
"Added to Favorites" gettext("Added to Favorites")
} else { } else {
"Removed from Favorites" gettext("Removed from Favorites")
}, },
); );
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
toast_error(&ui, "Couldn't change the favorite", &message) toast_error(&ui, &gettext("Couldn't change the favorite"), &message)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't change the favorite", &gettext("Couldn't change the favorite"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -2526,8 +2612,8 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
gallery_status( gallery_status(
ui, ui,
"image-x-generic-symbolic", "image-x-generic-symbolic",
"Loading photos…", &gettext("Loading photos…"),
"Reading your Proton Drive timeline.", &gettext("Reading your Proton Drive timeline."),
false, false,
); );
// Rebuild the date jump for the current kind, but only for a full-span // Rebuild the date jump for the current kind, but only for a full-span
@@ -2584,8 +2670,8 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
gallery_status( gallery_status(
&ui, &ui,
"image-missing-symbolic", "image-missing-symbolic",
"No photo library", &gettext("No photo library"),
"This Proton account doesn't have Photos enabled.", &gettext("This Proton account doesn't have Photos enabled."),
false, false,
); );
return; return;
@@ -2614,8 +2700,8 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|| ui.gallery.range.get().is_some(); || ui.gallery.range.get().is_some();
let (title, description) = if ui.gallery.album.borrow().is_some() { let (title, description) = if ui.gallery.album.borrow().is_some() {
( (
"Empty album".to_string(), gettext("Empty album"),
"This album has no photos in it.".to_string(), gettext("This album has no photos in it."),
) )
} else { } else {
let month = ui let month = ui
@@ -2656,26 +2742,26 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
// A failed *next* page keeps the photos already on screen — the failure // A failed *next* page keeps the photos already on screen — the failure
// goes to a toast rather than wiping the timeline for a status page. // goes to a toast rather than wiping the timeline for a status page.
Ok(Ok(Response::Error { message, .. })) if append => { Ok(Ok(Response::Error { message, .. })) if append => {
toast_error(&ui, "Couldn't load more photos", &message) toast_error(&ui, &gettext("Couldn't load more photos"), &message)
} }
Ok(Ok(Response::Error { message, .. })) => gallery_status( Ok(Ok(Response::Error { message, .. })) => gallery_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't load photos", &gettext("Couldn't load photos"),
&message, &message,
false, false,
), ),
Ok(Ok(_)) => gallery_status( Ok(Ok(_)) => gallery_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't load photos", &gettext("Couldn't load photos"),
"Unexpected reply from the mount service.", &gettext("Unexpected reply from the mount service."),
false, false,
), ),
Ok(Err(_)) | Err(_) if append => toast_error( Ok(Err(_)) | Err(_) if append => toast_error(
&ui, &ui,
"Couldn't load more photos", &gettext("Couldn't load more photos"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
Ok(Err(_)) | Err(_) => gallery_unreachable(&ui), Ok(Err(_)) | Err(_) => gallery_unreachable(&ui),
} }
@@ -2703,8 +2789,8 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
gallery_status( gallery_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -2712,8 +2798,8 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
gallery_status( gallery_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
+120 -97
View File
@@ -48,7 +48,9 @@ pub(crate) struct SharedWidgets {
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
let add_bookmark = gtk4::Button::builder() let add_bookmark = gtk4::Button::builder()
.icon_name("bookmark-new-symbolic") .icon_name("bookmark-new-symbolic")
.tooltip_text("Add Bookmark — save a public link to open it from here") .tooltip_text(gettext(
"Add Bookmark — save a public link to open it from here",
))
.build(); .build();
let refresh = refresh_button(); let refresh = refresh_button();
@@ -57,13 +59,13 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
// "nothing here". Invitations come first: they are the one thing on the // "nothing here". Invitations come first: they are the one thing on the
// page waiting for a decision. // page waiting for a decision.
let invitations = adw::PreferencesGroup::builder() let invitations = adw::PreferencesGroup::builder()
.title("Invitations") .title(gettext("Invitations"))
.description("Accept to add the item to your shared files.") .description(gettext("Accept to add the item to your shared files."))
.visible(false) .visible(false)
.build(); .build();
let shared_with_me = adw::PreferencesGroup::new(); let shared_with_me = adw::PreferencesGroup::new();
let bookmarks = adw::PreferencesGroup::builder() let bookmarks = adw::PreferencesGroup::builder()
.title("Bookmarks") .title(gettext("Bookmarks"))
.visible(false) .visible(false)
.build(); .build();
@@ -78,7 +80,7 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
.build(); .build();
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -106,10 +108,10 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
let back = gtk4::Button::builder() let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic") .icon_name("go-previous-symbolic")
.tooltip_text("Back") .tooltip_text(gettext("Back"))
.visible(false) .visible(false)
.build(); .build();
let (frame, header, title) = page_frame("Shared with Me", &inner); let (frame, header, title) = page_frame(&gettext("Shared with Me"), &inner);
header.pack_start(&back); header.pack_start(&back);
header.pack_end(&refresh); header.pack_end(&refresh);
header.pack_end(&add_bookmark); header.pack_end(&add_bookmark);
@@ -176,8 +178,8 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
shared_status( shared_status(
ui, ui,
"emblem-shared-symbolic", "emblem-shared-symbolic",
"Loading…", &gettext("Loading…"),
"Reading your shared items.", &gettext("Reading your shared items."),
false, false,
); );
@@ -229,8 +231,8 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
shared_status( shared_status(
ui, ui,
"folder-symbolic", "folder-symbolic",
"Loading…", &gettext("Loading…"),
"Reading this shared folder.", &gettext("Reading this shared folder."),
false, false,
); );
ui.busy_begin(); ui.busy_begin();
@@ -249,7 +251,7 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
// The folder is gone or access was revoked: fall back to the top // The folder is gone or access was revoked: fall back to the top
// level rather than stranding the page on a dead uid. // level rather than stranding the page on a dead uid.
ui.shared.nav.borrow_mut().pop(); ui.shared.nav.borrow_mut().pop();
toast_failure(&ui, "Couldn't open shared folder", &message, kind); toast_failure(&ui, &gettext("Couldn't open shared folder"), &message, kind);
load_shared(&ui); load_shared(&ui);
} }
_ => { _ => {
@@ -272,9 +274,10 @@ pub(crate) fn shared_path(nav: &[(String, String)]) -> String {
/// The Invitations heading, with the count once there is more than one. /// The Invitations heading, with the count once there is more than one.
pub(crate) fn invitations_title(count: usize) -> String { pub(crate) fn invitations_title(count: usize) -> String {
if count > 1 { if count > 1 {
format!("Invitations ({count})") // Translators: the Invitations heading; {count} is the number of pending invitations (2 or more).
gettext_f("Invitations ({count})", &[("count", &count.to_string())])
} else { } else {
"Invitations".to_string() gettext("Invitations")
} }
} }
@@ -299,7 +302,7 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
if entries.is_empty() { if entries.is_empty() {
let row = dim_row("This folder is empty."); let row = dim_row(&gettext("This folder is empty."));
ui.shared.with_me_group.add(&row); ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast())); rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else { } else {
@@ -340,7 +343,7 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> adw::Actio
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic"); let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
warning.add_css_class("warning"); warning.add_css_class("warning");
warning.set_valign(gtk4::Align::Center); warning.set_valign(gtk4::Align::Center);
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP)); warning.set_tooltip_text(Some(&gettext(UNVERIFIED_INVITER_TOOLTIP)));
row.add_suffix(&warning); row.add_suffix(&warning);
} }
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
@@ -370,7 +373,7 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> adw::Actio
/// A shared row's ⋮ menu: open it, find it in My Files when the mount has /// A shared row's ⋮ menu: open it, find it in My Files when the mount has
/// interned it, and leave it (share roots only). /// interned it, and leave it (share roots only).
fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton { fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton {
let mut items: Vec<(&str, MenuAction)> = Vec::new(); let mut items: Vec<(String, MenuAction)> = Vec::new();
let (ui_open, uid, name, is_dir) = ( let (ui_open, uid, name, is_dir) = (
ui.clone(), ui.clone(),
entry.uid.clone(), entry.uid.clone(),
@@ -378,7 +381,7 @@ fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::Men
entry.is_dir, entry.is_dir,
); );
items.push(( items.push((
"Open", pgettext("verb", "Open"),
Box::new(move || { Box::new(move || {
if is_dir { if is_dir {
ui_open ui_open
@@ -395,25 +398,27 @@ fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::Men
if !entry.path.is_empty() { if !entry.path.is_empty() {
let (ui_show, entry_show) = (ui.clone(), entry.clone()); let (ui_show, entry_show) = (ui.clone(), entry.clone());
items.push(( items.push((
"Show in My Files", gettext("Show in My Files"),
Box::new(move || show_in_my_files(&ui_show, &entry_show)), Box::new(move || show_in_my_files(&ui_show, &entry_show)),
)); ));
} }
if leavable { if leavable {
let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone()); let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone());
items.push(( items.push((
"Leave…", gettext("Leave…"),
Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)), Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)),
)); ));
} }
let button = more_menu_button(items); let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
let button = more_menu_button(labels.iter().map(String::as_str).zip(actions).collect());
button.set_valign(gtk4::Align::Center); button.set_valign(gtk4::Align::Center);
button button
} }
/// Tooltip on the warning shown next to a share whose invitation did not verify. /// Tooltip on the warning shown next to a share whose invitation did not verify.
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \ const UNVERIFIED_INVITER_TOOLTIP: &str = gettext_noop(
It may not really be from them."; "The invitation's signature doesn't match the sender's keys. It may not really be from them.",
);
/// A shared row's subtitle: who shared it and when (share roots only — that is /// A shared row's subtitle: who shared it and when (share roots only — that is
/// where the invitation lives) and its size (files only). Where the share sits /// where the invitation lives) and its size (files only). Where the share sits
@@ -437,33 +442,33 @@ pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
/// owned content carries no role, and a role the API did not report is not /// owned content carries no role, and a role the API did not report is not
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an /// guessed at (the SDK's `from_permissions_exact` returns `None` for an
/// unrecognised mask rather than degrading it to viewer). /// unrecognised mask rather than degrading it to viewer).
pub(crate) fn role_label(role: &str) -> Option<&'static str> { pub(crate) fn role_label(role: &str) -> Option<String> {
match role { match role {
"viewer" => Some("Viewer"), "viewer" => Some(pgettext("role", "Viewer")),
"editor" => Some("Editor"), "editor" => Some(pgettext("role", "Editor")),
"admin" => Some("Admin"), "admin" => Some(pgettext("role", "Admin")),
_ => None, _ => None,
} }
} }
/// What a role lets me do, as a short phrase for the row's pill. A bare /// What a role lets me do, as a short phrase for the row's pill. A bare
/// "Editor" beside a Leave button read like one more button. /// "Editor" beside a Leave button read like one more button.
pub(crate) fn role_access(role: &str) -> Option<&'static str> { pub(crate) fn role_access(role: &str) -> Option<String> {
match role { match role {
"viewer" => Some("Can view"), "viewer" => Some(gettext("Can view")),
"editor" => Some("Can edit"), "editor" => Some(gettext("Can edit")),
"admin" => Some("Can manage"), "admin" => Some(gettext("Can manage")),
_ => None, _ => None,
} }
} }
/// A tinted, rounded pill saying what I may do with a share I did not create. /// A tinted, rounded pill saying what I may do with a share I did not create.
pub(crate) fn role_pill(role: &str) -> Option<gtk4::Label> { pub(crate) fn role_pill(role: &str) -> Option<gtk4::Label> {
let pill = gtk4::Label::new(Some(role_access(role)?)); let pill = gtk4::Label::new(Some(&role_access(role)?));
pill.add_css_class("role-pill"); pill.add_css_class("role-pill");
pill.add_css_class("caption"); pill.add_css_class("caption");
pill.set_valign(gtk4::Align::Center); pill.set_valign(gtk4::Align::Center);
pill.set_tooltip_text(role_label(role)); pill.set_tooltip_text(role_label(role).as_deref());
Some(pill) Some(pill)
} }
@@ -477,7 +482,8 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
return; return;
} }
ui.busy_begin(); ui.busy_begin();
toast(ui, &format!("Downloading “{name}”…")); // Translators: {name} is a file name.
toast(ui, &gettext_f("Downloading “{name}”…", &[("name", name)]));
let rx = spawn_request( let rx = spawn_request(
ui.dirs.control_socket(), ui.dirs.control_socket(),
Request::OpenSharedFile { Request::OpenSharedFile {
@@ -496,12 +502,12 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
// the shared node's name instead. // the shared node's name instead.
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name), Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't open file", &message, kind) toast_failure(&ui, &gettext("Couldn't open file"), &message, kind)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't open file", &gettext("Couldn't open file"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -514,8 +520,8 @@ pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
shared_status( shared_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -523,8 +529,8 @@ pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
shared_status( shared_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
@@ -554,9 +560,10 @@ pub(crate) fn repaint_shared(
shared_status( shared_status(
ui, ui,
"emblem-shared-symbolic", "emblem-shared-symbolic",
"Nothing Shared with You", &gettext("Nothing Shared with You"),
"Files and folders other people share with you appear here. Public links \ &gettext(
you bookmark are kept here too.", "Files and folders other people share with you appear here. Public links you bookmark are kept here too.",
),
false, false,
); );
return; return;
@@ -570,10 +577,10 @@ pub(crate) fn repaint_shared(
// The list only needs a heading when another section sits beside it. // The list only needs a heading when another section sits beside it.
ui.shared ui.shared
.with_me_group .with_me_group
.set_title(if invitations.is_empty() && bookmarks.is_empty() { .set_title(&if invitations.is_empty() && bookmarks.is_empty() {
"" String::new()
} else { } else {
"Shared with you" gettext("Shared with you")
}); });
ui.shared ui.shared
.invitations_group .invitations_group
@@ -588,13 +595,11 @@ pub(crate) fn repaint_shared(
// Invitations: inviter + item, Accept / Reject. // Invitations: inviter + item, Accept / Reject.
for inv in invitations { for inv in invitations {
let item = inv let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item"));
.name
.clone()
.unwrap_or_else(|| "a shared item".to_string());
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&item) .title(&item)
.subtitle(format!("From {}", inv.inviter_email)) // Translators: {email} is the address of the person who sent the invitation.
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir { row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir {
"folder-symbolic" "folder-symbolic"
@@ -603,12 +608,12 @@ pub(crate) fn repaint_shared(
})); }));
let reject = gtk4::Button::builder() let reject = gtk4::Button::builder()
.icon_name("window-close-symbolic") .icon_name("window-close-symbolic")
.tooltip_text("Reject") .tooltip_text(gettext("Reject"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
reject.add_css_class("flat"); reject.add_css_class("flat");
let accept = gtk4::Button::builder() let accept = gtk4::Button::builder()
.label("Accept") .label(gettext("Accept"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
accept.add_css_class("suggested-action"); accept.add_css_class("suggested-action");
@@ -620,15 +625,19 @@ pub(crate) fn repaint_shared(
}); });
let ui_rej = ui.clone(); let ui_rej = ui.clone();
let id_rej = inv.id.clone(); let id_rej = inv.id.clone();
let name_rej = inv.name.clone().unwrap_or_else(|| "this item".into()); let name_rej = inv.name.clone().unwrap_or_else(|| gettext("this item"));
reject.connect_clicked(move |btn| { reject.connect_clicked(move |btn| {
let ui = ui_rej.clone(); let ui = ui_rej.clone();
let id = id_rej.clone(); let id = id_rej.clone();
confirm_destructive( confirm_destructive(
btn, btn,
"Reject Invitation?", &gettext("Reject Invitation?"),
&format!("You won't have access to {name_rej} unless it is shared again."), // Translators: {name} is the shared item's name, or "this item".
"Reject", &gettext_f(
"You won't have access to {name} unless it is shared again.",
&[("name", &name_rej)],
),
&gettext("Reject"),
move || respond_invitation(&ui, &id, false), move || respond_invitation(&ui, &id, false),
); );
}); });
@@ -640,7 +649,7 @@ pub(crate) fn repaint_shared(
// Bookmarks: name/URL, Open / Remove. // Bookmarks: name/URL, Open / Remove.
for bm in bookmarks { for bm in bookmarks {
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string()); let title = bm.name.clone().unwrap_or_else(|| gettext("Shared link"));
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&title) .title(&title)
.subtitle(&bm.url) .subtitle(&bm.url)
@@ -648,13 +657,13 @@ pub(crate) fn repaint_shared(
row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link")); row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link"));
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("user-trash-symbolic")
.tooltip_text("Remove bookmark") .tooltip_text(gettext("Remove bookmark"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
let open = gtk4::Button::builder() let open = gtk4::Button::builder()
.icon_name("external-link-symbolic") .icon_name("external-link-symbolic")
.tooltip_text("Open in browser") .tooltip_text(gettext("Open in browser"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
open.add_css_class("flat"); open.add_css_class("flat");
@@ -681,24 +690,32 @@ pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
Request::RejectInvitation { id: id.to_string() } Request::RejectInvitation { id: id.to_string() }
}; };
let (done, failed) = if accept { let (done, failed) = if accept {
("Invitation accepted", "Couldn't accept the invitation") (
gettext("Invitation accepted"),
gettext("Couldn't accept the invitation"),
)
} else { } else {
("Invitation rejected", "Couldn't reject the invitation") (
gettext("Invitation rejected"),
gettext("Couldn't reject the invitation"),
)
}; };
run_shared_mutation(ui, req, done, failed); run_shared_mutation(ui, req, &done, &failed);
} }
/// Confirm, then leave a node shared with me. /// Confirm, then leave a node shared with me.
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) { pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Leave shared item") .heading(gettext("Leave shared item"))
.body(format!( // Translators: {name} is the shared item's name.
"Leave “{name}”? You'll lose access until you're invited again." .body(gettext_f(
"Leave “{name}”? You'll lose access until you're invited again.",
&[("name", name)],
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("leave", "Leave"); dialog.add_response("leave", &pgettext("verb", "Leave"));
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -709,8 +726,8 @@ pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
run_shared_mutation( run_shared_mutation(
&ui, &ui,
Request::LeaveShared { uid: uid.clone() }, Request::LeaveShared { uid: uid.clone() },
"Left shared item", &gettext("Left shared item"),
"Couldn't leave the shared item", &gettext("Couldn't leave the shared item"),
); );
} }
}); });
@@ -721,11 +738,15 @@ pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) { pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Remove bookmark") .heading(gettext("Remove bookmark"))
.body(format!("Remove the bookmark for “{name}”?")) // Translators: {name} is the bookmark's name.
.body(gettext_f(
"Remove the bookmark for “{name}”?",
&[("name", name)],
))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", "Remove"); dialog.add_response("remove", &gettext("Remove"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -738,8 +759,8 @@ pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
Request::DeleteBookmark { Request::DeleteBookmark {
token: token.clone(), token: token.clone(),
}, },
"Bookmark removed", &gettext("Bookmark removed"),
"Couldn't remove the bookmark", &gettext("Couldn't remove the bookmark"),
); );
} }
}); });
@@ -750,23 +771,23 @@ pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) { pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Add bookmark") .heading(gettext("Add bookmark"))
.body("Paste a Proton Drive public link to save it here.") .body(gettext("Paste a Proton Drive public link to save it here."))
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let url_row = adw::EntryRow::builder() let url_row = adw::EntryRow::builder()
.title("Public link URL") .title(gettext("Public link URL"))
.activates_default(true) .activates_default(true)
.build(); .build();
let pw_row = adw::PasswordEntryRow::builder() let pw_row = adw::PasswordEntryRow::builder()
.title("Password (if the link has one)") .title(gettext("Password (if the link has one)"))
.activates_default(true) .activates_default(true)
.build(); .build();
group.add(&url_row); group.add(&url_row);
group.add(&pw_row); group.add(&pw_row);
dialog.set_extra_child(Some(&group)); dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("add", "Add"); dialog.add_response("add", &gettext("Add"));
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("add")); dialog.set_default_response(Some("add"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -777,7 +798,11 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
} }
let url = url_row.text().trim().to_string(); let url = url_row.text().trim().to_string();
if url.is_empty() { if url.is_empty() {
toast_error(&ui, "Couldn't add bookmark", "A URL is required."); toast_error(
&ui,
&gettext("Couldn't add bookmark"),
&gettext("A URL is required."),
);
return; return;
} }
let pw = pw_row.text().to_string(); let pw = pw_row.text().to_string();
@@ -785,24 +810,20 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
run_shared_mutation( run_shared_mutation(
&ui, &ui,
Request::CreateBookmark { url, password }, Request::CreateBookmark { url, password },
"Bookmark saved", &gettext("Bookmark saved"),
"Couldn't add the bookmark", &gettext("Couldn't add the bookmark"),
); );
}); });
dialog.present(win.as_ref()); dialog.present(win.as_ref());
} }
/// Run a mutation raised from the Shared page and reload the page on success. /// Run a mutation raised from the Shared page and reload the page on success.
pub(crate) fn run_shared_mutation( pub(crate) fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin(); ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req); let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone(); let ui = ui.clone();
let done = done.to_string(); let done = done.to_string();
let failed = failed.to_string();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
@@ -811,8 +832,10 @@ pub(crate) fn run_shared_mutation(
load_shared(&ui); load_shared(&ui);
toast(&ui, &done); toast(&ui, &done);
} }
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), Ok(Ok(Response::Error { message, kind })) => {
_ => toast_error(&ui, failed, "The mount service didn't respond."), toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
} }
}); });
} }
@@ -868,9 +891,9 @@ mod tests {
#[test] #[test]
fn a_role_pill_says_what_it_allows() { fn a_role_pill_says_what_it_allows() {
assert_eq!(role_access("viewer"), Some("Can view")); assert_eq!(role_access("viewer").as_deref(), Some("Can view"));
assert_eq!(role_access("editor"), Some("Can edit")); assert_eq!(role_access("editor").as_deref(), Some("Can edit"));
assert_eq!(role_access("admin"), Some("Can manage")); assert_eq!(role_access("admin").as_deref(), Some("Can manage"));
assert_eq!(role_access(""), None); assert_eq!(role_access(""), None);
} }
+52 -42
View File
@@ -35,7 +35,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
.build(); .build();
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -60,7 +60,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
inner.set_margin_start(18); inner.set_margin_start(18);
inner.set_margin_end(18); inner.set_margin_end(18);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Shared by Me", &inner); let (frame, header, _) = page_frame(&gettext("Shared by Me"), &inner);
header.pack_end(&refresh); header.pack_end(&refresh);
( (
@@ -109,8 +109,8 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
shared_by_me_status( shared_by_me_status(
ui, ui,
"emblem-shared-symbolic", "emblem-shared-symbolic",
"Loading…", &gettext("Loading…"),
"Reading what you've shared.", &gettext("Reading what you've shared."),
false, false,
); );
ui.busy_begin(); ui.busy_begin();
@@ -128,15 +128,15 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status( Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't read your shares", &gettext("Couldn't read your shares"),
&message, &message,
false, false,
), ),
Ok(Ok(_)) => shared_by_me_status( Ok(Ok(_)) => shared_by_me_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't read your shares", &gettext("Couldn't read your shares"),
"Unexpected reply from the mount service.", &gettext("Unexpected reply from the mount service."),
false, false,
), ),
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
@@ -153,8 +153,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
shared_by_me_status( shared_by_me_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -162,8 +162,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
shared_by_me_status( shared_by_me_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
@@ -183,8 +183,8 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
shared_by_me_status( shared_by_me_status(
ui, ui,
"emblem-shared-symbolic", "emblem-shared-symbolic",
"Nothing shared yet", &gettext("Nothing shared yet"),
"Items you share with people or by link show up here.", &gettext("Items you share with people or by link show up here."),
false, false,
); );
return; return;
@@ -212,14 +212,14 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
if let Some(url) = url.clone() { if let Some(url) = url.clone() {
let copy = gtk4::Button::builder() let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic") .icon_name("edit-copy-symbolic")
.tooltip_text("Copy link") .tooltip_text(gettext("Copy link"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
copy.add_css_class("flat"); copy.add_css_class("flat");
let ui_copy = ui.clone(); let ui_copy = ui.clone();
copy.connect_clicked(move |btn| { copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url); btn.clipboard().set_text(&url);
toast(&ui_copy, "Link copied"); toast(&ui_copy, &gettext("Link copied"));
}); });
row.add_suffix(&copy); row.add_suffix(&copy);
} }
@@ -237,44 +237,48 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
/// Manage access opens the per-node Share dialog, which addresses a pathless /// Manage access opens the per-node Share dialog, which addresses a pathless
/// node by uid, so every shared item can be managed from here. /// node by uid, so every shared item can be managed from here.
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton { fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
let mut items: Vec<(&str, MenuAction)> = Vec::new(); let mut items: Vec<(String, MenuAction)> = Vec::new();
if !entry.path.is_empty() { if !entry.path.is_empty() {
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push(("Open", Box::new(move || open_shared_by_me(&ui_c, &entry_c)))); items.push((
pgettext("verb", "Open"),
Box::new(move || open_shared_by_me(&ui_c, &entry_c)),
));
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push(( items.push((
"Show in My Files", gettext("Show in My Files"),
Box::new(move || show_in_my_files(&ui_c, &entry_c)), Box::new(move || show_in_my_files(&ui_c, &entry_c)),
)); ));
} }
if let Some(url) = url { if let Some(url) = url {
items.push(("Open Link", Box::new(move || open_uri(&url)))); items.push((gettext("Open Link"), Box::new(move || open_uri(&url))));
} }
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push(( items.push((
"Manage Access…", gettext("Manage Access…"),
Box::new(move || open_share_dialog(&ui_c, &entry_c)), Box::new(move || open_share_dialog(&ui_c, &entry_c)),
)); ));
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push(( items.push((
"Stop Sharing…", gettext("Stop Sharing…"),
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)), Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
)); ));
more_menu_button(items) let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
more_menu_button(labels.iter().map(String::as_str).zip(actions).collect())
} }
/// Confirm, then remove every member, invitation and the public link. /// Confirm, then remove every member, invitation and the public link.
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Stop sharing?") .heading(gettext("Stop sharing?"))
.body(format!( // Translators: {name} is the shared item's name.
"Everyone you invited loses access to “{}”, pending invitations are \ .body(gettext_f(
withdrawn and its public link stops working.", "Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
entry.name &[("name", &entry.name)],
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", "Stop Sharing"); dialog.add_response("stop", &gettext("Stop Sharing"));
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -285,8 +289,9 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
run_mutation( run_mutation(
&ui_c, &ui_c,
Request::StopSharingByUid { uid: uid.clone() }, Request::StopSharingByUid { uid: uid.clone() },
format!("Stopped sharing {name}"), // Translators: {name} is the item's name.
"Couldn't stop sharing", gettext_f("Stopped sharing {name}", &[("name", &name)]),
gettext_noop("Couldn't stop sharing"),
); );
}); });
dialog.present(ui_window(ui).as_ref()); dialog.present(ui_window(ui).as_ref());
@@ -319,24 +324,29 @@ pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
pub(crate) fn shared_item_summary(item: &SharedItem) -> String { pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
let mut parts = Vec::new(); let mut parts = Vec::new();
if item.member_count > 0 { if item.member_count > 0 {
parts.push(format!( // Translators: how many people have access to a shared item.
"{} {}", parts.push(ngettext_f(
item.member_count, "{n} person",
if item.member_count == 1 { "{n} people",
"person" item.member_count as u64,
} else { &[],
"people"
}
)); ));
} }
if item.invite_count > 0 { if item.invite_count > 0 {
parts.push(format!("{} pending", item.invite_count)); // Translators: how many invitations to a shared item are not accepted yet.
parts.push(ngettext_f(
"{n} pending",
"{n} pending",
item.invite_count as u64,
&[],
));
} }
if item.link.is_some() { if item.link.is_some() {
parts.push("Public link".to_string()); parts.push(gettext("Public link"));
} }
if parts.is_empty() { if parts.is_empty() {
"Shared".to_string() // Translators: a shared item's summary when no people or link can be counted.
pgettext("state", "Shared")
} else { } else {
parts.join(" · ") parts.join(" · ")
} }
+279 -135
View File
@@ -48,6 +48,9 @@ pub(crate) struct StatusState {
pub(crate) accent_row: adw::SwitchRow, pub(crate) accent_row: adw::SwitchRow,
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`]. /// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
pub(crate) tray_row: adw::SwitchRow, pub(crate) tray_row: adw::SwitchRow,
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
/// [`Self::settings_suppress`].
pub(crate) language_row: adw::ComboRow,
/// 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>,
@@ -138,6 +141,7 @@ pub(crate) struct MainWidgets {
pub(crate) mountpoint_button: gtk4::Button, pub(crate) mountpoint_button: gtk4::Button,
pub(crate) accent_row: adw::SwitchRow, pub(crate) accent_row: adw::SwitchRow,
pub(crate) tray_row: adw::SwitchRow, pub(crate) tray_row: adw::SwitchRow,
pub(crate) language_row: adw::ComboRow,
} }
/// Build the two surfaces that replaced the old Settings page: /// Build the two surfaces that replaced the old Settings page:
@@ -150,22 +154,30 @@ pub(crate) struct MainWidgets {
/// the app version and user agent in About → Troubleshooting. /// the app version and user agent in About → Troubleshooting.
pub(crate) fn build_main_page() -> MainWidgets { pub(crate) fn build_main_page() -> MainWidgets {
// ---- Preferences: General // ---- Preferences: General
let startup_group = adw::PreferencesGroup::builder().title("Startup").build(); let startup_group = adw::PreferencesGroup::builder()
.title(gettext("Startup"))
.build();
let autostart_row = adw::SwitchRow::builder() let autostart_row = adw::SwitchRow::builder()
.title("Start on login") .title(gettext("Start on login"))
.subtitle("Connect Proton Drive automatically when you log in") .subtitle(gettext(
"Connect Proton Drive automatically when you log in",
))
.build(); .build();
startup_group.add(&autostart_row); startup_group.add(&autostart_row);
let location_group = adw::PreferencesGroup::builder().title("Location").build(); let location_group = adw::PreferencesGroup::builder()
.title(gettext("Location"))
.build();
let mountpoint_row = adw::ActionRow::builder() let mountpoint_row = adw::ActionRow::builder()
.title("Proton Drive folder") .title(gettext("Proton Drive folder"))
.subtitle("—") .subtitle("—")
.build(); .build();
mountpoint_row.add_css_class("property"); mountpoint_row.add_css_class("property");
let mountpoint_button = gtk4::Button::builder() let mountpoint_button = gtk4::Button::builder()
.label("Change…") .label(gettext("Change…"))
.tooltip_text("Choose a different folder for the Proton Drive mount") .tooltip_text(gettext(
"Choose a different folder for the Proton Drive mount",
))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
mountpoint_button.add_css_class("flat"); mountpoint_button.add_css_class("flat");
@@ -175,8 +187,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
// Bandwidth caps in MiB/s. 0 = unlimited; the smallest cap is one 0.5 step, // Bandwidth caps in MiB/s. 0 = unlimited; the smallest cap is one 0.5 step,
// since anything slower would stall a single block for most of a minute. // since anything slower would stall a single block for most of a minute.
let network_group = adw::PreferencesGroup::builder() let network_group = adw::PreferencesGroup::builder()
.title("Network") .title(gettext("Network"))
.description("Limit how fast files move, shared by every transfer. 0 means no limit.") .description(gettext(
"Limit how fast files move, shared by every transfer. 0 means no limit.",
))
.build(); .build();
let limit_row = |title: &str| { let limit_row = |title: &str| {
adw::SpinRow::builder() adw::SpinRow::builder()
@@ -185,25 +199,31 @@ pub(crate) fn build_main_page() -> MainWidgets {
.digits(1) .digits(1)
.build() .build()
}; };
let upload_limit_row = limit_row("Upload limit (MiB/s)"); let upload_limit_row = limit_row(&gettext("Upload limit (MiB/s)"));
let download_limit_row = limit_row("Download limit (MiB/s)"); let download_limit_row = limit_row(&gettext("Download limit (MiB/s)"));
network_group.add(&upload_limit_row); network_group.add(&upload_limit_row);
network_group.add(&download_limit_row); network_group.add(&download_limit_row);
let appearance_group = adw::PreferencesGroup::builder().title("Appearance").build(); let appearance_group = adw::PreferencesGroup::builder()
.title(gettext("Appearance"))
.build();
let accent_row = adw::SwitchRow::builder() let accent_row = adw::SwitchRow::builder()
.title("Proton purple accent") .title(gettext("Proton purple accent"))
.subtitle("Use the Proton brand color instead of the system accent color") .subtitle(gettext(
"Use the Proton brand color instead of the system accent color",
))
.build(); .build();
appearance_group.add(&accent_row); appearance_group.add(&accent_row);
let tray_row = adw::SwitchRow::builder() let tray_row = adw::SwitchRow::builder()
.title("Show tray icon") .title(gettext("Show tray icon"))
.subtitle("Sync status and quick actions in the panel") .subtitle(gettext("Sync status and quick actions in the panel"))
.build(); .build();
appearance_group.add(&tray_row); appearance_group.add(&tray_row);
let language_row = build_language_row();
appearance_group.add(&language_row);
let general = adw::PreferencesPage::builder() let general = adw::PreferencesPage::builder()
.title("General") .title(gettext("General"))
.icon_name("preferences-system-symbolic") .icon_name("preferences-system-symbolic")
.build(); .build();
general.add(&startup_group); general.add(&startup_group);
@@ -213,8 +233,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
// ---- Preferences: Storage // ---- Preferences: Storage
let storage_group = adw::PreferencesGroup::builder() let storage_group = adw::PreferencesGroup::builder()
.title("Cache") .title(gettext("Cache"))
.description("Pinned and recently opened files, kept on this computer.") .description(gettext(
"Pinned and recently opened files, kept on this computer.",
))
.build(); .build();
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
storage_box.set_margin_top(12); storage_box.set_margin_top(12);
@@ -236,18 +258,20 @@ pub(crate) fn build_main_page() -> MainWidgets {
// as "no eviction". Step in 0.5 GiB; the upper bound is generous. // as "no eviction". Step in 0.5 GiB; the upper bound is generous.
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0); let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
let budget_row = adw::SpinRow::builder() let budget_row = adw::SpinRow::builder()
.title("Cache size limit (GiB)") .title(gettext("Cache size limit (GiB)"))
.subtitle("Older files are removed past this size; 0 means no limit") .subtitle(gettext(
"Older files are removed past this size; 0 means no limit",
))
.adjustment(&budget_adj) .adjustment(&budget_adj)
.digits(1) .digits(1)
.build(); .build();
storage_group.add(&budget_row); storage_group.add(&budget_row);
let purge_row = adw::ActionRow::builder() let purge_row = adw::ActionRow::builder()
.title("Clear cache") .title(gettext("Clear cache"))
.subtitle("Remove cached copies. Files kept offline stay.") .subtitle(gettext("Remove cached copies. Files kept offline stay."))
.build(); .build();
let purge_button = gtk4::Button::builder() let purge_button = gtk4::Button::builder()
.label("Clear…") .label(gettext("Clear…"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
purge_button.add_css_class("flat"); purge_button.add_css_class("flat");
@@ -256,12 +280,14 @@ pub(crate) fn build_main_page() -> MainWidgets {
// 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("Available offline") .title(gettext("Available offline"))
.description("Files kept on this computer, even without a connection.") .description(gettext(
"Files kept on this computer, even without a connection.",
))
.build(); .build();
let storage = adw::PreferencesPage::builder() let storage = adw::PreferencesPage::builder()
.title("Storage") .title(gettext("Storage"))
.icon_name("drive-harddisk-symbolic") .icon_name("drive-harddisk-symbolic")
.build(); .build();
storage.add(&storage_group); storage.add(&storage_group);
@@ -276,7 +302,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
// ---- Sidebar footer // ---- Sidebar footer
let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic"); let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic");
let status_title = gtk4::Label::builder() let status_title = gtk4::Label::builder()
.label("Connecting…") .label(gettext("Connecting…"))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End) .ellipsize(gtk4::pango::EllipsizeMode::End)
.build(); .build();
@@ -297,7 +323,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
status_content.append(&status_text); status_content.append(&status_text);
let status_button = gtk4::Button::builder() let status_button = gtk4::Button::builder()
.child(&status_content) .child(&status_content)
.tooltip_text("Show sync status") .tooltip_text(gettext("Show sync status"))
.build(); .build();
status_button.add_css_class("flat"); status_button.add_css_class("flat");
status_button.add_css_class("sidebar-status"); status_button.add_css_class("sidebar-status");
@@ -324,13 +350,13 @@ pub(crate) fn build_main_page() -> MainWidgets {
.ellipsize(gtk4::pango::EllipsizeMode::Middle) .ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build(); .build();
let account_menu = gio::Menu::new(); let account_menu = gio::Menu::new();
account_menu.append(Some("Preferences"), Some("win.preferences")); account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
let sign_out = gio::Menu::new(); let sign_out = gio::Menu::new();
sign_out.append(Some("Sign Out…"), Some("win.sign-out")); sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
account_menu.append_section(None, &sign_out); account_menu.append_section(None, &sign_out);
let account_button = gtk4::MenuButton::builder() let account_button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic") .icon_name("view-more-symbolic")
.tooltip_text("Account") .tooltip_text(gettext("Account"))
.menu_model(&account_menu) .menu_model(&account_menu)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -371,15 +397,44 @@ pub(crate) fn build_main_page() -> MainWidgets {
mountpoint_button, mountpoint_button,
accent_row, accent_row,
tray_row, tray_row,
language_row,
} }
} }
/// The Preferences language picker. Row 0 follows the system locale; the rest
/// are [`i18n::LANGUAGES`] in order, under their own names.
fn build_language_row() -> adw::ComboRow {
let names = gtk4::StringList::new(&[]);
names.append(&gettext("System Default"));
for (_, name) in i18n::LANGUAGES {
names.append(name);
}
adw::ComboRow::builder()
.title(gettext("Language"))
.subtitle(gettext("Takes effect the next time Proton Drive starts"))
.model(&names)
.build()
}
/// The picker row for a saved language code; unknown codes show as the system
/// default, which is also how [`i18n::init`] treats them.
pub(crate) fn language_index(code: Option<&str>) -> u32 {
code.and_then(|code| i18n::LANGUAGES.iter().position(|(known, _)| *known == code))
.map_or(0, |at| at as u32 + 1)
}
/// The language code a picker row stands for; `None` is the system default.
pub(crate) fn language_at(index: u32) -> Option<&'static str> {
let at = usize::try_from(index).ok()?.checked_sub(1)?;
i18n::LANGUAGES.get(at).map(|(code, _)| *code)
}
/// The Sync page's live-transfers group, hidden until the refresh loop sees an /// The Sync page's live-transfers group, hidden until the refresh loop sees an
/// in-flight transfer from [`Request::GetQueueStatus`]. /// in-flight transfer from [`Request::GetQueueStatus`].
pub(crate) fn build_transfers_group() -> adw::PreferencesGroup { pub(crate) fn build_transfers_group() -> adw::PreferencesGroup {
adw::PreferencesGroup::builder() adw::PreferencesGroup::builder()
.title("Transfers") .title(gettext("Transfers"))
.description("Files moving to and from Proton Drive.") .description(gettext("Files moving to and from Proton Drive."))
.visible(false) .visible(false)
.build() .build()
} }
@@ -430,6 +485,9 @@ pub(crate) fn wire_settings(
.accent_row .accent_row
.set_active(config.proton_accent.unwrap_or(false)); .set_active(config.proton_accent.unwrap_or(false));
ui.status.tray_row.set_active(!config.tray_hidden); ui.status.tray_row.set_active(!config.tray_hidden);
ui.status
.language_row
.set_selected(language_index(config.language.as_deref()));
ui.status.settings_suppress.set(false); ui.status.settings_suppress.set(false);
// Cache budget: a user edit applies the new soft cap on the daemon (which // Cache budget: a user edit applies the new soft cap on the daemon (which
@@ -450,8 +508,8 @@ pub(crate) fn wire_settings(
settings_request( settings_request(
&ui_fire, &ui_fire,
Request::SetCacheBudget { bytes }, Request::SetCacheBudget { bytes },
"Cache budget updated", gettext("Cache budget updated"),
"Couldn't set cache budget", gettext("Couldn't set cache budget"),
); );
}); });
*ui_budget.status.budget_source.borrow_mut() = Some(src); *ui_budget.status.budget_source.borrow_mut() = Some(src);
@@ -476,8 +534,8 @@ pub(crate) fn wire_settings(
settings_request( settings_request(
&ui_fire, &ui_fire,
Request::SetBandwidthLimits { upload, download }, Request::SetBandwidthLimits { upload, download },
"Bandwidth limits updated", gettext("Bandwidth limits updated"),
"Couldn't set bandwidth limits", gettext("Couldn't set bandwidth limits"),
); );
}); });
*ui_limit.status.limit_source.borrow_mut() = Some(src); *ui_limit.status.limit_source.borrow_mut() = Some(src);
@@ -489,14 +547,11 @@ pub(crate) fn wire_settings(
purge_button.connect_clicked(move |_| { purge_button.connect_clicked(move |_| {
let ui = ui_purge.clone(); let ui = ui_purge.clone();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Clear Cache?") .heading(gettext("Clear Cache?"))
.body( .body(gettext("Cached copies are removed from this computer and download again when you open them. Files kept available offline stay."))
"Cached copies are removed from this computer and download again when \
you open them. Files kept available offline stay.",
)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("purge", "Clear Cache"); dialog.add_response("purge", &gettext("Clear Cache"));
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -505,8 +560,8 @@ pub(crate) fn wire_settings(
settings_request( settings_request(
&ui, &ui,
Request::PurgeCache, Request::PurgeCache,
"Cache cleared", gettext("Cache cleared"),
"Couldn't clear the cache", gettext("Couldn't clear the cache"),
); );
} }
}); });
@@ -541,7 +596,11 @@ pub(crate) fn wire_settings(
let mut config = ui_accent.dirs.load_config(); let mut config = ui_accent.dirs.load_config();
config.proton_accent = Some(on); config.proton_accent = Some(on);
if let Err(e) = ui_accent.dirs.save_config(&config) { if let Err(e) = ui_accent.dirs.save_config(&config) {
toast_error(&ui_accent, "Couldn't save the accent color", &e.to_string()); toast_error(
&ui_accent,
&gettext("Couldn't save the accent color"),
&e.to_string(),
);
} }
}); });
@@ -556,7 +615,11 @@ pub(crate) fn wire_settings(
let mut config = ui_tray.dirs.load_config(); let mut config = ui_tray.dirs.load_config();
config.tray_hidden = !show; config.tray_hidden = !show;
if let Err(e) = ui_tray.dirs.save_config(&config) { if let Err(e) = ui_tray.dirs.save_config(&config) {
toast_error(&ui_tray, "Couldn't save the tray setting", &e.to_string()); toast_error(
&ui_tray,
&gettext("Couldn't save the tray setting"),
&e.to_string(),
);
return; return;
} }
if show { if show {
@@ -565,18 +628,36 @@ pub(crate) fn wire_settings(
pdfs_core::tray::quit(&ui_tray.dirs); pdfs_core::tray::quit(&ui_tray.dirs);
} }
}); });
// Language: read once at start by the app, the tray and the prompt, so a
// change applies on the next launch.
let ui_language = ui.clone();
ui.status.language_row.connect_selected_notify(move |row| {
if ui_language.status.settings_suppress.get() {
return;
}
let mut config = ui_language.dirs.load_config();
config.language = language_at(row.selected()).map(str::to_string);
if let Err(e) = ui_language.dirs.save_config(&config) {
toast_error(
&ui_language,
&gettext("Couldn't save the language"),
&e.to_string(),
);
return;
}
toast(
&ui_language,
&gettext("Restart Proton Drive to use the new language"),
);
});
} }
/// 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,
/// confirming with `done` or reporting the daemon's error under `failed`. Unlike /// confirming with `done` or reporting the daemon's error under `failed`. Unlike
/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the /// [`run_mutation`] there's no browser reload; the next refresh tick repaints the
/// cache read-out. /// cache read-out.
pub(crate) fn settings_request( pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed: String) {
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin(); ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req); let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone(); let ui = ui.clone();
@@ -584,9 +665,11 @@ pub(crate) fn settings_request(
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
match result { match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, done), Ok(Ok(Response::Ok { .. })) => toast(&ui, &done),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), Ok(Ok(Response::Error { message, kind })) => {
_ => toast_error(&ui, failed, "The mount service didn't respond."), toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
} }
}); });
} }
@@ -596,7 +679,7 @@ pub(crate) fn settings_request(
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) { pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
.title("Choose mountpoint folder") .title(gettext("Choose mountpoint folder"))
.build(); .build();
let ui = ui.clone(); let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
@@ -608,7 +691,7 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let mut config = ui.dirs.load_config(); let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone()); config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) { if let Err(e) = ui.dirs.save_config(&config) {
toast_error(&ui, "Couldn't save mountpoint", &e.to_string()); toast_error(&ui, &gettext("Couldn't save mountpoint"), &e.to_string());
return; return;
} }
ui.status.mountpoint_row.set_subtitle(&path_str); ui.status.mountpoint_row.set_subtitle(&path_str);
@@ -621,13 +704,15 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
// The daemon only reads the mountpoint at mount time, so offer a restart. // The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder() let confirm = adw::AlertDialog::builder()
.heading("Restart to apply") .heading(gettext("Restart to apply"))
.body(format!( // Translators: {path} is the folder the drive is mounted at.
"The mountpoint is now “{path_str}”. Restart the Drive mount to use it?" .body(gettext_f(
"The mountpoint is now “{path}”. Restart the Drive mount to use it?",
&[("path", &path_str)],
)) ))
.build(); .build();
confirm.add_response("later", "Later"); confirm.add_response("later", &gettext("Later"));
confirm.add_response("restart", "Restart now"); confirm.add_response("restart", &gettext("Restart now"));
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested); confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart")); confirm.set_default_response(Some("restart"));
confirm.set_close_response("later"); confirm.set_close_response("later");
@@ -778,17 +863,26 @@ fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) { fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
let used = used_space.max(0) as u64; let used = used_space.max(0) as u64;
if max_space <= 0 { if max_space <= 0 {
return (0.0, format!("{} used", human_bytes(used))); return (
0.0,
// Translators: {size} is an amount of storage, such as "1.2 GiB".
gettext_f("{size} used", &[("size", &human_bytes(used))]),
);
} }
let total = max_space as u64; let total = max_space as u64;
let fraction = (used as f64 / total as f64).clamp(0.0, 1.0); 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;
( (
fraction, fraction,
format!( // Translators: account storage. {used} and {total} are sizes such as
"{} of {} used ({pct}%)", // "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
human_bytes(used), gettext_f(
human_bytes(total) "{used} of {total} used ({percent}%)",
&[
("used", &human_bytes(used)),
("total", &human_bytes(total)),
("percent", &pct.to_string()),
],
), ),
) )
} }
@@ -806,11 +900,17 @@ fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String,
let pct = (fraction * 100.0).round() as u64; let pct = (fraction * 100.0).round() as u64;
Some(( Some((
fraction, fraction,
format!("{} free", human_bytes(free)), // Translators: {size} is an amount of storage, such as "1.2 GiB".
format!( gettext_f("{size} free", &[("size", &human_bytes(free))]),
"{} free out of {} ({pct}% used)", // Translators: account storage. {free} and {total} are sizes such as
human_bytes(free), // "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
human_bytes(total) gettext_f(
"{free} free out of {total} ({percent}% used)",
&[
("free", &human_bytes(free)),
("total", &human_bytes(total)),
("percent", &pct.to_string()),
],
), ),
)) ))
} }
@@ -843,14 +943,14 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
if mounted { if mounted {
notify( notify(
"mount-state", "mount-state",
"Proton Drive connected", &gettext("Proton Drive connected"),
"Your Drive is mounted and available.", &gettext("Your Drive is mounted and available."),
); );
} else { } else {
notify( notify(
"mount-state", "mount-state",
"Proton Drive disconnected", &gettext("Proton Drive disconnected"),
"The mount service stopped. Files aren't available until it restarts.", &gettext("The mount service stopped. Files aren't available until it restarts."),
); );
} }
} }
@@ -900,15 +1000,15 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
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 {
"1 file".to_string()
} else {
format!("{previous} files")
};
notify( notify(
"sync-complete", "sync-complete",
"Sync complete", &gettext("Sync complete"),
&format!("{files} finished transferring."), &ngettext_f(
"{n} file finished transferring.",
"{n} files finished transferring.",
previous as u64,
&[],
),
); );
// A just-finished batch may have added files (bulk upload) the current // A just-finished batch may have added files (bulk upload) the current
// listing doesn't show yet; refresh whichever listing is on screen. // listing doesn't show yet; refresh whichever listing is on screen.
@@ -966,11 +1066,26 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
/// One Activity row for a daemon job: its title, plus whatever it can say about /// One Activity row for a daemon job: its title, plus whatever it can say about
/// where it is — a count when it has one, else what it is currently chewing on. /// where it is — a count when it has one, else what it is currently chewing on.
pub(crate) fn job_line(j: &JobItem) -> ActivityLine { pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
let done = j.done.to_string();
let total = j.total.to_string();
let args: [(&str, &str); 4] = [
("title", &j.title),
("detail", &j.detail),
("done", &done),
("total", &total),
];
let text = match (j.total > 0, j.detail.is_empty()) { let text = match (j.total > 0, j.detail.is_empty()) {
(true, true) => format!("{} — {} of {}", j.title, j.done, j.total), // Translators: a background job's progress. {title} is the job, such as
(true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total), // "Scanning Photos"; {done} and {total} are counts.
(false, true) => format!("{}…", j.title), (true, true) => gettext_f("{title} — {done} of {total}", &args),
(false, false) => format!("{} — {}…", j.title, j.detail), // Translators: a background job's progress. {title} is the job, {detail}
// what it is working on; {done} and {total} are counts.
(true, false) => gettext_f("{title} — {detail} ({done} of {total})", &args),
// Translators: a background job still running. {title} is the job.
(false, true) => gettext_f("{title}…", &args),
// Translators: a background job still running. {title} is the job,
// {detail} what it is working on.
(false, false) => gettext_f("{title} — {detail}…", &args),
}; };
ActivityLine { ActivityLine {
text, text,
@@ -984,25 +1099,30 @@ pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
TransferDirection::Download => "↓", TransferDirection::Download => "↓",
TransferDirection::Upload => "↑", TransferDirection::Upload => "↑",
}; };
let done = human_bytes(t.bytes_completed);
let total = human_bytes(t.bytes_total);
let speed = human_bytes(t.speed_bytes_sec);
let args: [(&str, &str); 5] = [
("arrow", arrow),
("name", &t.name),
("done", &done),
("total", &total),
("speed", &speed),
];
if t.bytes_total == 0 { if t.bytes_total == 0 {
ActivityLine { ActivityLine {
text: format!( // Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
"{arrow} {} — {} ({}/s)", // file name, {done} the size moved so far and {speed} a size such as
t.name, // "1.2 MiB"; "/s" means per second.
human_bytes(t.bytes_completed), text: gettext_f("{arrow} {name} — {done} ({speed}/s)", &args),
human_bytes(t.speed_bytes_sec),
),
fraction: None, fraction: None,
} }
} else { } else {
ActivityLine { ActivityLine {
text: format!( // Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
"{arrow} {} — {} of {} ({}/s)", // file name, {done} and {total} sizes and {speed} a size such as
t.name, // "1.2 MiB"; "/s" means per second.
human_bytes(t.bytes_completed), text: gettext_f("{arrow} {name} — {done} of {total} ({speed}/s)", &args),
human_bytes(t.bytes_total),
human_bytes(t.speed_bytes_sec),
),
fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)), fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)),
} }
} }
@@ -1042,7 +1162,7 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
// The queue is the more useful thing to say when it has anything // The queue is the more useful thing to say when it has anything
// in it: it is why a file that looks saved is not on the remote // in it: it is why a file that looks saved is not on the remote
// yet, and offline is usually the reason it is still queued. // yet, and offline is usually the reason it is still queued.
let queued = pending_summary(pending_uploads, pending_changes); let queued = i18n::pending_summary(pending_uploads, pending_changes);
let state = if paused { let state = if paused {
SyncState::Paused { SyncState::Paused {
until: paused_until, until: paused_until,
@@ -1131,47 +1251,55 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
SyncState::Paused { until, queued } => ( SyncState::Paused { until, queued } => (
"media-playback-pause-symbolic", "media-playback-pause-symbolic",
Some("warning"), Some("warning"),
"Sync paused".to_string(), gettext("Sync paused"),
Some(match (until, queued) { Some(match (until, queued) {
(Some(until), Some(queued)) => format!("{queued} · resumes {}", clock_time(until)), // Translators: {queued} says what is waiting, such as "3 changes
(Some(until), None) => format!("Resumes {}", clock_time(until)), // waiting"; {time} is when syncing resumes, such as "at 14:30".
(Some(until), Some(queued)) => gettext_f(
"{queued} · resumes {time}",
&[("queued", &queued), ("time", &clock_time(until))],
),
// Translators: {time} is when syncing resumes, such as "at 14:30"
// or "Tue 09:00".
(Some(until), None) => gettext_f("Resumes {time}", &[("time", &clock_time(until))]),
(None, Some(queued)) => queued, (None, Some(queued)) => queued,
(None, None) => "Until you resume".to_string(), (None, None) => gettext("Until you resume"),
}), }),
), ),
SyncState::Attention { count, error } => ( SyncState::Attention { count, error } => (
"dialog-warning-symbolic", "dialog-warning-symbolic",
Some("error"), Some("error"),
format!( ngettext_f(
"{} need{} attention", "{n} change needs attention",
count_noun(count as usize, "change", "changes"), "{n} changes need attention",
if count == 1 { "s" } else { "" } count,
&[],
), ),
error, error,
), ),
SyncState::Offline { queued } => ( SyncState::Offline { queued } => (
"network-offline-symbolic", "network-offline-symbolic",
Some("warning"), Some("warning"),
"Offline".to_string(), pgettext("state", "Offline"),
Some(queued.unwrap_or_else(|| "Cached files only".to_string())), Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
), ),
SyncState::Syncing { queued } => ( SyncState::Syncing { queued } => (
"emblem-synchronizing-symbolic", "emblem-synchronizing-symbolic",
None, None,
"Syncing".to_string(), pgettext("state", "Syncing"),
Some(queued), Some(queued),
), ),
SyncState::UpToDate { mountpoint } => ( SyncState::UpToDate { mountpoint } => (
"emblem-ok-symbolic", "emblem-ok-symbolic",
Some("success"), Some("success"),
"Up to date".to_string(), gettext("Up to date"),
Some(mountpoint), Some(mountpoint),
), ),
SyncState::Disconnected => ( SyncState::Disconnected => (
"network-offline-symbolic", "network-offline-symbolic",
Some("warning"), Some("warning"),
"Not connected".to_string(), gettext("Not connected"),
Some("Proton Drive isn't running".to_string()), Some(gettext("Proton Drive isn't running")),
), ),
}; };
let image = &ui.status.status_icon; let image = &ui.status.status_icon;
@@ -1205,18 +1333,24 @@ pub(crate) fn clock_time(unix: i64) -> String {
glib::DateTime::from_unix_local(unix), glib::DateTime::from_unix_local(unix),
glib::DateTime::now_local(), glib::DateTime::now_local(),
) else { ) else {
return "later".to_string(); // Translators: stands in for a time that could not be read, as in
// "Resumes later".
return gettext("later");
}; };
let format = if at.ymd() == now.ymd() { let format = if at.ymd() == now.ymd() {
"at %H:%M" // Translators: strftime format for a time later today, such as "at 14:30".
gettext("at %H:%M")
} else if at.difference(&now).as_seconds() < 6 * 86_400 { } else if at.difference(&now).as_seconds() < 6 * 86_400 {
"%a %H:%M" // Translators: strftime format for a time within the next week, such as
// "Tue 09:00".
gettext("%a %H:%M")
} else { } else {
"%e %b" // Translators: strftime format for a date further ahead, such as "23 Sep".
gettext("%e %b")
}; };
at.format(format) at.format(&format)
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.unwrap_or_else(|_| "later".to_string()) .unwrap_or_else(|_| gettext("later"))
} }
/// Render the pins group from `pins`, with the unpin buttons enabled only while a /// Render the pins group from `pins`, with the unpin buttons enabled only while a
@@ -1242,8 +1376,10 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
if pins.is_empty() { if pins.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("No files kept offline") .title(gettext("No files kept offline"))
.subtitle("Right-click a file and choose “Make available offline”.") .subtitle(gettext(
"Right-click a file and choose “Make available offline”.",
))
.build(); .build();
ui.status.pins_group.add(&row); ui.status.pins_group.add(&row);
ui.status ui.status
@@ -1277,7 +1413,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let unpin = gtk4::Button::builder() let unpin = gtk4::Button::builder()
.icon_name("pdfs-online-only-symbolic") .icon_name("pdfs-online-only-symbolic")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.tooltip_text("Make online only") .tooltip_text(gettext("Make online only"))
.sensitive(mounted) .sensitive(mounted)
.build(); .build();
unpin.add_css_class("flat"); unpin.add_css_class("flat");
@@ -1291,14 +1427,17 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let ui = ui_btn.clone(); let ui = ui_btn.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => toast_failure(
toast_failure(&ui, "Couldn't make it online only", &message, kind) &ui,
} &gettext("Couldn't make it online only"),
&message,
kind,
),
Ok(Ok(_)) => refresh(&ui), Ok(Ok(_)) => refresh(&ui),
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't make it online only", &gettext("Couldn't make it online only"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -1315,9 +1454,14 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
if pins.len() > PINS_COLLAPSED { if pins.len() > PINS_COLLAPSED {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(if expanded { .title(if expanded {
"Show fewer".to_string() gettext("Show fewer")
} else { } else {
format!("Show all {}", count_noun(pins.len(), "file", "files")) ngettext_f(
"Show all {n} file",
"Show all {n} files",
pins.len() as u64,
&[],
)
}) })
.activatable(true) .activatable(true)
.build(); .build();
@@ -1360,7 +1504,7 @@ mod tests {
#[test] #[test]
fn quota_display_clamps_bad_api_values() { fn quota_display_clamps_bad_api_values() {
assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string())); assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
assert_eq!(quota_display(100, 150).0, 1.0); assert_eq!(quota_display(100, 150).0, 1.0);
} }
+129 -92
View File
@@ -70,8 +70,11 @@ fn save_staged(ui: &Rc<Ui>) {
} }
} }
const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \ fn empty_list_description() -> String {
re-running an interrupted import is safe."; gettext(
"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 { pub(crate) struct TakeoutState {
/// Archives staged for this import, in the order they were added. Sent as /// Archives staged for this import, in the order they were added. Sent as
@@ -130,13 +133,15 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// there and has no sidebar row of its own to navigate back with. // there and has no sidebar row of its own to navigate back with.
let back_button = gtk4::Button::builder() let back_button = gtk4::Button::builder()
.icon_name("go-previous-symbolic") .icon_name("go-previous-symbolic")
.tooltip_text("Back to Photos") .tooltip_text(gettext("Back to Photos"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
back_button.add_css_class("flat"); back_button.add_css_class("flat");
let subtitle = gtk4::Label::builder() let subtitle = gtk4::Label::builder()
.label("Add a Google Takeout export to your Proton Photos timeline.") .label(gettext(
"Add a Google Takeout export to your Proton Photos timeline.",
))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.wrap(true) .wrap(true)
.xalign(0.0) .xalign(0.0)
@@ -152,18 +157,18 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
drop_icon.set_pixel_size(48); drop_icon.set_pixel_size(48);
drop_icon.add_css_class("dim-label"); drop_icon.add_css_class("dim-label");
let drop_title = gtk4::Label::builder() let drop_title = gtk4::Label::builder()
.label("Drop your Takeout .zip files here") .label(gettext("Drop your Takeout .zip files here"))
.build(); .build();
drop_title.add_css_class("title-4"); drop_title.add_css_class("title-4");
let drop_hint = gtk4::Label::builder() 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.") .label(gettext("Add every part of the export at once — a photo and its metadata often sit in different parts."))
.wrap(true) .wrap(true)
.justify(gtk4::Justification::Center) .justify(gtk4::Justification::Center)
.max_width_chars(48) .max_width_chars(48)
.build(); .build();
drop_hint.add_css_class("dim-label"); drop_hint.add_css_class("dim-label");
let choose_button = gtk4::Button::builder() let choose_button = gtk4::Button::builder()
.label("Choose files…") .label(gettext("Choose files…"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
choose_button.add_css_class("pill"); choose_button.add_css_class("pill");
@@ -181,13 +186,13 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// group carries a placeholder row while empty so it never renders as a bare // group carries a placeholder row while empty so it never renders as a bare
// title. // title.
let list_group = adw::PreferencesGroup::builder() let list_group = adw::PreferencesGroup::builder()
.title("Archives to import") .title(gettext("Archives to import"))
.description(EMPTY_LIST_DESCRIPTION) .description(empty_list_description())
.build(); .build();
let clear_button = gtk4::Button::builder() let clear_button = gtk4::Button::builder()
.label("Clear") .label(gettext("Clear"))
.tooltip_text("Remove every staged archive") .tooltip_text(gettext("Remove every staged archive"))
.build(); .build();
clear_button.add_css_class("flat"); clear_button.add_css_class("flat");
@@ -195,16 +200,22 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// what an import *would* do, without uploading a byte. It is offered first // 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. // because the import behind it is hours long and hard to take back.
let scan_button = gtk4::Button::builder() let scan_button = gtk4::Button::builder()
.label("Scan") .label(pgettext("verb", "Scan"))
.tooltip_text("Check the archives and report what would be imported, without uploading") .tooltip_text(gettext(
"Check the archives and report what would be imported, without uploading",
))
.build(); .build();
scan_button.add_css_class("pill"); scan_button.add_css_class("pill");
let import_button = gtk4::Button::builder().label("Import").build(); let import_button = gtk4::Button::builder()
.label(pgettext("verb", "Import"))
.build();
import_button.add_css_class("pill"); import_button.add_css_class("pill");
import_button.add_css_class("suggested-action"); import_button.add_css_class("suggested-action");
let cancel_button = gtk4::Button::builder() let cancel_button = gtk4::Button::builder()
.label("Stop import") .label(gettext("Stop import"))
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop") .tooltip_text(gettext(
"Finish the photo on the wire, file what was uploaded, then stop",
))
.build(); .build();
cancel_button.add_css_class("pill"); cancel_button.add_css_class("pill");
cancel_button.add_css_class("destructive-action"); cancel_button.add_css_class("destructive-action");
@@ -224,7 +235,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// Progress: the import's job from the transfer snapshot, so this page shows // Progress: the import's job from the transfer snapshot, so this page shows
// the same numbers the Sync page's Transfers list does. // the same numbers the Sync page's Transfers list does.
let progress_group = adw::PreferencesGroup::builder() let progress_group = adw::PreferencesGroup::builder()
.title("Import in progress") .title(gettext("Import in progress"))
.visible(false) .visible(false)
.build(); .build();
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
@@ -246,7 +257,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// outlives the window being on this page, and "what happened" is the whole // outlives the window being on this page, and "what happened" is the whole
// reason to come back to it. // reason to come back to it.
let summary_group = adw::PreferencesGroup::builder() let summary_group = adw::PreferencesGroup::builder()
.title("Last import") .title(gettext("Last import"))
.visible(false) .visible(false)
.build(); .build();
@@ -273,7 +284,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
page.set_margin_end(18); page.set_margin_end(18);
page.append(&subtitle_clamp); page.append(&subtitle_clamp);
page.append(&scroll); page.append(&scroll);
let (frame, header, _) = page_frame("Import from Google Photos", &page); let (frame, header, _) = page_frame(&gettext("Import from Google Photos"), &page);
header.pack_start(&back_button); header.pack_start(&back_button);
let widgets = TakeoutWidgets { let widgets = TakeoutWidgets {
@@ -330,10 +341,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
widgets.choose_button.connect_clicked(move |_| { widgets.choose_button.connect_clicked(move |_| {
// Multi-select on purpose: the whole export goes in one request. // Multi-select on purpose: the whole export goes in one request.
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
.title("Select your Google Takeout archives") .title(gettext("Select your Google Takeout archives"))
.build(); .build();
let filter = gtk4::FileFilter::new(); let filter = gtk4::FileFilter::new();
filter.set_name(Some("Takeout archives")); filter.set_name(Some(&gettext("Takeout archives")));
filter.add_pattern("*.zip"); filter.add_pattern("*.zip");
let filters = gio::ListStore::new::<gtk4::FileFilter>(); let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter); filters.append(&filter);
@@ -373,16 +384,16 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let count = ui_import.takeout.archives.borrow().len(); let count = ui_import.takeout.archives.borrow().len();
let ui = ui_import.clone(); let ui = ui_import.clone();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Import this export?") .heading(gettext("Import this export?"))
.body(format!( .body(ngettext_f(
"{} will be uploaded to Proton Photos, creating albums as they appear in \ "{n} archive 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.",
the export. This can take hours; you can stop it at any point and what has \ "{n} archives 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.",
been uploaded stays. Photos you already have are skipped.", count as u64,
count_noun(count, "archive", "archives") &[],
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("import", "Import"); dialog.add_response("import", &pgettext("verb", "Import"));
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -403,14 +414,14 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
match result { match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"), Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Stopping the import…")),
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't stop the import", &message, kind); toast_failure(&ui, &gettext("Couldn't stop the import"), &message, kind);
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't stop the import", &gettext("Couldn't stop the import"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -460,12 +471,14 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
if rejected > 0 { if rejected > 0 {
toast( toast(
ui, ui,
"Only Takeout .zip files can be imported — extracted folders aren't supported.", &gettext(
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
),
); );
} else if added > 0 { } else if added > 0 {
toast( toast(
ui, ui,
&format!("Added {}", count_noun(added, "archive", "archives")), &ngettext_f("Added {n} archive", "Added {n} archives", added as u64, &[]),
); );
} }
} }
@@ -481,8 +494,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
if staged.is_empty() { if staged.is_empty() {
ui.takeout ui.takeout
.list_group .list_group
.set_description(Some(EMPTY_LIST_DESCRIPTION)); .set_description(Some(&empty_list_description()));
let row = dim_row("No archives added yet."); let row = dim_row(&gettext("No archives added yet."));
ui.takeout.list_group.add(&row); ui.takeout.list_group.add(&row);
rows.push(row.upcast()); rows.push(row.upcast());
} else { } else {
@@ -500,7 +513,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
.build(); .build();
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("edit-delete-symbolic") .icon_name("edit-delete-symbolic")
.tooltip_text("Remove from this import") .tooltip_text(gettext("Remove from this import"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
@@ -518,10 +531,14 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
ui.takeout.list_group.add(&row); ui.takeout.list_group.add(&row);
rows.push(row.upcast()); rows.push(row.upcast());
} }
ui.takeout.list_group.set_description(Some(&format!( let size = human_bytes(total);
"{}, {} in total. {EMPTY_LIST_DESCRIPTION}", let note = empty_list_description();
count_noun(staged.len(), "archive", "archives"), // Translators: {size} is a file size such as "4.2 MB"; {note} is the sentence about skipped duplicates.
human_bytes(total) ui.takeout.list_group.set_description(Some(&ngettext_f(
"{n} archive, {size} in total. {note}",
"{n} archives, {size} in total. {note}",
staged.len() as u64,
&[("size", &size), ("note", &note)],
))); )));
} }
*ui.takeout.rows.borrow_mut() = rows; *ui.takeout.rows.borrow_mut() = rows;
@@ -575,28 +592,28 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
ui.takeout.summary_group.set_visible(false); ui.takeout.summary_group.set_visible(false);
ui.takeout.progress_group.set_visible(true); ui.takeout.progress_group.set_visible(true);
ui.takeout.progress_bar.set_fraction(0.0); ui.takeout.progress_bar.set_fraction(0.0);
ui.takeout.progress_label.set_text(if dry_run { ui.takeout.progress_label.set_text(&if dry_run {
"Reading archives…" gettext("Reading archives…")
} else { } else {
"Starting import…" gettext("Starting import…")
}); });
sync_takeout_actions(&ui); sync_takeout_actions(&ui);
toast( toast(
&ui, &ui,
if dry_run { &if dry_run {
"Scanning the export…" gettext("Scanning the export…")
} else { } else {
"Import started — you can leave this page, it keeps running." gettext("Import started — you can leave this page, it keeps running.")
}, },
); );
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't start the import", &message, kind); toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't start the import", &gettext("Couldn't start the import"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -629,23 +646,35 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
if was_running { if was_running {
let dry = ui.takeout.dry_run.get(); let dry = ui.takeout.dry_run.get();
let headline = if dry { let headline = if dry {
"Scan finished" gettext("Scan finished")
} else { } else {
"Import finished" gettext("Import finished")
}; };
toast(&ui, headline); toast(&ui, &headline);
if !dry { if !dry {
let body = match &summary { let body = match &summary {
Some(s) if s.failed > 0 => { // Translators: {n} is the number of photos uploaded, {failed} the number that failed.
format!("{} photos added, {} failed.", s.uploaded, s.failed) Some(s) if s.failed > 0 => ngettext_f(
} "{n} photo added, {failed} failed.",
Some(s) if s.cancelled => { "{n} photos added, {failed} failed.",
format!("Stopped after {} photos.", s.uploaded) s.uploaded as u64,
} &[("failed", &s.failed.to_string())],
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded), ),
None => "The Google Photos import has finished.".to_string(), Some(s) if s.cancelled => ngettext_f(
"Stopped after {n} photo.",
"Stopped after {n} photos.",
s.uploaded as u64,
&[],
),
Some(s) => ngettext_f(
"{n} photo added to Proton Photos.",
"{n} photos added to Proton Photos.",
s.uploaded as u64,
&[],
),
None => gettext("The Google Photos import has finished."),
}; };
notify("takeout-import", headline, &body); notify("takeout-import", &headline, &body);
} }
} }
} }
@@ -665,69 +694,72 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
ui.takeout.summary_group.remove(&row); ui.takeout.summary_group.remove(&row);
} }
let dry = ui.takeout.dry_run.get(); let dry = ui.takeout.dry_run.get();
ui.takeout ui.takeout.summary_group.set_title(&if dry {
.summary_group gettext("Scan result")
.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 { } else {
"Finished." gettext("Last import")
});
ui.takeout.summary_group.set_description(Some(&if dry {
gettext("Nothing was uploaded — this is what an import would do.")
} else if summary.cancelled {
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
} else {
gettext("Finished.")
})); }));
// Only lines that carry information: a run with no failures should not have // Only lines that carry information: a run with no failures should not have
// to show a "0 failed" row for the user to read past. // to show a "0 failed" row for the user to read past.
let mut lines: Vec<(String, String)> = vec![ let mut lines: Vec<(String, String)> = vec![
("Photos in the export".into(), summary.found.to_string()), (gettext("Photos in the export"), summary.found.to_string()),
( (
if dry { "Would upload" } else { "Uploaded" }.into(), if dry {
gettext("Would upload")
} else {
gettext("Uploaded")
},
summary.uploaded.to_string(), summary.uploaded.to_string(),
), ),
]; ];
if summary.duplicates > 0 { if summary.duplicates > 0 {
lines.push(( lines.push((
"Already in Proton Photos".into(), gettext("Already in Proton Photos"),
summary.duplicates.to_string(), summary.duplicates.to_string(),
)); ));
} }
if summary.albums_created > 0 { if summary.albums_created > 0 {
lines.push(( lines.push((
if dry { if dry {
"Albums to create" gettext("Albums to create")
} else { } else {
"Albums created" gettext("Albums created")
} },
.into(),
summary.albums_created.to_string(), summary.albums_created.to_string(),
)); ));
} }
if summary.album_links > 0 { if summary.album_links > 0 {
lines.push(( lines.push((
"Photos filed into albums".into(), gettext("Photos filed into albums"),
summary.album_links.to_string(), summary.album_links.to_string(),
)); ));
} }
if summary.skipped_trashed > 0 { if summary.skipped_trashed > 0 {
lines.push(( lines.push((
"Skipped (in Google's trash)".into(), gettext("Skipped (in Google's trash)"),
summary.skipped_trashed.to_string(), summary.skipped_trashed.to_string(),
)); ));
} }
if summary.bytes > 0 { if summary.bytes > 0 {
lines.push(( lines.push((
if dry { if dry {
"Data to upload" gettext("Data to upload")
} else { } else {
"Data uploaded" gettext("Data uploaded")
} },
.into(),
human_bytes(summary.bytes), human_bytes(summary.bytes),
)); ));
} }
if summary.failed > 0 { if summary.failed > 0 {
lines.push(("Failed".into(), summary.failed.to_string())); lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
} }
let mut rows = Vec::new(); let mut rows = Vec::new();
@@ -756,17 +788,22 @@ pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
ui.takeout ui.takeout
.progress_bar .progress_bar
.set_fraction((job.done as f64 / job.total as f64).min(1.0)); .set_fraction((job.done as f64 / job.total as f64).min(1.0));
ui.takeout let done = job.done.to_string();
.progress_label let total = job.total.to_string();
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail)); // Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
let text = gettext_f(
"{done} of {total} — {detail}",
&[("done", &done), ("total", &total), ("detail", &job.detail)],
);
ui.takeout.progress_label.set_text(&text);
} else { } else {
ui.takeout.progress_bar.pulse(); ui.takeout.progress_bar.pulse();
ui.takeout ui.takeout
.progress_label .progress_label
.set_text(if job.detail.is_empty() { .set_text(&if job.detail.is_empty() {
"Working…" gettext("Working…")
} else { } else {
&job.detail job.detail.clone()
}); });
} }
} }
+92 -52
View File
@@ -47,14 +47,14 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
let model = gio::ListStore::new::<BoxedAnyObject>(); let model = gio::ListStore::new::<BoxedAnyObject>();
let empty = gtk4::Button::builder() let empty = gtk4::Button::builder()
.label("Empty Trash…") .label(gettext("Empty Trash…"))
.tooltip_text("Permanently delete everything in the Trash") .tooltip_text(gettext("Permanently delete everything in the Trash"))
.sensitive(false) .sensitive(false)
.build(); .build();
let refresh = refresh_button(); let refresh = refresh_button();
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -90,8 +90,8 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
inner.append(&content); inner.append(&content);
let selection_label = gtk4::Label::new(None); let selection_label = gtk4::Label::new(None);
let restore_selected = gtk4::Button::with_label("Restore"); let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
let delete_selected = gtk4::Button::with_label("Delete Permanently…"); let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
delete_selected.add_css_class("destructive-action"); delete_selected.add_css_class("destructive-action");
let bar = gtk4::ActionBar::new(); let bar = gtk4::ActionBar::new();
bar.set_center_widget(Some(&selection_label)); bar.set_center_widget(Some(&selection_label));
@@ -103,7 +103,7 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
.build(); .build();
inner.append(&selection_bar); inner.append(&selection_bar);
let (frame, header, subtitle) = page_frame("Trash", &inner); let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
header.pack_start(&empty); header.pack_start(&empty);
header.pack_end(&refresh); header.pack_end(&refresh);
@@ -154,13 +154,13 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
let restore = gtk4::Button::builder() let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic") .icon_name("edit-undo-symbolic")
.tooltip_text("Restore to its original folder") .tooltip_text(gettext("Restore to its original folder"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
restore.add_css_class("flat"); restore.add_css_class("flat");
let purge = gtk4::Button::builder() let purge = gtk4::Button::builder()
.icon_name("edit-delete-symbolic") .icon_name("edit-delete-symbolic")
.tooltip_text("Delete permanently") .tooltip_text(gettext("Delete permanently"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
purge.add_css_class("flat"); purge.add_css_class("flat");
@@ -211,11 +211,17 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
} }
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() { if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
let kind = if entry.is_dir { let kind = if entry.is_dir {
"Folder".to_string() gettext("Folder")
} else { } else {
human_bytes(entry.size) human_bytes(entry.size)
}; };
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); let modified = format_modified(entry.modified);
// Translators: {kind} is "Folder" or a file size; {modified} is a date.
let text = gettext_f(
"{kind} · {modified}",
&[("kind", &kind), ("modified", &modified)],
);
meta.set_label(&text);
} }
} }
}); });
@@ -259,9 +265,12 @@ fn sync_trash_selection(ui: &Rc<Ui>) {
let count = ui.trash.selection.selection().size() as usize; let count = ui.trash.selection.selection().size() as usize;
ui.trash.selection_bar.set_reveal_child(count > 0); ui.trash.selection_bar.set_reveal_child(count > 0);
if count > 0 { if count > 0 {
ui.trash ui.trash.selection_label.set_label(&ngettext_f(
.selection_label "{n} item selected",
.set_label(&format!("{} selected", count_noun(count, "item", "items"))); "{n} items selected",
count as u64,
&[],
));
} }
} }
@@ -284,8 +293,8 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
trash_status( trash_status(
ui, ui,
"user-trash-symbolic", "user-trash-symbolic",
"Loading…", &gettext("Loading…"),
"Reading the trash.", &gettext("Reading the trash."),
false, false,
); );
@@ -300,15 +309,15 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
Ok(Ok(Response::Error { message, .. })) => trash_status( Ok(Ok(Response::Error { message, .. })) => trash_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't read the trash", &gettext("Couldn't read the trash"),
&message, &message,
false, false,
), ),
Ok(Ok(_)) => trash_status( Ok(Ok(_)) => trash_status(
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
"Couldn't read the trash", &gettext("Couldn't read the trash"),
"Unexpected reply from the mount service.", &gettext("Unexpected reply from the mount service."),
false, false,
), ),
Ok(Err(_)) | Err(_) => trash_unreachable(&ui), Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
@@ -323,8 +332,8 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
trash_status( trash_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
"Not connected", &gettext("Not connected"),
"The Proton Drive mount service isn't running.", &gettext("The Proton Drive mount service isn't running."),
true, true,
); );
return; return;
@@ -332,8 +341,8 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
trash_status( trash_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
"Connecting…", &gettext("Connecting…"),
"Waiting for the Proton Drive mount service to come up.", &gettext("Waiting for the Proton Drive mount service to come up."),
false, false,
); );
let ui = ui.clone(); let ui = ui.clone();
@@ -364,16 +373,19 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
trash_status( trash_status(
ui, ui,
"user-trash-symbolic", "user-trash-symbolic",
"Trash is empty", &gettext("Trash is empty"),
"Items you delete from Proton Drive show up here.", &gettext("Items you delete from Proton Drive show up here."),
false, false,
); );
return; return;
} }
ui.trash.content.set_visible_child_name("list"); ui.trash.content.set_visible_child_name("list");
ui.trash ui.trash.subtitle.set_subtitle(&ngettext_f(
.subtitle "{n} item",
.set_subtitle(&count_noun(entries.len(), "item", "items")); "{n} items",
entries.len() as u64,
&[],
));
let mut sorted = entries.to_vec(); let mut sorted = entries.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified)); sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
@@ -394,30 +406,56 @@ pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
Request::Restore { Request::Restore {
uids: entries.iter().map(|e| e.uid.clone()).collect(), uids: entries.iter().map(|e| e.uid.clone()).collect(),
}, },
format!("Restored {}", entries_label(entries)), match entries {
"Couldn't restore", // Translators: {name} is a file or folder name.
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
_ => ngettext_f(
"Restored {n} item",
"Restored {n} items",
entries.len() as u64,
&[],
),
},
gettext_noop("Couldn't restore"),
); );
} }
/// “name” for one entry, “3 items” for several.
fn entries_label(entries: &[DirEntry]) -> String {
match entries {
[one] => format!("“{}”", one.name),
_ => count_noun(entries.len(), "item", "items"),
}
}
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks. /// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) { pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
let win = ui_window(ui); let win = ui_window(ui);
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect(); let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
let name = entries_label(entries); let count = entries.len() as u64;
let (body, done) = match entries {
[one] => {
let args = [("name", one.name.as_str())];
(
// Translators: {name} is a file or folder name.
gettext_f("Permanently delete “{name}”? This cannot be undone.", &args),
// Translators: {name} is a file or folder name.
gettext_f("Deleted “{name}” permanently", &args),
)
}
_ => (
ngettext_f(
"Permanently delete {n} item? This cannot be undone.",
"Permanently delete {n} items? This cannot be undone.",
count,
&[],
),
ngettext_f(
"Deleted {n} item permanently",
"Deleted {n} items permanently",
count,
&[],
),
),
};
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Delete Permanently") .heading(gettext("Delete Permanently"))
.body(format!("Permanently delete {name}? This cannot be undone.")) .body(body)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("delete", "Delete Permanently"); dialog.add_response("delete", &gettext("Delete Permanently"));
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -428,8 +466,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
run_mutation( run_mutation(
&ui, &ui,
Request::DeleteForever { uids: uids.clone() }, Request::DeleteForever { uids: uids.clone() },
format!("Deleted {name} permanently"), done.clone(),
"Couldn't delete", gettext_noop("Couldn't delete"),
); );
} }
}); });
@@ -441,14 +479,16 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let count = ui.trash.model.n_items(); let count = ui.trash.model.n_items();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Empty Trash") .heading(gettext("Empty Trash"))
.body(format!( .body(ngettext_f(
"Permanently delete all {} in the trash? This cannot be undone.", "Permanently delete all {n} item in the trash? This cannot be undone.",
count_noun(count as usize, "item", "items") "Permanently delete all {n} items in the trash? This cannot be undone.",
count as u64,
&[],
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("empty", "Empty Trash"); dialog.add_response("empty", &gettext("Empty Trash"));
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -459,8 +499,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
run_mutation( run_mutation(
&ui, &ui,
Request::EmptyTrash, Request::EmptyTrash,
"Trash emptied".to_string(), gettext("Trash emptied"),
"Couldn't empty the trash", gettext_noop("Couldn't empty the trash"),
); );
} }
}); });
+1 -1
View File
@@ -54,7 +54,7 @@ pub(crate) fn prompt_human_verification(
.build(); .build();
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Verification") .title(gettext("Verification"))
.content_width(420) .content_width(420)
.content_height(560) .content_height(560)
.build(); .build();
+71 -43
View File
@@ -50,12 +50,12 @@ pub(crate) struct DetailsWidgets {
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle = gtk4::ToggleButton::builder() let toggle = gtk4::ToggleButton::builder()
.icon_name("sidebar-show-right-symbolic") .icon_name("sidebar-show-right-symbolic")
.tooltip_text("Details (Alt+Enter)") .tooltip_text(gettext("Details (Alt+Enter)"))
.build(); .build();
let close_button = gtk4::Button::builder() let close_button = gtk4::Button::builder()
.icon_name("window-close-symbolic") .icon_name("window-close-symbolic")
.tooltip_text("Close details") .tooltip_text(gettext("Close details"))
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.build(); .build();
close_button.add_css_class("flat"); close_button.add_css_class("flat");
@@ -81,19 +81,21 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
head.append(&name); head.append(&name);
head.append(&kind); head.append(&kind);
let props = adw::PreferencesGroup::builder().title("Info").build(); let props = adw::PreferencesGroup::builder()
.title(gettext("Info"))
.build();
let size_row = adw::ActionRow::builder() let size_row = adw::ActionRow::builder()
.title("Size") .title(pgettext("column", "Size"))
.subtitle("—") .subtitle("—")
.build(); .build();
size_row.add_css_class("property"); size_row.add_css_class("property");
let modified_row = adw::ActionRow::builder() let modified_row = adw::ActionRow::builder()
.title("Modified") .title(pgettext("column", "Modified"))
.subtitle("—") .subtitle("—")
.build(); .build();
modified_row.add_css_class("property"); modified_row.add_css_class("property");
let path_row = adw::ActionRow::builder() let path_row = adw::ActionRow::builder()
.title("Location") .title(gettext("Location"))
.subtitle("—") .subtitle("—")
.subtitle_lines(3) .subtitle_lines(3)
.build(); .build();
@@ -102,22 +104,28 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
props.add(&modified_row); props.add(&modified_row);
props.add(&path_row); props.add(&path_row);
let offline = adw::PreferencesGroup::builder().title("Offline").build(); let offline = adw::PreferencesGroup::builder()
.title(pgettext("state", "Offline"))
.build();
let pin_row = adw::SwitchRow::builder() let pin_row = adw::SwitchRow::builder()
.title("Available offline") .title(gettext("Available offline"))
.subtitle("Keep a copy on this computer, even without a connection.") .subtitle(gettext(
"Keep a copy on this computer, even without a connection.",
))
.build(); .build();
offline.add(&pin_row); offline.add(&pin_row);
let sharing = adw::PreferencesGroup::builder().title("Sharing").build(); let sharing = adw::PreferencesGroup::builder()
.title(gettext("Sharing"))
.build();
let sharing_row = adw::ActionRow::builder() let sharing_row = adw::ActionRow::builder()
.title("Access") .title(gettext("Access"))
.subtitle("—") .subtitle("—")
.build(); .build();
sharing_row.add_css_class("property"); sharing_row.add_css_class("property");
sharing.add(&sharing_row); sharing.add(&sharing_row);
let share_button = gtk4::Button::builder().label("Share…").build(); let share_button = gtk4::Button::builder().label(gettext("Share…")).build();
let copy_link_button = gtk4::Button::builder().label("Copy Link").build(); let copy_link_button = gtk4::Button::builder().label(gettext("Copy Link")).build();
let share_actions = gtk4::Box::builder() let share_actions = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
.spacing(6) .spacing(6)
@@ -128,25 +136,31 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
share_actions.append(&copy_link_button); share_actions.append(&copy_link_button);
sharing.add(&share_actions); sharing.add(&share_actions);
let versions_group = adw::PreferencesGroup::builder().title("Versions").build(); let versions_group = adw::PreferencesGroup::builder()
.title(gettext("Versions"))
.build();
let versions_row = adw::ActionRow::builder() let versions_row = adw::ActionRow::builder()
.title("History") .title(gettext("History"))
.subtitle("—") .subtitle("—")
.build(); .build();
versions_row.add_css_class("property"); versions_row.add_css_class("property");
versions_group.add(&versions_row); versions_group.add(&versions_row);
let versions_button = gtk4::Button::builder() let versions_button = gtk4::Button::builder()
.label("Versions…") .label(gettext("Versions…"))
.margin_top(6) .margin_top(6)
.build(); .build();
versions_group.add(&versions_button); versions_group.add(&versions_button);
let open_button = gtk4::Button::builder().label("Open").build(); let open_button = gtk4::Button::builder()
.label(pgettext("verb", "Open"))
.build();
open_button.add_css_class("suggested-action"); open_button.add_css_class("suggested-action");
open_button.add_css_class("pill"); open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build(); let rename_button = gtk4::Button::builder().label(gettext("Rename")).build();
rename_button.add_css_class("pill"); rename_button.add_css_class("pill");
let trash_button = gtk4::Button::builder().label("Move to Trash").build(); let trash_button = gtk4::Button::builder()
.label(gettext("Move to Trash"))
.build();
trash_button.add_css_class("destructive-action"); trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill"); trash_button.add_css_class("pill");
@@ -178,7 +192,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
// itself would move the files under the pointer. // itself would move the files under the pointer.
let empty_close = gtk4::Button::builder() let empty_close = gtk4::Button::builder()
.icon_name("window-close-symbolic") .icon_name("window-close-symbolic")
.tooltip_text("Close details") .tooltip_text(gettext("Close details"))
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.margin_top(12) .margin_top(12)
.margin_end(12) .margin_end(12)
@@ -189,8 +203,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
empty_close.connect_clicked(move |_| toggle_off.set_active(false)); empty_close.connect_clicked(move |_| toggle_off.set_active(false));
let empty_status = adw::StatusPage::builder() let empty_status = adw::StatusPage::builder()
.icon_name("sidebar-show-right-symbolic") .icon_name("sidebar-show-right-symbolic")
.title("No item selected") .title(gettext("No item selected"))
.description("Select a file or folder to see its details.") .description(gettext("Select a file or folder to see its details."))
.vexpand(true) .vexpand(true)
.build(); .build();
empty_status.add_css_class("compact"); empty_status.add_css_class("compact");
@@ -347,8 +361,11 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
let d = &ui.details.details; let d = &ui.details.details;
d.icon.set_icon_name(Some(icon_base_for(entry))); d.icon.set_icon_name(Some(icon_base_for(entry)));
d.name.set_label(&entry.name); d.name.set_label(&entry.name);
d.kind d.kind.set_label(&if entry.is_dir {
.set_label(if entry.is_dir { "Folder" } else { "File" }); gettext("Folder")
} else {
gettext("File")
});
d.size_row.set_subtitle(&if entry.is_dir { d.size_row.set_subtitle(&if entry.is_dir {
"—".to_string() "—".to_string()
} else { } else {
@@ -362,18 +379,21 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
Some(i) => &rel[..i], Some(i) => &rel[..i],
None => "", None => "",
}; };
d.path_row.set_subtitle(if parent.is_empty() { d.path_row.set_subtitle(&if parent.is_empty() {
"Proton Drive" gettext("Proton Drive")
} else { } else {
parent parent.to_string()
}); });
// Pinning is per file: the context menu and the selection bar offer it for // Pinning is per file: the context menu and the selection bar offer it for
// files only, so the pane must not offer it for folders either. // files only, so the pane must not offer it for folders either.
d.pin_row.set_active(entry.pinned); d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir); d.pin_row.set_visible(!entry.is_dir);
d.pin_row.set_sensitive(*ui.mounted.borrow()); d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button d.open_button.set_label(&if entry.is_dir {
.set_label(if entry.is_dir { "Open folder" } else { "Open" }); gettext("Open folder")
} else {
pgettext("verb", "Open")
});
// Only files have revisions, and only a connected daemon can fetch them. // Only files have revisions, and only a connected daemon can fetch them.
let mounted = *ui.mounted.borrow(); let mounted = *ui.mounted.borrow();
d.versions_group.set_visible(!entry.is_dir); d.versions_group.set_visible(!entry.is_dir);
@@ -414,7 +434,7 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
Ok(Ok(Response::Share { entries, link })) => { Ok(Ok(Response::Share { entries, link })) => {
sharing_summary(entries.len(), link.is_some()) sharing_summary(entries.len(), link.is_some())
} }
_ => "Couldn't load sharing".to_string(), _ => gettext("Couldn't load sharing"),
}; };
if details_showing(&ui_c, &uid) { if details_showing(&ui_c, &uid) {
ui_c.details.details.sharing_row.set_subtitle(&text); ui_c.details.details.sharing_row.set_subtitle(&text);
@@ -436,7 +456,7 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let text = match rx.recv().await { let text = match rx.recv().await {
Ok(Ok(Response::Revisions { items })) => versions_summary(&items), Ok(Ok(Response::Revisions { items })) => versions_summary(&items),
_ => "Couldn't load versions".to_string(), _ => gettext("Couldn't load versions"),
}; };
if details_showing(&ui_c, &uid) { if details_showing(&ui_c, &uid) {
ui_c.details.details.versions_row.set_subtitle(&text); ui_c.details.details.versions_row.set_subtitle(&text);
@@ -454,16 +474,17 @@ fn details_showing(ui: &Rc<Ui>, uid: &str) -> bool {
/// "Only you", "3 people", "Public link" or both. /// "Only you", "3 people", "Public link" or both.
pub(crate) fn sharing_summary(people: usize, link: bool) -> String { pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
let people = match people { let n = people as u64;
0 => None,
1 => Some("1 person".to_string()),
n => Some(format!("{n} people")),
};
match (people, link) { match (people, link) {
(None, false) => "Only you".to_string(), (0, false) => gettext("Only you"),
(None, true) => "Anyone with the link".to_string(), (0, true) => gettext("Anyone with the link"),
(Some(p), false) => format!("Shared with {p}"), (_, false) => ngettext_f("Shared with {n} person", "Shared with {n} people", n, &[]),
(Some(p), true) => format!("Shared with {p} · Public link"), (_, true) => ngettext_f(
"Shared with {n} person · Public link",
"Shared with {n} people · Public link",
n,
&[],
),
} }
} }
@@ -471,9 +492,16 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String { pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
let latest = items.iter().map(|r| r.created).max(); let latest = items.iter().map(|r| r.created).max();
match (items.len(), latest) { match (items.len(), latest) {
(0, _) | (_, None) => "No earlier versions".to_string(), (0, _) | (_, None) => gettext("No earlier versions"),
(1, Some(t)) => format!("1 version · {}", format_modified(t)), // Translators: {date} is the date of the only version.
(n, Some(t)) => format!("{n} versions · latest {}", format_modified(t)), (1, Some(t)) => gettext_f("1 version · {date}", &[("date", &format_modified(t))]),
// Translators: {date} is the date of the newest version.
(n, Some(t)) => ngettext_f(
"{n} version · latest {date}",
"{n} versions · latest {date}",
n as u64,
&[("date", &format_modified(t))],
),
} }
} }
+2 -1
View File
@@ -120,7 +120,8 @@ impl ActionMenu {
let (menu, group) = self.finish(); let (menu, group) = self.finish();
let button = gtk4::MenuButton::builder() let button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic") .icon_name("view-more-symbolic")
.tooltip_text("More") // Translators: tooltip of a button that opens a menu of further actions.
.tooltip_text(gettext("More"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.menu_model(&menu) .menu_model(&menu)
.build(); .build();
+121 -69
View File
@@ -1,7 +1,19 @@
use crate::*; use crate::*;
/// The roles offered in the Share dialog's dropdowns, in index order. /// The role labels offered in the Share dialog's dropdowns, in index order.
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"]; pub(crate) fn share_roles() -> [String; 3] {
[
pgettext("role", "Viewer"),
pgettext("role", "Editor"),
pgettext("role", "Admin"),
]
}
/// A dropdown model from translated labels.
fn string_list(labels: &[String]) -> gtk4::StringList {
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
gtk4::StringList::new(&labels)
}
/// Map a role dropdown index to the wire role string. /// Map a role dropdown index to the wire role string.
pub(crate) fn role_index_to_wire(idx: u32) -> &'static str { pub(crate) fn role_index_to_wire(idx: u32) -> &'static str {
@@ -24,10 +36,11 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
/// The expiry choices offered when creating a public link, in dropdown order, /// The expiry choices offered when creating a public link, in dropdown order,
/// with their lifetime in days (`None` never expires). /// with their lifetime in days (`None` never expires).
pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [ pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [
("Never", None), // Translators: a public link that never expires.
("1 day", Some(1)), (gettext_noop("Never"), None),
("7 days", Some(7)), (gettext_noop("1 day"), Some(1)),
("30 days", Some(30)), (gettext_noop("7 days"), Some(7)),
(gettext_noop("30 days"), Some(30)),
]; ];
/// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`. /// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`.
@@ -235,7 +248,11 @@ pub(crate) struct ShareDialog {
/// access and their roles, and create/copy/remove a public link. /// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() { if !*ui.mounted.borrow() {
toast_error(ui, "Can't share", "Proton Drive isn't connected."); toast_error(
ui,
&gettext("Can't share"),
&gettext("Proton Drive isn't connected."),
);
return; return;
} }
// A node with no path is not a bug here: Shared and Shared-with-me list // A node with no path is not a bug here: Shared and Shared-with-me list
@@ -248,26 +265,35 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name))); header.set_title_widget(Some(&adw::WindowTitle::new(
&pgettext("verb", "Share"),
&entry.name,
)));
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
// Invite section: emails + role + optional message. // Invite section: emails + role + optional message.
let invite_group = adw::PreferencesGroup::builder() let invite_group = adw::PreferencesGroup::builder()
.title("Invite people") .title(gettext("Invite people"))
.description("Proton and non-Proton email addresses, separated by spaces or commas.") .description(gettext(
"Proton and non-Proton email addresses, separated by spaces or commas.",
))
.build(); .build();
let email_row = adw::EntryRow::builder().title("Email addresses").build(); let email_row = adw::EntryRow::builder()
let role_model = gtk4::StringList::new(&SHARE_ROLES); .title(gettext("Email addresses"))
.build();
let role_model = string_list(&share_roles());
let role_drop = gtk4::DropDown::builder() let role_drop = gtk4::DropDown::builder()
.model(&role_model) .model(&role_model)
.selected(0) .selected(0)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let role_wrap = adw::ActionRow::builder().title("Role").build(); let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
role_wrap.add_suffix(&role_drop); role_wrap.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder().title("Message (optional)").build(); let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)"))
.build();
let invite_btn = gtk4::Button::builder() let invite_btn = gtk4::Button::builder()
.label("Send Invitations") .label(gettext("Send Invitations"))
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.margin_top(6) .margin_top(6)
.build(); .build();
@@ -283,10 +309,10 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
invite_group.add(&invite_wrap); invite_group.add(&invite_wrap);
let people = adw::PreferencesGroup::builder() let people = adw::PreferencesGroup::builder()
.title("People with access") .title(gettext("People with access"))
.build(); .build();
let link_group = adw::PreferencesGroup::builder() let link_group = adw::PreferencesGroup::builder()
.title("Public link") .title(gettext("Public link"))
.build(); .build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
@@ -306,7 +332,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
toolbar.set_content(Some(&scroll)); toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Share") .title(pgettext("verb", "Share"))
.content_width(480) .content_width(480)
.content_height(560) .content_height(560)
.child(&toolbar) .child(&toolbar)
@@ -330,7 +356,11 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
match &parsed { match &parsed {
Err(bad) => { Err(bad) => {
row.add_css_class("error"); row.add_css_class("error");
row.set_tooltip_text(Some(&format!("“{bad}” isn't an email address"))); // Translators: {text} is what the user typed in place of an email address.
row.set_tooltip_text(Some(&gettext_f(
"“{text}” isn't an email address",
&[("text", bad)],
)));
} }
Ok(_) => { Ok(_) => {
row.remove_css_class("error"); row.remove_css_class("error");
@@ -367,8 +397,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
share_dialog_op( share_dialog_op(
&state_inv, &state_inv,
state_inv.target.invite(emails, role, message), state_inv.target.invite(emails, role, message),
"Invitations sent", &gettext("Invitations sent"),
"Couldn't send invitations", &gettext("Couldn't send invitations"),
Some(Busy::start(btn)), Some(Busy::start(btn)),
Some(Box::new(move || { Some(Box::new(move || {
email_clear.set_text(""); email_clear.set_text("");
@@ -392,12 +422,12 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
repaint_share_link(&state, link.as_ref()); repaint_share_link(&state, link.as_ref());
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load sharing", &message) toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
} }
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
"Couldn't load sharing", &gettext("Couldn't load sharing"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -410,7 +440,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
} }
let mut rows: Vec<gtk4::Widget> = Vec::new(); let mut rows: Vec<gtk4::Widget> = Vec::new();
if entries.is_empty() { if entries.is_empty() {
let row = dim_row("No one else has access yet."); let row = dim_row(&gettext("No one else has access yet."));
state.people.add(&row); state.people.add(&row);
rows.push(row.upcast()); rows.push(row.upcast());
*state.people_rows.borrow_mut() = rows; *state.people_rows.borrow_mut() = rows;
@@ -418,9 +448,9 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
} }
for entry in entries { for entry in entries {
let subtitle = match entry.kind { let subtitle = match entry.kind {
ShareEntryKind::Member => "Member".to_string(), ShareEntryKind::Member => gettext("Member"),
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(), ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(), ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
}; };
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&entry.email) .title(&entry.email)
@@ -433,7 +463,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
entry.kind, entry.kind,
ShareEntryKind::Member | ShareEntryKind::ProtonInvite ShareEntryKind::Member | ShareEntryKind::ProtonInvite
) { ) {
let model = gtk4::StringList::new(&SHARE_ROLES); let model = string_list(&share_roles());
let drop = gtk4::DropDown::builder() let drop = gtk4::DropDown::builder()
.model(&model) .model(&model)
.selected(role_wire_to_index(&entry.role)) .selected(role_wire_to_index(&entry.role))
@@ -450,8 +480,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
kind, kind,
role_index_to_wire(d.selected()).to_string(), role_index_to_wire(d.selected()).to_string(),
), ),
"Role updated", &gettext("Role updated"),
"Couldn't update the role", &gettext("Couldn't update the role"),
None, None,
None, None,
); );
@@ -459,7 +489,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
row.add_suffix(&drop); row.add_suffix(&drop);
} else { } else {
let label = gtk4::Label::builder() let label = gtk4::Label::builder()
.label(capitalize(&entry.role)) .label(role_label(&entry.role).unwrap_or_else(|| capitalize(&entry.role)))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
label.add_css_class("dim-label"); label.add_css_class("dim-label");
@@ -468,7 +498,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("user-trash-symbolic")
.tooltip_text("Remove access") .tooltip_text(gettext("Remove access"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
@@ -482,15 +512,19 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let button = btn.clone(); let button = btn.clone();
confirm_destructive( confirm_destructive(
btn, btn,
"Remove Access?", &gettext("Remove Access?"),
&format!("{who} will no longer be able to open this item."), // Translators: {email} is the address of the person losing access.
"Remove", &gettext_f(
"{email} will no longer be able to open this item.",
&[("email", &who)],
),
&gettext("Remove"),
move || { move || {
share_dialog_op( share_dialog_op(
&state, &state,
state.target.remove_entry(id.clone(), kind), state.target.remove_entry(id.clone(), kind),
"Access removed", &gettext("Access removed"),
"Couldn't remove access", &gettext("Couldn't remove access"),
Some(Busy::start(&button)), Some(Busy::start(&button)),
None, None,
); );
@@ -516,16 +550,23 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
match link { match link {
Some(link) => { Some(link) => {
let url = link.url.clone().unwrap_or_default(); let url = link.url.clone().unwrap_or_default();
let mut subtitle = format!("Anyone with the link ({})", capitalize(&link.role)); let role = role_label(&link.role).unwrap_or_else(|| capitalize(&link.role));
// Translators: {role} is the link's role, such as "Viewer".
let mut subtitle = gettext_f("Anyone with the link ({role})", &[("role", &role)]);
if link.has_password { if link.has_password {
subtitle.push_str(" · password-protected"); subtitle.push_str(" · ");
subtitle.push_str(&gettext("password-protected"));
} }
if let Some(expires) = link.expires { if let Some(expires) = link.expires {
subtitle.push_str(" · "); subtitle.push_str(" · ");
subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000)); subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000));
} }
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url }) .title(if url.is_empty() {
gettext("Public link")
} else {
url.clone()
})
.subtitle(&subtitle) .subtitle(&subtitle)
.build(); .build();
row.add_css_class("property"); row.add_css_class("property");
@@ -533,7 +574,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
if !url.is_empty() { if !url.is_empty() {
let copy = gtk4::Button::builder() let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic") .icon_name("edit-copy-symbolic")
.tooltip_text("Copy link") .tooltip_text(gettext("Copy link"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
copy.add_css_class("flat"); copy.add_css_class("flat");
@@ -541,14 +582,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let url_copy = url.clone(); let url_copy = url.clone();
copy.connect_clicked(move |btn| { copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy); btn.clipboard().set_text(&url_copy);
toast(&state_copy.ui, "Link copied"); toast(&state_copy.ui, &gettext("Link copied"));
}); });
row.add_suffix(&copy); row.add_suffix(&copy);
} }
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("user-trash-symbolic")
.tooltip_text("Remove link") .tooltip_text(gettext("Remove link"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
@@ -560,16 +601,15 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let button = btn.clone(); let button = btn.clone();
confirm_destructive( confirm_destructive(
btn, btn,
"Remove Public Link?", &gettext("Remove Public Link?"),
"The link stops working for everyone who has it. A new link will have \ &gettext("The link stops working for everyone who has it. A new link will have a different address."),
a different address.", &gettext("Remove Link"),
"Remove Link",
move || { move || {
share_dialog_op( share_dialog_op(
&state, &state,
state.target.remove_link(id.clone()), state.target.remove_link(id.clone()),
"Public link removed", &gettext("Public link removed"),
"Couldn't remove the link", &gettext("Couldn't remove the link"),
Some(Busy::start(&button)), Some(Busy::start(&button)),
None, None,
); );
@@ -582,27 +622,30 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
rows.push(row.upcast()); rows.push(row.upcast());
} }
None => { None => {
let role_model = gtk4::StringList::new(&["Viewer", "Editor"]); let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
let role_drop = gtk4::DropDown::builder() let role_drop = gtk4::DropDown::builder()
.model(&role_model) .model(&role_model)
.selected(0) .selected(0)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let role_row = adw::ActionRow::builder().title("Link role").build(); let role_row = adw::ActionRow::builder()
.title(gettext("Link role"))
.build();
role_row.add_suffix(&role_drop); role_row.add_suffix(&role_drop);
let pw_row = adw::PasswordEntryRow::builder() let pw_row = adw::PasswordEntryRow::builder()
.title("Password (optional)") .title(gettext("Password (optional)"))
.build(); .build();
let expiry_names: Vec<&str> = LINK_EXPIRY.iter().map(|(name, _)| *name).collect(); let expiry_names: Vec<String> =
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
let expiry_drop = gtk4::DropDown::builder() let expiry_drop = gtk4::DropDown::builder()
.model(&gtk4::StringList::new(&expiry_names)) .model(&string_list(&expiry_names))
.selected(0) .selected(0)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let expiry_row = adw::ActionRow::builder().title("Expires").build(); let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop); expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder() let create = gtk4::Button::builder()
.label("Create Public Link") .label(gettext("Create Public Link"))
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.margin_top(6) .margin_top(6)
.build(); .build();
@@ -666,16 +709,16 @@ pub(crate) fn share_dialog_create_link(
drop(busy); drop(busy);
match reply { match reply {
Ok(Ok(Response::PublicLink { .. })) => { Ok(Ok(Response::PublicLink { .. })) => {
toast(&state.ui, "Public link created"); toast(&state.ui, &gettext("Public link created"));
share_dialog_reload(&state); share_dialog_reload(&state);
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't create the link", &message) toast_error(&state.ui, &gettext("Couldn't create the link"), &message)
} }
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
"Couldn't create the link", &gettext("Couldn't create the link"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -687,13 +730,14 @@ pub(crate) fn share_dialog_create_link(
pub(crate) fn share_dialog_op( pub(crate) fn share_dialog_op(
state: &Rc<ShareDialog>, state: &Rc<ShareDialog>,
req: Request, req: Request,
done: &'static str, done: &str,
failed: &'static str, failed: &str,
busy: Option<Busy>, busy: Option<Busy>,
on_success: Option<Box<dyn Fn()>>, on_success: Option<Box<dyn Fn()>>,
) { ) {
let rx = spawn_request(state.ui.dirs.control_socket(), req); let rx = spawn_request(state.ui.dirs.control_socket(), req);
let state = state.clone(); let state = state.clone();
let (done, failed) = (done.to_string(), failed.to_string());
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let reply = rx.recv().await; let reply = rx.recv().await;
drop(busy); drop(busy);
@@ -702,14 +746,18 @@ pub(crate) fn share_dialog_op(
if let Some(cb) = on_success { if let Some(cb) = on_success {
cb(); cb();
} }
toast(&state.ui, done); toast(&state.ui, &done);
share_dialog_reload(&state); share_dialog_reload(&state);
} }
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message), Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
_ => { _ => {
// A role dropdown that failed is now out of sync with the server; // A role dropdown that failed is now out of sync with the server;
// reload to snap it back. // reload to snap it back.
toast_error(&state.ui, failed, "The mount service didn't respond."); toast_error(
&state.ui,
&failed,
&gettext("The mount service didn't respond."),
);
share_dialog_reload(&state); share_dialog_reload(&state);
} }
} }
@@ -720,14 +768,18 @@ pub(crate) fn share_dialog_op(
/// working, or that it already has. /// working, or that it already has.
pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String { pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String {
if expires <= now { if expires <= now {
return "expired".to_string(); // Translators: a public link past its expiry date.
return gettext("expired");
} }
// Translators: strftime format for a link's expiry date; "%x" is the locale's date, such as "09/23/26".
let format = gettext("%x");
let date = glib::DateTime::from_unix_local(expires) let date = glib::DateTime::from_unix_local(expires)
.ok() .ok()
.and_then(|at| at.format("%x").ok()) .and_then(|at| at.format(&format).ok())
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_default(); .unwrap_or_default();
format!("expires {date}") // Translators: {date} is the date a public link stops working.
gettext_f("expires {date}", &[("date", &date)])
} }
#[cfg(test)] #[cfg(test)]
@@ -85,11 +85,19 @@ pub(crate) struct VersionsDialog {
/// Open the Versions dialog for a file. /// Open the Versions dialog for a file.
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() { if !*ui.mounted.borrow() {
toast_error(ui, "Can't load versions", "Proton Drive isn't connected."); toast_error(
ui,
&gettext("Can't load versions"),
&gettext("Proton Drive isn't connected."),
);
return; return;
} }
if entry.is_dir { if entry.is_dir {
toast_error(ui, "No versions", "Folders don't have versions."); toast_error(
ui,
&gettext("No versions"),
&gettext("Folders don't have versions."),
);
return; return;
} }
let target = if entry.path.is_empty() { let target = if entry.path.is_empty() {
@@ -100,12 +108,17 @@ pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name))); header.set_title_widget(Some(&adw::WindowTitle::new(
&gettext("Versions"),
&entry.name,
)));
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Version history") .title(gettext("Version history"))
.description("Proton Drive keeps earlier versions of a file until you delete them.") .description(gettext(
"Proton Drive keeps earlier versions of a file until you delete them.",
))
.build(); .build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
@@ -123,7 +136,7 @@ pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
toolbar.set_content(Some(&scroll)); toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Versions") .title(gettext("Versions"))
.content_width(520) .content_width(520)
.content_height(520) .content_height(520)
.child(&toolbar) .child(&toolbar)
@@ -149,12 +162,12 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items), Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load versions", &message) toast_error(&state.ui, &gettext("Couldn't load versions"), &message)
} }
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
"Couldn't load versions", &gettext("Couldn't load versions"),
"The mount service didn't respond.", &gettext("The mount service didn't respond."),
), ),
} }
}); });
@@ -167,7 +180,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
} }
let mut rows: Vec<gtk4::Widget> = Vec::new(); let mut rows: Vec<gtk4::Widget> = Vec::new();
if items.is_empty() { if items.is_empty() {
let row = dim_row("No earlier versions."); let row = dim_row(&gettext("No earlier versions."));
state.group.add(&row); state.group.add(&row);
rows.push(row.upcast()); rows.push(row.upcast());
*state.rows.borrow_mut() = rows; *state.rows.borrow_mut() = rows;
@@ -186,8 +199,15 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
// when they were saved instead. // when they were saved instead.
let (title, mut subtitle) = if item.is_active { let (title, mut subtitle) = if item.is_active {
( (
"Current version".to_string(), gettext("Current version"),
format!("{} · {}", activity_time(item.created), human_bytes(size)), // Translators: {time} is when the version was saved, {size} its file size.
gettext_f(
"{time} · {size}",
&[
("time", &activity_time(item.created)),
("size", &human_bytes(size)),
],
),
) )
} else { } else {
(activity_time(item.created), human_bytes(size)) (activity_time(item.created), human_bytes(size))
@@ -203,7 +223,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
let save = gtk4::Button::builder() let save = gtk4::Button::builder()
.icon_name("document-save-symbolic") .icon_name("document-save-symbolic")
.tooltip_text("Save a copy…") .tooltip_text(gettext("Save a copy…"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
save.add_css_class("flat"); save.add_css_class("flat");
@@ -215,7 +235,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
if !item.is_active { if !item.is_active {
let restore = gtk4::Button::builder() let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic") .icon_name("edit-undo-symbolic")
.tooltip_text("Restore this version") .tooltip_text(gettext("Restore this version"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
restore.add_css_class("flat"); restore.add_css_class("flat");
@@ -226,7 +246,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
let delete = gtk4::Button::builder() let delete = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("user-trash-symbolic")
.tooltip_text("Delete this version permanently") .tooltip_text(gettext("Delete this version permanently"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
delete.add_css_class("flat"); delete.add_css_class("flat");
@@ -246,14 +266,15 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
/// every device on the account, not only this one. /// every device on the account, not only this one.
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) { fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Restore version") .heading(gettext("Restore version"))
.body(format!( // Translators: {name} is a file name.
"Make this version the current content of “{}”? The version it replaces stays in the history.", .body(gettext_f(
state.name "Make this version the current content of “{name}”? The version it replaces stays in the history.",
&[("name", &state.name)],
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("restore", "Restore"); dialog.add_response("restore", &pgettext("verb", "Restore"));
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -270,8 +291,8 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
state.target.restore(revision_id.clone()), state.target.restore(revision_id.clone()),
// The server applies the swap in the background, so the wording // The server applies the swap in the background, so the wording
// promises the request, not the result. // promises the request, not the result.
"Restoring that version", &gettext("Restoring that version"),
"Couldn't restore that version", &gettext("Couldn't restore that version"),
); );
}); });
dialog.present(window.as_ref()); dialog.present(window.as_ref());
@@ -280,11 +301,13 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
/// Confirm, then permanently delete one revision. /// Confirm, then permanently delete one revision.
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) { fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Delete version") .heading(gettext("Delete version"))
.body("Delete this version permanently? Its content can't be recovered.") .body(gettext(
"Delete this version permanently? Its content can't be recovered.",
))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("delete", "Delete"); dialog.add_response("delete", &pgettext("verb", "Delete"));
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -299,8 +322,8 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
versions_dialog_op( versions_dialog_op(
&state, &state,
state.target.delete(revision_id.clone()), state.target.delete(revision_id.clone()),
"Version deleted", &gettext("Version deleted"),
"Couldn't delete that version", &gettext("Couldn't delete that version"),
); );
}); });
dialog.present(window.as_ref()); dialog.present(window.as_ref());
@@ -314,7 +337,7 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) { fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let window = ui_window(&state.ui); let window = ui_window(&state.ui);
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
.title("Save version") .title(gettext("Save version"))
.initial_name(&state.name) .initial_name(&state.name)
.build(); .build();
let state = state.clone(); let state = state.clone();
@@ -324,16 +347,16 @@ fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else { let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
toast_error( toast_error(
&state.ui, &state.ui,
"Couldn't save that version", &gettext("Couldn't save that version"),
"That location isn't a local file.", &gettext("That location isn't a local file."),
); );
return; return;
}; };
versions_dialog_op( versions_dialog_op(
&state, &state,
state.target.save_as(revision_id.clone(), dest), state.target.save_as(revision_id.clone(), dest),
"Version saved", &gettext("Version saved"),
"Couldn't save that version", &gettext("Couldn't save that version"),
); );
}); });
} }
@@ -351,7 +374,11 @@ fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str,
versions_dialog_reload(&state); versions_dialog_reload(&state);
} }
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message), Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."), _ => toast_error(
&state.ui,
&failed,
&gettext("The mount service didn't respond."),
),
} }
}); });
} }
+9 -5
View File
@@ -16,8 +16,8 @@ use pdfs_core::config::AppDirs;
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig}; use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
use pdfs_core::opener::OpenWith; use pdfs_core::opener::OpenWith;
use crate::Hit;
use crate::query; use crate::query;
use crate::{Hit, gettext, gettext_f};
/// Guard against a launcher that keeps handing back text we cannot satisfy — /// Guard against a launcher that keeps handing back text we cannot satisfy —
/// without it a scripted (non-interactive) menu could spin forever. /// without it a scripted (non-interactive) menu could spin forever.
@@ -90,8 +90,10 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
/// shows which round you are in. /// shows which round you are in.
fn title(query: Option<&str>) -> String { fn title(query: Option<&str>) -> String {
match query { match query {
Some(text) => format!("Drive: {text} › "), // Translators: the launcher prompt while refining a search; {query} is the text searched for. Keep the trailing space.
None => "Search Drive › ".to_string(), Some(text) => gettext_f("Drive: {query} › ", &[("query", text)]),
// Translators: the launcher prompt; keep the trailing space, the input follows it directly.
None => gettext("Search Drive › "),
} }
} }
@@ -107,8 +109,10 @@ fn title(query: Option<&str>) -> String {
/// wording is parenthesised and avoids words that read like a filename query. /// wording is parenthesised and avoids words that read like a filename query.
fn placeholder(query: Option<&str>) -> String { fn placeholder(query: Option<&str>) -> String {
match query { match query {
Some(text) => format!("(no matches for “{text}”)"), // Translators: the only row of an empty launcher list; {query} is the text searched for. Keep the parentheses.
None => "(no pinned files — type to search)".to_string(), Some(text) => gettext_f("(no matches for “{query}”)", &[("query", text)]),
// Translators: the only row of an empty launcher list. Keep the parentheses, and avoid words that look like a file name.
None => gettext("(no pinned files — type to search)"),
} }
} }
+6 -4
View File
@@ -32,13 +32,14 @@ use pdfs_core::menu::{self, PromptConfig};
use pdfs_core::opener::OpenWith; use pdfs_core::opener::OpenWith;
use crate::query; use crate::query;
use crate::{Hit, SEARCH_DEBOUNCE}; use crate::{Hit, SEARCH_DEBOUNCE, gettext, gettext_noop};
/// Separates the visible half of a feed line from its payload. /// Separates the visible half of a feed line from its payload.
const FIELD: char = '\t'; const FIELD: char = '\t';
/// What the launcher prompt reads, matching the dmenu front end's wording. /// What the launcher prompt reads, matching the dmenu front end's wording.
const PROMPT: &str = "Search Drive › "; // Translators: the launcher prompt; keep the trailing space, the input follows it directly.
const PROMPT: &str = gettext_noop("Search Drive › ");
pub(crate) struct Options { pub(crate) struct Options {
/// Search for this immediately instead of opening on the pinned files. /// Search for this immediately instead of opening on the pinned files.
@@ -166,7 +167,8 @@ fn interactive(query: Option<String>) -> Result<(), String> {
/// empty list with no explanation, so a failure becomes a visible row instead. /// empty list with no explanation, so a failure becomes a visible row instead.
pub(crate) fn feed(query: Option<&str>) { pub(crate) fn feed(query: Option<&str>) {
let Ok(dirs) = AppDirs::new() else { let Ok(dirs) = AppDirs::new() else {
println!("(cannot resolve app dirs)"); // Translators: shown as a row in the search list. Keep the parentheses.
println!("{}", gettext("(cannot resolve app dirs)"));
return; return;
}; };
let limit = dirs.load_config().resolved_prompt().resolved_menu_limit(); let limit = dirs.load_config().resolved_prompt().resolved_menu_limit();
@@ -219,7 +221,7 @@ fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec<String> {
.iter() .iter()
.map(|arg| (*arg).to_string()) .map(|arg| (*arg).to_string())
.collect(); .collect();
argv.push(format!("--prompt={PROMPT}")); argv.push(format!("--prompt={}", gettext(PROMPT)));
// `reload:` rather than `reload(…)`: the unparenthesised form runs to the // `reload:` rather than `reload(…)`: the unparenthesised form runs to the
// end of the argument, so an install path containing a bracket cannot // end of the argument, so an install path containing a bracket cannot
// truncate the command fzf parses out of it. // truncate the command fzf parses out of it.
+359
View File
@@ -0,0 +1,359 @@
//! Translation layer shared by `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
//!
//! Strings go through GNU gettext in the `pdfs` text domain. [`init`] runs first
//! in every binary: it applies the language picked in Preferences (if any) on
//! top of the system locale, then binds the domain to the installed catalogs.
//!
//! Messages with values use named placeholders (`{name}`) filled in by
//! [`gettext_f`] and [`ngettext_f`], so translators can reorder them. `po/`
//! holds the template and the catalogs; see `docs/TRANSLATING.md`.
// Each binary includes this module and uses a different part of it.
#![allow(dead_code)]
use gettextrs::LocaleCategory;
use pdfs_core::config::AppDirs;
/// The gettext text domain, matching `po/pdfs.pot` and the installed `.mo` name.
pub(crate) const DOMAIN: &str = "pdfs";
/// Overrides the catalog directory at run time, for running from a source tree
/// (`PDFS_LOCALEDIR=target/locale`).
const LOCALEDIR_ENV: &str = "PDFS_LOCALEDIR";
/// Where packages install the catalogs unless the build sets `PDFS_LOCALEDIR`.
const DEFAULT_LOCALEDIR: &str = "/usr/share/locale";
/// Languages the app ships a catalog for, as (gettext code, native name). The
/// Preferences picker lists them after "System Default". US English is the
/// source language and needs no catalog. Names stay untranslated so a user can
/// always find their own language.
pub(crate) const LANGUAGES: &[(&str, &str)] = &[
("de", "Deutsch"),
("en_GB", "English (UK)"),
("en_US", "English (US)"),
("es", "Español"),
("fr", "Français"),
("it", "Italiano"),
("nl", "Nederlands"),
("pl", "Polski"),
("pt_BR", "Português (Brasil)"),
];
/// Set up the locale and the text domain. Call first thing in `main`, before
/// any thread starts: an explicit language is applied through the process
/// environment, so that GTK's own strings follow it too.
pub(crate) fn init() {
let language = AppDirs::new()
.ok()
.and_then(|dirs| dirs.load_config().language)
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
gettextrs::setlocale(LocaleCategory::LcAll, "");
if let Some(code) = language {
apply_language(&code);
}
let dir = std::env::var(LOCALEDIR_ENV)
.ok()
.filter(|dir| !dir.is_empty())
.unwrap_or_else(|| {
option_env!("PDFS_LOCALEDIR")
.unwrap_or(DEFAULT_LOCALEDIR)
.to_string()
});
if let Err(e) = gettextrs::bindtextdomain(DOMAIN, dir) {
tracing::warn!("bindtextdomain failed: {e}");
}
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
if let Err(e) = gettextrs::textdomain(DOMAIN) {
tracing::warn!("textdomain failed: {e}");
}
}
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
/// a configured locale also gets its message locale moved to a real one. Both
/// go into the environment, because GTK calls `setlocale` again during its own
/// start.
fn apply_language(code: &str) {
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
unsafe { std::env::set_var("LANGUAGE", code) };
let current = gettextrs::setlocale(LocaleCategory::LcMessages, "")
.map(|name| String::from_utf8_lossy(&name).into_owned())
.unwrap_or_default();
if !is_c_locale(&current) {
return;
}
for candidate in message_locales(code) {
if gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()).is_some() {
// SAFETY: as above, still single-threaded.
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
return;
}
}
}
fn is_c_locale(name: &str) -> bool {
let base = name.split('.').next().unwrap_or(name);
matches!(base, "" | "C" | "POSIX")
}
/// Locales to try, in order, as the message locale for `code`: the language's
/// own locale, then US English, which any locale-enabled system has and which
/// is enough for gettext to honour `LANGUAGE`.
fn message_locales(code: &str) -> Vec<String> {
let full = if code.contains('_') {
code.to_string()
} else {
format!("{code}_{}", code.to_uppercase())
};
let mut locales = vec![format!("{full}.UTF-8")];
if full != "en_US" {
locales.push("en_US.UTF-8".to_string());
}
locales
}
/// Mark `msgid` for extraction without translating it yet, for tables of
/// labels built at compile time. Pass the value through [`gettext`] where it is
/// shown.
pub(crate) const fn gettext_noop(msgid: &'static str) -> &'static str {
msgid
}
/// Translate `msgid`.
pub(crate) fn gettext(msgid: &str) -> String {
gettextrs::gettext(msgid)
}
/// Translate `msgid` in context `context`, for short strings whose meaning
/// depends on where they appear ("Open" the verb against "Open" the state).
pub(crate) fn pgettext(context: &str, msgid: &str) -> String {
gettextrs::pgettext(context, msgid)
}
/// Translate the singular or plural form for `n`.
pub(crate) fn ngettext(singular: &str, plural: &str, n: u64) -> String {
gettextrs::ngettext(singular, plural, plural_count(n))
}
/// Translate `msgid`, then fill its `{name}` placeholders from `args`.
pub(crate) fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String {
fill(&gettext(msgid), args)
}
/// Translate the form for `n`, then fill `{n}` with the count and the other
/// placeholders from `args`.
pub(crate) fn ngettext_f(singular: &str, plural: &str, n: u64, args: &[(&str, &str)]) -> String {
let count = n.to_string();
let filled = fill(&ngettext(singular, plural, n), args);
fill(&filled, &[("n", &count)])
}
/// The translated form of [`pdfs_core::control::pending_summary`]: what is
/// still waiting to go up, or `None` when nothing is.
pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
match (uploads, changes) {
(0, 0) => None,
(uploads, 0) => Some(ngettext_f(
"{n} upload queued",
"{n} uploads queued",
uploads,
&[],
)),
(0, changes) => Some(ngettext_f(
"{n} change queued",
"{n} changes queued",
changes,
&[],
)),
(uploads, changes) => {
let uploads = ngettext_f("{n} upload", "{n} uploads", uploads, &[]);
let changes = ngettext_f("{n} change", "{n} changes", changes, &[]);
// Translators: {uploads} is "3 uploads", {changes} is "2 changes".
Some(gettext_f(
"{uploads}, {changes} queued",
&[("uploads", &uploads), ("changes", &changes)],
))
}
}
}
/// gettext takes the count as `u32`. Counts past that pick the same form as
/// the largest one, which is the plural in every shipped language.
fn plural_count(n: u64) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
/// Replace each `{key}` in `template` with its value. Unknown placeholders are
/// left as they are, so a translation mistake shows up rather than vanishing.
fn fill(template: &str, args: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (key, value) in args {
out = out.replace(&format!("{{{key}}}"), value);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fill_replaces_named_placeholders_in_any_order() {
assert_eq!(
fill("{b} before {a}", &[("a", "x"), ("b", "y")]),
"y before x"
);
}
#[test]
fn fill_keeps_unknown_placeholders() {
assert_eq!(fill("{missing} {a}", &[("a", "1")]), "{missing} 1");
}
#[test]
fn ngettext_f_fills_the_count() {
assert_eq!(ngettext_f("{n} file", "{n} files", 1, &[]), "1 file");
assert_eq!(
ngettext_f(
"{n} file in {dir}",
"{n} files in {dir}",
3,
&[("dir", "A")]
),
"3 files in A"
);
}
#[test]
fn pending_summary_matches_the_core_wording() {
for (uploads, changes) in [(0, 0), (1, 0), (3, 0), (0, 1), (2, 4)] {
assert_eq!(
pending_summary(uploads, changes),
pdfs_core::control::pending_summary(uploads, changes)
);
}
}
#[test]
fn plural_count_saturates() {
assert_eq!(plural_count(7), 7);
assert_eq!(plural_count(u64::MAX), u32::MAX);
}
#[test]
fn language_codes_are_unique_and_sorted() {
let codes: Vec<&str> = LANGUAGES.iter().map(|(code, _)| *code).collect();
let mut sorted = codes.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(codes, sorted);
}
fn repo_file(path: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(path)
}
fn linguas() -> Vec<String> {
std::fs::read_to_string(repo_file("po/LINGUAS"))
.unwrap()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect()
}
#[test]
fn every_offered_language_has_a_catalog() {
let linguas = linguas();
for (code, _) in LANGUAGES {
// US English is the source language.
if *code != "en_US" {
assert!(
linguas.iter().any(|lang| lang == code),
"{code} is not in po/LINGUAS"
);
}
}
for lang in &linguas {
assert!(
repo_file(&format!("po/{lang}.po")).exists(),
"po/{lang}.po is missing"
);
}
}
#[test]
fn potfiles_lists_every_source_that_translates() {
let listed = std::fs::read_to_string(repo_file("po/POTFILES.in")).unwrap();
let listed: Vec<&str> = listed.lines().map(str::trim).collect();
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut stack = vec![src];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|ext| ext != "rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
if !text.contains("gettext") {
continue;
}
let relative = path.strip_prefix(env!("CARGO_MANIFEST_DIR")).unwrap();
let name = format!("crates/pdfs-gui/{}", relative.display());
assert!(
listed.contains(&name.as_str()),
"{name} is not in po/POTFILES.in"
);
}
}
}
/// `msgfmt --check` rejects a translation whose `{placeholders}` differ from
/// the English, besides syntax errors. Skipped where gettext is not installed.
#[test]
fn catalogs_compile_with_matching_placeholders() {
let msgfmt = std::process::Command::new("msgfmt")
.arg("--version")
.output();
if msgfmt.is_err() {
eprintln!("msgfmt not installed; skipping the catalog check");
return;
}
for lang in linguas() {
let output = std::process::Command::new("msgfmt")
.args(["--check", "--output-file=/dev/null"])
.arg(repo_file(&format!("po/{lang}.po")))
.output()
.unwrap();
assert!(
output.status.success(),
"po/{lang}.po: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn c_locales_are_detected() {
assert!(is_c_locale("C"));
assert!(is_c_locale("POSIX"));
assert!(is_c_locale("C.UTF-8"));
assert!(is_c_locale("C.utf8"));
assert!(!is_c_locale("de_DE.UTF-8"));
}
#[test]
fn message_locales_start_with_the_language_region() {
assert_eq!(message_locales("de"), ["de_DE.UTF-8", "en_US.UTF-8"]);
assert_eq!(message_locales("pt_BR"), ["pt_BR.UTF-8", "en_US.UTF-8"]);
assert_eq!(message_locales("en_US"), ["en_US.UTF-8"]);
}
}
+75 -33
View File
@@ -21,11 +21,12 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Status, ToolTip, Tray}; use ksni::{MenuItem, Status, ToolTip, Tray};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
};
use pdfs_core::service; use pdfs_core::service;
mod i18n;
use i18n::{gettext, gettext_f, ngettext_f};
/// How often the tray re-polls the daemon to refresh its menu. /// How often the tray re-polls the daemon to refresh its menu.
const POLL_INTERVAL: Duration = Duration::from_secs(3); const POLL_INTERVAL: Duration = Duration::from_secs(3);
@@ -89,7 +90,14 @@ struct DriveState {
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String { fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items { match items {
[] => match jobs.first() { [] => match jobs.first() {
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total), Some(j) if j.total > 0 => {
let (done, total) = (j.done.to_string(), j.total.to_string());
// Translators: {title} names a running job such as a scan; {done} and {total} are counts.
gettext_f(
"{title} ({done} of {total})",
&[("title", &j.title), ("done", &done), ("total", &total)],
)
}
Some(j) => format!("{}…", j.title), Some(j) => format!("{}…", j.title),
None => String::new(), None => String::new(),
}, },
@@ -112,8 +120,10 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
.count(); .count();
let up = items.len() - down; let up = items.len() - down;
match (down, up) { match (down, up) {
(d, 0) => format!("↓ {d} downloading"), // Translators: {n} files are downloading; keep the arrow.
(0, u) => format!("↑ {u} uploading"), (d, 0) => ngettext_f("↓ {n} downloading", "↓ {n} downloading", d as u64, &[]),
// Translators: {n} files are uploading; keep the arrow.
(0, u) => ngettext_f("↑ {n} uploading", "↑ {n} uploading", u as u64, &[]),
(d, u) => format!("↓ {d} · ↑ {u}"), (d, u) => format!("↓ {d} · ↑ {u}"),
} }
} }
@@ -144,7 +154,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(), _ => String::new(),
}; };
let queued = pending_summary(pending_uploads, pending_changes); let queued = i18n::pending_summary(pending_uploads, pending_changes);
let phase = phase_of( let phase = phase_of(
paused, paused,
failing_ops, failing_ops,
@@ -153,15 +163,36 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
); );
DriveState { DriveState {
line: match (online, queued) { line: match (online, queued) {
_ if paused => "Sync paused".to_string(), _ if paused => gettext("Sync paused"),
_ if failing_ops > 0 => match failing_ops { _ if failing_ops > 0 => ngettext_f(
1 => "1 change needs attention".to_string(), "{n} change needs attention",
n => format!("{n} changes need attention"), "{n} changes need attention",
}, failing_ops,
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"), &[],
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"), ),
(false, None) => format!("Offline — cached files only ({pinned} pinned)"), // Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
(false, Some(q)) => format!("Offline — {q}"), (true, None) => ngettext_f(
"Mounted at {mountpoint} ({n} pinned)",
"Mounted at {mountpoint} ({n} pinned)",
pinned as u64,
&[("mountpoint", &mountpoint)],
),
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
(true, Some(q)) => ngettext_f(
"Syncing — {queued} ({n} pinned)",
"Syncing — {queued} ({n} pinned)",
pinned as u64,
&[("queued", &q)],
),
// Translators: {n} is the number of pinned items.
(false, None) => ngettext_f(
"Offline — cached files only ({n} pinned)",
"Offline — cached files only ({n} pinned)",
pinned as u64,
&[],
),
// Translators: {queued} is a queue summary such as "3 uploads queued".
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
}, },
phase, phase,
mounted: true, mounted: true,
@@ -173,7 +204,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
} }
// Socket answered but with something unexpected — treat as up but odd. // Socket answered but with something unexpected — treat as up but odd.
Ok(_) => DriveState { Ok(_) => DriveState {
line: "Mount: unexpected daemon response".into(), line: gettext("Mount: unexpected daemon response"),
phase: Phase::Attention, phase: Phase::Attention,
mounted: true, mounted: true,
mountpoint: default_mountpoint.to_path_buf(), mountpoint: default_mountpoint.to_path_buf(),
@@ -185,11 +216,19 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
Err(_) => { Err(_) => {
let (line, phase) = match auth::load() { let (line, phase) = match auth::load() {
Ok(s) => ( Ok(s) => (
format!("Logged in as {} — not mounted", s.username), // Translators: {username} is the Proton account name.
gettext_f(
"Logged in as {username} — not mounted",
&[("username", &s.username)],
),
Phase::Disconnected, Phase::Disconnected,
), ),
Err(pdfs_core::Error::NotLoggedIn) => ("Not signed in".into(), Phase::SignedOut), Err(pdfs_core::Error::NotLoggedIn) => (gettext("Not signed in"), Phase::SignedOut),
Err(e) => (format!("Error: {e}"), Phase::Attention), Err(e) => (
// Translators: {error} is an error message, in English.
gettext_f("Error: {error}", &[("error", &e.to_string())]),
Phase::Attention,
),
}; };
DriveState { DriveState {
line, line,
@@ -222,10 +261,8 @@ fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
/// "3 issues — View": the menu item that opens the Sync page's queue. /// "3 issues — View": the menu item that opens the Sync page's queue.
fn issues_label(failing: u64) -> String { fn issues_label(failing: u64) -> String {
match failing { // Translators: a menu item that opens the list of failing changes.
1 => "1 issue — View".to_string(), ngettext_f("{n} issue — View", "{n} issues — View", failing, &[])
n => format!("{n} issues — View"),
}
} }
fn open_folder(mountpoint: &Path) { fn open_folder(mountpoint: &Path) {
@@ -331,14 +368,14 @@ impl Tray for DriveTray {
match self.state.phase { match self.state.phase {
// Nothing to mount without a session; the app's sign-in page is the // Nothing to mount without a session; the app's sign-in page is the
// way in. // way in.
Phase::SignedOut => items.push(item("Sign In…", |_| open_manager(&[]))), Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
_ => items.push(item("Open Proton Drive", |_| open_manager(&[]))), _ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
} }
if self.state.mounted { if self.state.mounted {
items.push( items.push(
StandardItem { StandardItem {
label: "Open Folder".into(), label: gettext("Open Folder"),
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)), activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
..Default::default() ..Default::default()
} }
@@ -349,7 +386,11 @@ impl Tray for DriveTray {
let paused = self.state.paused; let paused = self.state.paused;
items.push( items.push(
StandardItem { StandardItem {
label: if paused { "Resume Sync" } else { "Pause Sync" }.into(), label: if paused {
gettext("Resume Sync")
} else {
gettext("Pause Sync")
},
activate: Box::new(move |this: &mut Self| { activate: Box::new(move |this: &mut Self| {
let request = Request::SetSyncPaused { let request = Request::SetSyncPaused {
paused: !paused, paused: !paused,
@@ -367,22 +408,22 @@ impl Tray for DriveTray {
); );
} else if self.state.phase == Phase::Disconnected { } else if self.state.phase == Phase::Disconnected {
// Enable+start the service so it mounts now and on future logins. // Enable+start the service so it mounts now and on future logins.
items.push(item("Connect", |this| { items.push(item(&gettext("Connect"), |this| {
service::enable_start(); service::enable_start();
this.state.line = "Connecting…".into(); this.state.line = gettext("Connecting…");
})); }));
} }
items.push(MenuItem::Separator); items.push(MenuItem::Separator);
items.push(item("Hide Tray Icon", |_| hide_tray())); items.push(item(&gettext("Hide Tray Icon"), |_| hide_tray()));
if self.state.mounted { if self.state.mounted {
// Stopping unmounts the drive, so the app asks first; the tray has no // Stopping unmounts the drive, so the app asks first; the tray has no
// GTK of its own to ask with. // GTK of its own to ask with.
items.push(item("Stop Proton Drive…", |_| { items.push(item(&gettext("Stop Proton Drive…"), |_| {
open_manager(&["--confirm-stop"]) open_manager(&["--confirm-stop"])
})); }));
} else { } else {
items.push(item("Quit", |_| std::process::exit(0))); items.push(item(&gettext("Quit"), |_| std::process::exit(0)));
} }
items items
} }
@@ -394,6 +435,7 @@ fn main() -> Result<()> {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) )
.init(); .init();
i18n::init();
let dirs = AppDirs::new().context("resolve app dirs")?; let dirs = AppDirs::new().context("resolve app dirs")?;
// Hidden from its own menu: the session's autostart still runs the tray, and // Hidden from its own menu: the session's autostart still runs the tray, and
+98 -58
View File
@@ -23,6 +23,8 @@ use pdfs_core::menu::PromptMode;
use pdfs_core::opener::{self, OpenWith}; use pdfs_core::opener::{self, OpenWith};
mod activation; mod activation;
mod i18n;
use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
mod dmenu; mod dmenu;
mod fzf; mod fzf;
mod query; mod query;
@@ -51,11 +53,13 @@ enum Filter {
/// The chip row, in Tab-cycle order. /// The chip row, in Tab-cycle order.
const FILTERS: [(Filter, &str); 5] = [ const FILTERS: [(Filter, &str); 5] = [
(Filter::All, "All"), // Translators: a filter chip that shows every kind of result.
(Filter::Folders, "Folders"), (Filter::All, gettext_noop("All")),
(Filter::Documents, "Documents"), (Filter::Folders, gettext_noop("Folders")),
(Filter::Images, "Images"), (Filter::Documents, gettext_noop("Documents")),
(Filter::Media, "Media"), (Filter::Images, gettext_noop("Images")),
// Translators: a filter chip for audio and video files.
(Filter::Media, gettext_noop("Media")),
]; ];
impl Filter { impl Filter {
@@ -162,9 +166,13 @@ impl Hit {
None => { None => {
let parent = parent_of(&h.path); let parent = parent_of(&h.path);
if parent.is_empty() { if parent.is_empty() {
"My files".to_string() gettext("My files")
} else { } else {
format!("My files / {}", parent.replace('/', " / ")) // Translators: {path} is a folder path inside the user's Drive, such as "Work / Taxes".
gettext_f(
"My files / {path}",
&[("path", &parent.replace('/', " / "))],
)
} }
} }
}, },
@@ -425,6 +433,7 @@ fn main() -> glib::ExitCode {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) )
.init(); .init();
i18n::init();
let raw: Vec<String> = std::env::args().skip(1).collect(); let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.iter().any(|arg| arg == "-h" || arg == "--help") { if raw.iter().any(|arg| arg == "-h" || arg == "--help") {
@@ -506,7 +515,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let window = adw::ApplicationWindow::builder() let window = adw::ApplicationWindow::builder()
.application(app) .application(app)
.title("Search Proton Drive") .title(gettext("Search Proton Drive"))
.default_width(720) .default_width(720)
.default_height(540) .default_height(540)
.resizable(false) .resizable(false)
@@ -527,7 +536,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
search_bar.append(&search_icon); search_bar.append(&search_icon);
let entry = gtk4::Entry::builder() let entry = gtk4::Entry::builder()
.placeholder_text("Search in Drive and on this computer") .placeholder_text(gettext("Search in Drive and on this computer"))
.hexpand(true) .hexpand(true)
.build(); .build();
entry.add_css_class("search-entry"); entry.add_css_class("search-entry");
@@ -537,7 +546,8 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
spinner.set_visible(false); spinner.set_visible(false);
search_bar.append(&spinner); search_bar.append(&spinner);
let esc = gtk4::Label::new(Some("Esc")); // Translators: the Escape key, shown as a hint that it closes the window.
let esc = gtk4::Label::new(Some(&gettext("Esc")));
esc.add_css_class("key-hint"); esc.add_css_class("key-hint");
search_bar.append(&esc); search_bar.append(&esc);
card.append(&search_bar); card.append(&search_bar);
@@ -551,9 +561,9 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
results.add_css_class("results"); results.add_css_class("results");
let search_section = Section::new("Best matches", "system-search-symbolic"); let search_section = Section::new(&gettext("Best matches"), "system-search-symbolic");
let drive_section = Section::new("Proton Drive", "folder-remote-symbolic"); let drive_section = Section::new("Proton Drive", "folder-remote-symbolic");
let local_section = Section::new("This computer", "drive-harddisk-symbolic"); let local_section = Section::new(&gettext("This computer"), "drive-harddisk-symbolic");
search_section.attach(&results); search_section.attach(&results);
drive_section.attach(&results); drive_section.attach(&results);
local_section.attach(&results); local_section.attach(&results);
@@ -567,7 +577,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let placeholder = adw::StatusPage::builder() let placeholder = adw::StatusPage::builder()
.icon_name("system-search-symbolic") .icon_name("system-search-symbolic")
.title("No results") .title(gettext("No results"))
.build(); .build();
placeholder.add_css_class("compact"); placeholder.add_css_class("compact");
@@ -575,12 +585,12 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
// is still usable for nothing else, so it owns the whole view. // is still usable for nothing else, so it owns the whole view.
let offline = adw::StatusPage::builder() let offline = adw::StatusPage::builder()
.icon_name("network-offline-symbolic") .icon_name("network-offline-symbolic")
.title("Proton Drive is not running") .title(gettext("Proton Drive is not running"))
.description("Start the mount daemon to search your Drive.") .description(gettext("Start the mount daemon to search your Drive."))
.build(); .build();
offline.add_css_class("compact"); offline.add_css_class("compact");
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label(gettext("Retry"))
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.build(); .build();
retry.add_css_class("pill"); retry.add_css_class("pill");
@@ -601,14 +611,15 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
footer.add_css_class("footer"); footer.add_css_class("footer");
let hint = gtk4::Label::builder() let hint = gtk4::Label::builder()
.label("Connecting…") .label(gettext("Connecting…"))
.xalign(0.0) .xalign(0.0)
.hexpand(true) .hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::End) .ellipsize(gtk4::pango::EllipsizeMode::End)
.build(); .build();
hint.add_css_class("footer-text"); hint.add_css_class("footer-text");
footer.append(&hint); footer.append(&hint);
let keys = gtk4::Label::new(Some("↑↓ navigate · ↵ open · Tab filter")); // Translators: keyboard hints in the footer; keep the arrow and Enter symbols.
let keys = gtk4::Label::new(Some(&gettext("↑↓ navigate · ↵ open · Tab filter")));
keys.add_css_class("footer-text"); keys.add_css_class("footer-text");
footer.append(&keys); footer.append(&keys);
card.append(&footer); card.append(&footer);
@@ -646,7 +657,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
for (filter, label) in FILTERS { for (filter, label) in FILTERS {
let chip = gtk4::ToggleButton::builder() let chip = gtk4::ToggleButton::builder()
.label(label) .label(gettext(label))
.active(filter == Filter::All) .active(filter == Filter::All)
.build(); .build();
chip.add_css_class("chip"); chip.add_css_class("chip");
@@ -852,7 +863,7 @@ impl Ui {
/// Ask the daemon for status and, if it answers, load the suggested (pinned) /// Ask the daemon for status and, if it answers, load the suggested (pinned)
/// files. Runs off-thread: a dead daemon must not freeze the first frame. /// files. Runs off-thread: a dead daemon must not freeze the first frame.
fn connect(self: &Rc<Self>) { fn connect(self: &Rc<Self>) {
self.hint.set_label("Connecting…"); self.hint.set_label(&gettext("Connecting…"));
let rx = spawn_request(self.socket.clone(), Request::Status); let rx = spawn_request(self.socket.clone(), Request::Status);
let ui = self.clone(); let ui = self.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
@@ -865,7 +876,7 @@ impl Ui {
} }
_ => { _ => {
ui.entry.set_sensitive(false); ui.entry.set_sensitive(false);
ui.hint.set_label("Daemon offline"); ui.hint.set_label(&gettext("Daemon offline"));
ui.stack.set_visible_child_name("offline"); ui.stack.set_visible_child_name("offline");
} }
} }
@@ -879,7 +890,7 @@ impl Ui {
self.spinner.stop(); self.spinner.stop();
self.spinner.set_visible(false); self.spinner.set_visible(false);
self.entry.set_sensitive(false); self.entry.set_sensitive(false);
self.hint.set_label("Daemon offline"); self.hint.set_label(&gettext("Daemon offline"));
self.stack.set_visible_child_name("offline"); self.stack.set_visible_child_name("offline");
} }
@@ -1072,7 +1083,8 @@ impl Ui {
visible.extend(local); visible.extend(local);
if searching { if searching {
rank_hits(&mut visible); rank_hits(&mut visible);
self.search_section.set_rows(&visible, Some("Best matches")); self.search_section
.set_rows(&visible, Some(&gettext("Best matches")));
self.drive_section.set_rows(&[], None); self.drive_section.set_rows(&[], None);
self.local_section.set_rows(&[], None); self.local_section.set_rows(&[], None);
} else { } else {
@@ -1081,8 +1093,10 @@ impl Ui {
.iter() .iter()
.take_while(|hit| matches!(hit, Hit::Drive(_))) .take_while(|hit| matches!(hit, Hit::Drive(_)))
.count(); .count();
self.drive_section self.drive_section.set_rows(
.set_rows(&visible[..drive_count], Some("Pinned in Proton Drive")); &visible[..drive_count],
Some(&gettext("Pinned in Proton Drive")),
);
self.local_section.set_rows(&visible[drive_count..], None); self.local_section.set_rows(&visible[drive_count..], None);
} }
@@ -1093,18 +1107,20 @@ impl Ui {
*self.rendered_query.borrow_mut() = query; *self.rendered_query.borrow_mut() = query;
if total == 0 { if total == 0 {
self.placeholder.set_title(if searching { self.placeholder.set_title(&if searching {
"No results" gettext("No results")
} else { } else {
"Search your Drive" gettext("Search your Drive")
}); });
self.placeholder self.placeholder
.set_description(Some(match (searching, self.indexing.get()) { .set_description(Some(&match (searching, self.indexing.get()) {
(true, true) => { (true, true) => gettext(
"Still indexing this computer — local results will fill in shortly." "Still indexing this computer — local results will fill in shortly.",
} ),
(true, false) => "Try a different search, or another filter.", (true, false) => gettext("Try a different search, or another filter."),
_ => "Start typing to search Proton Drive and the files on this computer.", _ => gettext(
"Start typing to search Proton Drive and the files on this computer.",
),
})); }));
self.stack.set_visible_child_name("empty"); self.stack.set_visible_child_name("empty");
self.cursor.set(None); self.cursor.set(None);
@@ -1129,18 +1145,23 @@ impl Ui {
fn status_text(&self, total: usize, searching: bool) -> String { fn status_text(&self, total: usize, searching: bool) -> String {
if !searching { if !searching {
return match total { return match total {
0 => "No pinned files".to_string(), 0 => gettext("No pinned files"),
1 => "1 pinned file".to_string(), n => ngettext_f("{n} pinned file", "{n} pinned files", n as u64, &[]),
n => format!("{n} pinned files"),
}; };
} }
let drive = self.drive_count(); let drive = self.drive_count().to_string();
let local = total - drive; let local = (total - self.drive_count()).to_string();
let mut text = format!("{drive} in Drive · {local} on this computer"); let args = [("drive", drive.as_str()), ("local", local.as_str())];
if self.indexing.get() { if self.indexing.get() {
text.push_str(" · indexing…"); // Translators: result counts in the footer while this computer is still being indexed; {drive} and {local} are numbers.
gettext_f(
"{drive} in Drive · {local} on this computer · indexing…",
&args,
)
} else {
// Translators: result counts in the footer; {drive} and {local} are numbers.
gettext_f("{drive} in Drive · {local} on this computer", &args)
} }
text
} }
/// Move the cursor by `delta` rows, wrapping at both ends. /// Move the cursor by `delta` rows, wrapping at both ends.
@@ -1272,7 +1293,9 @@ impl Ui {
self.opening.set(true); self.opening.set(true);
self.spinner.set_visible(true); self.spinner.set_visible(true);
self.spinner.start(); self.spinner.start();
self.hint.set_label(&format!("Opening {}…", drive.name)); // Translators: {name} is a file name.
let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]);
self.hint.set_label(&opening);
let rx = spawn_request( let rx = spawn_request(
self.socket.clone(), self.socket.clone(),
@@ -1293,9 +1316,13 @@ impl Ui {
ui.dismiss(); ui.dismiss();
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
ui.hint.set_label(&format!("Could not open: {message}")); // Translators: {message} is an error from the daemon, in English.
ui.hint.set_label(&gettext_f(
"Could not open: {message}",
&[("message", &message)],
));
} }
_ => ui.hint.set_label("Could not reach the daemon"), _ => ui.hint.set_label(&gettext("Could not reach the daemon")),
} }
}); });
} }
@@ -1403,8 +1430,10 @@ fn home_relative(parent: &str) -> String {
.ok() .ok()
.map(|rel| rel.display().to_string()) .map(|rel| rel.display().to_string())
}) { }) {
Some(rel) if rel.is_empty() => "Home".to_string(), // Translators: the user's home folder, as the location of a result.
Some(rel) => format!("Home / {}", rel.replace('/', " / ")), Some(rel) if rel.is_empty() => gettext("Home"),
// Translators: {path} is a folder path inside the home folder, folder names separated by " / ".
Some(rel) => gettext_f("Home / {path}", &[("path", &rel.replace('/', " / "))]),
None => parent.to_string(), None => parent.to_string(),
} }
} }
@@ -1418,16 +1447,21 @@ fn parent_of(path: &str) -> String {
fn format_size(bytes: u64) -> String { fn format_size(bytes: u64) -> String {
const UNITS: [(u64, &str); 3] = [ const UNITS: [(u64, &str); 3] = [
(1024 * 1024 * 1024, "GB"), // Translators: a file size in gigabytes; {size} is a number such as "4.2".
(1024 * 1024, "MB"), (1024 * 1024 * 1024, gettext_noop("{size} GB")),
(1024, "KB"), // Translators: a file size in megabytes; {size} is a number such as "4.2".
(1024 * 1024, gettext_noop("{size} MB")),
// Translators: a file size in kilobytes; {size} is a number such as "4.2".
(1024, gettext_noop("{size} KB")),
]; ];
for (scale, unit) in UNITS { for (scale, unit) in UNITS {
if bytes >= scale { if bytes >= scale {
return format!("{:.1} {unit}", bytes as f64 / scale as f64); let size = format!("{:.1}", bytes as f64 / scale as f64);
return gettext_f(unit, &[("size", &size)]);
} }
} }
format!("{bytes} B") // Translators: a file size in bytes; {size} is a whole number.
gettext_f("{size} B", &[("size", &bytes.to_string())])
} }
/// Coarse relative age, in the granularity a launcher row has room for. /// Coarse relative age, in the granularity a launcher row has room for.
@@ -1437,12 +1471,18 @@ fn format_age(epoch_secs: i64) -> String {
.map_or(0, |d| d.as_secs() as i64); .map_or(0, |d| d.as_secs() as i64);
let diff = now - epoch_secs; let diff = now - epoch_secs;
match diff { match diff {
d if d < 60 => "now".to_string(), // Translators: the age of a file modified under a minute ago.
d if d < 3600 => format!("{}m", d / 60), d if d < 60 => pgettext("age", "now"),
d if d < 86_400 => format!("{}h", d / 3600), // Translators: a compact file age; {count} is a number of minutes.
d if d < 2_592_000 => format!("{}d", d / 86_400), d if d < 3600 => gettext_f("{count}m", &[("count", &(d / 60).to_string())]),
d if d < 31_536_000 => format!("{}mo", d / 2_592_000), // Translators: a compact file age; {count} is a number of hours.
d => format!("{}y", d / 31_536_000), d if d < 86_400 => gettext_f("{count}h", &[("count", &(d / 3600).to_string())]),
// Translators: a compact file age; {count} is a number of days.
d if d < 2_592_000 => gettext_f("{count}d", &[("count", &(d / 86_400).to_string())]),
// Translators: a compact file age; {count} is a number of months.
d if d < 31_536_000 => gettext_f("{count}mo", &[("count", &(d / 2_592_000).to_string())]),
// Translators: a compact file age; {count} is a number of years.
d => gettext_f("{count}y", &[("count", &(d / 31_536_000).to_string())]),
} }
} }
+8 -2
View File
@@ -17,7 +17,7 @@ use pdfs_core::control::{
use pdfs_core::opener::{self, OpenWith}; use pdfs_core::opener::{self, OpenWith};
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits}; use crate::{Hit, file_name, gettext_f, is_document, is_image, is_media, rank_hits};
/// Where the mount lives, for turning a Drive path into something an /// Where the mount lives, for turning a Drive path into something an
/// application can open. Falls back to the configured default so a momentarily /// application can open. Falls back to the configured default so a momentarily
@@ -164,7 +164,13 @@ pub(crate) fn open(
/// One control-socket round-trip, with the daemon-is-down case phrased for a /// One control-socket round-trip, with the daemon-is-down case phrased for a
/// terminal rather than a status page. /// terminal rather than a status page.
pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> { pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> {
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}")) send(socket, &request).map_err(|e| {
// Translators: shown as a row in the search list; {error} is a system error, in English.
gettext_f(
"cannot reach the Proton Drive daemon: {error}",
&[("error", &e.to_string())],
)
})
} }
#[cfg(test)] #[cfg(test)]
+25
View File
@@ -12,6 +12,31 @@ Conventions:
--- ---
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
superseded upload yet.
**Found:** 2026-09-23. The Sync page showed `Unconfirmed 654050.crdownload — 0 bytes of 23.8 MiB`
for hours after Brave had finished and renamed the download. `pdfs transfers` listed the same
entry, `pdfs diagnostics` showed every worker idle, and `/proc/<pid>/task` showed `pdfs-drain-1`
in state `R` at 100% CPU, holding a deleted staging blob open.
**Where:** `crates/pdfs-fuse/src/transfers.rs` (`CountingReader::read`).
**Cause.** A write that lands while a revision is on the wire cancels that upload through the
flag `CountingReader` checks before every read. The reader reported the cancel as
`ErrorKind::Interrupted`. Every read loop treats that kind as "try again", including the SDK's
`read_full_block`, and the flag never clears, so the loop never ended. The `TransferGuard` was
never dropped, so the transfer stayed in the registry, and the drain thread was lost until the
daemon restarted. A browser download is the natural trigger: the `.crdownload` file keeps growing,
so each new write supersedes the upload of the previous one.
**Fix.** The cancel is reported as `ErrorKind::Other`, which the SDK turns into an error.
`drain_revision` already recognises a cancelled upload and returns without counting a failure.
Regression test: `a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry`.
---
## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings ## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real **Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
+71
View File
@@ -11,6 +11,74 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [2.2.1] — 2026-09-24
Schema: **32** (unchanged).
### Added
- **Legal information in About.** The About dialog now links to the source code and to Proton's
terms of service and privacy policy, and its Legal page carries the full disclaimer: this client
is unofficial, not affiliated with or endorsed by Proton AG, and comes without warranty.
## [2.2.0] — 2026-09-23
Schema: **32**. Videos that earlier got no thumbnail are tried again.
### Added
- **Video thumbnails.** Videos without a thumbnail from Drive now show a frame from the clip in
Photos. This needs `ffmpeg`, a new optional dependency.
- **Uploads carry a thumbnail.** Photos and videos uploaded from this client now include one, so
they show a preview in Proton's own apps as well, without downloading the whole file.
### Fixed
- **The Photos gallery no longer jumps** while thumbnails load, when zooming, or when switching
tabs and back.
- **The month scrubber no longer collides with the scrollbar.**
- **The photo details panel is no longer empty.**
- **Photo details read like a camera's:** "Google Pixel 6" instead of quoted EXIF values, shutter
speeds such as 1/19 s, and a labelled ISO.
## [2.1.1] — 2026-09-23
Schema: **31** (unchanged).
### Fixed
- **Uploads no longer get stuck at 0 bytes.** When a file changed again while its previous
version was uploading, such as a browser download that is still growing, the old upload could
hang forever. It kept one background thread busy at full CPU and showed as "Uploading" in the
Transfers list until the daemon restarted.
## [2.1.0] — 2026-09-23
The desktop app, the tray and the search prompt speak German, British English, Spanish, French,
Italian, Dutch, Polish and Brazilian Portuguese, picked from the system language or chosen in
Preferences.
Schema: **31** (unchanged).
### Added
- **Translations.** The desktop app, the tray and the search prompt are translated into German,
British English, Spanish, French, Italian, Dutch, Polish and Brazilian Portuguese, and follow the
system language. Counts use each language's plural rules, and dates use its own formats. The
desktop files carry translated descriptions and search keywords. The `pdfs` CLI and messages
from the daemon stay in English. [docs/TRANSLATING.md](TRANSLATING.md) describes how to improve a
translation or add a language. A few labels on the Photos page (the filter tabs, the gallery
subtitle and the heading shown when a filtered timeline is empty) are still in English.
- **Language preference.** Preferences → General → Appearance → Language overrides the system
language for the app, the tray and the prompt, from their next start.
### Changed
- **Packages install translations.** The Arch, AUR, Debian and Fedora packages install the compiled
catalogs under `/usr/share/locale`, and the release tarball carries them in `locale/`. Building
the source packages now needs `gettext`.
## [2.0.0] — 2026-09-23
A redesign of the desktop app: a new shell with a Preferences dialog, keyboard-driven context
menus, a docked details pane, a rewritten photo viewer with infinite scroll, album management,
stop sharing and link expiry, a redesigned Shared with Me page, per-device restore, a tray that
shows the sync state, and a new app icon.
Schema: **31** (unchanged). Schema: **31** (unchanged).
### Added ### Added
@@ -970,6 +1038,9 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1 [1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
[1.11.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.0 [1.11.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.0
[1.10.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.2 [1.10.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.2
+78
View File
@@ -0,0 +1,78 @@
# Translating Proton Drive for Linux
The desktop app (`pdfs-app`), the tray (`pdfs-tray`) and the search prompt (`pdfs-prompt`) are
translated with GNU gettext in the `pdfs` text domain. The `pdfs` command-line tool and the daemon's
own messages stay in English, because scripts parse their output.
US English is the source language. The shipped catalogs are listed in [`po/LINGUAS`](../po/LINGUAS):
German, British English, Spanish, French, Italian, Dutch, Polish and Brazilian Portuguese.
## Picking a language
The apps follow the system locale (`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`). Preferences →
General → Appearance → **Language** overrides that for the app, the tray and the prompt. The choice is
stored as `language` in `config.json` and takes effect the next time they start.
## Layout
| Path | Purpose |
| --- | --- |
| `crates/pdfs-gui/src/i18n.rs` | Locale setup and the `gettext`, `gettext_f`, `ngettext_f`, `pgettext` and `gettext_noop` helpers |
| `po/POTFILES.in` | Sources that `xgettext` scans |
| `po/pdfs.pot` | The template, generated from the sources |
| `po/<lang>.po` | One catalog per language |
| `po/update.sh` | Regenerates the template and merges it into every catalog |
| `po/build.sh <localedir>` | Compiles the catalogs to `<localedir>/<lang>/LC_MESSAGES/pdfs.mo` |
## Updating an existing translation
1. Run `po/update.sh`. It regenerates `po/pdfs.pot` and merges new and changed strings into each
catalog, marking changed ones `fuzzy`.
2. Edit `po/<lang>.po` with a PO editor (Poedit, Lokalize, GNOME Translation Editor) or a text
editor. Translate the empty entries and review every `fuzzy` one, then remove its `fuzzy` flag.
3. Check the catalog with `msgfmt --check --statistics -o /dev/null po/<lang>.po`.
4. Try it from the source tree:
```bash
po/build.sh target/locale
PDFS_LOCALEDIR=target/locale LANGUAGE=<lang> cargo run -p pdfs-gui --bin pdfs-app
```
## Adding a language
1. Add the code (for example `sv` or `pt_PT`) to `po/LINGUAS`, keeping the list sorted.
2. Run `po/update.sh`. It creates `po/<lang>.po` from the template with `msginit`.
3. Fill in the `Plural-Forms` header if `msginit` did not, and translate the catalog.
4. Add the code and the language's own name to `LANGUAGES` in `crates/pdfs-gui/src/i18n.rs`, so the
Preferences picker offers it.
5. Add `GenericName[<lang>]`, `Comment[<lang>]` and `Keywords[<lang>]` lines to the two desktop files
in `packaging/`.
`cargo test -p pdfs-gui` checks that every language in the picker has a catalog, that
`po/POTFILES.in` lists every source file that calls gettext, and that every catalog passes
`msgfmt --check`.
## Rules for translators
- Keep every `{placeholder}` from the English text. You may move it, but not rename or drop it.
`msgfmt --check` rejects a catalog where they differ. Plural forms keep `{n}` in every form.
- Read the `#.` translator comments above an entry. They explain placeholders and date formats.
- Date entries such as `%b %e` are `strftime` formats for GLib's `DateTime::format`. Translate them
into the order and separators your language uses, not into literal dates.
- A `msgctxt` such as `verb` or `column` tells you which meaning of a short word is meant.
- Keep product names as they are: Proton Drive, Proton, Google Takeout.
- Use the same term for the same thing across the catalog, and follow the terminology of your
desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel.
## Rules for developers
- Wrap every user-visible GUI string: `gettext("Move to Trash")`.
- Never build a sentence from translated pieces or with `format!`. Use named placeholders:
`gettext_f("Moved {name} to Trash", &[("name", &name)])`.
- Counts use `ngettext_f("{n} item", "{n} items", count, &[])`; `{n}` is filled in for you.
- Give short, ambiguous words a context: `pgettext("verb", "Open")`.
- Mark labels in `const` tables with `gettext_noop` and translate them where they are shown.
- The message id must be a single string literal on one line, since `xgettext` reads literals only.
- Add a `// Translators:` comment on the line above a call whose meaning is not obvious.
- Do not translate log messages, CLI output, action or icon names, or values the daemon sends back.
- Run `po/update.sh` after changing strings and commit the updated template and catalogs.
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 92 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+28 -18
View File
@@ -1,26 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <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="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse"> <linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8B72FF"/> <stop offset="0" stop-color="#A48BFF"/>
<stop offset="1" stop-color="#5A38E8"/> <stop offset="0.55" stop-color="#7552FF"/>
<stop offset="1" stop-color="#5B36F2"/>
</linearGradient> </linearGradient>
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#ECE6FF"/>
</linearGradient>
<!-- the cloud, with the arrow cut out of it as negative space -->
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
<g fill="#FFFFFF">
<circle cx="66" cy="57" r="23"/>
<circle cx="42" cy="68" r="16"/>
<circle cx="88" cy="69" r="15"/>
<rect x="26" y="68" width="77" height="20" rx="10"/>
</g>
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M64 49v26"/>
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
</g>
</mask>
</defs> </defs>
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/> <!-- base: a rounded tile with a darker lower edge for depth -->
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
<!-- cloud: the remote drive (symmetric about x=64) --> <!-- cloud shadow, then the cloud itself -->
<g fill="#FFFFFF"> <rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
<circle cx="64" cy="42" r="20"/> <rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
<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: 990 B

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 899 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+6 -2
View File
@@ -9,17 +9,18 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=2.0.0 pkgver=2.2.1
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
url="https://github.com/narrrl/proton-drive-linux" url="https://github.com/narrrl/proton-drive-linux"
license=('MIT') license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool') depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
makedepends=('cargo' 'git') makedepends=('cargo' 'git' 'gettext')
provides=('pdfs') provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime. # Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service' optdepends=('gnome-keyring: credential storage via Secret Service'
'ffmpeg: video thumbnails'
'xdg-utils: "Open folder" action from the tray' 'xdg-utils: "Open folder" action from the tray'
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)' 'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
'foot: terminal for pdfs-prompt --fzf started from a keybinding') 'foot: terminal for pdfs-prompt --fzf started from a keybinding')
@@ -73,5 +74,8 @@ package() {
install -Dm644 "packaging/proton-drive.service" \ install -Dm644 "packaging/proton-drive.service" \
"$pkgdir/usr/lib/systemd/user/proton-drive.service" "$pkgdir/usr/lib/systemd/user/proton-drive.service"
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+18 -1
View File
@@ -21,6 +21,22 @@ built packages never reach the AUR.
## Releasing a new version ## Releasing a new version
`.github/workflows/aur.yml` does this automatically:
- **On a release tag**, `release.yml` calls it after the GitHub release is
published. It runs `./update.sh <version>`, pushes all three packages with
`./push.sh`, and commits the updated recipes back to `main` as
`chore(aur): update packages to <version>`.
- **On a push to `main` that changes `proton-drive-for-linux-git/`**, it
regenerates that package's `.SRCINFO` and pushes only the -git package.
- **By hand**, run the workflow from the Actions tab. Give a version to publish
a release, or leave it empty to publish only the -git package.
It needs the `AUR_SSH_PRIVATE_KEY` repository secret: a private key whose public
half is registered with the AUR account.
To do the same by hand:
1. Tag and publish the upstream release, so the source tarball and the 1. Tag and publish the upstream release, so the source tarball and the
`proton-drive-linux-<version>-x86_64.tar.gz` release asset exist. `proton-drive-linux-<version>-x86_64.tar.gz` release asset exist.
2. Run `./update.sh <version>` — it rewrites `pkgver`, resets `pkgrel=1`, 2. Run `./update.sh <version>` — it rewrites `pkgver`, resets `pkgrel=1`,
@@ -32,7 +48,8 @@ built packages never reach the AUR.
for a single package). for a single package).
`proton-drive-for-linux-git` carries a `pkgver()` function, so its `pkgver` is `proton-drive-for-linux-git` carries a `pkgver()` function, so its `pkgver` is
computed at build time; do not push a commit that only bumps it. computed at build time; do not push a commit that only bumps it. This is why new
commits on `main` do not trigger an AUR push.
## Requirements for pushing ## Requirements for pushing
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 1.11.1 pkgver = 2.2.0
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
@@ -14,29 +14,30 @@ pkgbase = proton-drive-for-linux-bin
depends = glibc depends = glibc
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=1.11.1 provides = proton-drive-for-linux=2.2.0
provides = proton-drive-linux=1.11.1 provides = proton-drive-linux=2.2.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug options = !debug
source = proton-drive-linux-1.11.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v1.11.1/proton-drive-linux-1.11.1-x86_64.tar.gz source = proton-drive-linux-2.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.0/proton-drive-linux-2.2.0-x86_64.tar.gz
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/io.narl.proton-drive-linux.desktop source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/io.narl.proton-drive-linux-tray.desktop source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/io.narl.proton-drive-linux.svg source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/proton-drive.service source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/proton-drive.service
source = LICENSE-1.11.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/LICENSE source = LICENSE-2.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/LICENSE
sha256sums = 5fa20a17c0068ad1d74c51eb081b7e36c24b6bf18c4cb0e0b90d2e1c1d40c56f sha256sums = 4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479
sha256sums = 034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = 2bfdef503b4ac02f047adb022e02e81099970589f9d776c821b7751f1be6c12a sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60 sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60
sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=1.11.1 pkgver=2.2.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') arch=('x86_64')
@@ -10,6 +10,7 @@ license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -29,10 +30,10 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive.service::$_raw/packaging/proton-drive.service" "proton-drive.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('5fa20a17c0068ad1d74c51eb081b7e36c24b6bf18c4cb0e0b90d2e1c1d40c56f' sha256sums=('4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479'
'034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'2bfdef503b4ac02f047adb022e02e81099970589f9d776c821b7751f1be6c12a' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
'25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60' '25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60'
'02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c') '02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c')
@@ -53,5 +54,9 @@ package() {
install -Dm644 "$srcdir/proton-drive.service" \ install -Dm644 "$srcdir/proton-drive.service" \
"$pkgdir/usr/lib/systemd/user/proton-drive.service" "$pkgdir/usr/lib/systemd/user/proton-drive.service"
# Translations ship precompiled in the release tarball.
install -dm755 "$pkgdir/usr/share"
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
@@ -1,12 +1,13 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 1.11.1.r0.g860d29c pkgver = 2.2.0.r1.g82e9654
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
license = MIT license = MIT
makedepends = cargo makedepends = cargo
makedepends = git makedepends = git
makedepends = gettext
depends = fuse3 depends = fuse3
depends = gtk4 depends = gtk4
depends = libadwaita depends = libadwaita
@@ -16,13 +17,14 @@ pkgbase = proton-drive-for-linux-git
depends = glibc depends = glibc
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=1.11.1.r0.g860d29c provides = proton-drive-for-linux=2.2.0.r1.g82e9654
provides = proton-drive-linux=1.11.1.r0.g860d29c provides = proton-drive-linux=2.2.0.r1.g82e9654
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=1.11.1.r0.g860d29c pkgver=2.2.0.r1.g82e9654
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -9,8 +9,9 @@ url="https://github.com/narrrl/proton-drive-linux"
license=('MIT') license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
makedepends=('cargo' 'git') makedepends=('cargo' 'git' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -73,6 +74,9 @@ package() {
install -Dm644 packaging/proton-drive.service \ install -Dm644 packaging/proton-drive.service \
"$pkgdir/usr/lib/systemd/user/proton-drive.service" "$pkgdir/usr/lib/systemd/user/proton-drive.service"
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
} }
@@ -1,11 +1,12 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 1.11.1 pkgver = 2.2.0
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
license = MIT license = MIT
makedepends = cargo makedepends = cargo
makedepends = gettext
depends = fuse3 depends = fuse3
depends = gtk4 depends = gtk4
depends = libadwaita depends = libadwaita
@@ -15,18 +16,19 @@ pkgbase = proton-drive-for-linux
depends = glibc depends = glibc
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=1.11.1 provides = proton-drive-linux=2.2.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto options = !lto
source = proton-drive-for-linux-1.11.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v1.11.1.tar.gz source = proton-drive-for-linux-2.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.0.tar.gz
sha256sums = 1481379b2b70102ea6345a00b1092f73511f6ba99d6fba9a3f185ad6d28caaf2 sha256sums = 73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=1.11.1 pkgver=2.2.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') arch=('x86_64')
@@ -9,8 +9,9 @@ url="https://github.com/narrrl/proton-drive-linux"
license=('MIT') license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
makedepends=('cargo') makedepends=('cargo' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -21,7 +22,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('1481379b2b70102ea6345a00b1092f73511f6ba99d6fba9a3f185ad6d28caaf2') sha256sums=('73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"
@@ -68,6 +69,9 @@ package() {
install -Dm644 packaging/proton-drive.service \ install -Dm644 packaging/proton-drive.service \
"$pkgdir/usr/lib/systemd/user/proton-drive.service" "$pkgdir/usr/lib/systemd/user/proton-drive.service"
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
} }
+5
View File
@@ -21,6 +21,11 @@ for pkg in proton-drive-for-linux proton-drive-for-linux-bin; do
(cd "$pkg" && updpkgsums) (cd "$pkg" && updpkgsums)
done done
# --printsrcinfo does not run pkgver(), so let makepkg clone the source and
# write the current git describe version back into the -git PKGBUILD first.
# --noprepare skips the cargo fetch in prepare().
(cd proton-drive-for-linux-git && makepkg -od --noprepare --noconfirm && rm -rf src)
for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO) (cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
echo "updated $pkg" echo "updated $pkg"
@@ -2,7 +2,23 @@
Type=Application Type=Application
Name=Proton Drive for Linux (Tray) Name=Proton Drive for Linux (Tray)
GenericName=Cloud Drive Tray GenericName=Cloud Drive Tray
GenericName[de]=Cloud-Speicher im Infobereich
GenericName[en_GB]=Cloud Drive Tray
GenericName[es]=Bandeja de almacenamiento en la nube
GenericName[fr]=Zone de notification du stockage en ligne
GenericName[it]=Area di notifica archiviazione cloud
GenericName[nl]=Cloudopslag in systeemvak
GenericName[pl]=Dysk w chmurze w zasobniku
GenericName[pt_BR]=Bandeja de armazenamento em nuvem
Comment=Proton Drive status and quick actions (unofficial client) Comment=Proton Drive status and quick actions (unofficial client)
Comment[de]=Status und Schnellaktionen für Proton Drive (inoffizieller Client)
Comment[en_GB]=Proton Drive status and quick actions (unofficial client)
Comment[es]=Estado y acciones rápidas de Proton Drive (cliente no oficial)
Comment[fr]=État et actions rapides de Proton Drive (client non officiel)
Comment[it]=Stato e azioni rapide di Proton Drive (client non ufficiale)
Comment[nl]=Status en snelle acties voor Proton Drive (onofficiële client)
Comment[pl]=Stan i szybkie działania Proton Drive (nieoficjalny klient)
Comment[pt_BR]=Status e ações rápidas do Proton Drive (cliente não oficial)
Exec=pdfs-tray Exec=pdfs-tray
Icon=io.narl.proton-drive-linux Icon=io.narl.proton-drive-linux
Terminal=false Terminal=false
@@ -2,11 +2,35 @@
Type=Application Type=Application
Name=Proton Drive for Linux Name=Proton Drive for Linux
GenericName=Cloud Drive GenericName=Cloud Drive
GenericName[de]=Cloud-Speicher
GenericName[en_GB]=Cloud Drive
GenericName[es]=Almacenamiento en la nube
GenericName[fr]=Stockage en ligne
GenericName[it]=Archiviazione cloud
GenericName[nl]=Cloudopslag
GenericName[pl]=Dysk w chmurze
GenericName[pt_BR]=Armazenamento em nuvem
Comment=Files-on-demand Proton Drive (unofficial client) Comment=Files-on-demand Proton Drive (unofficial client)
Comment[de]=Proton Drive mit Dateien auf Abruf (inoffizieller Client)
Comment[en_GB]=Files-on-demand Proton Drive (unofficial client)
Comment[es]=Proton Drive con archivos bajo demanda (cliente no oficial)
Comment[fr]=Proton Drive avec fichiers à la demande (client non officiel)
Comment[it]=Proton Drive con file su richiesta (client non ufficiale)
Comment[nl]=Proton Drive met bestanden op aanvraag (onofficiële client)
Comment[pl]=Proton Drive z plikami na żądanie (nieoficjalny klient)
Comment[pt_BR]=Proton Drive com arquivos sob demanda (cliente não oficial)
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;files;backup; Keywords=proton;drive;cloud;sync;storage;files;backup;
Keywords[de]=proton;drive;cloud;sync;speicher;dateien;sicherung;synchronisation;
Keywords[en_GB]=proton;drive;cloud;sync;storage;files;backup;
Keywords[es]=proton;drive;nube;sincronizar;almacenamiento;archivos;copia de seguridad;
Keywords[fr]=proton;drive;cloud;nuage;synchronisation;stockage;fichiers;sauvegarde;
Keywords[it]=proton;drive;cloud;sincronizzazione;archiviazione;file;backup;
Keywords[nl]=proton;drive;cloud;synchronisatie;opslag;bestanden;back-up;
Keywords[pl]=proton;drive;chmura;synchronizacja;dysk;pliki;kopia zapasowa;
Keywords[pt_BR]=proton;drive;nuvem;sincronização;armazenamento;arquivos;backup;
StartupNotify=true StartupNotify=true
SingleMainWindow=true SingleMainWindow=true
+3
View File
@@ -27,6 +27,7 @@ BuildRequires: libsecret-devel
BuildRequires: dbus-devel BuildRequires: dbus-devel
BuildRequires: glib2-devel BuildRequires: glib2-devel
BuildRequires: webkitgtk6.0-devel BuildRequires: webkitgtk6.0-devel
BuildRequires: gettext
Requires: fuse3 Requires: fuse3
Requires: gtk4 Requires: gtk4
@@ -80,6 +81,7 @@ install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.svg \
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
install -D -m0644 %{git_dir}/packaging/proton-drive.service \ install -D -m0644 %{git_dir}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service %{buildroot}/usr/lib/systemd/user/proton-drive.service
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
%files %files
%license LICENSE %license LICENSE
@@ -91,6 +93,7 @@ install -D -m0644 %{git_dir}/packaging/proton-drive.service \
%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop %{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg %{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
/usr/lib/systemd/user/proton-drive.service /usr/lib/systemd/user/proton-drive.service
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
%changelog %changelog
* Sun Jul 19 2026 Local Packager - 0.4.0-1 * Sun Jul 19 2026 Local Packager - 0.4.0-1
+8
View File
@@ -0,0 +1,8 @@
de
en_GB
es
fr
it
nl
pl
pt_BR
+27
View File
@@ -0,0 +1,27 @@
crates/pdfs-gui/src/activation.rs
crates/pdfs-gui/src/app/main.rs
crates/pdfs-gui/src/app/pages/activity.rs
crates/pdfs-gui/src/app/pages/albums.rs
crates/pdfs-gui/src/app/pages/browser.rs
crates/pdfs-gui/src/app/pages/devices.rs
crates/pdfs-gui/src/app/pages/locations.rs
crates/pdfs-gui/src/app/pages/login.rs
crates/pdfs-gui/src/app/pages/photo_viewer.rs
crates/pdfs-gui/src/app/pages/photos.rs
crates/pdfs-gui/src/app/pages/shared.rs
crates/pdfs-gui/src/app/pages/shared_by_me.rs
crates/pdfs-gui/src/app/pages/status.rs
crates/pdfs-gui/src/app/pages/takeout.rs
crates/pdfs-gui/src/app/pages/trash.rs
crates/pdfs-gui/src/app/pages/verify.rs
crates/pdfs-gui/src/app/widgets/details.rs
crates/pdfs-gui/src/app/widgets/menu.rs
crates/pdfs-gui/src/app/widgets/share_dialog.rs
crates/pdfs-gui/src/app/widgets/thumbnails.rs
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
crates/pdfs-gui/src/dmenu.rs
crates/pdfs-gui/src/fzf.rs
crates/pdfs-gui/src/i18n.rs
crates/pdfs-gui/src/main.rs
crates/pdfs-gui/src/prompt.rs
crates/pdfs-gui/src/query.rs
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Compile every catalog in po/LINGUAS into <localedir>/<lang>/LC_MESSAGES/pdfs.mo.
# Packages pass their install tree, e.g. "$pkgdir/usr/share/locale"; for a run
# from the source tree use target/locale with PDFS_LOCALEDIR=target/locale.
set -euo pipefail
if [ $# -ne 1 ]; then
echo "usage: $0 <localedir>" >&2
exit 2
fi
localedir="$1"
po_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while read -r lang; do
[ -n "$lang" ] || continue
mkdir -p "$localedir/$lang/LC_MESSAGES"
msgfmt --check --output-file="$localedir/$lang/LC_MESSAGES/pdfs.mo" "$po_dir/$lang.po"
done < "$po_dir/LINGUAS"
+4887
View File
File diff suppressed because it is too large. Load diff
+4800
View File
File diff suppressed because it is too large. Load diff
+4864
View File
File diff suppressed because it is too large. Load diff
+4888
View File
File diff suppressed because it is too large. Load diff
+4870
View File
File diff suppressed because it is too large. Load diff
+4865
View File
File diff suppressed because it is too large. Load diff
+4702
View File
File diff suppressed because it is too large. Load diff
+4926
View File
File diff suppressed because it is too large. Load diff
+4851
View File
File diff suppressed because it is too large. Load diff
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Regenerate po/pdfs.pot from the sources listed in po/POTFILES.in, then merge
# the new template into every catalog in po/LINGUAS. Run from anywhere.
set -euo pipefail
po_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$po_dir/.."
version="$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)"
xgettext \
--language=Rust \
--from-code=UTF-8 \
--files-from=po/POTFILES.in \
--output=po/pdfs.pot \
--package-name=proton-drive-linux \
--package-version="$version" \
--msgid-bugs-address=https://github.com/narrrl/proton-drive-linux/issues \
--add-comments=Translators: \
--sort-by-file \
--keyword= \
--keyword=gettext \
--keyword=gettext_f \
--keyword=gettext_noop \
--keyword=pgettext:1c,2 \
--keyword=ngettext:1,2 \
--keyword=ngettext_f:1,2
while read -r lang; do
[ -n "$lang" ] || continue
if [ -f "po/$lang.po" ]; then
msgmerge --quiet --update --backup=none --previous "po/$lang.po" po/pdfs.pot
else
msginit --no-translator --locale="$lang.UTF-8" --input=po/pdfs.pot --output-file="po/$lang.po"
fi
done < po/LINGUAS