Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd14eb745 | ||
|
|
b3c03a7162 | ||
|
|
8749d6c2c6 | ||
|
|
4ee2aed29b | ||
|
|
753056418c | ||
|
|
2757977fd6 | ||
|
|
2f14c1688f | ||
|
|
7dbf818d83 | ||
|
|
c3ec82392a | ||
|
|
ce4656e46a | ||
|
|
557eeb5f1d | ||
|
|
b47e6c91a0 | ||
|
|
5c1da9d7a5 | ||
|
|
0135c29a4f | ||
|
|
0b81b76cb7 | ||
|
|
7470bd89f3 | ||
|
|
e6cb52339d | ||
|
|
912acc760a | ||
|
|
5109427c32 | ||
|
|
e2c4dac8df | ||
|
|
ebeb9cdbef | ||
|
|
82e96543ac | ||
|
|
96c77e96cb | ||
|
|
90e110e893 | ||
|
|
0b7236bd4a | ||
|
|
2fa5eaa75b | ||
|
|
dc9ea9f541 | ||
|
|
b13d9d9f38 | ||
|
|
b696adefc2 | ||
|
|
519ec1af07 | ||
|
|
4820ea1236 | ||
|
|
a9d6e0280d | ||
|
|
3e4c756361 | ||
|
|
38126ff2b3 | ||
|
|
9529f2124b | ||
|
|
77b8fd65be | ||
|
|
d9064ef880 | ||
|
|
66ab1f5b16 | ||
|
|
56161f649e |
No files matched your search
@@ -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
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
desktop-file-utils \
|
||||
gettext \
|
||||
rpm
|
||||
|
||||
- name: Set up Rust
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
cp target/release/pdfs-tray dist/binaries/
|
||||
cp target/release/pdfs-app dist/binaries/
|
||||
cp target/release/pdfs-prompt dist/binaries/
|
||||
po/build.sh dist/binaries/locale
|
||||
|
||||
# Create tarball
|
||||
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/proton-drive.service deb/usr/lib/systemd/user/
|
||||
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
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
@@ -199,3 +202,13 @@ jobs:
|
||||
prerelease: false
|
||||
env:
|
||||
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 }}
|
||||
@@ -467,6 +467,12 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1814,6 +1820,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
@@ -2829,6 +2855,19 @@ version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2868,6 +2907,15 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -3189,6 +3237,35 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ocb3"
|
||||
version = "0.1.0"
|
||||
@@ -3372,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "2.0.0"
|
||||
version = "2.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3387,7 +3464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "2.0.0"
|
||||
version = "2.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbus-secret-service-keyring-store",
|
||||
@@ -3411,7 +3488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "2.0.0"
|
||||
version = "2.3.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"fuser",
|
||||
@@ -3431,10 +3508,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "2.0.0"
|
||||
version = "2.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"gettext-rs",
|
||||
"gtk4",
|
||||
"kamadak-exif",
|
||||
"ksni",
|
||||
@@ -3668,9 +3746,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.6.6"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757"
|
||||
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3695,9 +3773,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.6.6"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce"
|
||||
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
@@ -4055,6 +4133,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
@@ -4796,6 +4886,12 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "temp-dir"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0"
|
||||
version = "2.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.6.6" }
|
||||
proton-drive-rs = { version = "0.6.6" }
|
||||
proton-sdk = { version = "0.7.0" }
|
||||
proton-drive-rs = { version = "0.7.0" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -55,7 +55,7 @@ pdfs-app # the desktop app
|
||||
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
||||
|
||||
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
||||
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
|
||||
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) · [Translating](docs/TRANSLATING.md) ·
|
||||
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
||||
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||
|
||||
@@ -75,6 +75,7 @@ pdfs-app # the desktop app
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines (public domain), so no map tiles are fetched; install `iso-codes` for country names in your language.
|
||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
@@ -348,11 +349,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>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -499,6 +504,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
|
||||
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
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
@@ -2074,6 +2074,7 @@ fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
|
||||
kind: None,
|
||||
range: None,
|
||||
favorites,
|
||||
not_in_album: false,
|
||||
})? {
|
||||
CtlResponse::Photos {
|
||||
available: false, ..
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
AD Andorra
|
||||
AE United Arab Emirates
|
||||
AF Afghanistan
|
||||
AG Antigua and Barbuda
|
||||
AI Anguilla
|
||||
AL Albania
|
||||
AM Armenia
|
||||
AO Angola
|
||||
AQ Antarctica
|
||||
AR Argentina
|
||||
AS American Samoa
|
||||
AT Austria
|
||||
AU Australia
|
||||
AW Aruba
|
||||
AX Åland Islands
|
||||
AZ Azerbaijan
|
||||
BA Bosnia and Herzegovina
|
||||
BB Barbados
|
||||
BD Bangladesh
|
||||
BE Belgium
|
||||
BF Burkina Faso
|
||||
BG Bulgaria
|
||||
BH Bahrain
|
||||
BI Burundi
|
||||
BJ Benin
|
||||
BL Saint Barthélemy
|
||||
BM Bermuda
|
||||
BN Brunei Darussalam
|
||||
BO Bolivia, Plurinational State of
|
||||
BQ Bonaire, Sint Eustatius and Saba
|
||||
BR Brazil
|
||||
BS Bahamas
|
||||
BT Bhutan
|
||||
BV Bouvet Island
|
||||
BW Botswana
|
||||
BY Belarus
|
||||
BZ Belize
|
||||
CA Canada
|
||||
CC Cocos (Keeling) Islands
|
||||
CD Congo, The Democratic Republic of the
|
||||
CF Central African Republic
|
||||
CG Congo
|
||||
CH Switzerland
|
||||
CI Côte d'Ivoire
|
||||
CK Cook Islands
|
||||
CL Chile
|
||||
CM Cameroon
|
||||
CN China
|
||||
CO Colombia
|
||||
CR Costa Rica
|
||||
CU Cuba
|
||||
CV Cabo Verde
|
||||
CW Curaçao
|
||||
CX Christmas Island
|
||||
CY Cyprus
|
||||
CZ Czechia
|
||||
DE Germany
|
||||
DJ Djibouti
|
||||
DK Denmark
|
||||
DM Dominica
|
||||
DO Dominican Republic
|
||||
DZ Algeria
|
||||
EC Ecuador
|
||||
EE Estonia
|
||||
EG Egypt
|
||||
EH Western Sahara
|
||||
ER Eritrea
|
||||
ES Spain
|
||||
ET Ethiopia
|
||||
FI Finland
|
||||
FJ Fiji
|
||||
FK Falkland Islands (Malvinas)
|
||||
FM Micronesia, Federated States of
|
||||
FO Faroe Islands
|
||||
FR France
|
||||
GA Gabon
|
||||
GB United Kingdom
|
||||
GD Grenada
|
||||
GE Georgia
|
||||
GF French Guiana
|
||||
GG Guernsey
|
||||
GH Ghana
|
||||
GI Gibraltar
|
||||
GL Greenland
|
||||
GM Gambia
|
||||
GN Guinea
|
||||
GP Guadeloupe
|
||||
GQ Equatorial Guinea
|
||||
GR Greece
|
||||
GS South Georgia and the South Sandwich Islands
|
||||
GT Guatemala
|
||||
GU Guam
|
||||
GW Guinea-Bissau
|
||||
GY Guyana
|
||||
HK Hong Kong
|
||||
HM Heard Island and McDonald Islands
|
||||
HN Honduras
|
||||
HR Croatia
|
||||
HT Haiti
|
||||
HU Hungary
|
||||
ID Indonesia
|
||||
IE Ireland
|
||||
IL Israel
|
||||
IM Isle of Man
|
||||
IN India
|
||||
IO British Indian Ocean Territory
|
||||
IQ Iraq
|
||||
IR Iran, Islamic Republic of
|
||||
IS Iceland
|
||||
IT Italy
|
||||
JE Jersey
|
||||
JM Jamaica
|
||||
JO Jordan
|
||||
JP Japan
|
||||
KE Kenya
|
||||
KG Kyrgyzstan
|
||||
KH Cambodia
|
||||
KI Kiribati
|
||||
KM Comoros
|
||||
KN Saint Kitts and Nevis
|
||||
KP Korea, Democratic People's Republic of
|
||||
KR Korea, Republic of
|
||||
KW Kuwait
|
||||
KY Cayman Islands
|
||||
KZ Kazakhstan
|
||||
LA Lao People's Democratic Republic
|
||||
LB Lebanon
|
||||
LC Saint Lucia
|
||||
LI Liechtenstein
|
||||
LK Sri Lanka
|
||||
LR Liberia
|
||||
LS Lesotho
|
||||
LT Lithuania
|
||||
LU Luxembourg
|
||||
LV Latvia
|
||||
LY Libya
|
||||
MA Morocco
|
||||
MC Monaco
|
||||
MD Moldova, Republic of
|
||||
ME Montenegro
|
||||
MF Saint Martin (French part)
|
||||
MG Madagascar
|
||||
MH Marshall Islands
|
||||
MK North Macedonia
|
||||
ML Mali
|
||||
MM Myanmar
|
||||
MN Mongolia
|
||||
MO Macao
|
||||
MP Northern Mariana Islands
|
||||
MQ Martinique
|
||||
MR Mauritania
|
||||
MS Montserrat
|
||||
MT Malta
|
||||
MU Mauritius
|
||||
MV Maldives
|
||||
MW Malawi
|
||||
MX Mexico
|
||||
MY Malaysia
|
||||
MZ Mozambique
|
||||
NA Namibia
|
||||
NC New Caledonia
|
||||
NE Niger
|
||||
NF Norfolk Island
|
||||
NG Nigeria
|
||||
NI Nicaragua
|
||||
NL Netherlands
|
||||
NO Norway
|
||||
NP Nepal
|
||||
NR Nauru
|
||||
NU Niue
|
||||
NZ New Zealand
|
||||
OM Oman
|
||||
PA Panama
|
||||
PE Peru
|
||||
PF French Polynesia
|
||||
PG Papua New Guinea
|
||||
PH Philippines
|
||||
PK Pakistan
|
||||
PL Poland
|
||||
PM Saint Pierre and Miquelon
|
||||
PN Pitcairn
|
||||
PR Puerto Rico
|
||||
PS Palestine, State of
|
||||
PT Portugal
|
||||
PW Palau
|
||||
PY Paraguay
|
||||
QA Qatar
|
||||
RE Réunion
|
||||
RO Romania
|
||||
RS Serbia
|
||||
RU Russian Federation
|
||||
RW Rwanda
|
||||
SA Saudi Arabia
|
||||
SB Solomon Islands
|
||||
SC Seychelles
|
||||
SD Sudan
|
||||
SE Sweden
|
||||
SG Singapore
|
||||
SH Saint Helena, Ascension and Tristan da Cunha
|
||||
SI Slovenia
|
||||
SJ Svalbard and Jan Mayen
|
||||
SK Slovakia
|
||||
SL Sierra Leone
|
||||
SM San Marino
|
||||
SN Senegal
|
||||
SO Somalia
|
||||
SR Suriname
|
||||
SS South Sudan
|
||||
ST Sao Tome and Principe
|
||||
SV El Salvador
|
||||
SX Sint Maarten (Dutch part)
|
||||
SY Syrian Arab Republic
|
||||
SZ Eswatini
|
||||
TC Turks and Caicos Islands
|
||||
TD Chad
|
||||
TF French Southern Territories
|
||||
TG Togo
|
||||
TH Thailand
|
||||
TJ Tajikistan
|
||||
TK Tokelau
|
||||
TL Timor-Leste
|
||||
TM Turkmenistan
|
||||
TN Tunisia
|
||||
TO Tonga
|
||||
TR Türkiye
|
||||
TT Trinidad and Tobago
|
||||
TV Tuvalu
|
||||
TW Taiwan, Province of China
|
||||
TZ Tanzania, United Republic of
|
||||
UA Ukraine
|
||||
UG Uganda
|
||||
UM United States Minor Outlying Islands
|
||||
US United States
|
||||
UY Uruguay
|
||||
UZ Uzbekistan
|
||||
VA Holy See (Vatican City State)
|
||||
VC Saint Vincent and the Grenadines
|
||||
VE Venezuela, Bolivarian Republic of
|
||||
VG Virgin Islands, British
|
||||
VI Virgin Islands, U.S.
|
||||
VN Viet Nam
|
||||
VU Vanuatu
|
||||
WF Wallis and Futuna
|
||||
WS Samoa
|
||||
XK Kosovo
|
||||
YE Yemen
|
||||
YT Mayotte
|
||||
ZA South Africa
|
||||
ZM Zambia
|
||||
ZW Zimbabwe
|
||||
|
@@ -142,6 +142,11 @@ pub struct AppConfig {
|
||||
/// back on. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -214,6 +219,7 @@ impl Default for AppConfig {
|
||||
proton_accent: None,
|
||||
files_view: FilesView::default(),
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +574,7 @@ mod tests {
|
||||
folders_first: false,
|
||||
},
|
||||
tray_hidden: true,
|
||||
language: Some("de".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).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.proton_accent, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||
assert_eq!(decoded.download_limit, None);
|
||||
assert_eq!(decoded.files_view, config.files_view);
|
||||
@@ -661,6 +669,10 @@ mod tests {
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.ignore_patterns.is_none());
|
||||
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
|
||||
assert!(
|
||||
decoded.language.is_none(),
|
||||
"an old config follows the system"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.resolved_ignore_patterns().len(),
|
||||
DEFAULT_IGNORE_PATTERNS.len(),
|
||||
|
||||
@@ -67,6 +67,10 @@ pub enum Request {
|
||||
/// get the whole timeline, as before.
|
||||
#[serde(default)]
|
||||
favorites: bool,
|
||||
/// Restrict the page to photos no album of ours holds — what is left to
|
||||
/// file. Older front-ends omit it and get the whole timeline, as before.
|
||||
#[serde(default)]
|
||||
not_in_album: bool,
|
||||
},
|
||||
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
|
||||
/// live photo and its clip. Replies with [`Response::Photos`], holding one
|
||||
@@ -80,6 +84,25 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
kind: Option<PhotoKind>,
|
||||
},
|
||||
/// Photos taken on today's date in earlier years, newest first, at most
|
||||
/// `limit` of them — the "On this day" strip above the timeline. Replies
|
||||
/// with [`Response::Photos`]; a front-end groups them by year itself.
|
||||
PhotosOnThisDay { limit: usize },
|
||||
/// Sets of photos that are byte-identical copies of each other, going by
|
||||
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
||||
PhotoDuplicates,
|
||||
/// The towns the photos were taken in, the one with the most photos first.
|
||||
/// Only photos whose uploader recorded a location count. Replies with
|
||||
/// [`Response::Places`].
|
||||
PhotoPlaces,
|
||||
/// Fetch a page of the photos taken in one town, newest first. `id` is the
|
||||
/// town's id from [`Request::PhotoPlaces`]. Replies with
|
||||
/// [`Response::Photos`], like an album page.
|
||||
PlacePhotos {
|
||||
id: u32,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
},
|
||||
/// List the account's photo albums, newest activity first, including the
|
||||
/// albums other people share with us. Metadata only — an album's cover
|
||||
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||
@@ -126,8 +149,14 @@ pub enum Request {
|
||||
///
|
||||
/// A photo that belongs to a group takes the whole group with it: trashing
|
||||
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
|
||||
/// deleting that photo.
|
||||
TrashNodes { uids: Vec<String> },
|
||||
/// deleting that photo. With `files_only` set, exactly the listed files
|
||||
/// go and their groups stay: the duplicate finder removes one copy of a
|
||||
/// shot, not the shot. Older front-ends omit it and trash whole groups.
|
||||
TrashNodes {
|
||||
uids: Vec<String>,
|
||||
#[serde(default)]
|
||||
files_only: bool,
|
||||
},
|
||||
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
||||
/// timeline. The modification time is the cache validity tag, so replacing
|
||||
/// an image can never reuse its previous revision's thumbnail.
|
||||
@@ -429,10 +458,12 @@ pub enum Request {
|
||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||
RetryPendingOp { id: Option<i64> },
|
||||
/// List the `(sync-conflict …)` copies under My Files. Replies with
|
||||
/// List the `(sync-conflict …)` copies under My Files and in synced
|
||||
/// folders. Replies with
|
||||
/// [`Response::Conflicts`].
|
||||
ListConflicts,
|
||||
/// Resolve one conflict copy (`path` mountpoint-relative or absolute).
|
||||
/// Resolve one conflict copy (`path` mountpoint-relative, or absolute for a
|
||||
/// copy in a synced folder).
|
||||
/// Everything removed goes to the Proton trash. Replies with
|
||||
/// [`Response::Ok`].
|
||||
ResolveConflict { path: String, keep: ConflictKeep },
|
||||
@@ -701,9 +732,10 @@ pub struct DeviceInfo {
|
||||
/// [`Response::Conflicts`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ConflictInfo {
|
||||
/// Mountpoint-relative path of the copy.
|
||||
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
||||
/// sits in a synced folder.
|
||||
pub path: String,
|
||||
/// Mountpoint-relative path of the file it is a copy of.
|
||||
/// Path of the file it is a copy of, in the same form as `path`.
|
||||
pub original_path: String,
|
||||
/// False when the original is gone and only the copy is left.
|
||||
pub original_exists: bool,
|
||||
@@ -1415,6 +1447,35 @@ pub struct AlbumInfo {
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// One town in a [`Response::Places`] listing.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PlaceInfo {
|
||||
/// The town's GeoNames id — what [`Request::PlacePhotos`] takes.
|
||||
pub id: u32,
|
||||
/// The town's name, as the town itself spells it.
|
||||
pub name: String,
|
||||
/// ISO 3166-1 alpha-2 code of the town's country.
|
||||
pub country: String,
|
||||
/// Where the town is, in decimal degrees, for the map. Zero from a daemon
|
||||
/// older than the map.
|
||||
#[serde(default)]
|
||||
pub latitude: f64,
|
||||
#[serde(default)]
|
||||
pub longitude: f64,
|
||||
/// How many photos were taken there, counting a RAW+JPEG shot once.
|
||||
pub photo_count: usize,
|
||||
/// The newest photo taken there, shown as the place's cover.
|
||||
pub cover: PhotoItem,
|
||||
}
|
||||
|
||||
/// How many of the timeline's photos a running refresh has read the metadata
|
||||
/// (and so the location) of, out of how many. Part of [`Response::Places`].
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MappingProgress {
|
||||
pub done: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoItem {
|
||||
@@ -1773,6 +1834,17 @@ pub enum Response {
|
||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||
/// newest first.
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
|
||||
/// first. Each set lists the copy worth keeping first.
|
||||
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
|
||||
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
|
||||
/// and how far a running timeline refresh has got reading photo locations
|
||||
/// — `None` when none is reading any.
|
||||
Places {
|
||||
items: Vec<PlaceInfo>,
|
||||
#[serde(default)]
|
||||
mapping: Option<MappingProgress>,
|
||||
},
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
Thumbs { items: Vec<PhotoThumb> },
|
||||
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
||||
@@ -2169,7 +2241,17 @@ mod tests {
|
||||
fn trash_requests_and_outcomes_roundtrip() {
|
||||
let request = Request::TrashNodes {
|
||||
uids: vec!["volume~first".into(), "volume~second".into()],
|
||||
files_only: false,
|
||||
};
|
||||
// An older front-end's request trashes whole groups.
|
||||
let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
old,
|
||||
Request::TrashNodes {
|
||||
files_only: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let line = serde_json::to_string(&request).unwrap();
|
||||
assert!(!line.contains('\n'), "wire form must be a single line");
|
||||
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||
@@ -2456,6 +2538,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A place listing and a place page cross the wire intact: a mangled id
|
||||
/// would open the wrong town.
|
||||
#[test]
|
||||
fn places_requests_and_reply_roundtrip() {
|
||||
for req in [
|
||||
Request::PhotoPlaces,
|
||||
Request::PlacePhotos {
|
||||
id: 2950159,
|
||||
offset: 60,
|
||||
limit: 30,
|
||||
},
|
||||
] {
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
let back: Request = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
||||
}
|
||||
|
||||
let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2,
|
||||
"cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#;
|
||||
let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else {
|
||||
panic!("not a place listing");
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE"));
|
||||
assert_eq!(items[0].cover.uid, "vol~b2");
|
||||
// A daemon from before the progress field still parses: no mapping.
|
||||
assert_eq!(mapping, None);
|
||||
|
||||
let reply = Response::Places {
|
||||
items: Vec::new(),
|
||||
mapping: Some(MappingProgress {
|
||||
done: 120,
|
||||
total: 480,
|
||||
}),
|
||||
};
|
||||
let line = serde_json::to_string(&reply).unwrap();
|
||||
let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else {
|
||||
panic!("not a place listing");
|
||||
};
|
||||
assert_eq!(
|
||||
mapping,
|
||||
Some(MappingProgress {
|
||||
done: 120,
|
||||
total: 480
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// The sharing and devices requests must survive the same line-delimited JSON
|
||||
/// round-trip: a mangled id or role would touch the wrong share or member.
|
||||
#[test]
|
||||
@@ -2787,6 +2917,7 @@ mod tests {
|
||||
decoded,
|
||||
Request::PhotosTimeline {
|
||||
favorites: false,
|
||||
not_in_album: false,
|
||||
kind: None,
|
||||
range: None,
|
||||
..
|
||||
|
||||
@@ -29,6 +29,17 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
|
||||
/// the daemon report something once across restarts rather than once per
|
||||
/// run, without keeping a second table in step with the log.
|
||||
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
|
||||
let kind = serde_json::to_string(&kind)?;
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
|
||||
Ok(stmt.exists(params![kind, target])?)
|
||||
}
|
||||
|
||||
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||
/// whose stored `kind` no longer parses (written by an older build) are
|
||||
/// skipped rather than failing the whole read.
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 31;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 33;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -270,6 +270,36 @@ impl Db {
|
||||
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), [])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if current < 33 {
|
||||
// Same guards as V26-V31.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V33)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -952,3 +982,27 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||
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;
|
||||
";
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Schema v33: where each photo was taken.
|
||||
///
|
||||
/// `latitude` and `longitude` come from the location other clients write into
|
||||
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||
///
|
||||
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||
/// the whole library once. That is the backfill: the metadata is on the node
|
||||
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||
const MIGRATION_V33: &str = "
|
||||
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||
UPDATE photos SET resolved_at = NULL;
|
||||
";
|
||||
@@ -59,7 +59,7 @@ pub use ops::{
|
||||
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -42,6 +42,15 @@ pub struct StoredPhoto {
|
||||
pub has_raw: bool,
|
||||
}
|
||||
|
||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||
/// many photos it holds, and the newest of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPlace {
|
||||
pub id: u32,
|
||||
pub count: usize,
|
||||
pub cover: StoredPhoto,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||
@@ -58,6 +67,8 @@ pub struct TimelineRow {
|
||||
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||
/// sibling), when the server says it belongs to one.
|
||||
pub main_uid: Option<String>,
|
||||
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
|
||||
pub location: Option<(f64, f64)>,
|
||||
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||
/// makes every other field authoritative — including a `None` that means
|
||||
/// "the server no longer reports one" — while `None` means the refresh
|
||||
@@ -86,6 +97,7 @@ struct Learned {
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
main_uid: Option<String>,
|
||||
location: Option<(f64, f64)>,
|
||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||
/// does not make it look unresolved again.
|
||||
resolved_at: Option<i64>,
|
||||
@@ -281,7 +293,7 @@ impl Db {
|
||||
let learned: HashMap<String, Learned> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||
main_uid, resolved_at FROM photos",
|
||||
main_uid, resolved_at, latitude, longitude FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
@@ -295,6 +307,9 @@ impl Db {
|
||||
content_hash: r.get(6)?,
|
||||
main_uid: r.get(7)?,
|
||||
resolved_at: r.get(8)?,
|
||||
location: r
|
||||
.get::<_, Option<f64>>(9)?
|
||||
.zip(r.get::<_, Option<f64>>(10)?),
|
||||
},
|
||||
))
|
||||
})?;
|
||||
@@ -319,12 +334,14 @@ impl Db {
|
||||
carried.favorite = row.favorite.unwrap_or(false);
|
||||
carried.content_hash = row.content_hash.clone();
|
||||
carried.main_uid = row.main_uid.clone();
|
||||
carried.location = row.location;
|
||||
} else {
|
||||
carried.name = row.name.clone().or(carried.name);
|
||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||
carried.location = row.location.or(carried.location);
|
||||
}
|
||||
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
@@ -355,8 +372,10 @@ impl Db {
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
content_hash, main_uid, group_key, resolved_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||
place_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||
?16)",
|
||||
)?;
|
||||
for (seq, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
@@ -375,6 +394,12 @@ impl Db {
|
||||
carried.main_uid,
|
||||
group_key,
|
||||
carried.resolved_at,
|
||||
carried.location.map(|(lat, _)| lat),
|
||||
carried.location.map(|(_, lon)| lon),
|
||||
carried
|
||||
.location
|
||||
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||
.map(|city| city.id),
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -447,6 +472,14 @@ impl Db {
|
||||
albums.execute([uid])?;
|
||||
}
|
||||
}
|
||||
// A group whose representative went is left pointing at nothing, which
|
||||
// drops its other files off every page. Make them stand alone until the
|
||||
// next refresh groups them again.
|
||||
tx.execute(
|
||||
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
|
||||
AND group_key NOT IN (SELECT uid FROM photos)",
|
||||
[],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
}
|
||||
@@ -455,7 +488,8 @@ impl Db {
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
/// `favorites` restricts the page to favorited photos.
|
||||
/// `favorites` restricts the page to favorited photos, and `not_in_album`
|
||||
/// to the shots no album holds any file of.
|
||||
///
|
||||
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||
@@ -467,6 +501,7 @@ impl Db {
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
not_in_album: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
@@ -490,6 +525,15 @@ impl Db {
|
||||
if favorites {
|
||||
conds.push("p.favorite = 1".to_string());
|
||||
}
|
||||
// By group, not by file: a shot filed as its JPEG is filed, even though
|
||||
// its RAW was never added anywhere.
|
||||
if not_in_album {
|
||||
conds.push(
|
||||
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
|
||||
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
@@ -577,6 +621,133 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Photos taken on `today`'s calendar day in earlier years, newest first —
|
||||
/// the "On this day" strip. `today` is epoch seconds: its local month and day
|
||||
/// pick the date, and its own year is left out, because today's photos are
|
||||
/// what the timeline already opens on. One row per group, like the timeline.
|
||||
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
|
||||
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
|
||||
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
|
||||
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
|
||||
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
|
||||
ORDER BY p.seq LIMIT ?2"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![today, limit as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every town photos were taken in, with how many and the newest one as its
|
||||
/// cover, most photographed first. Counted per group, like the timeline;
|
||||
/// photos without a location, or taken far from any town, are in none.
|
||||
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
|
||||
let rows: Vec<(u32, usize, String)> = {
|
||||
let conn = self.read();
|
||||
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
|
||||
// is the newest photo of the place.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
|
||||
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
|
||||
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
|
||||
)?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
|
||||
let mut covers: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(id, count, uid)| {
|
||||
Some(StoredPlace {
|
||||
id,
|
||||
count,
|
||||
cover: covers.remove(&uid)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One page of the photos taken in a town, newest first, one per group.
|
||||
pub fn photos_at_place(
|
||||
&self,
|
||||
id: u32,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
|
||||
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Sets of photos with the same content hash, each set ordered with the
|
||||
/// copy worth keeping first, and the sets newest first.
|
||||
///
|
||||
/// The copy to keep is the one in the most albums, then the one that is part
|
||||
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
|
||||
/// not), then the earliest in server order. At most one file per group is
|
||||
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
|
||||
/// set left with one member is no set.
|
||||
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, String, String)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
|
||||
WHERE p.content_hash IN (SELECT content_hash FROM photos \
|
||||
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
|
||||
ORDER BY p.content_hash, \
|
||||
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
|
||||
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
|
||||
p.seq",
|
||||
)?;
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
|
||||
let mut hash_of_set: Option<&str> = None;
|
||||
let mut groups_in_set: HashSet<&str> = HashSet::new();
|
||||
for (uid, hash, group) in &rows {
|
||||
if hash_of_set != Some(hash.as_str()) {
|
||||
hash_of_set = Some(hash);
|
||||
groups_in_set.clear();
|
||||
sets.push(Vec::new());
|
||||
}
|
||||
if !groups_in_set.insert(group) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
|
||||
set.push(photo);
|
||||
}
|
||||
}
|
||||
sets.retain(|set| set.len() > 1);
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
|
||||
@@ -140,7 +140,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p0", "p1", "p2"],
|
||||
@@ -163,7 +163,14 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
// The counts break down by tab, and a filtered page returns only its tab.
|
||||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||||
let videos = db
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
|
||||
.photos_page(
|
||||
0,
|
||||
10,
|
||||
Some(crate::control::PhotoKind::Video),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
@@ -176,7 +183,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
||||
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||||
let ranged = db
|
||||
.photos_page(0, 10, None, Some((150, 350)), false)
|
||||
.photos_page(0, 10, None, Some((150, 350)), false, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
@@ -189,6 +196,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
Some(crate::control::PhotoKind::Video),
|
||||
Some((150, 350)),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -218,7 +226,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1"],
|
||||
@@ -236,7 +244,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
TimelineRow::new("p2", 200),
|
||||
])
|
||||
.unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2"],
|
||||
@@ -245,6 +253,211 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||||
}
|
||||
|
||||
/// The strip shows the same calendar day in earlier years only: not today's
|
||||
/// own photos, not the day before, and one tile per RAW+JPEG shot.
|
||||
#[test]
|
||||
fn on_this_day_finds_the_same_date_in_earlier_years() {
|
||||
const DAY: i64 = 86_400;
|
||||
// Noon UTC, so the local date is the same in every common time zone.
|
||||
let today = 1_781_524_800; // 2026-06-15 12:00 UTC
|
||||
let year_ago = today - 365 * DAY; // 2025-06-15
|
||||
let three_years_ago = today - (365 * 3 + 1) * DAY; // 2023-06-15, past a 29 February
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow::new("today", today),
|
||||
TimelineRow {
|
||||
name: Some("IMG_1.JPG".into()),
|
||||
..TimelineRow::new("jpeg", year_ago)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("IMG_1.CR2".into()),
|
||||
..TimelineRow::new("raw", year_ago)
|
||||
},
|
||||
TimelineRow::new("day-before", year_ago - DAY),
|
||||
TimelineRow::new("older", three_years_ago),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let found = db.photos_on_this_day(today, 10).unwrap();
|
||||
assert_eq!(
|
||||
found.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["jpeg", "older"]
|
||||
);
|
||||
}
|
||||
|
||||
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
|
||||
TimelineRow {
|
||||
name: Some(name.into()),
|
||||
content_hash: Some(hash.into()),
|
||||
..TimelineRow::new(uid, capture_time)
|
||||
}
|
||||
}
|
||||
|
||||
fn located(uid: &str, capture_time: i64, name: &str, at: (f64, f64)) -> TimelineRow {
|
||||
TimelineRow {
|
||||
name: Some(name.into()),
|
||||
location: Some(at),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new(uid, capture_time)
|
||||
}
|
||||
}
|
||||
|
||||
const BERLIN: (f64, f64) = (52.52, 13.405);
|
||||
const PARIS: (f64, f64) = (48.857, 2.352);
|
||||
|
||||
/// Places count shots, not files, lead with the most photographed town, and
|
||||
/// leave out photos with no location or taken far from any town.
|
||||
#[test]
|
||||
fn places_group_photos_by_the_nearest_town() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||||
located("p1", 400, "IMG_4.JPG", PARIS),
|
||||
located("b1-jpeg", 300, "IMG_3.JPG", BERLIN),
|
||||
located("b1-raw", 300, "IMG_3.CR2", BERLIN),
|
||||
located("sea", 200, "IMG_2.JPG", (0.0, -30.0)),
|
||||
TimelineRow::new("nowhere", 100),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let places = db.photo_places().unwrap();
|
||||
let summary: Vec<(&str, usize, &str)> = places
|
||||
.iter()
|
||||
.map(|place| {
|
||||
let city = crate::places::city(place.id).expect("a known town");
|
||||
(city.name, place.count, place.cover.uid.as_str())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(summary, [("Berlin", 2, "b2"), ("Paris", 1, "p1")]);
|
||||
|
||||
let berlin: Vec<String> = db
|
||||
.photos_at_place(places[0].id, 0, 10)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.uid)
|
||||
.collect();
|
||||
assert_eq!(berlin, ["b2", "b1-jpeg"]);
|
||||
}
|
||||
|
||||
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||||
/// that reads the node again takes what the node now says.
|
||||
#[test]
|
||||
fn a_skipped_refresh_keeps_a_photos_location() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[located("a", 100, "IMG_1.JPG", BERLIN)])
|
||||
.unwrap();
|
||||
db.photos_replace(&[TimelineRow::new("a", 100)]).unwrap();
|
||||
assert_eq!(db.photo_places().unwrap().len(), 1);
|
||||
|
||||
db.photos_replace(&[TimelineRow {
|
||||
resolved_at: Some(2),
|
||||
..TimelineRow::new("a", 100)
|
||||
}])
|
||||
.unwrap();
|
||||
assert!(db.photo_places().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A copy an album holds is the one kept; the files of one RAW+JPEG shot are
|
||||
/// never copies of each other; a hash only one file carries is no set.
|
||||
#[test]
|
||||
fn duplicates_list_the_copy_to_keep_first() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
hashed("copy", 500, "IMG_2.JPG", "h2"),
|
||||
hashed("filed", 400, "IMG_2(1).JPG", "h2"),
|
||||
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||||
hashed("raw", 300, "IMG_1.CR2", "h1"),
|
||||
hashed("stray", 200, "IMG_1(1).CR2", "h1"),
|
||||
hashed("alone", 100, "IMG_3.JPG", "h3"),
|
||||
])
|
||||
.unwrap();
|
||||
db.album_photos_replace("a1", &[("filed".into(), 400, None, None)])
|
||||
.unwrap();
|
||||
|
||||
let sets = db.photo_duplicates().unwrap();
|
||||
let uids: Vec<Vec<&str>> = sets
|
||||
.iter()
|
||||
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||
.collect();
|
||||
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
||||
}
|
||||
|
||||
/// Trashing the file a group is shown as leaves the rest of the group on the
|
||||
/// timeline instead of pointing at a photo that is gone.
|
||||
#[test]
|
||||
fn deleting_a_group_representative_keeps_its_other_files() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||||
hashed("raw", 300, "IMG_1.CR2", "h2"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.photos_page(0, 10, None, None, false, false)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
db.photos_delete(&["jpeg".into()]).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["raw"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_in_album_hides_photos_any_album_holds() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow::new("p1", 300),
|
||||
TimelineRow::new("p2", 200),
|
||||
TimelineRow::new("p3", 100),
|
||||
])
|
||||
.unwrap();
|
||||
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||||
.unwrap();
|
||||
db.album_photos_replace("a2", &[("p3".into(), 100, None, None)])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Filing the JPEG of a RAW+JPEG shot files the shot: the grid shows it as
|
||||
/// one tile, and that tile is in an album.
|
||||
#[test]
|
||||
fn not_in_album_counts_a_raw_pair_as_filed() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("IMG_1234.JPG".into()),
|
||||
..TimelineRow::new("jpeg", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("img_1234.cr2".into()),
|
||||
..TimelineRow::new("raw", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("IMG_9999.JPG".into()),
|
||||
..TimelineRow::new("alone", 200)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
db.album_photos_replace("a1", &[("raw".into(), 300, None, None)])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["alone"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_page_slices_the_timeline_in_order() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -253,12 +466,16 @@ fn photos_page_slices_the_timeline_in_order() {
|
||||
.collect();
|
||||
db.photos_replace(&items).unwrap();
|
||||
|
||||
let page = db.photos_page(2, 2, None, None, false).unwrap();
|
||||
let page = db.photos_page(2, 2, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2", "p3"]
|
||||
);
|
||||
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
|
||||
assert!(
|
||||
db.photos_page(9, 2, None, None, false, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1414,6 +1631,29 @@ fn activity_reads_back_newest_first() {
|
||||
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_has_finds_only_matching_kind_and_target() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.activity_add(&activity(
|
||||
"a (sync-conflict 1).txt",
|
||||
ActivityKind::Conflict,
|
||||
false,
|
||||
))
|
||||
.unwrap();
|
||||
db.activity_add(&activity("b.txt", ActivityKind::Upload, true))
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
db.activity_has(ActivityKind::Conflict, "a (sync-conflict 1).txt")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!db.activity_has(ActivityKind::Upload, "a (sync-conflict 1).txt")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!db.activity_has(ActivityKind::Conflict, "b.txt").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_prunes_to_the_keep_limit() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -4054,7 +4294,7 @@ fn photos_delete_removes_the_photo_and_its_album_membership() {
|
||||
|
||||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1", "p3"],
|
||||
@@ -4094,7 +4334,7 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["jpeg", "alone"],
|
||||
@@ -4107,7 +4347,14 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
||||
|
||||
// The Raw tab answers "which raws do I have", so it lists files.
|
||||
let raws = db
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false)
|
||||
.photos_page(
|
||||
0,
|
||||
10,
|
||||
Some(crate::control::PhotoKind::Raw),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
@@ -4152,7 +4399,7 @@ fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["cover", "notes", "other"],
|
||||
@@ -4191,7 +4438,7 @@ fn a_shared_name_alone_does_not_group_photos() {
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(page.len(), 3, "three shots, three tiles");
|
||||
assert!(page.iter().all(|p| p.group_size == 1));
|
||||
}
|
||||
@@ -4214,7 +4461,7 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["main"]
|
||||
@@ -4235,6 +4482,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
||||
/// 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
|
||||
/// 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]
|
||||
fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -4274,7 +4563,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["jpeg", "raw"],
|
||||
@@ -4295,7 +4584,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].uid, "jpeg");
|
||||
|
||||
@@ -4324,14 +4613,14 @@ fn photos_replace_keeps_a_skipped_photos_name() {
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(page.len(), 1, "one shot, two files");
|
||||
assert!(page[0].has_raw);
|
||||
|
||||
// The next refresh resolves neither, because both are already resolved.
|
||||
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
||||
.unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
|
||||
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
||||
assert!(page[0].has_raw, "the raw file is still a raw file");
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
pub mod opener;
|
||||
pub mod places;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
|
||||
//! position, without asking any service.
|
||||
//!
|
||||
//! The towns are GeoNames' `cities15000` dump (every place with more than
|
||||
//! 15,000 people), trimmed to id, name, position and country in
|
||||
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
|
||||
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
|
||||
//! lands in the town people would name. GeoNames publishes the dump under
|
||||
//! CC BY 4.0, which is why the About dialog credits it.
|
||||
//!
|
||||
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
|
||||
//! as the `iso-codes` project spells it, so a front end can translate it
|
||||
//! through that project's `iso_3166-1` gettext domain.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How far from the nearest town a photo may be and still count as taken
|
||||
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
|
||||
pub const MAX_DISTANCE_KM: f64 = 50.0;
|
||||
|
||||
/// Mean Earth radius in km, for the haversine distance.
|
||||
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||
|
||||
const CITIES: &str = include_str!("../data/cities15000.tsv");
|
||||
const COUNTRIES: &str = include_str!("../data/countries.tsv");
|
||||
|
||||
/// One town of the table.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct City {
|
||||
/// The GeoNames id, stable across dumps.
|
||||
pub id: u32,
|
||||
pub name: &'static str,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
/// ISO 3166-1 alpha-2 country code.
|
||||
pub country: &'static str,
|
||||
}
|
||||
|
||||
/// The towns, and an index of them by whole-degree cell.
|
||||
struct Table {
|
||||
cities: Vec<City>,
|
||||
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||
}
|
||||
|
||||
fn table() -> &'static Table {
|
||||
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
|
||||
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||
for (index, city) in cities.iter().enumerate() {
|
||||
cells
|
||||
.entry(cell(city.latitude, city.longitude))
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
Table { cities, cells }
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_city(line: &'static str) -> Option<City> {
|
||||
let mut fields = line.split('\t');
|
||||
Some(City {
|
||||
id: fields.next()?.parse().ok()?,
|
||||
name: fields.next()?,
|
||||
latitude: fields.next()?.parse().ok()?,
|
||||
longitude: fields.next()?.parse().ok()?,
|
||||
country: fields.next()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
|
||||
(latitude.floor() as i32, longitude.floor() as i32)
|
||||
}
|
||||
|
||||
/// Great-circle distance between two positions, in km.
|
||||
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
|
||||
let dlat = lat2 - lat1;
|
||||
let dlon = (b.1 - a.1).to_radians();
|
||||
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
|
||||
}
|
||||
|
||||
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
|
||||
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
|
||||
if !latitude.is_finite() || !longitude.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let table = table();
|
||||
// A degree of latitude is about 111 km everywhere; a degree of longitude
|
||||
// shrinks towards the poles, so more cells are needed to cover the radius.
|
||||
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
|
||||
let lon_span = {
|
||||
let scale = latitude.to_radians().cos().abs().max(0.01);
|
||||
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
|
||||
};
|
||||
let (row, column) = cell(latitude, longitude);
|
||||
let mut best: Option<(f64, &City)> = None;
|
||||
for dlat in -lat_span..=lat_span {
|
||||
for dlon in -lon_span..=lon_span {
|
||||
// Longitude wraps at the antimeridian: -181 is 179.
|
||||
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
|
||||
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
|
||||
continue;
|
||||
};
|
||||
for &index in indices {
|
||||
let city = &table.cities[index];
|
||||
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
|
||||
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, city));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, city)| city)
|
||||
}
|
||||
|
||||
/// The town with this GeoNames id.
|
||||
pub fn city(id: u32) -> Option<&'static City> {
|
||||
let cities = &table().cities;
|
||||
// The table is sorted by id.
|
||||
cities
|
||||
.binary_search_by_key(&id, |city| city.id)
|
||||
.ok()
|
||||
.map(|index| &cities[index])
|
||||
}
|
||||
|
||||
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
|
||||
pub fn country_name(code: &str) -> Option<&'static str> {
|
||||
COUNTRIES.lines().find_map(|line| {
|
||||
let (c, name) = line.split_once('\t')?;
|
||||
(c == code).then_some(name)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_position_in_a_town_resolves_to_that_town() {
|
||||
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
|
||||
assert_eq!(berlin.name, "Berlin");
|
||||
assert_eq!(berlin.country, "DE");
|
||||
assert_eq!(city(berlin.id), Some(berlin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_sea_has_no_town() {
|
||||
assert_eq!(nearest(0.0, -30.0), None);
|
||||
assert_eq!(nearest(f64::NAN, 0.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_town_across_the_antimeridian_is_still_found() {
|
||||
// Just east of 180°, the nearest towns in Fiji are west of it.
|
||||
let town = nearest(-16.8, 179.99);
|
||||
assert!(town.is_none_or(|t| t.country == "FJ"));
|
||||
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
|
||||
assert_eq!(suva.country, "FJ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_town_has_a_named_country() {
|
||||
for city in &table().cities {
|
||||
assert!(
|
||||
country_name(city.country).is_some(),
|
||||
"{} has no country name for {}",
|
||||
city.name,
|
||||
city.country
|
||||
);
|
||||
}
|
||||
assert_eq!(country_name("DE"), Some("Germany"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distances_are_great_circle_km() {
|
||||
let d = distance_km((52.52, 13.405), (48.137, 11.575));
|
||||
assert!(
|
||||
(d - 504.0).abs() < 5.0,
|
||||
"Berlin to Munich is about 504 km, got {d}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,40 @@ impl Core {
|
||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Make sure every album of ours has its contents on disk, for a filter
|
||||
/// that asks which photos no album holds. Contents are otherwise fetched
|
||||
/// the first time an album is opened, so an album never opened would read
|
||||
/// as empty and its photos as unfiled.
|
||||
///
|
||||
/// Shared-with-me albums are skipped: their photos live on the sharer's
|
||||
/// volume and are never in our timeline. The freshness stamps are no guide
|
||||
/// here — any album change clears all of them — so only an album whose
|
||||
/// server count disagrees with an empty local copy is waited for.
|
||||
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
|
||||
if self.db.albums_count().map_err(CoreError::from)? == 0
|
||||
&& !self.rt.block_on(self.refresh_albums())?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
|
||||
let stored = self
|
||||
.db
|
||||
.album_photos_count(&album.uid)
|
||||
.map_err(CoreError::from)?;
|
||||
if stored > 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = parse_uid(&album.uid) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
|
||||
warn!(album = %album.uid, %error, "fetching an album's contents failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
|
||||
@@ -14,15 +14,38 @@
|
||||
//! that is open, being written, or still owed an upload, for the same reason the
|
||||
//! sweep does: the node is about to change under the decision.
|
||||
//!
|
||||
//! Only copies under My Files are handled here. A copy inside a mirror folder is
|
||||
//! a local file first; the sync engine owns it.
|
||||
//! Copies under My Files are named by their mountpoint-relative path. Copies
|
||||
//! inside a synced folder are named by their absolute local path, so a resolve
|
||||
//! request routes to the on-demand mount that serves them. A copy inside a
|
||||
//! mirror folder is listed too, but it is a local file first: the sync engine
|
||||
//! owns it, so it is resolved by editing the folder, not through here.
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||
use sweep::conflict_base_name;
|
||||
|
||||
impl Core {
|
||||
/// Every conflict copy under My Files, with the file it is a copy of.
|
||||
/// Where the user finds a conflict copy: its mountpoint-relative path under
|
||||
/// My Files, or its absolute local path inside a synced folder. `None` for a
|
||||
/// node no location of this machine shows, such as one in another
|
||||
/// computer's device tree.
|
||||
///
|
||||
/// The sweep only flags what this can name, and the listing uses it too. A
|
||||
/// flagged copy the listing cannot see reads as resolved in the activity
|
||||
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
|
||||
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
|
||||
let uid = uid.to_string();
|
||||
if let Ok(Some(path)) = self
|
||||
.db
|
||||
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
let drive_path = self.db.node_path(&uid).ok().flatten()?;
|
||||
roots.resolve(&drive_path)
|
||||
}
|
||||
|
||||
/// Every conflict copy this machine shows, with the file it is a copy of.
|
||||
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
||||
let stored = self
|
||||
.db
|
||||
@@ -37,13 +60,13 @@ impl Core {
|
||||
for node in &nodes {
|
||||
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||
}
|
||||
let root = self.primary_root_uid.to_string();
|
||||
let roots = self.search_roots();
|
||||
let mut items = Vec::new();
|
||||
for node in &nodes {
|
||||
let Some(base) = conflict_base_name(&node.name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) else {
|
||||
let Some(path) = self.conflict_path(&roots, &node.uid) else {
|
||||
continue;
|
||||
};
|
||||
let original = by_parent_name
|
||||
|
||||
@@ -185,6 +185,38 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
||||
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||
}
|
||||
|
||||
/// [`route_to_mount`] for a conflict copy. The listing names a copy inside a
|
||||
/// synced folder by its absolute local path (`docs/BUGS.md` B99), so the
|
||||
/// resolve has to run against the on-demand mount serving it. A mirror folder
|
||||
/// has no mount to run against: its copy is an ordinary local file.
|
||||
fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||
let p = Path::new(path);
|
||||
let mirrored = core
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||
if mirrored {
|
||||
CoreError::invalid(format!(
|
||||
"{path} is in a mirrored folder; rename or delete the local file there"
|
||||
))
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
if routed
|
||||
.1
|
||||
.components()
|
||||
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
Ok(routed)
|
||||
} else {
|
||||
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||
/// the mount root.
|
||||
@@ -408,7 +440,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
kind,
|
||||
range,
|
||||
favorites,
|
||||
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
||||
not_in_album,
|
||||
}) => match core.photos_timeline(
|
||||
offset,
|
||||
clamp_limit(limit),
|
||||
kind,
|
||||
range,
|
||||
favorites,
|
||||
not_in_album,
|
||||
) {
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
@@ -421,6 +461,37 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
||||
Ok(items) => CtlResponse::Places {
|
||||
items,
|
||||
mapping: core.timeline_progress(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PlacePhotos { id, offset, limit }) => {
|
||||
match core.place_photos(id, offset, clamp_limit(limit)) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
@@ -514,8 +585,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
items: core.photo_thumbs(&parsed),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
|
||||
Ok(parsed) => match core.trash_photos(&parsed) {
|
||||
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
|
||||
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
|
||||
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||
trashed,
|
||||
failed: failed
|
||||
@@ -1123,13 +1194,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(items) => CtlResponse::Conflicts { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ResolveConflict { path, keep }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.resolve_conflict(&rel, &keep) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Ok(CtlRequest::ResolveConflict { path, keep }) => {
|
||||
match route_conflict(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||
Ok(items) => CtlResponse::PendingOps { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
|
||||
@@ -945,6 +945,7 @@ impl Core {
|
||||
if !meta.complete {
|
||||
return Err("queued create holds an incomplete blob".into());
|
||||
}
|
||||
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
@@ -955,7 +956,7 @@ impl Core {
|
||||
media_type_for(name),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
@@ -1145,6 +1146,7 @@ impl Core {
|
||||
}
|
||||
|
||||
let alt = conflict_name(&name, now_secs());
|
||||
let thumbnails = self.upload_thumbnails(blob, &alt);
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
@@ -1155,7 +1157,7 @@ impl Core {
|
||||
media_type_for(&alt),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
@@ -1438,6 +1440,7 @@ impl Core {
|
||||
}
|
||||
|
||||
let name = self.node_name(&uid);
|
||||
let thumbnails = self.upload_thumbnails(&blob, &name);
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
@@ -1451,7 +1454,7 @@ impl Core {
|
||||
&uid,
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
));
|
||||
let took = started.elapsed();
|
||||
|
||||
@@ -550,6 +550,10 @@ struct Core {
|
||||
/// already running, so a burst of page requests against a stale listing kicks
|
||||
/// off one refresh rather than one per request.
|
||||
timeline_refreshing: Arc<AtomicBool>,
|
||||
/// How far the running timeline refresh has got resolving photo metadata,
|
||||
/// as `(done, total)` photos; `None` when no refresh is resolving any. The
|
||||
/// Places view shows it while a library's locations are first read.
|
||||
timeline_progress: Arc<Mutex<Option<(usize, usize)>>>,
|
||||
/// The same, for the album listing.
|
||||
albums_refreshing: Arc<AtomicBool>,
|
||||
trash_refreshing: Arc<AtomicBool>,
|
||||
@@ -558,9 +562,10 @@ struct Core {
|
||||
/// instead of polling or blocking for the whole run. See
|
||||
/// [`Core::await_trash_refresh`].
|
||||
trash_progress: Arc<tokio::sync::Notify>,
|
||||
/// Conflict copies the sweep has already flagged as needing attention this
|
||||
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
||||
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
||||
/// Conflict copies the sweep has already checked against the activity log
|
||||
/// this run, so each pass does not ask the database again. The log itself
|
||||
/// is what keeps a copy from being flagged again after a restart. See
|
||||
/// [`Core::run_conflict_sweep_loop`] and `docs/BUGS.md` B99.
|
||||
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
||||
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||
/// only report them. Resolved once at mount from config + environment
|
||||
|
||||
@@ -391,6 +391,7 @@ pub fn mount(
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
timeline_progress: Arc::new(Mutex::new(None)),
|
||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||
|
||||
@@ -12,21 +12,21 @@ use std::io::{Read as _, Write as _};
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Once;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Once, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine as _;
|
||||
use pdfs_core::control::{
|
||||
FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, ThumbnailBuildStatus, is_raw_image_name,
|
||||
is_thumbnail_image_name,
|
||||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoThumb, PlaceInfo,
|
||||
ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name,
|
||||
};
|
||||
use pdfs_core::db::{self, StoredPhoto};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
|
||||
use tracing::{info, warn};
|
||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{
|
||||
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);
|
||||
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
||||
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
|
||||
/// 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.
|
||||
enum ThumbAttempt {
|
||||
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,
|
||||
/// The photo couldn't be downloaded. Transient: try again next time.
|
||||
Unavailable,
|
||||
@@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool(
|
||||
}
|
||||
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());
|
||||
if width == 0 || height == 0 {
|
||||
return ScaleAttempt::Undecodable;
|
||||
@@ -268,9 +288,69 @@ impl Drop for RawTempFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one bounded exiftool JSON query while draining stdout concurrently. The
|
||||
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a
|
||||
/// pipe buffer long before the child exits.
|
||||
/// How a bounded helper run failed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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(
|
||||
exiftool: &OsStr,
|
||||
path: &Path,
|
||||
@@ -280,58 +360,172 @@ fn exiftool_query(
|
||||
let mut command = Command::new(exiftool);
|
||||
command.args(["-j", "-b", "-n"]);
|
||||
command.args(tags);
|
||||
command
|
||||
.arg(path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
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);
|
||||
command.arg(path);
|
||||
let output = run_bounded(command, timeout).map_err(|error| {
|
||||
if error == ToolError::Missing {
|
||||
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
||||
warn!(
|
||||
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
||||
(Debian/Ubuntu: libimage-exiftool-perl)"
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
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(
|
||||
value: &serde_json::Value,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
@@ -407,6 +601,16 @@ fn extract_raw_preview(
|
||||
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
|
||||
|
||||
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
|
||||
/// Clears the timeline refresh's progress when dropped, so an error or a
|
||||
/// cancelled refresh doesn't leave a bar standing.
|
||||
struct ProgressGuard<'a>(&'a parking_lot::Mutex<Option<(usize, usize)>>);
|
||||
|
||||
impl Drop for ProgressGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
*self.0.lock() = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a timeline enrichment pass resolves for one photo, beyond what the
|
||||
/// timeline listing already carries.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -415,9 +619,29 @@ struct PhotoMeta {
|
||||
media_type: Option<String>,
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
/// Where the photo was taken, as its uploader recorded it.
|
||||
location: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
offset: usize,
|
||||
@@ -425,6 +649,7 @@ impl Core {
|
||||
kind: Option<PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
not_in_album: bool,
|
||||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
@@ -441,10 +666,16 @@ impl Core {
|
||||
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||
self.spawn_timeline_refresh();
|
||||
}
|
||||
// The filter is only as good as the album contents on disk, and those
|
||||
// are fetched lazily, per album opened. Fill in the missing ones before
|
||||
// the first page; later pages reuse what that fetch stored.
|
||||
if not_in_album && offset == 0 {
|
||||
self.ensure_album_contents()?;
|
||||
}
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.photos_page(offset, limit, kind, range, favorites)
|
||||
.photos_page(offset, limit, kind, range, favorites, not_in_album)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||||
}
|
||||
@@ -510,6 +741,66 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The timeline's duplicate sets, as wire items (see
|
||||
/// [`db::Db::photo_duplicates`]).
|
||||
pub(crate) fn photo_duplicates(&self) -> CoreResult<Vec<Vec<PhotoItem>>> {
|
||||
let sets = self.db.photo_duplicates().map_err(CoreError::from)?;
|
||||
Ok(sets
|
||||
.into_iter()
|
||||
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The towns the photos were taken in, as wire items. A town the bundled
|
||||
/// table no longer knows (the stored id came from an older table) is left
|
||||
/// out rather than shown without a name.
|
||||
pub(crate) fn photo_places(&self) -> CoreResult<Vec<PlaceInfo>> {
|
||||
let places = self.db.photo_places().map_err(CoreError::from)?;
|
||||
Ok(places
|
||||
.into_iter()
|
||||
.filter_map(|place| {
|
||||
let city = pdfs_core::places::city(place.id)?;
|
||||
Some(PlaceInfo {
|
||||
id: place.id,
|
||||
name: city.name.to_string(),
|
||||
country: city.country.to_string(),
|
||||
latitude: city.latitude,
|
||||
longitude: city.longitude,
|
||||
photo_count: place.count,
|
||||
cover: self.photo_item(place.cover),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A page of the photos taken in town `id`, as wire items.
|
||||
pub(crate) fn place_photos(
|
||||
&self,
|
||||
id: u32,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> CoreResult<Vec<PhotoItem>> {
|
||||
let photos = self
|
||||
.db
|
||||
.photos_at_place(id, offset, limit)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Photos taken on today's date in earlier years, as wire items. Served
|
||||
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
||||
/// on the network, and the timeline's own load keeps that store fresh.
|
||||
pub(crate) fn photos_on_this_day(&self, limit: usize) -> CoreResult<Vec<PhotoItem>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let photos = self
|
||||
.db
|
||||
.photos_on_this_day(now, limit)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Every file of one photo's group, in server order, as wire items. A photo
|
||||
/// the timeline does not hold comes back empty, which the lightbox reads as
|
||||
/// "nothing to switch between".
|
||||
@@ -589,12 +880,23 @@ impl Core {
|
||||
/// A photo that is one file of a group takes its whole group with it. The
|
||||
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
|
||||
/// delete the shot — leaving the RAW behind would put the photo back on the
|
||||
/// Raw tab and nowhere else.
|
||||
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
|
||||
/// Raw tab and nowhere else. `files_only` turns that off, for removing
|
||||
/// one copy of a shot that is stored twice.
|
||||
pub(crate) fn trash_photos(
|
||||
&self,
|
||||
uids: &[NodeUid],
|
||||
files_only: bool,
|
||||
) -> CoreResult<TrashOutcome> {
|
||||
if uids.is_empty() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let uids = &self.expand_photo_groups(uids);
|
||||
let expanded;
|
||||
let uids = if files_only {
|
||||
uids
|
||||
} else {
|
||||
expanded = self.expand_photo_groups(uids);
|
||||
&expanded
|
||||
};
|
||||
for uid in uids {
|
||||
self.require_photo_writable(uid)
|
||||
.map_err(|errno| self.errno_error(errno, "trash access"))?;
|
||||
@@ -973,6 +1275,10 @@ impl Core {
|
||||
if !core.thumb_job_current(job) {
|
||||
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,
|
||||
// rather than multiplying the limit by every control
|
||||
@@ -1019,6 +1325,19 @@ impl Core {
|
||||
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 = async move { client.download_file(&download_uid).await };
|
||||
tokio::pin!(download);
|
||||
@@ -1072,7 +1391,17 @@ impl Core {
|
||||
// stall the runtime's worker; hand it to the blocking pool.
|
||||
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||
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
|
||||
.unwrap_or(ScaleAttempt::Unavailable);
|
||||
@@ -1451,7 +1780,14 @@ impl Core {
|
||||
// can land in different chunks, and one of them may not resolve at all.
|
||||
let mut main_of: HashMap<String, String> = HashMap::new();
|
||||
let mut stopped = false;
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
// Photos read before count as done, so the bar picks up where an
|
||||
// interrupted pass left off. Cleared however the pass ends.
|
||||
let skipped = items.len() - uids.len();
|
||||
let progress = ProgressGuard(&self.timeline_progress);
|
||||
if !uids.is_empty() {
|
||||
*progress.0.lock() = Some((skipped, items.len()));
|
||||
}
|
||||
for (index, chunk) in uids.chunks(TIMELINE_ENRICH_CHUNK).enumerate() {
|
||||
// Teardown cancels the runtime under this loop, and every chunk
|
||||
// still in flight then fails its decrypt task — a burst of
|
||||
// "skipping undecryptable photo ... task was cancelled" warnings
|
||||
@@ -1478,6 +1814,11 @@ impl Core {
|
||||
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||||
let key = node.uid.to_string();
|
||||
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
|
||||
let location = node
|
||||
.photo
|
||||
.as_ref()
|
||||
.and_then(|p| p.location)
|
||||
.map(|l| (l.latitude, l.longitude));
|
||||
if let Some(photo) = node.photo.as_ref() {
|
||||
if let Some(main) = photo.main_photo_uid.as_ref() {
|
||||
main_of.insert(key.clone(), main.to_string());
|
||||
@@ -1493,13 +1834,17 @@ impl Core {
|
||||
media_type,
|
||||
favorite,
|
||||
content_hash,
|
||||
location,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||||
}
|
||||
let done = (skipped + (index + 1) * TIMELINE_ENRICH_CHUNK).min(items.len());
|
||||
*progress.0.lock() = Some((done, items.len()));
|
||||
}
|
||||
drop(progress);
|
||||
|
||||
let rows: Vec<db::TimelineRow> = items
|
||||
.iter()
|
||||
@@ -1514,6 +1859,7 @@ impl Core {
|
||||
media_type: m.media_type,
|
||||
favorite: Some(m.favorite),
|
||||
content_hash: m.content_hash,
|
||||
location: m.location,
|
||||
main_uid,
|
||||
resolved_at: Some(resolved_at),
|
||||
..db::TimelineRow::new(key, it.capture_time)
|
||||
@@ -1560,6 +1906,14 @@ impl Core {
|
||||
});
|
||||
}
|
||||
|
||||
/// How far the running timeline refresh has got reading photo metadata, or
|
||||
/// `None` when no refresh is reading any.
|
||||
pub(crate) fn timeline_progress(&self) -> Option<MappingProgress> {
|
||||
self.timeline_progress
|
||||
.lock()
|
||||
.map(|(done, total)| MappingProgress { done, total })
|
||||
}
|
||||
|
||||
/// Whether a timeline refresh is running. A front-end that asked for one
|
||||
/// watches this to know when the new timeline is worth re-reading.
|
||||
pub(crate) fn timeline_refreshing(&self) -> bool {
|
||||
@@ -1592,10 +1946,12 @@ impl Core {
|
||||
#[cfg(test)]
|
||||
mod thumb_tests {
|
||||
use super::{
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail,
|
||||
scale_thumbnail_with_exiftool, thumbnail_build_may_start,
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||||
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 proton_drive_rs::ThumbnailType;
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::time::Duration;
|
||||
@@ -1758,6 +2114,103 @@ mod thumb_tests {
|
||||
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]
|
||||
fn a_running_build_only_accepts_the_same_root() {
|
||||
let status = ThumbnailBuildStatus {
|
||||
@@ -1771,4 +2224,92 @@ mod thumb_tests {
|
||||
let idle = ThumbnailBuildStatus::default();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ impl Core {
|
||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||
}
|
||||
|
||||
let roots = self.search_roots();
|
||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
@@ -132,7 +133,16 @@ impl Core {
|
||||
self.remove_conflict_copy(node, &base);
|
||||
}
|
||||
}
|
||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
||||
// Only flag what the conflict listing can show. Anything else
|
||||
// would sit in the feed as "resolved" with no way to act on
|
||||
// it (`docs/BUGS.md` B99).
|
||||
SweepAction::Flag(detail) => {
|
||||
if self.conflict_path(&roots, &node.uid).is_some() {
|
||||
self.flag_conflict(&node.uid, &node.name, detail);
|
||||
} else {
|
||||
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,12 +283,21 @@ impl Core {
|
||||
busy
|
||||
}
|
||||
|
||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||
/// daemon run so the activity feed is not spammed each pass.
|
||||
/// Surface a conflict copy that needs the user's attention, once. The
|
||||
/// in-memory set spares the database each pass; the activity log itself is
|
||||
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
||||
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
||||
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||
return;
|
||||
}
|
||||
match self.db.activity_has(ActivityKind::Conflict, name) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => return,
|
||||
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
||||
// conflict.
|
||||
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
||||
}
|
||||
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||
}
|
||||
|
||||
@@ -1226,6 +1226,9 @@ impl Core {
|
||||
// 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.
|
||||
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
|
||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||
// draft is sealed, so the transfer registers without one.
|
||||
@@ -1246,7 +1249,7 @@ impl Core {
|
||||
crate::media_type_for(name),
|
||||
reader,
|
||||
meta.len() as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
Some(mtime),
|
||||
false,
|
||||
)
|
||||
@@ -1274,6 +1277,9 @@ impl Core {
|
||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||
let mtime = system_mtime(&meta);
|
||||
let streamed = LocalSig::from(&meta);
|
||||
let thumbnails = self
|
||||
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
|
||||
.await;
|
||||
let reader = OwnedCountingReader::new(
|
||||
file,
|
||||
self.transfers.begin(
|
||||
@@ -1284,7 +1290,7 @@ impl Core {
|
||||
),
|
||||
);
|
||||
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
|
||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||
warn_if_torn(rel, &path, streamed);
|
||||
|
||||
@@ -389,11 +389,11 @@ impl<R: Read> Read for CountingReader<'_, R> {
|
||||
&& cancel.load(Ordering::Relaxed)
|
||||
{
|
||||
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||
// and the SDK would seal a truncated revision from it.
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"upload superseded by a newer write",
|
||||
));
|
||||
// and the SDK would seal a truncated revision from it. Not
|
||||
// `Interrupted` either: every reader loop, the SDK's included,
|
||||
// retries that kind, and this flag never clears, so the drain
|
||||
// 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)?;
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ async fn run_uploads(
|
||||
format!("destination access denied: errno {}", error.code()),
|
||||
));
|
||||
}
|
||||
let thumbnails = core
|
||||
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||
.await;
|
||||
match core
|
||||
.client
|
||||
.upload_file_from(
|
||||
@@ -77,7 +80,7 @@ async fn run_uploads(
|
||||
media_type_for(&t.name),
|
||||
reader,
|
||||
t.size as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
mtime,
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -35,3 +35,5 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
webkit6 = "0.6"
|
||||
# The system libintl (glibc) rather than a bundled gettext build.
|
||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||
@@ -26,4 +26,5 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||
println!("cargo:rerun-if-changed=resources/icons");
|
||||
println!("cargo:rerun-if-changed=resources/style.css");
|
||||
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- "Filters": a funnel, wide at the top and narrowing to a short spout.
|
||||
No stock icon theme ships one under a standard name. -->
|
||||
<path fill="#222222" d="M 1.5,2 H 14.5 L 9.5,8.5 V 13.5 L 6.5,15 V 8.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -7,8 +7,12 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||
<!-- The app icon, from the one file packaging installs, so the in-app brand
|
||||
matches the launcher and resolves even when nothing is installed. -->
|
||||
<file alias="scalable/apps/io.narl.proton-drive-linux.svg" preprocess="xml-stripblanks">../../../packaging/io.narl.proton-drive-linux.svg</file>
|
||||
</gresource>
|
||||
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||
libadwaita palette so the app follows the system accent, light/dark and
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
/* Brand */
|
||||
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||
.brand-icon { color: #6d4aff; }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||
@@ -59,8 +58,8 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||
|
||||
/* Photos grid */
|
||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||
.photo-thumb { transition: transform 220ms ease; }
|
||||
@@ -72,8 +71,12 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
|
||||
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
|
||||
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
||||
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
|
||||
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
||||
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
||||
.memory-card { padding: 0; border-radius: 12px; }
|
||||
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
|
||||
.memory-label { font-weight: 700; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); }
|
||||
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
||||
.album-card:hover { background: alpha(currentColor, 0.07); }
|
||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||
@@ -83,3 +86,10 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
|
||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||
|
||||
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||
/* The Places map: water a faint wash of the text colour, bubbles (drawn in
|
||||
* the area's own colour) in the accent. */
|
||||
.places-map-frame { border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); }
|
||||
.places-map { color: var(--accent-bg-color); }
|
||||
@@ -1,5 +1,7 @@
|
||||
#[path = "../activation.rs"]
|
||||
pub(crate) mod activation;
|
||||
#[path = "../i18n.rs"]
|
||||
pub(crate) mod i18n;
|
||||
pub(crate) mod pages;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
@@ -7,10 +9,13 @@ use pages::activity::*;
|
||||
use pages::albums::*;
|
||||
use pages::browser::*;
|
||||
use pages::devices::*;
|
||||
use pages::duplicates::*;
|
||||
use pages::locations::*;
|
||||
use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
use pages::photos::*;
|
||||
use pages::places::*;
|
||||
use pages::places_map::*;
|
||||
use pages::shared::*;
|
||||
use pages::shared_by_me::*;
|
||||
use pages::status::*;
|
||||
@@ -23,6 +28,8 @@ use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
use widgets::versions_dialog::*;
|
||||
|
||||
pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
@@ -49,11 +56,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
|
||||
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
|
||||
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
|
||||
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
||||
pending_summary, send,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
||||
send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
@@ -195,6 +202,7 @@ fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
i18n::init();
|
||||
|
||||
// The tray launches the app with arguments ("--page locations",
|
||||
// "--confirm-stop"), which a running instance must receive too.
|
||||
@@ -297,10 +305,14 @@ fn load_proton_theme() {
|
||||
};
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_resource("/de/nils/protondrivelinux/style.css");
|
||||
// Above the user's own gtk.css, which often imports a whole theme: its
|
||||
// `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every
|
||||
// photo tile and push the grid past the window. Every rule in the sheet is
|
||||
// scoped to the app's own classes, so nothing else the user styled changes.
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
|
||||
);
|
||||
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
|
||||
}
|
||||
@@ -444,6 +456,7 @@ fn build_window(app: &adw::Application) {
|
||||
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
||||
accent_row: main_widgets.accent_row.clone(),
|
||||
tray_row: main_widgets.tray_row.clone(),
|
||||
language_row: main_widgets.language_row.clone(),
|
||||
settings_suppress: Cell::new(false),
|
||||
budget_source: RefCell::new(None),
|
||||
limit_source: RefCell::new(None),
|
||||
@@ -517,10 +530,8 @@ fn build_window(app: &adw::Application) {
|
||||
model: gallery_widgets.model.clone(),
|
||||
groups: gallery_widgets.groups.clone(),
|
||||
row_height: Cell::new(ROW_DEFAULT),
|
||||
learned_ratios: RefCell::new(HashMap::new()),
|
||||
scrolling_down: Cell::new(true),
|
||||
scroll_offset: Cell::new(0.0),
|
||||
assumed_ratios: RefCell::new(HashSet::new()),
|
||||
content: gallery_widgets.content.clone(),
|
||||
status: gallery_widgets.status.clone(),
|
||||
retry: gallery_widgets.retry.clone(),
|
||||
@@ -543,6 +554,19 @@ fn build_window(app: &adw::Application) {
|
||||
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||
view_switch: gallery_widgets.view_switch.clone(),
|
||||
albums_loading: Cell::new(false),
|
||||
places: gallery_widgets.places.clone(),
|
||||
places_stack: gallery_widgets.places_stack.clone(),
|
||||
places_status: gallery_widgets.places_status.clone(),
|
||||
places_btn: gallery_widgets.places_btn.clone(),
|
||||
places_loading: Cell::new(false),
|
||||
places_mapping: gallery_widgets.places_mapping.clone(),
|
||||
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
|
||||
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
|
||||
places_poll: RefCell::new(None),
|
||||
places_map: gallery_widgets.places_map.clone(),
|
||||
places_map_btn: gallery_widgets.places_map_btn.clone(),
|
||||
place_count: Cell::new(0),
|
||||
place: RefCell::new(None),
|
||||
album: RefCell::new(None),
|
||||
timeline_stale: Cell::new(false),
|
||||
album_list: RefCell::new(Vec::new()),
|
||||
@@ -552,11 +576,20 @@ fn build_window(app: &adw::Application) {
|
||||
tabs: gallery_widgets.tabs.clone(),
|
||||
counts: Cell::new(None),
|
||||
dates: gallery_widgets.dates.clone(),
|
||||
filter_btn: gallery_widgets.filter_btn.clone(),
|
||||
filter_count: gallery_widgets.filter_count.clone(),
|
||||
clear_filters: gallery_widgets.clear_filters.clone(),
|
||||
date_ranges: RefCell::new(vec![None]),
|
||||
range: Cell::new(None),
|
||||
favorites_btn: gallery_widgets.favorites_btn.clone(),
|
||||
favorites_row: gallery_widgets.favorites_row.clone(),
|
||||
favorites: Cell::new(false),
|
||||
not_in_album_row: gallery_widgets.not_in_album_row.clone(),
|
||||
not_in_album: Cell::new(false),
|
||||
memories: gallery_widgets.memories.clone(),
|
||||
memory_cards: gallery_widgets.memory_cards.clone(),
|
||||
has_memories: Cell::new(false),
|
||||
date_suppress: Cell::new(false),
|
||||
reload_pending: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
has_more: Cell::new(false),
|
||||
page_waiters: RefCell::new(Vec::new()),
|
||||
@@ -575,6 +608,7 @@ fn build_window(app: &adw::Application) {
|
||||
list: gallery_widgets.list.clone(),
|
||||
selecting: Cell::new(false),
|
||||
selected: RefCell::new(HashSet::new()),
|
||||
select_anchor: RefCell::new(None),
|
||||
select_btn: gallery_widgets.select_btn.clone(),
|
||||
select_bar: gallery_widgets.select_bar.clone(),
|
||||
select_label: gallery_widgets.select_label.clone(),
|
||||
@@ -713,7 +747,12 @@ fn build_window(app: &adw::Application) {
|
||||
&gallery_widgets.empty_upload,
|
||||
&gallery_widgets.empty_import,
|
||||
);
|
||||
let ui_duplicates = ui.clone();
|
||||
gallery_widgets
|
||||
.duplicates
|
||||
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
|
||||
wire_albums(&ui);
|
||||
wire_places(&ui);
|
||||
wire_trash(&ui, &trash_widgets);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||
@@ -819,14 +858,35 @@ fn build_window(app: &adw::Application) {
|
||||
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
|
||||
/// sync, set off by a separator.
|
||||
const DESTINATIONS: [(&str, &str, &str); 8] = [
|
||||
("browser", "My files", "folder-symbolic"),
|
||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
||||
("shared", "Shared with me", "system-users-symbolic"),
|
||||
("sharedbyme", "Shared by me", "emblem-shared-symbolic"),
|
||||
("devices", "Computers", "computer-symbolic"),
|
||||
("trash", "Trash", "user-trash-symbolic"),
|
||||
("locations", "Sync", "emblem-synchronizing-symbolic"),
|
||||
("activity", "Activity", "document-open-recent-symbolic"),
|
||||
("browser", gettext_noop("My files"), "folder-symbolic"),
|
||||
(
|
||||
"gallery",
|
||||
gettext_noop("Photos"),
|
||||
"image-x-generic-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.
|
||||
@@ -842,7 +902,7 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
for (_, label, icon) in DESTINATIONS {
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
row_box.append(>k4::Image::from_icon_name(icon));
|
||||
row_box.append(>k4::Label::new(Some(label)));
|
||||
row_box.append(>k4::Label::new(Some(&gettext(label))));
|
||||
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
|
||||
list.append(&row);
|
||||
}
|
||||
@@ -858,8 +918,8 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
});
|
||||
|
||||
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let icon = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
||||
icon.add_css_class("brand-icon");
|
||||
let icon = gtk4::Image::from_icon_name(APP_ID);
|
||||
icon.set_pixel_size(24);
|
||||
brand.append(&icon);
|
||||
let name = gtk4::Label::new(Some("Proton Drive"));
|
||||
name.add_css_class("heading");
|
||||
@@ -970,7 +1030,7 @@ fn page_fresh(loaded_at: &Cell<Option<Instant>>) -> bool {
|
||||
fn refresh_button() -> gtk4::Button {
|
||||
let button = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Refresh (F5)")
|
||||
.tooltip_text(gettext("Refresh (F5)"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
button.add_css_class("flat");
|
||||
@@ -1001,12 +1061,12 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||
}
|
||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||
// the two — album grid or timeline/album — is actually on screen.
|
||||
// the views — a grid, or the timeline, an album or a place — is on screen.
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
} else {
|
||||
load_gallery(ui, false);
|
||||
match ui.gallery.content.visible_child_name().as_deref() {
|
||||
Some("albums") => load_albums(ui),
|
||||
Some("places") => load_places(ui),
|
||||
_ => load_gallery(ui, false),
|
||||
}
|
||||
}),
|
||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||
@@ -1110,12 +1170,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
|
||||
/// reported without stealing focus or forcing a click, so the user can keep
|
||||
/// working while a slow upload lands.
|
||||
@@ -1147,7 +1201,10 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
||||
let message = if detail.is_empty() {
|
||||
what.to_string()
|
||||
} 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}");
|
||||
let toast = adw::Toast::builder().title(&message).timeout(6).build();
|
||||
@@ -1166,15 +1223,34 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
||||
/// used where the class carries no better wording than the caller already has.
|
||||
fn error_headline(kind: ErrorKind, fallback: &str) -> &str {
|
||||
match kind {
|
||||
ErrorKind::Offline => "You're offline",
|
||||
ErrorKind::NotFound => "That's not there any more",
|
||||
ErrorKind::Denied => "You don't have access to that",
|
||||
ErrorKind::Conflict => "Something changed this first",
|
||||
ErrorKind::Quota => "Your Proton Drive is full",
|
||||
ErrorKind::Offline => static_gettext(gettext_noop("You're offline")),
|
||||
ErrorKind::NotFound => static_gettext(gettext_noop("That's not there any more")),
|
||||
ErrorKind::Denied => static_gettext(gettext_noop("You don't have access to that")),
|
||||
ErrorKind::Conflict => static_gettext(gettext_noop("Something changed this first")),
|
||||
ErrorKind::Quota => static_gettext(gettext_noop("Your Proton Drive is full")),
|
||||
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.
|
||||
///
|
||||
/// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown:
|
||||
@@ -1192,12 +1268,15 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
||||
/// belong on any one page.
|
||||
fn build_primary_menu() -> gtk4::MenuButton {
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
|
||||
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
menu.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
gtk4::MenuButton::builder()
|
||||
.icon_name("open-menu-symbolic")
|
||||
.tooltip_text("Main menu")
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.primary(true)
|
||||
.menu_model(&menu)
|
||||
.build()
|
||||
@@ -1243,20 +1322,67 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let ui_about = ui.clone();
|
||||
about.connect_activate(move |_, _| {
|
||||
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")
|
||||
.version(pdfs_core::config::APP_VERSION)
|
||||
.developer_name("Nils Pukropp")
|
||||
.website("https://github.com/narrrl/proton-drive-linux")
|
||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
|
||||
.license_type(gtk4::License::MitX11)
|
||||
.comments(
|
||||
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
|
||||
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
|
||||
)
|
||||
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
|
||||
.debug_info(debug_info(&ui_about))
|
||||
.debug_info_filename("proton-drive-linux-debug.txt")
|
||||
.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 "&".
|
||||
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(
|
||||
"GeoNames",
|
||||
None,
|
||||
gtk4::License::Custom,
|
||||
Some(&gettext(
|
||||
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
|
||||
)),
|
||||
);
|
||||
dialog.add_legal_section(
|
||||
"Natural Earth",
|
||||
None,
|
||||
gtk4::License::Custom,
|
||||
Some(&gettext(
|
||||
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/\">Natural Earth</a> land outlines, which are in the public domain.",
|
||||
)),
|
||||
);
|
||||
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));
|
||||
});
|
||||
window.add_action(&about);
|
||||
@@ -1284,14 +1410,11 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
|
||||
let ui_stop = ui.clone();
|
||||
confirm_stop.connect_activate(move |_, _| {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Stop Proton Drive?")
|
||||
.body(
|
||||
"The drive unmounts and nothing syncs until you connect again. \
|
||||
Proton Drive starts again at your next login.",
|
||||
)
|
||||
.heading(gettext("Stop Proton Drive?"))
|
||||
.body(gettext("The drive unmounts and nothing syncs until you connect again. Proton Drive starts again at your next login."))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("stop", "Stop");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("stop", &pgettext("verb", "Stop"));
|
||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -1308,8 +1431,8 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
|
||||
} else {
|
||||
toast_error(
|
||||
&ui,
|
||||
"Couldn't stop Proton Drive",
|
||||
"The mount service did not stop.",
|
||||
&gettext("Couldn't stop Proton Drive"),
|
||||
&gettext("The mount service did not stop."),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1345,53 +1468,55 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
||||
(
|
||||
"General",
|
||||
gettext_noop("General"),
|
||||
&[
|
||||
("<Control>f", "Search Drive"),
|
||||
("F5", "Refresh"),
|
||||
("<Control>comma", "Preferences"),
|
||||
("<Control>question", "Keyboard shortcuts"),
|
||||
("<Control>f", gettext_noop("Search Drive")),
|
||||
("F5", gettext_noop("Refresh")),
|
||||
("<Control>comma", gettext_noop("Preferences")),
|
||||
("<Control>question", gettext_noop("Keyboard shortcuts")),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Files",
|
||||
gettext_noop("Files"),
|
||||
&[
|
||||
("<Control>n", "New folder"),
|
||||
("<Control>u", "Upload files"),
|
||||
("<Alt>Left <Alt>Right", "Back / forward"),
|
||||
("<Alt>Up", "Parent folder"),
|
||||
("<Control>1 <Control>2", "Grid / list"),
|
||||
("F2", "Rename"),
|
||||
("Delete", "Move to Trash"),
|
||||
("Escape", "Clear the selection"),
|
||||
("<Control>n", gettext_noop("New folder")),
|
||||
("<Control>u", gettext_noop("Upload files")),
|
||||
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
|
||||
("<Alt>Up", gettext_noop("Parent folder")),
|
||||
("<Control>1 <Control>2", gettext_noop("Grid / list")),
|
||||
("F2", gettext_noop("Rename")),
|
||||
("Delete", gettext_noop("Move to Trash")),
|
||||
("Escape", gettext_noop("Clear the selection")),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Photos",
|
||||
gettext_noop("Photos"),
|
||||
&[
|
||||
("<Control>plus", "Larger thumbnails"),
|
||||
("<Control>minus", "Smaller thumbnails"),
|
||||
("<Control>0", "Reset thumbnail size"),
|
||||
("<Control>a", "Select all"),
|
||||
("<Control>plus", gettext_noop("Larger thumbnails")),
|
||||
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
||||
("<Control>0", gettext_noop("Reset thumbnail size")),
|
||||
("<Control>a", gettext_noop("Select all")),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Photo Viewer",
|
||||
gettext_noop("Photo Viewer"),
|
||||
&[
|
||||
("Left Right", "Previous / next photo"),
|
||||
("Home End", "First / last photo"),
|
||||
("i", "Show details"),
|
||||
("f", "Fullscreen"),
|
||||
("Delete", "Move to Trash"),
|
||||
("Escape", "Close"),
|
||||
("Left Right", gettext_noop("Previous / next photo")),
|
||||
("Home End", gettext_noop("First / last photo")),
|
||||
("i", gettext_noop("Show details")),
|
||||
("f", gettext_noop("Fullscreen")),
|
||||
("Delete", gettext_noop("Move to Trash")),
|
||||
("Escape", gettext_noop("Close")),
|
||||
],
|
||||
),
|
||||
];
|
||||
let page = adw::PreferencesPage::new();
|
||||
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 {
|
||||
let row = adw::ActionRow::builder().title(*action).build();
|
||||
let row = adw::ActionRow::builder().title(gettext(action)).build();
|
||||
row.add_suffix(
|
||||
>k4::ShortcutLabel::builder()
|
||||
.accelerator(*accel)
|
||||
@@ -1404,7 +1529,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
}
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Keyboard Shortcuts")
|
||||
.title(gettext("Keyboard Shortcuts"))
|
||||
.content_width(460)
|
||||
.content_height(620)
|
||||
.child(&{
|
||||
@@ -1484,7 +1609,7 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
// Renaming is one name at a time; saying so beats a key that
|
||||
// silently does nothing with several items highlighted.
|
||||
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) {
|
||||
prompt_rename(&ui, &entry);
|
||||
}
|
||||
@@ -1561,7 +1686,7 @@ fn confirm_destructive(
|
||||
.heading(heading)
|
||||
.body(body)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("confirm", action);
|
||||
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
@@ -1608,23 +1733,15 @@ pub(crate) fn open_named_path(path: &str, name: &str) {
|
||||
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 {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
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])
|
||||
}
|
||||
glib::format_size_full(bytes, glib::FormatSizeFlags::IEC_UNITS).replace('\u{a0}', " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1651,89 +1768,6 @@ mod tests {
|
||||
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
|
||||
}
|
||||
|
||||
/// The full width a row occupies, gaps included.
|
||||
fn row_width(widths: &[i32]) -> i32 {
|
||||
widths.iter().sum::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
|
||||
}
|
||||
|
||||
/// Ratios of a typical mixed day: landscape phone shots, a portrait, a
|
||||
/// square crop.
|
||||
fn mixed() -> Vec<f64> {
|
||||
vec![
|
||||
1.5, 1.5, 0.75, 1.0, 1.33, 1.5, 0.75, 1.78, 1.0, 1.5, 1.5, 1.33,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_full_row_spans_the_content_width() {
|
||||
// The point of justifying: no ragged right margin at any width, at any
|
||||
// zoom, for any mix of shapes.
|
||||
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
||||
let rows = plan_rows(&mixed(), width, ROW_DEFAULT);
|
||||
// The last row is deliberately short, so it is not part of this.
|
||||
for (_, widths) in rows.iter().take(rows.len() - 1) {
|
||||
assert_eq!(row_width(widths), width, "row does not span {width}px");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_keeps_its_photos_shape() {
|
||||
// Each tile is its photo's ratio at the row's height, which is what
|
||||
// makes cropping unnecessary.
|
||||
let ratios = mixed();
|
||||
let rows = plan_rows(&ratios, 1440, ROW_DEFAULT);
|
||||
let (height, widths) = &rows[0];
|
||||
for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) {
|
||||
let laid_out = *width as f64 / *height as f64;
|
||||
assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_land_near_the_target_height() {
|
||||
// A row is closed as soon as it no longer fits at the target, so it is
|
||||
// never taller than the target and never far below it.
|
||||
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
|
||||
let rows = plan_rows(&mixed(), 1600, target);
|
||||
for (height, _) in rows.iter().take(rows.len() - 1) {
|
||||
assert!(
|
||||
*height <= target,
|
||||
"{height}px exceeds the {target}px target"
|
||||
);
|
||||
assert!(*height > target / 2, "{height}px is far under {target}px");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_day_is_not_stretched_across_the_window() {
|
||||
// Two photos are two photos, not two half-window tiles.
|
||||
let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT);
|
||||
assert_eq!(rows.len(), 1);
|
||||
let (height, widths) = &rows[0];
|
||||
assert_eq!(*height, ROW_DEFAULT);
|
||||
assert!(row_width(widths) < 1920 / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_puts_fewer_photos_in_a_row() {
|
||||
let small = plan_rows(&mixed(), 1200, ROW_MIN);
|
||||
let big = plan_rows(&mixed(), 1200, ROW_MAX);
|
||||
assert!(small[0].1.len() > big[0].1.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_photo_is_laid_out_exactly_once() {
|
||||
let ratios = mixed();
|
||||
let placed: usize = plan_rows(&ratios, 1000, ROW_DEFAULT)
|
||||
.iter()
|
||||
.map(|(_, widths)| widths.len())
|
||||
.sum();
|
||||
assert_eq!(placed, ratios.len());
|
||||
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
|
||||
}
|
||||
|
||||
/// One file of a shot, for the group-switch tests.
|
||||
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
||||
PhotoItem {
|
||||
@@ -1786,14 +1820,4 @@ mod tests {
|
||||
"the video"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_narrower_than_one_photo_still_lays_it_out() {
|
||||
// A tile of at least one px, rather than a zero-width widget or a
|
||||
// division by zero.
|
||||
for width in [0, 40] {
|
||||
let rows = plan_rows(&[1.5, 1.5, 1.5], width, ROW_DEFAULT);
|
||||
assert!(rows.iter().all(|(h, w)| *h > 0 && w.iter().all(|w| *w > 0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,13 @@ impl ActivityFilter {
|
||||
ActivityFilter::Problems,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
fn label(self) -> String {
|
||||
match self {
|
||||
ActivityFilter::All => "All",
|
||||
ActivityFilter::Transfers => "Transfers",
|
||||
ActivityFilter::Changes => "Changes",
|
||||
ActivityFilter::Sharing => "Sharing",
|
||||
ActivityFilter::Problems => "Problems",
|
||||
ActivityFilter::All => pgettext("activity filter", "All"),
|
||||
ActivityFilter::Transfers => pgettext("activity filter", "Transfers"),
|
||||
ActivityFilter::Changes => pgettext("activity filter", "Changes"),
|
||||
ActivityFilter::Sharing => pgettext("activity filter", "Sharing"),
|
||||
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);
|
||||
let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new();
|
||||
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() {
|
||||
button.set_group(Some(first));
|
||||
} else {
|
||||
@@ -106,7 +106,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
}
|
||||
|
||||
let attention = adw::PreferencesGroup::builder()
|
||||
.title("Needs attention")
|
||||
.title(gettext("Needs attention"))
|
||||
.visible(false)
|
||||
.build();
|
||||
let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
@@ -131,7 +131,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
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(&status, Some("status"));
|
||||
|
||||
let (frame, header, _) = page_frame("Activity", &content);
|
||||
let (frame, header, _) = page_frame(&gettext("Activity"), &content);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
@@ -245,8 +245,8 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
"Loading…",
|
||||
"Reading recent activity.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading recent activity."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -264,15 +264,15 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Error { message, .. })) => activity_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read activity",
|
||||
&gettext("Couldn't read activity"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => activity_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read activity",
|
||||
"Unexpected reply from the mount service.",
|
||||
&gettext("Couldn't read activity"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
|
||||
@@ -286,8 +286,8 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -295,8 +295,8 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
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 {
|
||||
let today = now.ymd();
|
||||
if at.ymd() == today {
|
||||
return "Today".to_string();
|
||||
return gettext("Today");
|
||||
}
|
||||
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 {
|
||||
"%A"
|
||||
"%A".to_string()
|
||||
} 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 {
|
||||
"%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
|
||||
@@ -447,8 +451,8 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
"Nothing yet",
|
||||
"Uploads, moves, shares and other changes appear here as they happen.",
|
||||
&gettext("Nothing yet"),
|
||||
&gettext("Uploads, moves, shares and other changes appear here as they happen."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -461,12 +465,12 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
state.attention.remove(&row);
|
||||
}
|
||||
state.attention.set_visible(!open.is_empty());
|
||||
state.attention.set_description(Some(&match open.len() {
|
||||
1 => "A file was changed in two places at once. Choose which version to keep.".to_string(),
|
||||
n => {
|
||||
format!("{n} files were changed in two places at once. Choose which versions to keep.")
|
||||
}
|
||||
}));
|
||||
state.attention.set_description(Some(&ngettext_f(
|
||||
"A file was changed in two places at once. Choose which version to keep.",
|
||||
"{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();
|
||||
for conflict in open {
|
||||
let row = conflict_row(ui, conflict);
|
||||
@@ -485,8 +489,8 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
if rows.is_empty() {
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("edit-find-symbolic")
|
||||
.title("No matching activity")
|
||||
.description("Nothing in the recent log fits this filter.")
|
||||
.title(gettext("No matching activity"))
|
||||
.description(gettext("Nothing in the recent log fits this filter."))
|
||||
.build();
|
||||
empty.add_css_class("compact");
|
||||
state.days.append(&empty);
|
||||
@@ -519,11 +523,7 @@ fn feed_row(
|
||||
open: &[ConflictInfo],
|
||||
) -> adw::ActionRow {
|
||||
let a = row.entry;
|
||||
let title = match a.kind {
|
||||
// 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),
|
||||
};
|
||||
let title = activity_title(a.kind, &a.target);
|
||||
// A logged conflict names the copy; it is still open while the daemon
|
||||
// still lists a copy by that name.
|
||||
let pending = (a.kind == ActivityKind::Conflict)
|
||||
@@ -538,14 +538,17 @@ fn feed_row(
|
||||
subtitle.push(capitalize(&a.detail));
|
||||
}
|
||||
if row.count > 1 {
|
||||
subtitle.push(format!(
|
||||
"{} times since {}",
|
||||
row.count,
|
||||
activity_time(row.first)
|
||||
let since = activity_time(row.first);
|
||||
// Translators: {time} is a date and time such as "Sep 21, 14:05".
|
||||
subtitle.push(ngettext_f(
|
||||
"{n} time since {time}",
|
||||
"{n} times since {time}",
|
||||
row.count as u64,
|
||||
&[("time", &since)],
|
||||
));
|
||||
}
|
||||
if resolved {
|
||||
subtitle.push("Resolved".to_string());
|
||||
subtitle.push(pgettext("state", "Resolved"));
|
||||
}
|
||||
let widget = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
@@ -573,8 +576,10 @@ fn feed_row(
|
||||
}
|
||||
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(
|
||||
&at.and_then(|at| at.format("%H:%M").ok())
|
||||
&at.and_then(|at| at.format(&time_format).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
@@ -587,7 +592,7 @@ fn feed_row(
|
||||
|
||||
if let Some(conflict) = pending {
|
||||
let resolve = gtk4::Button::builder()
|
||||
.label("Resolve…")
|
||||
.label(gettext("Resolve…"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
@@ -598,23 +603,40 @@ fn feed_row(
|
||||
widget
|
||||
}
|
||||
|
||||
/// A human verb for an activity kind, used as the row title's lead word.
|
||||
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
/// The row title for an activity: what happened to `target`, as one sentence
|
||||
/// 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 {
|
||||
ActivityKind::Upload => "Uploaded",
|
||||
ActivityKind::Download => "Downloaded",
|
||||
ActivityKind::Sync => "Synced",
|
||||
ActivityKind::Rename => "Renamed",
|
||||
ActivityKind::Move => "Moved",
|
||||
ActivityKind::CreateFolder => "Created folder",
|
||||
ActivityKind::Trash => "Trashed",
|
||||
ActivityKind::Restore => "Restored",
|
||||
ActivityKind::DeleteForever => "Deleted",
|
||||
ActivityKind::EmptyTrash => "Emptied Trash",
|
||||
ActivityKind::Share => "Shared",
|
||||
ActivityKind::PublicLink => "Created a link to",
|
||||
ActivityKind::Unshare => "Unshared",
|
||||
ActivityKind::Conflict => "Conflict",
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Upload => gettext_f("Uploaded {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Download => gettext_f("Downloaded {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Sync => gettext_f("Synced {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Rename => gettext_f("Renamed {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Move => gettext_f("Moved {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
ActivityKind::CreateFolder => gettext_f("Created folder {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
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.
|
||||
pub(crate) fn activity_time(secs: i64) -> String {
|
||||
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())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
/// position. The timeline's filters do not apply to albums, so they go.
|
||||
fn show_album_grid(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
close_place(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
}
|
||||
@@ -40,8 +41,8 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
"Loading albums…",
|
||||
"Reading your Proton Drive albums.",
|
||||
&gettext("Loading albums…"),
|
||||
&gettext("Reading your Proton Drive albums."),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,33 +57,33 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
})) => albums_status(
|
||||
&ui,
|
||||
"image-missing-symbolic",
|
||||
"No photo library",
|
||||
"This Proton account doesn't have Photos enabled.",
|
||||
&gettext("No photo library"),
|
||||
&gettext("This Proton account doesn't have Photos enabled."),
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||
&ui,
|
||||
"view-grid-symbolic",
|
||||
"No albums",
|
||||
"Create one with New Album, or in Proton Photos.",
|
||||
&gettext("No albums"),
|
||||
&gettext("Create one with New Album, or in Proton Photos."),
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
fill_albums(&ui, &items);
|
||||
ui.gallery.albums_stack.set_visible_child_name("grid");
|
||||
}
|
||||
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(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't load albums",
|
||||
&gettext("Couldn't load albums"),
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service didn't respond.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -102,9 +103,12 @@ fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||
}
|
||||
*ui.gallery.album_list.borrow_mut() = albums.to_vec();
|
||||
if ui.gallery.album.borrow().is_none() {
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&count_noun(albums.len(), "album", "albums"));
|
||||
ui.gallery.title.set_subtitle(&ngettext_f(
|
||||
"{n} album",
|
||||
"{n} albums",
|
||||
albums.len() as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
for album in albums {
|
||||
ui.gallery.albums.append(&album_card(ui, album));
|
||||
@@ -199,14 +203,18 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
||||
fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64, y: f64) {
|
||||
let mut menu = ActionMenu::new();
|
||||
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 {
|
||||
menu.section();
|
||||
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();
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -226,13 +234,13 @@ fn prompt_album_name(
|
||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("Album name")
|
||||
.title(gettext("Album name"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("ok", action);
|
||||
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_response_enabled("ok", !current.trim().is_empty());
|
||||
@@ -254,7 +262,8 @@ fn prompt_album_name(
|
||||
/// 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>) {
|
||||
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 ui = ui_c.clone();
|
||||
let photos = photos.clone();
|
||||
@@ -265,19 +274,19 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumCreated { uid })) => {
|
||||
if photos.is_empty() {
|
||||
toast(&ui, "Album created");
|
||||
toast(&ui, &gettext("Album created"));
|
||||
reload_album_grid(&ui);
|
||||
} else {
|
||||
add_to_album(&ui, uid, photos);
|
||||
}
|
||||
}
|
||||
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(
|
||||
&ui,
|
||||
"Couldn't create the album",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't create the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -286,15 +295,16 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
|
||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
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(
|
||||
&ui_c,
|
||||
Request::RenameAlbum {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
"Album renamed",
|
||||
"Couldn't rename the album",
|
||||
gettext("Album renamed"),
|
||||
gettext("Couldn't rename the album"),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -304,12 +314,13 @@ fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||
confirm_destructive(
|
||||
&win,
|
||||
"Delete Album?",
|
||||
&format!(
|
||||
"“{}” is deleted. Its photos stay in your timeline.",
|
||||
album.name
|
||||
&gettext("Delete Album?"),
|
||||
// Translators: {name} is the album's name.
|
||||
&gettext_f(
|
||||
"“{name}” is deleted. Its photos stay in your timeline.",
|
||||
&[("name", &album.name)],
|
||||
),
|
||||
"Delete",
|
||||
&gettext("Delete"),
|
||||
move || {
|
||||
run_album_request(
|
||||
&ui_c,
|
||||
@@ -317,15 +328,15 @@ fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
uid: uid.clone(),
|
||||
delete_photos: false,
|
||||
},
|
||||
"Album deleted",
|
||||
"Couldn't delete the album",
|
||||
gettext("Album deleted"),
|
||||
gettext("Couldn't delete the album"),
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
@@ -334,11 +345,13 @@ fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed:
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&ui, done);
|
||||
toast(&ui, &done);
|
||||
reload_album_grid(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -360,14 +373,14 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
items.into_iter().filter(|album| !album.shared).collect()
|
||||
}
|
||||
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;
|
||||
}
|
||||
_ => {
|
||||
toast_error(
|
||||
&ui,
|
||||
"Couldn't load albums",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't load albums"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -396,15 +409,20 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
.child(&list)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Add to Album")
|
||||
.heading(gettext("Add to Album"))
|
||||
.body(match photos.len() {
|
||||
1 => "Choose an album for this photo.".to_string(),
|
||||
n => format!("Choose an album for {n} photos."),
|
||||
1 => gettext("Choose an album for this photo."),
|
||||
n => ngettext_f(
|
||||
"Choose an album for {n} photo.",
|
||||
"Choose an album for {n} photos.",
|
||||
n as u64,
|
||||
&[],
|
||||
),
|
||||
})
|
||||
.extra_child(&scroll)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("new", "New Album…");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("new", &gettext("New Album…"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone());
|
||||
@@ -435,25 +453,41 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||
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 {
|
||||
toast(
|
||||
&ui,
|
||||
&format!(
|
||||
"Added {} to the album",
|
||||
count_noun(changed.len(), "photo", "photos")
|
||||
&ngettext_f(
|
||||
"Added {n} photo to the album",
|
||||
"Added {n} photos to the album",
|
||||
changed.len() as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Filed photos no longer belong in a timeline of unfiled ones.
|
||||
// The daemon's filter drops them too, so the paging offset stays
|
||||
// in step with what is left.
|
||||
if ui.gallery.not_in_album.get() && ui.gallery.album.borrow().is_none() {
|
||||
ui.gallery
|
||||
.selected
|
||||
.borrow_mut()
|
||||
.retain(|uid| !changed.contains(uid));
|
||||
remove_photos(&ui, &changed);
|
||||
}
|
||||
reload_album_grid(&ui);
|
||||
}
|
||||
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(
|
||||
&ui,
|
||||
"Couldn't add to the album",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't add to the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -487,7 +521,7 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
|
||||
restore_photos(&ui, kept);
|
||||
toast_error(
|
||||
&ui,
|
||||
"Some photos couldn't be removed from the album",
|
||||
&gettext("Some photos couldn't be removed from the album"),
|
||||
&failed[0].message,
|
||||
);
|
||||
}
|
||||
@@ -503,22 +537,29 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
|
||||
drop(open);
|
||||
toast(
|
||||
&ui,
|
||||
&format!(
|
||||
"Removed {} from the album",
|
||||
count_noun(changed.len(), "photo", "photos")
|
||||
&ngettext_f(
|
||||
"Removed {n} photo from the album",
|
||||
"Removed {n} photos from the album",
|
||||
changed.len() as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
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);
|
||||
toast_error(
|
||||
&ui,
|
||||
"Couldn't remove from the album",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't remove from the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -527,20 +568,22 @@ 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.
|
||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
let photos = match album.photo_count {
|
||||
1 => "1 photo".to_string(),
|
||||
n => format!("{n} photos"),
|
||||
};
|
||||
let count = album.photo_count as u64;
|
||||
if album.shared {
|
||||
format!("{photos} · shared with me")
|
||||
ngettext_f(
|
||||
"{n} photo · shared with me",
|
||||
"{n} photos · shared with me",
|
||||
count,
|
||||
&[],
|
||||
)
|
||||
} else {
|
||||
photos
|
||||
ngettext_f("{n} photo", "{n} photos", count, &[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||
/// same texture cache, same decode pacing as a timeline tile.
|
||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
@@ -588,7 +631,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_title("Photos");
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
@@ -620,6 +663,7 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
close_album(&ui_photos);
|
||||
close_place(&ui_photos);
|
||||
ui_photos.gallery.filters.set_visible(true);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
// The timeline comes back as it was left unless an album took over the
|
||||
@@ -632,15 +676,21 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
}
|
||||
});
|
||||
|
||||
// Back returns to the grid the album was opened from, scrolled where it
|
||||
// was, instead of reloading it.
|
||||
// Back returns to the grid the album or place was opened from, scrolled
|
||||
// where it was, instead of reloading it.
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
if ui_back.gallery.place.borrow().is_some() {
|
||||
show_place_grid(&ui_back);
|
||||
return;
|
||||
}
|
||||
show_album_grid(&ui_back);
|
||||
let count = ui_back.gallery.album_list.borrow().len();
|
||||
ui_back
|
||||
.gallery
|
||||
.title
|
||||
.set_subtitle(&count_noun(count, "album", "albums"));
|
||||
ui_back.gallery.title.set_subtitle(&ngettext_f(
|
||||
"{n} album",
|
||||
"{n} albums",
|
||||
count as u64,
|
||||
&[],
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
// second copy of that list here would be two places to change the same mode.
|
||||
let sync_group = adw::PreferencesGroup::builder()
|
||||
.title("This computer")
|
||||
.description("The computer this machine backs up as.")
|
||||
.title(gettext("This computer"))
|
||||
.description(gettext("The computer this machine backs up as."))
|
||||
.build();
|
||||
// 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
|
||||
// place it can be renamed; insensitive until the device list identifies it.
|
||||
let rename_this = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text("Rename this computer")
|
||||
.tooltip_text(gettext("Rename this computer"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
rename_this.add_css_class("flat");
|
||||
sync_group.set_header_suffix(Some(&rename_this));
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Other computers")
|
||||
.description("Other computers backing up to this account.")
|
||||
.title(gettext("Other computers"))
|
||||
.description(gettext("Other computers backing up to this account."))
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
@@ -81,7 +81,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
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_end(18);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame("Computers", &inner);
|
||||
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
@@ -158,8 +158,8 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
devices_status(
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
"Loading…",
|
||||
"Reading your computers.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your computers."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -206,8 +206,8 @@ pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
devices_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -215,8 +215,8 @@ pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
devices_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
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
|
||||
.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_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 => {
|
||||
*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();
|
||||
if others.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No other computers")
|
||||
.subtitle("Desktop apps syncing to this account appear here.")
|
||||
.title(gettext("No other computers"))
|
||||
.subtitle(gettext("Desktop apps syncing to this account appear here."))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
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());
|
||||
row.add_suffix(&more_menu_button(vec![
|
||||
(
|
||||
"Restore to This Computer…",
|
||||
&gettext("Restore to This Computer…"),
|
||||
Box::new(move || {
|
||||
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)),
|
||||
),
|
||||
(
|
||||
"Use This Computer's Identity…",
|
||||
&gettext("Use This Computer's Identity…"),
|
||||
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)),
|
||||
),
|
||||
]));
|
||||
@@ -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.
|
||||
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
match dev.last_sync {
|
||||
Some(secs) if secs > 0 => {
|
||||
format!("{} · last synced {}", dev.device_type, activity_time(secs))
|
||||
}
|
||||
_ => format!("{} · never synced", dev.device_type),
|
||||
// Translators: {platform} is the device's platform (such as "Linux"), {time} a date and time.
|
||||
Some(secs) if secs > 0 => gettext_f(
|
||||
"{platform} · last synced {time}",
|
||||
&[
|
||||
("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);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("This computer")
|
||||
.title(gettext("This computer"))
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Open Sync")
|
||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||
.label(gettext("Open Sync"))
|
||||
.tooltip_text(gettext("Manage this computer's folders and mountpoint"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
@@ -350,7 +367,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
row.add_suffix(&manage);
|
||||
let ui_rs = ui.clone();
|
||||
row.add_suffix(&more_menu_button(vec![(
|
||||
"Restore Folders…",
|
||||
&gettext("Restore Folders…"),
|
||||
Box::new(move || prompt_restore_folders(&ui_rs, None)),
|
||||
)]));
|
||||
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.
|
||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
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() {
|
||||
1 => "1 folder backed up".to_string(),
|
||||
n => format!("{n} folders backed up"),
|
||||
};
|
||||
let count = ngettext_f(
|
||||
"{n} folder backed up",
|
||||
"{n} folders backed up",
|
||||
folders.len() as u64,
|
||||
&[],
|
||||
);
|
||||
let attention = folders
|
||||
.iter()
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
.count();
|
||||
match attention {
|
||||
0 => format!("{count} · manage them on the Sync page"),
|
||||
1 => format!("{count} · 1 needs attention"),
|
||||
n => format!("{count} · {n} need attention"),
|
||||
// Translators: {folders} is "N folders backed up".
|
||||
0 => gettext_f(
|
||||
"{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.
|
||||
pub(crate) fn sync_state_label(state: &str) -> &str {
|
||||
pub(crate) fn sync_state_label(state: &str) -> String {
|
||||
match state {
|
||||
"syncing" => "syncing…",
|
||||
"error" => "sync error",
|
||||
"conflict" => "needs attention",
|
||||
_ => "up to date",
|
||||
"syncing" => gettext("syncing…"),
|
||||
"error" => gettext("sync error"),
|
||||
"conflict" => gettext("needs attention"),
|
||||
_ => gettext("up to date"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,18 +424,30 @@ pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||
match p.phase {
|
||||
// Before the first pass finishes there is no estimate, so the count would
|
||||
// be "checked 12 of 12" — worse than saying nothing.
|
||||
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(),
|
||||
SyncPhase::Scanning => format!(
|
||||
"checking for changes — {} of {}",
|
||||
p.done,
|
||||
p.total.max(p.done)
|
||||
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
|
||||
// Translators: {done} and {total} count folders and files checked so far.
|
||||
SyncPhase::Scanning => gettext_f(
|
||||
"checking for changes — {done} of {total}",
|
||||
&[
|
||||
("done", &p.done.to_string()),
|
||||
("total", &p.total.max(p.done).to_string()),
|
||||
],
|
||||
),
|
||||
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() {
|
||||
format!("syncing {count}")
|
||||
// Translators: {done} and {total} count the changes applied so far.
|
||||
gettext_f(
|
||||
"syncing {done} of {total}",
|
||||
&[("done", &done), ("total", &total)],
|
||||
)
|
||||
} 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>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Add Folder to Sync")
|
||||
.title(gettext("Add Folder to Sync"))
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
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
|
||||
// `refresh_locations` tick pick the row up whenever it
|
||||
// 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 })) => {
|
||||
toast_failure(&ui, "Couldn't add folder", &message, kind)
|
||||
toast_failure(&ui, &gettext("Couldn't add folder"), &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't add folder",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't add folder"),
|
||||
&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 })) => {
|
||||
show_restore_picker(&ui, items, device)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(
|
||||
&ui,
|
||||
&gettext("Couldn't list folders to restore"),
|
||||
&message,
|
||||
kind,
|
||||
),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't list folders to restore",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't list folders to restore"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -510,9 +553,9 @@ fn show_restore_picker(
|
||||
}
|
||||
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.description(
|
||||
.description(gettext(
|
||||
"Tick the folders to sync to this machine, and adjust where each one should live.",
|
||||
)
|
||||
))
|
||||
.build();
|
||||
// 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();
|
||||
@@ -534,14 +577,14 @@ fn show_restore_picker(
|
||||
.child(&group)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore folders")
|
||||
.heading(gettext("Restore folders"))
|
||||
.body(restore_picker_body(
|
||||
device.as_ref().map(|(_, n)| n.as_str()),
|
||||
))
|
||||
.extra_child(&scroll)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("restore", "Restore");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("restore", &gettext("Restore"));
|
||||
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("restore"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -574,8 +617,8 @@ fn show_restore_picker(
|
||||
},
|
||||
None => Request::RestoreSyncFolders { items },
|
||||
},
|
||||
"Restoring folders…",
|
||||
"Couldn't restore folders",
|
||||
&gettext("Restoring folders…"),
|
||||
&gettext("Couldn't restore folders"),
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
@@ -584,19 +627,24 @@ fn show_restore_picker(
|
||||
/// The restore picker's body: whose backup the folders come from.
|
||||
pub(crate) fn restore_picker_body(device: Option<&str>) -> String {
|
||||
match device {
|
||||
Some(name) => format!(
|
||||
"These folders are backed up from “{name}”. Restored folders keep syncing with \
|
||||
that computer's copy."
|
||||
// Translators: {name} is the name of another computer.
|
||||
Some(name) => gettext_f(
|
||||
"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.
|
||||
pub(crate) fn nothing_to_restore(device: Option<&str>) -> String {
|
||||
match device {
|
||||
Some(name) => format!("Nothing to restore — “{name}”'s folders are all synced here."),
|
||||
None => "Nothing to restore — this computer's folders are all synced here.".to_string(),
|
||||
// Translators: {name} is the name of another computer.
|
||||
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".
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
if mode == "ondemand" {
|
||||
"Folder will switch to on-demand"
|
||||
&if mode == "ondemand" {
|
||||
gettext("Folder will switch to on-demand")
|
||||
} else {
|
||||
"Folder will download and stay synced"
|
||||
gettext("Folder will download and stay synced")
|
||||
},
|
||||
),
|
||||
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(
|
||||
&ui,
|
||||
"Couldn't change mode",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't change mode"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
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
|
||||
// recoverable only by turning On-demand off *first* and letting it download.
|
||||
let body = if ondemand {
|
||||
format!(
|
||||
"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."
|
||||
// Translators: {path} is a local folder path.
|
||||
gettext_f(
|
||||
"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.",
|
||||
&[("path", path)],
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"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."
|
||||
// Translators: {path} is a local folder path.
|
||||
gettext_f(
|
||||
"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()
|
||||
.heading("Stop syncing folder")
|
||||
.heading(gettext("Stop syncing folder"))
|
||||
.body(body)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let delete_remote = adw::SwitchRow::builder()
|
||||
.title("Also delete from Proton Drive")
|
||||
.title(gettext("Also delete from Proton Drive"))
|
||||
.subtitle(if ondemand {
|
||||
"Deletes the only copy of these files."
|
||||
gettext("Deletes the only copy of these files.")
|
||||
} else {
|
||||
"The local copy is unaffected."
|
||||
gettext("The local copy is unaffected.")
|
||||
})
|
||||
.active(false)
|
||||
.build();
|
||||
group.add(&delete_remote);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Stop Syncing");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("remove", &gettext("Stop Syncing"));
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("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,
|
||||
delete_remote: delete_remote.is_active(),
|
||||
},
|
||||
"Stopped syncing folder",
|
||||
"Couldn't stop syncing the folder",
|
||||
&gettext("Stopped syncing 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) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Rename Computer")
|
||||
.body(format!("Rename “{current}”."))
|
||||
.heading(gettext("Rename Computer"))
|
||||
// Translators: {name} is the computer's current name.
|
||||
.body(gettext_f("Rename “{name}”.", &[("name", current)]))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("New name")
|
||||
.title(gettext("New name"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("rename", "Rename");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("rename", &gettext("Rename"));
|
||||
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("rename"));
|
||||
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();
|
||||
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;
|
||||
}
|
||||
run_devices_mutation(
|
||||
@@ -747,8 +800,8 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
"Computer renamed",
|
||||
"Couldn't rename the computer",
|
||||
&gettext("Computer renamed"),
|
||||
&gettext("Couldn't rename the computer"),
|
||||
);
|
||||
});
|
||||
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) {
|
||||
let win = ui_window(ui);
|
||||
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();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.add(&confirm);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove Computer")
|
||||
.body(format!(
|
||||
"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."
|
||||
.heading(gettext("Remove Computer"))
|
||||
// Translators: {name} is the name of the computer being removed.
|
||||
.body(gettext_f(
|
||||
"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.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.extra_child(&group)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("remove", &gettext("Remove"));
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_response_enabled("remove", false);
|
||||
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(
|
||||
&ui,
|
||||
Request::DeleteDevice { uid: uid.clone() },
|
||||
"Computer removed",
|
||||
"Couldn't remove the computer",
|
||||
&gettext("Computer removed"),
|
||||
&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) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Use this computer's identity")
|
||||
.body(format!(
|
||||
"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."
|
||||
.heading(gettext("Use this computer's identity"))
|
||||
// Translators: {name} is the name of the other computer whose identity is adopted.
|
||||
.body(gettext_f(
|
||||
"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.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("adopt", "Use identity");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("adopt", &gettext("Use identity"));
|
||||
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("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 {
|
||||
uid: Some(uid.clone()),
|
||||
},
|
||||
"Identity adopted",
|
||||
"Couldn't adopt that computer",
|
||||
&gettext("Identity adopted"),
|
||||
&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.
|
||||
pub(crate) fn run_devices_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
let done = done.to_string();
|
||||
let failed = failed.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
@@ -857,8 +906,10 @@ pub(crate) fn run_devices_mutation(
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
use crate::*;
|
||||
|
||||
/// Side of a copy's thumbnail in the duplicates dialog, in px.
|
||||
const COPY_SIZE: i32 = 150;
|
||||
|
||||
/// Each set's copies with their Keep toggles.
|
||||
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
|
||||
|
||||
/// Open the duplicate finder: every set of photos stored more than once, side
|
||||
/// by side, with the copy worth keeping ticked. The copies left unticked go to
|
||||
/// Trash — one file each, never the rest of their shot.
|
||||
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.title(gettext("Looking for duplicates…"))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let stack = gtk4::Stack::new();
|
||||
stack.add_named(&status, Some("status"));
|
||||
|
||||
let trash = gtk4::Button::builder()
|
||||
.label(gettext("Move to Trash"))
|
||||
.halign(gtk4::Align::End)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
trash.add_css_class("destructive-action");
|
||||
trash.add_css_class("pill");
|
||||
let bottom = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bottom.add_css_class("toolbar");
|
||||
bottom.set_halign(gtk4::Align::End);
|
||||
bottom.append(&trash);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
toolbar.set_content(Some(&stack));
|
||||
toolbar.add_bottom_bar(&bottom);
|
||||
toolbar.set_reveal_bottom_bars(false);
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(gettext("Duplicates"))
|
||||
.content_width(760)
|
||||
.content_height(640)
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let sets = match rx.recv().await {
|
||||
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||
status.set_title(&gettext("Couldn't look for duplicates"));
|
||||
status.set_description(Some(&message));
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||
status.set_title(&gettext("Couldn't look for duplicates"));
|
||||
status.set_description(Some(&gettext("The mount service didn't respond.")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if sets.is_empty() {
|
||||
status.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
status.set_title(&gettext("No duplicates"));
|
||||
status.set_description(Some(&gettext(
|
||||
"Every photo in your library is stored once.",
|
||||
)));
|
||||
return;
|
||||
}
|
||||
let review = duplicate_review(&ui, sets, &trash);
|
||||
stack.add_named(&review, Some("review"));
|
||||
stack.set_visible_child_name("review");
|
||||
toolbar.set_reveal_bottom_bars(true);
|
||||
schedule_thumbs(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// The review list: one section per set, each copy a thumbnail with a Keep
|
||||
/// toggle. Wires `trash` to move every copy not kept to Trash.
|
||||
fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button) -> gtk4::Widget {
|
||||
let intro = gtk4::Label::builder()
|
||||
.label(gettext(
|
||||
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
|
||||
))
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
intro.add_css_class("dim-label");
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(18);
|
||||
content.set_margin_end(18);
|
||||
content.append(&intro);
|
||||
|
||||
// Every copy's toggle, by set, so the button can count what goes.
|
||||
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
|
||||
for set in sets {
|
||||
let Some(first) = set.first() else {
|
||||
continue;
|
||||
};
|
||||
let date = section_heading(first.capture_time, Grouping::Day);
|
||||
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
|
||||
let title = ngettext_f(
|
||||
"{date}, {n} copy",
|
||||
"{date}, {n} copies",
|
||||
set.len() as u64,
|
||||
&[("date", &date)],
|
||||
);
|
||||
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
|
||||
heading.add_css_class("heading");
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
let mut set_toggles = Vec::new();
|
||||
for (index, photo) in set.into_iter().enumerate() {
|
||||
let (card, keep) = copy_card(ui, &photo, index == 0);
|
||||
row.append(&card);
|
||||
set_toggles.push((photo, keep));
|
||||
}
|
||||
let row_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.child(&row)
|
||||
.build();
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
section.append(&heading);
|
||||
section.append(&row_scroll);
|
||||
content.append(§ion);
|
||||
toggles.borrow_mut().push(set_toggles);
|
||||
}
|
||||
|
||||
let sync = {
|
||||
let toggles = toggles.clone();
|
||||
let trash = trash.clone();
|
||||
move || {
|
||||
let n = doomed(&toggles.borrow()).len();
|
||||
trash.set_label(&ngettext_f(
|
||||
"Move {n} copy to Trash",
|
||||
"Move {n} copies to Trash",
|
||||
n as u64,
|
||||
&[],
|
||||
));
|
||||
trash.set_sensitive(n > 0);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
for set in toggles.borrow().iter() {
|
||||
for (_, keep) in set {
|
||||
let toggles = toggles.clone();
|
||||
let sync = sync.clone();
|
||||
keep.connect_toggled(move |keep| {
|
||||
// A set always keeps a copy: unticking the last one would trash
|
||||
// the photo itself, which is not what removing duplicates means.
|
||||
if !keep.is_active()
|
||||
&& toggles.borrow().iter().any(|set| {
|
||||
set.iter().any(|(_, t)| t == keep)
|
||||
&& set.iter().all(|(_, t)| !t.is_active())
|
||||
})
|
||||
{
|
||||
keep.set_active(true);
|
||||
return;
|
||||
}
|
||||
sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ui_trash = ui.clone();
|
||||
trash.connect_clicked(move |button| {
|
||||
let uids = doomed(&toggles.borrow());
|
||||
if uids.is_empty() {
|
||||
return;
|
||||
}
|
||||
trash_photos(&ui_trash, uids, true);
|
||||
if let Some(dialog) = button
|
||||
.ancestor(adw::Dialog::static_type())
|
||||
.and_downcast::<adw::Dialog>()
|
||||
{
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&content)
|
||||
.build();
|
||||
scroll.upcast()
|
||||
}
|
||||
|
||||
/// The uids of every copy not kept.
|
||||
fn doomed(toggles: &SetToggles) -> Vec<String> {
|
||||
toggles
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|(_, keep)| !keep.is_active())
|
||||
.map(|(photo, _)| photo.uid.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One copy: its thumbnail, its file name, and whether to keep it.
|
||||
fn copy_card(ui: &Rc<Ui>, photo: &PhotoItem, keep: bool) -> (gtk4::Box, gtk4::ToggleButton) {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.build();
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_size_request(COPY_SIZE, COPY_SIZE);
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||
overlay.add_css_class("memory-cover");
|
||||
want_thumb(ui, photo, &picture);
|
||||
|
||||
let name = photo.name.clone().unwrap_or_default();
|
||||
let label = gtk4::Label::builder()
|
||||
.label(&name)
|
||||
.tooltip_text(&name)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.max_width_chars(18)
|
||||
.build();
|
||||
label.add_css_class("caption");
|
||||
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Keep"))
|
||||
.active(keep)
|
||||
.build();
|
||||
toggle.add_css_class("pill");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
card.set_size_request(COPY_SIZE, -1);
|
||||
card.append(&overlay);
|
||||
card.append(&label);
|
||||
card.append(&toggle);
|
||||
(card, toggle)
|
||||
}
|
||||
@@ -86,22 +86,23 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.child(
|
||||
&adw::ButtonContent::builder()
|
||||
.label("Add Folder")
|
||||
.label(gettext("Add Folder"))
|
||||
.icon_name("list-add-symbolic")
|
||||
.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();
|
||||
let refresh = refresh_button();
|
||||
let transfers_group = build_transfers_group();
|
||||
let (card, card_icon, card_row, pause) = build_sync_card();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
let conflicts_group = adw::PreferencesGroup::builder()
|
||||
.title("Conflicts")
|
||||
.description(
|
||||
"Files changed in two places at once. Both versions were kept; choose which \
|
||||
one stays.",
|
||||
)
|
||||
.title(gettext("Conflicts"))
|
||||
.description(gettext(
|
||||
"Files changed in two places at once. Both versions were kept; choose which one stays.",
|
||||
))
|
||||
.visible(false)
|
||||
.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
|
||||
// after the fact.
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("On this computer")
|
||||
.description(
|
||||
"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.",
|
||||
)
|
||||
.title(gettext("On this computer"))
|
||||
.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."))
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
@@ -134,7 +131,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -142,7 +139,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("drive-harddisk-symbolic")
|
||||
.title("Loading…")
|
||||
.title(gettext("Loading…"))
|
||||
.child(&retry)
|
||||
.build();
|
||||
// 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_end(18);
|
||||
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_end(&refresh);
|
||||
|
||||
@@ -219,24 +216,29 @@ fn build_sync_card() -> (
|
||||
.build();
|
||||
icon.add_css_class("sidebar-status");
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Pause for 1 Hour"), Some("sync.pause-for(int64 3600)"));
|
||||
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)"),
|
||||
);
|
||||
menu.append(
|
||||
Some("Pause for 24 Hours"),
|
||||
Some(&gettext("Pause for 24 Hours")),
|
||||
Some("sync.pause-for(int64 86400)"),
|
||||
);
|
||||
let pause = adw::SplitButton::builder()
|
||||
.label("Pause")
|
||||
.label(pgettext("verb", "Pause"))
|
||||
.menu_model(&menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Stop uploading until you resume. Files still open as usual.")
|
||||
.dropdown_tooltip("Pause for a while")
|
||||
.tooltip_text(gettext(
|
||||
"Stop uploading until you resume. Files still open as usual.",
|
||||
))
|
||||
.dropdown_tooltip(gettext("Pause for a while"))
|
||||
.build();
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Checking…")
|
||||
.title(gettext("Checking…"))
|
||||
.title_lines(1)
|
||||
.subtitle_lines(2)
|
||||
.build();
|
||||
@@ -269,12 +271,15 @@ pub(crate) fn paint_sync_card(
|
||||
card.row.set_title(title);
|
||||
card.row.set_subtitle(detail.unwrap_or_default());
|
||||
card.paused.set(paused);
|
||||
card.pause
|
||||
.set_label(if paused { "Resume" } else { "Pause" });
|
||||
card.pause.set_tooltip_text(Some(if paused {
|
||||
"Upload everything that waited while sync was paused"
|
||||
card.pause.set_label(&if paused {
|
||||
pgettext("verb", "Resume")
|
||||
} 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 {
|
||||
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();
|
||||
glib::spawn_future_local(async move {
|
||||
let what = if paused {
|
||||
"Couldn't pause sync"
|
||||
gettext("Couldn't pause sync")
|
||||
} else {
|
||||
"Couldn't resume sync"
|
||||
gettext("Couldn't resume sync")
|
||||
};
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
match (paused, until) {
|
||||
(false, _) => "Sync resumed",
|
||||
(true, None) => "Sync paused until you resume",
|
||||
(true, Some(_)) => "Sync paused",
|
||||
&match (paused, until) {
|
||||
(false, _) => gettext("Sync resumed"),
|
||||
(true, None) => gettext("Sync paused until you resume"),
|
||||
(true, Some(_)) => gettext("Sync paused"),
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, what, &message, kind),
|
||||
_ => toast_error(&ui, what, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, &what, &message, kind),
|
||||
_ => toast_error(&ui, &what, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
ui.locations.card.pause.set_sensitive(true);
|
||||
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.
|
||||
fn build_queue_group() -> (adw::PreferencesGroup, gtk4::Button) {
|
||||
let retry_all = gtk4::Button::builder()
|
||||
.label("Retry All")
|
||||
.label(gettext("Retry All"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Try every failed change again now")
|
||||
.tooltip_text(gettext("Try every failed change again now"))
|
||||
.visible(false)
|
||||
.build();
|
||||
retry_all.add_css_class("flat");
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Waiting to Upload")
|
||||
.description("Changes made on this computer that are not on Proton Drive yet.")
|
||||
.title(gettext("Waiting to Upload"))
|
||||
.description(gettext(
|
||||
"Changes made on this computer that are not on Proton Drive yet.",
|
||||
))
|
||||
.header_suffix(&retry_all)
|
||||
.visible(false)
|
||||
.build();
|
||||
@@ -406,13 +413,11 @@ fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
||||
.set_visible(items.iter().any(|op| op.attempts > 0 && !op.parked));
|
||||
let hidden = items.len().saturating_sub(QUEUE_ROWS_SHOWN);
|
||||
queue.group.set_description(Some(&if hidden > 0 {
|
||||
format!(
|
||||
"Changes made on this computer that are not on Proton Drive yet. \
|
||||
Showing the first {QUEUE_ROWS_SHOWN} of {}.",
|
||||
items.len()
|
||||
)
|
||||
let shown = QUEUE_ROWS_SHOWN.to_string();
|
||||
// Translators: {shown} is how many rows are listed (50), {n} the total number of queued changes.
|
||||
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)])
|
||||
} 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()
|
||||
.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 {
|
||||
let (icon, action) = match op.kind.as_str() {
|
||||
"revision" => ("pdfs-upload-symbolic", "Upload changes"),
|
||||
"create" => ("pdfs-upload-symbolic", "Upload new file"),
|
||||
"mkdir" => ("folder-new-symbolic", "Create folder"),
|
||||
"rename" => ("document-edit-symbolic", "Rename or move"),
|
||||
"trash" => ("user-trash-symbolic", "Move to trash"),
|
||||
_ => ("emblem-synchronizing-symbolic", "Change"),
|
||||
"revision" => ("pdfs-upload-symbolic", gettext("Upload changes")),
|
||||
"create" => ("pdfs-upload-symbolic", gettext("Upload new file")),
|
||||
"mkdir" => ("folder-new-symbolic", gettext("Create folder")),
|
||||
"rename" => ("document-edit-symbolic", gettext("Rename or move")),
|
||||
"trash" => ("user-trash-symbolic", gettext("Move to trash")),
|
||||
// Translators: a queued change of an unknown kind.
|
||||
_ => ("emblem-synchronizing-symbolic", pgettext("noun", "Change")),
|
||||
};
|
||||
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 => {
|
||||
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, _) => "up next".to_string(),
|
||||
(false, _) if ui.locations.card.paused.get() => gettext("waiting for sync to resume"),
|
||||
(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 {
|
||||
subtitle.push_str(&format!(
|
||||
"\nFailed {}: {error}",
|
||||
count_noun(op.attempts.max(0) as usize, "time", "times")
|
||||
subtitle.push('\n');
|
||||
// Translators: {n} is how many attempts failed, {error} the last error message.
|
||||
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()
|
||||
@@ -469,7 +484,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||
if waiting {
|
||||
let retry = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Retry now")
|
||||
.tooltip_text(gettext("Retry now"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("flat");
|
||||
@@ -488,14 +503,18 @@ fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&ui, "Retrying now…");
|
||||
toast(&ui, &gettext("Retrying now…"));
|
||||
ui.locations.queue.painted.borrow_mut().clear();
|
||||
refresh_queue(&ui);
|
||||
}
|
||||
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.
|
||||
fn conflict_side(size: u64, modified: i64) -> String {
|
||||
format!(
|
||||
"{}, changed {}",
|
||||
glib::format_size(size),
|
||||
clock_time(modified)
|
||||
// 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".
|
||||
gettext_f(
|
||||
"{size}, changed {time}",
|
||||
&[
|
||||
("size", &glib::format_size(size)),
|
||||
("time", &clock_time(modified)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
||||
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 {
|
||||
"Same content as the original".to_string()
|
||||
gettext("Same content as the original")
|
||||
} 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()
|
||||
.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 original_name = file_name(&conflict.original_path).to_string();
|
||||
let body = match (conflict.original_size, conflict.original_modified) {
|
||||
(Some(size), Some(modified)) if conflict.original_exists => format!(
|
||||
"“{original_name}” was changed here and elsewhere at the same time.\n\n\
|
||||
Original: {}\nCopy: {}\n\n\
|
||||
Whatever is not kept goes to Trash, where it can be restored.",
|
||||
conflict_side(size, modified),
|
||||
conflict_side(conflict.size, conflict.modified),
|
||||
),
|
||||
_ => format!(
|
||||
"The original “{original_name}” no longer exists; only the copy is left \
|
||||
({}).\n\nKeep the copy to give it the original name back.",
|
||||
conflict_side(conflict.size, conflict.modified),
|
||||
),
|
||||
(Some(size), Some(modified)) if conflict.original_exists => {
|
||||
let original = conflict_side(size, modified);
|
||||
let copy = conflict_side(conflict.size, conflict.modified);
|
||||
// Translators: {name} is the file name; {original} and {copy} each read like "12.3 MB, changed 14:30".
|
||||
gettext_f(
|
||||
"“{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.",
|
||||
&[
|
||||
("name", &original_name),
|
||||
("original", &original),
|
||||
("copy", ©),
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
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", ©)],
|
||||
)
|
||||
}
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Resolve conflict")
|
||||
.heading(gettext("Resolve conflict"))
|
||||
.body(body)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name for the copy, if keeping both")
|
||||
.title(gettext("Name for the copy, if keeping both"))
|
||||
.build();
|
||||
name_row.set_text(©_name);
|
||||
group.add(&name_row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
if conflict.original_exists {
|
||||
dialog.add_response("original", "Keep Original");
|
||||
dialog.add_response("both", "Keep Both");
|
||||
dialog.add_response("original", &gettext("Keep Original"));
|
||||
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_close_response("cancel");
|
||||
// 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_activity_conflicts(&ui, true);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't resolve the conflict", &message, kind)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(
|
||||
&ui,
|
||||
&gettext("Couldn't resolve the conflict"),
|
||||
&message,
|
||||
kind,
|
||||
),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't resolve the conflict",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't resolve the conflict"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -737,8 +775,8 @@ pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||
locations_status(
|
||||
ui,
|
||||
"drive-harddisk-symbolic",
|
||||
"Loading…",
|
||||
"Reading this computer's Proton Drive locations.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this computer's Proton Drive locations."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -759,7 +797,7 @@ pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||
locations_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Unavailable",
|
||||
&gettext("Unavailable"),
|
||||
&message,
|
||||
true,
|
||||
);
|
||||
@@ -803,8 +841,8 @@ pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
|
||||
locations_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -812,8 +850,8 @@ pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
|
||||
locations_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -830,8 +868,10 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
||||
}
|
||||
if items.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No locations")
|
||||
.subtitle("The mount service hasn't reported a mountpoint yet.")
|
||||
.title(gettext("No locations"))
|
||||
.subtitle(gettext(
|
||||
"The mount service hasn't reported a mountpoint yet.",
|
||||
))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("drive-harddisk-symbolic"));
|
||||
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
|
||||
// from a save dialog.
|
||||
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("caption");
|
||||
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()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.tooltip_text("Open this folder")
|
||||
.tooltip_text(gettext("Open this folder"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
@@ -906,8 +946,10 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
||||
/// [`prompt_mountpoint`] dialog.
|
||||
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||
let change = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
.tooltip_text("Choose a different folder for the Proton Drive mount")
|
||||
.label(pgettext("verb", "Change"))
|
||||
.tooltip_text(gettext(
|
||||
"Choose a different folder for the Proton Drive mount",
|
||||
))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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 {
|
||||
true => (
|
||||
"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()
|
||||
.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 {
|
||||
let sync_now = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Sync this folder now")
|
||||
.tooltip_text(gettext("Sync this folder now"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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.
|
||||
let target = spec.pending_mode.unwrap_or(spec.mode);
|
||||
let ondemand = gtk4::Switch::builder()
|
||||
.tooltip_text(
|
||||
"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.",
|
||||
)
|
||||
.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."))
|
||||
.valign(gtk4::Align::Center)
|
||||
.active(target == MountMode::OnDemand)
|
||||
// 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()
|
||||
.icon_name("media-playback-stop-symbolic")
|
||||
.tooltip_text("Stop syncing this folder")
|
||||
.tooltip_text(gettext("Stop syncing this folder"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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.
|
||||
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Make Folder On-Demand?")
|
||||
.body(format!(
|
||||
"Files in {path} will be removed from this computer and kept in Proton \
|
||||
Drive only. Each file downloads again when you open it."
|
||||
))
|
||||
.heading(gettext("Make Folder On-Demand?"))
|
||||
// Translators: {path} is a local folder path.
|
||||
.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)]))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("ondemand", "Make On-Demand");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("ondemand", &gettext("Make On-Demand"));
|
||||
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("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();
|
||||
glib::spawn_future_local(async move {
|
||||
let failed = match paused {
|
||||
true => "Couldn't pause the folder",
|
||||
false => "Couldn't resume the folder",
|
||||
true => gettext("Couldn't pause the folder"),
|
||||
false => gettext("Couldn't resume the folder"),
|
||||
};
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
match paused {
|
||||
true => "Folder paused",
|
||||
false => "Folder resumed",
|
||||
&match paused {
|
||||
true => gettext("Folder paused"),
|
||||
false => gettext("Folder resumed"),
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
refresh_locations(&ui);
|
||||
});
|
||||
@@ -1058,11 +1099,15 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
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 })) => {
|
||||
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 {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
match &spec.kind {
|
||||
MountKind::MyFiles => parts.push("My files".to_string()),
|
||||
MountKind::Shared { .. } => parts.push("Shared folder".to_string()),
|
||||
MountKind::MyFiles => parts.push(gettext("My files")),
|
||||
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
|
||||
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
||||
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()),
|
||||
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()),
|
||||
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going on-demand")),
|
||||
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
|
||||
(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 { .. }) {
|
||||
parts.push(match &spec.progress {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -1109,7 +1156,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
||||
|| spec.mode == MountMode::OnDemand
|
||||
|| spec.pending_mode == Some(MountMode::OnDemand);
|
||||
if expects_session && !spec.mounted {
|
||||
parts.push("not mounted".to_string());
|
||||
parts.push(gettext("not mounted"));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
@@ -21,19 +21,23 @@ const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
|
||||
/// only when the account actually requires it.
|
||||
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Sign in to Proton")
|
||||
.description("Use your Proton account to connect Drive.")
|
||||
.title(gettext("Sign in to Proton"))
|
||||
.description(gettext("Use your Proton account to connect Drive."))
|
||||
.build();
|
||||
|
||||
let email = adw::EntryRow::builder().title("Email or username").build();
|
||||
let password = adw::PasswordEntryRow::builder().title("Password").build();
|
||||
let email = adw::EntryRow::builder()
|
||||
.title(gettext("Email or username"))
|
||||
.build();
|
||||
let password = adw::PasswordEntryRow::builder()
|
||||
.title(gettext("Password"))
|
||||
.build();
|
||||
group.add(&email);
|
||||
group.add(&password);
|
||||
|
||||
let login_spinner = gtk4::Spinner::builder().visible(false).build();
|
||||
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
button_content.append(&login_spinner);
|
||||
button_content.append(>k4::Label::new(Some("Sign in")));
|
||||
button_content.append(>k4::Label::new(Some(&gettext("Sign in"))));
|
||||
let login_button = gtk4::Button::builder()
|
||||
.child(&button_content)
|
||||
.halign(gtk4::Align::Center)
|
||||
@@ -48,9 +52,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
login_status.add_css_class("dim-label");
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
||||
logo.set_pixel_size(64);
|
||||
logo.add_css_class("brand-icon");
|
||||
let logo = gtk4::Image::from_icon_name(APP_ID);
|
||||
logo.set_pixel_size(96);
|
||||
let title = gtk4::Label::new(Some("Proton Drive"));
|
||||
title.add_css_class("brand-title");
|
||||
header.append(&logo);
|
||||
@@ -68,10 +71,13 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
inner.append(&login_status);
|
||||
let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
links.set_halign(gtk4::Align::Center);
|
||||
links.append(>k4::LinkButton::with_label(SIGNUP_URL, "Create account"));
|
||||
links.append(>k4::LinkButton::with_label(
|
||||
SIGNUP_URL,
|
||||
&gettext("Create account"),
|
||||
));
|
||||
links.append(>k4::LinkButton::with_label(
|
||||
RESET_PASSWORD_URL,
|
||||
"Forgot password?",
|
||||
&gettext("Forgot password?"),
|
||||
));
|
||||
inner.append(&links);
|
||||
|
||||
@@ -115,12 +121,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
if username.is_empty() || password.is_empty() {
|
||||
ui.login
|
||||
.login_status
|
||||
.set_text("Enter your email and password.");
|
||||
.set_text(&gettext("Enter your email and password."));
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||
@@ -145,7 +151,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
ui_hv
|
||||
.login
|
||||
.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);
|
||||
}
|
||||
});
|
||||
@@ -155,7 +161,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
let result = rx
|
||||
.recv()
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||
.unwrap_or_else(|_| Err(gettext("login cancelled")));
|
||||
set_signing_in(&ui, false);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -189,28 +195,28 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
use pdfs_core::proton_sdk::ProtonError;
|
||||
match error {
|
||||
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() => {
|
||||
api.message.clone()
|
||||
}
|
||||
pdfs_core::Error::Proton(ProtonError::Transport(_)) => {
|
||||
"Couldn't reach Proton. Check your internet connection and try again.".to_string()
|
||||
}
|
||||
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()
|
||||
gettext("Couldn't reach Proton. Check your internet connection and try again.")
|
||||
}
|
||||
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") => {
|
||||
"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") => {
|
||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new \
|
||||
one."
|
||||
.to_string()
|
||||
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 one.",
|
||||
),
|
||||
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 +295,22 @@ pub(crate) fn spawn_login(
|
||||
/// the worker reads as a cancelled login.
|
||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Two-factor authentication")
|
||||
.body("Enter the code from your authenticator app, or one of your recovery codes.")
|
||||
.heading(gettext("Two-factor authentication"))
|
||||
.body(gettext(
|
||||
"Enter the code from your authenticator app, or one of your recovery codes.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let entry = adw::EntryRow::builder()
|
||||
.title("Authentication code")
|
||||
.title(gettext("Authentication code"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&entry);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("confirm", "Confirm");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("confirm", &gettext("Confirm"));
|
||||
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("confirm"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -330,10 +338,11 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||
let ui = ui.clone();
|
||||
confirm_destructive(
|
||||
&window,
|
||||
"Sign Out?",
|
||||
"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?"),
|
||||
&gettext(
|
||||
"Proton Drive will disconnect and stop syncing until you sign in again. Files already on this computer stay where they are.",
|
||||
),
|
||||
&gettext("Sign Out"),
|
||||
move || {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
|
||||
@@ -2,10 +2,13 @@ pub(crate) mod activity;
|
||||
pub(crate) mod albums;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
pub(crate) mod duplicates;
|
||||
pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
pub(crate) mod places;
|
||||
pub(crate) mod places_map;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
|
||||
@@ -9,6 +9,12 @@ const ZOOM_STEP: f64 = 1.25;
|
||||
/// The furthest the lightbox zooms in, as a multiple of the image's own pixels.
|
||||
const MAX_ZOOM: f64 = 8.0;
|
||||
|
||||
/// How long a slideshow shows each photo. A video plays to its end instead.
|
||||
const SLIDESHOW_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How often a slideshow checks whether the video on screen has finished.
|
||||
const SLIDESHOW_VIDEO_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How close to the end of the loaded photos stepping forward starts paging the
|
||||
/// next batch in, so reaching the end rarely means waiting for it.
|
||||
const PAGE_AHEAD: u32 = 10;
|
||||
@@ -75,15 +81,22 @@ pub(crate) struct Viewer {
|
||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||
pub(crate) chrome_hover: Cell<bool>,
|
||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||
/// The slideshow toggle; the timer that moves it on; and whether it put
|
||||
/// the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||
/// entered itself.
|
||||
pub(crate) slideshow: gtk4::ToggleButton,
|
||||
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) slide_fullscreened: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||
/// label/value pairs for the details panel.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ExifInfo {
|
||||
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
|
||||
/// EXIF at all, which is normal for screenshots and re-encoded images.
|
||||
pub(crate) fields: Vec<(&'static str, String)>,
|
||||
/// `("Camera", "Apple iPhone 15")` and friends, the label already
|
||||
/// translated; empty when the file has no EXIF at all, which is normal for
|
||||
/// screenshots and re-encoded images.
|
||||
pub(crate) fields: Vec<(String, String)>,
|
||||
/// Decimal degrees, if the photo is geotagged.
|
||||
pub(crate) coords: Option<(f64, f64)>,
|
||||
}
|
||||
@@ -146,7 +159,7 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
|
||||
if let Some(next) = next_group_member(&items, &uid) {
|
||||
viewer
|
||||
.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_btn.set_visible(true);
|
||||
@@ -173,6 +186,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
|
||||
/// 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> {
|
||||
@@ -204,6 +232,8 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
stop_video(viewer);
|
||||
viewer.media.set_visible_child_name("photo");
|
||||
reset_zoom(viewer);
|
||||
// A slideshow waits for this photo to land before it starts counting.
|
||||
disarm_slide(viewer);
|
||||
|
||||
// Looked up before the group is refetched, which empties it.
|
||||
let item = viewer_item(ui, viewer, &uid);
|
||||
@@ -228,6 +258,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
Request::OpenPhoto { uid: uid.clone() },
|
||||
);
|
||||
let viewer = viewer.clone();
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
// The user may have moved on while this was in flight; that photo's own
|
||||
@@ -238,6 +269,9 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.stop();
|
||||
viewer.spinner.set_visible(false);
|
||||
viewer.loading.set(false);
|
||||
// Armed whether or not the photo rendered: a slideshow steps past a
|
||||
// photo it could not show rather than stalling on it.
|
||||
arm_slide(&ui, &viewer);
|
||||
|
||||
let fail = |message: &str| {
|
||||
viewer.status.set_label(message);
|
||||
@@ -269,16 +303,102 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
}
|
||||
Err(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(_)) => fail("Unexpected reply from the mount service."),
|
||||
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."),
|
||||
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the mount service.")),
|
||||
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
||||
/// screen counted down once it has loaded.
|
||||
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
if !viewer.window.is_fullscreen() {
|
||||
viewer.window.fullscreen();
|
||||
viewer.slide_fullscreened.set(true);
|
||||
}
|
||||
viewer
|
||||
.slideshow
|
||||
.set_icon_name("media-playback-pause-symbolic");
|
||||
hide_chrome(viewer);
|
||||
if !viewer.loading.get() {
|
||||
arm_slide(ui, viewer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||
disarm_slide(viewer);
|
||||
if viewer.slide_fullscreened.replace(false) {
|
||||
viewer.window.unfullscreen();
|
||||
}
|
||||
viewer
|
||||
.slideshow
|
||||
.set_icon_name("media-playback-start-symbolic");
|
||||
if viewer.slideshow.is_active() {
|
||||
viewer.slideshow.set_active(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a running slideshow on from the photo on screen: after
|
||||
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
||||
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
disarm_slide(viewer);
|
||||
if !viewer.slideshow.is_active() {
|
||||
return;
|
||||
}
|
||||
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||
let (ui, weak) = (ui.clone(), Rc::downgrade(viewer));
|
||||
let source = if playing_video {
|
||||
glib::timeout_add_local(SLIDESHOW_VIDEO_POLL, move || {
|
||||
let Some(viewer) = weak.upgrade() else {
|
||||
return glib::ControlFlow::Break;
|
||||
};
|
||||
let ended = viewer
|
||||
.video
|
||||
.media_stream()
|
||||
.is_some_and(|stream| stream.is_ended());
|
||||
if !ended {
|
||||
return glib::ControlFlow::Continue;
|
||||
}
|
||||
// This timer is finishing, so its id must not be removed again.
|
||||
viewer.slide_source.borrow_mut().take();
|
||||
next_slide(&ui, &viewer);
|
||||
glib::ControlFlow::Break
|
||||
})
|
||||
} else {
|
||||
glib::timeout_add_local_once(SLIDESHOW_INTERVAL, move || {
|
||||
let Some(viewer) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
viewer.slide_source.borrow_mut().take();
|
||||
next_slide(&ui, &viewer);
|
||||
})
|
||||
};
|
||||
*viewer.slide_source.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
fn disarm_slide(viewer: &Rc<Viewer>) {
|
||||
if let Some(source) = viewer.slide_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the slideshow forward, or end it at the last photo there is.
|
||||
fn next_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let model = &ui.gallery.model;
|
||||
let last = find_photo_index(model, &viewer.uid.borrow())
|
||||
.is_some_and(|index| index + 1 >= model.n_items());
|
||||
if last && !ui.gallery.has_more.get() {
|
||||
stop_slideshow(viewer);
|
||||
return;
|
||||
}
|
||||
navigate_photo(ui, viewer, 1);
|
||||
}
|
||||
|
||||
/// Stop whatever the player was playing, so stepping off a video (or closing
|
||||
/// the lightbox) doesn't leave its sound running.
|
||||
fn stop_video(viewer: &Rc<Viewer>) {
|
||||
@@ -326,7 +446,7 @@ pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
|
||||
viewer.info_rows.remove(&row);
|
||||
}
|
||||
let label = gtk4::Label::builder()
|
||||
.label("Reading photo details…")
|
||||
.label(gettext("Reading photo details…"))
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
@@ -340,15 +460,16 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
||||
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) {
|
||||
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() {
|
||||
let label = gtk4::Label::builder()
|
||||
.label("This photo carries no metadata.")
|
||||
.label(gettext("This photo carries no metadata."))
|
||||
.halign(gtk4::Align::Start)
|
||||
.wrap(true)
|
||||
.build();
|
||||
@@ -360,7 +481,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
||||
group.add_css_class("boxed-list");
|
||||
for (label, value) in fields {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(label)
|
||||
.title(label.as_str())
|
||||
.subtitle(value)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
@@ -378,7 +499,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
|
||||
/// images legitimately carry no EXIF at all.
|
||||
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 reader = match std::fs::File::open(path) {
|
||||
@@ -402,52 +523,112 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
||||
.get_field(tag, exif::In::PRIMARY)
|
||||
.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)
|
||||
.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) {
|
||||
fields.push(("Taken", taken));
|
||||
if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) {
|
||||
// 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]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !camera.is_empty() {
|
||||
fields.push(("Camera", camera));
|
||||
if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
|
||||
// Translators: label of the camera model in a photo's details panel.
|
||||
fields.push((gettext("Camera"), camera));
|
||||
}
|
||||
if let Some(lens) = field(exif::Tag::LensModel) {
|
||||
fields.push(("Lens", lens));
|
||||
if let Some(lens) = ascii(exif::Tag::LensModel) {
|
||||
// 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> = [
|
||||
exif::Tag::FNumber,
|
||||
exif::Tag::ExposureTime,
|
||||
exif::Tag::PhotographicSensitivity,
|
||||
exif::Tag::FocalLength,
|
||||
field(exif::Tag::FNumber),
|
||||
shutter,
|
||||
iso,
|
||||
field(exif::Tag::FocalLength),
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
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)
|
||||
&& 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));
|
||||
}
|
||||
|
||||
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
|
||||
/// 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> {
|
||||
@@ -605,7 +786,7 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
|
||||
/// 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: >k4::Window, source: &str, name: &str) {
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save a Copy")
|
||||
.title(gettext("Save a Copy"))
|
||||
.initial_name(name)
|
||||
.build();
|
||||
let source = source.to_string();
|
||||
@@ -625,10 +806,15 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &st
|
||||
|| dest.display().to_string(),
|
||||
|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()),
|
||||
Err(_) => toast_error(&ui, "Couldn't save the copy", "The copy was interrupted."),
|
||||
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
|
||||
Err(_) => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't save the copy"),
|
||||
&gettext("The copy was interrupted."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -731,7 +917,13 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
|
||||
if let Some(album) = ui.gallery.album.borrow().as_ref() {
|
||||
return Some(album.photo_count);
|
||||
}
|
||||
if ui.gallery.range.get().is_some() || ui.gallery.favorites.get() {
|
||||
if let Some(place) = ui.gallery.place.borrow().as_ref() {
|
||||
return Some(place.photo_count);
|
||||
}
|
||||
if ui.gallery.range.get().is_some()
|
||||
|| ui.gallery.favorites.get()
|
||||
|| ui.gallery.not_in_album.get()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (photos, videos, raw) = ui.gallery.counts.get()?;
|
||||
@@ -749,9 +941,21 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
|
||||
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);
|
||||
match total {
|
||||
_ if !more => format!("{} of {}", thousands(at), thousands(loaded)),
|
||||
Some(total) if total > loaded => format!("{} of {}", thousands(at), thousands(total)),
|
||||
_ => 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.
|
||||
_ if !more => gettext_f(
|
||||
"{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 +981,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
|
||||
|
||||
let window = gtk4::Window::builder()
|
||||
.title("Photo")
|
||||
.title(gettext("Photo"))
|
||||
.modal(true)
|
||||
.transient_for(&parent)
|
||||
.default_width(1100)
|
||||
@@ -824,7 +1028,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let prev_btn = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Previous (←)")
|
||||
.tooltip_text(gettext("Previous (←)"))
|
||||
.build();
|
||||
prev_btn.add_css_class("circular");
|
||||
prev_btn.add_css_class("flat");
|
||||
@@ -838,7 +1042,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let next_btn = gtk4::Button::builder()
|
||||
.icon_name("go-next-symbolic")
|
||||
.tooltip_text("Next (→)")
|
||||
.tooltip_text(gettext("Next (→)"))
|
||||
.build();
|
||||
next_btn.add_css_class("circular");
|
||||
next_btn.add_css_class("flat");
|
||||
@@ -876,7 +1080,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let info_toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("info-outline-symbolic")
|
||||
.tooltip_text("Details (i)")
|
||||
.tooltip_text(gettext("Details (i)"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
info_toggle.add_css_class("flat");
|
||||
@@ -884,21 +1088,30 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let favorite_btn = gtk4::ToggleButton::builder()
|
||||
.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)
|
||||
.build();
|
||||
favorite_btn.add_css_class("flat");
|
||||
favorite_btn.add_css_class("viewer-action-btn");
|
||||
|
||||
let slideshow_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("media-playback-start-symbolic")
|
||||
.tooltip_text(gettext("Slideshow (F5)"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
slideshow_btn.add_css_class("flat");
|
||||
slideshow_btn.add_css_class("viewer-action-btn");
|
||||
|
||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||
// 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);
|
||||
|
||||
let download_btn = action("document-save-symbolic", "Save a Copy…");
|
||||
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
|
||||
let open_ext_btn = action("document-open-symbolic", "Open With…");
|
||||
let close_btn = action("window-close-symbolic", "Close (Esc)");
|
||||
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
|
||||
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
|
||||
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
|
||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||
close_btn.add_css_class("viewer-close-btn");
|
||||
|
||||
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
@@ -907,6 +1120,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
top_bar.append(&group_btn);
|
||||
top_bar.append(&favorite_btn);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&slideshow_btn);
|
||||
top_bar.append(&download_btn);
|
||||
top_bar.append(&delete_btn);
|
||||
top_bar.append(&open_ext_btn);
|
||||
@@ -921,7 +1135,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let info_map = gtk4::Button::builder()
|
||||
.child(
|
||||
&adw::ButtonContent::builder()
|
||||
.label("Show on map")
|
||||
.label(gettext("Show on map"))
|
||||
.icon_name("map-symbolic")
|
||||
.build(),
|
||||
)
|
||||
@@ -931,7 +1145,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_map.set_visible(false);
|
||||
|
||||
let info_title = gtk4::Label::builder()
|
||||
.label("Details")
|
||||
.label(gettext("Details"))
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
@@ -939,7 +1153,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let info_close = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Hide details")
|
||||
.tooltip_text(gettext("Hide details"))
|
||||
.build();
|
||||
info_close.add_css_class("flat");
|
||||
info_close.add_css_class("circular");
|
||||
@@ -970,8 +1184,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_panel.set_width_request(320);
|
||||
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()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
||||
.hexpand(false)
|
||||
.child(&info_panel)
|
||||
.build();
|
||||
|
||||
@@ -1030,6 +1247,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
||||
chrome_hover: Cell::new(false),
|
||||
chrome_source: RefCell::new(None),
|
||||
slideshow: slideshow_btn.clone(),
|
||||
slide_source: RefCell::new(None),
|
||||
slide_fullscreened: Cell::new(false),
|
||||
});
|
||||
|
||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||
@@ -1143,6 +1363,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let viewer_closing = viewer.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
stop_video(&viewer_closing);
|
||||
disarm_slide(&viewer_closing);
|
||||
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
@@ -1156,6 +1377,16 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
.set_reveal_child(toggle.is_active());
|
||||
});
|
||||
|
||||
let ui_slides = ui.clone();
|
||||
let viewer_slides = viewer.clone();
|
||||
slideshow_btn.connect_toggled(move |toggle| {
|
||||
if toggle.is_active() {
|
||||
start_slideshow(&ui_slides, &viewer_slides);
|
||||
} else {
|
||||
stop_slideshow(&viewer_slides);
|
||||
}
|
||||
});
|
||||
|
||||
let toggle_off = info_toggle.clone();
|
||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
|
||||
@@ -1193,10 +1424,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
None
|
||||
}
|
||||
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 {
|
||||
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
|
||||
// what is actually stored — without firing this handler again.
|
||||
if *viewer_result.uid.borrow() == uid {
|
||||
@@ -1215,7 +1450,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let uid = viewer_delete.uid.borrow().clone();
|
||||
w_delete.close();
|
||||
trash_photos(&ui_delete, vec![uid]);
|
||||
trash_photos(&ui_delete, vec![uid], false);
|
||||
});
|
||||
|
||||
let ui_group = ui.clone();
|
||||
@@ -1321,6 +1556,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||
Some("F5") => viewer_key
|
||||
.slideshow
|
||||
.set_active(!viewer_key.slideshow.is_active()),
|
||||
// Escape ends a slideshow before it closes anything.
|
||||
Some("Escape") if viewer_key.slideshow.is_active() => {
|
||||
viewer_key.slideshow.set_active(false)
|
||||
}
|
||||
Some("i") => viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active()),
|
||||
@@ -1334,7 +1576,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
Some("Delete" | "KP_Delete") => {
|
||||
let uid = viewer_key.uid.borrow().clone();
|
||||
w_key.close();
|
||||
trash_photos(&ui_key, vec![uid]);
|
||||
trash_photos(&ui_key, vec![uid], false);
|
||||
}
|
||||
Some("Escape" | "q") => w_key.close(),
|
||||
Some("w") if ctrl => w_key.close(),
|
||||
@@ -1372,7 +1614,27 @@ pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn the_counter_counts_the_library_while_pages_are_left() {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! The Places view of the Photos page: a grid of the towns the photos were
|
||||
//! taken in, and the place that opens when one is clicked.
|
||||
//!
|
||||
//! An open place works like an open album: it is parked in
|
||||
//! [`GalleryState::place`], and [`load_gallery`] pages
|
||||
//! [`Request::PlacePhotos`] instead of the timeline.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Edge length in px of a place's cover, the same as an album's.
|
||||
const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// How often the listing is asked for again while photo locations are read.
|
||||
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Show the Places grid and (re)load it. Called when the Places toggle goes on.
|
||||
pub(crate) fn show_places(ui: &Rc<Ui>) {
|
||||
show_place_grid(ui);
|
||||
load_places(ui);
|
||||
}
|
||||
|
||||
/// Switch to the place grid as it was last filled, keeping its scroll
|
||||
/// position. The timeline's filters do not apply to places, so they go.
|
||||
pub(crate) fn show_place_grid(ui: &Rc<Ui>) {
|
||||
close_place(ui);
|
||||
close_album(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("places");
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&places_subtitle(ui.gallery.place_count.get()));
|
||||
}
|
||||
|
||||
/// Ask the daemon for the place listing and rebuild the grid.
|
||||
pub(crate) fn load_places(ui: &Rc<Ui>) {
|
||||
if ui.gallery.places_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.places_loading.set(true);
|
||||
// A grid already on screen stays there while it refreshes.
|
||||
if ui.gallery.places_stack.visible_child_name().as_deref() != Some("grid") {
|
||||
places_status(
|
||||
ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("Loading places…"),
|
||||
&gettext("Finding where your photos were taken."),
|
||||
);
|
||||
}
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoPlaces);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.gallery.places_loading.set(false);
|
||||
let mapping = match &result {
|
||||
Ok(Ok(Response::Places { mapping, .. })) => *mapping,
|
||||
_ => None,
|
||||
};
|
||||
sync_mapping(&ui, mapping);
|
||||
match result {
|
||||
Ok(Ok(Response::Places { items, .. })) if items.is_empty() && mapping.is_some() => {
|
||||
ui.gallery.place_count.set(0);
|
||||
places_status(
|
||||
&ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("Mapping your photos…"),
|
||||
&gettext("Places show up here once the locations of your photos are read."),
|
||||
)
|
||||
}
|
||||
Ok(Ok(Response::Places { items, .. })) if items.is_empty() => {
|
||||
ui.gallery.place_count.set(0);
|
||||
if ui.gallery.place.borrow().is_none() {
|
||||
ui.gallery.title.set_subtitle("");
|
||||
}
|
||||
places_status(
|
||||
&ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("No places"),
|
||||
&gettext(
|
||||
"Photos that carry the location they were taken at show up here, grouped by town.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Ok(Ok(Response::Places { items, .. })) => {
|
||||
fill_places(&ui, &items);
|
||||
ui.gallery.places_map.set_places(&items);
|
||||
show_places_layout(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't load places"), &message, kind);
|
||||
places_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't load places"),
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => places_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show how far the daemon has got reading photo locations, and ask again in a
|
||||
/// moment while it is still reading — the new places land once it is done.
|
||||
fn sync_mapping(ui: &Rc<Ui>, mapping: Option<MappingProgress>) {
|
||||
if let Some(source) = ui.gallery.places_poll.take() {
|
||||
source.remove();
|
||||
}
|
||||
ui.gallery.places_mapping.set_visible(mapping.is_some());
|
||||
let Some(MappingProgress { done, total }) = mapping else {
|
||||
return;
|
||||
};
|
||||
ui.gallery.places_mapping_label.set_label(&gettext_f(
|
||||
// Translators: {done} and {total} are numbers of photos.
|
||||
"Mapping photos: {done} of {total}",
|
||||
&[("done", &thousands(done)), ("total", &thousands(total))],
|
||||
));
|
||||
ui.gallery.places_mapping_bar.set_fraction(if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
done as f64 / total as f64
|
||||
});
|
||||
let ui_poll = ui.clone();
|
||||
let source = glib::timeout_add_local_once(MAPPING_POLL, move || {
|
||||
ui_poll.gallery.places_poll.take();
|
||||
let on_places = ui_poll.gallery.content.visible_child_name().as_deref() == Some("places");
|
||||
if on_places && ui_poll.gallery.place.borrow().is_none() {
|
||||
load_places(&ui_poll);
|
||||
}
|
||||
});
|
||||
ui.gallery.places_poll.replace(Some(source));
|
||||
}
|
||||
|
||||
/// Replace the grid's cards with `places`, the one with the most photos first.
|
||||
fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
|
||||
let scroll = ui
|
||||
.gallery
|
||||
.places
|
||||
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||
.and_downcast::<gtk4::ScrolledWindow>();
|
||||
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||
while let Some(child) = ui.gallery.places.first_child() {
|
||||
ui.gallery.places.remove(&child);
|
||||
}
|
||||
ui.gallery.place_count.set(places.len());
|
||||
if ui.gallery.place.borrow().is_none() {
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&places_subtitle(places.len()));
|
||||
}
|
||||
for place in places {
|
||||
ui.gallery.places.append(&place_card(ui, place));
|
||||
}
|
||||
schedule_thumbs(ui);
|
||||
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn places_subtitle(count: usize) -> String {
|
||||
ngettext_f("{n} place", "{n} places", count as u64, &[])
|
||||
}
|
||||
|
||||
/// "Germany · 12 photos": where the town is, and how much was taken there.
|
||||
pub(crate) fn place_subtitle(place: &PlaceInfo) -> String {
|
||||
ngettext_f(
|
||||
// Translators: {country} is a country name, {n} a number of photos.
|
||||
"{country} · {n} photo",
|
||||
"{country} · {n} photos",
|
||||
place.photo_count as u64,
|
||||
&[("country", &country_name(&place.country))],
|
||||
)
|
||||
}
|
||||
|
||||
/// One place as a clickable card: its newest photo, the town, and the country
|
||||
/// with the number of photos taken there.
|
||||
fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("mark-location-symbolic")
|
||||
.pixel_size(32)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
let cover = gtk4::Overlay::new();
|
||||
cover.set_child(Some(&placeholder));
|
||||
cover.add_overlay(&picture);
|
||||
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||
cover.add_css_class("album-cover");
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.label(&place.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
name.add_css_class("heading");
|
||||
|
||||
let subtitle = place_subtitle(place);
|
||||
let count = gtk4::Label::builder()
|
||||
.label(&subtitle)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
count.add_css_class("dim-label");
|
||||
count.add_css_class("caption");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
card.append(&cover);
|
||||
card.append(&name);
|
||||
card.append(&count);
|
||||
|
||||
let button = gtk4::Button::builder().child(&card).build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("album-card");
|
||||
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
|
||||
|
||||
want_cover(ui, place.cover.uid.clone(), &picture);
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let place = place.clone();
|
||||
button.connect_clicked(move |_| open_place(&ui_open, place.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// Open one place in the gallery: the timeline view, paged from that town.
|
||||
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||
ui.gallery.title.set_title(&place.name);
|
||||
ui.gallery.title.set_subtitle(&place_subtitle(&place));
|
||||
*ui.gallery.place.borrow_mut() = Some(place);
|
||||
ui.gallery.timeline_stale.set(true);
|
||||
|
||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
ui.gallery.kind.set(None);
|
||||
ui.gallery.range.set(None);
|
||||
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
|
||||
/// Leave an open place, restoring the timeline's header. A no-op when no place
|
||||
/// is open.
|
||||
pub(crate) fn close_place(ui: &Rc<Ui>) {
|
||||
if ui.gallery.place.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
|
||||
/// Show the place grid's own status page (loading, empty, or an error).
|
||||
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
ui.gallery.places_status.set_icon_name(Some(icon));
|
||||
ui.gallery.places_status.set_title(title);
|
||||
ui.gallery.places_status.set_description(Some(description));
|
||||
ui.gallery.places_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Wire the Places toggle of the view switcher.
|
||||
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
let ui_places = ui.clone();
|
||||
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
show_places(&ui_places);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_layout = ui.clone();
|
||||
ui.gallery.places_map_btn.connect_toggled(move |_| {
|
||||
// A status page (loading, empty, an error) stays until a listing
|
||||
// replaces it; only the grid and the map swap.
|
||||
if ui_layout
|
||||
.gallery
|
||||
.places_stack
|
||||
.visible_child_name()
|
||||
.as_deref()
|
||||
!= Some("status")
|
||||
{
|
||||
show_places_layout(&ui_layout);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_open = ui.clone();
|
||||
ui.gallery
|
||||
.places_map
|
||||
.connect_open(move |place| open_place(&ui_open, place));
|
||||
}
|
||||
|
||||
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
||||
fn show_places_layout(ui: &Rc<Ui>) {
|
||||
let layout = if ui.gallery.places_map_btn.is_active() {
|
||||
"map"
|
||||
} else {
|
||||
"grid"
|
||||
};
|
||||
ui.gallery.places_stack.set_visible_child_name(layout);
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! The map of the Places view: the towns the photos were taken in, drawn as
|
||||
//! bubbles over an outline of the world's land.
|
||||
//!
|
||||
//! The map is drawn here rather than loaded from a tile server: no tile source
|
||||
//! with terms that allow an app like this one to use it is free, and a town
|
||||
//! bubble needs no more detail than coastlines. The land is Natural Earth's
|
||||
//! 1:50m land polygons (public domain), trimmed into `data/land.txt` by
|
||||
//! `scripts/natural-earth-land.py`.
|
||||
//!
|
||||
//! The projection is Web Mercator, the one every other map uses, so the
|
||||
//! world looks the way people expect. Towns that would overlap on screen merge
|
||||
//! into one bubble counting all their photos; clicking it zooms in until they
|
||||
//! part, clicking a single town opens it.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const LAND: &str = include_str!("../../../data/land.txt");
|
||||
|
||||
/// Latitude past which Mercator is cut off, as on every web map.
|
||||
const MAX_LATITUDE: f64 = 85.0;
|
||||
|
||||
/// How far in the map zooms, as a multiple of the whole world fitting the
|
||||
/// widget's width.
|
||||
const MAX_ZOOM: f64 = 2048.0;
|
||||
|
||||
/// How close "show all" zooms onto a single town, the same way.
|
||||
const FIT_MAX_ZOOM: f64 = 64.0;
|
||||
|
||||
/// The zoom step of one scroll notch and of the zoom buttons.
|
||||
const ZOOM_STEP: f64 = 1.25;
|
||||
const BUTTON_ZOOM_STEP: f64 = 2.0;
|
||||
|
||||
/// A press that moves less than this many px is a click, not a drag.
|
||||
const CLICK_SLOP: f64 = 4.0;
|
||||
|
||||
/// Coastline points closer than this many px to the last one drawn are
|
||||
/// skipped: at world zoom most of the outline would fall onto one pixel.
|
||||
const MIN_SEGMENT: f64 = 0.75;
|
||||
|
||||
/// A point in projected world space: `x` and `y` both run 0..1, from the
|
||||
/// antimeridian eastwards and from the north edge southwards.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct World {
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
fn project(latitude: f64, longitude: f64) -> World {
|
||||
let lat = latitude.clamp(-MAX_LATITUDE, MAX_LATITUDE).to_radians();
|
||||
World {
|
||||
x: (longitude + 180.0) / 360.0,
|
||||
y: (1.0 - (lat.tan() + 1.0 / lat.cos()).ln() / std::f64::consts::PI) / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// One land outline, projected, with its bounding box for culling.
|
||||
struct Ring {
|
||||
points: Vec<World>,
|
||||
min: World,
|
||||
max: World,
|
||||
}
|
||||
|
||||
fn land() -> &'static [Ring] {
|
||||
static LAND_RINGS: OnceLock<Vec<Ring>> = OnceLock::new();
|
||||
LAND_RINGS.get_or_init(|| LAND.lines().filter_map(parse_ring).collect())
|
||||
}
|
||||
|
||||
fn parse_ring(line: &str) -> Option<Ring> {
|
||||
let points: Vec<World> = line
|
||||
.split(' ')
|
||||
.filter_map(|pair| {
|
||||
let (lon, lat) = pair.split_once(',')?;
|
||||
Some(project(lat.parse().ok()?, lon.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let first = *points.first()?;
|
||||
let (min, max) = points.iter().fold((first, first), |(min, max), p| {
|
||||
(
|
||||
World {
|
||||
x: min.x.min(p.x),
|
||||
y: min.y.min(p.y),
|
||||
},
|
||||
World {
|
||||
x: max.x.max(p.x),
|
||||
y: max.y.max(p.y),
|
||||
},
|
||||
)
|
||||
});
|
||||
Some(Ring { points, min, max })
|
||||
}
|
||||
|
||||
/// Radius in px of a bubble holding `photos` photos: grows with the count,
|
||||
/// but slowly, so a home town with thousands doesn't cover a continent.
|
||||
fn bubble_radius(photos: usize) -> f64 {
|
||||
7.0 + 3.0 * (photos.max(1) as f64).ln()
|
||||
}
|
||||
|
||||
/// What the map shows: which part of the world, at what size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct View {
|
||||
/// The world point at the middle of the widget.
|
||||
center: World,
|
||||
/// px per world unit: the whole world is this wide on screen.
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
/// One bubble as last drawn: where, how big, and the places it stands for
|
||||
/// (indices into [`MapState::places`], the one with the most photos first).
|
||||
struct Bubble {
|
||||
x: f64,
|
||||
y: f64,
|
||||
radius: f64,
|
||||
photos: usize,
|
||||
members: Vec<usize>,
|
||||
}
|
||||
|
||||
struct MapState {
|
||||
places: Vec<(PlaceInfo, World)>,
|
||||
view: Cell<View>,
|
||||
/// False until the view has been fitted to the places at a real size, and
|
||||
/// again whenever new places arrive before the user moved the map.
|
||||
fitted: Cell<bool>,
|
||||
moved: Cell<bool>,
|
||||
bubbles: RefCell<Vec<Bubble>>,
|
||||
pointer: Cell<(f64, f64)>,
|
||||
/// The view a drag started from.
|
||||
drag_origin: Cell<Option<View>>,
|
||||
/// The scale a pinch started from.
|
||||
pinch_origin: Cell<Option<View>>,
|
||||
}
|
||||
|
||||
/// What clicking a town on the map does.
|
||||
type OpenPlace = Box<dyn Fn(PlaceInfo)>;
|
||||
|
||||
/// The Places map widget.
|
||||
pub(crate) struct PlacesMap {
|
||||
root: gtk4::Overlay,
|
||||
area: gtk4::DrawingArea,
|
||||
state: RefCell<MapState>,
|
||||
on_open: RefCell<Option<OpenPlace>>,
|
||||
}
|
||||
|
||||
impl PlacesMap {
|
||||
pub(crate) fn new() -> Rc<Self> {
|
||||
let area = gtk4::DrawingArea::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.has_tooltip(true)
|
||||
.build();
|
||||
area.add_css_class("places-map");
|
||||
|
||||
let zoom_in = map_button("zoom-in-symbolic", &gettext("Zoom In"));
|
||||
let zoom_out = map_button("zoom-out-symbolic", &gettext("Zoom Out"));
|
||||
let fit = map_button("zoom-fit-best-symbolic", &gettext("Show All Places"));
|
||||
let zoom = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
zoom.add_css_class("linked");
|
||||
zoom.append(&zoom_in);
|
||||
zoom.append(&zoom_out);
|
||||
let buttons = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
buttons.set_halign(gtk4::Align::End);
|
||||
buttons.set_valign(gtk4::Align::Start);
|
||||
buttons.set_margin_top(12);
|
||||
buttons.set_margin_end(12);
|
||||
buttons.append(&zoom);
|
||||
buttons.append(&fit);
|
||||
|
||||
let root = gtk4::Overlay::new();
|
||||
root.set_child(Some(&area));
|
||||
root.add_overlay(&buttons);
|
||||
root.set_overflow(gtk4::Overflow::Hidden);
|
||||
root.add_css_class("places-map-frame");
|
||||
|
||||
let map = Rc::new(Self {
|
||||
root,
|
||||
area,
|
||||
state: RefCell::new(MapState {
|
||||
places: Vec::new(),
|
||||
view: Cell::new(View {
|
||||
center: World { x: 0.5, y: 0.5 },
|
||||
scale: 1.0,
|
||||
}),
|
||||
fitted: Cell::new(false),
|
||||
moved: Cell::new(false),
|
||||
bubbles: RefCell::new(Vec::new()),
|
||||
pointer: Cell::new((0.0, 0.0)),
|
||||
drag_origin: Cell::new(None),
|
||||
pinch_origin: Cell::new(None),
|
||||
}),
|
||||
on_open: RefCell::new(None),
|
||||
});
|
||||
|
||||
let weak = Rc::downgrade(&map);
|
||||
map.area.set_draw_func(move |area, cr, width, height| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.draw(area, cr, f64::from(width), f64::from(height));
|
||||
}
|
||||
});
|
||||
map.wire(&zoom_in, &zoom_out, &fit);
|
||||
map
|
||||
}
|
||||
|
||||
pub(crate) fn widget(&self) -> >k4::Overlay {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// What clicking a town does.
|
||||
pub(crate) fn connect_open(&self, f: impl Fn(PlaceInfo) + 'static) {
|
||||
self.on_open.replace(Some(Box::new(f)));
|
||||
}
|
||||
|
||||
/// Show `places`. The view is fitted to them unless the user has already
|
||||
/// moved the map, so a refresh while they look around leaves it be.
|
||||
pub(crate) fn set_places(&self, places: &[PlaceInfo]) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.places = places
|
||||
.iter()
|
||||
.filter(|p| p.latitude != 0.0 || p.longitude != 0.0)
|
||||
.map(|p| (p.clone(), project(p.latitude, p.longitude)))
|
||||
.collect();
|
||||
if !state.moved.get() {
|
||||
state.fitted.set(false);
|
||||
}
|
||||
drop(state);
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
fn wire(self: &Rc<Self>, zoom_in: >k4::Button, zoom_out: >k4::Button, fit: >k4::Button) {
|
||||
let weak = Rc::downgrade(self);
|
||||
zoom_in.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.zoom_at_middle(BUTTON_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
zoom_out.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.zoom_at_middle(1.0 / BUTTON_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
fit.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.moved.set(false);
|
||||
state.fitted.set(false);
|
||||
drop(state);
|
||||
map.area.queue_draw();
|
||||
}
|
||||
});
|
||||
|
||||
let motion = gtk4::EventControllerMotion::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.state.borrow().pointer.set((x, y));
|
||||
let over = map.bubble_at(x, y).is_some();
|
||||
map.area
|
||||
.set_cursor_from_name(Some(if over { "pointer" } else { "default" }));
|
||||
}
|
||||
});
|
||||
self.area.add_controller(motion);
|
||||
|
||||
let scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||
let weak = Rc::downgrade(self);
|
||||
scroll.connect_scroll(move |_, _, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let (x, y) = map.state.borrow().pointer.get();
|
||||
map.zoom_at(x, y, ZOOM_STEP.powf(-dy));
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
self.area.add_controller(scroll);
|
||||
|
||||
// One drag gesture for both panning and clicking: a press that barely
|
||||
// moves is a click on whatever bubble it started on.
|
||||
let drag = gtk4::GestureDrag::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_begin(move |_, _, _| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.drag_origin.set(Some(state.view.get()));
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_update(move |_, dx, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if dx.hypot(dy) < CLICK_SLOP {
|
||||
return;
|
||||
}
|
||||
let state = map.state.borrow();
|
||||
let Some(origin) = state.drag_origin.get() else {
|
||||
return;
|
||||
};
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: World {
|
||||
x: origin.center.x - dx / origin.scale,
|
||||
y: origin.center.y - dy / origin.scale,
|
||||
},
|
||||
..origin
|
||||
});
|
||||
drop(state);
|
||||
map.clamp_view();
|
||||
map.area.queue_draw();
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_end(move |gesture, dx, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
map.state.borrow().drag_origin.set(None);
|
||||
if dx.hypot(dy) >= CLICK_SLOP {
|
||||
return;
|
||||
}
|
||||
if let Some((x, y)) = gesture.start_point() {
|
||||
map.click(x, y);
|
||||
}
|
||||
});
|
||||
self.area.add_controller(drag);
|
||||
|
||||
let pinch = gtk4::GestureZoom::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
pinch.connect_begin(move |_, _| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.pinch_origin.set(Some(state.view.get()));
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
pinch.connect_scale_changed(move |gesture, scale| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(origin) = map.state.borrow().pinch_origin.get() else {
|
||||
return;
|
||||
};
|
||||
let (x, y) = gesture
|
||||
.bounding_box_center()
|
||||
.unwrap_or_else(|| map.middle());
|
||||
let now = map.state.borrow().view.get().scale;
|
||||
map.zoom_at(x, y, origin.scale * scale / now);
|
||||
});
|
||||
self.area.add_controller(pinch);
|
||||
|
||||
let weak = Rc::downgrade(self);
|
||||
self.area
|
||||
.connect_query_tooltip(move |_, x, y, _keyboard, tooltip| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
let Some(text) = map.tooltip_at(f64::from(x), f64::from(y)) else {
|
||||
return false;
|
||||
};
|
||||
tooltip.set_text(Some(&text));
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn middle(&self) -> (f64, f64) {
|
||||
(
|
||||
f64::from(self.area.width()) / 2.0,
|
||||
f64::from(self.area.height()) / 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The whole world's width at the least zoom: the widget's own width.
|
||||
fn min_scale(&self) -> f64 {
|
||||
f64::from(self.area.width().max(self.area.height()).max(1))
|
||||
}
|
||||
|
||||
fn zoom_at_middle(&self, factor: f64) {
|
||||
let (x, y) = self.middle();
|
||||
self.zoom_at(x, y, factor);
|
||||
}
|
||||
|
||||
/// Zoom by `factor`, keeping the world point under `(x, y)` where it is.
|
||||
fn zoom_at(&self, x: f64, y: f64, factor: f64) {
|
||||
let min = self.min_scale();
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
{
|
||||
let state = self.state.borrow();
|
||||
let view = state.view.get();
|
||||
let scale = (view.scale * factor).clamp(min, min * MAX_ZOOM);
|
||||
let under = World {
|
||||
x: view.center.x + (x - width / 2.0) / view.scale,
|
||||
y: view.center.y + (y - height / 2.0) / view.scale,
|
||||
};
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: World {
|
||||
x: under.x - (x - width / 2.0) / scale,
|
||||
y: under.y - (y - height / 2.0) / scale,
|
||||
},
|
||||
scale,
|
||||
});
|
||||
}
|
||||
self.clamp_view();
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
/// Keep the world on screen: the map can't be dragged off into nothing.
|
||||
fn clamp_view(&self) {
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
let state = self.state.borrow();
|
||||
let mut view = state.view.get();
|
||||
view.center.x = clamp_axis(view.center.x, width / 2.0 / view.scale);
|
||||
view.center.y = clamp_axis(view.center.y, height / 2.0 / view.scale);
|
||||
state.view.set(view);
|
||||
}
|
||||
|
||||
/// Frame every place, or the whole world when there are none.
|
||||
fn fit(&self, width: f64, height: f64) {
|
||||
let min = self.min_scale();
|
||||
let state = self.state.borrow();
|
||||
let view = match fit_view(&state.places, width, height) {
|
||||
Some(view) => View {
|
||||
scale: view.scale.clamp(min, min * FIT_MAX_ZOOM),
|
||||
..view
|
||||
},
|
||||
None => View {
|
||||
center: World { x: 0.5, y: 0.5 },
|
||||
scale: min,
|
||||
},
|
||||
};
|
||||
state.view.set(view);
|
||||
state.fitted.set(true);
|
||||
drop(state);
|
||||
self.clamp_view();
|
||||
}
|
||||
|
||||
fn draw(&self, area: >k4::DrawingArea, cr: >k4::cairo::Context, width: f64, height: f64) {
|
||||
if width < 1.0 || height < 1.0 {
|
||||
return;
|
||||
}
|
||||
if !self.state.borrow().fitted.get() {
|
||||
self.fit(width, height);
|
||||
}
|
||||
let state = self.state.borrow();
|
||||
// Land in the text colour at low alpha, so it reads in light and dark
|
||||
// alike; bubbles in the accent colour the stylesheet gives the area.
|
||||
let text = area.parent().map_or_else(|| area.color(), |p| p.color());
|
||||
let accent = area.color();
|
||||
let bubbles = paint(
|
||||
cr,
|
||||
state.view.get(),
|
||||
(width, height),
|
||||
&state.places,
|
||||
(&text, &accent),
|
||||
);
|
||||
state.bubbles.replace(bubbles);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the land and the bubbles for `places` as seen through `view`, and
|
||||
/// hand back the bubbles for hit-testing.
|
||||
fn paint(
|
||||
cr: >k4::cairo::Context,
|
||||
view: View,
|
||||
(width, height): (f64, f64),
|
||||
places: &[(PlaceInfo, World)],
|
||||
(text, accent): (>k4::gdk::RGBA, >k4::gdk::RGBA),
|
||||
) -> Vec<Bubble> {
|
||||
let to_screen = |p: World| {
|
||||
(
|
||||
(p.x - view.center.x) * view.scale + width / 2.0,
|
||||
(p.y - view.center.y) * view.scale + height / 2.0,
|
||||
)
|
||||
};
|
||||
let min = World {
|
||||
x: view.center.x - width / 2.0 / view.scale,
|
||||
y: view.center.y - height / 2.0 / view.scale,
|
||||
};
|
||||
let max = World {
|
||||
x: view.center.x + width / 2.0 / view.scale,
|
||||
y: view.center.y + height / 2.0 / view.scale,
|
||||
};
|
||||
for ring in land() {
|
||||
if ring.max.x < min.x || ring.min.x > max.x || ring.max.y < min.y || ring.min.y > max.y {
|
||||
continue;
|
||||
}
|
||||
let mut last: Option<(f64, f64)> = None;
|
||||
for &point in &ring.points {
|
||||
let (x, y) = to_screen(point);
|
||||
match last {
|
||||
None => cr.move_to(x, y),
|
||||
Some((lx, ly)) if (x - lx).hypot(y - ly) < MIN_SEGMENT => continue,
|
||||
Some(_) => cr.line_to(x, y),
|
||||
}
|
||||
last = Some((x, y));
|
||||
}
|
||||
cr.close_path();
|
||||
}
|
||||
set_color(cr, text, 0.12);
|
||||
let _ = cr.fill_preserve();
|
||||
set_color(cr, text, 0.25);
|
||||
cr.set_line_width(0.75);
|
||||
let _ = cr.stroke();
|
||||
|
||||
let screen: Vec<(f64, f64)> = places.iter().map(|(_, p)| to_screen(*p)).collect();
|
||||
let counts: Vec<usize> = places.iter().map(|(p, _)| p.photo_count).collect();
|
||||
let bubbles = cluster(&screen, &counts, width, height);
|
||||
|
||||
// Cairo's own text is enough for a few digits, and needs no layout.
|
||||
cr.select_font_face(
|
||||
"Sans",
|
||||
gtk4::cairo::FontSlant::Normal,
|
||||
gtk4::cairo::FontWeight::Bold,
|
||||
);
|
||||
for bubble in &bubbles {
|
||||
// The last label left a current point; an arc would line up from it.
|
||||
cr.new_path();
|
||||
cr.arc(
|
||||
bubble.x,
|
||||
bubble.y,
|
||||
bubble.radius,
|
||||
0.0,
|
||||
std::f64::consts::TAU,
|
||||
);
|
||||
set_color(cr, accent, 0.85);
|
||||
let _ = cr.fill_preserve();
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
|
||||
cr.set_line_width(1.5);
|
||||
let _ = cr.stroke();
|
||||
|
||||
let label = compact_count(bubble.photos);
|
||||
cr.set_font_size(bubble.radius.clamp(7.0, 14.0));
|
||||
let Ok(extents) = cr.text_extents(&label) else {
|
||||
continue;
|
||||
};
|
||||
if extents.width() > bubble.radius * 1.8 {
|
||||
continue;
|
||||
}
|
||||
cr.move_to(
|
||||
bubble.x - extents.width() / 2.0 - extents.x_bearing(),
|
||||
bubble.y - extents.height() / 2.0 - extents.y_bearing(),
|
||||
);
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
|
||||
let _ = cr.show_text(&label);
|
||||
}
|
||||
bubbles
|
||||
}
|
||||
|
||||
impl PlacesMap {
|
||||
/// The bubble under `(x, y)`, the topmost (last drawn) first.
|
||||
fn bubble_at(&self, x: f64, y: f64) -> Option<Vec<usize>> {
|
||||
let state = self.state.borrow();
|
||||
let bubbles = state.bubbles.borrow();
|
||||
bubbles
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| (b.x - x).hypot(b.y - y) <= b.radius)
|
||||
.map(|b| b.members.clone())
|
||||
}
|
||||
|
||||
fn click(&self, x: f64, y: f64) {
|
||||
let Some(members) = self.bubble_at(x, y) else {
|
||||
return;
|
||||
};
|
||||
if let [only] = members[..] {
|
||||
let place = self.state.borrow().places[only].0.clone();
|
||||
if let Some(open) = self.on_open.borrow().as_ref() {
|
||||
open(place);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A merged bubble zooms in on its towns until they part.
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
let target = {
|
||||
let state = self.state.borrow();
|
||||
let chosen: Vec<(PlaceInfo, World)> =
|
||||
members.iter().map(|&i| state.places[i].clone()).collect();
|
||||
fit_view(&chosen, width, height)
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return;
|
||||
};
|
||||
let min = self.min_scale();
|
||||
{
|
||||
let state = self.state.borrow();
|
||||
let view = state.view.get();
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: target.center,
|
||||
// At least one real step in, even when the towns sit on top of
|
||||
// each other at every zoom the fit would pick.
|
||||
scale: target
|
||||
.scale
|
||||
.max(view.scale * BUTTON_ZOOM_STEP)
|
||||
.clamp(min, min * MAX_ZOOM),
|
||||
});
|
||||
}
|
||||
self.clamp_view();
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
/// "Berlin" over "Germany · 12 photos" for a town, or the number of places
|
||||
/// over the first few of their names for a merged bubble.
|
||||
fn tooltip_at(&self, x: f64, y: f64) -> Option<String> {
|
||||
let members = self.bubble_at(x, y)?;
|
||||
let state = self.state.borrow();
|
||||
if let [only] = members[..] {
|
||||
let place = &state.places[only].0;
|
||||
return Some(format!("{}\n{}", place.name, place_subtitle(place)));
|
||||
}
|
||||
let mut lines = vec![places_subtitle(members.len())];
|
||||
lines.extend(
|
||||
members
|
||||
.iter()
|
||||
.take(TOOLTIP_NAMES)
|
||||
.map(|&i| state.places[i].0.name.clone()),
|
||||
);
|
||||
if members.len() > TOOLTIP_NAMES {
|
||||
lines.push("…".to_owned());
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many town names a merged bubble's tooltip lists.
|
||||
const TOOLTIP_NAMES: usize = 5;
|
||||
|
||||
fn map_button(icon: &str, tooltip: &str) -> gtk4::Button {
|
||||
let button = gtk4::Button::from_icon_name(icon);
|
||||
button.set_tooltip_text(Some(tooltip));
|
||||
button.add_css_class("osd");
|
||||
button
|
||||
}
|
||||
|
||||
/// Keep `center` so that half a view, `half`, either side stays inside 0..1 —
|
||||
/// or centred, when the view is wider than the world.
|
||||
fn clamp_axis(center: f64, half: f64) -> f64 {
|
||||
if half * 2.0 >= 1.0 {
|
||||
0.5
|
||||
} else {
|
||||
center.clamp(half, 1.0 - half)
|
||||
}
|
||||
}
|
||||
|
||||
/// The view framing `places` in a `width` × `height` widget, with room for
|
||||
/// the bubbles at the edges. `None` when there is nothing to frame.
|
||||
fn fit_view(places: &[(PlaceInfo, World)], width: f64, height: f64) -> Option<View> {
|
||||
let first = places.first()?.1;
|
||||
let (min, max) = places.iter().fold((first, first), |(min, max), (_, p)| {
|
||||
(
|
||||
World {
|
||||
x: min.x.min(p.x),
|
||||
y: min.y.min(p.y),
|
||||
},
|
||||
World {
|
||||
x: max.x.max(p.x),
|
||||
y: max.y.max(p.y),
|
||||
},
|
||||
)
|
||||
});
|
||||
// A margin of the largest bubble on every side, plus the zoom buttons'
|
||||
// column on the right.
|
||||
let margin = 2.0 * bubble_radius(places.iter().map(|(p, _)| p.photo_count).max()?) + 48.0;
|
||||
let span_x = (max.x - min.x).max(1e-9);
|
||||
let span_y = (max.y - min.y).max(1e-9);
|
||||
let scale =
|
||||
((width - 2.0 * margin).max(1.0) / span_x).min((height - 2.0 * margin).max(1.0) / span_y);
|
||||
Some(View {
|
||||
center: World {
|
||||
x: (min.x + max.x) / 2.0,
|
||||
y: (min.y + max.y) / 2.0,
|
||||
},
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge places whose bubbles would overlap on screen, the one with the most
|
||||
/// photos claiming its neighbours. Places off screen are left out.
|
||||
fn cluster(screen: &[(f64, f64)], counts: &[usize], width: f64, height: f64) -> Vec<Bubble> {
|
||||
let mut order: Vec<usize> = (0..screen.len()).collect();
|
||||
order.sort_by(|a, b| counts[*b].cmp(&counts[*a]));
|
||||
let mut bubbles: Vec<Bubble> = Vec::new();
|
||||
for index in order {
|
||||
let (x, y) = screen[index];
|
||||
let reach = bubble_radius(counts[index]);
|
||||
if x < -reach || y < -reach || x > width + reach || y > height + reach {
|
||||
continue;
|
||||
}
|
||||
match bubbles
|
||||
.iter_mut()
|
||||
.find(|b| (b.x - x).hypot(b.y - y) < b.radius + reach * 0.5)
|
||||
{
|
||||
Some(bubble) => {
|
||||
bubble.photos += counts[index];
|
||||
bubble.radius = bubble_radius(bubble.photos);
|
||||
bubble.members.push(index);
|
||||
}
|
||||
None => bubbles.push(Bubble {
|
||||
x,
|
||||
y,
|
||||
radius: reach,
|
||||
photos: counts[index],
|
||||
members: vec![index],
|
||||
}),
|
||||
}
|
||||
}
|
||||
// Smallest last, so a small bubble next to a big one stays clickable.
|
||||
bubbles.sort_by(|a, b| b.radius.total_cmp(&a.radius));
|
||||
bubbles
|
||||
}
|
||||
|
||||
/// "7", "840", "1.2k", "35k": short enough to fit inside a bubble. Digits
|
||||
/// and the "k" read the same in every language the app ships.
|
||||
fn compact_count(n: usize) -> String {
|
||||
match n {
|
||||
0..1_000 => n.to_string(),
|
||||
1_000..10_000 => format!("{:.1}k", n as f64 / 1_000.0),
|
||||
_ => format!("{}k", n / 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_color(cr: >k4::cairo::Context, color: >k4::gdk::RGBA, alpha: f32) {
|
||||
cr.set_source_rgba(
|
||||
f64::from(color.red()),
|
||||
f64::from(color.green()),
|
||||
f64::from(color.blue()),
|
||||
f64::from(color.alpha() * alpha),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn projection_puts_the_equator_and_meridian_in_the_middle() {
|
||||
let p = project(0.0, 0.0);
|
||||
assert!((p.x - 0.5).abs() < 1e-9 && (p.y - 0.5).abs() < 1e-9);
|
||||
// North is up, east is right.
|
||||
let berlin = project(52.52, 13.40);
|
||||
assert!(berlin.x > 0.5 && berlin.y < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_land_ring_parses() {
|
||||
assert_eq!(land().len(), LAND.lines().count());
|
||||
assert!(land().iter().all(|ring| ring.points.len() >= 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_towns_merge_into_one_bubble() {
|
||||
let screen = [(100.0, 100.0), (104.0, 102.0), (400.0, 300.0)];
|
||||
let counts = [3, 10, 1];
|
||||
let bubbles = cluster(&screen, &counts, 800.0, 600.0);
|
||||
assert_eq!(bubbles.len(), 2);
|
||||
let merged = bubbles.iter().find(|b| b.members.len() == 2).unwrap();
|
||||
// The town with more photos claims the other and keeps its position.
|
||||
assert_eq!(merged.members, vec![1, 0]);
|
||||
assert_eq!((merged.x, merged.photos), (104.0, 13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn towns_off_screen_get_no_bubble() {
|
||||
let bubbles = cluster(&[(-500.0, 10.0)], &[1], 800.0, 600.0);
|
||||
assert!(bubbles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_shorten_to_fit_a_bubble() {
|
||||
assert_eq!(compact_count(840), "840");
|
||||
assert_eq!(compact_count(1_240), "1.2k");
|
||||
assert_eq!(compact_count(35_900), "35k");
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,9 @@ pub(crate) struct SharedWidgets {
|
||||
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
let add_bookmark = gtk4::Button::builder()
|
||||
.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();
|
||||
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
|
||||
// page waiting for a decision.
|
||||
let invitations = adw::PreferencesGroup::builder()
|
||||
.title("Invitations")
|
||||
.description("Accept to add the item to your shared files.")
|
||||
.title(gettext("Invitations"))
|
||||
.description(gettext("Accept to add the item to your shared files."))
|
||||
.visible(false)
|
||||
.build();
|
||||
let shared_with_me = adw::PreferencesGroup::new();
|
||||
let bookmarks = adw::PreferencesGroup::builder()
|
||||
.title("Bookmarks")
|
||||
.title(gettext("Bookmarks"))
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
@@ -78,7 +80,7 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -106,10 +108,10 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
|
||||
let back = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back")
|
||||
.tooltip_text(gettext("Back"))
|
||||
.visible(false)
|
||||
.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_end(&refresh);
|
||||
header.pack_end(&add_bookmark);
|
||||
@@ -176,8 +178,8 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
shared_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Loading…",
|
||||
"Reading your shared items.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your shared items."),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -229,8 +231,8 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
shared_status(
|
||||
ui,
|
||||
"folder-symbolic",
|
||||
"Loading…",
|
||||
"Reading this shared folder.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this shared folder."),
|
||||
false,
|
||||
);
|
||||
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
|
||||
// level rather than stranding the page on a dead uid.
|
||||
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);
|
||||
}
|
||||
_ => {
|
||||
@@ -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.
|
||||
pub(crate) fn invitations_title(count: usize) -> String {
|
||||
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 {
|
||||
"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();
|
||||
|
||||
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);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} 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");
|
||||
warning.add_css_class("warning");
|
||||
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_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
|
||||
/// interned it, and leave it (share roots only).
|
||||
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) = (
|
||||
ui.clone(),
|
||||
entry.uid.clone(),
|
||||
@@ -378,7 +381,7 @@ fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::Men
|
||||
entry.is_dir,
|
||||
);
|
||||
items.push((
|
||||
"Open",
|
||||
pgettext("verb", "Open"),
|
||||
Box::new(move || {
|
||||
if is_dir {
|
||||
ui_open
|
||||
@@ -395,25 +398,27 @@ fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::Men
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_show, entry_show) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
"Show in My Files",
|
||||
gettext("Show in My Files"),
|
||||
Box::new(move || show_in_my_files(&ui_show, &entry_show)),
|
||||
));
|
||||
}
|
||||
if leavable {
|
||||
let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone());
|
||||
items.push((
|
||||
"Leave…",
|
||||
gettext("Leave…"),
|
||||
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
|
||||
}
|
||||
|
||||
/// 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. \
|
||||
It may not really be from them.";
|
||||
const UNVERIFIED_INVITER_TOOLTIP: &str = gettext_noop(
|
||||
"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
|
||||
/// 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
|
||||
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an
|
||||
/// 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 {
|
||||
"viewer" => Some("Viewer"),
|
||||
"editor" => Some("Editor"),
|
||||
"admin" => Some("Admin"),
|
||||
"viewer" => Some(pgettext("role", "Viewer")),
|
||||
"editor" => Some(pgettext("role", "Editor")),
|
||||
"admin" => Some(pgettext("role", "Admin")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn role_access(role: &str) -> Option<&'static str> {
|
||||
pub(crate) fn role_access(role: &str) -> Option<String> {
|
||||
match role {
|
||||
"viewer" => Some("Can view"),
|
||||
"editor" => Some("Can edit"),
|
||||
"admin" => Some("Can manage"),
|
||||
"viewer" => Some(gettext("Can view")),
|
||||
"editor" => Some(gettext("Can edit")),
|
||||
"admin" => Some(gettext("Can manage")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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("caption");
|
||||
pill.set_valign(gtk4::Align::Center);
|
||||
pill.set_tooltip_text(role_label(role));
|
||||
pill.set_tooltip_text(role_label(role).as_deref());
|
||||
Some(pill)
|
||||
}
|
||||
|
||||
@@ -477,7 +482,8 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
return;
|
||||
}
|
||||
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(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenSharedFile {
|
||||
@@ -496,12 +502,12 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
// the shared node's name instead.
|
||||
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
|
||||
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(
|
||||
&ui,
|
||||
"Couldn't open file",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't open file"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -514,8 +520,8 @@ pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
|
||||
shared_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -523,8 +529,8 @@ pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
|
||||
shared_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -554,9 +560,10 @@ pub(crate) fn repaint_shared(
|
||||
shared_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Nothing Shared with You",
|
||||
"Files and folders other people share with you appear here. Public links \
|
||||
you bookmark are kept here too.",
|
||||
&gettext("Nothing Shared with You"),
|
||||
&gettext(
|
||||
"Files and folders other people share with you appear here. Public links you bookmark are kept here too.",
|
||||
),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -570,10 +577,10 @@ pub(crate) fn repaint_shared(
|
||||
// The list only needs a heading when another section sits beside it.
|
||||
ui.shared
|
||||
.with_me_group
|
||||
.set_title(if invitations.is_empty() && bookmarks.is_empty() {
|
||||
""
|
||||
.set_title(&if invitations.is_empty() && bookmarks.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"Shared with you"
|
||||
gettext("Shared with you")
|
||||
});
|
||||
ui.shared
|
||||
.invitations_group
|
||||
@@ -588,13 +595,11 @@ pub(crate) fn repaint_shared(
|
||||
|
||||
// Invitations: inviter + item, Accept / Reject.
|
||||
for inv in invitations {
|
||||
let item = inv
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "a shared item".to_string());
|
||||
let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item"));
|
||||
let row = adw::ActionRow::builder()
|
||||
.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();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
@@ -603,12 +608,12 @@ pub(crate) fn repaint_shared(
|
||||
}));
|
||||
let reject = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Reject")
|
||||
.tooltip_text(gettext("Reject"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
reject.add_css_class("flat");
|
||||
let accept = gtk4::Button::builder()
|
||||
.label("Accept")
|
||||
.label(gettext("Accept"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
accept.add_css_class("suggested-action");
|
||||
@@ -620,15 +625,19 @@ pub(crate) fn repaint_shared(
|
||||
});
|
||||
let ui_rej = ui.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| {
|
||||
let ui = ui_rej.clone();
|
||||
let id = id_rej.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
"Reject Invitation?",
|
||||
&format!("You won't have access to {name_rej} unless it is shared again."),
|
||||
"Reject",
|
||||
&gettext("Reject Invitation?"),
|
||||
// Translators: {name} is the shared item's name, or "this item".
|
||||
&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),
|
||||
);
|
||||
});
|
||||
@@ -640,7 +649,7 @@ pub(crate) fn repaint_shared(
|
||||
|
||||
// Bookmarks: name/URL, Open / Remove.
|
||||
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()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
@@ -648,13 +657,13 @@ pub(crate) fn repaint_shared(
|
||||
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove bookmark")
|
||||
.tooltip_text(gettext("Remove bookmark"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open in browser")
|
||||
.tooltip_text(gettext("Open in browser"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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() }
|
||||
};
|
||||
let (done, failed) = if accept {
|
||||
("Invitation accepted", "Couldn't accept the invitation")
|
||||
(
|
||||
gettext("Invitation accepted"),
|
||||
gettext("Couldn't accept the invitation"),
|
||||
)
|
||||
} 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.
|
||||
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Leave shared item")
|
||||
.body(format!(
|
||||
"Leave “{name}”? You'll lose access until you're invited again."
|
||||
.heading(gettext("Leave shared item"))
|
||||
// Translators: {name} is the shared item's name.
|
||||
.body(gettext_f(
|
||||
"Leave “{name}”? You'll lose access until you're invited again.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("leave", "Leave");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("leave", &pgettext("verb", "Leave"));
|
||||
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("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(
|
||||
&ui,
|
||||
Request::LeaveShared { uid: uid.clone() },
|
||||
"Left shared item",
|
||||
"Couldn't leave the shared item",
|
||||
&gettext("Left 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) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove bookmark")
|
||||
.body(format!("Remove the bookmark for “{name}”?"))
|
||||
.heading(gettext("Remove bookmark"))
|
||||
// Translators: {name} is the bookmark's name.
|
||||
.body(gettext_f(
|
||||
"Remove the bookmark for “{name}”?",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("remove", &gettext("Remove"));
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("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 {
|
||||
token: token.clone(),
|
||||
},
|
||||
"Bookmark removed",
|
||||
"Couldn't remove the bookmark",
|
||||
&gettext("Bookmark removed"),
|
||||
&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>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Add bookmark")
|
||||
.body("Paste a Proton Drive public link to save it here.")
|
||||
.heading(gettext("Add bookmark"))
|
||||
.body(gettext("Paste a Proton Drive public link to save it here."))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("Public link URL")
|
||||
.title(gettext("Public link URL"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (if the link has one)")
|
||||
.title(gettext("Password (if the link has one)"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&url_row);
|
||||
group.add(&pw_row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("add", "Add");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("add", &gettext("Add"));
|
||||
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("add"));
|
||||
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();
|
||||
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;
|
||||
}
|
||||
let pw = pw_row.text().to_string();
|
||||
@@ -785,24 +810,20 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
run_shared_mutation(
|
||||
&ui,
|
||||
Request::CreateBookmark { url, password },
|
||||
"Bookmark saved",
|
||||
"Couldn't add the bookmark",
|
||||
&gettext("Bookmark saved"),
|
||||
&gettext("Couldn't add the bookmark"),
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from the Shared page and reload the page on success.
|
||||
pub(crate) fn run_shared_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
pub(crate) fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
let done = done.to_string();
|
||||
let failed = failed.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
@@ -811,8 +832,10 @@ pub(crate) fn run_shared_mutation(
|
||||
load_shared(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -868,9 +891,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_role_pill_says_what_it_allows() {
|
||||
assert_eq!(role_access("viewer"), Some("Can view"));
|
||||
assert_eq!(role_access("editor"), Some("Can edit"));
|
||||
assert_eq!(role_access("admin"), Some("Can manage"));
|
||||
assert_eq!(role_access("viewer").as_deref(), Some("Can view"));
|
||||
assert_eq!(role_access("editor").as_deref(), Some("Can edit"));
|
||||
assert_eq!(role_access("admin").as_deref(), Some("Can manage"));
|
||||
assert_eq!(role_access(""), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
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_end(18);
|
||||
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);
|
||||
|
||||
(
|
||||
@@ -109,8 +109,8 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Loading…",
|
||||
"Reading what you've shared.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading what you've shared."),
|
||||
false,
|
||||
);
|
||||
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(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read your shares",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read your shares",
|
||||
"Unexpected reply from the mount service.",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
@@ -153,8 +153,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -162,8 +162,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -183,8 +183,8 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Nothing shared yet",
|
||||
"Items you share with people or by link show up here.",
|
||||
&gettext("Nothing shared yet"),
|
||||
&gettext("Items you share with people or by link show up here."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -212,14 +212,14 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
if let Some(url) = url.clone() {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text("Copy link")
|
||||
.tooltip_text(gettext("Copy link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let ui_copy = ui.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url);
|
||||
toast(&ui_copy, "Link copied");
|
||||
toast(&ui_copy, &gettext("Link copied"));
|
||||
});
|
||||
row.add_suffix(©);
|
||||
}
|
||||
@@ -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
|
||||
/// 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 {
|
||||
let mut items: Vec<(&str, MenuAction)> = Vec::new();
|
||||
let mut items: Vec<(String, MenuAction)> = Vec::new();
|
||||
if !entry.path.is_empty() {
|
||||
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());
|
||||
items.push((
|
||||
"Show in My Files",
|
||||
gettext("Show in My Files"),
|
||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
||||
));
|
||||
}
|
||||
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());
|
||||
items.push((
|
||||
"Manage Access…",
|
||||
gettext("Manage Access…"),
|
||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
||||
));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
"Stop Sharing…",
|
||||
gettext("Stop Sharing…"),
|
||||
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.
|
||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Stop sharing?")
|
||||
.body(format!(
|
||||
"Everyone you invited loses access to “{}”, pending invitations are \
|
||||
withdrawn and its public link stops working.",
|
||||
entry.name
|
||||
.heading(gettext("Stop sharing?"))
|
||||
// Translators: {name} is the shared item's name.
|
||||
.body(gettext_f(
|
||||
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||
&[("name", &entry.name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("stop", "Stop Sharing");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("stop", &gettext("Stop Sharing"));
|
||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -285,8 +289,9 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
run_mutation(
|
||||
&ui_c,
|
||||
Request::StopSharingByUid { uid: uid.clone() },
|
||||
format!("Stopped sharing {name}"),
|
||||
"Couldn't stop sharing",
|
||||
// Translators: {name} is the item's name.
|
||||
gettext_f("Stopped sharing {name}", &[("name", &name)]),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
);
|
||||
});
|
||||
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 {
|
||||
let mut parts = Vec::new();
|
||||
if item.member_count > 0 {
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
item.member_count,
|
||||
if item.member_count == 1 {
|
||||
"person"
|
||||
} else {
|
||||
"people"
|
||||
}
|
||||
// Translators: how many people have access to a shared item.
|
||||
parts.push(ngettext_f(
|
||||
"{n} person",
|
||||
"{n} people",
|
||||
item.member_count as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
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() {
|
||||
parts.push("Public link".to_string());
|
||||
parts.push(gettext("Public link"));
|
||||
}
|
||||
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 {
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ pub(crate) struct StatusState {
|
||||
pub(crate) accent_row: adw::SwitchRow,
|
||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||
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
|
||||
/// change handler skips the IPC/systemd side effect.
|
||||
pub(crate) settings_suppress: Cell<bool>,
|
||||
@@ -138,6 +141,7 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) mountpoint_button: gtk4::Button,
|
||||
pub(crate) accent_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:
|
||||
@@ -150,22 +154,30 @@ pub(crate) struct MainWidgets {
|
||||
/// the app version and user agent in About → Troubleshooting.
|
||||
pub(crate) fn build_main_page() -> MainWidgets {
|
||||
// ---- 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()
|
||||
.title("Start on login")
|
||||
.subtitle("Connect Proton Drive automatically when you log in")
|
||||
.title(gettext("Start on login"))
|
||||
.subtitle(gettext(
|
||||
"Connect Proton Drive automatically when you log in",
|
||||
))
|
||||
.build();
|
||||
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()
|
||||
.title("Proton Drive folder")
|
||||
.title(gettext("Proton Drive folder"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
mountpoint_row.add_css_class("property");
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label("Change…")
|
||||
.tooltip_text("Choose a different folder for the Proton Drive mount")
|
||||
.label(gettext("Change…"))
|
||||
.tooltip_text(gettext(
|
||||
"Choose a different folder for the Proton Drive mount",
|
||||
))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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,
|
||||
// since anything slower would stall a single block for most of a minute.
|
||||
let network_group = adw::PreferencesGroup::builder()
|
||||
.title("Network")
|
||||
.description("Limit how fast files move, shared by every transfer. 0 means no limit.")
|
||||
.title(gettext("Network"))
|
||||
.description(gettext(
|
||||
"Limit how fast files move, shared by every transfer. 0 means no limit.",
|
||||
))
|
||||
.build();
|
||||
let limit_row = |title: &str| {
|
||||
adw::SpinRow::builder()
|
||||
@@ -185,25 +199,31 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
.digits(1)
|
||||
.build()
|
||||
};
|
||||
let upload_limit_row = limit_row("Upload limit (MiB/s)");
|
||||
let download_limit_row = limit_row("Download limit (MiB/s)");
|
||||
let upload_limit_row = limit_row(&gettext("Upload limit (MiB/s)"));
|
||||
let download_limit_row = limit_row(&gettext("Download limit (MiB/s)"));
|
||||
network_group.add(&upload_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()
|
||||
.title("Proton purple accent")
|
||||
.subtitle("Use the Proton brand color instead of the system accent color")
|
||||
.title(gettext("Proton purple accent"))
|
||||
.subtitle(gettext(
|
||||
"Use the Proton brand color instead of the system accent color",
|
||||
))
|
||||
.build();
|
||||
appearance_group.add(&accent_row);
|
||||
let tray_row = adw::SwitchRow::builder()
|
||||
.title("Show tray icon")
|
||||
.subtitle("Sync status and quick actions in the panel")
|
||||
.title(gettext("Show tray icon"))
|
||||
.subtitle(gettext("Sync status and quick actions in the panel"))
|
||||
.build();
|
||||
appearance_group.add(&tray_row);
|
||||
let language_row = build_language_row();
|
||||
appearance_group.add(&language_row);
|
||||
|
||||
let general = adw::PreferencesPage::builder()
|
||||
.title("General")
|
||||
.title(gettext("General"))
|
||||
.icon_name("preferences-system-symbolic")
|
||||
.build();
|
||||
general.add(&startup_group);
|
||||
@@ -213,8 +233,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
|
||||
// ---- Preferences: Storage
|
||||
let storage_group = adw::PreferencesGroup::builder()
|
||||
.title("Cache")
|
||||
.description("Pinned and recently opened files, kept on this computer.")
|
||||
.title(gettext("Cache"))
|
||||
.description(gettext(
|
||||
"Pinned and recently opened files, kept on this computer.",
|
||||
))
|
||||
.build();
|
||||
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
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.
|
||||
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
|
||||
let budget_row = adw::SpinRow::builder()
|
||||
.title("Cache size limit (GiB)")
|
||||
.subtitle("Older files are removed past this size; 0 means no limit")
|
||||
.title(gettext("Cache size limit (GiB)"))
|
||||
.subtitle(gettext(
|
||||
"Older files are removed past this size; 0 means no limit",
|
||||
))
|
||||
.adjustment(&budget_adj)
|
||||
.digits(1)
|
||||
.build();
|
||||
storage_group.add(&budget_row);
|
||||
let purge_row = adw::ActionRow::builder()
|
||||
.title("Clear cache")
|
||||
.subtitle("Remove cached copies. Files kept offline stay.")
|
||||
.title(gettext("Clear cache"))
|
||||
.subtitle(gettext("Remove cached copies. Files kept offline stay."))
|
||||
.build();
|
||||
let purge_button = gtk4::Button::builder()
|
||||
.label("Clear…")
|
||||
.label(gettext("Clear…"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
purge_button.add_css_class("flat");
|
||||
@@ -256,12 +280,14 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
|
||||
// Pins group: filled in by refresh.
|
||||
let pins_group = adw::PreferencesGroup::builder()
|
||||
.title("Available offline")
|
||||
.description("Files kept on this computer, even without a connection.")
|
||||
.title(gettext("Available offline"))
|
||||
.description(gettext(
|
||||
"Files kept on this computer, even without a connection.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let storage = adw::PreferencesPage::builder()
|
||||
.title("Storage")
|
||||
.title(gettext("Storage"))
|
||||
.icon_name("drive-harddisk-symbolic")
|
||||
.build();
|
||||
storage.add(&storage_group);
|
||||
@@ -276,7 +302,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
// ---- Sidebar footer
|
||||
let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic");
|
||||
let status_title = gtk4::Label::builder()
|
||||
.label("Connecting…")
|
||||
.label(gettext("Connecting…"))
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
@@ -297,7 +323,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
status_content.append(&status_text);
|
||||
let status_button = gtk4::Button::builder()
|
||||
.child(&status_content)
|
||||
.tooltip_text("Show sync status")
|
||||
.tooltip_text(gettext("Show sync status"))
|
||||
.build();
|
||||
status_button.add_css_class("flat");
|
||||
status_button.add_css_class("sidebar-status");
|
||||
@@ -324,13 +350,13 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
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();
|
||||
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);
|
||||
let account_button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.tooltip_text("Account")
|
||||
.tooltip_text(gettext("Account"))
|
||||
.menu_model(&account_menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
@@ -371,15 +397,44 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
mountpoint_button,
|
||||
accent_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
|
||||
/// in-flight transfer from [`Request::GetQueueStatus`].
|
||||
pub(crate) fn build_transfers_group() -> adw::PreferencesGroup {
|
||||
adw::PreferencesGroup::builder()
|
||||
.title("Transfers")
|
||||
.description("Files moving to and from Proton Drive.")
|
||||
.title(gettext("Transfers"))
|
||||
.description(gettext("Files moving to and from Proton Drive."))
|
||||
.visible(false)
|
||||
.build()
|
||||
}
|
||||
@@ -430,6 +485,9 @@ pub(crate) fn wire_settings(
|
||||
.accent_row
|
||||
.set_active(config.proton_accent.unwrap_or(false));
|
||||
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);
|
||||
|
||||
// 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(
|
||||
&ui_fire,
|
||||
Request::SetCacheBudget { bytes },
|
||||
"Cache budget updated",
|
||||
"Couldn't set cache budget",
|
||||
gettext("Cache budget updated"),
|
||||
gettext("Couldn't set cache budget"),
|
||||
);
|
||||
});
|
||||
*ui_budget.status.budget_source.borrow_mut() = Some(src);
|
||||
@@ -476,8 +534,8 @@ pub(crate) fn wire_settings(
|
||||
settings_request(
|
||||
&ui_fire,
|
||||
Request::SetBandwidthLimits { upload, download },
|
||||
"Bandwidth limits updated",
|
||||
"Couldn't set bandwidth limits",
|
||||
gettext("Bandwidth limits updated"),
|
||||
gettext("Couldn't set bandwidth limits"),
|
||||
);
|
||||
});
|
||||
*ui_limit.status.limit_source.borrow_mut() = Some(src);
|
||||
@@ -489,14 +547,11 @@ pub(crate) fn wire_settings(
|
||||
purge_button.connect_clicked(move |_| {
|
||||
let ui = ui_purge.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Clear Cache?")
|
||||
.body(
|
||||
"Cached copies are removed from this computer and download again when \
|
||||
you open them. Files kept available offline stay.",
|
||||
)
|
||||
.heading(gettext("Clear Cache?"))
|
||||
.body(gettext("Cached copies are removed from this computer and download again when you open them. Files kept available offline stay."))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("purge", "Clear Cache");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("purge", &gettext("Clear Cache"));
|
||||
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -505,8 +560,8 @@ pub(crate) fn wire_settings(
|
||||
settings_request(
|
||||
&ui,
|
||||
Request::PurgeCache,
|
||||
"Cache cleared",
|
||||
"Couldn't clear the cache",
|
||||
gettext("Cache cleared"),
|
||||
gettext("Couldn't clear the cache"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -541,7 +596,11 @@ pub(crate) fn wire_settings(
|
||||
let mut config = ui_accent.dirs.load_config();
|
||||
config.proton_accent = Some(on);
|
||||
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();
|
||||
config.tray_hidden = !show;
|
||||
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;
|
||||
}
|
||||
if show {
|
||||
@@ -565,18 +628,36 @@ pub(crate) fn wire_settings(
|
||||
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,
|
||||
/// 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
|
||||
/// cache read-out.
|
||||
pub(crate) fn settings_request(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed: String) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
@@ -584,9 +665,11 @@ pub(crate) fn settings_request(
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, done),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, &done),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
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>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Choose mountpoint folder")
|
||||
.title(gettext("Choose mountpoint folder"))
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
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();
|
||||
config.mountpoint = Some(path_str.clone());
|
||||
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;
|
||||
}
|
||||
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.
|
||||
let confirm = adw::AlertDialog::builder()
|
||||
.heading("Restart to apply")
|
||||
.body(format!(
|
||||
"The mountpoint is now “{path_str}”. Restart the Drive mount to use it?"
|
||||
.heading(gettext("Restart to apply"))
|
||||
// Translators: {path} is the folder the drive is mounted at.
|
||||
.body(gettext_f(
|
||||
"The mountpoint is now “{path}”. Restart the Drive mount to use it?",
|
||||
&[("path", &path_str)],
|
||||
))
|
||||
.build();
|
||||
confirm.add_response("later", "Later");
|
||||
confirm.add_response("restart", "Restart now");
|
||||
confirm.add_response("later", &gettext("Later"));
|
||||
confirm.add_response("restart", &gettext("Restart now"));
|
||||
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
||||
confirm.set_default_response(Some("restart"));
|
||||
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) {
|
||||
let used = used_space.max(0) as u64;
|
||||
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 fraction = (used as f64 / total as f64).clamp(0.0, 1.0);
|
||||
let pct = (fraction * 100.0).round() as u64;
|
||||
(
|
||||
fraction,
|
||||
format!(
|
||||
"{} of {} used ({pct}%)",
|
||||
human_bytes(used),
|
||||
human_bytes(total)
|
||||
// Translators: account storage. {used} and {total} are sizes such as
|
||||
// "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
|
||||
gettext_f(
|
||||
"{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;
|
||||
Some((
|
||||
fraction,
|
||||
format!("{} free", human_bytes(free)),
|
||||
format!(
|
||||
"{} free out of {} ({pct}% used)",
|
||||
human_bytes(free),
|
||||
human_bytes(total)
|
||||
// Translators: {size} is an amount of storage, such as "1.2 GiB".
|
||||
gettext_f("{size} free", &[("size", &human_bytes(free))]),
|
||||
// Translators: account storage. {free} and {total} are sizes such as
|
||||
// "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
|
||||
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 {
|
||||
notify(
|
||||
"mount-state",
|
||||
"Proton Drive connected",
|
||||
"Your Drive is mounted and available.",
|
||||
&gettext("Proton Drive connected"),
|
||||
&gettext("Your Drive is mounted and available."),
|
||||
);
|
||||
} else {
|
||||
notify(
|
||||
"mount-state",
|
||||
"Proton Drive disconnected",
|
||||
"The mount service stopped. Files aren't available until it restarts.",
|
||||
&gettext("Proton Drive disconnected"),
|
||||
&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());
|
||||
if items.is_empty() && previous > 0 {
|
||||
let files = if previous == 1 {
|
||||
"1 file".to_string()
|
||||
} else {
|
||||
format!("{previous} files")
|
||||
};
|
||||
notify(
|
||||
"sync-complete",
|
||||
"Sync complete",
|
||||
&format!("{files} finished transferring."),
|
||||
&gettext("Sync complete"),
|
||||
&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
|
||||
// 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
|
||||
/// where it is — a count when it has one, else what it is currently chewing on.
|
||||
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()) {
|
||||
(true, true) => format!("{} — {} of {}", j.title, j.done, j.total),
|
||||
(true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total),
|
||||
(false, true) => format!("{}…", j.title),
|
||||
(false, false) => format!("{} — {}…", j.title, j.detail),
|
||||
// Translators: a background job's progress. {title} is the job, such as
|
||||
// "Scanning Photos"; {done} and {total} are counts.
|
||||
(true, true) => gettext_f("{title} — {done} of {total}", &args),
|
||||
// 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 {
|
||||
text,
|
||||
@@ -984,25 +1099,30 @@ pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
|
||||
TransferDirection::Download => "↓",
|
||||
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 {
|
||||
ActivityLine {
|
||||
text: format!(
|
||||
"{arrow} {} — {} ({}/s)",
|
||||
t.name,
|
||||
human_bytes(t.bytes_completed),
|
||||
human_bytes(t.speed_bytes_sec),
|
||||
),
|
||||
// Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
||||
// file name, {done} the size moved so far and {speed} a size such as
|
||||
// "1.2 MiB"; "/s" means per second.
|
||||
text: gettext_f("{arrow} {name} — {done} ({speed}/s)", &args),
|
||||
fraction: None,
|
||||
}
|
||||
} else {
|
||||
ActivityLine {
|
||||
text: format!(
|
||||
"{arrow} {} — {} of {} ({}/s)",
|
||||
t.name,
|
||||
human_bytes(t.bytes_completed),
|
||||
human_bytes(t.bytes_total),
|
||||
human_bytes(t.speed_bytes_sec),
|
||||
),
|
||||
// Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
|
||||
// file name, {done} and {total} sizes and {speed} a size such as
|
||||
// "1.2 MiB"; "/s" means per second.
|
||||
text: gettext_f("{arrow} {name} — {done} of {total} ({speed}/s)", &args),
|
||||
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
|
||||
// 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.
|
||||
let queued = pending_summary(pending_uploads, pending_changes);
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let state = if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
@@ -1131,47 +1251,55 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
"Sync paused".to_string(),
|
||||
gettext("Sync paused"),
|
||||
Some(match (until, queued) {
|
||||
(Some(until), Some(queued)) => format!("{queued} · resumes {}", clock_time(until)),
|
||||
(Some(until), None) => format!("Resumes {}", clock_time(until)),
|
||||
// Translators: {queued} says what is waiting, such as "3 changes
|
||||
// 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, None) => "Until you resume".to_string(),
|
||||
(None, None) => gettext("Until you resume"),
|
||||
}),
|
||||
),
|
||||
SyncState::Attention { count, error } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("error"),
|
||||
format!(
|
||||
"{} need{} attention",
|
||||
count_noun(count as usize, "change", "changes"),
|
||||
if count == 1 { "s" } else { "" }
|
||||
ngettext_f(
|
||||
"{n} change needs attention",
|
||||
"{n} changes need attention",
|
||||
count,
|
||||
&[],
|
||||
),
|
||||
error,
|
||||
),
|
||||
SyncState::Offline { queued } => (
|
||||
"network-offline-symbolic",
|
||||
Some("warning"),
|
||||
"Offline".to_string(),
|
||||
Some(queued.unwrap_or_else(|| "Cached files only".to_string())),
|
||||
pgettext("state", "Offline"),
|
||||
Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
|
||||
),
|
||||
SyncState::Syncing { queued } => (
|
||||
"emblem-synchronizing-symbolic",
|
||||
None,
|
||||
"Syncing".to_string(),
|
||||
pgettext("state", "Syncing"),
|
||||
Some(queued),
|
||||
),
|
||||
SyncState::UpToDate { mountpoint } => (
|
||||
"emblem-ok-symbolic",
|
||||
Some("success"),
|
||||
"Up to date".to_string(),
|
||||
gettext("Up to date"),
|
||||
Some(mountpoint),
|
||||
),
|
||||
SyncState::Disconnected => (
|
||||
"network-offline-symbolic",
|
||||
Some("warning"),
|
||||
"Not connected".to_string(),
|
||||
Some("Proton Drive isn't running".to_string()),
|
||||
gettext("Not connected"),
|
||||
Some(gettext("Proton Drive isn't running")),
|
||||
),
|
||||
};
|
||||
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::now_local(),
|
||||
) 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() {
|
||||
"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 {
|
||||
"%a %H:%M"
|
||||
// Translators: strftime format for a time within the next week, such as
|
||||
// "Tue 09:00".
|
||||
gettext("%a %H:%M")
|
||||
} 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())
|
||||
.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
|
||||
@@ -1242,8 +1376,10 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
|
||||
if pins.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No files kept offline")
|
||||
.subtitle("Right-click a file and choose “Make available offline”.")
|
||||
.title(gettext("No files kept offline"))
|
||||
.subtitle(gettext(
|
||||
"Right-click a file and choose “Make available offline”.",
|
||||
))
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
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()
|
||||
.icon_name("pdfs-online-only-symbolic")
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Make online only")
|
||||
.tooltip_text(gettext("Make online only"))
|
||||
.sensitive(mounted)
|
||||
.build();
|
||||
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();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't make it online only", &message, kind)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(
|
||||
&ui,
|
||||
&gettext("Couldn't make it online only"),
|
||||
&message,
|
||||
kind,
|
||||
),
|
||||
Ok(Ok(_)) => refresh(&ui),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't make it online only",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't make it online only"),
|
||||
&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 {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(if expanded {
|
||||
"Show fewer".to_string()
|
||||
gettext("Show fewer")
|
||||
} 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)
|
||||
.build();
|
||||
@@ -1360,7 +1504,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn quota_display_clamps_bad_api_values() {
|
||||
assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string()));
|
||||
assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
|
||||
assert_eq!(quota_display(100, 150).0, 1.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 \
|
||||
re-running an interrupted import is safe.";
|
||||
fn empty_list_description() -> String {
|
||||
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 {
|
||||
/// 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.
|
||||
let back_button = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back to Photos")
|
||||
.tooltip_text(gettext("Back to Photos"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
back_button.add_css_class("flat");
|
||||
|
||||
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)
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
@@ -152,18 +157,18 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
drop_icon.set_pixel_size(48);
|
||||
drop_icon.add_css_class("dim-label");
|
||||
let drop_title = gtk4::Label::builder()
|
||||
.label("Drop your Takeout .zip files here")
|
||||
.label(gettext("Drop your Takeout .zip files here"))
|
||||
.build();
|
||||
drop_title.add_css_class("title-4");
|
||||
let drop_hint = gtk4::Label::builder()
|
||||
.label("Add every part of the export at once — a photo and its metadata often sit in different parts.")
|
||||
.label(gettext("Add every part of the export at once — a photo and its metadata often sit in different parts."))
|
||||
.wrap(true)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.max_width_chars(48)
|
||||
.build();
|
||||
drop_hint.add_css_class("dim-label");
|
||||
let choose_button = gtk4::Button::builder()
|
||||
.label("Choose files…")
|
||||
.label(gettext("Choose files…"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
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
|
||||
// title.
|
||||
let list_group = adw::PreferencesGroup::builder()
|
||||
.title("Archives to import")
|
||||
.description(EMPTY_LIST_DESCRIPTION)
|
||||
.title(gettext("Archives to import"))
|
||||
.description(empty_list_description())
|
||||
.build();
|
||||
|
||||
let clear_button = gtk4::Button::builder()
|
||||
.label("Clear")
|
||||
.tooltip_text("Remove every staged archive")
|
||||
.label(gettext("Clear"))
|
||||
.tooltip_text(gettext("Remove every staged archive"))
|
||||
.build();
|
||||
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
|
||||
// because the import behind it is hours long and hard to take back.
|
||||
let scan_button = gtk4::Button::builder()
|
||||
.label("Scan")
|
||||
.tooltip_text("Check the archives and report what would be imported, without uploading")
|
||||
.label(pgettext("verb", "Scan"))
|
||||
.tooltip_text(gettext(
|
||||
"Check the archives and report what would be imported, without uploading",
|
||||
))
|
||||
.build();
|
||||
scan_button.add_css_class("pill");
|
||||
let import_button = gtk4::Button::builder().label("Import").build();
|
||||
let import_button = gtk4::Button::builder()
|
||||
.label(pgettext("verb", "Import"))
|
||||
.build();
|
||||
import_button.add_css_class("pill");
|
||||
import_button.add_css_class("suggested-action");
|
||||
let cancel_button = gtk4::Button::builder()
|
||||
.label("Stop import")
|
||||
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop")
|
||||
.label(gettext("Stop import"))
|
||||
.tooltip_text(gettext(
|
||||
"Finish the photo on the wire, file what was uploaded, then stop",
|
||||
))
|
||||
.build();
|
||||
cancel_button.add_css_class("pill");
|
||||
cancel_button.add_css_class("destructive-action");
|
||||
@@ -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
|
||||
// the same numbers the Sync page's Transfers list does.
|
||||
let progress_group = adw::PreferencesGroup::builder()
|
||||
.title("Import in progress")
|
||||
.title(gettext("Import in progress"))
|
||||
.visible(false)
|
||||
.build();
|
||||
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
|
||||
// reason to come back to it.
|
||||
let summary_group = adw::PreferencesGroup::builder()
|
||||
.title("Last import")
|
||||
.title(gettext("Last import"))
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
@@ -273,7 +284,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
page.set_margin_end(18);
|
||||
page.append(&subtitle_clamp);
|
||||
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);
|
||||
|
||||
let widgets = TakeoutWidgets {
|
||||
@@ -330,10 +341,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
widgets.choose_button.connect_clicked(move |_| {
|
||||
// Multi-select on purpose: the whole export goes in one request.
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Select your Google Takeout archives")
|
||||
.title(gettext("Select your Google Takeout archives"))
|
||||
.build();
|
||||
let filter = gtk4::FileFilter::new();
|
||||
filter.set_name(Some("Takeout archives"));
|
||||
filter.set_name(Some(&gettext("Takeout archives")));
|
||||
filter.add_pattern("*.zip");
|
||||
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
||||
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 ui = ui_import.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Import this export?")
|
||||
.body(format!(
|
||||
"{} 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.",
|
||||
count_noun(count, "archive", "archives")
|
||||
.heading(gettext("Import this export?"))
|
||||
.body(ngettext_f(
|
||||
"{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.",
|
||||
"{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.",
|
||||
count as u64,
|
||||
&[],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("import", "Import");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("import", &pgettext("verb", "Import"));
|
||||
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("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;
|
||||
ui.busy_end();
|
||||
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 })) => {
|
||||
toast_failure(&ui, "Couldn't stop the import", &message, kind);
|
||||
toast_failure(&ui, &gettext("Couldn't stop the import"), &message, kind);
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't stop the import",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't stop the import"),
|
||||
&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 {
|
||||
toast(
|
||||
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 {
|
||||
toast(
|
||||
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() {
|
||||
ui.takeout
|
||||
.list_group
|
||||
.set_description(Some(EMPTY_LIST_DESCRIPTION));
|
||||
let row = dim_row("No archives added yet.");
|
||||
.set_description(Some(&empty_list_description()));
|
||||
let row = dim_row(&gettext("No archives added yet."));
|
||||
ui.takeout.list_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
} else {
|
||||
@@ -500,7 +513,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
.build();
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.tooltip_text("Remove from this import")
|
||||
.tooltip_text(gettext("Remove from this import"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
@@ -518,10 +531,14 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
ui.takeout.list_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
ui.takeout.list_group.set_description(Some(&format!(
|
||||
"{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||
count_noun(staged.len(), "archive", "archives"),
|
||||
human_bytes(total)
|
||||
let size = human_bytes(total);
|
||||
let note = empty_list_description();
|
||||
// Translators: {size} is a file size such as "4.2 MB"; {note} is the sentence about skipped duplicates.
|
||||
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", ¬e)],
|
||||
)));
|
||||
}
|
||||
*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.progress_group.set_visible(true);
|
||||
ui.takeout.progress_bar.set_fraction(0.0);
|
||||
ui.takeout.progress_label.set_text(if dry_run {
|
||||
"Reading archives…"
|
||||
ui.takeout.progress_label.set_text(&if dry_run {
|
||||
gettext("Reading archives…")
|
||||
} else {
|
||||
"Starting import…"
|
||||
gettext("Starting import…")
|
||||
});
|
||||
sync_takeout_actions(&ui);
|
||||
toast(
|
||||
&ui,
|
||||
if dry_run {
|
||||
"Scanning the export…"
|
||||
&if dry_run {
|
||||
gettext("Scanning the export…")
|
||||
} 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 })) => {
|
||||
toast_failure(&ui, "Couldn't start the import", &message, kind);
|
||||
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't start the import",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't start the import"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -629,23 +646,35 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||
if was_running {
|
||||
let dry = ui.takeout.dry_run.get();
|
||||
let headline = if dry {
|
||||
"Scan finished"
|
||||
gettext("Scan finished")
|
||||
} else {
|
||||
"Import finished"
|
||||
gettext("Import finished")
|
||||
};
|
||||
toast(&ui, headline);
|
||||
toast(&ui, &headline);
|
||||
if !dry {
|
||||
let body = match &summary {
|
||||
Some(s) if s.failed > 0 => {
|
||||
format!("{} photos added, {} failed.", s.uploaded, s.failed)
|
||||
}
|
||||
Some(s) if s.cancelled => {
|
||||
format!("Stopped after {} photos.", s.uploaded)
|
||||
}
|
||||
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded),
|
||||
None => "The Google Photos import has finished.".to_string(),
|
||||
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
||||
Some(s) if s.failed > 0 => ngettext_f(
|
||||
"{n} photo added, {failed} failed.",
|
||||
"{n} photos added, {failed} failed.",
|
||||
s.uploaded as u64,
|
||||
&[("failed", &s.failed.to_string())],
|
||||
),
|
||||
Some(s) if s.cancelled => ngettext_f(
|
||||
"Stopped after {n} photo.",
|
||||
"Stopped after {n} photos.",
|
||||
s.uploaded as u64,
|
||||
&[],
|
||||
),
|
||||
Some(s) => ngettext_f(
|
||||
"{n} photo added to Proton Photos.",
|
||||
"{n} photos added to Proton Photos.",
|
||||
s.uploaded as u64,
|
||||
&[],
|
||||
),
|
||||
None => gettext("The Google Photos import has finished."),
|
||||
};
|
||||
notify("takeout-import", headline, &body);
|
||||
notify("takeout-import", &headline, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,69 +694,72 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
ui.takeout.summary_group.remove(&row);
|
||||
}
|
||||
let dry = ui.takeout.dry_run.get();
|
||||
ui.takeout
|
||||
.summary_group
|
||||
.set_title(if dry { "Scan result" } else { "Last import" });
|
||||
ui.takeout.summary_group.set_description(Some(if dry {
|
||||
"Nothing was uploaded — this is what an import would do."
|
||||
} else if summary.cancelled {
|
||||
"Stopped early. Everything uploaded so far is in your timeline; run it again to \
|
||||
continue where it left off."
|
||||
ui.takeout.summary_group.set_title(&if dry {
|
||||
gettext("Scan result")
|
||||
} 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
|
||||
// to show a "0 failed" row for the user to read past.
|
||||
let mut lines: Vec<(String, String)> = vec![
|
||||
("Photos in the export".into(), summary.found.to_string()),
|
||||
(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(),
|
||||
),
|
||||
];
|
||||
if summary.duplicates > 0 {
|
||||
lines.push((
|
||||
"Already in Proton Photos".into(),
|
||||
gettext("Already in Proton Photos"),
|
||||
summary.duplicates.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.albums_created > 0 {
|
||||
lines.push((
|
||||
if dry {
|
||||
"Albums to create"
|
||||
gettext("Albums to create")
|
||||
} else {
|
||||
"Albums created"
|
||||
}
|
||||
.into(),
|
||||
gettext("Albums created")
|
||||
},
|
||||
summary.albums_created.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.album_links > 0 {
|
||||
lines.push((
|
||||
"Photos filed into albums".into(),
|
||||
gettext("Photos filed into albums"),
|
||||
summary.album_links.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.skipped_trashed > 0 {
|
||||
lines.push((
|
||||
"Skipped (in Google's trash)".into(),
|
||||
gettext("Skipped (in Google's trash)"),
|
||||
summary.skipped_trashed.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.bytes > 0 {
|
||||
lines.push((
|
||||
if dry {
|
||||
"Data to upload"
|
||||
gettext("Data to upload")
|
||||
} else {
|
||||
"Data uploaded"
|
||||
}
|
||||
.into(),
|
||||
gettext("Data uploaded")
|
||||
},
|
||||
human_bytes(summary.bytes),
|
||||
));
|
||||
}
|
||||
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();
|
||||
@@ -756,17 +788,22 @@ pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||
ui.takeout
|
||||
.progress_bar
|
||||
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
|
||||
ui.takeout
|
||||
.progress_label
|
||||
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail));
|
||||
let done = job.done.to_string();
|
||||
let total = job.total.to_string();
|
||||
// Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
|
||||
let text = gettext_f(
|
||||
"{done} of {total} — {detail}",
|
||||
&[("done", &done), ("total", &total), ("detail", &job.detail)],
|
||||
);
|
||||
ui.takeout.progress_label.set_text(&text);
|
||||
} else {
|
||||
ui.takeout.progress_bar.pulse();
|
||||
ui.takeout
|
||||
.progress_label
|
||||
.set_text(if job.detail.is_empty() {
|
||||
"Working…"
|
||||
.set_text(&if job.detail.is_empty() {
|
||||
gettext("Working…")
|
||||
} else {
|
||||
&job.detail
|
||||
job.detail.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,14 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
|
||||
let empty = gtk4::Button::builder()
|
||||
.label("Empty Trash…")
|
||||
.tooltip_text("Permanently delete everything in the Trash")
|
||||
.label(gettext("Empty Trash…"))
|
||||
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
||||
.sensitive(false)
|
||||
.build();
|
||||
let refresh = refresh_button();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -90,8 +90,8 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
inner.append(&content);
|
||||
|
||||
let selection_label = gtk4::Label::new(None);
|
||||
let restore_selected = gtk4::Button::with_label("Restore");
|
||||
let delete_selected = gtk4::Button::with_label("Delete Permanently…");
|
||||
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
||||
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
||||
delete_selected.add_css_class("destructive-action");
|
||||
let bar = gtk4::ActionBar::new();
|
||||
bar.set_center_widget(Some(&selection_label));
|
||||
@@ -103,7 +103,7 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
.build();
|
||||
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_end(&refresh);
|
||||
|
||||
@@ -154,13 +154,13 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore to its original folder")
|
||||
.tooltip_text(gettext("Restore to its original folder"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let purge = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.tooltip_text("Delete permanently")
|
||||
.tooltip_text(gettext("Delete permanently"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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>() {
|
||||
let kind = if entry.is_dir {
|
||||
"Folder".to_string()
|
||||
gettext("Folder")
|
||||
} else {
|
||||
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;
|
||||
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||
if count > 0 {
|
||||
ui.trash
|
||||
.selection_label
|
||||
.set_label(&format!("{} selected", count_noun(count, "item", "items")));
|
||||
ui.trash.selection_label.set_label(&ngettext_f(
|
||||
"{n} item selected",
|
||||
"{n} items selected",
|
||||
count as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,8 +293,8 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
"Loading…",
|
||||
"Reading the trash.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading the trash."),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -300,15 +309,15 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read the trash",
|
||||
&gettext("Couldn't read the trash"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => trash_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read the trash",
|
||||
"Unexpected reply from the mount service.",
|
||||
&gettext("Couldn't read the trash"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
||||
@@ -323,8 +332,8 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
trash_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -332,8 +341,8 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
trash_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -364,16 +373,19 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
"Trash is empty",
|
||||
"Items you delete from Proton Drive show up here.",
|
||||
&gettext("Trash is empty"),
|
||||
&gettext("Items you delete from Proton Drive show up here."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.trash.content.set_visible_child_name("list");
|
||||
ui.trash
|
||||
.subtitle
|
||||
.set_subtitle(&count_noun(entries.len(), "item", "items"));
|
||||
ui.trash.subtitle.set_subtitle(&ngettext_f(
|
||||
"{n} item",
|
||||
"{n} items",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
));
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
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 {
|
||||
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||
},
|
||||
format!("Restored {}", entries_label(entries)),
|
||||
"Couldn't restore",
|
||||
match entries {
|
||||
// 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.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let win = ui_window(ui);
|
||||
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()
|
||||
.heading("Delete Permanently")
|
||||
.body(format!("Permanently delete {name}? This cannot be undone."))
|
||||
.heading(gettext("Delete Permanently"))
|
||||
.body(body)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete Permanently");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("delete", &gettext("Delete Permanently"));
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -428,8 +466,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::DeleteForever { uids: uids.clone() },
|
||||
format!("Deleted {name} permanently"),
|
||||
"Couldn't delete",
|
||||
done.clone(),
|
||||
gettext_noop("Couldn't delete"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -441,14 +479,16 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let count = ui.trash.model.n_items();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Empty Trash")
|
||||
.body(format!(
|
||||
"Permanently delete all {} in the trash? This cannot be undone.",
|
||||
count_noun(count as usize, "item", "items")
|
||||
.heading(gettext("Empty Trash"))
|
||||
.body(ngettext_f(
|
||||
"Permanently delete all {n} item in the trash? This cannot be undone.",
|
||||
"Permanently delete all {n} items in the trash? This cannot be undone.",
|
||||
count as u64,
|
||||
&[],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("empty", "Empty Trash");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("empty", &gettext("Empty Trash"));
|
||||
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -459,8 +499,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::EmptyTrash,
|
||||
"Trash emptied".to_string(),
|
||||
"Couldn't empty the trash",
|
||||
gettext("Trash emptied"),
|
||||
gettext_noop("Couldn't empty the trash"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(crate) fn prompt_human_verification(
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Verification")
|
||||
.title(gettext("Verification"))
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.build();
|
||||
|
||||
@@ -50,12 +50,12 @@ pub(crate) struct DetailsWidgets {
|
||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.tooltip_text("Details (Alt+Enter)")
|
||||
.tooltip_text(gettext("Details (Alt+Enter)"))
|
||||
.build();
|
||||
|
||||
let close_button = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Close details")
|
||||
.tooltip_text(gettext("Close details"))
|
||||
.halign(gtk4::Align::End)
|
||||
.build();
|
||||
close_button.add_css_class("flat");
|
||||
@@ -81,19 +81,21 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
head.append(&name);
|
||||
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()
|
||||
.title("Size")
|
||||
.title(pgettext("column", "Size"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
size_row.add_css_class("property");
|
||||
let modified_row = adw::ActionRow::builder()
|
||||
.title("Modified")
|
||||
.title(pgettext("column", "Modified"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
modified_row.add_css_class("property");
|
||||
let path_row = adw::ActionRow::builder()
|
||||
.title("Location")
|
||||
.title(gettext("Location"))
|
||||
.subtitle("—")
|
||||
.subtitle_lines(3)
|
||||
.build();
|
||||
@@ -102,22 +104,28 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
props.add(&modified_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()
|
||||
.title("Available offline")
|
||||
.subtitle("Keep a copy on this computer, even without a connection.")
|
||||
.title(gettext("Available offline"))
|
||||
.subtitle(gettext(
|
||||
"Keep a copy on this computer, even without a connection.",
|
||||
))
|
||||
.build();
|
||||
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()
|
||||
.title("Access")
|
||||
.title(gettext("Access"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
sharing_row.add_css_class("property");
|
||||
sharing.add(&sharing_row);
|
||||
let share_button = gtk4::Button::builder().label("Share…").build();
|
||||
let copy_link_button = gtk4::Button::builder().label("Copy Link").build();
|
||||
let share_button = gtk4::Button::builder().label(gettext("Share…")).build();
|
||||
let copy_link_button = gtk4::Button::builder().label(gettext("Copy Link")).build();
|
||||
let share_actions = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
@@ -128,25 +136,31 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
share_actions.append(©_link_button);
|
||||
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()
|
||||
.title("History")
|
||||
.title(gettext("History"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
versions_row.add_css_class("property");
|
||||
versions_group.add(&versions_row);
|
||||
let versions_button = gtk4::Button::builder()
|
||||
.label("Versions…")
|
||||
.label(gettext("Versions…"))
|
||||
.margin_top(6)
|
||||
.build();
|
||||
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("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");
|
||||
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("pill");
|
||||
|
||||
@@ -178,7 +192,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
// itself would move the files under the pointer.
|
||||
let empty_close = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Close details")
|
||||
.tooltip_text(gettext("Close details"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(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));
|
||||
let empty_status = adw::StatusPage::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.title("No item selected")
|
||||
.description("Select a file or folder to see its details.")
|
||||
.title(gettext("No item selected"))
|
||||
.description(gettext("Select a file or folder to see its details."))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
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;
|
||||
d.icon.set_icon_name(Some(icon_base_for(entry)));
|
||||
d.name.set_label(&entry.name);
|
||||
d.kind
|
||||
.set_label(if entry.is_dir { "Folder" } else { "File" });
|
||||
d.kind.set_label(&if entry.is_dir {
|
||||
gettext("Folder")
|
||||
} else {
|
||||
gettext("File")
|
||||
});
|
||||
d.size_row.set_subtitle(&if entry.is_dir {
|
||||
"—".to_string()
|
||||
} else {
|
||||
@@ -362,18 +379,21 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
Some(i) => &rel[..i],
|
||||
None => "",
|
||||
};
|
||||
d.path_row.set_subtitle(if parent.is_empty() {
|
||||
"Proton Drive"
|
||||
d.path_row.set_subtitle(&if parent.is_empty() {
|
||||
gettext("Proton Drive")
|
||||
} else {
|
||||
parent
|
||||
parent.to_string()
|
||||
});
|
||||
// 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.
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_visible(!entry.is_dir);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.open_button
|
||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||
d.open_button.set_label(&if entry.is_dir {
|
||||
gettext("Open folder")
|
||||
} else {
|
||||
pgettext("verb", "Open")
|
||||
});
|
||||
// Only files have revisions, and only a connected daemon can fetch them.
|
||||
let mounted = *ui.mounted.borrow();
|
||||
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 })) => {
|
||||
sharing_summary(entries.len(), link.is_some())
|
||||
}
|
||||
_ => "Couldn't load sharing".to_string(),
|
||||
_ => gettext("Couldn't load sharing"),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
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 {
|
||||
let text = match rx.recv().await {
|
||||
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) {
|
||||
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.
|
||||
pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
||||
let people = match people {
|
||||
0 => None,
|
||||
1 => Some("1 person".to_string()),
|
||||
n => Some(format!("{n} people")),
|
||||
};
|
||||
let n = people as u64;
|
||||
match (people, link) {
|
||||
(None, false) => "Only you".to_string(),
|
||||
(None, true) => "Anyone with the link".to_string(),
|
||||
(Some(p), false) => format!("Shared with {p}"),
|
||||
(Some(p), true) => format!("Shared with {p} · Public link"),
|
||||
(0, false) => gettext("Only you"),
|
||||
(0, true) => gettext("Anyone with the link"),
|
||||
(_, false) => ngettext_f("Shared with {n} person", "Shared with {n} people", n, &[]),
|
||||
(_, 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 {
|
||||
let latest = items.iter().map(|r| r.created).max();
|
||||
match (items.len(), latest) {
|
||||
(0, _) | (_, None) => "No earlier versions".to_string(),
|
||||
(1, Some(t)) => format!("1 version · {}", format_modified(t)),
|
||||
(n, Some(t)) => format!("{n} versions · latest {}", format_modified(t)),
|
||||
(0, _) | (_, None) => gettext("No earlier versions"),
|
||||
// Translators: {date} is the date of the only version.
|
||||
(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))],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,8 @@ impl ActionMenu {
|
||||
let (menu, group) = self.finish();
|
||||
let button = gtk4::MenuButton::builder()
|
||||
.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)
|
||||
.menu_model(&menu)
|
||||
.build();
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
use crate::*;
|
||||
|
||||
/// The roles offered in the Share dialog's dropdowns, in index order.
|
||||
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"];
|
||||
/// The role labels offered in the Share dialog's dropdowns, in index order.
|
||||
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.
|
||||
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,
|
||||
/// with their lifetime in days (`None` never expires).
|
||||
pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [
|
||||
("Never", None),
|
||||
("1 day", Some(1)),
|
||||
("7 days", Some(7)),
|
||||
("30 days", Some(30)),
|
||||
// Translators: a public link that never expires.
|
||||
(gettext_noop("Never"), None),
|
||||
(gettext_noop("1 day"), Some(1)),
|
||||
(gettext_noop("7 days"), Some(7)),
|
||||
(gettext_noop("30 days"), Some(30)),
|
||||
];
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
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;
|
||||
}
|
||||
// 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 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);
|
||||
|
||||
// Invite section: emails + role + optional message.
|
||||
let invite_group = adw::PreferencesGroup::builder()
|
||||
.title("Invite people")
|
||||
.description("Proton and non-Proton email addresses, separated by spaces or commas.")
|
||||
.title(gettext("Invite people"))
|
||||
.description(gettext(
|
||||
"Proton and non-Proton email addresses, separated by spaces or commas.",
|
||||
))
|
||||
.build();
|
||||
let email_row = adw::EntryRow::builder().title("Email addresses").build();
|
||||
let role_model = gtk4::StringList::new(&SHARE_ROLES);
|
||||
let email_row = adw::EntryRow::builder()
|
||||
.title(gettext("Email addresses"))
|
||||
.build();
|
||||
let role_model = string_list(&share_roles());
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.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);
|
||||
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()
|
||||
.label("Send Invitations")
|
||||
.label(gettext("Send Invitations"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
@@ -283,10 +309,10 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
invite_group.add(&invite_wrap);
|
||||
|
||||
let people = adw::PreferencesGroup::builder()
|
||||
.title("People with access")
|
||||
.title(gettext("People with access"))
|
||||
.build();
|
||||
let link_group = adw::PreferencesGroup::builder()
|
||||
.title("Public link")
|
||||
.title(gettext("Public link"))
|
||||
.build();
|
||||
|
||||
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));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Share")
|
||||
.title(pgettext("verb", "Share"))
|
||||
.content_width(480)
|
||||
.content_height(560)
|
||||
.child(&toolbar)
|
||||
@@ -330,7 +356,11 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
match &parsed {
|
||||
Err(bad) => {
|
||||
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(_) => {
|
||||
row.remove_css_class("error");
|
||||
@@ -367,8 +397,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
share_dialog_op(
|
||||
&state_inv,
|
||||
state_inv.target.invite(emails, role, message),
|
||||
"Invitations sent",
|
||||
"Couldn't send invitations",
|
||||
&gettext("Invitations sent"),
|
||||
&gettext("Couldn't send invitations"),
|
||||
Some(Busy::start(btn)),
|
||||
Some(Box::new(move || {
|
||||
email_clear.set_text("");
|
||||
@@ -392,12 +422,12 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
repaint_share_link(&state, link.as_ref());
|
||||
}
|
||||
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(
|
||||
&state.ui,
|
||||
"Couldn't load sharing",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't load sharing"),
|
||||
&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();
|
||||
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);
|
||||
rows.push(row.upcast());
|
||||
*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 {
|
||||
let subtitle = match entry.kind {
|
||||
ShareEntryKind::Member => "Member".to_string(),
|
||||
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(),
|
||||
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(),
|
||||
ShareEntryKind::Member => gettext("Member"),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.email)
|
||||
@@ -433,7 +463,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
entry.kind,
|
||||
ShareEntryKind::Member | ShareEntryKind::ProtonInvite
|
||||
) {
|
||||
let model = gtk4::StringList::new(&SHARE_ROLES);
|
||||
let model = string_list(&share_roles());
|
||||
let drop = gtk4::DropDown::builder()
|
||||
.model(&model)
|
||||
.selected(role_wire_to_index(&entry.role))
|
||||
@@ -450,8 +480,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
kind,
|
||||
role_index_to_wire(d.selected()).to_string(),
|
||||
),
|
||||
"Role updated",
|
||||
"Couldn't update the role",
|
||||
&gettext("Role updated"),
|
||||
&gettext("Couldn't update the role"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
@@ -459,7 +489,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
row.add_suffix(&drop);
|
||||
} else {
|
||||
let label = gtk4::Label::builder()
|
||||
.label(capitalize(&entry.role))
|
||||
.label(role_label(&entry.role).unwrap_or_else(|| capitalize(&entry.role)))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove access")
|
||||
.tooltip_text(gettext("Remove access"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
@@ -482,15 +512,19 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
"Remove Access?",
|
||||
&format!("{who} will no longer be able to open this item."),
|
||||
"Remove",
|
||||
&gettext("Remove Access?"),
|
||||
// Translators: {email} is the address of the person losing access.
|
||||
&gettext_f(
|
||||
"{email} will no longer be able to open this item.",
|
||||
&[("email", &who)],
|
||||
),
|
||||
&gettext("Remove"),
|
||||
move || {
|
||||
share_dialog_op(
|
||||
&state,
|
||||
state.target.remove_entry(id.clone(), kind),
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
&gettext("Access removed"),
|
||||
&gettext("Couldn't remove access"),
|
||||
Some(Busy::start(&button)),
|
||||
None,
|
||||
);
|
||||
@@ -516,16 +550,23 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
match link {
|
||||
Some(link) => {
|
||||
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 {
|
||||
subtitle.push_str(" · password-protected");
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(&gettext("password-protected"));
|
||||
}
|
||||
if let Some(expires) = link.expires {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000));
|
||||
}
|
||||
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)
|
||||
.build();
|
||||
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() {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text("Copy link")
|
||||
.tooltip_text(gettext("Copy link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
toast(&state_copy.ui, "Link copied");
|
||||
toast(&state_copy.ui, &gettext("Link copied"));
|
||||
});
|
||||
row.add_suffix(©);
|
||||
}
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove link")
|
||||
.tooltip_text(gettext("Remove link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
"Remove Public Link?",
|
||||
"The link stops working for everyone who has it. A new link will have \
|
||||
a different address.",
|
||||
"Remove Link",
|
||||
&gettext("Remove Public Link?"),
|
||||
&gettext("The link stops working for everyone who has it. A new link will have a different address."),
|
||||
&gettext("Remove Link"),
|
||||
move || {
|
||||
share_dialog_op(
|
||||
&state,
|
||||
state.target.remove_link(id.clone()),
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
&gettext("Public link removed"),
|
||||
&gettext("Couldn't remove the link"),
|
||||
Some(Busy::start(&button)),
|
||||
None,
|
||||
);
|
||||
@@ -582,27 +622,30 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
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()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.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);
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (optional)")
|
||||
.title(gettext("Password (optional)"))
|
||||
.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()
|
||||
.model(>k4::StringList::new(&expiry_names))
|
||||
.model(&string_list(&expiry_names))
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.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);
|
||||
let create = gtk4::Button::builder()
|
||||
.label("Create Public Link")
|
||||
.label(gettext("Create Public Link"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
@@ -666,16 +709,16 @@ pub(crate) fn share_dialog_create_link(
|
||||
drop(busy);
|
||||
match reply {
|
||||
Ok(Ok(Response::PublicLink { .. })) => {
|
||||
toast(&state.ui, "Public link created");
|
||||
toast(&state.ui, &gettext("Public link created"));
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
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(
|
||||
&state.ui,
|
||||
"Couldn't create the link",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't create the link"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -687,13 +730,14 @@ pub(crate) fn share_dialog_create_link(
|
||||
pub(crate) fn share_dialog_op(
|
||||
state: &Rc<ShareDialog>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
done: &str,
|
||||
failed: &str,
|
||||
busy: Option<Busy>,
|
||||
on_success: Option<Box<dyn Fn()>>,
|
||||
) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), req);
|
||||
let state = state.clone();
|
||||
let (done, failed) = (done.to_string(), failed.to_string());
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
drop(busy);
|
||||
@@ -702,14 +746,18 @@ pub(crate) fn share_dialog_op(
|
||||
if let Some(cb) = on_success {
|
||||
cb();
|
||||
}
|
||||
toast(&state.ui, done);
|
||||
toast(&state.ui, &done);
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -720,14 +768,18 @@ pub(crate) fn share_dialog_op(
|
||||
/// working, or that it already has.
|
||||
pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String {
|
||||
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)
|
||||
.ok()
|
||||
.and_then(|at| at.format("%x").ok())
|
||||
.and_then(|at| at.format(&format).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
format!("expires {date}")
|
||||
// Translators: {date} is the date a public link stops working.
|
||||
gettext_f("expires {date}", &[("date", &date)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -85,11 +85,19 @@ pub(crate) struct VersionsDialog {
|
||||
/// Open the Versions dialog for a file.
|
||||
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 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);
|
||||
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Version history")
|
||||
.description("Proton Drive keeps earlier versions of a file until you delete them.")
|
||||
.title(gettext("Version history"))
|
||||
.description(gettext(
|
||||
"Proton Drive keeps earlier versions of a file until you delete them.",
|
||||
))
|
||||
.build();
|
||||
|
||||
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));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Versions")
|
||||
.title(gettext("Versions"))
|
||||
.content_width(520)
|
||||
.content_height(520)
|
||||
.child(&toolbar)
|
||||
@@ -149,12 +162,12 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
|
||||
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(
|
||||
&state.ui,
|
||||
"Couldn't load versions",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't load versions"),
|
||||
&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();
|
||||
if items.is_empty() {
|
||||
let row = dim_row("No earlier versions.");
|
||||
let row = dim_row(&gettext("No earlier versions."));
|
||||
state.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.rows.borrow_mut() = rows;
|
||||
@@ -186,8 +199,15 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
// when they were saved instead.
|
||||
let (title, mut subtitle) = if item.is_active {
|
||||
(
|
||||
"Current version".to_string(),
|
||||
format!("{} · {}", activity_time(item.created), human_bytes(size)),
|
||||
gettext("Current version"),
|
||||
// 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 {
|
||||
(activity_time(item.created), human_bytes(size))
|
||||
@@ -203,7 +223,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
|
||||
let save = gtk4::Button::builder()
|
||||
.icon_name("document-save-symbolic")
|
||||
.tooltip_text("Save a copy…")
|
||||
.tooltip_text(gettext("Save a copy…"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
save.add_css_class("flat");
|
||||
@@ -215,7 +235,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
if !item.is_active {
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore this version")
|
||||
.tooltip_text(gettext("Restore this version"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
@@ -226,7 +246,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
|
||||
let delete = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete this version permanently")
|
||||
.tooltip_text(gettext("Delete this version permanently"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
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.
|
||||
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore version")
|
||||
.body(format!(
|
||||
"Make this version the current content of “{}”? The version it replaces stays in the history.",
|
||||
state.name
|
||||
.heading(gettext("Restore version"))
|
||||
// Translators: {name} is a file name.
|
||||
.body(gettext_f(
|
||||
"Make this version the current content of “{name}”? The version it replaces stays in the history.",
|
||||
&[("name", &state.name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("restore", "Restore");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("restore", &pgettext("verb", "Restore"));
|
||||
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("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()),
|
||||
// The server applies the swap in the background, so the wording
|
||||
// promises the request, not the result.
|
||||
"Restoring that version",
|
||||
"Couldn't restore that version",
|
||||
&gettext("Restoring that version"),
|
||||
&gettext("Couldn't restore that version"),
|
||||
);
|
||||
});
|
||||
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.
|
||||
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete version")
|
||||
.body("Delete this version permanently? Its content can't be recovered.")
|
||||
.heading(gettext("Delete version"))
|
||||
.body(gettext(
|
||||
"Delete this version permanently? Its content can't be recovered.",
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("delete", &pgettext("verb", "Delete"));
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -299,8 +322,8 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.delete(revision_id.clone()),
|
||||
"Version deleted",
|
||||
"Couldn't delete that version",
|
||||
&gettext("Version deleted"),
|
||||
&gettext("Couldn't delete that version"),
|
||||
);
|
||||
});
|
||||
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) {
|
||||
let window = ui_window(&state.ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save version")
|
||||
.title(gettext("Save version"))
|
||||
.initial_name(&state.name)
|
||||
.build();
|
||||
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 {
|
||||
toast_error(
|
||||
&state.ui,
|
||||
"Couldn't save that version",
|
||||
"That location isn't a local file.",
|
||||
&gettext("Couldn't save that version"),
|
||||
&gettext("That location isn't a local file."),
|
||||
);
|
||||
return;
|
||||
};
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.save_as(revision_id.clone(), dest),
|
||||
"Version saved",
|
||||
"Couldn't save that version",
|
||||
&gettext("Version saved"),
|
||||
&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);
|
||||
}
|
||||
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."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -16,8 +16,8 @@ use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
||||
use pdfs_core::opener::OpenWith;
|
||||
|
||||
use crate::Hit;
|
||||
use crate::query;
|
||||
use crate::{Hit, gettext, gettext_f};
|
||||
|
||||
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||
/// 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.
|
||||
fn title(query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(text) => format!("Drive: {text} › "),
|
||||
None => "Search Drive › ".to_string(),
|
||||
// Translators: the launcher prompt while refining a search; {query} is the text searched for. Keep the trailing space.
|
||||
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.
|
||||
fn placeholder(query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(text) => format!("(no matches for “{text}”)"),
|
||||
None => "(no pinned files — type to search)".to_string(),
|
||||
// Translators: the only row of an empty launcher list; {query} is the text searched for. Keep the parentheses.
|
||||
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)"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,14 @@ use pdfs_core::menu::{self, PromptConfig};
|
||||
use pdfs_core::opener::OpenWith;
|
||||
|
||||
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.
|
||||
const FIELD: char = '\t';
|
||||
|
||||
/// 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 {
|
||||
/// 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.
|
||||
pub(crate) fn feed(query: Option<&str>) {
|
||||
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;
|
||||
};
|
||||
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()
|
||||
.map(|arg| (*arg).to_string())
|
||||
.collect();
|
||||
argv.push(format!("--prompt={PROMPT}"));
|
||||
argv.push(format!("--prompt={}", gettext(PROMPT)));
|
||||
// `reload:` rather than `reload(…)`: the unparenthesised form runs to the
|
||||
// end of the argument, so an install path containing a bracket cannot
|
||||
// truncate the command fzf parses out of it.
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! 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");
|
||||
// Country names come translated from the iso-codes catalogs, which live in
|
||||
// the system's own locale directory.
|
||||
let _ = gettextrs::bind_textdomain_codeset(ISO_3166_DOMAIN, "UTF-8");
|
||||
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
||||
tracing::warn!("textdomain failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The iso-codes text domain that translates ISO 3166-1 country names.
|
||||
const ISO_3166_DOMAIN: &str = "iso_3166-1";
|
||||
|
||||
/// The name of the country with ISO 3166-1 code `code`, in the user's language.
|
||||
/// The English names are the iso-codes project's own, so that project's
|
||||
/// catalogs translate them; Kosovo has no ISO code of its own and is
|
||||
/// translated here. An unknown code is shown as it is.
|
||||
pub(crate) fn country_name(code: &str) -> String {
|
||||
if code == "XK" {
|
||||
return gettext("Kosovo");
|
||||
}
|
||||
match pdfs_core::places::country_name(code) {
|
||||
Some(name) => gettextrs::dgettext(ISO_3166_DOMAIN, name),
|
||||
None => code.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(¤t) {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,12 @@ use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
|
||||
};
|
||||
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
|
||||
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.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
@@ -89,7 +90,14 @@ struct DriveState {
|
||||
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
match items {
|
||||
[] => 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),
|
||||
None => String::new(),
|
||||
},
|
||||
@@ -112,8 +120,10 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
.count();
|
||||
let up = items.len() - down;
|
||||
match (down, up) {
|
||||
(d, 0) => format!("↓ {d} downloading"),
|
||||
(0, u) => format!("↑ {u} uploading"),
|
||||
// Translators: {n} files are downloading; keep the arrow.
|
||||
(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}"),
|
||||
}
|
||||
}
|
||||
@@ -144,7 +154,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
};
|
||||
let queued = pending_summary(pending_uploads, pending_changes);
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
@@ -153,15 +163,36 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
);
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
_ if paused => "Sync paused".to_string(),
|
||||
_ if failing_ops > 0 => match failing_ops {
|
||||
1 => "1 change needs attention".to_string(),
|
||||
n => format!("{n} changes need attention"),
|
||||
},
|
||||
(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)"),
|
||||
(false, Some(q)) => format!("Offline — {q}"),
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
"{n} changes need attention",
|
||||
failing_ops,
|
||||
&[],
|
||||
),
|
||||
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
|
||||
(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,
|
||||
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.
|
||||
Ok(_) => DriveState {
|
||||
line: "Mount: unexpected daemon response".into(),
|
||||
line: gettext("Mount: unexpected daemon response"),
|
||||
phase: Phase::Attention,
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
@@ -185,11 +216,19 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
Err(_) => {
|
||||
let (line, phase) = match auth::load() {
|
||||
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,
|
||||
),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => ("Not signed in".into(), Phase::SignedOut),
|
||||
Err(e) => (format!("Error: {e}"), Phase::Attention),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => (gettext("Not signed in"), Phase::SignedOut),
|
||||
Err(e) => (
|
||||
// Translators: {error} is an error message, in English.
|
||||
gettext_f("Error: {error}", &[("error", &e.to_string())]),
|
||||
Phase::Attention,
|
||||
),
|
||||
};
|
||||
DriveState {
|
||||
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.
|
||||
fn issues_label(failing: u64) -> String {
|
||||
match failing {
|
||||
1 => "1 issue — View".to_string(),
|
||||
n => format!("{n} issues — View"),
|
||||
}
|
||||
// Translators: a menu item that opens the list of failing changes.
|
||||
ngettext_f("{n} issue — View", "{n} issues — View", failing, &[])
|
||||
}
|
||||
|
||||
fn open_folder(mountpoint: &Path) {
|
||||
@@ -331,14 +368,14 @@ impl Tray for DriveTray {
|
||||
match self.state.phase {
|
||||
// Nothing to mount without a session; the app's sign-in page is the
|
||||
// way in.
|
||||
Phase::SignedOut => items.push(item("Sign In…", |_| open_manager(&[]))),
|
||||
_ => items.push(item("Open Proton Drive", |_| open_manager(&[]))),
|
||||
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
||||
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
||||
}
|
||||
|
||||
if self.state.mounted {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Open Folder".into(),
|
||||
label: gettext("Open Folder"),
|
||||
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -349,7 +386,11 @@ impl Tray for DriveTray {
|
||||
let paused = self.state.paused;
|
||||
items.push(
|
||||
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| {
|
||||
let request = Request::SetSyncPaused {
|
||||
paused: !paused,
|
||||
@@ -367,22 +408,22 @@ impl Tray for DriveTray {
|
||||
);
|
||||
} else if self.state.phase == Phase::Disconnected {
|
||||
// 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();
|
||||
this.state.line = "Connecting…".into();
|
||||
this.state.line = gettext("Connecting…");
|
||||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
// Stopping unmounts the drive, so the app asks first; the tray has no
|
||||
// GTK of its own to ask with.
|
||||
items.push(item("Stop Proton Drive…", |_| {
|
||||
items.push(item(&gettext("Stop Proton Drive…"), |_| {
|
||||
open_manager(&["--confirm-stop"])
|
||||
}));
|
||||
} else {
|
||||
items.push(item("Quit", |_| std::process::exit(0)));
|
||||
items.push(item(&gettext("Quit"), |_| std::process::exit(0)));
|
||||
}
|
||||
items
|
||||
}
|
||||
@@ -394,6 +435,7 @@ fn main() -> Result<()> {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
i18n::init();
|
||||
|
||||
let dirs = AppDirs::new().context("resolve app dirs")?;
|
||||
// Hidden from its own menu: the session's autostart still runs the tray, and
|
||||
|
||||
@@ -23,6 +23,8 @@ use pdfs_core::menu::PromptMode;
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
mod activation;
|
||||
mod i18n;
|
||||
use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
||||
mod dmenu;
|
||||
mod fzf;
|
||||
mod query;
|
||||
@@ -51,11 +53,13 @@ enum Filter {
|
||||
|
||||
/// The chip row, in Tab-cycle order.
|
||||
const FILTERS: [(Filter, &str); 5] = [
|
||||
(Filter::All, "All"),
|
||||
(Filter::Folders, "Folders"),
|
||||
(Filter::Documents, "Documents"),
|
||||
(Filter::Images, "Images"),
|
||||
(Filter::Media, "Media"),
|
||||
// Translators: a filter chip that shows every kind of result.
|
||||
(Filter::All, gettext_noop("All")),
|
||||
(Filter::Folders, gettext_noop("Folders")),
|
||||
(Filter::Documents, gettext_noop("Documents")),
|
||||
(Filter::Images, gettext_noop("Images")),
|
||||
// Translators: a filter chip for audio and video files.
|
||||
(Filter::Media, gettext_noop("Media")),
|
||||
];
|
||||
|
||||
impl Filter {
|
||||
@@ -162,9 +166,13 @@ impl Hit {
|
||||
None => {
|
||||
let parent = parent_of(&h.path);
|
||||
if parent.is_empty() {
|
||||
"My files".to_string()
|
||||
gettext("My files")
|
||||
} 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()),
|
||||
)
|
||||
.init();
|
||||
i18n::init();
|
||||
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
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()
|
||||
.application(app)
|
||||
.title("Search Proton Drive")
|
||||
.title(gettext("Search Proton Drive"))
|
||||
.default_width(720)
|
||||
.default_height(540)
|
||||
.resizable(false)
|
||||
@@ -527,7 +536,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
search_bar.append(&search_icon);
|
||||
|
||||
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)
|
||||
.build();
|
||||
entry.add_css_class("search-entry");
|
||||
@@ -537,7 +546,8 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
spinner.set_visible(false);
|
||||
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");
|
||||
search_bar.append(&esc);
|
||||
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);
|
||||
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 local_section = Section::new("This computer", "drive-harddisk-symbolic");
|
||||
let local_section = Section::new(&gettext("This computer"), "drive-harddisk-symbolic");
|
||||
search_section.attach(&results);
|
||||
drive_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()
|
||||
.icon_name("system-search-symbolic")
|
||||
.title("No results")
|
||||
.title(gettext("No results"))
|
||||
.build();
|
||||
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.
|
||||
let offline = adw::StatusPage::builder()
|
||||
.icon_name("network-offline-symbolic")
|
||||
.title("Proton Drive is not running")
|
||||
.description("Start the mount daemon to search your Drive.")
|
||||
.title(gettext("Proton Drive is not running"))
|
||||
.description(gettext("Start the mount daemon to search your Drive."))
|
||||
.build();
|
||||
offline.add_css_class("compact");
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
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);
|
||||
footer.add_css_class("footer");
|
||||
let hint = gtk4::Label::builder()
|
||||
.label("Connecting…")
|
||||
.label(gettext("Connecting…"))
|
||||
.xalign(0.0)
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
hint.add_css_class("footer-text");
|
||||
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");
|
||||
footer.append(&keys);
|
||||
card.append(&footer);
|
||||
@@ -646,7 +657,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
|
||||
for (filter, label) in FILTERS {
|
||||
let chip = gtk4::ToggleButton::builder()
|
||||
.label(label)
|
||||
.label(gettext(label))
|
||||
.active(filter == Filter::All)
|
||||
.build();
|
||||
chip.add_css_class("chip");
|
||||
@@ -852,7 +863,7 @@ impl Ui {
|
||||
/// 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.
|
||||
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 ui = self.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
@@ -865,7 +876,7 @@ impl Ui {
|
||||
}
|
||||
_ => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -879,7 +890,7 @@ impl Ui {
|
||||
self.spinner.stop();
|
||||
self.spinner.set_visible(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");
|
||||
}
|
||||
|
||||
@@ -1072,7 +1083,8 @@ impl Ui {
|
||||
visible.extend(local);
|
||||
if searching {
|
||||
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.local_section.set_rows(&[], None);
|
||||
} else {
|
||||
@@ -1081,8 +1093,10 @@ impl Ui {
|
||||
.iter()
|
||||
.take_while(|hit| matches!(hit, Hit::Drive(_)))
|
||||
.count();
|
||||
self.drive_section
|
||||
.set_rows(&visible[..drive_count], Some("Pinned in Proton Drive"));
|
||||
self.drive_section.set_rows(
|
||||
&visible[..drive_count],
|
||||
Some(&gettext("Pinned in Proton Drive")),
|
||||
);
|
||||
self.local_section.set_rows(&visible[drive_count..], None);
|
||||
}
|
||||
|
||||
@@ -1093,18 +1107,20 @@ impl Ui {
|
||||
*self.rendered_query.borrow_mut() = query;
|
||||
|
||||
if total == 0 {
|
||||
self.placeholder.set_title(if searching {
|
||||
"No results"
|
||||
self.placeholder.set_title(&if searching {
|
||||
gettext("No results")
|
||||
} else {
|
||||
"Search your Drive"
|
||||
gettext("Search your Drive")
|
||||
});
|
||||
self.placeholder
|
||||
.set_description(Some(match (searching, self.indexing.get()) {
|
||||
(true, true) => {
|
||||
"Still indexing this computer — local results will fill in shortly."
|
||||
}
|
||||
(true, false) => "Try a different search, or another filter.",
|
||||
_ => "Start typing to search Proton Drive and the files on this computer.",
|
||||
.set_description(Some(&match (searching, self.indexing.get()) {
|
||||
(true, true) => gettext(
|
||||
"Still indexing this computer — local results will fill in shortly.",
|
||||
),
|
||||
(true, false) => gettext("Try a different search, or another filter."),
|
||||
_ => gettext(
|
||||
"Start typing to search Proton Drive and the files on this computer.",
|
||||
),
|
||||
}));
|
||||
self.stack.set_visible_child_name("empty");
|
||||
self.cursor.set(None);
|
||||
@@ -1129,18 +1145,23 @@ impl Ui {
|
||||
fn status_text(&self, total: usize, searching: bool) -> String {
|
||||
if !searching {
|
||||
return match total {
|
||||
0 => "No pinned files".to_string(),
|
||||
1 => "1 pinned file".to_string(),
|
||||
n => format!("{n} pinned files"),
|
||||
0 => gettext("No pinned files"),
|
||||
n => ngettext_f("{n} pinned file", "{n} pinned files", n as u64, &[]),
|
||||
};
|
||||
}
|
||||
let drive = self.drive_count();
|
||||
let local = total - drive;
|
||||
let mut text = format!("{drive} in Drive · {local} on this computer");
|
||||
let drive = self.drive_count().to_string();
|
||||
let local = (total - self.drive_count()).to_string();
|
||||
let args = [("drive", drive.as_str()), ("local", local.as_str())];
|
||||
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.
|
||||
@@ -1272,7 +1293,9 @@ impl Ui {
|
||||
self.opening.set(true);
|
||||
self.spinner.set_visible(true);
|
||||
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(
|
||||
self.socket.clone(),
|
||||
@@ -1293,9 +1316,13 @@ impl Ui {
|
||||
ui.dismiss();
|
||||
}
|
||||
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()
|
||||
.map(|rel| rel.display().to_string())
|
||||
}) {
|
||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||
// Translators: the user's home folder, as the location of a result.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -1418,16 +1447,21 @@ fn parent_of(path: &str) -> String {
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const UNITS: [(u64, &str); 3] = [
|
||||
(1024 * 1024 * 1024, "GB"),
|
||||
(1024 * 1024, "MB"),
|
||||
(1024, "KB"),
|
||||
// Translators: a file size in gigabytes; {size} is a number such as "4.2".
|
||||
(1024 * 1024 * 1024, gettext_noop("{size} GB")),
|
||||
// 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 {
|
||||
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.
|
||||
@@ -1437,12 +1471,18 @@ fn format_age(epoch_secs: i64) -> String {
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let diff = now - epoch_secs;
|
||||
match diff {
|
||||
d if d < 60 => "now".to_string(),
|
||||
d if d < 3600 => format!("{}m", d / 60),
|
||||
d if d < 86_400 => format!("{}h", d / 3600),
|
||||
d if d < 2_592_000 => format!("{}d", d / 86_400),
|
||||
d if d < 31_536_000 => format!("{}mo", d / 2_592_000),
|
||||
d => format!("{}y", d / 31_536_000),
|
||||
// Translators: the age of a file modified under a minute ago.
|
||||
d if d < 60 => pgettext("age", "now"),
|
||||
// Translators: a compact file age; {count} is a number of minutes.
|
||||
d if d < 3600 => gettext_f("{count}m", &[("count", &(d / 60).to_string())]),
|
||||
// Translators: a compact file age; {count} is a number of hours.
|
||||
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())]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use pdfs_core::control::{
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
||||
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
|
||||
/// 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
|
||||
/// terminal rather than a status page.
|
||||
pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
||||
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)]
|
||||
|
||||
@@ -12,6 +12,67 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved"
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has been restarted against the
|
||||
affected copies yet.
|
||||
**Found:** 2026-09-27. The Activity page listed the same six conflict copies every day, e.g.
|
||||
`Conflict [EMBER] Oshi no Ko S2 - 12 (sync-conflict 1786815864).mkv` with `Differs from … · 24 times
|
||||
since Sep 20, 13:13 · Resolved`. `find` showed all six copies still on disk, in `~/Videos` and
|
||||
`~/Documents`. Both are on-demand sync folders. `pdfs conflicts` answered `No conflicts.`. The
|
||||
journal showed the daemon mounting at 17:29:33, and the entries were stamped 17:30. That is the
|
||||
sweep's 30-second warmup.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/sweep.rs` (`flag_conflict`, `sweep_conflicts_once`),
|
||||
`crates/pdfs-fuse/src/conflicts.rs` (`list_conflicts`), `crates/pdfs-fuse/src/control.rs`
|
||||
(`ResolveConflict`).
|
||||
|
||||
**Cause.** There were two faults.
|
||||
|
||||
- The sweep kept its "already flagged" set only in memory. Every daemon start forgot it, so the
|
||||
first pass logged every open conflict again.
|
||||
- The sweep flags copies anywhere in the account's own tree. `list_conflicts` only reported copies
|
||||
below My Files, and resolving only accepted paths under the primary mount. A copy in a synced
|
||||
folder was logged but never listed, so the GUI read it as resolved. It also could not be
|
||||
resolved from the GUI or the CLI.
|
||||
|
||||
**Fix.** `Core::conflict_path` names a copy by its mountpoint-relative path under My Files, or
|
||||
by its absolute local path inside a synced folder. It reuses the roots that search already
|
||||
resolves hits through. The listing uses it, and the sweep flags only copies it can name. So the
|
||||
feed never holds a conflict the listing cannot answer for. `ResolveConflict` routes an absolute
|
||||
path to the on-demand mount that serves it. For a copy in a mirror folder, it says to edit the
|
||||
local file instead. Before logging, `flag_conflict` checks the activity log
|
||||
(`Db::activity_has`), so a restart does not log the same copy again. The copy's name carries its
|
||||
own timestamp, so the name identifies the copy. No schema change.
|
||||
Regression test: `activity_has_finds_only_matching_kind_and_target`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
|
||||
|
||||
@@ -11,6 +11,116 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.3.0] — 2026-09-28
|
||||
|
||||
Schema: **33**. Photos gain their location; the first timeline refresh after updating reads
|
||||
the metadata of the whole library once (nothing is downloaded), and the Places view shows how
|
||||
far it has got.
|
||||
|
||||
### Added
|
||||
- **Places.** A third Photos view groups photos by the town they were taken in, as cards or on a
|
||||
world map. Towns are looked up offline in bundled GeoNames data (CC BY 4.0), and the map is
|
||||
drawn offline from Natural Earth land outlines (public domain), so no map tiles are fetched.
|
||||
Install `iso-codes`, a new optional dependency, for country names in your language.
|
||||
- **On this day.** A strip at the top of the timeline shows a card for every earlier year with
|
||||
photos taken on today's date.
|
||||
- **Find duplicates.** Photos can list every set of byte-identical copies and move the copies
|
||||
you don't keep to Trash. The copy in the most albums is kept by default.
|
||||
- **Photos in no album.** A filter narrows the timeline to photos no album holds yet.
|
||||
- **One Filters popover.** Favorites, "in no album" and the month jump now share a Filters
|
||||
button, which shows how many filters are on and can clear them all at once.
|
||||
- **Zoom-level grouping and range select.** Zooming out groups the timeline by month, then by
|
||||
year. Shift+click selects a range, and dragging across the timeline selects what it sweeps.
|
||||
- **Slideshow.** The photo viewer plays the loaded photos full screen, with F5 or a top-bar
|
||||
button.
|
||||
- **App icon in the app.** The sidebar header and the login page show the app's own icon.
|
||||
|
||||
### Fixed
|
||||
- **Photos grid.** The timeline is laid out as square tiles that fill the window's width
|
||||
exactly, and a theme's own `gtk.css` can no longer pad the tiles. It stays at its top, and at
|
||||
the photo you were looking at, when the window is resized or zoomed.
|
||||
- **Timeline shaking near the top.** Scrolling up to the "On this day" strip no longer makes the
|
||||
whole page jump between two heights.
|
||||
- **Filters while loading.** Changing a filter while a page was still loading could leave the
|
||||
timeline showing the old filter.
|
||||
|
||||
## [2.2.2] — 2026-09-27
|
||||
|
||||
Schema: **32** (unchanged).
|
||||
|
||||
### Fixed
|
||||
- **Conflicts in synced folders.** A conflict copy inside a synced folder is now listed under
|
||||
Needs attention and can be resolved there or with `pdfs conflicts`. It used to be logged again
|
||||
after every restart and marked "Resolved" while it was still on disk (B99).
|
||||
|
||||
## [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).
|
||||
|
||||
### Added
|
||||
@@ -970,6 +1080,11 @@ 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
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[2.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.3.0
|
||||
[2.2.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
|
||||
[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.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
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,26 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
|
||||
<defs>
|
||||
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8B72FF"/>
|
||||
<stop offset="1" stop-color="#5A38E8"/>
|
||||
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A48BFF"/>
|
||||
<stop offset="0.55" stop-color="#7552FF"/>
|
||||
<stop offset="1" stop-color="#5B36F2"/>
|
||||
</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>
|
||||
|
||||
<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) -->
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="64" cy="42" r="20"/>
|
||||
<circle cx="44" cy="52" r="14"/>
|
||||
<circle cx="84" cy="52" r="14"/>
|
||||
<rect x="32" y="50" width="64" height="16" rx="8"/>
|
||||
</g>
|
||||
|
||||
<!-- pull: files arrive only when opened -->
|
||||
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 75v17"/>
|
||||
<path d="M53 84l11 11 11-11"/>
|
||||
<path d="M45 108h38" stroke-opacity="0.55"/>
|
||||
</g>
|
||||
<!-- cloud shadow, then the cloud itself -->
|
||||
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 990 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 899 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -9,17 +9,19 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=2.0.0
|
||||
pkgver=2.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
|
||||
makedepends=('cargo' 'git')
|
||||
makedepends=('cargo' 'git' 'gettext')
|
||||
provides=('pdfs')
|
||||
# Files-on-demand mount + keyring need these at runtime.
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'xdg-utils: "Open folder" action from the tray'
|
||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||
@@ -73,5 +75,8 @@ package() {
|
||||
install -Dm644 "packaging/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"
|
||||
}
|
||||
@@ -21,6 +21,22 @@ built packages never reach the AUR.
|
||||
|
||||
## 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
|
||||
`proton-drive-linux-<version>-x86_64.tar.gz` release asset exist.
|
||||
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).
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 1.11.1
|
||||
pkgver = 2.2.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
@@ -14,29 +14,31 @@ pkgbase = proton-drive-for-linux-bin
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = iso-codes: country names in your language under Places
|
||||
optdepends = gnome-keyring: 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 = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=1.11.1
|
||||
provides = proton-drive-linux=1.11.1
|
||||
provides = proton-drive-for-linux=2.2.2
|
||||
provides = proton-drive-linux=2.2.2
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !strip
|
||||
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 = 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-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.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/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 = LICENSE-1.11.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/LICENSE
|
||||
sha256sums = 5fa20a17c0068ad1d74c51eb081b7e36c24b6bf18c4cb0e0b90d2e1c1d40c56f
|
||||
sha256sums = 034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b
|
||||
sha256sums = 5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8
|
||||
sha256sums = 2bfdef503b4ac02f047adb022e02e81099970589f9d776c821b7751f1be6c12a
|
||||
source = proton-drive-linux-2.2.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.2/proton-drive-linux-2.2.2-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/proton-drive.service
|
||||
source = LICENSE-2.2.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/LICENSE
|
||||
sha256sums = a8b856464c3c235699def1b22b5b07ad099f61257c557f034bcc10ca9a76a890
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60
|
||||
sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=1.11.1
|
||||
pkgver=2.2.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
@@ -10,6 +10,8 @@ license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
@@ -29,10 +31,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"
|
||||
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||
sha256sums=('5fa20a17c0068ad1d74c51eb081b7e36c24b6bf18c4cb0e0b90d2e1c1d40c56f'
|
||||
'034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b'
|
||||
'5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8'
|
||||
'2bfdef503b4ac02f047adb022e02e81099970589f9d776c821b7751f1be6c12a'
|
||||
sha256sums=('a8b856464c3c235699def1b22b5b07ad099f61257c557f034bcc10ca9a76a890'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
'25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60'
|
||||
'02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c')
|
||||
|
||||
@@ -53,5 +55,9 @@ package() {
|
||||
install -Dm644 "$srcdir/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"
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
pkgbase = proton-drive-for-linux-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.2.r0.g0b81b76
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
makedepends = git
|
||||
makedepends = gettext
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
@@ -16,13 +17,15 @@ pkgbase = proton-drive-for-linux-git
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = iso-codes: country names in your language under Places
|
||||
optdepends = gnome-keyring: 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 = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
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-linux=1.11.1.r0.g860d29c
|
||||
provides = proton-drive-for-linux=2.2.2.r0.g0b81b76
|
||||
provides = proton-drive-linux=2.2.2.r0.g0b81b76
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=1.11.1.r0.g860d29c
|
||||
pkgver=2.2.2.r0.g0b81b76
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
@@ -9,8 +9,10 @@ url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
makedepends=('cargo' 'git')
|
||||
makedepends=('cargo' 'git' 'gettext')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
@@ -73,6 +75,9 @@ package() {
|
||||
install -Dm644 packaging/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 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 1.11.1
|
||||
pkgver = 2.2.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
makedepends = gettext
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
@@ -15,18 +16,20 @@ pkgbase = proton-drive-for-linux
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = iso-codes: country names in your language under Places
|
||||
optdepends = gnome-keyring: 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 = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
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.2
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
conflicts = proton-drive-for-linux-git
|
||||
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
|
||||
sha256sums = 1481379b2b70102ea6345a00b1092f73511f6ba99d6fba9a3f185ad6d28caaf2
|
||||
source = proton-drive-for-linux-2.2.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.2.tar.gz
|
||||
sha256sums = 23d9a3477f1dccc399bf8e90754c1be64302da86c1a220790e543d2e33de4d26
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=1.11.1
|
||||
pkgver=2.2.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
@@ -9,8 +9,10 @@ url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
makedepends=('cargo')
|
||||
makedepends=('cargo' 'gettext')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
@@ -21,7 +23,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.
|
||||
options=('!lto')
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('1481379b2b70102ea6345a00b1092f73511f6ba99d6fba9a3f185ad6d28caaf2')
|
||||
sha256sums=('23d9a3477f1dccc399bf8e90754c1be64302da86c1a220790e543d2e33de4d26')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
@@ -68,6 +70,9 @@ package() {
|
||||
install -Dm644 packaging/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 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
@@ -21,6 +21,11 @@ for pkg in proton-drive-for-linux proton-drive-for-linux-bin; do
|
||||
(cd "$pkg" && updpkgsums)
|
||||
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
|
||||
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||
echo "updated $pkg"
|
||||
|
||||
@@ -2,7 +2,23 @@
|
||||
Type=Application
|
||||
Name=Proton Drive for Linux (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[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
|
||||
Icon=io.narl.proton-drive-linux
|
||||
Terminal=false
|
||||
|
||||
@@ -2,11 +2,35 @@
|
||||
Type=Application
|
||||
Name=Proton Drive for Linux
|
||||
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[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
|
||||
Icon=io.narl.proton-drive-linux
|
||||
Terminal=false
|
||||
Categories=Network;FileTransfer;GTK;
|
||||
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
|
||||
SingleMainWindow=true
|
||||
@@ -27,6 +27,7 @@ BuildRequires: libsecret-devel
|
||||
BuildRequires: dbus-devel
|
||||
BuildRequires: glib2-devel
|
||||
BuildRequires: webkitgtk6.0-devel
|
||||
BuildRequires: gettext
|
||||
|
||||
Requires: fuse3
|
||||
Requires: gtk4
|
||||
@@ -38,6 +39,7 @@ Requires: xdg-utils
|
||||
|
||||
# DE-specific; do not Require a single desktop environment.
|
||||
Recommends: gnome-keyring
|
||||
Recommends: iso-codes
|
||||
Recommends: gnome-shell-extension-appindicator
|
||||
Recommends: kwallet
|
||||
|
||||
@@ -80,6 +82,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
|
||||
install -D -m0644 %{git_dir}/packaging/proton-drive.service \
|
||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
@@ -91,6 +94,7 @@ install -D -m0644 %{git_dir}/packaging/proton-drive.service \
|
||||
%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
|
||||
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||
/usr/lib/systemd/user/proton-drive.service
|
||||
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
|
||||
|
||||
%changelog
|
||||
* Sun Jul 19 2026 Local Packager - 0.4.0-1
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
de
|
||||
en_GB
|
||||
es
|
||||
fr
|
||||
it
|
||||
nl
|
||||
pl
|
||||
pt_BR
|
||||
@@ -0,0 +1,30 @@
|
||||
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/duplicates.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/places.rs
|
||||
crates/pdfs-gui/src/app/pages/places_map.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
|
||||
@@ -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"
|
||||