Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f100dbe5e | ||
|
|
7c5ef269cd | ||
|
|
f13b6fc0ef | ||
|
|
3d61fc637b | ||
|
|
889b2aa4a9 | ||
|
|
8bd3867cb3 | ||
|
|
9577387c2f | ||
|
|
7a087b8827 | ||
|
|
7099106802 | ||
|
|
43385d36ee | ||
|
|
4b97f401cb | ||
|
|
af869944cf | ||
|
|
4b90abf3b5 | ||
|
|
61266fc9b1 | ||
|
|
d18655538e | ||
|
|
da7ea0ec56 | ||
|
|
127b71efd0 | ||
|
|
2339b17d97 | ||
|
|
bd4f797d1f | ||
|
|
e872cad058 | ||
|
|
081c8fb889 | ||
|
|
da35a05b84 | ||
|
|
a90eeecda9 | ||
|
|
c98ed0d83b | ||
|
|
c46b2c1082 | ||
|
|
fde9c2d271 | ||
|
|
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 | ||
|
|
69b5ccb28b | ||
|
|
af84ce2168 | ||
|
|
52651caf8e | ||
|
|
0b63a65661 | ||
|
|
7f84ff7dea | ||
|
|
15985f8684 | ||
|
|
dfc48375e2 | ||
|
|
2dde69525e | ||
|
|
f6136ce344 | ||
|
|
4fa61a14c3 | ||
|
|
fe8fb8cdcf | ||
|
|
f457fa68fc | ||
|
|
be8464f4a0 | ||
|
|
a5d29969dc | ||
|
|
43d1afe705 | ||
|
|
29e5769dec | ||
|
|
0fbc6421ba | ||
|
|
abf76cd5e8 | ||
|
|
8d0a575a7c | ||
|
|
91a5f21340 | ||
|
|
8351382ef2 | ||
|
|
b58b60010e | ||
|
|
a86c3e586d | ||
|
|
fdda35918f | ||
|
|
8bc52edeb4 | ||
|
|
792eee75a4 | ||
|
|
067561564e | ||
|
|
cf9e1665ca | ||
|
|
cc308ac25a | ||
|
|
690b91deab | ||
|
|
9cd8a196aa |
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@v7
|
||||||
|
with:
|
||||||
|
# The updated recipes are committed back to main, not to the tag.
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Create build user
|
||||||
|
run: |
|
||||||
|
useradd --create-home builder
|
||||||
|
chown -R builder: "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
|
- name: Configure AUR access
|
||||||
|
env:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
install -d -m700 -o builder -g builder "$HOME/.ssh"
|
||||||
|
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
|
||||||
|
cat > "$HOME/.ssh/config" <<'EOF'
|
||||||
|
Host aur.archlinux.org
|
||||||
|
IdentityFile ~/.ssh/aur
|
||||||
|
User aur
|
||||||
|
EOF
|
||||||
|
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
||||||
|
ssh-keyscan -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts"
|
||||||
|
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
||||||
|
chown -R builder: "$HOME/.ssh"
|
||||||
|
chmod 600 "$HOME/.ssh/aur"
|
||||||
|
runuser -u builder -- git config --global user.name 'Nils Pukropp'
|
||||||
|
runuser -u builder -- git config --global user.email 'contact@narl.io'
|
||||||
|
|
||||||
|
- name: Update release packages
|
||||||
|
if: env.VERSION != ''
|
||||||
|
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
|
||||||
|
|
||||||
|
- name: Refresh the -git metadata
|
||||||
|
if: env.VERSION == ''
|
||||||
|
working-directory: packaging/aur/proton-drive-for-linux-git
|
||||||
|
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
|
||||||
|
|
||||||
|
- name: Publish to the AUR
|
||||||
|
run: |
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
runuser -u builder -- packaging/aur/push.sh
|
||||||
|
else
|
||||||
|
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit the updated recipes to main
|
||||||
|
if: env.VERSION != ''
|
||||||
|
run: |
|
||||||
|
runuser -u builder -- git add packaging/aur
|
||||||
|
if ! runuser -u builder -- git diff --cached --quiet; then
|
||||||
|
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
|
||||||
|
runuser -u builder -- git push origin HEAD:main
|
||||||
|
fi
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install native dependencies
|
- name: Install native dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -32,8 +32,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# Tags can only restore caches saved on main, so only main saves one. The
|
||||||
|
# release workflow restores this key for its quality gates.
|
||||||
- uses: swatinem/rust-cache@v2
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: debug
|
||||||
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Formatting
|
- name: Formatting
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
@@ -57,3 +63,37 @@ jobs:
|
|||||||
|
|
||||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||||
|
|
||||||
|
# Keeps the release-profile dependencies cached on main, where the release
|
||||||
|
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
||||||
|
release-cache:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Install native dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
libfuse3-dev \
|
||||||
|
libgtk-4-dev \
|
||||||
|
libadwaita-1-dev \
|
||||||
|
libwebkitgtk-6.0-dev \
|
||||||
|
libsecret-1-dev \
|
||||||
|
libdbus-1-dev
|
||||||
|
|
||||||
|
- name: Install minimum supported Rust
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: release
|
||||||
|
|
||||||
|
- name: Release build
|
||||||
|
run: cargo build --release --locked
|
||||||
@@ -10,14 +10,13 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-release:
|
checks:
|
||||||
name: Build and Release
|
name: Release quality gates
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment: 'rpm signing'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -29,18 +28,22 @@ jobs:
|
|||||||
libadwaita-1-dev \
|
libadwaita-1-dev \
|
||||||
libwebkitgtk-6.0-dev \
|
libwebkitgtk-6.0-dev \
|
||||||
libsecret-1-dev \
|
libsecret-1-dev \
|
||||||
libdbus-1-dev \
|
libdbus-1-dev
|
||||||
desktop-file-utils \
|
|
||||||
rpm
|
|
||||||
|
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# A tag can read the caches of main but its own caches are useless to the next
|
||||||
|
# tag, so this only restores what CI saved on main.
|
||||||
- name: Cache Cargo target
|
- name: Cache Cargo target
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: debug
|
||||||
|
save-if: false
|
||||||
|
|
||||||
- name: Verify tag and manifest versions
|
- name: Verify tag and manifest versions
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -59,6 +62,43 @@ jobs:
|
|||||||
cargo test --workspace --locked
|
cargo test --workspace --locked
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
|
|
||||||
|
build-release:
|
||||||
|
name: Build packages
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
environment: 'rpm signing'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
libfuse3-dev \
|
||||||
|
libgtk-4-dev \
|
||||||
|
libadwaita-1-dev \
|
||||||
|
libwebkitgtk-6.0-dev \
|
||||||
|
libsecret-1-dev \
|
||||||
|
libdbus-1-dev \
|
||||||
|
desktop-file-utils \
|
||||||
|
gettext \
|
||||||
|
rpm
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# Restores the release-profile dependencies that CI builds on main.
|
||||||
|
- name: Cache Cargo target
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: release
|
||||||
|
save-if: false
|
||||||
|
|
||||||
- name: Build release binaries
|
- name: Build release binaries
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
@@ -84,6 +124,7 @@ jobs:
|
|||||||
cp target/release/pdfs-tray dist/binaries/
|
cp target/release/pdfs-tray dist/binaries/
|
||||||
cp target/release/pdfs-app dist/binaries/
|
cp target/release/pdfs-app dist/binaries/
|
||||||
cp target/release/pdfs-prompt dist/binaries/
|
cp target/release/pdfs-prompt dist/binaries/
|
||||||
|
po/build.sh dist/binaries/locale
|
||||||
|
|
||||||
# Create tarball
|
# Create tarball
|
||||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||||
@@ -110,6 +151,7 @@ jobs:
|
|||||||
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
||||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
|
po/build.sh deb/usr/share/locale
|
||||||
|
|
||||||
# Calculate Installed-Size in KB for the control file
|
# Calculate Installed-Size in KB for the control file
|
||||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||||
@@ -149,6 +191,7 @@ jobs:
|
|||||||
--define "_topdir $PWD/rpmbuild" \
|
--define "_topdir $PWD/rpmbuild" \
|
||||||
--define "_sourcedir $PWD" \
|
--define "_sourcedir $PWD" \
|
||||||
--define "git_dir $PWD" \
|
--define "git_dir $PWD" \
|
||||||
|
--define "prebuilt 1" \
|
||||||
--define "version ${RPM_VERSION}" \
|
--define "version ${RPM_VERSION}" \
|
||||||
--define "_dbpath $PWD/rpmbuild/db" \
|
--define "_dbpath $PWD/rpmbuild/db" \
|
||||||
packaging/proton-drive-linux.spec
|
packaging/proton-drive-linux.spec
|
||||||
@@ -178,7 +221,7 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
name: proton-drive-linux-builds
|
||||||
path: |
|
path: |
|
||||||
@@ -187,9 +230,20 @@ jobs:
|
|||||||
*.rpm
|
*.rpm
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Create GitHub Release
|
||||||
|
needs: [checks, build-release]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download packages
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: proton-drive-linux-builds
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
uses: softprops/action-gh-release@v3
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
@@ -199,3 +253,13 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
aur:
|
||||||
|
name: Publish AUR packages
|
||||||
|
needs: publish
|
||||||
|
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 }}
|
||||||
@@ -6,9 +6,10 @@ deliberately high — everything else is ordinary Rust review.
|
|||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
|
- Read [`AGENTS.md`](AGENTS.md) for repository conventions,
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source,
|
||||||
together.
|
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||||
|
together. [`docs/README.md`](docs/README.md) indexes the rest.
|
||||||
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||||
already be tracked there with context.
|
already be tracked there with context.
|
||||||
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||||
@@ -23,8 +24,9 @@ cargo build --workspace
|
|||||||
```
|
```
|
||||||
|
|
||||||
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
||||||
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
|
development packages listed in [`docs/INSTALL.md`](docs/INSTALL.md#build-dependencies). Minimum
|
||||||
version is 1.96.
|
supported Rust version is 1.96. [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#running-from-source)
|
||||||
|
explains how to run your build next to, or instead of, the installed service.
|
||||||
|
|
||||||
## Quality gates
|
## Quality gates
|
||||||
|
|
||||||
@@ -51,6 +53,13 @@ use a dedicated test account, never one holding data you cannot lose.
|
|||||||
control-socket work stays off GTK's main thread.
|
control-socket work stays off GTK's main thread.
|
||||||
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
||||||
them opportunistically.
|
them opportunistically.
|
||||||
|
- **The GUI is fully translated.** Wrap every user-visible string in the helpers from
|
||||||
|
`crates/pdfs-gui/src/i18n.rs`, run `po/update.sh`, and translate the new entries in every
|
||||||
|
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
|
||||||
|
The CLI and daemon messages stay in English.
|
||||||
|
- **Document what users see.** A new command, setting or behaviour belongs in the matching page
|
||||||
|
under `docs/` (user guide, CLI reference or configuration) and in the `[Unreleased]` section of
|
||||||
|
[`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
||||||
|
|
||||||
## Commits and pull requests
|
## Commits and pull requests
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,12 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1814,6 +1820,26 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gettext-rs"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf"
|
||||||
|
dependencies = [
|
||||||
|
"gettext-sys",
|
||||||
|
"locale_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gettext-sys"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"temp-dir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ghash"
|
name = "ghash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -2829,6 +2855,19 @@ version = "0.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "locale_config"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"objc",
|
||||||
|
"objc-foundation",
|
||||||
|
"regex",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2868,6 +2907,15 @@ version = "0.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3189,6 +3237,35 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||||
|
dependencies = [
|
||||||
|
"malloc_buf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc-foundation"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||||
|
dependencies = [
|
||||||
|
"block",
|
||||||
|
"objc",
|
||||||
|
"objc_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc_id"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
||||||
|
dependencies = [
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocb3"
|
name = "ocb3"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3312,6 +3389,31 @@ dependencies = [
|
|||||||
"system-deps 9.0.0",
|
"system-deps 9.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pangocairo"
|
||||||
|
version = "0.22.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd199a0fef965b3ebda91a929a1ab5e7729538882c68e8de38e1904d2e094757"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"glib",
|
||||||
|
"pango",
|
||||||
|
"pangocairo-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pangocairo-sys"
|
||||||
|
version = "0.22.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a649a616d67ae68539bc85f00fb45d36fba742ed20fc20e6712a15d8ee52bd3f"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps 9.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -3372,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.11.1"
|
version = "2.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3387,7 +3489,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.11.1"
|
version = "2.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
@@ -3411,7 +3513,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.11.1"
|
version = "2.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"fuser",
|
"fuser",
|
||||||
@@ -3431,15 +3533,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.11.1"
|
version = "2.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"gettext-rs",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"ksni",
|
"ksni",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
|
"pangocairo",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3668,9 +3773,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.6.5"
|
version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46cbe05623d9b73bd48b1473f42d43180142b2ee676b1cb9e9a06b562c60ba26"
|
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3695,9 +3800,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.6.5"
|
version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7310d93bed440dfe4fd60306923f6a491901ee0d618b65ace4e5e22ff0387c1f"
|
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4055,6 +4160,18 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.18"
|
version = "0.4.18"
|
||||||
@@ -4796,6 +4913,12 @@ version = "0.13.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "temp-dir"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.11.1"
|
version = "2.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.5" }
|
proton-sdk = { version = "0.7.0" }
|
||||||
proton-drive-rs = { version = "0.6.5" }
|
proton-drive-rs = { version = "0.7.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -4,488 +4,162 @@
|
|||||||
|
|
||||||
# Proton Drive for Linux
|
# Proton Drive for Linux
|
||||||
|
|
||||||
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
|
**Proton Drive on the Linux desktop.** Your files as a folder that downloads on demand, synced
|
||||||
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
|
local folders, Photos, sharing, and a native GTK4 app with tray and search launcher, plus a
|
||||||
|
scriptable CLI.
|
||||||
|
|
||||||
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||||
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||||
|
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
[](#prerequisites)
|
|
||||||
|
|
||||||
<img src="images/files.png" alt="The GTK4 file browser" width="820">
|
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||||
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||||
> to describe what the software talks to. Use a test account first, and read
|
> to describe what the software talks to. Try it with data you have another copy of first, and
|
||||||
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
|
> read [what survives a lost machine](docs/RECOVERY.md) before you rely on it.
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Debian / Ubuntu
|
|
||||||
sudo apt install ./proton-drive-linux_*.deb
|
|
||||||
|
|
||||||
# Fedora
|
|
||||||
sudo dnf install ./proton-drive-linux-*.rpm
|
|
||||||
|
|
||||||
# Arch Linux
|
|
||||||
cd packaging && makepkg -fi
|
|
||||||
```
|
|
||||||
|
|
||||||
Packages for each release are attached to the [latest
|
|
||||||
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
|
|
||||||
[Installation & Packages](#installation--packages) for details and
|
|
||||||
[Building from Source](#building-from-source) to compile it yourself.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
|
|
||||||
pdfs status # mount state, queued writes, cache usage
|
|
||||||
pdfs-app # the desktop app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
|
|
||||||
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
|
|
||||||
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
|
|
||||||
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
|
||||||
|
|
||||||
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
|
||||||
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
|
|
||||||
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
|
||||||
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
**Files**
|
||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
|
||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
|
||||||
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
|
||||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
|
||||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
|
||||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
|
||||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
|
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
|
||||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
|
||||||
- **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.
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Quick Search and Media Opening
|
- **Your Drive as a folder.** `~/ProtonDrive` works with every program. Files download when they
|
||||||
|
are opened, a block at a time, so a video starts playing and a large archive can be read
|
||||||
|
without fetching all of it.
|
||||||
|
- **Available offline.** Keep chosen files and folders fully downloaded; everything else is
|
||||||
|
cached within a size limit you set.
|
||||||
|
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
||||||
|
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||||
|
instead of overwrites, pause and bandwidth limits.
|
||||||
|
- **Safe writes.** Changes are staged on disk and queued before they are acknowledged, so they
|
||||||
|
survive a lost connection, a crash or a reboot and upload when they can.
|
||||||
|
|
||||||
`pdfs-prompt` searches Drive metadata and the local home-directory index in one request. Results are ranked together rather than split by source. Matching is case-insensitive and supports exact names, prefixes, parent paths, multiple terms, punctuation-separated words, ordered abbreviations, common typos, and adjacent transpositions.
|
**Desktop app**
|
||||||
|
|
||||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
- **Browse** My files, Shared with me, Shared by me, Computers and Trash, with thumbnails
|
||||||
|
(including camera RAW), drag and drop, details and **version history**.
|
||||||
|
- **Photos.** Timeline with favorites, albums, and Places on a map; RAW+JPEG pairs shown as one
|
||||||
|
photo; duplicate finder; slideshow; **import from Google Photos** (Takeout).
|
||||||
|
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||||
|
expiry, accept invitations.
|
||||||
|
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||||
|
what happened.
|
||||||
|
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
||||||
|
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||||
|
rofi or wofi, or live in `fzf`.
|
||||||
|
- **Restore a computer.** Continue a backup on a new machine and bring its folders back in one
|
||||||
|
step.
|
||||||
|
- **Sign-in** with two-factor authentication and CAPTCHA; the session is kept in the system
|
||||||
|
keyring.
|
||||||
|
- Available in English, German, Spanish, French, Italian, Dutch, Polish and Brazilian
|
||||||
|
Portuguese.
|
||||||
|
|
||||||
### Using your own launcher (fuzzel, rofi, wofi, …)
|
**Command line**
|
||||||
|
|
||||||
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
|
- About 55 `pdfs` commands cover files, sync, sharing, Photos, Trash, versions and diagnostics.
|
||||||
launcher instead of the built-in window, so the HUD matches the rest of a
|
Query commands print JSON with `--json`.
|
||||||
tiling-WM desktop:
|
|
||||||
|
|
||||||
```bash
|
## Install
|
||||||
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
|
|
||||||
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
|
||||||
pdfs-prompt --dmenu --query invoice # skip straight to a search
|
|
||||||
```
|
|
||||||
|
|
||||||
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
|
| Distribution | Package |
|
||||||
searching takes two steps, and the prompt says which step you are on:
|
|
||||||
|
|
||||||
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
|
|
||||||
and press Enter to search for it.
|
|
||||||
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
|
|
||||||
and pressing Enter searches again. Escape closes.
|
|
||||||
|
|
||||||
`--query` skips the first step. fuzzel and rofi also get file-type icons.
|
|
||||||
|
|
||||||
To make an existing keybinding use it without re-binding, and to pin the
|
|
||||||
launcher command, set them in `config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": {
|
|
||||||
"mode": "dmenu",
|
|
||||||
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
|
||||||
"menu_limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
|
|
||||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
|
||||||
own prompt flag is appended.
|
|
||||||
|
|
||||||
### Live launcher search (fzf)
|
|
||||||
|
|
||||||
The two-step above exists because no dmenu-style launcher can call back for new
|
|
||||||
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
|
|
||||||
exactly like the built-in window:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pdfs-prompt --fzf # pinned files, then live results as you type
|
|
||||||
pdfs-prompt --fzf --query invoice # start on a search
|
|
||||||
```
|
|
||||||
|
|
||||||
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
|
|
||||||
own matcher is switched off, so the ordering you see is the daemon's ranking —
|
|
||||||
abbreviations and typos included, which a launcher filtering a fixed list cannot
|
|
||||||
do. Enter opens; Escape closes.
|
|
||||||
|
|
||||||
fzf is a terminal program, so when it is started without one — from a WM
|
|
||||||
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
|
|
||||||
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
|
|
||||||
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
|
|
||||||
Hyprland:
|
|
||||||
|
|
||||||
```
|
|
||||||
windowrulev2 = float, class:^(pdfs-prompt)$
|
|
||||||
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
|
||||||
windowrulev2 = center, class:^(pdfs-prompt)$
|
|
||||||
bind = SUPER, space, exec, pdfs-prompt --fzf
|
|
||||||
```
|
|
||||||
|
|
||||||
Set it as the default front end, and pick the terminal explicitly, in
|
|
||||||
`config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": {
|
|
||||||
"mode": "fzf",
|
|
||||||
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
|
|
||||||
"menu_limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
|
|
||||||
without one it is appended, which is what every detected terminal expects.
|
|
||||||
|
|
||||||
## Choosing How Files Open
|
|
||||||
|
|
||||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
|
||||||
`config.json` overrides that per file type — for instance opening text in
|
|
||||||
Neovim inside Alacritty, while everything else still goes to the desktop:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"open_with": {
|
|
||||||
"terminal": ["alacritty", "-e"],
|
|
||||||
"rules": [
|
|
||||||
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
|
|
||||||
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
|
|
||||||
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
|
|
||||||
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
|
|
||||||
without one the path is appended. `$VAR` tokens expand from the environment,
|
|
||||||
so `["$EDITOR"]` follows your editor.
|
|
||||||
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
|
|
||||||
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
|
|
||||||
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
|
|
||||||
"run this" flag automatically.
|
|
||||||
- `"default": [...]` replaces `xdg-open` for everything unmatched.
|
|
||||||
|
|
||||||
The rules apply to the prompt (both front ends) and to the GTK browser, so a
|
|
||||||
file opens the same way wherever it was picked. Patterns are matched against the
|
|
||||||
Drive name — a downloaded file is stored in the cache under its content hash,
|
|
||||||
which says nothing about its type, so matching that would send everything to
|
|
||||||
`xdg-open`.
|
|
||||||
|
|
||||||
## Selective Sync (`.pdfsignore`)
|
|
||||||
|
|
||||||
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
|
||||||
directory does not upload `node_modules/`, `target/`, or `.git/`.
|
|
||||||
|
|
||||||
Rules come from two places, and both apply:
|
|
||||||
|
|
||||||
1. **Per folder** — a `.pdfsignore` file at the root of the synced folder
|
|
||||||
(`.protonignore` also works). Gitignore syntax, including negation:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# everything build-related
|
|
||||||
build/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# ...except this one
|
|
||||||
!important.log
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every
|
|
||||||
synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`,
|
|
||||||
`node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
|
|
||||||
`.DS_Store`, and `Thumbs.db`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ignore_patterns": ["node_modules/", "target/", "*.iso"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Set it to `[]` to opt out of the defaults entirely.
|
|
||||||
|
|
||||||
Rules are re-read at the start of every sync pass, so edits take effect on the
|
|
||||||
next pass without restarting the daemon.
|
|
||||||
|
|
||||||
**Ignoring is never destructive.** If a rule starts matching a file that was
|
|
||||||
already synced, its copy on Drive is left untouched — the file simply stops
|
|
||||||
being tracked. Removing the rule later picks the existing remote file back up
|
|
||||||
rather than re-uploading it.
|
|
||||||
|
|
||||||
## Diagnostics & Maintenance
|
|
||||||
|
|
||||||
When something looks wrong, `pdfs diagnose` checks the installation and prints a
|
|
||||||
report. It runs without a daemon on purpose — the state worth diagnosing is
|
|
||||||
usually the state where the daemon will not start:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ pdfs diagnose
|
|
||||||
Paths
|
|
||||||
[ok ] state dir: /home/you/.local/state/proton-drive-linux
|
|
||||||
[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB)
|
|
||||||
|
|
||||||
Account
|
|
||||||
[ok ] keyring session: you@proton.me
|
|
||||||
|
|
||||||
Daemon
|
|
||||||
[ok ] daemon responding
|
|
||||||
[ok ] mounted at: /home/you/ProtonDrive
|
|
||||||
[ok ] queued writes: none
|
|
||||||
|
|
||||||
No problems found.
|
|
||||||
```
|
|
||||||
|
|
||||||
It exits non-zero if any check fails, so it works in a health-check script.
|
|
||||||
|
|
||||||
For the local metadata database and content cache:
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget |
|
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||||
| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database |
|
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||||
| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database |
|
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||||
| `pdfs cache clear` | Deletes cached file content, keeping pinned files |
|
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||||
|
|
||||||
`vacuum` takes a write lock for its duration and needs room for a second copy of
|
```bash
|
||||||
the database while it runs, so it is a deliberate operation rather than
|
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
|
||||||
something the daemon does on a timer.
|
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
|
||||||
|
paru -S proton-drive-for-linux # Arch
|
||||||
Before a release, run the real-kernel acceptance suite against a dedicated test
|
|
||||||
account and mount. See [`docs/TESTING.md`](docs/TESTING.md); it includes optional
|
|
||||||
two-mount convergence checks and confines destructive operations to a fresh
|
|
||||||
test directory.
|
|
||||||
|
|
||||||
## Scripting (`--json`)
|
|
||||||
|
|
||||||
Query commands accept a global `--json` flag and emit machine-readable output:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
|
|
||||||
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
|
|
||||||
$ pdfs --json status | jq -r '.mount.mountpoint'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
|
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||||
`activity`, and `cache inspect`. Commands that perform an action keep their
|
service, and your files appear in `~/ProtonDrive`.
|
||||||
human output — a script that needs to know whether one worked has the exit code.
|
|
||||||
|
|
||||||
Two things worth relying on:
|
Signing in from a terminal instead? `pdfs login` stores the session but does not start the
|
||||||
|
service. Enable it once:
|
||||||
|
|
||||||
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
|
```bash
|
||||||
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
|
pdfs login
|
||||||
variant.
|
systemctl --user enable --now proton-drive.service
|
||||||
- **Errors still fail.** A daemon-side error prints its JSON body (with a
|
pdfs status
|
||||||
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
|
```
|
||||||
`if pdfs …` behave as expected rather than treating a failed lookup as
|
|
||||||
success.
|
|
||||||
|
|
||||||
## Performance & Caching
|
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||||
|
video thumbnails), headless use and uninstalling.
|
||||||
|
|
||||||
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
|
## Documentation
|
||||||
|
|
||||||
- **On-Demand Block Cache**: Files are read in fixed 4 MiB blocks. For unpinned files, the client fetches and caches only the blocks containing the requested byte ranges. This enables fast sequential and sparse reads (e.g. media streaming or metadata scanning) without downloading entire files.
|
| | |
|
||||||
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
|
|---|---|
|
||||||
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
|
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||||
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
|
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
|
||||||
- **Durable Staging**: Scratch metadata and staged writes are synced and atomically published before an upload is acknowledged locally. Rapid revisions supersede pending work transactionally.
|
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||||
|
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||||
## Filesystem Safety
|
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||||
|
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||||
Version 1.0 strengthens the boundaries around local-only data and destructive reconciliation:
|
|
||||||
|
|
||||||
- Truncate composes with queued revisions, preserving the authoritative prefix and correct zero-filled growth.
|
|
||||||
- Combined move-and-rename operations are queued durably and tolerate partially completed remote state.
|
|
||||||
- Session tombstones prevent successfully unlinked entries from reappearing through eventually consistent listings.
|
|
||||||
- Names are valid UTF-8 and no longer than Linux's 255-byte component limit.
|
|
||||||
- Incomplete mirror scans are non-destructive, and total-wipe protection covers every non-empty baseline.
|
|
||||||
- Failed conflict preservation, local deletion, or staging publication does not settle a new baseline or discard the only local copy.
|
|
||||||
- State, cache, and config directories must be real, current-user-owned `0700` directories; control sockets are `0600`.
|
|
||||||
|
|
||||||
|
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||||
|
[testing](docs/TESTING.md), [translating](docs/TRANSLATING.md), [roadmap](docs/ROADMAP.md) and the
|
||||||
|
[bug ledger](docs/BUGS.md). The [documentation index](docs/README.md) lists everything.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### GUI Application & Launcher
|
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
|
<td align="center" width="50%"><img src="images/files.png" alt="My files" width="100%"><br><sub><b>My files</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
|
<td align="center" width="50%"><img src="images/photos.png" alt="Photos timeline" width="100%"><br><sub><b>Photos</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/shared.png" alt="Shared Links" width="100%"><br><sub><b>Shared Links</b></sub></td>
|
<td align="center" width="50%"><img src="images/photos_map.png" alt="Photos places map" width="100%"><br><sub><b>Places</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
|
<td align="center" width="50%"><img src="images/computers.png" alt="Computers" width="100%"><br><sub><b>Computers</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
|
<td align="center" width="50%"><img src="images/sync.png" alt="Sync folders" width="100%"><br><sub><b>Sync</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
<td align="center" width="50%"><img src="images/conflicts.png" alt="Sync history showing resolved conflicts" width="100%"><br><sub><b>Sync history</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
|
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search launcher" width="100%"><br><sub><b>Search launcher</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search launcher in fzf" width="100%"><br><sub><b>Search launcher in fzf</b></sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="50%"><img src="images/login.png" alt="Sign in" width="100%"><br><sub><b>Sign in</b></sub></td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
---
|
## How it works
|
||||||
|
|
||||||
## Prerequisites
|
A background service (`proton-drive.service`, the `pdfs daemon` command) holds your session,
|
||||||
|
mounts `~/ProtonDrive` through FUSE, runs the upload queue and the sync engine, and follows
|
||||||
|
Proton's event stream for changes made elsewhere. The app, the tray, the launcher and the CLI are
|
||||||
|
clients of that service over a local socket. Everything is end-to-end encrypted with your Proton
|
||||||
|
keys before it leaves the machine, through a Rust implementation of Proton's Drive SDK.
|
||||||
|
|
||||||
To compile the application from source or run the built binaries, ensure you have the following system libraries installed on your distribution:
|
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||||
|
the security of the machine is spelled out in the
|
||||||
### Ubuntu / Debian (24.04+)
|
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
pkg-config \
|
|
||||||
libfuse3-dev \
|
|
||||||
libgtk-4-dev \
|
|
||||||
libadwaita-1-dev \
|
|
||||||
libwebkitgtk-6.0-dev \
|
|
||||||
libsecret-1-dev \
|
|
||||||
libdbus-1-dev \
|
|
||||||
libimage-exiftool-perl
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
```bash
|
|
||||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fedora (44+)
|
|
||||||
```bash
|
|
||||||
sudo dnf install -y \
|
|
||||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
|
||||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
|
||||||
perl-Image-ExifTool cargo rust
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime extras (pick your desktop):
|
|
||||||
```bash
|
|
||||||
# GNOME — keyring + tray (AppIndicator)
|
|
||||||
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
|
|
||||||
|
|
||||||
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
|
|
||||||
sudo dnf install -y kwallet xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Building from Source
|
|
||||||
|
|
||||||
Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96).
|
|
||||||
|
|
||||||
1. Clone the repository and navigate into the project directory:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/narrrl/proton-drive-linux.git
|
|
||||||
cd proton-drive-linux
|
|
||||||
```
|
|
||||||
2. Build the workspace in release mode:
|
|
||||||
```bash
|
|
||||||
cargo build --release --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
The compiled binaries will be available under `target/release/`:
|
|
||||||
- `pdfs`: The CLI utility.
|
|
||||||
- `pdfs-app`: The GTK4 application.
|
|
||||||
- `pdfs-tray`: The tray status notifier.
|
|
||||||
- `pdfs-prompt`: The launcher prompt for quick HUD search.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation & Packages
|
|
||||||
|
|
||||||
### 1. Debian / Ubuntu (.deb)
|
|
||||||
Install the debian package via `dpkg` or `apt`:
|
|
||||||
```bash
|
|
||||||
sudo apt install ./proton-drive-linux_*.deb
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Arch Linux
|
|
||||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
|
||||||
```bash
|
|
||||||
cd packaging && makepkg -fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Fedora (local RPM)
|
|
||||||
A local `.spec` is available under `packaging/`. From the repository root:
|
|
||||||
```bash
|
|
||||||
sudo dnf install -y rpm-build
|
|
||||||
rpmbuild -bb packaging/proton-drive-linux.spec \
|
|
||||||
--define "git_dir $PWD" \
|
|
||||||
--define "_rpmdir $PWD/packaging/out" \
|
|
||||||
--define "_builddir $PWD/packaging/build" \
|
|
||||||
--define "_sourcedir $PWD" \
|
|
||||||
--define "_specdir $PWD/packaging" \
|
|
||||||
--define "_srcrpmdir $PWD/packaging/out"
|
|
||||||
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Automated Releases (CI/CD)
|
|
||||||
|
|
||||||
This project has a GitHub Actions CI workflow configured under `.github/workflows/release.yml`.
|
|
||||||
|
|
||||||
### How it works:
|
|
||||||
1. **Triggers**:
|
|
||||||
- Pushing a git tag matching `v*` (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
|
|
||||||
- Manual runs via the **Actions** tab in GitHub (**workflow_dispatch**).
|
|
||||||
2. **Quality Gates and Build Process**:
|
|
||||||
- For tagged builds, verifies that the tag, workspace version, and `packaging/PKGBUILD` version agree.
|
|
||||||
- Runs `cargo fmt`, Clippy with warnings denied, the locked workspace test suite, and the account-free FUSE acceptance contract.
|
|
||||||
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
|
|
||||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
|
||||||
- Compiles the workspace members in release mode.
|
|
||||||
3. **Artifact Packaging**:
|
|
||||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
|
||||||
- Packs them into Debian (`.deb`) and Fedora (`.rpm`) packages.
|
|
||||||
- Includes the systemd user service and tray autostart entry in the Debian package.
|
|
||||||
4. **Publishing**:
|
|
||||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.rpm`, and `.tar.gz` packages as release assets.
|
|
||||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
|
||||||
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
|
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||||
vulnerability privately.
|
privately.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Released under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
The Places map uses [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and
|
||||||
|
[Natural Earth](https://www.naturalearthdata.com/) outlines (public domain). The optional street
|
||||||
|
map comes from [OpenFreeMap](https://openfreemap.org/), © [OpenStreetMap](https://www.openstreetmap.org/copyright)
|
||||||
|
contributors.
|
||||||
|
|
||||||
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||||
affiliation with or endorsement from Proton AG.
|
affiliation with or endorsement from Proton AG.
|
||||||
@@ -30,5 +30,10 @@ the interesting boundaries are:
|
|||||||
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||||
or the wrong key material to Proton.
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
|
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||||
|
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
|
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||||
|
than described is.
|
||||||
|
|
||||||
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
||||||
require an attacker who already has your user account on the machine.
|
require an attacker who already has your user account on the machine.
|
||||||
@@ -13,8 +13,9 @@ use pdfs_core::auth;
|
|||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase,
|
||||||
|
pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
@@ -145,11 +146,11 @@ enum Command {
|
|||||||
/// Skip this many photos from the start of the timeline.
|
/// Skip this many photos from the start of the timeline.
|
||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
offset: usize,
|
offset: usize,
|
||||||
/// List only favourited photos.
|
/// List only favorited photos.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
},
|
},
|
||||||
/// Add or remove a photo's favourite mark.
|
/// Add or remove a photo's favorite mark.
|
||||||
Favorite {
|
Favorite {
|
||||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
uid: String,
|
uid: String,
|
||||||
@@ -187,7 +188,7 @@ enum Command {
|
|||||||
/// and rewrites them at the right time.
|
/// and rewrites them at the right time.
|
||||||
///
|
///
|
||||||
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
|
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
|
||||||
/// the photo and trashing the original — favourites and albums are carried
|
/// the photo and trashing the original — favorites and albums are carried
|
||||||
/// across, and the original goes to Proton's trash, not away. Run it with
|
/// across, and the original goes to Proton's trash, not away. Run it with
|
||||||
/// `--dry-run` first.
|
/// `--dry-run` first.
|
||||||
RedatePhotos {
|
RedatePhotos {
|
||||||
@@ -229,6 +230,43 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
offset: usize,
|
offset: usize,
|
||||||
},
|
},
|
||||||
|
/// Create a photo album and print its uid.
|
||||||
|
NewAlbum {
|
||||||
|
/// Album name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Rename a photo album.
|
||||||
|
RenameAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// New name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Delete a photo album. Its photos stay in the timeline.
|
||||||
|
DeleteAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Also delete photos that exist only in this album (saved from a
|
||||||
|
/// shared album); without it the server refuses to delete such an album.
|
||||||
|
#[arg(long)]
|
||||||
|
with_photos: bool,
|
||||||
|
},
|
||||||
|
/// Add photos to an album.
|
||||||
|
AddToAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Photo node uids (from `pdfs photos`).
|
||||||
|
#[arg(required = true)]
|
||||||
|
photos: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Take photos out of an album. They stay in the timeline.
|
||||||
|
RemoveFromAlbum {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Photo node uids (from `pdfs album <uid>`).
|
||||||
|
#[arg(required = true)]
|
||||||
|
photos: Vec<String>,
|
||||||
|
},
|
||||||
/// Download a photo by uid (`volume~link`) and print its cached path.
|
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||||
OpenPhoto {
|
OpenPhoto {
|
||||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
@@ -241,6 +279,9 @@ enum Command {
|
|||||||
/// Max hits to return.
|
/// Max hits to return.
|
||||||
#[arg(long, default_value_t = 50)]
|
#[arg(long, default_value_t = 50)]
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
/// Only search below this folder (under the mountpoint).
|
||||||
|
#[arg(long = "in", value_name = "FOLDER")]
|
||||||
|
scope: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Rename a file or folder via the running daemon.
|
/// Rename a file or folder via the running daemon.
|
||||||
Rename {
|
Rename {
|
||||||
@@ -327,6 +368,11 @@ enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: SyncCmd,
|
action: SyncCmd,
|
||||||
},
|
},
|
||||||
|
/// List `(sync-conflict …)` copies, or resolve one.
|
||||||
|
Conflicts {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: Option<ConflictCmd>,
|
||||||
|
},
|
||||||
/// Share a file or folder with Proton and/or external email addresses.
|
/// Share a file or folder with Proton and/or external email addresses.
|
||||||
Share {
|
Share {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
@@ -357,14 +403,18 @@ enum Command {
|
|||||||
/// New role: viewer, editor or admin.
|
/// New role: viewer, editor or admin.
|
||||||
role: String,
|
role: String,
|
||||||
},
|
},
|
||||||
/// Remove a member or pending invitation from a node's share.
|
/// Stop sharing a node, or remove one member or pending invitation.
|
||||||
|
///
|
||||||
|
/// With only a path, removes the public link, every invitation and every
|
||||||
|
/// member. With an id and kind (from `pdfs members`), removes that entry.
|
||||||
Unshare {
|
Unshare {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
/// Entry id (membership or invitation id), from `pdfs members`.
|
||||||
id: String,
|
#[arg(requires = "kind")]
|
||||||
|
id: Option<String>,
|
||||||
/// Entry kind: member, proton or external.
|
/// Entry kind: member, proton or external.
|
||||||
kind: ShareKindArg,
|
kind: Option<ShareKindArg>,
|
||||||
},
|
},
|
||||||
/// Inspect and restore a file's earlier versions.
|
/// Inspect and restore a file's earlier versions.
|
||||||
Versions {
|
Versions {
|
||||||
@@ -521,6 +571,22 @@ enum DeviceCmd {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ConflictCmd {
|
||||||
|
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
||||||
|
Resolve {
|
||||||
|
/// The conflict copy, absolute or relative to the mountpoint.
|
||||||
|
path: PathBuf,
|
||||||
|
/// `original` trashes the copy; `copy` trashes the original and gives the
|
||||||
|
/// copy its name; `both` renames the copy to `--name`.
|
||||||
|
#[arg(long, value_parser = ["original", "copy", "both"])]
|
||||||
|
keep: String,
|
||||||
|
/// New name for the copy with `--keep both`.
|
||||||
|
#[arg(long, required_if_eq("keep", "both"))]
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum SyncCmd {
|
enum SyncCmd {
|
||||||
/// Add a local folder to this machine's device and upload its contents.
|
/// Add a local folder to this machine's device and upload its contents.
|
||||||
@@ -543,6 +609,38 @@ enum SyncCmd {
|
|||||||
/// Optional synced-folder id; omit to reconcile every folder.
|
/// Optional synced-folder id; omit to reconcile every folder.
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
},
|
},
|
||||||
|
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||||
|
Pause {
|
||||||
|
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
||||||
|
/// Omit to pause until `pdfs sync resume`.
|
||||||
|
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
||||||
|
duration: Option<u64>,
|
||||||
|
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
||||||
|
#[arg(long)]
|
||||||
|
folder: Option<i64>,
|
||||||
|
},
|
||||||
|
/// Resume syncing after `pdfs sync pause`.
|
||||||
|
Resume {
|
||||||
|
/// Resume only this folder, paused with `sync pause --folder`.
|
||||||
|
#[arg(long)]
|
||||||
|
folder: Option<i64>,
|
||||||
|
},
|
||||||
|
/// Cap upload and download speed, or show the caps with no options.
|
||||||
|
Limit {
|
||||||
|
/// Upload cap per second, e.g. `500K` or `2M`; `0` removes it.
|
||||||
|
#[arg(long, value_parser = parse_rate)]
|
||||||
|
up: Option<u64>,
|
||||||
|
/// Download cap per second, e.g. `500K` or `2M`; `0` removes it.
|
||||||
|
#[arg(long, value_parser = parse_rate)]
|
||||||
|
down: Option<u64>,
|
||||||
|
},
|
||||||
|
/// List uploads and changes not yet on Proton Drive.
|
||||||
|
Queue,
|
||||||
|
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||||
|
Retry {
|
||||||
|
/// Queue id; omit to retry every failed operation.
|
||||||
|
id: Option<i64>,
|
||||||
|
},
|
||||||
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
||||||
Mode {
|
Mode {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
@@ -557,6 +655,10 @@ enum SyncCmd {
|
|||||||
/// Accept every proposed local path without asking.
|
/// Accept every proposed local path without asking.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
|
/// Restore another computer's folders instead, by device uid (from
|
||||||
|
/// `pdfs devices list`).
|
||||||
|
#[arg(long)]
|
||||||
|
device: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,8 +777,28 @@ fn main() -> Result<()> {
|
|||||||
Command::ImportStatus => cmd_import_status(),
|
Command::ImportStatus => cmd_import_status(),
|
||||||
Command::CancelImport => cmd_cancel_import(),
|
Command::CancelImport => cmd_cancel_import(),
|
||||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
|
Command::NewAlbum { name } => cmd_new_album(name),
|
||||||
|
Command::RenameAlbum { uid, name } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::RenameAlbum { uid, name })?)
|
||||||
|
}
|
||||||
|
Command::DeleteAlbum { uid, with_photos } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::DeleteAlbum {
|
||||||
|
uid,
|
||||||
|
delete_photos: with_photos,
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
Command::AddToAlbum { uid, photos } => {
|
||||||
|
cmd_album_change(CtlRequest::AddToAlbum { uid, photos }, "added")
|
||||||
|
}
|
||||||
|
Command::RemoveFromAlbum { uid, photos } => {
|
||||||
|
cmd_album_change(CtlRequest::RemoveFromAlbum { uid, photos }, "removed")
|
||||||
|
}
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
Command::Search { query, limit } => cmd_search(query, limit),
|
Command::Search {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
scope,
|
||||||
|
} => cmd_search(query, limit, scope),
|
||||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||||
Command::Rm { path } => cmd_rm(path),
|
Command::Rm { path } => cmd_rm(path),
|
||||||
@@ -691,6 +813,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Refresh { target, full } => cmd_refresh(target, full),
|
Command::Refresh { target, full } => cmd_refresh(target, full),
|
||||||
Command::Devices { action } => cmd_devices(action),
|
Command::Devices { action } => cmd_devices(action),
|
||||||
Command::Sync { action } => cmd_sync(action),
|
Command::Sync { action } => cmd_sync(action),
|
||||||
|
Command::Conflicts { action } => cmd_conflicts(action),
|
||||||
Command::Share {
|
Command::Share {
|
||||||
path,
|
path,
|
||||||
emails,
|
emails,
|
||||||
@@ -798,6 +921,10 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
Some(pending) => format!("{} → {pending}", f.mode),
|
Some(pending) => format!("{} → {pending}", f.mode),
|
||||||
None => f.mode.clone(),
|
None => f.mode.clone(),
|
||||||
};
|
};
|
||||||
|
let mode = match f.paused {
|
||||||
|
true => format!("{mode}, paused"),
|
||||||
|
false => mode,
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
"[{}] {} ({mode}, {}, synced: {sync})",
|
"[{}] {} ({mode}, {}, synced: {sync})",
|
||||||
f.id, f.local_path, f.state
|
f.id, f.local_path, f.state
|
||||||
@@ -834,10 +961,114 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
})?)?
|
})?)?
|
||||||
}
|
}
|
||||||
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
||||||
|
SyncCmd::Pause {
|
||||||
|
folder: Some(id), ..
|
||||||
|
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||||
|
id,
|
||||||
|
paused: true,
|
||||||
|
})?)?,
|
||||||
|
SyncCmd::Resume { folder: Some(id) } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||||
|
id,
|
||||||
|
paused: false,
|
||||||
|
})?)?
|
||||||
|
}
|
||||||
|
SyncCmd::Pause {
|
||||||
|
duration,
|
||||||
|
folder: None,
|
||||||
|
} => {
|
||||||
|
let until = duration.map(|secs| unix_now().saturating_add(secs as i64));
|
||||||
|
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||||
|
paused: true,
|
||||||
|
until,
|
||||||
|
})?)?
|
||||||
|
}
|
||||||
|
SyncCmd::Queue => return cmd_sync_queue(),
|
||||||
|
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
||||||
|
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
||||||
|
SyncCmd::Resume { folder: None } => {
|
||||||
|
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||||
|
paused: false,
|
||||||
|
until: None,
|
||||||
|
})?)?
|
||||||
|
}
|
||||||
SyncCmd::Mode { id, mode } => {
|
SyncCmd::Mode { id, mode } => {
|
||||||
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
|
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
|
||||||
}
|
}
|
||||||
SyncCmd::Restore { yes } => return cmd_sync_restore(yes),
|
SyncCmd::Restore { yes, device } => return cmd_sync_restore(yes, device),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs conflicts`: list conflict copies, or resolve one.
|
||||||
|
fn cmd_conflicts(action: Option<ConflictCmd>) -> Result<()> {
|
||||||
|
if let Some(ConflictCmd::Resolve { path, keep, name }) = action {
|
||||||
|
let keep = match keep.as_str() {
|
||||||
|
"original" => ConflictKeep::Original,
|
||||||
|
"copy" => ConflictKeep::Copy,
|
||||||
|
_ => ConflictKeep::Both {
|
||||||
|
name: name.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let path = path_arg(&path)?;
|
||||||
|
return ok_or_bail(control_request(CtlRequest::ResolveConflict { path, keep })?);
|
||||||
|
}
|
||||||
|
let response = control_request(CtlRequest::ListConflicts)?;
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match response {
|
||||||
|
CtlResponse::Conflicts { items } if items.is_empty() => println!("No conflicts."),
|
||||||
|
CtlResponse::Conflicts { items } => {
|
||||||
|
for c in items {
|
||||||
|
println!("{}", c.path);
|
||||||
|
let verdict = match (c.original_exists, c.identical) {
|
||||||
|
(false, _) => "original is gone".to_string(),
|
||||||
|
(true, true) => format!("identical to {}", c.original_path),
|
||||||
|
(true, false) => format!(
|
||||||
|
"differs from {} ({} vs {})",
|
||||||
|
c.original_path,
|
||||||
|
human_bytes(c.size),
|
||||||
|
human_bytes(c.original_size.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
println!(" {verdict}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs sync queue`: what has not reached Proton Drive yet, and why.
|
||||||
|
fn cmd_sync_queue() -> Result<()> {
|
||||||
|
let response = control_request(CtlRequest::ListPendingOps)?;
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match response {
|
||||||
|
CtlResponse::PendingOps { items } if items.is_empty() => {
|
||||||
|
println!("Nothing queued: everything is on Proton Drive.");
|
||||||
|
}
|
||||||
|
CtlResponse::PendingOps { items } => {
|
||||||
|
let now = unix_now();
|
||||||
|
for op in items {
|
||||||
|
let state = match (op.parked, op.next_attempt_at) {
|
||||||
|
(true, _) => "waiting for its final name".to_string(),
|
||||||
|
(false, Some(at)) if at > now => {
|
||||||
|
format!("next try in {}", human_age((at - now) as u64))
|
||||||
|
}
|
||||||
|
(false, _) => "due now".to_string(),
|
||||||
|
};
|
||||||
|
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
||||||
|
if let Some(error) = &op.last_error {
|
||||||
|
println!(" failed {} time(s): {error}", op.attempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -848,8 +1079,13 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
/// The daemon only ever *proposes* local paths — a path recorded by another
|
/// The daemon only ever *proposes* local paths — a path recorded by another
|
||||||
/// machine may name a home directory that does not exist here — so the default
|
/// machine may name a home directory that does not exist here — so the default
|
||||||
/// flow is propose-and-confirm. `--yes` accepts the proposals for scripting.
|
/// flow is propose-and-confirm. `--yes` accepts the proposals for scripting.
|
||||||
fn cmd_sync_restore(yes: bool) -> Result<()> {
|
fn cmd_sync_restore(yes: bool, device: Option<String>) -> Result<()> {
|
||||||
let response = control_request(CtlRequest::ListRestorableFolders)?;
|
let response = control_request(match &device {
|
||||||
|
Some(device) => CtlRequest::ListDeviceRestorableFolders {
|
||||||
|
device: device.clone(),
|
||||||
|
},
|
||||||
|
None => CtlRequest::ListRestorableFolders,
|
||||||
|
})?;
|
||||||
if emit_json(&response)? {
|
if emit_json(&response)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -899,8 +1135,12 @@ fn cmd_sync_restore(yes: bool) -> Result<()> {
|
|||||||
println!("Nothing selected.");
|
println!("Nothing selected.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
ok_or_bail(control_request(CtlRequest::RestoreSyncFolders {
|
ok_or_bail(control_request(match device {
|
||||||
items: chosen,
|
Some(device) => CtlRequest::RestoreDeviceFolders {
|
||||||
|
device,
|
||||||
|
items: chosen,
|
||||||
|
},
|
||||||
|
None => CtlRequest::RestoreSyncFolders { items: chosen },
|
||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,12 +1278,17 @@ fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -
|
|||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> {
|
fn cmd_unshare(path: PathBuf, id: Option<String>, kind: Option<ShareKindArg>) -> Result<()> {
|
||||||
ok_or_bail(control_request(CtlRequest::RemoveShareEntry {
|
let path = path_arg(&path)?;
|
||||||
path: path_arg(&path)?,
|
let request = match (id, kind) {
|
||||||
id,
|
(Some(id), Some(kind)) => CtlRequest::RemoveShareEntry {
|
||||||
kind: kind.to_kind(),
|
path,
|
||||||
})?)
|
id,
|
||||||
|
kind: kind.to_kind(),
|
||||||
|
},
|
||||||
|
_ => CtlRequest::StopSharing { path },
|
||||||
|
};
|
||||||
|
ok_or_bail(control_request(request)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||||
@@ -1472,9 +1717,15 @@ fn cmd_status() -> Result<()> {
|
|||||||
failing_error,
|
failing_error,
|
||||||
staged_bytes,
|
staged_bytes,
|
||||||
staged_oldest_secs,
|
staged_oldest_secs,
|
||||||
|
paused,
|
||||||
|
paused_until,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let state = if online { "" } else { ", offline" };
|
let state = match (online, paused) {
|
||||||
|
(_, true) => ", sync paused",
|
||||||
|
(false, false) => ", offline",
|
||||||
|
(true, false) => "",
|
||||||
|
};
|
||||||
let queued = match pending_summary(pending_uploads, pending_changes) {
|
let queued = match pending_summary(pending_uploads, pending_changes) {
|
||||||
Some(s) => format!(", {s}"),
|
Some(s) => format!(", {s}"),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
@@ -1490,6 +1741,10 @@ fn cmd_status() -> Result<()> {
|
|||||||
human_age(staged_oldest_secs),
|
human_age(staged_oldest_secs),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(until) = paused_until {
|
||||||
|
let left = until.saturating_sub(unix_now()).max(0) as u64;
|
||||||
|
println!("Paused sync resumes in {}", human_age(left.max(1)));
|
||||||
|
}
|
||||||
if parked_uploads > 0 {
|
if parked_uploads > 0 {
|
||||||
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
||||||
}
|
}
|
||||||
@@ -1682,6 +1937,8 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
pdfs_fuse::MountOptions {
|
pdfs_fuse::MountOptions {
|
||||||
username,
|
username,
|
||||||
sweep_mode: config.resolved_conflict_sweep(),
|
sweep_mode: config.resolved_conflict_sweep(),
|
||||||
|
upload_limit: config.upload_limit.unwrap_or(0),
|
||||||
|
download_limit: config.download_limit.unwrap_or(0),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1817,6 +2074,7 @@ fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
|
|||||||
kind: None,
|
kind: None,
|
||||||
range: None,
|
range: None,
|
||||||
favorites,
|
favorites,
|
||||||
|
not_in_album: false,
|
||||||
})? {
|
})? {
|
||||||
CtlResponse::Photos {
|
CtlResponse::Photos {
|
||||||
available: false, ..
|
available: false, ..
|
||||||
@@ -1886,6 +2144,42 @@ fn cmd_albums() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_new_album(name: String) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::CreateAlbum { name })? {
|
||||||
|
CtlResponse::AlbumCreated { uid } => println!("{uid}"),
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an album add or remove and report it per photo; any failed photo
|
||||||
|
/// fails the command.
|
||||||
|
fn cmd_album_change(request: CtlRequest, verb: &str) -> Result<()> {
|
||||||
|
match control_request(request)? {
|
||||||
|
CtlResponse::AlbumChanged { changed, failed } => {
|
||||||
|
println!("{} {verb}", photo_count(changed.len()));
|
||||||
|
for failure in &failed {
|
||||||
|
eprintln!("{}: {}", failure.uid, failure.message);
|
||||||
|
}
|
||||||
|
if !failed.is_empty() {
|
||||||
|
bail!("{} failed", photo_count(failed.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn photo_count(n: usize) -> String {
|
||||||
|
if n == 1 {
|
||||||
|
"1 photo".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
||||||
// The daemon opens the archives itself off the shared filesystem, so it gets
|
// The daemon opens the archives itself off the shared filesystem, so it gets
|
||||||
// absolute paths — the same arrangement `pdfs upload` uses.
|
// absolute paths — the same arrangement `pdfs upload` uses.
|
||||||
@@ -2112,8 +2406,13 @@ fn cmd_open_photo(uid: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_search(query: String, limit: usize) -> Result<()> {
|
fn cmd_search(query: String, limit: usize, scope: Option<PathBuf>) -> Result<()> {
|
||||||
match control_request(CtlRequest::Search { query, limit })? {
|
let scope = scope.map(|p| path_arg(&p)).transpose()?;
|
||||||
|
match control_request(CtlRequest::Search {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
scope,
|
||||||
|
})? {
|
||||||
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
|
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
|
||||||
CtlResponse::SearchResults { hits } => {
|
CtlResponse::SearchResults { hits } => {
|
||||||
for h in hits {
|
for h in hits {
|
||||||
@@ -2384,6 +2683,72 @@ fn path_arg(path: &Path) -> Result<String> {
|
|||||||
|
|
||||||
/// Format an age in seconds for a report line, coarsely — the point of the
|
/// Format an age in seconds for a report line, coarsely — the point of the
|
||||||
/// number is whether it is minutes or weeks.
|
/// number is whether it is minutes or weeks.
|
||||||
|
/// Parse a `sync pause --for` duration: a whole number followed by `m`, `h` or
|
||||||
|
/// `d`. Returns seconds.
|
||||||
|
fn parse_pause_duration(text: &str) -> Result<u64, String> {
|
||||||
|
let text = text.trim();
|
||||||
|
let split = text.char_indices().last().map_or(0, |(at, _)| at);
|
||||||
|
let (number, unit) = text.split_at(split);
|
||||||
|
let per_unit: u64 = match unit {
|
||||||
|
"m" => 60,
|
||||||
|
"h" => 3600,
|
||||||
|
"d" => 86_400,
|
||||||
|
_ => return Err("use a number followed by m, h or d, e.g. 30m".to_string()),
|
||||||
|
};
|
||||||
|
match number.parse::<u64>() {
|
||||||
|
Ok(n) if n > 0 => Ok(n.saturating_mul(per_unit)),
|
||||||
|
_ => Err("use a positive whole number, e.g. 30m".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a bytes-per-second rate: a whole number with an optional `K`, `M` or
|
||||||
|
/// `G` suffix in the same binary units `human_bytes` prints.
|
||||||
|
fn parse_rate(text: &str) -> Result<u64, String> {
|
||||||
|
let text = text.trim();
|
||||||
|
let (number, scale) = match text.char_indices().last() {
|
||||||
|
Some((at, 'K' | 'k')) => (&text[..at], 1024),
|
||||||
|
Some((at, 'M' | 'm')) => (&text[..at], 1_048_576),
|
||||||
|
Some((at, 'G' | 'g')) => (&text[..at], 1_073_741_824),
|
||||||
|
_ => (text, 1),
|
||||||
|
};
|
||||||
|
number
|
||||||
|
.parse::<u64>()
|
||||||
|
.map(|n| n.saturating_mul(scale))
|
||||||
|
.map_err(|_| "use a whole number with an optional K, M or G, e.g. 2M".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn human_rate(rate: u64) -> String {
|
||||||
|
if rate == 0 {
|
||||||
|
"unlimited".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/s", human_bytes(rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs sync limit`: set the bandwidth caps, keeping whichever one is not
|
||||||
|
/// given, or print both when neither is.
|
||||||
|
fn cmd_sync_limit(up: Option<u64>, down: Option<u64>) -> Result<()> {
|
||||||
|
let config = AppDirs::new()?.load_config();
|
||||||
|
let upload = up.or(config.upload_limit).unwrap_or(0);
|
||||||
|
let download = down.or(config.download_limit).unwrap_or(0);
|
||||||
|
if up.is_none() && down.is_none() {
|
||||||
|
println!("Upload {}", human_rate(upload));
|
||||||
|
println!("Download {}", human_rate(download));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ok_or_bail(control_request(CtlRequest::SetBandwidthLimits {
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
fn human_age(secs: u64) -> String {
|
fn human_age(secs: u64) -> String {
|
||||||
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
||||||
for (unit, scale) in UNITS {
|
for (unit, scale) in UNITS {
|
||||||
@@ -2847,6 +3212,40 @@ fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
|||||||
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pause_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_durations_read_minutes_hours_and_days() {
|
||||||
|
assert_eq!(parse_pause_duration("30m"), Ok(1800));
|
||||||
|
assert_eq!(parse_pause_duration("1h"), Ok(3600));
|
||||||
|
assert_eq!(parse_pause_duration("2d"), Ok(172_800));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_durations_reject_what_they_cannot_read() {
|
||||||
|
assert!(parse_pause_duration("").is_err());
|
||||||
|
assert!(parse_pause_duration("0h").is_err());
|
||||||
|
assert!(parse_pause_duration("1w").is_err());
|
||||||
|
assert!(parse_pause_duration("h").is_err());
|
||||||
|
assert!(parse_pause_duration("-1h").is_err());
|
||||||
|
assert!(parse_pause_duration("1ä").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rates_read_plain_bytes_and_binary_suffixes() {
|
||||||
|
assert_eq!(parse_rate("0"), Ok(0));
|
||||||
|
assert_eq!(parse_rate("1500"), Ok(1500));
|
||||||
|
assert_eq!(parse_rate("500K"), Ok(512_000));
|
||||||
|
assert_eq!(parse_rate("2m"), Ok(2_097_152));
|
||||||
|
assert!(parse_rate("").is_err());
|
||||||
|
assert!(parse_rate("M").is_err());
|
||||||
|
assert!(parse_rate("1.5M").is_err());
|
||||||
|
assert!(parse_rate("2MB").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod diagnose_tests {
|
mod diagnose_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2965,6 +3364,8 @@ mod login_wait_tests {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
};
|
};
|
||||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||||
entry.shared_by = "alice@proton.me".into();
|
entry.shared_by = "alice@proton.me".into();
|
||||||
@@ -2977,3 +3378,36 @@ mod login_wait_tests {
|
|||||||
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod unshare_tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unshare_with_only_a_path_stops_sharing() {
|
||||||
|
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs/report.pdf"]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::Unshare {
|
||||||
|
id: None,
|
||||||
|
kind: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unshare_with_an_id_needs_its_kind() {
|
||||||
|
assert!(Cli::try_parse_from(["pdfs", "unshare", "docs", "abc"]).is_err());
|
||||||
|
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs", "abc", "member"]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::Unshare {
|
||||||
|
id: Some(_),
|
||||||
|
kind: Some(_),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
@@ -298,6 +298,10 @@ pub struct Pin {
|
|||||||
/// which is why it is defaulted.
|
/// which is why it is defaulted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_dir: Option<bool>,
|
pub is_dir: Option<bool>,
|
||||||
|
/// A pinned file whose content is on disk right now. The daemon fills it
|
||||||
|
/// in when it answers `ListPins`; folders and older daemons say `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||||
@@ -1854,6 +1858,7 @@ impl ContentCache {
|
|||||||
path: row.path,
|
path: row.path,
|
||||||
recursive: row.recursive,
|
recursive: row.recursive,
|
||||||
is_dir: row.is_dir,
|
is_dir: row.is_dir,
|
||||||
|
cached: false,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ pub struct AppConfig {
|
|||||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_budget: Option<u64>,
|
pub cache_budget: Option<u64>,
|
||||||
|
/// Upload cap in bytes per second shared by every upload. `None` or `0`
|
||||||
|
/// means no cap. Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub upload_limit: Option<u64>,
|
||||||
|
/// Download cap in bytes per second shared by every download, FUSE reads
|
||||||
|
/// included. `None` or `0` means no cap. Defaulted for configs predating
|
||||||
|
/// the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub download_limit: Option<u64>,
|
||||||
/// Mountpoint the daemon mounts at. `None` means
|
/// Mountpoint the daemon mounts at. `None` means
|
||||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||||
/// path here. Defaulted for configs predating the field.
|
/// path here. Defaulted for configs predating the field.
|
||||||
@@ -119,6 +128,85 @@ pub struct AppConfig {
|
|||||||
/// field.
|
/// field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<PromptConfig>,
|
pub prompt: Option<PromptConfig>,
|
||||||
|
/// Paint the desktop app in Proton's colours instead of the system theme
|
||||||
|
/// and accent colour. `None` means "follow the system". Defaulted for
|
||||||
|
/// configs predating the field; also read from `proton_accent`, its name
|
||||||
|
/// while it only changed the accent.
|
||||||
|
#[serde(default, alias = "proton_accent")]
|
||||||
|
pub proton_theme: Option<bool>,
|
||||||
|
/// How the desktop app's My Files page lays out and orders a folder.
|
||||||
|
/// Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub files_view: FilesView,
|
||||||
|
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||||
|
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||||
|
/// 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>,
|
||||||
|
/// Draw the Places map from OpenFreeMap's street map instead of the
|
||||||
|
/// built-in outline. Off by default: the tiles come from a third party,
|
||||||
|
/// which then learns which parts of the world are looked at. Defaulted for
|
||||||
|
/// configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub online_map: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The My Files page's view choices, remembered between runs.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct FilesView {
|
||||||
|
/// Columns instead of the icon grid.
|
||||||
|
pub list: bool,
|
||||||
|
pub sort: FileSort,
|
||||||
|
pub descending: bool,
|
||||||
|
/// Folders ahead of files, whatever the sort.
|
||||||
|
pub folders_first: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FilesView {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
list: false,
|
||||||
|
sort: FileSort::Name,
|
||||||
|
descending: false,
|
||||||
|
folders_first: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key a My Files listing is ordered by.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FileSort {
|
||||||
|
#[default]
|
||||||
|
Name,
|
||||||
|
Size,
|
||||||
|
Modified,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSort {
|
||||||
|
/// The name used in the desktop app's actions and the config file.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FileSort::Name => "name",
|
||||||
|
FileSort::Size => "size",
|
||||||
|
FileSort::Modified => "modified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"name" => Some(FileSort::Name),
|
||||||
|
"size" => Some(FileSort::Size),
|
||||||
|
"modified" => Some(FileSort::Modified),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -127,12 +215,19 @@ impl Default for AppConfig {
|
|||||||
app_version: APP_VERSION.to_string(),
|
app_version: APP_VERSION.to_string(),
|
||||||
user_agent: USER_AGENT.to_string(),
|
user_agent: USER_AGENT.to_string(),
|
||||||
cache_budget: None,
|
cache_budget: None,
|
||||||
|
upload_limit: None,
|
||||||
|
download_limit: None,
|
||||||
mountpoint: None,
|
mountpoint: None,
|
||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
proton_theme: None,
|
||||||
|
files_view: FilesView::default(),
|
||||||
|
tray_hidden: false,
|
||||||
|
language: None,
|
||||||
|
online_map: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,17 +566,54 @@ mod tests {
|
|||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||||
user_agent: "test-agent/1.0".to_string(),
|
user_agent: "test-agent/1.0".to_string(),
|
||||||
cache_budget: Some(1234),
|
cache_budget: Some(1234),
|
||||||
|
upload_limit: Some(500_000),
|
||||||
|
download_limit: None,
|
||||||
mountpoint: Some("/tmp/x".to_string()),
|
mountpoint: Some("/tmp/x".to_string()),
|
||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
proton_theme: Some(true),
|
||||||
|
files_view: FilesView {
|
||||||
|
list: true,
|
||||||
|
sort: FileSort::Modified,
|
||||||
|
descending: true,
|
||||||
|
folders_first: false,
|
||||||
|
},
|
||||||
|
tray_hidden: true,
|
||||||
|
language: Some("de".to_string()),
|
||||||
|
online_map: true,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
|
assert!(decoded.tray_hidden);
|
||||||
|
assert!(decoded.online_map);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_files_view_gets_the_default() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||||
|
assert_eq!(decoded.files_view, FilesView::default());
|
||||||
|
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
|
||||||
|
assert_eq!(partial.sort, FileSort::Size);
|
||||||
|
assert!(partial.folders_first);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -554,6 +686,11 @@ mod tests {
|
|||||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
assert!(decoded.ignore_patterns.is_none());
|
assert!(decoded.ignore_patterns.is_none());
|
||||||
|
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
|
||||||
|
assert!(
|
||||||
|
decoded.language.is_none(),
|
||||||
|
"an old config follows the system"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decoded.resolved_ignore_patterns().len(),
|
decoded.resolved_ignore_patterns().len(),
|
||||||
DEFAULT_IGNORE_PATTERNS.len(),
|
DEFAULT_IGNORE_PATTERNS.len(),
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ impl Db {
|
|||||||
Ok(())
|
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
|
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
@@ -239,7 +239,7 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 31;
|
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -270,6 +270,68 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V31)?;
|
tx.execute_batch(MIGRATION_V31)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 32 {
|
||||||
|
// Data only, so safe to repeat; each table is guarded for a very
|
||||||
|
// old fixture that never had it.
|
||||||
|
for table in ["photos", "album_photos"] {
|
||||||
|
let has_table: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_table {
|
||||||
|
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 34 {
|
||||||
|
// Same guards as V26-V33.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||||
|
[],
|
||||||
|
|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_V34)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 35 {
|
||||||
|
// Same guards as V26-V34.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V35)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -684,7 +746,7 @@ CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
|||||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// V20: whether a photo is a favourite.
|
/// V20: whether a photo is a favorite.
|
||||||
///
|
///
|
||||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||||
@@ -938,7 +1000,7 @@ CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
|||||||
/// Schema v31: which photos a refresh still has to read from the server.
|
/// Schema v31: which photos a refresh still has to read from the server.
|
||||||
///
|
///
|
||||||
/// A refresh used to resolve every photo's node to learn its name, media type,
|
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||||
/// favourite tag, content hash and photo relation — minutes of round-trips for a
|
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||||
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||||
/// records that the work was already done, so a refresh only reads the photos it
|
/// records that the work was already done, so a refresh only reads the photos it
|
||||||
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||||
@@ -952,3 +1014,48 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
|||||||
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||||
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// V32: give videos written off as un-thumbnailable another chance.
|
||||||
|
///
|
||||||
|
/// Before local video thumbnails, the fallback fed a video's bytes to the image
|
||||||
|
/// decoder, which could only fail, and that failure was recorded as the
|
||||||
|
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||||
|
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||||
|
///
|
||||||
|
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||||
|
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||||
|
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||||
|
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||||
|
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||||
|
const MIGRATION_V34: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v35: when each trashed node went to the trash.
|
||||||
|
///
|
||||||
|
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||||
|
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||||
|
/// from before this version, and nodes found by a refresh that did not follow a
|
||||||
|
/// complete one, which may have been in the trash for a long time already.
|
||||||
|
const MIGRATION_V35: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
|
";
|
||||||
@@ -56,10 +56,10 @@ pub use local::LocalFileHit;
|
|||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||||
PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
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 pins::PinRow;
|
||||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||||
pub use trash::StoredTrash;
|
pub use trash::StoredTrash;
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ impl Db {
|
|||||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||||
mounted: false,
|
mounted: false,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(mounts)
|
Ok(mounts)
|
||||||
|
|||||||
@@ -355,6 +355,22 @@ impl Db {
|
|||||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||||
/// nothing below 3 chars.
|
/// nothing below 3 chars.
|
||||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||||
|
self.search_in(query, limit, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`search`](Self::search) restricted to the subtree under the
|
||||||
|
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||||
|
/// everything.
|
||||||
|
pub fn search_in(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
scope: Option<&str>,
|
||||||
|
) -> Result<Vec<SearchHit>> {
|
||||||
|
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||||
|
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||||
|
// checked once their path is walked below.
|
||||||
|
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -365,9 +381,10 @@ impl Db {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
// all terms must match but can appear in any order or position.
|
// all terms must match but can appear in any order or position.
|
||||||
@@ -380,9 +397,10 @@ impl Db {
|
|||||||
"SELECT n.node_json, n.uid, n.path
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hits = Vec::with_capacity(rows.len());
|
let mut hits = Vec::with_capacity(rows.len());
|
||||||
@@ -392,6 +410,13 @@ impl Db {
|
|||||||
Some(path) => path,
|
Some(path) => path,
|
||||||
None => walk_path_of(&conn, &uid)?,
|
None => walk_path_of(&conn, &uid)?,
|
||||||
};
|
};
|
||||||
|
if let Some(scope) = scope
|
||||||
|
&& !path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
hits.push(SearchHit { node, path });
|
hits.push(SearchHit { node, path });
|
||||||
}
|
}
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
|
|||||||
@@ -772,6 +772,31 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make one backed-off op due now, for the user's "Retry now". Parked ops are
|
||||||
|
/// left alone: they wait for a rename, not for time, and un-parking one would
|
||||||
|
/// upload a file its writer has not finished. Returns whether a row changed.
|
||||||
|
pub fn retry_op_now(&self, id: i64, now: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?2
|
||||||
|
WHERE id = ?1 AND next_attempt_at > ?2 AND next_attempt_at < ?3",
|
||||||
|
params![id, now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||||
|
/// least once. Returns how many rows changed.
|
||||||
|
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?1
|
||||||
|
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
||||||
|
params![now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||||
/// the staged bytes are still the only copy of the user's write.
|
/// the staged bytes are still the only copy of the user's write.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -42,6 +42,15 @@ pub struct StoredPhoto {
|
|||||||
pub has_raw: bool,
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||||
/// sibling), when the server says it belongs to one.
|
/// sibling), when the server says it belongs to one.
|
||||||
pub main_uid: Option<String>,
|
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`
|
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||||
/// makes every other field authoritative — including a `None` that means
|
/// makes every other field authoritative — including a `None` that means
|
||||||
/// "the server no longer reports one" — while `None` means the refresh
|
/// "the server no longer reports one" — while `None` means the refresh
|
||||||
@@ -86,6 +97,9 @@ struct Learned {
|
|||||||
favorite: bool,
|
favorite: bool,
|
||||||
content_hash: Option<String>,
|
content_hash: Option<String>,
|
||||||
main_uid: Option<String>,
|
main_uid: Option<String>,
|
||||||
|
location: Option<(f64, f64)>,
|
||||||
|
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||||
|
similar_hash: Option<i64>,
|
||||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||||
/// does not make it look unresolved again.
|
/// does not make it look unresolved again.
|
||||||
resolved_at: Option<i64>,
|
resolved_at: Option<i64>,
|
||||||
@@ -281,7 +295,7 @@ impl Db {
|
|||||||
let learned: HashMap<String, Learned> = {
|
let learned: HashMap<String, Learned> = {
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||||
main_uid, resolved_at FROM photos",
|
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -295,6 +309,10 @@ impl Db {
|
|||||||
content_hash: r.get(6)?,
|
content_hash: r.get(6)?,
|
||||||
main_uid: r.get(7)?,
|
main_uid: r.get(7)?,
|
||||||
resolved_at: r.get(8)?,
|
resolved_at: r.get(8)?,
|
||||||
|
location: r
|
||||||
|
.get::<_, Option<f64>>(9)?
|
||||||
|
.zip(r.get::<_, Option<f64>>(10)?),
|
||||||
|
similar_hash: r.get(11)?,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
@@ -317,14 +335,20 @@ impl Db {
|
|||||||
carried.name = row.name.clone();
|
carried.name = row.name.clone();
|
||||||
carried.media_type = row.media_type.clone();
|
carried.media_type = row.media_type.clone();
|
||||||
carried.favorite = row.favorite.unwrap_or(false);
|
carried.favorite = row.favorite.unwrap_or(false);
|
||||||
|
// New bytes may show another picture: hash it again.
|
||||||
|
if row.content_hash != carried.content_hash {
|
||||||
|
carried.similar_hash = None;
|
||||||
|
}
|
||||||
carried.content_hash = row.content_hash.clone();
|
carried.content_hash = row.content_hash.clone();
|
||||||
carried.main_uid = row.main_uid.clone();
|
carried.main_uid = row.main_uid.clone();
|
||||||
|
carried.location = row.location;
|
||||||
} else {
|
} else {
|
||||||
carried.name = row.name.clone().or(carried.name);
|
carried.name = row.name.clone().or(carried.name);
|
||||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
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);
|
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||||
// The tab this photo lands in is derived here, once, so a page or
|
// The tab this photo lands in is derived here, once, so a page or
|
||||||
@@ -355,8 +379,10 @@ impl Db {
|
|||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||||
content_hash, main_uid, group_key, resolved_at)
|
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
place_id, similar_hash)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
|
?16, ?17)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, ((row, carried, kind), group_key)) in
|
for (seq, ((row, carried, kind), group_key)) in
|
||||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||||
@@ -375,6 +401,13 @@ impl Db {
|
|||||||
carried.main_uid,
|
carried.main_uid,
|
||||||
group_key,
|
group_key,
|
||||||
carried.resolved_at,
|
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),
|
||||||
|
carried.similar_hash,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,6 +480,14 @@ impl Db {
|
|||||||
albums.execute([uid])?;
|
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()?;
|
tx.commit()?;
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
@@ -455,7 +496,8 @@ impl Db {
|
|||||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||||
/// `favorites` restricts the page to favourited 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
|
/// 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
|
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||||
@@ -467,6 +509,7 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
|
not_in_album: bool,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
@@ -490,6 +533,15 @@ impl Db {
|
|||||||
if favorites {
|
if favorites {
|
||||||
conds.push("p.favorite = 1".to_string());
|
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() {
|
if !conds.is_empty() {
|
||||||
sql.push_str(" WHERE ");
|
sql.push_str(" WHERE ");
|
||||||
sql.push_str(&conds.join(" AND "));
|
sql.push_str(&conds.join(" AND "));
|
||||||
@@ -577,6 +629,256 @@ impl Db {
|
|||||||
Ok(rows)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
|
||||||
|
/// with where exactly it was taken. A group's own location is that of the
|
||||||
|
/// photo standing for it.
|
||||||
|
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
|
||||||
|
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
|
||||||
|
let located: HashMap<String, (f64, f64)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT uid, latitude, longitude FROM photos \
|
||||||
|
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
Ok(photos
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|photo| {
|
||||||
|
let at = *located.get(&photo.uid)?;
|
||||||
|
Some((photo, at))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||||
|
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||||
|
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||||
|
/// any of its files is, since they all show one picture.
|
||||||
|
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let uids: Vec<String> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||||
|
GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NULL \
|
||||||
|
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||||
|
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(
|
||||||
|
params![
|
||||||
|
crate::control::PhotoKind::Video.as_i64(),
|
||||||
|
THUMB_NONE,
|
||||||
|
limit as i64
|
||||||
|
],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
self.photos_by_uid(&uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the look of photo `uid`: its difference hash, or
|
||||||
|
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||||
|
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||||
|
params![uid, hash as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many shots have been hashed, out of how many could be.
|
||||||
|
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (hashed, total): (i64, i64) = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||||
|
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||||
|
GROUP BY COALESCE(group_key, uid) \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL \
|
||||||
|
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||||
|
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||||
|
/// shot in capture order, and the sets newest first. A set whose photos
|
||||||
|
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||||
|
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||||
|
let rows: Vec<(String, u64, Option<String>)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||||
|
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||||
|
FROM photos GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||||
|
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||||
|
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|set| {
|
||||||
|
let first = &rows[set[0]].2;
|
||||||
|
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.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>> = found
|
||||||
|
.iter()
|
||||||
|
.map(|set| {
|
||||||
|
let mut photos: Vec<StoredPhoto> = set
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||||
|
.collect();
|
||||||
|
photos.sort_by_key(|p| p.capture_time);
|
||||||
|
photos
|
||||||
|
})
|
||||||
|
.filter(|set| set.len() > 1)
|
||||||
|
.collect();
|
||||||
|
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)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
@@ -620,7 +922,7 @@ impl Db {
|
|||||||
Ok(photos)
|
Ok(photos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a photo's favourite flag locally, after the server accepted the
|
/// Record a photo's favorite flag locally, after the server accepted the
|
||||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||||
/// someone else's volume is never in our own `photos` table.
|
/// someone else's volume is never in our own `photos` table.
|
||||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p0", "p1", "p2"],
|
["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.
|
// The counts break down by tab, and a filtered page returns only its tab.
|
||||||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||||||
let videos = db
|
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();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
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,
|
// 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.
|
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||||||
let ranged = db
|
let ranged = db
|
||||||
.photos_page(0, 10, None, Some((150, 350)), false)
|
.photos_page(0, 10, None, Some((150, 350)), false, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
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(crate::control::PhotoKind::Video),
|
||||||
Some((150, 350)),
|
Some((150, 350)),
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -218,11 +226,11 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p1"],
|
["p1"],
|
||||||
"only the favourited photo is in a favourites page"
|
"only the favorited photo is in a favorites page"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A local toggle survives a refresh that could not resolve the photo's node
|
// A local toggle survives a refresh that could not resolve the photo's node
|
||||||
@@ -236,7 +244,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
TimelineRow::new("p2", 200),
|
TimelineRow::new("p2", 200),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p2"],
|
["p2"],
|
||||||
@@ -245,6 +253,305 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
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 town's photos come with where each was taken, one per RAW+JPEG shot.
|
||||||
|
#[test]
|
||||||
|
fn place_photos_carry_their_own_location() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let kreuzberg = (52.497, 13.411);
|
||||||
|
db.photos_replace(&[
|
||||||
|
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||||||
|
located("b1-jpeg", 300, "IMG_3.JPG", kreuzberg),
|
||||||
|
located("b1-raw", 300, "IMG_3.CR2", kreuzberg),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let id = db.photo_places().unwrap()[0].id;
|
||||||
|
let located: Vec<(String, (f64, f64))> = db
|
||||||
|
.photo_locations_at_place(id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(photo, at)| (photo.uid, at))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
located,
|
||||||
|
[("b2".to_owned(), BERLIN), ("b1-jpeg".to_owned(), kreuzberg)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shots that look alike form a set, one photo per shot, while a set of
|
||||||
|
/// byte-identical copies is left to the duplicate finder.
|
||||||
|
#[test]
|
||||||
|
fn similar_photos_group_by_look_and_skip_exact_copies() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
hashed("big", 500, "IMG_1.JPG", "h1"),
|
||||||
|
hashed("small", 400, "IMG_1-small.JPG", "h2"),
|
||||||
|
hashed("copy", 300, "IMG_2.JPG", "h3"),
|
||||||
|
hashed("copy2", 200, "IMG_2(1).JPG", "h3"),
|
||||||
|
hashed("other", 100, "IMG_3.JPG", "h4"),
|
||||||
|
hashed("flat", 50, "IMG_4.JPG", "h5"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let look = 0x0f0f_3c3c_5a5a_9696_u64;
|
||||||
|
db.photo_set_similar_hash("big", look).unwrap();
|
||||||
|
db.photo_set_similar_hash("small", look ^ 0b101).unwrap();
|
||||||
|
db.photo_set_similar_hash("copy", !look).unwrap();
|
||||||
|
db.photo_set_similar_hash("copy2", !look).unwrap();
|
||||||
|
db.photo_set_similar_hash("other", look ^ 0xffff_ffff)
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_similar_hash("flat", crate::similar::NO_HASH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sets = db.photo_similar().unwrap();
|
||||||
|
let uids: Vec<Vec<&str>> = sets
|
||||||
|
.iter()
|
||||||
|
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(uids, [vec!["small", "big"]]);
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (6, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is left to hash skips videos and hashed shots, and a hash survives a
|
||||||
|
/// refresh unless the photo's bytes changed.
|
||||||
|
#[test]
|
||||||
|
fn similar_hashes_are_kept_until_the_photo_changes() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let resolved = |uid: &str, hash: &str| TimelineRow {
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..hashed(uid, 100, &format!("{uid}.JPG"), hash)
|
||||||
|
};
|
||||||
|
db.photos_replace(&[
|
||||||
|
resolved("a", "h1"),
|
||||||
|
resolved("b", "h2"),
|
||||||
|
hashed("clip", 50, "clip.mp4", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_similar_hash("a", 7).unwrap();
|
||||||
|
db.photo_set_similar_hash("b", 9).unwrap();
|
||||||
|
let left = |db: &Db| -> Vec<String> {
|
||||||
|
db.photos_unhashed(10)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.uid)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert!(left(&db).is_empty());
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (2, 2));
|
||||||
|
|
||||||
|
db.photos_replace(&[
|
||||||
|
resolved("a", "h1"),
|
||||||
|
resolved("b", "h2-edited"),
|
||||||
|
hashed("clip", 50, "clip.mp4", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left(&db), ["b"]);
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (1, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[test]
|
||||||
fn photos_page_slices_the_timeline_in_order() {
|
fn photos_page_slices_the_timeline_in_order() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -253,12 +560,16 @@ fn photos_page_slices_the_timeline_in_order() {
|
|||||||
.collect();
|
.collect();
|
||||||
db.photos_replace(&items).unwrap();
|
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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p2", "p3"]
|
["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]
|
#[test]
|
||||||
@@ -272,6 +583,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
size: 10,
|
size: 10,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
parent_uid: Some("t2".into()),
|
parent_uid: Some("t2".into()),
|
||||||
|
trashed_at: Some(100),
|
||||||
},
|
},
|
||||||
StoredTrash {
|
StoredTrash {
|
||||||
uid: "t2".into(),
|
uid: "t2".into(),
|
||||||
@@ -280,6 +592,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
size: 0,
|
size: 0,
|
||||||
mtime: 2,
|
mtime: 2,
|
||||||
parent_uid: None,
|
parent_uid: None,
|
||||||
|
trashed_at: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -302,6 +615,15 @@ fn trash_replace_lists_folders_first() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The first-seen times round-trip, unknown ones included.
|
||||||
|
let mut seen: Vec<_> = db.trash_seen().unwrap().into_iter().collect();
|
||||||
|
seen.sort();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
[("t1".to_string(), Some(100)), ("t2".to_string(), None)]
|
||||||
|
);
|
||||||
|
assert_eq!(items[1].trashed_at, Some(100));
|
||||||
|
|
||||||
// A replace is a replace: emptying the trash on the server empties it here.
|
// A replace is a replace: emptying the trash on the server empties it here.
|
||||||
db.trash_replace(&[]).unwrap();
|
db.trash_replace(&[]).unwrap();
|
||||||
assert!(db.trash_list().unwrap().is_empty());
|
assert!(db.trash_list().unwrap().is_empty());
|
||||||
@@ -1083,6 +1405,40 @@ fn search_trigram_substring_and_path() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_in_scope_only_returns_hits_below_the_folder() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||||
|
db.upsert_node(&folder("work", Some("root"), "Work"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&folder("sub", Some("work"), "Old")).unwrap();
|
||||||
|
db.upsert_node(&folder("work2", Some("root"), "Work 2"))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f1", "work", "report.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f2", "sub", "report-2019.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f3", "work2", "report-copy.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
db.upsert_node(&file("f4", "root", "report-top.pdf", 1))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut paths: Vec<String> = db
|
||||||
|
.search_in("report", 10, Some("Work"))
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| h.path)
|
||||||
|
.collect();
|
||||||
|
paths.sort();
|
||||||
|
// A sibling whose name starts with the scope is not inside it.
|
||||||
|
assert_eq!(paths, vec!["Work/Old/report-2019.pdf", "Work/report.pdf"]);
|
||||||
|
// Short queries take the LIKE path and scope the same way.
|
||||||
|
assert_eq!(db.search_in("re", 10, Some("Work/Old")).unwrap().len(), 1);
|
||||||
|
// No scope, or the root, is everywhere.
|
||||||
|
assert_eq!(db.search_in("report", 10, None).unwrap().len(), 4);
|
||||||
|
assert_eq!(db.search_in("report", 10, Some("")).unwrap().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_excludes_trashed_and_respects_limit() {
|
fn search_excludes_trashed_and_respects_limit() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -1380,6 +1736,29 @@ fn activity_reads_back_newest_first() {
|
|||||||
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
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]
|
#[test]
|
||||||
fn activity_prunes_to_the_keep_limit() {
|
fn activity_prunes_to_the_keep_limit() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -4020,7 +4399,7 @@ fn photos_delete_removes_the_photo_and_its_album_membership() {
|
|||||||
|
|
||||||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p1", "p3"],
|
["p1", "p3"],
|
||||||
@@ -4060,7 +4439,7 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["jpeg", "alone"],
|
["jpeg", "alone"],
|
||||||
@@ -4073,7 +4452,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.
|
// The Raw tab answers "which raws do I have", so it lists files.
|
||||||
let raws = db
|
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();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
@@ -4118,7 +4504,7 @@ fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["cover", "notes", "other"],
|
["cover", "notes", "other"],
|
||||||
@@ -4157,7 +4543,7 @@ fn a_shared_name_alone_does_not_group_photos() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.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_eq!(page.len(), 3, "three shots, three tiles");
|
||||||
assert!(page.iter().all(|p| p.group_size == 1));
|
assert!(page.iter().all(|p| p.group_size == 1));
|
||||||
}
|
}
|
||||||
@@ -4180,7 +4566,7 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["main"]
|
["main"]
|
||||||
@@ -4201,6 +4587,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
|||||||
/// A database written before grouping existed opens, keeps its timeline, and
|
/// A database written before grouping existed opens, keeps its timeline, and
|
||||||
/// shows every photo — a row with no `group_key` is its own group, which is how
|
/// shows every photo — a row with no `group_key` is its own group, which is how
|
||||||
/// the gallery behaved before the column.
|
/// the gallery behaved before the column.
|
||||||
|
#[test]
|
||||||
|
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v31-fixture-{}-{}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
{
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("VID-20260804-WA0023.mp4".into()),
|
||||||
|
..TimelineRow::new("video", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("broken.jpg".into()),
|
||||||
|
..TimelineRow::new("photo", 200)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
|
||||||
|
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
|
||||||
|
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
|
||||||
|
assert_eq!(
|
||||||
|
state("photo"),
|
||||||
|
THUMB_NONE,
|
||||||
|
"an undecodable photo stays written off"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_v30_leaves_a_v29_timeline_intact() {
|
fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
@@ -4240,7 +4668,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db = Db::open(&path).unwrap();
|
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!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["jpeg", "raw"],
|
["jpeg", "raw"],
|
||||||
@@ -4261,7 +4689,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.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.len(), 1);
|
||||||
assert_eq!(page[0].uid, "jpeg");
|
assert_eq!(page[0].uid, "jpeg");
|
||||||
|
|
||||||
@@ -4290,14 +4718,14 @@ fn photos_replace_keeps_a_skipped_photos_name() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.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_eq!(page.len(), 1, "one shot, two files");
|
||||||
assert!(page[0].has_raw);
|
assert!(page[0].has_raw);
|
||||||
|
|
||||||
// The next refresh resolves neither, because both are already resolved.
|
// The next refresh resolves neither, because both are already resolved.
|
||||||
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
||||||
.unwrap();
|
.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.len(), 1, "the group survives a skipped refresh");
|
||||||
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
||||||
assert!(page[0].has_raw, "the raw file is still a raw file");
|
assert!(page[0].has_raw, "the raw file is still a raw file");
|
||||||
@@ -4480,3 +4908,58 @@ fn migration_v31_treats_an_existing_timeline_as_resolved() {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn queued_op(db: &Db, uid: &str, failures: i64, next_attempt_at: i64) -> i64 {
|
||||||
|
let (id, _) = db
|
||||||
|
.enqueue_op(&ops::PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: ops::OP_REVISION.to_string(),
|
||||||
|
uid: uid.to_string(),
|
||||||
|
parent_uid: None,
|
||||||
|
name: None,
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 1000,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..failures {
|
||||||
|
db.record_op_failure(id, "boom", next_attempt_at).unwrap();
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Retry now" must make a backed-off op due, but never un-park a transient
|
||||||
|
/// file's op: that one waits for its finishing rename, not for time.
|
||||||
|
#[test]
|
||||||
|
fn retry_now_makes_a_backed_off_op_due_but_leaves_parked_ops_parked() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let backed_off = queued_op(&db, "vol~a", 3, 90_000);
|
||||||
|
let parked = queued_op(&db, "vol~b", 0, ops::PARK_UNTIL);
|
||||||
|
|
||||||
|
assert!(db.retry_op_now(backed_off, 5_000).unwrap());
|
||||||
|
assert!(!db.retry_op_now(parked, 5_000).unwrap());
|
||||||
|
|
||||||
|
let ops = db.pending_ops().unwrap();
|
||||||
|
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||||||
|
assert_eq!(due(backed_off), 5_000);
|
||||||
|
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_all_touches_only_ops_that_have_failed() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let failed = queued_op(&db, "vol~a", 2, 90_000);
|
||||||
|
let debounced = queued_op(&db, "vol~b", 0, 7_000);
|
||||||
|
let parked = queued_op(&db, "vol~c", 4, ops::PARK_UNTIL);
|
||||||
|
|
||||||
|
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
|
||||||
|
|
||||||
|
let ops = db.pending_ops().unwrap();
|
||||||
|
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||||||
|
assert_eq!(due(failed), 5_000);
|
||||||
|
assert_eq!(due(debounced), 7_000);
|
||||||
|
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
@@ -15,6 +17,9 @@ pub struct StoredTrash {
|
|||||||
/// row written before schema v29, and for a node whose parent the server
|
/// row written before schema v29, and for a node whose parent the server
|
||||||
/// did not report.
|
/// did not report.
|
||||||
pub parent_uid: Option<String>,
|
pub parent_uid: Option<String>,
|
||||||
|
/// When the daemon first saw the node in the trash, epoch seconds. `None`
|
||||||
|
/// when it cannot say (see schema v35).
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -24,8 +29,8 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM trash", [])?;
|
tx.execute("DELETE FROM trash", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
|
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for item in items {
|
for item in items {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
@@ -34,7 +39,8 @@ impl Db {
|
|||||||
item.is_dir as i64,
|
item.is_dir as i64,
|
||||||
item.size,
|
item.size,
|
||||||
item.mtime,
|
item.mtime,
|
||||||
item.parent_uid
|
item.parent_uid,
|
||||||
|
item.trashed_at
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +53,7 @@ impl Db {
|
|||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -58,6 +64,7 @@ impl Db {
|
|||||||
size: r.get(3)?,
|
size: r.get(3)?,
|
||||||
mtime: r.get(4)?,
|
mtime: r.get(4)?,
|
||||||
parent_uid: r.get(5)?,
|
parent_uid: r.get(5)?,
|
||||||
|
trashed_at: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -81,5 +88,19 @@ impl Db {
|
|||||||
Ok(pairs)
|
Ok(pairs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||||
|
/// a refresh can carry the times over into the listing it writes.
|
||||||
|
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (uid, at) = row?;
|
||||||
|
seen.insert(uid, at);
|
||||||
|
}
|
||||||
|
Ok(seen)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync (devices.md) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,20 @@ impl Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||||
|
/// daemon is running but stuck, as opposed to not running at all.
|
||||||
|
pub fn is_timeout(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Error::Io(e) if matches!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// A failure on its way to a front-end: prose for the user, plus the
|
/// A failure on its way to a front-end: prose for the user, plus the
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ pub mod localindex;
|
|||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
|
pub mod places;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sdkcache;
|
pub mod sdkcache;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
|
pub mod tray;
|
||||||
|
|
||||||
pub use access::{Access, access_for, perm_bits};
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
pub use error::{CoreError, CoreResult, Error, Result};
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ pub struct MountSpec {
|
|||||||
/// Live device sync progress, absent when no pass is running.
|
/// Live device sync progress, absent when no pass is running.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub progress: Option<SyncProgress>,
|
pub progress: Option<SyncProgress>,
|
||||||
|
/// A synced folder paused on its own. Live daemon state, not a stored column.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Telling photos apart by what they show rather than by their bytes.
|
||||||
|
//!
|
||||||
|
//! The server's content hash only matches byte-identical files. A photo that
|
||||||
|
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||||
|
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||||
|
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||||
|
//! copies of one picture land a few bits apart, and different pictures about
|
||||||
|
//! half of the bits apart.
|
||||||
|
|
||||||
|
/// Hashes this many bits apart or fewer show the same picture.
|
||||||
|
pub const SIMILAR_BITS: u32 = 6;
|
||||||
|
|
||||||
|
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||||
|
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||||
|
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||||
|
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||||
|
pub const NO_HASH: u64 = 0;
|
||||||
|
|
||||||
|
/// Samples of the grid that differ by less than this in brightness, all of
|
||||||
|
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||||
|
/// noise and would match every other flat picture.
|
||||||
|
const FLAT: u8 = 8;
|
||||||
|
|
||||||
|
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||||
|
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||||
|
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||||
|
if width == 0 || height == 0 || luma.len() < width * height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||||
|
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||||
|
let mut grid = [[0u8; 9]; 8];
|
||||||
|
for (gy, row) in grid.iter_mut().enumerate() {
|
||||||
|
let (y0, y1) = span(gy, 8, height);
|
||||||
|
for (gx, cell) in row.iter_mut().enumerate() {
|
||||||
|
let (x0, x1) = span(gx, 9, width);
|
||||||
|
let mut sum = 0u64;
|
||||||
|
for y in y0..y1 {
|
||||||
|
sum += luma[y * width + x0..y * width + x1]
|
||||||
|
.iter()
|
||||||
|
.map(|&v| u64::from(v))
|
||||||
|
.sum::<u64>();
|
||||||
|
}
|
||||||
|
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (min, max) = grid
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||||
|
if max - min < FLAT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hash = 0u64;
|
||||||
|
for row in &grid {
|
||||||
|
for pair in row.windows(2) {
|
||||||
|
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(hash != NO_HASH).then_some(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||||
|
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||||
|
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||||
|
let from = (i * len / cells).min(len - 1);
|
||||||
|
let to = ((i + 1) * len / cells).max(from + 1);
|
||||||
|
(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two hashes show the same picture.
|
||||||
|
pub fn alike(a: u64, b: u64) -> bool {
|
||||||
|
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||||
|
/// and the sets by their first index. A hash alike to none other is in no
|
||||||
|
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||||
|
/// from the last is one set.
|
||||||
|
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||||
|
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||||
|
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||||
|
// byte are compared, not every pair of the library.
|
||||||
|
const _: () = assert!(SIMILAR_BITS < 8);
|
||||||
|
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||||
|
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
for byte in 0..8 {
|
||||||
|
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||||
|
for (i, &hash) in hashes.iter().enumerate() {
|
||||||
|
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||||
|
}
|
||||||
|
for bucket in &buckets {
|
||||||
|
for (n, &a) in bucket.iter().enumerate() {
|
||||||
|
for &b in &bucket[n + 1..] {
|
||||||
|
if alike(hashes[a], hashes[b]) {
|
||||||
|
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||||
|
if ra != rb {
|
||||||
|
parent[ra.max(rb)] = ra.min(rb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||||
|
for i in 0..hashes.len() {
|
||||||
|
let r = root(&mut parent, i);
|
||||||
|
by_root.entry(r).or_default().push(i);
|
||||||
|
}
|
||||||
|
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A 90×80 picture: a bright disc on a gradient.
|
||||||
|
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut luma = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||||
|
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||||
|
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||||
|
luma.push(v as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luma
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resized_copy_hashes_alike() {
|
||||||
|
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||||
|
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||||
|
assert!(
|
||||||
|
alike(big, small),
|
||||||
|
"{} bits apart",
|
||||||
|
(big ^ small).count_ones()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_picture_hashes_apart() {
|
||||||
|
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||||
|
let mirrored: Vec<u8> = picture(90, 80)
|
||||||
|
.chunks(90)
|
||||||
|
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||||
|
.collect();
|
||||||
|
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||||
|
assert!(!alike(one, other));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flat_picture_has_no_hash() {
|
||||||
|
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||||
|
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||||
|
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||||
|
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||||
|
assert!(difference_hash(4, 3, &luma).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alike_hashes_form_sets_and_catch_on() {
|
||||||
|
let a = 0x0123_4567_89ab_cdef_u64;
|
||||||
|
let hashes = [
|
||||||
|
a,
|
||||||
|
!a,
|
||||||
|
a ^ 0b111,
|
||||||
|
a ^ 0b111 ^ (0b1111 << 40),
|
||||||
|
0x5555_0000_ffff_aaaa,
|
||||||
|
];
|
||||||
|
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||||
|
// One bit apart in each of six bytes: six bits in all, and only the
|
||||||
|
// top two bytes agree.
|
||||||
|
let a = 1u64 << 63;
|
||||||
|
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||||
|
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is scratch another program writes and throws away: the
|
||||||
|
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
|
||||||
|
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
|
||||||
|
/// them is a file the user made, so a front-end can hide them from a history
|
||||||
|
/// of what happened to the user's files.
|
||||||
|
pub fn is_scratch_name(name: &str) -> bool {
|
||||||
|
if is_transient_name(name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
lower.ends_with(".tmp")
|
||||||
|
|| lower.ends_with(".temp")
|
||||||
|
|| name.starts_with(".goutputstream-")
|
||||||
|
|| name.starts_with(".org.chromium.")
|
||||||
|
|| name.starts_with(".com.google.chrome.")
|
||||||
|
|| name.starts_with(".#")
|
||||||
|
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|
||||||
|
|| (name.starts_with('.') && name.contains(".tmp."))
|
||||||
|
}
|
||||||
|
|
||||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||||
|
|
||||||
@@ -341,6 +361,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_by_rename_temporaries_are_scratch() {
|
||||||
|
for name in [
|
||||||
|
".goutputstream-4XK2B2",
|
||||||
|
".org.chromium.Chromium.aBc123",
|
||||||
|
"~$report.docx",
|
||||||
|
".~lock.budget.ods#",
|
||||||
|
".#notes.org",
|
||||||
|
"#notes.org#",
|
||||||
|
"cache.tmp",
|
||||||
|
"page.html.crdownload",
|
||||||
|
] {
|
||||||
|
assert!(is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
||||||
|
assert!(!is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finished_names_are_not_transient() {
|
fn finished_names_are_not_transient() {
|
||||||
for name in [
|
for name in [
|
||||||
|
|||||||
@@ -812,7 +812,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidecar_supplies_capture_time_and_favourite() {
|
fn sidecar_supplies_capture_time_and_favorite() {
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Talking to a running `pdfs-tray`.
|
||||||
|
//!
|
||||||
|
//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same
|
||||||
|
//! socket takes one command, [`QUIT`], so the desktop app can take the icon
|
||||||
|
//! away when the user hides it or stops Proton Drive.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
|
use crate::config::AppDirs;
|
||||||
|
|
||||||
|
/// The line that asks the tray to exit.
|
||||||
|
pub const QUIT: &str = "quit";
|
||||||
|
|
||||||
|
/// Ask a running tray to exit. False when no tray answers.
|
||||||
|
pub fn quit(dirs: &AppDirs) -> bool {
|
||||||
|
UnixStream::connect(dirs.tray_socket())
|
||||||
|
.and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes()))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
|
||||||
use pdfs_core::db::StoredAlbum;
|
use pdfs_core::db::StoredAlbum;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
@@ -137,10 +137,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_albums().await {
|
let refreshed = core.refresh_albums().await;
|
||||||
warn!(error = %e, "background album refresh failed");
|
|
||||||
}
|
|
||||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||||
|
Err(e) => warn!(error = %e, "background album refresh failed"),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +177,40 @@ impl Core {
|
|||||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
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.
|
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||||
let photos = self.photos();
|
let photos = self.photos();
|
||||||
@@ -221,6 +257,84 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create an album and return its uid. The listing is refreshed before
|
||||||
|
/// answering, so the next [`Core::albums`] already shows it.
|
||||||
|
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
|
||||||
|
let uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().create_album(name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "create album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(uid.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().rename_album(album, name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
|
||||||
|
/// which only matters for photos that exist in the album alone.
|
||||||
|
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().delete_album(album, delete_photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
|
||||||
|
let key = album.to_string();
|
||||||
|
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
|
||||||
|
warn!(%album, %error, "a deleted album's photos could not be dropped");
|
||||||
|
}
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add photos to an album, per photo: `(added, failed)`.
|
||||||
|
pub(crate) fn add_to_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().add_photos_to_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
|
||||||
|
/// stay in the timeline.
|
||||||
|
pub(crate) fn remove_from_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().remove_photos_from_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the persisted listing, and `album`'s contents when given, up to
|
||||||
|
/// date after a change made here, so the reply is followed by a listing
|
||||||
|
/// that shows it. A failed refresh only leaves them stale: the change
|
||||||
|
/// itself went through.
|
||||||
|
fn after_album_change(&self, album: Option<&NodeUid>) {
|
||||||
|
self.invalidate_albums();
|
||||||
|
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
|
||||||
|
warn!(%error, "album listing refresh after a change failed");
|
||||||
|
}
|
||||||
|
if let Some(album) = album
|
||||||
|
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
|
||||||
|
{
|
||||||
|
warn!(%album, %error, "album refresh after a change failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||||
/// to the timeline can just as easily be a change to an album.
|
/// to the timeline can just as easily be a change to an album.
|
||||||
@@ -230,6 +344,20 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos an album change applied to, and the ones it failed for with why.
|
||||||
|
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
|
||||||
|
|
||||||
|
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
|
||||||
|
let (done, failed) = pdfs_core::batch::split(outcomes);
|
||||||
|
(
|
||||||
|
done.iter().map(|uid| uid.to_string()).collect(),
|
||||||
|
failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Project a persisted album into the wire item a front-end paints.
|
/// Project a persisted album into the wire item a front-end paints.
|
||||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||||
AlbumInfo {
|
AlbumInfo {
|
||||||
|
|||||||
@@ -422,6 +422,10 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
|
// moved, including what is shared and what is in the trash.
|
||||||
|
core.events
|
||||||
|
.publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +483,7 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
apply_photo_events(&core, &events);
|
apply_photo_events(&core, &events);
|
||||||
|
core.events.publish(&[Topic::Photos]);
|
||||||
// One cursor write per batch, after the batch is applied: a crash in
|
// One cursor write per batch, after the batch is applied: a crash in
|
||||||
// between replays events whose effects are idempotent.
|
// between replays events whose effects are idempotent.
|
||||||
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||||
@@ -511,7 +516,7 @@ impl PhotoEventPlan {
|
|||||||
///
|
///
|
||||||
/// `is_echo` claims an event as this daemon's own write. The feed replays our
|
/// `is_echo` claims an event as this daemon's own write. The feed replays our
|
||||||
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
|
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
|
||||||
/// every favourite the user toggles here would mark its own photo stale and buy
|
/// every favorite the user toggles here would mark its own photo stale and buy
|
||||||
/// a resolve of a node we just wrote.
|
/// a resolve of a node we just wrote.
|
||||||
fn classify_photo_events(
|
fn classify_photo_events(
|
||||||
events: &[DriveEvent],
|
events: &[DriveEvent],
|
||||||
@@ -529,7 +534,7 @@ fn classify_photo_events(
|
|||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
plan.gone.push(node_uid.to_string());
|
plan.gone.push(node_uid.to_string());
|
||||||
} else if !is_echo(node_uid) {
|
} else if !is_echo(node_uid) {
|
||||||
// An untrash, a rename, a favourite toggled on the phone: the
|
// An untrash, a rename, a favorite toggled on the phone: the
|
||||||
// event says which node changed but never what about it, so
|
// event says which node changed but never what about it, so
|
||||||
// the uid is marked stale and the next refresh reads it.
|
// the uid is marked stale and the next refresh reads it.
|
||||||
plan.touched.push(node_uid.to_string());
|
plan.touched.push(node_uid.to_string());
|
||||||
@@ -974,7 +979,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
DriveEvent::NodeUpdated {
|
DriveEvent::NodeUpdated {
|
||||||
id: DriveEventId::from("e3"),
|
id: DriveEventId::from("e3"),
|
||||||
node_uid: uid("favourited"),
|
node_uid: uid("favorited"),
|
||||||
parent_node_uid: None,
|
parent_node_uid: None,
|
||||||
is_trashed: false,
|
is_trashed: false,
|
||||||
is_shared: false,
|
is_shared: false,
|
||||||
@@ -986,7 +991,7 @@ mod tests {
|
|||||||
plan,
|
plan,
|
||||||
PhotoEventPlan {
|
PhotoEventPlan {
|
||||||
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
||||||
touched: vec![uid("favourited").to_string()],
|
touched: vec![uid("favorited").to_string()],
|
||||||
full: false,
|
full: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! Listing and resolving `(sync-conflict …)` copies for the user.
|
||||||
|
//!
|
||||||
|
//! The sweep ([`crate::sweep`]) removes copies it can prove identical and flags
|
||||||
|
//! the rest once in the activity feed. That leaves divergent copies sitting in
|
||||||
|
//! folders with nothing but a name to find them by. This module lists them with
|
||||||
|
//! the file they are a copy of, and resolves one the way the user chooses:
|
||||||
|
//!
|
||||||
|
//! - **Keep the original**: trash the copy.
|
||||||
|
//! - **Keep the copy**: trash the original, then give the copy its name.
|
||||||
|
//! - **Keep both**: rename the copy to a name of the user's choosing.
|
||||||
|
//!
|
||||||
|
//! Every resolution goes through the Proton trash, never a delete, so a wrong
|
||||||
|
//! choice is undone by restoring from Trash. Resolution refuses to touch a file
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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 {
|
||||||
|
/// 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
|
||||||
|
.load_all()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?;
|
||||||
|
let nodes: Vec<Node> = stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|stored| stored.node)
|
||||||
|
.filter(|node| !node.trashed && !node.is_folder() && self.is_own_or_virtual(&node.uid))
|
||||||
|
.collect();
|
||||||
|
let mut by_parent_name: HashMap<(Option<&NodeUid>, &str), &Node> = HashMap::new();
|
||||||
|
for node in &nodes {
|
||||||
|
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||||
|
}
|
||||||
|
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 Some(path) = self.conflict_path(&roots, &node.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let original = by_parent_name
|
||||||
|
.get(&(node.parent_uid.as_ref(), base.as_str()))
|
||||||
|
.copied();
|
||||||
|
items.push(ConflictInfo {
|
||||||
|
original_path: sibling_path(&path, &base),
|
||||||
|
original_exists: original.is_some(),
|
||||||
|
size: node_size(node),
|
||||||
|
modified: node.modification_time,
|
||||||
|
original_size: original.map(node_size),
|
||||||
|
original_modified: original.map(|o| o.modification_time),
|
||||||
|
identical: original.is_some_and(|o| {
|
||||||
|
node_size(o) == node_size(node)
|
||||||
|
&& node_content_sha1(node)
|
||||||
|
.is_some_and(|sha| node_content_sha1(o).as_deref() == Some(&sha))
|
||||||
|
}),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the conflict copy at mountpoint-relative `copy`. Returns a line
|
||||||
|
/// for the user saying what happened.
|
||||||
|
pub(crate) fn resolve_conflict(&self, copy: &Path, keep: &ConflictKeep) -> CoreResult<String> {
|
||||||
|
let copy_name = copy
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or_else(|| CoreError::invalid("the conflict copy has no file name"))?;
|
||||||
|
let base = conflict_base_name(copy_name)
|
||||||
|
.ok_or_else(|| CoreError::invalid(format!("{copy_name} is not a conflict copy")))?;
|
||||||
|
let original = copy.with_file_name(&base);
|
||||||
|
self.require_settled(copy)?;
|
||||||
|
let message = match keep {
|
||||||
|
ConflictKeep::Original => {
|
||||||
|
self.delete(copy)?;
|
||||||
|
self.log_activity(ActivityKind::Trash, copy_name, format!("kept {base}"), true);
|
||||||
|
format!("kept {base}; the copy is in Trash")
|
||||||
|
}
|
||||||
|
ConflictKeep::Copy => {
|
||||||
|
self.require_settled(&original)?;
|
||||||
|
self.delete(&original)?;
|
||||||
|
// The original is already in Trash. A failed rename leaves the
|
||||||
|
// copy under its conflict name, which loses nothing — say so
|
||||||
|
// rather than leave the user guessing where their file went.
|
||||||
|
self.rename(copy, &base).map_err(|e| {
|
||||||
|
e.context(&format!(
|
||||||
|
"{base} was moved to Trash, but the copy kept its conflict name"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Rename,
|
||||||
|
&base,
|
||||||
|
format!("kept the conflict copy {copy_name}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
format!("kept the copy as {base}; the previous version is in Trash")
|
||||||
|
}
|
||||||
|
ConflictKeep::Both { name } => {
|
||||||
|
let name = name.trim();
|
||||||
|
if name == base || name == copy_name {
|
||||||
|
return Err(CoreError::invalid("choose a name that no other file has"));
|
||||||
|
}
|
||||||
|
self.rename(copy, name)?;
|
||||||
|
self.log_activity(ActivityKind::Rename, name, format!("was {copy_name}"), true);
|
||||||
|
format!("kept both; the copy is now {name}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to resolve around a file that is open, being written, or still
|
||||||
|
/// owed an upload: the decision would be about content that is changing.
|
||||||
|
fn require_settled(&self, rel: &Path) -> CoreResult<()> {
|
||||||
|
let (_, uid) = self.resolve(rel)?;
|
||||||
|
let queued = self.db.has_any_op(&uid.to_string()).unwrap_or(true);
|
||||||
|
if queued || self.is_busy(&uid) {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{} is still being written or uploaded; try again once it has synced",
|
||||||
|
rel.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.conflict_notified.lock().remove(&uid);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path of `name` next to the file at `path`.
|
||||||
|
fn sibling_path(path: &str, name: &str) -> String {
|
||||||
|
match path.rsplit_once('/') {
|
||||||
|
Some((dir, _)) => format!("{dir}/{name}"),
|
||||||
|
None => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_original_sits_next_to_its_copy() {
|
||||||
|
assert_eq!(
|
||||||
|
sibling_path("a/b/f (sync-conflict 1).txt", "f.txt"),
|
||||||
|
"a/b/f.txt"
|
||||||
|
);
|
||||||
|
assert_eq!(sibling_path("f (sync-conflict 1).txt", "f.txt"), "f.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest,
|
||||||
TransferDirection, TrashFailure,
|
Response as CtlResponse, Topic, TransferDirection, TrashFailure,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
@@ -29,6 +29,26 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
|||||||
|
|
||||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||||
|
/// into an error that names what is still shared.
|
||||||
|
fn stop_sharing_reply(
|
||||||
|
core: &Core,
|
||||||
|
target: &str,
|
||||||
|
result: CoreResult<super::sharing::StopSharingOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
match result.map(|outcome| outcome.into_result()) {
|
||||||
|
Ok(Ok(message)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true);
|
||||||
|
CtlResponse::Ok { message }
|
||||||
|
}
|
||||||
|
Ok(Err(left)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, &left, false);
|
||||||
|
CtlResponse::error(CoreError::remote(left))
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A client that connects, sends a request and then never reads its reply used
|
/// A client that connects, sends a request and then never reads its reply used
|
||||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||||
@@ -46,6 +66,32 @@ const MAX_ROW_LIMIT: usize = 1000;
|
|||||||
fn clamp_limit(limit: usize) -> usize {
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
limit.min(MAX_ROW_LIMIT)
|
limit.min(MAX_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
|
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||||
|
/// per photo.
|
||||||
|
fn album_change(
|
||||||
|
uid: &str,
|
||||||
|
photos: &[String],
|
||||||
|
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
let Some(album) = parse_uid(uid) else {
|
||||||
|
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||||
|
};
|
||||||
|
let photos = match Core::parse_uids(photos) {
|
||||||
|
Ok(photos) => photos,
|
||||||
|
Err(e) => return CtlResponse::error(e),
|
||||||
|
};
|
||||||
|
match change(&album, &photos) {
|
||||||
|
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||||
|
changed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -139,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))
|
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
|
/// 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
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
@@ -208,6 +286,155 @@ impl Drop for RefreshGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reply to [`CtlRequest::Status`], which subscriptions are also sent
|
||||||
|
/// whenever it changes.
|
||||||
|
pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse {
|
||||||
|
let pins = core.cache.list_pins();
|
||||||
|
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||||
|
let staging = core.cache.staging_usage();
|
||||||
|
CtlResponse::Status {
|
||||||
|
parked_uploads: queued.parked.max(0) as u64,
|
||||||
|
failing_ops: queued.failing.max(0) as u64,
|
||||||
|
failing_error: queued.last_error.clone(),
|
||||||
|
staged_bytes: staging.bytes,
|
||||||
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
|
paused: core.sync_pause().is_some(),
|
||||||
|
paused_until: core.sync_pause().flatten(),
|
||||||
|
username: username.to_string(),
|
||||||
|
mountpoint: mountpoint.display().to_string(),
|
||||||
|
pinned: pins.len(),
|
||||||
|
used: core.cache.usage(),
|
||||||
|
budget: core.cache.budget(),
|
||||||
|
pins,
|
||||||
|
online: core.online.load(Ordering::Relaxed),
|
||||||
|
pending_uploads: queued.uploads.max(0) as u64,
|
||||||
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a front-end shows that `request` changes when it succeeds. Requests
|
||||||
|
/// from the CLI publish the same way as the app's, so an open window follows
|
||||||
|
/// a change made in a terminal.
|
||||||
|
fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||||
|
use CtlRequest as R;
|
||||||
|
match request {
|
||||||
|
R::Pin { .. } | R::Unpin { .. } => &[Topic::Files],
|
||||||
|
R::CreateAlbum { .. }
|
||||||
|
| R::RenameAlbum { .. }
|
||||||
|
| R::DeleteAlbum { .. }
|
||||||
|
| R::AddToAlbum { .. }
|
||||||
|
| R::RemoveFromAlbum { .. }
|
||||||
|
| R::SetPhotoFavorite { .. }
|
||||||
|
| R::UploadPhoto { .. } => &[Topic::Photos],
|
||||||
|
R::TrashNodes { .. } => &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Rename { .. } | R::Move { .. } | R::CreateFolder { .. } | R::UploadPaths { .. } => {
|
||||||
|
&[Topic::Files, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Delete { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Restore { .. } | R::DeleteForever { .. } | R::EmptyTrash => {
|
||||||
|
&[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Refresh { scope } => match scope {
|
||||||
|
RefreshScope::Dir { .. } => &[Topic::Files],
|
||||||
|
RefreshScope::Trash => &[Topic::Trash],
|
||||||
|
// A photos refresh answers once it has started; the timeline
|
||||||
|
// publishes again when it lands.
|
||||||
|
RefreshScope::Photos { .. } => &[],
|
||||||
|
},
|
||||||
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
|
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
|
R::AddSyncFolder { .. }
|
||||||
|
| R::RemoveSyncFolder { .. }
|
||||||
|
| R::SetSyncFolderMode { .. }
|
||||||
|
| R::SetSyncFolderPaused { .. }
|
||||||
|
| R::RestoreSyncFolders { .. }
|
||||||
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
|
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::ShareNode { .. }
|
||||||
|
| R::ShareNodeByUid { .. }
|
||||||
|
| R::UpdateShareRole { .. }
|
||||||
|
| R::UpdateShareRoleByUid { .. }
|
||||||
|
| R::RemoveShareEntry { .. }
|
||||||
|
| R::RemoveShareEntryByUid { .. }
|
||||||
|
| R::CreatePublicLink { .. }
|
||||||
|
| R::CreatePublicLinkByUid { .. }
|
||||||
|
| R::RemovePublicLink { .. }
|
||||||
|
| R::RemovePublicLinkByUid { .. }
|
||||||
|
| R::StopSharing { .. }
|
||||||
|
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||||
|
R::LeaveShared { .. }
|
||||||
|
| R::AcceptInvitation { .. }
|
||||||
|
| R::RejectInvitation { .. }
|
||||||
|
| R::CreateBookmark { .. }
|
||||||
|
| R::DeleteBookmark { .. } => &[Topic::Shares],
|
||||||
|
R::RestoreRevision { .. }
|
||||||
|
| R::RestoreRevisionByUid { .. }
|
||||||
|
| R::SaveRevisionAs { .. }
|
||||||
|
| R::SaveRevisionAsByUid { .. } => &[Topic::Files],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a subscription's writes may block. A client that stops reading
|
||||||
|
/// is dropped after this rather than holding its handler forever.
|
||||||
|
const SUBSCRIPTION_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Serve a [`CtlRequest::Subscribe`]: send the current status and transfers
|
||||||
|
/// and say every topic changed, so the client catches up, then send whatever
|
||||||
|
/// the [`EventHub`](super::events::EventHub) collects, with a heartbeat when
|
||||||
|
/// nothing does. Ends when the client goes away or the daemon stops.
|
||||||
|
fn serve_subscription(core: &Core, username: &str, mountpoint: &Path, mut stream: UnixStream) {
|
||||||
|
let Some(mailbox) = core.events.subscribe() else {
|
||||||
|
warn!("control: refusing a subscription at the limit");
|
||||||
|
if let Ok(mut out) = serde_json::to_vec(&CtlResponse::error(CoreError::remote(
|
||||||
|
"too many subscriptions",
|
||||||
|
))) {
|
||||||
|
out.push(b'\n');
|
||||||
|
let _ = stream.write_all(&out);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
struct Unsubscribe<'a>(&'a Core, std::sync::Arc<super::events::Mailbox>);
|
||||||
|
impl Drop for Unsubscribe<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.events.unsubscribe(&self.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _unsubscribe = Unsubscribe(core, mailbox.clone());
|
||||||
|
if let Err(e) = stream.set_write_timeout(Some(SUBSCRIPTION_WRITE_TIMEOUT)) {
|
||||||
|
warn!(error = %e, "control: could not set subscription timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let heartbeat = super::events::event_line(&Event::Heartbeat);
|
||||||
|
let mut lines: Vec<_> = [
|
||||||
|
super::events::event_line(&Event::Status(Box::new(status_response(
|
||||||
|
core, username, mountpoint,
|
||||||
|
)))),
|
||||||
|
super::events::transfers_line(core.transfers.snapshot(), core.jobs_snapshot()),
|
||||||
|
super::events::event_line(&Event::Changed {
|
||||||
|
topics: Topic::ALL.to_vec(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
loop {
|
||||||
|
for line in &lines {
|
||||||
|
if stream.write_all(line.as_bytes()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines = mailbox.take(HEARTBEAT);
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.extend(heartbeat.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||||
warn!(error = %e, "control: could not set request timeout");
|
warn!(error = %e, "control: could not set request timeout");
|
||||||
@@ -232,30 +459,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let request = serde_json::from_str::<CtlRequest>(line.trim());
|
||||||
|
// A subscription lasts as long as its client, so it is not a request in
|
||||||
|
// flight and stays out of the diagnostics' list of them.
|
||||||
|
if let Ok(CtlRequest::Subscribe) = request {
|
||||||
|
serve_subscription(core, username, mountpoint, stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let touched = request.as_ref().map_or(&[][..], touched_topics);
|
||||||
// Registered before the request is even parsed, so a handler that wedges
|
// Registered before the request is even parsed, so a handler that wedges
|
||||||
// inside the daemon shows up by name in `pdfs diagnostics`.
|
// inside the daemon shows up by name in `pdfs diagnostics`.
|
||||||
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
||||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
let response = match request {
|
||||||
Ok(CtlRequest::Status) => {
|
Ok(CtlRequest::Status) => status_response(core, username, mountpoint),
|
||||||
let pins = core.cache.list_pins();
|
Ok(CtlRequest::Subscribe) => {
|
||||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
let staging = core.cache.staging_usage();
|
|
||||||
CtlResponse::Status {
|
|
||||||
parked_uploads: queued.parked.max(0) as u64,
|
|
||||||
failing_ops: queued.failing.max(0) as u64,
|
|
||||||
failing_error: queued.last_error.clone(),
|
|
||||||
staged_bytes: staging.bytes,
|
|
||||||
staged_oldest_secs: staging.oldest_secs,
|
|
||||||
username: username.to_string(),
|
|
||||||
mountpoint: mountpoint.display().to_string(),
|
|
||||||
pinned: pins.len(),
|
|
||||||
used: core.cache.usage(),
|
|
||||||
budget: core.cache.budget(),
|
|
||||||
pins,
|
|
||||||
online: core.online.load(Ordering::Relaxed),
|
|
||||||
pending_uploads: queued.uploads.max(0) as u64,
|
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
@@ -282,7 +500,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||||
pins: core.cache.list_pins(),
|
pins: core.list_pins(),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||||
Ok((core, rel)) => match core.list_dir(&rel) {
|
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||||
@@ -360,7 +578,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
kind,
|
kind,
|
||||||
range,
|
range,
|
||||||
favorites,
|
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 {
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -373,6 +599,49 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||||
|
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoSimilar) => match core.photo_similar() {
|
||||||
|
Ok((sets, hashing, uncompared)) => CtlResponse::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing,
|
||||||
|
uncompared,
|
||||||
|
},
|
||||||
|
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::PlaceLocations { id }) => match core.place_locations(id) {
|
||||||
|
Ok(items) => CtlResponse::PhotoLocations { items },
|
||||||
|
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(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||||
Ok(items) => CtlResponse::Photos {
|
Ok(items) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -385,9 +654,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||||
Ok(()) => CtlResponse::Ok {
|
Ok(()) => CtlResponse::Ok {
|
||||||
message: if favorite {
|
message: if favorite {
|
||||||
"added to favourites".to_string()
|
"added to favorites".to_string()
|
||||||
} else {
|
} else {
|
||||||
"removed from favourites".to_string()
|
"removed from favorites".to_string()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -419,6 +688,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) {
|
||||||
|
Ok(uid) => CtlResponse::AlbumCreated { uid },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.rename_album(&album, &name) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("renamed album to {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.delete_album(&album, delete_photos) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: "album deleted".to_string(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::AddToAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.add_to_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.remove_from_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
@@ -434,8 +735,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
items: core.photo_thumbs(&parsed),
|
items: core.photo_thumbs(&parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
|
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
|
||||||
Ok(parsed) => match core.trash_photos(&parsed) {
|
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
|
||||||
Ok((trashed, failed)) => CtlResponse::Trashed {
|
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||||
trashed,
|
trashed,
|
||||||
failed: failed
|
failed: failed
|
||||||
@@ -629,7 +930,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
Ok(CtlRequest::Search {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
scope,
|
||||||
|
}) => match core.search(&query, clamp_limit(limit), scope.as_deref()) {
|
||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
@@ -810,9 +1115,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
let items = if n == 1 {
|
||||||
|
"1 item".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} items")
|
||||||
|
};
|
||||||
|
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
message: format!("emptied trash ({n} item(s))"),
|
message: format!("emptied trash ({items})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -892,6 +1202,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetBandwidthLimits { upload, download }) => {
|
||||||
|
core.transfers.set_limits(upload, download);
|
||||||
|
// Persisted like the cache budget: best-effort, the live caps are
|
||||||
|
// already applied.
|
||||||
|
match AppDirs::new().map(|dirs| {
|
||||||
|
let mut cfg = dirs.load_config();
|
||||||
|
cfg.upload_limit = Some(upload);
|
||||||
|
cfg.download_limit = Some(download);
|
||||||
|
dirs.save_config(&cfg)
|
||||||
|
}) {
|
||||||
|
Ok(Ok(())) => CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"bandwidth limits set to {upload} B/s up, {download} B/s down"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
|
||||||
|
"limits applied but config write failed: {e}"
|
||||||
|
))),
|
||||||
|
Err(e) => CtlResponse::error(CoreError::internal(format!(
|
||||||
|
"limits applied but config unavailable: {e}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
||||||
Ok(items) => CtlResponse::Devices { items },
|
Ok(items) => CtlResponse::Devices { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -975,6 +1308,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => {
|
||||||
|
match core.set_sync_folder_paused(id, paused) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match paused {
|
||||||
|
true => format!("folder {id} paused"),
|
||||||
|
false => format!("folder {id} resumed"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::SyncNow { id }) => {
|
Ok(CtlRequest::SyncNow { id }) => {
|
||||||
core.sync_now(id);
|
core.sync_now(id);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -984,22 +1328,77 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
|
Ok(CtlRequest::SetSyncPaused { paused, until }) => {
|
||||||
|
match core.set_sync_paused(paused, until) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match (paused, until) {
|
||||||
|
(false, _) => "sync resumed".to_string(),
|
||||||
|
(true, None) => "sync paused until resumed".to_string(),
|
||||||
|
(true, Some(_)) => "sync paused".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ListConflicts) => match core.list_conflicts() {
|
||||||
|
Ok(items) => CtlResponse::Conflicts { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||||
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||||
|
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||||
|
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
Ok(moved) => CtlResponse::Ok {
|
||||||
|
message: format!("retrying {moved} queued operation(s) now"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders(None) {
|
||||||
Ok(items) => CtlResponse::RestorableFolders { items },
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::RestoreSyncFolders { items }) => {
|
Ok(CtlRequest::ListDeviceRestorableFolders { device }) => {
|
||||||
|
match core.list_restorable_folders(Some(&device)) {
|
||||||
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
request @ (CtlRequest::RestoreSyncFolders { .. }
|
||||||
|
| CtlRequest::RestoreDeviceFolders { .. }),
|
||||||
|
) => {
|
||||||
|
let (device, items) = match request {
|
||||||
|
CtlRequest::RestoreDeviceFolders { device, items } => (Some(device), items),
|
||||||
|
CtlRequest::RestoreSyncFolders { items } => (None, items),
|
||||||
|
_ => unreachable!("matched above"),
|
||||||
|
};
|
||||||
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
||||||
// which outlasts the socket timeout. Ack, then work.
|
// which outlasts the socket timeout. Ack, then work.
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::spawn(move || match core.restore_sync_folders(&items) {
|
std::thread::spawn(move || {
|
||||||
Ok(message) => {
|
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||||
core.touch_profile();
|
Ok(message) => {
|
||||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
core.touch_profile();
|
||||||
}
|
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||||
Err(e) => {
|
}
|
||||||
warn!(error = %e, "restore failed");
|
Err(e) => {
|
||||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
warn!(error = %e, "restore failed");
|
||||||
|
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -1170,6 +1569,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)),
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::StopSharingByUid { uid }) => {
|
||||||
|
stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid))
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_revisions(&rel) {
|
Ok(rel) => match core.list_revisions(&rel) {
|
||||||
Ok(items) => CtlResponse::Revisions { items },
|
Ok(items) => CtlResponse::Revisions { items },
|
||||||
@@ -1316,6 +1722,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
// The request did not parse: the caller sent something malformed.
|
// The request did not parse: the caller sent something malformed.
|
||||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||||
};
|
};
|
||||||
|
if !touched.is_empty() && !matches!(response, CtlResponse::Error { .. }) {
|
||||||
|
core.events.publish(touched);
|
||||||
|
}
|
||||||
let mut out = match serde_json::to_vec(&response) {
|
let mut out = match serde_json::to_vec(&response) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ impl Core {
|
|||||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||||
location.progress = progress.get(sync_folder_id).cloned();
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
|
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(locations)
|
Ok(locations)
|
||||||
@@ -226,6 +227,39 @@ impl Core {
|
|||||||
Ok(format!("adopted device {name}"))
|
Ok(format!("adopted device {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The device a restore reads from: this machine's own for `None`, else
|
||||||
|
/// the registered device with that uid.
|
||||||
|
///
|
||||||
|
/// Another device is looked up without touching the cached device row —
|
||||||
|
/// restoring its folders here must not make this machine back up as it.
|
||||||
|
/// Its folders are then attached with that device's share id, so they sync
|
||||||
|
/// against the other computer's tree.
|
||||||
|
pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult<StoredDevice> {
|
||||||
|
let Some(uid) = device else {
|
||||||
|
return self.ensure_device();
|
||||||
|
};
|
||||||
|
let remote = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_devices())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
|
let d = remote
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.uid.to_string() == uid)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
|
||||||
|
Ok(StoredDevice {
|
||||||
|
uid: d.uid.to_string(),
|
||||||
|
share_id: d.share_id.to_string(),
|
||||||
|
root_uid: d.root_folder_uid.to_string(),
|
||||||
|
name: d
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.ok()
|
||||||
|
.unwrap_or("(unnamed device)")
|
||||||
|
.to_string(),
|
||||||
|
created: d.creation_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||||
|
|
||||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
@@ -448,7 +482,8 @@ impl Core {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
let live = progress.get(&f.id).cloned();
|
let live = progress.get(&f.id).cloned();
|
||||||
sync_folder_info(f, live)
|
let paused = self.sync_folder_paused(f.id);
|
||||||
|
sync_folder_info(f, live, paused)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -561,6 +596,7 @@ impl Core {
|
|||||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||||
}
|
}
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
self.clear_sync_folder_paused(id);
|
||||||
// Stop watching the folder we just dropped.
|
// Stop watching the folder we just dropped.
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
if delete_remote
|
if delete_remote
|
||||||
@@ -625,6 +661,13 @@ impl Core {
|
|||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
|
// it up front rather than queue it indefinitely.
|
||||||
|
if current_mode != target_mode && self.sync_folder_paused(id) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this folder is paused; resume it before changing its mode",
|
||||||
|
));
|
||||||
|
}
|
||||||
if current_mode == target_mode {
|
if current_mode == target_mode {
|
||||||
let mode_str = target_mode.as_str();
|
let mode_str = target_mode.as_str();
|
||||||
let lock = self.sync_lock(id);
|
let lock = self.sync_lock(id);
|
||||||
|
|||||||
@@ -285,8 +285,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let online = self.online.load(Ordering::Relaxed);
|
let online = self.online.load(Ordering::Relaxed);
|
||||||
|
let paused = self.sync_paused();
|
||||||
let op = match due {
|
let op = match due {
|
||||||
Some(op) if online => op,
|
Some(op) if online && !paused => op,
|
||||||
idle => {
|
idle => {
|
||||||
// Claimed but offline: hand it straight back, or it stays
|
// Claimed but offline: hand it straight back, or it stays
|
||||||
// invisible to every worker until this one happens to be
|
// invisible to every worker until this one happens to be
|
||||||
@@ -306,6 +307,14 @@ impl Core {
|
|||||||
// its writer has to be noticed.
|
// its writer has to be noticed.
|
||||||
self.sweep_parked_creates();
|
self.sweep_parked_creates();
|
||||||
}
|
}
|
||||||
|
// Paused, nothing due changes that: sleep until a resume
|
||||||
|
// wakes the worker (or the idle poll notices a timed pause
|
||||||
|
// ran out), not until the next op falls due — that is
|
||||||
|
// "now" for everything already queued.
|
||||||
|
if paused {
|
||||||
|
self.wait_for_drain_work();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// A debounced or backed-off op may be waiting: sleep only
|
// A debounced or backed-off op may be waiting: sleep only
|
||||||
// until it becomes due rather than the full idle-poll.
|
// until it becomes due rather than the full idle-poll.
|
||||||
self.wait_for_drain_work_or_due();
|
self.wait_for_drain_work_or_due();
|
||||||
@@ -936,6 +945,7 @@ impl Core {
|
|||||||
if !meta.complete {
|
if !meta.complete {
|
||||||
return Err("queued create holds an incomplete blob".into());
|
return Err("queued create holds an incomplete blob".into());
|
||||||
}
|
}
|
||||||
|
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
|
||||||
let guard = self
|
let guard = self
|
||||||
.transfers
|
.transfers
|
||||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -946,7 +956,7 @@ impl Core {
|
|||||||
media_type_for(name),
|
media_type_for(name),
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
))?;
|
))?;
|
||||||
@@ -1136,6 +1146,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let alt = conflict_name(&name, now_secs());
|
let alt = conflict_name(&name, now_secs());
|
||||||
|
let thumbnails = self.upload_thumbnails(blob, &alt);
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -1146,7 +1157,7 @@ impl Core {
|
|||||||
media_type_for(&alt),
|
media_type_for(&alt),
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
))?;
|
))?;
|
||||||
@@ -1173,6 +1184,10 @@ impl Core {
|
|||||||
format!("{reason}; local changes uploaded as {alt}"),
|
format!("{reason}; local changes uploaded as {alt}"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
self.events.publish(&[
|
||||||
|
pdfs_core::control::Topic::Files,
|
||||||
|
pdfs_core::control::Topic::Conflicts,
|
||||||
|
]);
|
||||||
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1429,6 +1444,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = self.node_name(&uid);
|
let name = self.node_name(&uid);
|
||||||
|
let thumbnails = self.upload_thumbnails(&blob, &name);
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -1442,7 +1458,7 @@ impl Core {
|
|||||||
&uid,
|
&uid,
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let took = started.elapsed();
|
let took = started.elapsed();
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! Push notifications for front-ends: the daemon's side of
|
||||||
|
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
|
||||||
|
//!
|
||||||
|
//! Front-ends used to poll. The app asked for the status every two seconds, the
|
||||||
|
//! tray every three, and pages re-read themselves on a timer, whether or not
|
||||||
|
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
|
||||||
|
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
|
||||||
|
//! [`run_event_sampler`] watches the few things that change without passing
|
||||||
|
//! through one place (the status, transfers, sync progress) and publishes them
|
||||||
|
//! when they differ.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex, MutexGuard};
|
||||||
|
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// Most subscriptions served at once. Each holds a control handler for as long
|
||||||
|
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
|
||||||
|
/// misbehaving client from spending the handlers that requests need.
|
||||||
|
const MAX_SUBSCRIBERS: usize = 8;
|
||||||
|
|
||||||
|
/// How long a subscription waits after a change before it sends, so a burst
|
||||||
|
/// (a folder of files trashed at once) goes out as one event.
|
||||||
|
const COALESCE: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
/// How often [`run_event_sampler`] looks at transfers. Status and sync
|
||||||
|
/// progress are looked at every other time.
|
||||||
|
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
|
||||||
|
/// on-demand forks included, so a change in any mount reaches every client.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct EventHub {
|
||||||
|
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one subscription has yet to send.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Mailbox {
|
||||||
|
inbox: Mutex<Inbox>,
|
||||||
|
ready: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inbox {
|
||||||
|
topics: BTreeSet<Topic>,
|
||||||
|
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||||
|
/// client wants the current status, not every status in between.
|
||||||
|
status: Option<Arc<str>>,
|
||||||
|
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||||
|
transfers: Option<Arc<str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `event` as one line of the wire format, newline included.
|
||||||
|
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
|
||||||
|
match serde_json::to_string(event) {
|
||||||
|
Ok(mut line) => {
|
||||||
|
line.push('\n');
|
||||||
|
Some(line.into())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "events: serialize event failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHub {
|
||||||
|
/// Tell every subscriber that `topics` changed.
|
||||||
|
pub(crate) fn publish(&self, topics: &[Topic]) {
|
||||||
|
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every subscriber a fresh status.
|
||||||
|
fn publish_status(&self, line: Arc<str>) {
|
||||||
|
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every subscriber a fresh transfers snapshot.
|
||||||
|
fn publish_transfers(&self, line: Arc<str>) {
|
||||||
|
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deliver(&self, put: impl Fn(&mut Inbox)) {
|
||||||
|
for mailbox in self.mailboxes.lock().iter() {
|
||||||
|
put(&mut mailbox.inbox.lock());
|
||||||
|
mailbox.ready.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
|
||||||
|
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
|
||||||
|
let mut mailboxes = self.mailboxes.lock();
|
||||||
|
if mailboxes.len() >= MAX_SUBSCRIBERS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mailbox = Arc::new(Mailbox::default());
|
||||||
|
mailboxes.push(mailbox.clone());
|
||||||
|
Some(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
|
||||||
|
self.mailboxes
|
||||||
|
.lock()
|
||||||
|
.retain(|other| !Arc::ptr_eq(other, mailbox));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_subscribers(&self) -> bool {
|
||||||
|
!self.mailboxes.lock().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mailbox {
|
||||||
|
/// Wait up to `timeout` for something to send, then take it as wire lines:
|
||||||
|
/// the status first, then transfers, then the changed topics. Empty when
|
||||||
|
/// nothing arrived in time.
|
||||||
|
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
|
||||||
|
let mut inbox = self.inbox.lock();
|
||||||
|
if inbox.is_empty() {
|
||||||
|
self.ready.wait_for(&mut inbox, timeout);
|
||||||
|
if inbox.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Let the rest of a burst arrive before sending the first of it.
|
||||||
|
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
|
||||||
|
}
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.extend(inbox.status.take());
|
||||||
|
lines.extend(inbox.transfers.take());
|
||||||
|
if !inbox.topics.is_empty() {
|
||||||
|
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
|
||||||
|
lines.extend(event_line(&Event::Changed { topics }));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Event::Transfers`] line for a snapshot.
|
||||||
|
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||||
|
event_line(&Event::Transfers { items, jobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch what changes without a single place to publish it from, and publish it
|
||||||
|
/// when it differs from the last look: the status (queue counts, cache use,
|
||||||
|
/// online, pause), the transfers snapshot, and the synced folders with their
|
||||||
|
/// live progress. Looks only while someone is subscribed.
|
||||||
|
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||||
|
let mut transfers: Option<Arc<str>> = None;
|
||||||
|
let mut status: Option<String> = None;
|
||||||
|
let mut queue: Option<String> = None;
|
||||||
|
let mut locations: Option<String> = None;
|
||||||
|
let mut tick = 0u32;
|
||||||
|
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||||
|
if !core.events.has_subscribers() {
|
||||||
|
// A new subscription starts from a snapshot of its own, so there is
|
||||||
|
// nothing to compare against until then.
|
||||||
|
transfers = None;
|
||||||
|
status = None;
|
||||||
|
queue = None;
|
||||||
|
locations = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||||
|
if line.is_some() && line != transfers {
|
||||||
|
transfers = line.clone();
|
||||||
|
if let Some(line) = line {
|
||||||
|
core.events.publish_transfers(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick = tick.wrapping_add(1);
|
||||||
|
if !tick.is_multiple_of(2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let now = super::control::status_response(&core, &username, &mountpoint);
|
||||||
|
let (key, queue_key) = status_keys(&now);
|
||||||
|
if status.as_ref().is_some_and(|last| *last != key)
|
||||||
|
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
|
||||||
|
{
|
||||||
|
core.events.publish_status(line);
|
||||||
|
}
|
||||||
|
status = Some(key);
|
||||||
|
if queue.as_ref().is_some_and(|last| *last != queue_key) {
|
||||||
|
core.events.publish(&[Topic::Queue]);
|
||||||
|
}
|
||||||
|
queue = Some(queue_key);
|
||||||
|
|
||||||
|
if let Ok(now) = core.list_locations() {
|
||||||
|
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||||
|
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||||
|
core.events.publish(&[Topic::Locations]);
|
||||||
|
}
|
||||||
|
locations = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What makes two statuses different enough to send, and what makes their
|
||||||
|
/// queues different. The age of the oldest staged write is left out of both:
|
||||||
|
/// it grows every second while anything is staged, which is not news.
|
||||||
|
fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||||
|
let CtlResponse::Status {
|
||||||
|
parked_uploads,
|
||||||
|
failing_ops,
|
||||||
|
failing_error,
|
||||||
|
staged_bytes,
|
||||||
|
paused,
|
||||||
|
paused_until,
|
||||||
|
username,
|
||||||
|
mountpoint,
|
||||||
|
pinned,
|
||||||
|
used,
|
||||||
|
budget,
|
||||||
|
online,
|
||||||
|
pending_uploads,
|
||||||
|
pending_changes,
|
||||||
|
..
|
||||||
|
} = status
|
||||||
|
else {
|
||||||
|
return (String::new(), String::new());
|
||||||
|
};
|
||||||
|
let queue = format!(
|
||||||
|
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
|
||||||
|
);
|
||||||
|
let key = format!(
|
||||||
|
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||||
|
{used}/{budget}/{online}"
|
||||||
|
);
|
||||||
|
(key, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn changed(line: &str) -> Vec<Topic> {
|
||||||
|
match serde_json::from_str::<Event>(line.trim()) {
|
||||||
|
Ok(Event::Changed { topics }) => topics,
|
||||||
|
other => panic!("not a change event: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_burst_of_changes_arrives_as_one_event() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.publish(&[Topic::Trash, Topic::Files]);
|
||||||
|
hub.publish(&[Topic::Files]);
|
||||||
|
let lines = mailbox.take(Duration::from_secs(1));
|
||||||
|
assert_eq!(lines.len(), 1);
|
||||||
|
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
|
||||||
|
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newer_status_replaces_one_not_yet_sent() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.publish_status("first\n".into());
|
||||||
|
hub.publish_status("second\n".into());
|
||||||
|
assert_eq!(
|
||||||
|
mailbox.take(Duration::from_secs(1)),
|
||||||
|
vec![Arc::<str>::from("second\n")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closed_subscription_gets_nothing_more() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.unsubscribe(&mailbox);
|
||||||
|
hub.publish(&[Topic::Files]);
|
||||||
|
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||||
|
assert!(!hub.has_subscribers());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscriptions_are_capped() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
|
||||||
|
.map(|_| hub.subscribe().expect("room for a subscriber"))
|
||||||
|
.collect();
|
||||||
|
assert!(hub.subscribe().is_none());
|
||||||
|
hub.unsubscribe(&open[0]);
|
||||||
|
assert!(hub.subscribe().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_age_of_a_staged_write_is_not_a_change() {
|
||||||
|
let status = |staged_oldest_secs| CtlResponse::Status {
|
||||||
|
parked_uploads: 0,
|
||||||
|
failing_ops: 0,
|
||||||
|
failing_error: None,
|
||||||
|
staged_bytes: 10,
|
||||||
|
staged_oldest_secs,
|
||||||
|
paused: false,
|
||||||
|
paused_until: None,
|
||||||
|
username: "u".into(),
|
||||||
|
mountpoint: "/m".into(),
|
||||||
|
pinned: 0,
|
||||||
|
used: 0,
|
||||||
|
budget: 0,
|
||||||
|
pins: Vec::new(),
|
||||||
|
online: true,
|
||||||
|
pending_uploads: 1,
|
||||||
|
pending_changes: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
|
|||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use fuser::ReplyXattr;
|
use fuser::ReplyXattr;
|
||||||
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||||
ThumbnailBuildStatus, TransferDirection,
|
ThumbnailBuildStatus, Topic, TransferDirection,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{
|
use pdfs_core::db::{
|
||||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||||
@@ -80,16 +80,20 @@ use proton_drive_rs::{
|
|||||||
|
|
||||||
mod albums;
|
mod albums;
|
||||||
mod background;
|
mod background;
|
||||||
|
mod conflicts;
|
||||||
mod control;
|
mod control;
|
||||||
use control::run_control_socket;
|
use control::run_control_socket;
|
||||||
mod devices;
|
mod devices;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod drain;
|
mod drain;
|
||||||
|
mod events;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
|
mod pause;
|
||||||
mod photos;
|
mod photos;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod queue;
|
||||||
mod reads;
|
mod reads;
|
||||||
mod redate;
|
mod redate;
|
||||||
mod revisions;
|
mod revisions;
|
||||||
@@ -268,6 +272,12 @@ const PHOTOS_AVAILABLE: &str = "photos_available";
|
|||||||
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
||||||
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
||||||
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
||||||
|
/// Set when a trash refresh finishes and cleared when one starts, so it is only
|
||||||
|
/// present while the stored listing is the whole trash as of some moment. A node
|
||||||
|
/// that a refresh finds and that listing lacks went to the trash since then; one
|
||||||
|
/// found after an interrupted refresh may have been there all along. Unlike
|
||||||
|
/// [`TRASH_SYNCED_MS`], our own mutations leave it alone.
|
||||||
|
const TRASH_COMPLETE_MS: &str = "trash_complete_ms";
|
||||||
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
||||||
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
||||||
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
||||||
@@ -508,6 +518,10 @@ struct Core {
|
|||||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||||
/// infer it from a wall of EIO.
|
/// infer it from a wall of EIO.
|
||||||
online: Arc<AtomicBool>,
|
online: Arc<AtomicBool>,
|
||||||
|
/// When the user paused syncing, the Unix second it resumes by itself;
|
||||||
|
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||||
|
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||||
|
sync_paused_until: Arc<AtomicI64>,
|
||||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||||
/// which it is rebuilt on mount.
|
/// which it is rebuilt on mount.
|
||||||
@@ -543,17 +557,25 @@ struct Core {
|
|||||||
/// already running, so a burst of page requests against a stale listing kicks
|
/// already running, so a burst of page requests against a stale listing kicks
|
||||||
/// off one refresh rather than one per request.
|
/// off one refresh rather than one per request.
|
||||||
timeline_refreshing: Arc<AtomicBool>,
|
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.
|
/// The same, for the album listing.
|
||||||
albums_refreshing: Arc<AtomicBool>,
|
albums_refreshing: Arc<AtomicBool>,
|
||||||
trash_refreshing: Arc<AtomicBool>,
|
trash_refreshing: Arc<AtomicBool>,
|
||||||
|
/// True while the similar-photo pass hashes thumbnails (see
|
||||||
|
/// [`Core::photo_similar`]), so asking again doesn't start a second one.
|
||||||
|
similar_hashing: Arc<AtomicBool>,
|
||||||
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
||||||
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
||||||
/// instead of polling or blocking for the whole run. See
|
/// instead of polling or blocking for the whole run. See
|
||||||
/// [`Core::await_trash_refresh`].
|
/// [`Core::await_trash_refresh`].
|
||||||
trash_progress: Arc<tokio::sync::Notify>,
|
trash_progress: Arc<tokio::sync::Notify>,
|
||||||
/// Conflict copies the sweep has already flagged as needing attention this
|
/// Conflict copies the sweep has already checked against the activity log
|
||||||
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
/// this run, so each pass does not ask the database again. The log itself
|
||||||
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
/// 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>>>,
|
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||||
/// only report them. Resolved once at mount from config + environment
|
/// only report them. Resolved once at mount from config + environment
|
||||||
@@ -644,6 +666,9 @@ struct Core {
|
|||||||
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
||||||
/// across the FUSE session and the control-socket task.
|
/// across the FUSE session and the control-socket task.
|
||||||
transfers: Arc<TransferRegistry>,
|
transfers: Arc<TransferRegistry>,
|
||||||
|
/// Open [`Request::Subscribe`](pdfs_core::control::Request::Subscribe)
|
||||||
|
/// connections, which every change a front-end shows is published to.
|
||||||
|
events: Arc<events::EventHub>,
|
||||||
/// True while the background scanner is rebuilding the local-file index, so
|
/// True while the background scanner is rebuilding the local-file index, so
|
||||||
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
||||||
indexing: Arc<AtomicBool>,
|
indexing: Arc<AtomicBool>,
|
||||||
@@ -3800,6 +3825,8 @@ impl Core {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -3850,10 +3877,25 @@ impl Core {
|
|||||||
/// Full-text search node names against the local SQLite index, mapping each
|
/// Full-text search node names against the local SQLite index, mapping each
|
||||||
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
||||||
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
||||||
fn search(&self, query: &str, limit: usize) -> CoreResult<Vec<SearchHit>> {
|
/// Every pin, with `cached` saying whether a pinned file's content is on
|
||||||
|
/// disk, so a front-end listing pins can badge them without a round-trip
|
||||||
|
/// per file.
|
||||||
|
fn list_pins(&self) -> Vec<pdfs_core::cache::Pin> {
|
||||||
|
let mut pins = self.cache.list_pins();
|
||||||
|
for pin in pins.iter_mut().filter(|pin| pin.is_dir == Some(false)) {
|
||||||
|
if let Ok(Some(node)) = self.db.node_by_uid(&pin.uid) {
|
||||||
|
pin.cached =
|
||||||
|
self.cache
|
||||||
|
.is_cached(&node.uid, node.modification_time, node_size(&node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pins
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(&self, query: &str, limit: usize, scope: Option<&str>) -> CoreResult<Vec<SearchHit>> {
|
||||||
let hits = self
|
let hits = self
|
||||||
.db
|
.db
|
||||||
.search(query, limit)
|
.search_in(query, limit, scope)
|
||||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||||
let roots = self.search_roots();
|
let roots = self.search_roots();
|
||||||
let pinned = self.pinned_set();
|
let pinned = self.pinned_set();
|
||||||
@@ -3865,6 +3907,12 @@ impl Core {
|
|||||||
size: node_size(&h.node),
|
size: node_size(&h.node),
|
||||||
modified: h.node.modification_time,
|
modified: h.node.modification_time,
|
||||||
pinned: pinned.contains(&h.node.uid.to_string()),
|
pinned: pinned.contains(&h.node.uid.to_string()),
|
||||||
|
cached: !h.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&h.node.uid,
|
||||||
|
h.node.modification_time,
|
||||||
|
node_size(&h.node),
|
||||||
|
),
|
||||||
uid: h.node.uid.to_string(),
|
uid: h.node.uid.to_string(),
|
||||||
mounted_path: roots.resolve(&h.path),
|
mounted_path: roots.resolve(&h.path),
|
||||||
path: h.path,
|
path: h.path,
|
||||||
@@ -3932,6 +3980,12 @@ impl Core {
|
|||||||
size: node_size(&hit.node),
|
size: node_size(&hit.node),
|
||||||
modified: hit.node.modification_time,
|
modified: hit.node.modification_time,
|
||||||
pinned: is_pinned,
|
pinned: is_pinned,
|
||||||
|
cached: !hit.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&hit.node.uid,
|
||||||
|
hit.node.modification_time,
|
||||||
|
node_size(&hit.node),
|
||||||
|
),
|
||||||
uid,
|
uid,
|
||||||
mounted_path: roots.resolve(&hit.path),
|
mounted_path: roots.resolve(&hit.path),
|
||||||
path: hit.path,
|
path: hit.path,
|
||||||
@@ -4364,12 +4418,21 @@ impl Core {
|
|||||||
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most of a trash comes from a handful of folders.
|
||||||
|
let mut origins: HashMap<String, Option<String>> = HashMap::new();
|
||||||
Ok(self
|
Ok(self
|
||||||
.db
|
.db
|
||||||
.trash_list()
|
.trash_list()
|
||||||
.map_err(CoreError::from)?
|
.map_err(CoreError::from)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| DirEntry {
|
.map(|item| DirEntry {
|
||||||
|
trashed_from: item.parent_uid.as_ref().and_then(|parent| {
|
||||||
|
origins
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_insert_with(|| self.db.node_path(parent).ok().flatten())
|
||||||
|
.clone()
|
||||||
|
}),
|
||||||
|
trashed_at: item.trashed_at.unwrap_or(0),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
is_dir: item.is_dir,
|
is_dir: item.is_dir,
|
||||||
size: item.size.max(0) as u64,
|
size: item.size.max(0) as u64,
|
||||||
@@ -4408,6 +4471,18 @@ impl Core {
|
|||||||
"trash refresh: enumerated"
|
"trash refresh: enumerated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// What the listing held before, and whether it was the whole trash: the
|
||||||
|
// chunks below overwrite it, so both are read up front.
|
||||||
|
let seen = self.db.trash_seen().unwrap_or_default();
|
||||||
|
let complete = self
|
||||||
|
.db
|
||||||
|
.state_i64(TRASH_COMPLETE_MS)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let _ = self.db.clear_state(TRASH_COMPLETE_MS);
|
||||||
|
let now = now_secs();
|
||||||
|
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
||||||
}
|
}
|
||||||
@@ -4428,6 +4503,7 @@ impl Core {
|
|||||||
// The folder the node will go back to, which is also what tells
|
// The folder the node will go back to, which is also what tells
|
||||||
// a restore whether that folder is itself in the trash.
|
// a restore whether that folder is itself in the trash.
|
||||||
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
||||||
|
trashed_at: first_seen_in_trash(&seen, &node.uid.to_string(), complete, now),
|
||||||
}));
|
}));
|
||||||
// Cumulative, so the table is always a prefix of the real trash
|
// Cumulative, so the table is always a prefix of the real trash
|
||||||
// rather than a mix of this refresh and the last one.
|
// rather than a mix of this refresh and the last one.
|
||||||
@@ -4442,6 +4518,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
||||||
|
let _ = self.db.set_state_i64(TRASH_COMPLETE_MS, now_ms());
|
||||||
info!(
|
info!(
|
||||||
count = items.len(),
|
count = items.len(),
|
||||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
@@ -4460,10 +4537,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_trash().await {
|
let refreshed = core.refresh_trash().await;
|
||||||
warn!(error = %e, "background trash refresh failed");
|
|
||||||
}
|
|
||||||
core.trash_refreshing.store(false, Ordering::SeqCst);
|
core.trash_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Trash]),
|
||||||
|
Err(e) => warn!(error = %e, "background trash refresh failed"),
|
||||||
|
}
|
||||||
// After the flag, so a waiter that wakes on this sees it cleared.
|
// After the flag, so a waiter that wakes on this sees it cleared.
|
||||||
core.trash_progress.notify_waiters();
|
core.trash_progress.notify_waiters();
|
||||||
});
|
});
|
||||||
@@ -4754,6 +4833,14 @@ impl Core {
|
|||||||
};
|
};
|
||||||
if let Err(e) = self.db.activity_add(&entry) {
|
if let Err(e) = self.db.activity_add(&entry) {
|
||||||
warn!(error = ?e, "could not record activity");
|
warn!(error = ?e, "could not record activity");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A sync pass is how a conflict copy reaches the listing, and the sweep
|
||||||
|
// reports the ones it finds as conflicts.
|
||||||
|
if matches!(entry.kind, ActivityKind::Conflict | ActivityKind::Sync) {
|
||||||
|
self.events.publish(&[Topic::Activity, Topic::Conflicts]);
|
||||||
|
} else {
|
||||||
|
self.events.publish(&[Topic::Activity]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4955,6 +5042,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
|||||||
role: role_to_str(link.role).to_string(),
|
role: role_to_str(link.role).to_string(),
|
||||||
expires: link.expiration_time,
|
expires: link.expiration_time,
|
||||||
has_password: link.has_custom_password,
|
has_password: link.has_custom_password,
|
||||||
|
created: link.creation_time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5134,7 +5222,11 @@ fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
|||||||
|
|
||||||
/// Convert a stored synced folder into its wire form for the front-ends, with
|
/// Convert a stored synced folder into its wire form for the front-ends, with
|
||||||
/// the live progress of its pass when one is running.
|
/// the live progress of its pass when one is running.
|
||||||
fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> SyncFolderInfo {
|
fn sync_folder_info(
|
||||||
|
f: StoredSyncFolder,
|
||||||
|
progress: Option<SyncProgress>,
|
||||||
|
paused: bool,
|
||||||
|
) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
local_path: f.local_path,
|
local_path: f.local_path,
|
||||||
@@ -5144,6 +5236,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
|||||||
state: f.state,
|
state: f.state,
|
||||||
last_sync: f.last_sync,
|
last_sync: f.last_sync,
|
||||||
progress,
|
progress,
|
||||||
|
paused,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5382,6 +5475,25 @@ fn node_size(node: &Node) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a node a trash refresh found went to the trash, as far as the daemon
|
||||||
|
/// can tell. A node the stored listing already had keeps the time it had. A new
|
||||||
|
/// one went there since the last refresh, so now is close, but only when that
|
||||||
|
/// refresh saw the whole trash (`complete`): after a first or interrupted one,
|
||||||
|
/// a node missing from the listing may be years old, and no date beats a wrong
|
||||||
|
/// one.
|
||||||
|
fn first_seen_in_trash(
|
||||||
|
seen: &HashMap<String, Option<i64>>,
|
||||||
|
uid: &str,
|
||||||
|
complete: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> Option<i64> {
|
||||||
|
match seen.get(uid) {
|
||||||
|
Some(at) => *at,
|
||||||
|
None if complete => Some(now),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand a restore request across the trashed tree, and order it so a parent is
|
/// Expand a restore request across the trashed tree, and order it so a parent is
|
||||||
/// always restored before its children.
|
/// always restored before its children.
|
||||||
///
|
///
|
||||||
@@ -6173,8 +6285,8 @@ mod tests {
|
|||||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||||
fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention,
|
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
|
||||||
parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||||
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||||
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||||
rename_needs_queue, require_node_parent_access, require_rename_access,
|
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||||
@@ -6317,6 +6429,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node keeps the time it was first seen in the trash; a new one is dated
|
||||||
|
/// now only when the previous refresh saw the whole trash.
|
||||||
|
#[test]
|
||||||
|
fn trash_dates_are_kept_and_only_guessed_after_a_complete_refresh() {
|
||||||
|
let seen = HashMap::from([("old".to_string(), Some(5)), ("unknown".to_string(), None)]);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", true, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", false, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "unknown", true, 9), None);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", true, 9), Some(9));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", false, 9), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// A parent chain that loops back on itself must terminate.
|
/// A parent chain that loops back on itself must terminate.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cyclic_listing_terminates() {
|
fn a_cyclic_listing_terminates() {
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ pub struct MountOptions {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
pub sweep_mode: SweepMode,
|
pub sweep_mode: SweepMode,
|
||||||
|
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||||
|
pub upload_limit: u64,
|
||||||
|
pub download_limit: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
@@ -340,6 +343,8 @@ pub fn mount(
|
|||||||
let MountOptions {
|
let MountOptions {
|
||||||
username,
|
username,
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
|
upload_limit,
|
||||||
|
download_limit,
|
||||||
} = options;
|
} = options;
|
||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
@@ -357,6 +362,7 @@ pub fn mount(
|
|||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
|
|
||||||
|
let paused_until = pause::load_paused_until(&db);
|
||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
@@ -377,6 +383,7 @@ pub fn mount(
|
|||||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
online: Arc::new(AtomicBool::new(online)),
|
||||||
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
@@ -384,6 +391,8 @@ pub fn mount(
|
|||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
timeline_progress: Arc::new(Mutex::new(None)),
|
||||||
|
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
@@ -407,6 +416,7 @@ pub fn mount(
|
|||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
session_live: Arc::new(AtomicBool::new(false)),
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
|
events: Arc::new(Default::default()),
|
||||||
indexing: Arc::new(AtomicBool::new(false)),
|
indexing: Arc::new(AtomicBool::new(false)),
|
||||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -417,6 +427,7 @@ pub fn mount(
|
|||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
// every mount's inode space through this registry, not through `core.state`.
|
// every mount's inode space through this registry, not through `core.state`.
|
||||||
core.register_state(mountpoint);
|
core.register_state(mountpoint);
|
||||||
|
core.transfers.set_limits(upload_limit, download_limit);
|
||||||
|
|
||||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||||
// an upload, and reads must be served from their staged blobs until they land.
|
// an upload, and reads must be served from their staged blobs until they land.
|
||||||
@@ -451,6 +462,10 @@ pub fn mount(
|
|||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||||
|
// A timed pause persisted by an earlier run still has to end on time.
|
||||||
|
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||||
|
core.arm_pause_timer(paused_until);
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
// out identical to the live file, surface the ones that genuinely diverge.
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
@@ -554,6 +569,22 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Publishes what changes without one place to publish it from (status,
|
||||||
|
// transfers, sync progress) to the front-ends subscribed on that socket.
|
||||||
|
{
|
||||||
|
let sampler_core = core.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-events".into())
|
||||||
|
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: front-ends still get every published change, only
|
||||||
|
// not the sampled ones.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the event sampler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
// The supervisor watches the pool and answers the systemd watchdog. Started
|
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||||
// after the listener, because its liveness probe is a round trip over that
|
// after the listener, because its liveness probe is a round trip over that
|
||||||
// socket.
|
// socket.
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! Pausing sync: the user's "stop uploading for a while".
|
||||||
|
//!
|
||||||
|
//! A pause holds back the two things that push local state to the remote — the
|
||||||
|
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||||
|
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||||
|
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||||
|
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||||
|
//!
|
||||||
|
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||||
|
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||||
|
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||||
|
//! deadline that passed while the daemon was down is already over on mount.
|
||||||
|
//!
|
||||||
|
//! A single mirror folder can be paused on its own as well: its reconcile is
|
||||||
|
//! skipped, and with it any mode switch queued behind that reconcile, until the
|
||||||
|
//! folder is resumed. On-demand folders cannot be paused alone — their writes
|
||||||
|
//! go through the shared queue, which only the global pause holds back.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `state` key holding the resume time, in Unix seconds.
|
||||||
|
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||||
|
|
||||||
|
/// The `state` key prefix marking one synced folder paused; its id follows.
|
||||||
|
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
|
||||||
|
|
||||||
|
fn folder_paused_key(id: i64) -> String {
|
||||||
|
format!("{FOLDER_PAUSED_PREFIX}{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether synced folder `id` is paused on its own. A row that cannot be read
|
||||||
|
/// counts as running, like the global pause.
|
||||||
|
fn load_folder_paused(db: &Db, id: i64) -> bool {
|
||||||
|
match db.state_i64(&folder_paused_key(id)) {
|
||||||
|
Ok(value) => value.is_some_and(|v| v != 0),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(id, %error, "reading a folder pause failed; treating it as running");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist synced folder `id`'s own pause, or clear it.
|
||||||
|
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
|
||||||
|
match paused {
|
||||||
|
true => db.set_state_i64(&folder_paused_key(id), 1),
|
||||||
|
false => db.clear_state(&folder_paused_key(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pause with no end time: it lasts until the user resumes.
|
||||||
|
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||||
|
|
||||||
|
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||||
|
/// broken state row must not keep sync paused forever).
|
||||||
|
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||||
|
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||||
|
Ok(Some(until)) => until,
|
||||||
|
Ok(None) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||||
|
/// seconds). `0` is "not paused".
|
||||||
|
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||||
|
until != 0 && now < until
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// True while the user has sync paused.
|
||||||
|
pub(crate) fn sync_paused(&self) -> bool {
|
||||||
|
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pause's end, for status reporting: `None` when not paused,
|
||||||
|
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||||
|
/// Unix second `t`.
|
||||||
|
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||||
|
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||||
|
match paused_at(until, now_secs()) {
|
||||||
|
false => None,
|
||||||
|
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||||
|
true => Some(Some(until)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||||
|
/// it when `paused` is false.
|
||||||
|
pub(crate) fn set_sync_paused(
|
||||||
|
&self,
|
||||||
|
paused: bool,
|
||||||
|
until: Option<i64>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
let value = match (paused, until) {
|
||||||
|
(false, _) => 0,
|
||||||
|
(true, None) => PAUSED_INDEFINITELY,
|
||||||
|
(true, Some(until)) if until <= now_secs() => {
|
||||||
|
return Err(CoreError::invalid("the pause would already be over"));
|
||||||
|
}
|
||||||
|
(true, Some(until)) => until,
|
||||||
|
};
|
||||||
|
let persisted = match value {
|
||||||
|
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||||
|
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||||
|
};
|
||||||
|
if let Err(error) = persisted {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the sync pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||||
|
match value {
|
||||||
|
0 => {
|
||||||
|
info!("sync resumed");
|
||||||
|
self.resume_sync_work();
|
||||||
|
}
|
||||||
|
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||||
|
until => {
|
||||||
|
info!(until, "sync paused");
|
||||||
|
self.arm_pause_timer(until);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the user has this one synced folder paused. A row that cannot
|
||||||
|
/// be read counts as running, like the global pause.
|
||||||
|
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
|
||||||
|
load_folder_paused(&self.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
|
||||||
|
/// straight away to catch up on what the watcher saw meanwhile.
|
||||||
|
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
|
||||||
|
let folder = self
|
||||||
|
.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if paused && folder.mode != "mirror" {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
|
||||||
|
through the shared queue, so pause all sync instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, paused) {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the folder pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync paused");
|
||||||
|
} else {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync resumed");
|
||||||
|
self.sync_now(Some(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a removed folder's pause, so a later folder reusing its id does
|
||||||
|
/// not start out paused.
|
||||||
|
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, false) {
|
||||||
|
warn!(id, %error, "clearing a removed folder's pause failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake everything a pause held back: the drain worker, and a full
|
||||||
|
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||||
|
fn resume_sync_work(&self) {
|
||||||
|
self.wake_drain();
|
||||||
|
self.sync_now(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||||
|
/// the stored deadline, which the timer checks before acting, so a stale
|
||||||
|
/// timer does nothing.
|
||||||
|
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||||
|
let core = self.clone();
|
||||||
|
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-pause".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if !core.shutdown.sleep(wait) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if core
|
||||||
|
.sync_paused_until
|
||||||
|
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||||
|
warn!(%error, "clearing an expired sync pause failed");
|
||||||
|
}
|
||||||
|
info!("sync pause ended");
|
||||||
|
core.resume_sync_work();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
// The drain and the reconcile still read the clock, so the pause
|
||||||
|
// still ends — just at the next idle poll instead of on time.
|
||||||
|
warn!(%error, "starting the sync pause timer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pause_is_in_force_until_its_deadline() {
|
||||||
|
assert!(!paused_at(0, 100));
|
||||||
|
assert!(paused_at(200, 100));
|
||||||
|
assert!(!paused_at(200, 200));
|
||||||
|
assert!(!paused_at(200, 300));
|
||||||
|
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_pause_is_kept_per_folder_until_cleared() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
store_folder_paused(&db, 3, true).unwrap();
|
||||||
|
assert!(load_folder_paused(&db, 3));
|
||||||
|
assert!(!load_folder_paused(&db, 4));
|
||||||
|
assert!(!load_folder_paused(&db, 33));
|
||||||
|
store_folder_paused(&db, 3, false).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,15 +248,19 @@ impl Core {
|
|||||||
|
|
||||||
// ---- restore (features.md 5.2) ----------------------------------------
|
// ---- restore (features.md 5.2) ----------------------------------------
|
||||||
|
|
||||||
/// The folders under this machine's device that could be synced here, each
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// carrying a proposed local path.
|
/// proposed local path. `device` names another registered device by uid;
|
||||||
|
/// `None` is this machine's own.
|
||||||
///
|
///
|
||||||
/// The proposal comes from the profile when one exists and its path is
|
/// The proposal comes from the profile when one exists and its path is
|
||||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||||
/// does not exist, and silently creating it would be worse.
|
/// does not exist, and silently creating it would be worse.
|
||||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
pub(crate) fn list_restorable_folders(
|
||||||
let device = self.ensure_device()?;
|
&self,
|
||||||
|
device: Option<&str>,
|
||||||
|
) -> CoreResult<Vec<RestorableFolder>> {
|
||||||
|
let device = self.restore_source(device)?;
|
||||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
})?;
|
})?;
|
||||||
@@ -334,11 +338,12 @@ impl Core {
|
|||||||
pub(crate) fn restore_sync_folders(
|
pub(crate) fn restore_sync_folders(
|
||||||
&self,
|
&self,
|
||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
|
device: Option<&str>,
|
||||||
) -> CoreResult<String> {
|
) -> CoreResult<String> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
let device = self.ensure_device()?;
|
let device = self.restore_source(device)?;
|
||||||
let existing = self
|
let existing = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
@@ -499,6 +504,19 @@ mod tests {
|
|||||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restoring another computer's folders reads that device, but must not
|
||||||
|
/// rewrite the cached device row: this machine would then back up as the
|
||||||
|
/// other computer.
|
||||||
|
#[test]
|
||||||
|
fn restoring_from_another_device_keeps_this_machines_identity() {
|
||||||
|
let source = function_source(include_str!("devices.rs"), "fn restore_source(");
|
||||||
|
assert!(source.contains("ensure_device()"), "None is this machine");
|
||||||
|
assert!(
|
||||||
|
!source.contains("device_set"),
|
||||||
|
"the cached row is untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||||
let start = source.find(signature).expect("function signature exists");
|
let start = source.find(signature).expect("function signature exists");
|
||||||
let body = &source[start..];
|
let body = &source[start..];
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! The user's view of the pending-op queue: what has not reached the remote
|
||||||
|
//! yet, why, and a way to stop waiting out a backoff.
|
||||||
|
//!
|
||||||
|
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
||||||
|
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
||||||
|
//! user's write (see `docs/RECOVERY.md`).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::PendingOpInfo;
|
||||||
|
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Every queued op, oldest first, with a path a person can read.
|
||||||
|
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
|
||||||
|
let ops = self
|
||||||
|
.db
|
||||||
|
.pending_ops()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||||
|
Ok(ops
|
||||||
|
.into_iter()
|
||||||
|
.map(|op| {
|
||||||
|
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||||
|
let path = self
|
||||||
|
.db
|
||||||
|
.node_path(&op.uid)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.or_else(|| op.name.clone())
|
||||||
|
.unwrap_or_else(|| op.uid.clone());
|
||||||
|
PendingOpInfo {
|
||||||
|
id: op.id,
|
||||||
|
kind: op.kind,
|
||||||
|
path,
|
||||||
|
attempts: op.attempts,
|
||||||
|
last_error: op.last_error,
|
||||||
|
queued_at: op.created_at / 1000,
|
||||||
|
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||||
|
parked,
|
||||||
|
failing: op.attempts >= FAILING_ATTEMPTS,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry one backed-off op now, or every failed one when `id` is `None`.
|
||||||
|
/// Returns how many ops were moved up.
|
||||||
|
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
|
||||||
|
let now = now_millis();
|
||||||
|
let moved = match id {
|
||||||
|
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
|
||||||
|
None => self.db.retry_failed_ops_now(now),
|
||||||
|
}
|
||||||
|
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
|
||||||
|
if moved > 0 {
|
||||||
|
self.wake_drain();
|
||||||
|
}
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Proton has no API for editing a sealed capture time, so the repair is a
|
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||||
//! rewrite: download the photo, upload it again with the capture time its file
|
//! rewrite: download the photo, upload it again with the capture time its file
|
||||||
//! name implies, put the new copy back into the albums and favourites the old
|
//! name implies, put the new copy back into the albums and favorites the old
|
||||||
//! one was in, then **trash** (not delete) the original. Trash rather than
|
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||||
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||||
//!
|
//!
|
||||||
@@ -213,7 +213,7 @@ impl Core {
|
|||||||
drop(guard);
|
drop(guard);
|
||||||
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||||
|
|
||||||
// Favourite and albums before the trash: a failure here leaves both
|
// Favorite and albums before the trash: a failure here leaves both
|
||||||
// copies on the account, which is recoverable by hand. Losing the
|
// copies on the account, which is recoverable by hand. Losing the
|
||||||
// original first would not be.
|
// original first would not be.
|
||||||
if candidate.favorite {
|
if candidate.favorite {
|
||||||
@@ -227,7 +227,7 @@ impl Core {
|
|||||||
// just set does not come back as a remote change.
|
// just set does not come back as a remote change.
|
||||||
Ok(_) => self.note_self_change(&new_uid),
|
Ok(_) => self.note_self_change(&new_uid),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo")
|
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
shared_by,
|
shared_by,
|
||||||
shared_at,
|
shared_at,
|
||||||
shared_by_unverified,
|
shared_by_unverified,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +390,66 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop sharing the node at `rel`: remove its public link, then every
|
||||||
|
/// external invitation, Proton invitation and member. The SDK has no call
|
||||||
|
/// that deletes a share outright, so this composes the per-entry ones.
|
||||||
|
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.stop_sharing_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||||
|
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.stop_sharing_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each step runs even when an earlier one failed, so a single stuck
|
||||||
|
/// member does not leave the public link up. The link goes first: it is the
|
||||||
|
/// widest exposure. Only a failure to list what is there fails the whole
|
||||||
|
/// request; a failed removal is counted in the outcome.
|
||||||
|
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
let link = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.get_public_link(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
|
||||||
|
if let Some(link) = link {
|
||||||
|
let result = self.rt.block_on(self.client.remove_public_link(&link));
|
||||||
|
outcome.record("the public link", result);
|
||||||
|
}
|
||||||
|
let external = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_external_invitations(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
|
||||||
|
for ext in external {
|
||||||
|
let result = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.delete_external_invitation(&ext));
|
||||||
|
outcome.record(&ext.invitee_email, result);
|
||||||
|
}
|
||||||
|
let invitations = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_share_invitations(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||||
|
for inv in invitations {
|
||||||
|
let result = self.rt.block_on(self.client.delete_invitation(&inv));
|
||||||
|
outcome.record(&inv.invitee_email, result);
|
||||||
|
}
|
||||||
|
let members = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_share_members(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list members"))?;
|
||||||
|
for member in members {
|
||||||
|
let result = self.rt.block_on(self.client.remove_member(&member));
|
||||||
|
outcome.record(&member.email, result);
|
||||||
|
}
|
||||||
|
if !outcome.failed.is_empty() {
|
||||||
|
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- shared with me ---------------------------------------------------
|
// ---- shared with me ---------------------------------------------------
|
||||||
|
|
||||||
/// List nodes shared with me that I have accepted.
|
/// List nodes shared with me that I have accepted.
|
||||||
@@ -657,6 +719,47 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`Core::stop_sharing`] managed to remove.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct StopSharingOutcome {
|
||||||
|
/// Entries removed (the link, invitations and members alike).
|
||||||
|
pub(crate) removed: usize,
|
||||||
|
/// What could not be removed, by email (or "the public link"), with why.
|
||||||
|
pub(crate) failed: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StopSharingOutcome {
|
||||||
|
fn record<E: std::fmt::Display>(&mut self, what: &str, result: Result<(), E>) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => self.removed += 1,
|
||||||
|
Err(e) => self.failed.push((what.to_string(), e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply for the control socket: a sentence for a full success, an
|
||||||
|
/// error naming what is left otherwise.
|
||||||
|
pub(crate) fn into_result(self) -> Result<String, String> {
|
||||||
|
if self.failed.is_empty() {
|
||||||
|
return Ok(match self.removed {
|
||||||
|
0 => "it was not shared".to_string(),
|
||||||
|
1 => "stopped sharing: removed 1 entry".to_string(),
|
||||||
|
n => format!("stopped sharing: removed {n} entries"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let left = self
|
||||||
|
.failed
|
||||||
|
.iter()
|
||||||
|
.map(|(what, why)| format!("{what} ({why})"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
Err(format!(
|
||||||
|
"removed {} of {}; still shared: {left}",
|
||||||
|
self.removed,
|
||||||
|
self.removed + self.failed.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||||
/// that mount has never interned the node, or if the chain breaks partway —
|
/// that mount has never interned the node, or if the chain breaks partway —
|
||||||
/// a broken chain is a missing path, never a partial one.
|
/// a broken chain is a missing path, never a partial one.
|
||||||
@@ -671,3 +774,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
|||||||
parts.reverse();
|
parts.reverse();
|
||||||
Some(parts.join("/"))
|
Some(parts.join("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopping_a_share_that_is_gone_says_so() {
|
||||||
|
let outcome = StopSharingOutcome::default();
|
||||||
|
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_removal_names_what_is_still_shared() {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
outcome.record::<String>("the public link", Ok(()));
|
||||||
|
outcome.record("bob@example.com", Err("forbidden"));
|
||||||
|
outcome.record::<String>("carol@example.com", Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.into_result().unwrap_err(),
|
||||||
|
"removed 2 of 3; still shared: bob@example.com (forbidden)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_removal_counts_the_entries() {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
outcome.record::<String>("the public link", Ok(()));
|
||||||
|
outcome.record::<String>("bob@example.com", Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.into_result().unwrap(),
|
||||||
|
"stopped sharing: removed 2 entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ impl Core {
|
|||||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
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);
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
if node.trashed || node.is_folder() {
|
if node.trashed || node.is_folder() {
|
||||||
@@ -132,7 +133,16 @@ impl Core {
|
|||||||
self.remove_conflict_copy(node, &base);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,7 +271,7 @@ impl Core {
|
|||||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||||
/// answered "idle" for every such file and let the sweep delete one with a
|
/// answered "idle" for every such file and let the sweep delete one with a
|
||||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
let mut busy = false;
|
let mut busy = false;
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
let Some(&ino) = st.by_uid.get(uid) else {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
@@ -273,12 +283,21 @@ impl Core {
|
|||||||
busy
|
busy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
/// Surface a conflict copy that needs the user's attention, once. The
|
||||||
/// daemon run so the activity feed is not spammed each pass.
|
/// 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) {
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
return;
|
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");
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ impl Core {
|
|||||||
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
||||||
/// pass that may run for minutes.
|
/// pass that may run for minutes.
|
||||||
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
||||||
|
// A paused folder skips the settle too: a queued switch to on-demand
|
||||||
|
// evicts the local tree, which is only safe after a pass uploaded it.
|
||||||
|
if self.sync_folder_paused(folder.id) {
|
||||||
|
debug!(id = folder.id, "sync: folder paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
||||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||||
// so the settle below is not skipped with it.
|
// so the settle below is not skipped with it.
|
||||||
@@ -1220,6 +1226,9 @@ impl Core {
|
|||||||
// Taken from the descriptor being streamed, so it names the content that
|
// Taken from the descriptor being streamed, so it names the content that
|
||||||
// actually goes up rather than whatever is at the path when we finish.
|
// actually goes up rather than whatever is at the path when we finish.
|
||||||
let streamed = LocalSig::from(&meta);
|
let streamed = LocalSig::from(&meta);
|
||||||
|
let thumbnails = self
|
||||||
|
.upload_thumbnails_async(path.clone(), name.to_string())
|
||||||
|
.await;
|
||||||
// Count the bytes as they are read, so this upload shows up in
|
// Count the bytes as they are read, so this upload shows up in
|
||||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||||
// draft is sealed, so the transfer registers without one.
|
// draft is sealed, so the transfer registers without one.
|
||||||
@@ -1240,7 +1249,7 @@ impl Core {
|
|||||||
crate::media_type_for(name),
|
crate::media_type_for(name),
|
||||||
reader,
|
reader,
|
||||||
meta.len() as i64,
|
meta.len() as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
Some(mtime),
|
Some(mtime),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -1268,6 +1277,9 @@ impl Core {
|
|||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
let streamed = LocalSig::from(&meta);
|
let streamed = LocalSig::from(&meta);
|
||||||
|
let thumbnails = self
|
||||||
|
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
|
||||||
|
.await;
|
||||||
let reader = OwnedCountingReader::new(
|
let reader = OwnedCountingReader::new(
|
||||||
file,
|
file,
|
||||||
self.transfers.begin(
|
self.transfers.begin(
|
||||||
@@ -1278,7 +1290,7 @@ impl Core {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
self.client
|
self.client
|
||||||
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
.upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||||
warn_if_torn(rel, &path, streamed);
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(&core);
|
reconcile_all(&core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -151,6 +154,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(core);
|
reconcile_all(core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -230,8 +236,14 @@ pub(super) fn classify(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn.
|
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||||
|
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||||
|
/// watcher reported meanwhile.
|
||||||
fn reconcile_all(core: &Core) {
|
fn reconcile_all(core: &Core) {
|
||||||
|
if core.sync_paused() {
|
||||||
|
debug!("sync: paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let folders = match core.db.sync_folder_list() {
|
let folders = match core.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -357,9 +357,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favourites are a separate call: the upload seal takes classification
|
// Favorites are a separate call: the upload seal takes classification
|
||||||
// tags, but `Favorite` is not one of them — it goes through the photos
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
// volume's dedicated favourite endpoint.
|
// volume's dedicated favorite endpoint.
|
||||||
if !favorites.is_empty() {
|
if !favorites.is_empty() {
|
||||||
let updates: Vec<PhotoTagsUpdate> = favorites
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -370,7 +370,7 @@ impl Core {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
warn!(error = %e, "marking imported favourites failed");
|
warn!(error = %e, "marking imported favorites failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
//! always reflects exactly what is in flight — even if the work fails partway
|
//! always reflects exactly what is in flight — even if the work fails partway
|
||||||
//! and unwinds.
|
//! and unwinds.
|
||||||
//!
|
//!
|
||||||
|
//! The same per-block tick enforces the user's bandwidth limits: every byte a
|
||||||
|
//! transfer moves is charged to its direction's [`RateLimit`], and the thread
|
||||||
|
//! moving it sleeps off whatever the charge puts it over the cap.
|
||||||
|
//!
|
||||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@@ -20,7 +24,7 @@ use std::collections::HashMap;
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||||
|
|
||||||
@@ -52,6 +56,59 @@ pub struct TransferRegistry {
|
|||||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
|
upload_limit: RateLimit,
|
||||||
|
download_limit: RateLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far ahead of its cap a direction may run before it is made to wait, so a
|
||||||
|
/// small file still goes out at full speed.
|
||||||
|
const RATE_BURST: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// A bytes-per-second cap shared by every transfer in one direction; `0` is no
|
||||||
|
/// cap.
|
||||||
|
///
|
||||||
|
/// A virtual clock rather than a refilled bucket: each charge pushes
|
||||||
|
/// `next_free` forward by the time its bytes take at the cap, so concurrent
|
||||||
|
/// transfers split the cap between them instead of each getting all of it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RateLimit {
|
||||||
|
rate: AtomicU64,
|
||||||
|
next_free: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
fn set(&self, rate: u64) {
|
||||||
|
self.rate.store(rate, Ordering::Relaxed);
|
||||||
|
// A new cap starts from now, not from the debt the old one ran up.
|
||||||
|
*self.next_free.lock() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `n` bytes moved at `now`, returning how long the mover must wait
|
||||||
|
/// to stay under the cap.
|
||||||
|
fn charge(&self, n: u64, now: Instant) -> Duration {
|
||||||
|
let rate = self.rate.load(Ordering::Relaxed);
|
||||||
|
if rate == 0 || n == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let mut next_free = self.next_free.lock();
|
||||||
|
let start = next_free.filter(|t| *t > now).unwrap_or(now);
|
||||||
|
let done = start + Duration::from_secs_f64(n as f64 / rate as f64);
|
||||||
|
*next_free = Some(done);
|
||||||
|
done.saturating_duration_since(now + RATE_BURST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep off a rate-limit wait. The bulk uploader reads on tokio workers, so
|
||||||
|
/// the sleep is announced to the runtime there rather than silently holding a
|
||||||
|
/// worker other tasks — FUSE reads among them — may need.
|
||||||
|
fn throttle(wait: Duration) {
|
||||||
|
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||||
|
match Handle::try_current() {
|
||||||
|
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||||
|
tokio::task::block_in_place(|| std::thread::sleep(wait))
|
||||||
|
}
|
||||||
|
_ => std::thread::sleep(wait),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransferRegistry {
|
impl TransferRegistry {
|
||||||
@@ -59,6 +116,20 @@ impl TransferRegistry {
|
|||||||
Arc::new(Self::default())
|
Arc::new(Self::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap uploads and downloads at these bytes per second; `0` lifts a cap.
|
||||||
|
/// Applies to transfers already running from their next block.
|
||||||
|
pub fn set_limits(&self, upload: u64, download: u64) {
|
||||||
|
self.upload_limit.set(upload);
|
||||||
|
self.download_limit.set(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
|
match direction {
|
||||||
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
|
TransferDirection::Download => &self.download_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
||||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||||
pub fn begin(
|
pub fn begin(
|
||||||
@@ -169,9 +240,17 @@ pub struct TransferGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TransferGuard {
|
impl TransferGuard {
|
||||||
/// Record `n` more bytes moved.
|
/// Record `n` more bytes moved, then wait if they put the direction over
|
||||||
|
/// its cap.
|
||||||
pub fn add(&self, n: u64) {
|
pub fn add(&self, n: u64) {
|
||||||
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
||||||
|
let wait = self
|
||||||
|
.reg
|
||||||
|
.limit(self.entry.direction)
|
||||||
|
.charge(n, Instant::now());
|
||||||
|
if !wait.is_zero() {
|
||||||
|
throttle(wait);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,14 +389,61 @@ impl<R: Read> Read for CountingReader<'_, R> {
|
|||||||
&& cancel.load(Ordering::Relaxed)
|
&& cancel.load(Ordering::Relaxed)
|
||||||
{
|
{
|
||||||
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||||
// and the SDK would seal a truncated revision from it.
|
// and the SDK would seal a truncated revision from it. Not
|
||||||
return Err(std::io::Error::new(
|
// `Interrupted` either: every reader loop, the SDK's included,
|
||||||
std::io::ErrorKind::Interrupted,
|
// retries that kind, and this flag never clears, so the drain
|
||||||
"upload superseded by a newer write",
|
// thread spun forever with the transfer stuck at 0 bytes (B98).
|
||||||
));
|
return Err(std::io::Error::other("upload superseded by a newer write"));
|
||||||
}
|
}
|
||||||
let n = self.inner.read(buf)?;
|
let n = self.inner.read(buf)?;
|
||||||
self.guard.add(n as u64);
|
self.guard.add(n as u64);
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncapped_direction_never_waits() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
assert_eq!(limit.charge(u64::MAX / 2, Instant::now()), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capped_direction_waits_once_past_its_burst() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
// One second of bytes is the burst: no wait yet.
|
||||||
|
assert_eq!(limit.charge(1000, now), Duration::ZERO);
|
||||||
|
// Two more seconds' worth at the same instant: wait out all but the burst.
|
||||||
|
assert_eq!(limit.charge(2000, now), Duration::from_secs(2));
|
||||||
|
// Lifting the cap drops the debt with it.
|
||||||
|
limit.set(0);
|
||||||
|
assert_eq!(limit.charge(5000, now), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_transfers_share_one_cap() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
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()),
|
format!("destination access denied: errno {}", error.code()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let thumbnails = core
|
||||||
|
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||||
|
.await;
|
||||||
match core
|
match core
|
||||||
.client
|
.client
|
||||||
.upload_file_from(
|
.upload_file_from(
|
||||||
@@ -77,7 +80,7 @@ async fn run_uploads(
|
|||||||
media_type_for(&t.name),
|
media_type_for(&t.name),
|
||||||
reader,
|
reader,
|
||||||
t.size as i64,
|
t.size as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
mtime,
|
mtime,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,3 +35,9 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
|||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
webkit6 = "0.6"
|
webkit6 = "0.6"
|
||||||
|
# Town and street names on the Places map, with font fallback for every script.
|
||||||
|
pangocairo = "0.22"
|
||||||
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
|
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||||
@@ -9,6 +9,8 @@ fn main() {
|
|||||||
&format!("{}/pdfs.gresource", out_dir),
|
&format!("{}/pdfs.gresource", out_dir),
|
||||||
"--sourcedir",
|
"--sourcedir",
|
||||||
"resources/icons",
|
"resources/icons",
|
||||||
|
"--sourcedir",
|
||||||
|
"resources",
|
||||||
"resources/pdfs.gresource.xml",
|
"resources/pdfs.gresource.xml",
|
||||||
])
|
])
|
||||||
.status()
|
.status()
|
||||||
@@ -22,10 +24,9 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
);
|
println!("cargo:rerun-if-changed=resources/proton-theme.css");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/prompt.css");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
47.98,8.00 48.94,9.45 48.94,11.26
|
||||||
|
47.98,8.00 46.42,6.50 44.94,4.91 43.99,4.95 43.58,4.85 43.13,4.64 42.86,4.32 42.02,4.14 41.88,3.98
|
||||||
|
34.25,31.21 34.53,31.53 34.48,31.58
|
||||||
|
35.55,32.40 35.19,32.53 35.07,32.46 34.96,32.16 34.95,31.84 35.20,31.75 34.95,31.60 34.87,31.40 34.91,31.35 35.10,31.37 35.45,31.48
|
||||||
|
35.11,33.08 35.41,33.08 35.49,33.12 35.53,33.25 35.60,33.24
|
||||||
|
35.79,32.73 35.89,32.94 35.79,33.29 35.84,33.42
|
||||||
|
34.00,35.07 33.87,35.09 33.48,35.00 33.38,35.16 33.19,35.17 32.92,35.09 32.71,35.17
|
||||||
|
77.80,35.50 77.89,35.45 78.04,35.48 78.01,35.25 78.33,34.61 78.67,34.52 78.97,34.30 78.97,34.23 78.73,34.01 78.80,33.50 78.92,33.39
|
||||||
|
77.05,35.11 77.00,34.99 76.59,34.74 76.04,34.67 75.71,34.50 75.32,34.58
|
||||||
|
91.63,27.76 91.98,27.73 92.10,27.81 92.55,27.88 92.69,27.99 92.64,28.06 92.70,28.15 93.17,28.36 93.36,28.65 93.90,28.74 93.97,28.86 94.27,28.97 94.29,29.14 94.62,29.31 94.77,29.18 95.39,29.04 95.52,29.21 95.71,29.24 95.77,29.34 96.13,29.38 96.23,29.25 96.36,29.25 96.18,29.12 96.20,28.96 96.44,29.05 96.58,28.76 96.28,28.41 96.39,28.37 96.60,28.46 96.78,28.37 97.08,28.37 97.32,28.22
|
||||||
|
128.37,38.62 128.17,38.36 128.04,38.31 127.09,38.28 126.67,37.92 126.63,37.78
|
||||||
|
-8.68,27.66 -8.82,27.66 -8.75,27.19 -8.79,27.12 -9.41,27.09 -9.82,26.85 -10.03,26.91 -10.25,26.86 -10.76,27.02 -11.39,26.88 -11.32,26.74 -11.34,26.63 -11.51,26.47 -11.72,26.10 -12.06,25.99 -12.43,24.83 -12.99,24.47 -13.31,23.98 -13.89,23.69 -14.10,23.10 -14.22,22.31 -14.63,21.86 -14.61,21.75 -14.75,21.50 -14.97,21.44 -16.04,21.50 -17.00,21.42
|
||||||
|
35.92,4.62 35.76,4.81 35.80,5.16 35.75,5.34 35.47,5.42 35.33,5.36 35.27,5.49
|
||||||
|
-124.76,48.49 -123.86,48.26 -123.39,48.25 -123.14,48.40 -123.24,48.64 -123.01,48.79 -123.31,48.99
|
||||||
|
91.63,27.76 91.64,27.92 91.27,28.08 91.02,27.97 90.33,28.12
|
||||||
|
23.61,51.52 23.61,51.61 23.71,51.64 23.98,51.59 24.36,51.87 25.79,51.92 27.14,51.75 27.30,51.60 27.69,51.57 27.70,51.48 27.86,51.59 28.18,51.61 28.60,51.54 28.73,51.43 29.10,51.63 29.35,51.38 30.16,51.48 30.33,51.33 30.54,51.27 30.63,51.36 30.53,51.60 30.76,51.90 31.08,52.08 31.76,52.10
|
||||||
|
35.84,33.42 35.60,33.24
|
||||||
|
35.84,33.42 36.03,33.59 35.94,33.67 35.99,33.75 36.09,33.83 36.37,33.84 36.28,33.93 36.58,34.22 36.50,34.43 36.33,34.50 36.43,34.61 36.38,34.66 35.98,34.63
|
||||||
|
34.68,-12.01 34.76,-12.03 34.68,-12.12 34.68,-12.01
|
||||||
|
80.21,42.19 80.26,42.24 80.16,42.63 80.20,42.73 80.54,42.87 80.38,42.97 80.39,43.04 80.62,43.13 80.75,43.10 80.79,43.16 80.67,43.31 80.70,43.43 80.65,43.56 80.36,44.10 80.36,44.55 80.38,44.66 80.48,44.71 80.26,44.81 80.00,44.80 79.87,44.88 80.06,45.01 80.85,45.13 81.69,45.35 81.79,45.23 81.94,45.16 82.27,45.22 82.52,45.13 82.62,45.29 82.61,45.42 82.33,45.52 82.32,45.59 82.97,46.97 83.03,47.19 84.02,46.97 84.67,46.97 84.79,46.83 85.23,47.04 85.48,47.06 85.66,47.25 85.53,47.92 85.75,48.39 86.55,48.53 86.72,48.70 86.73,48.94 86.81,49.05 87.05,49.11 87.32,49.09
|
||||||
|
23.48,53.94 24.19,53.95 24.32,53.89 24.77,53.97 24.87,54.15 25.05,54.13 25.46,54.29 25.51,54.16 25.75,54.16 25.75,54.26 25.55,54.33 25.72,54.56 25.72,54.72 25.86,54.92 26.18,55.00 26.29,55.14 26.60,55.13 26.78,55.27 26.46,55.34 26.59,55.67
|
||||||
|
-53.37,-33.74 -53.53,-33.66 -53.53,-33.17 -53.13,-32.74 -53.16,-32.68 -53.60,-32.40 -53.76,-32.06 -54.22,-31.86 -54.59,-31.49 -54.90,-31.39 -55.04,-31.28 -55.17,-31.28 -55.56,-30.88 -55.63,-30.86 -55.81,-31.03
|
||||||
|
116.68,49.82 116.03,48.78 115.82,48.58 115.79,48.25 115.53,48.13 115.62,47.87 115.90,47.69 116.23,47.86 116.76,47.87 117.07,47.81 117.35,47.65 117.77,47.99 118.15,48.03 118.50,47.98 118.76,47.76 119.08,47.65 119.16,47.53 119.71,47.15 119.90,46.86 119.87,46.67 119.71,46.61 119.33,46.61 118.84,46.76 118.72,46.69 118.31,46.72 118.07,46.67 117.74,46.52 117.44,46.59 117.33,46.36 116.86,46.39 116.56,46.29 116.21,45.89 116.20,45.74 115.68,45.46 114.56,45.39 114.42,45.20 114.08,44.97 113.59,44.75 112.71,44.88 112.41,45.06 111.90,45.06 111.62,44.83 111.40,44.37 111.60,44.11 111.84,43.93 111.93,43.71 111.55,43.50 111.01,43.34 110.40,42.77 109.34,42.44 108.17,42.45 106.77,42.29 105.20,41.74 104.98,41.60 104.50,41.66 104.50,41.88 103.71,41.75 103.07,42.01 102.16,42.16 101.97,42.22 101.66,42.50 101.50,42.54 99.98,42.68 99.47,42.57 97.21,42.79 96.39,42.72 96.30,42.93 95.86,43.28 95.53,43.95 95.33,44.04 95.35,44.28 95.05,44.26 94.71,44.35 94.20,44.65 93.96,44.67 93.52,44.94 92.79,45.04 91.58,45.08 91.05,45.22 90.88,45.20 90.66,45.53 90.71,45.73 91.00,46.04 90.91,46.27 91.03,46.57 90.87,46.95 90.72,47.00 90.50,47.29 90.33,47.66 90.10,47.75 90.03,47.88 89.78,47.83 89.48,48.03 89.05,48.00 88.58,48.22 88.52,48.38 87.98,48.56 88.06,48.71 87.74,48.88 87.87,49.00 87.81,49.16
|
||||||
|
13.81,48.77 13.55,48.96 13.40,48.98 12.92,49.33 12.68,49.41 12.39,49.74 12.51,49.90 12.21,50.10 12.09,50.30 12.28,50.18 12.55,50.39 12.94,50.41 13.02,50.49 13.44,50.60 13.56,50.70 14.37,50.90 14.26,51.00 14.32,51.04 14.55,50.99 14.66,50.83 14.81,50.86
|
||||||
|
27.35,57.53 26.97,57.61 26.46,57.54 25.99,57.84 25.28,58.05 25.26,58.00 25.11,58.06 24.32,57.87
|
||||||
|
27.35,57.53 27.51,57.51 27.54,57.43 27.83,57.29 27.64,56.85 27.85,56.85 28.10,56.55 28.20,56.26 28.15,56.14
|
||||||
|
21.05,56.07 21.65,56.31 22.08,56.41 22.88,56.40 23.04,56.32 23.20,56.37 24.12,56.26 24.47,56.28 24.84,56.41 25.07,56.20 25.66,56.10 26.28,55.75 26.59,55.67
|
||||||
|
20.62,69.04 20.12,69.02 20.35,68.85 20.24,68.67 19.97,68.54 20.24,68.48 19.97,68.36 18.30,68.56 18.16,68.53 18.18,68.20 17.92,67.96 17.32,68.10 16.78,67.90 16.57,67.62 16.19,67.51 16.13,67.43 16.43,67.16 16.40,67.05 15.42,66.49 15.48,66.31 15.04,66.17 14.54,66.13 14.63,65.79 14.48,65.30 13.65,64.58 14.08,64.46 14.14,64.17 13.96,64.01 13.20,64.08 12.79,64.00 12.18,63.60 12.21,63.49 12.00,63.29 12.22,63.00 12.11,62.92 12.11,62.59 12.30,62.29 12.16,61.72 12.88,61.35 12.71,61.06 12.29,61.00 12.59,60.45 12.49,60.11 12.17,59.91 11.93,59.86 11.83,59.70 11.68,59.59 11.80,59.29 11.64,58.93 11.47,58.91 11.34,59.09 11.24,59.10 11.14,59.09 11.06,58.96 10.91,58.95
|
||||||
|
20.62,69.04 21.07,69.04 21.13,69.08 21.07,69.21 21.59,69.27 22.30,68.86 22.41,68.72 23.32,68.65 23.71,68.71 23.85,68.81 24.94,68.59 25.09,68.64 25.25,68.82 25.58,68.89 25.75,68.99 25.77,69.28 26.07,69.69 26.53,69.92 27.13,69.91 27.75,70.06 27.89,70.06 28.41,69.82 29.14,69.67 29.33,69.47 28.85,69.18 28.83,69.12 28.97,69.02
|
||||||
|
104.43,10.41 104.56,10.52 104.85,10.53 105.05,10.70 105.05,10.91 105.31,10.85 105.41,10.95 105.76,10.99 105.85,10.86 106.16,10.79 106.16,11.04 105.86,11.29 105.85,11.64 106.01,11.76 106.40,11.69 106.41,11.95 106.70,11.98 106.76,12.05 106.93,12.08 107.21,12.30 107.33,12.32 107.39,12.26 107.51,12.36 107.56,12.54 107.48,13.03 107.61,13.44 107.33,14.13 107.36,14.37 107.49,14.55 107.52,14.71
|
||||||
|
-69.51,-17.50 -69.31,-17.94 -69.09,-18.05 -69.15,-18.14 -68.97,-18.97 -68.46,-19.43 -68.70,-19.72 -68.56,-19.97 -68.60,-20.04 -68.76,-20.09 -68.69,-20.31 -68.76,-20.42 -68.48,-20.63 -68.56,-20.72 -68.56,-20.90 -68.44,-20.95 -68.20,-21.30 -68.19,-21.62 -68.08,-21.98 -67.99,-22.06 -67.88,-22.49 -67.88,-22.82 -67.58,-22.89 -67.19,-22.82
|
||||||
|
-69.51,-17.51 -69.85,-17.70 -69.80,-17.99 -69.93,-18.21 -70.18,-18.33 -70.42,-18.35
|
||||||
|
6.34,49.45 6.49,49.80 6.20,49.92 6.11,50.03 6.12,50.12
|
||||||
|
2.52,51.10 2.60,50.88 2.76,50.75 2.84,50.71 3.11,50.78 3.23,50.66 3.27,50.53 3.60,50.48 3.69,50.31 3.95,50.34 4.17,50.25 4.15,49.97 4.55,49.96 4.82,50.15 4.86,50.14 4.79,49.96 4.87,49.79 5.28,49.68 5.51,49.51 5.79,49.54
|
||||||
|
6.12,50.12 5.98,50.17 5.74,49.92 5.72,49.81 5.88,49.64 5.79,49.54
|
||||||
|
46.43,41.89 45.95,42.04 45.64,42.21 45.71,42.50 45.34,42.53 45.16,42.68 44.87,42.76 44.77,42.62 44.64,42.73 44.51,42.75 43.83,42.57 43.74,42.62 43.78,42.75 43.09,42.99 42.99,43.09 42.76,43.17 42.57,43.16 42.42,43.22 41.58,43.22 40.65,43.53 40.15,43.57 39.98,43.42
|
||||||
|
20.96,40.85 20.93,40.90 20.71,40.93 20.49,41.27 20.45,41.52 20.52,41.57 20.57,41.87
|
||||||
|
20.57,41.87 20.58,42.01 20.49,42.22 20.24,42.34 20.06,42.55
|
||||||
|
41.51,41.52 41.82,41.43 41.93,41.50 42.47,41.44 42.61,41.58 42.75,41.58 43.15,41.31 43.21,41.20 43.40,41.18 43.44,41.11
|
||||||
|
101.14,21.57 101.20,21.52 101.25,21.20 101.54,21.23 101.70,21.15 101.80,21.21 101.72,21.31 101.74,21.83 101.60,21.99 101.52,22.25 101.74,22.50 101.84,22.39 102.02,22.44 102.13,22.38
|
||||||
|
73.63,39.45 73.82,39.49 73.91,39.58 73.84,39.80 73.99,40.04 74.41,40.14 74.68,40.31 74.83,40.33 74.84,40.48 75.11,40.45 75.56,40.63 75.68,40.31 75.87,40.30 76.26,40.43 76.32,40.35 76.48,40.45 76.66,40.78 76.91,41.02 77.58,40.99 78.12,41.08 78.44,41.42 79.29,41.78 79.77,41.90 79.84,42.00 80.22,42.03 80.21,42.19
|
||||||
|
43.44,41.11 44.23,41.21 44.84,41.21 44.81,41.26 45.00,41.29
|
||||||
|
80.21,42.19 79.92,42.41 79.43,42.48 79.13,42.78 76.99,42.97 75.84,42.94 75.64,42.81 75.37,42.84 74.82,42.98 74.21,43.24 73.95,43.20 73.56,43.00 73.42,42.59 73.49,42.41 72.28,42.76 71.76,42.82 71.26,42.73 70.89,42.34 70.95,42.25
|
||||||
|
-56.48,1.94 -56.84,1.88 -57.12,2.01 -57.32,1.96 -57.55,1.73 -57.98,1.65 -58.03,1.52 -58.34,1.59 -58.40,1.48 -58.51,1.44 -58.51,1.28 -58.68,1.28 -58.82,1.20 -59.23,1.38 -59.54,1.70 -59.67,1.75 -59.67,1.84 -59.76,1.90 -59.76,2.27 -59.89,2.36 -59.99,2.69 -59.97,2.99 -59.83,3.35 -59.85,3.59 -59.68,3.70 -59.55,3.93 -59.74,4.23 -59.70,4.38 -59.83,4.48 -60.15,4.53 -60.03,4.74 -59.99,5.08 -60.14,5.24 -60.74,5.20
|
||||||
|
8.39,55.10 8.59,54.92 9.25,54.81 9.74,54.83
|
||||||
|
9.52,30.23 9.90,30.39 10.22,30.78 10.26,30.94 10.11,31.46 10.27,31.68 10.48,31.74 10.61,31.93 10.83,32.08 11.51,32.41 11.53,32.52 11.45,32.64 11.50,33.18
|
||||||
|
45.00,41.29 45.19,41.15 45.07,41.08 45.42,40.99 45.59,40.85 45.38,40.67 45.45,40.53 45.57,40.42 45.96,40.23 45.89,40.02 45.58,39.98 46.20,39.59 46.48,39.56 46.48,39.48 46.37,39.40 46.58,39.22 46.40,39.19 46.49,39.07 46.49,38.91
|
||||||
|
44.77,39.70 45.03,39.77 45.17,39.57 45.25,39.60 45.46,39.49 45.78,39.55 45.77,39.38 45.98,39.24 45.95,39.18 46.11,38.88
|
||||||
|
20.24,46.11 20.51,46.17 20.61,46.13 20.76,46.25 21.12,46.28 21.26,46.41 21.32,46.61 21.50,46.70 21.66,47.04 22.00,47.40 22.00,47.51 22.29,47.73 22.61,47.77 22.88,47.95
|
||||||
|
70.96,40.24 71.30,40.29 71.63,40.22 71.69,40.15 71.96,40.26 72.13,40.44 72.36,40.40 72.40,40.58 72.60,40.53 73.14,40.81 72.66,40.87 72.36,41.04 72.19,41.03 72.16,41.17 71.88,41.20 71.86,41.31 71.66,41.54 71.60,41.50 71.59,41.33 71.42,41.34 71.39,41.12 70.86,41.22 70.69,41.45 70.47,41.41 70.20,41.51 70.18,41.57 70.63,41.88 70.80,41.92 70.86,42.03 71.23,42.16 71.04,42.28 70.95,42.25
|
||||||
|
18.83,49.51 18.94,49.50 18.97,49.40 19.15,49.40 19.44,49.60 19.63,49.41 19.77,49.37 19.76,49.20 20.06,49.18 20.16,49.32 20.36,49.39 20.62,49.39 20.87,49.31 21.08,49.42 21.64,49.41 21.89,49.34 22.02,49.21 22.54,49.07
|
||||||
|
-7.22,55.09 -7.38,55.03 -7.55,54.77 -7.91,54.70 -7.75,54.59 -8.12,54.48 -8.12,54.41 -7.85,54.22 -7.61,54.14 -7.32,54.13 -7.16,54.24 -7.20,54.30 -7.01,54.41 -6.80,54.21 -6.65,54.16 -6.65,54.06 -6.22,54.09
|
||||||
|
18.83,49.51 18.81,49.61 18.59,49.76 18.56,49.88 18.03,50.04 17.87,49.97 17.63,50.12 17.59,50.16 17.74,50.23 17.70,50.31 17.42,50.25 16.88,50.43 16.99,50.24 16.64,50.10 16.21,50.42 16.42,50.57 16.28,50.66 16.01,50.61 15.73,50.74 15.35,50.81 15.26,50.96 14.99,51.01 14.98,50.89 14.81,50.86
|
||||||
|
70.96,40.24 70.95,40.19 70.64,40.08 70.62,40.00 70.52,39.95 70.45,40.05 69.97,40.20 69.53,40.10 69.48,39.92 69.31,39.97 69.28,39.92 69.23,39.76 69.30,39.52 70.50,39.59 70.80,39.39 71.00,39.41 71.07,39.49 71.47,39.60 71.50,39.48 71.73,39.42 71.73,39.31 71.78,39.28 72.04,39.35 72.23,39.21 72.36,39.34 72.56,39.38 73.11,39.36 73.63,39.45
|
||||||
|
20.00,39.71 20.25,39.68 20.31,39.80 20.38,39.80 20.31,39.98 20.66,40.12 20.81,40.45 20.95,40.49 21.03,40.62 20.96,40.85
|
||||||
|
33.20,-14.01 33.15,-13.94 32.99,-14.02 32.87,-13.82 32.77,-13.76 32.80,-13.69 32.67,-13.59 32.85,-13.46 32.97,-13.22 33.00,-12.90 32.95,-12.80 33.02,-12.63 33.40,-12.49 33.51,-12.35 33.34,-12.31 33.25,-12.11 33.30,-11.69 33.23,-11.42 33.38,-11.16 33.26,-10.89 33.46,-10.78 33.66,-10.55 33.53,-10.23 33.31,-10.04 33.35,-9.86 33.20,-9.63 33.00,-9.62 32.92,-9.41
|
||||||
|
22.13,48.41 22.14,48.57 22.30,48.69 22.54,49.07
|
||||||
|
-11.51,6.91 -11.27,7.23 -10.65,7.76 -10.57,8.07 -10.36,8.19 -10.28,8.49
|
||||||
|
24.15,8.67 24.22,8.61 24.21,8.37 24.29,8.29 24.85,8.14 25.20,7.81 25.25,7.72 25.18,7.56 25.28,7.43 25.89,7.06 26.36,6.64 26.31,6.46 26.51,6.07 27.14,5.72 27.33,5.19
|
||||||
|
42.92,11.00 43.25,11.50
|
||||||
|
42.92,11.00 42.56,11.08 41.96,10.94 41.80,10.98 41.79,11.69 42.38,12.47
|
||||||
|
29.40,-4.45 29.32,-4.90 29.61,-5.72 29.48,-6.03 29.54,-6.31 29.71,-6.62 30.21,-7.04 30.75,-8.19
|
||||||
|
42.38,12.47 42.48,12.51 42.70,12.38 42.87,12.62 43.12,12.71
|
||||||
|
13.81,48.77 14.05,48.60 14.69,48.60 14.79,48.75 14.92,48.77 14.97,48.98 15.07,49.00 15.83,48.86 16.06,48.75 16.37,48.74 16.54,48.80 16.88,48.70 16.95,48.60
|
||||||
|
42.36,37.11 42.77,37.37 42.94,37.32 43.09,37.37 43.68,37.23 44.11,37.30 44.19,37.25 44.20,37.05 44.28,36.98 44.61,37.18 44.77,37.14
|
||||||
|
16.09,46.86 16.25,46.97 16.45,47.01 16.49,47.12 16.42,47.22 16.44,47.40 16.62,47.45 16.68,47.54 16.64,47.61 16.42,47.67 16.59,47.75 16.79,47.68 17.07,47.71 17.03,47.84 17.15,48.01
|
||||||
|
7.02,45.93 7.13,45.88 7.54,45.98 7.79,45.92 7.99,46.02 8.13,46.16 8.10,46.27 8.42,46.45 8.46,46.25 8.64,46.11 8.82,46.08 8.78,46.00 8.95,45.83 9.02,45.85 9.00,46.01 9.25,46.29 9.26,46.48 9.43,46.48 9.53,46.31 9.94,46.36 10.04,46.24 10.13,46.24 10.04,46.48 10.09,46.60 10.43,46.55 10.40,46.67 10.45,46.86
|
||||||
|
10.45,46.86 10.35,46.98 10.13,46.85 9.88,46.94 9.85,47.01 9.58,47.06
|
||||||
|
44.77,37.14 44.79,37.29 44.57,37.44 44.55,37.66 44.59,37.71 44.21,37.91 44.45,38.33 44.30,38.39 44.27,38.84 44.17,38.93 44.18,39.14 44.08,39.22 44.02,39.38 44.39,39.42 44.46,39.67 44.59,39.77 44.82,39.65
|
||||||
|
23.48,53.94 23.45,54.14 23.37,54.20 22.89,54.39 22.77,54.36
|
||||||
|
30.75,-8.19 30.89,-8.47 31.03,-8.60 31.35,-8.61 31.53,-8.71 31.67,-8.91 31.92,-8.94 31.94,-9.05 32.43,-9.16 32.92,-9.41
|
||||||
|
7.20,53.28 7.19,53.00 7.03,52.65 6.71,52.62 6.75,52.46 6.97,52.44 7.04,52.38 6.98,52.21 6.72,52.08 6.80,51.97 6.52,51.85 6.36,51.82 6.17,51.88 5.95,51.80 6.20,51.45 6.08,51.22 6.13,51.15 5.86,51.03 6.05,50.90 5.99,50.75
|
||||||
|
9.58,47.06 9.61,47.11 9.53,47.27
|
||||||
|
-8.49,7.56 -8.43,7.60 -8.23,7.56 -8.01,8.08 -8.05,8.17 -8.26,8.25 -8.24,8.46 -8.05,8.50 -7.82,8.47 -7.74,8.38 -7.68,8.41 -7.72,8.64 -7.95,8.79 -7.94,8.98 -7.78,9.08 -7.92,9.19 -7.90,9.42 -8.03,9.40 -8.14,9.50 -8.16,9.97 -7.99,10.16
|
||||||
|
7.49,43.77 7.48,43.86 7.68,44.08 7.64,44.16 7.32,44.14 6.90,44.34 6.84,44.51 7.03,44.72 6.99,44.83 6.74,44.92 6.63,45.07 6.69,45.14 6.84,45.14 7.08,45.24 7.15,45.38 6.79,45.74 6.80,45.81 7.02,45.93
|
||||||
|
28.97,69.02 28.41,68.90 28.77,68.84 28.48,68.54 28.56,68.35 28.69,68.19 29.34,68.06 29.99,67.67 29.94,67.55 29.24,67.10 29.07,66.89 29.90,66.09 30.09,65.79 30.10,65.68 29.72,65.62 29.82,65.57 29.73,65.47 29.71,65.34 29.61,65.25 29.81,65.20 29.83,65.15 29.62,65.04 29.60,64.97 29.78,64.80 30.11,64.73 30.12,64.64 29.99,64.52 30.11,64.37 30.49,64.24 30.53,64.08 30.21,63.80 29.99,63.74 31.18,63.21 31.53,62.89 31.19,62.48 29.25,61.29 28.41,60.90 27.73,60.46
|
||||||
|
30.87,69.78 30.92,69.61 30.86,69.54 30.62,69.53 30.18,69.64 30.19,69.54 30.09,69.43 29.39,69.30 29.21,69.10 28.97,69.02
|
||||||
|
55.19,22.70 55.20,23.03 55.53,23.82 55.47,23.94 55.99,24.06 55.93,24.22 55.76,24.24 55.80,24.87 55.92,24.97 56.00,24.95 55.97,24.86 56.06,24.74 56.39,24.98
|
||||||
|
48.57,41.84 48.39,41.60 48.06,41.46 47.79,41.20 47.32,41.28 47.14,41.52 46.75,41.81 46.57,41.80 46.54,41.87 46.43,41.89
|
||||||
|
-54.62,2.33 -54.72,2.44 -54.88,2.45 -54.98,2.60 -55.39,2.44 -55.73,2.41 -55.96,2.52 -56.14,2.26 -55.92,2.04 -55.93,1.89 -56.02,1.84 -56.48,1.94
|
||||||
|
46.43,41.89 46.20,41.74 46.18,41.66 46.31,41.51 46.67,41.29 46.53,41.09 46.43,41.08 46.17,41.20 45.92,41.19 45.73,41.26 45.72,41.34 45.28,41.45 45.00,41.29
|
||||||
|
-11.39,12.40 -11.45,12.56 -11.39,12.94 -11.63,13.37 -11.76,13.39 -11.83,13.32 -12.05,13.63 -11.96,13.88 -12.02,13.97 -12.02,14.21 -12.23,14.46 -12.19,14.65 -12.28,14.81
|
||||||
|
-54.62,2.33 -54.13,2.12 -53.77,2.35 -53.51,2.25 -53.33,2.34 -53.23,2.20 -52.90,2.21 -52.58,2.53 -52.33,3.18 -51.99,3.70 -51.65,4.06
|
||||||
|
2.71,6.37 2.77,6.71 2.72,6.98 2.79,7.48 2.69,7.87 2.77,9.05 3.04,9.08 3.15,9.32 3.14,9.45 3.33,9.67 3.35,9.81 3.56,9.91 3.65,10.16 3.58,10.29 3.65,10.41 3.77,10.42 3.83,10.61 3.72,11.08 3.49,11.40 3.60,11.70
|
||||||
|
13.07,-4.63 12.80,-4.43 12.38,-4.62 12.31,-4.77 12.20,-4.78 12.02,-5.00
|
||||||
|
16.52,46.50 16.32,46.53 16.24,46.48 16.23,46.37 16.07,46.37 15.64,46.20 15.59,46.14 15.67,46.05 15.65,45.86 15.28,45.73 15.35,45.66 15.28,45.58 15.34,45.47 14.79,45.48 14.57,45.66 14.37,45.48 13.99,45.51 13.88,45.43 13.58,45.52
|
||||||
|
18.91,45.93 18.67,45.91 18.53,45.80 18.36,45.75 17.81,45.79 17.31,46.00 16.87,46.34 16.52,46.50
|
||||||
|
20.24,46.11 19.61,46.17 19.28,46.00 19.09,46.02 18.91,45.93
|
||||||
|
16.52,46.50 16.28,46.86 16.09,46.86
|
||||||
|
51.27,24.61 50.97,24.57 50.80,24.79
|
||||||
|
25.26,-17.79 25.24,-17.97 25.94,-18.94 25.95,-19.08 26.17,-19.54 26.68,-19.89 27.18,-20.10 27.26,-20.23 27.28,-20.48 27.68,-20.50 27.67,-21.06 28.01,-21.55 29.03,-21.80 29.04,-22.02 29.36,-22.19
|
||||||
|
77.80,35.50 77.45,35.48 76.77,35.66
|
||||||
|
28.01,41.97 27.53,41.92 27.24,42.09 27.01,42.06 26.58,41.95 26.51,41.83 26.36,41.80 26.32,41.72
|
||||||
|
102.93,11.71 102.74,12.09 102.76,12.43 102.50,12.67 102.46,13.02 102.33,13.29 102.34,13.56 102.55,13.59 102.54,13.66 102.91,14.14 103.20,14.33 103.60,14.42 103.98,14.36 104.78,14.43 104.97,14.37 105.00,14.25 105.07,14.23 105.18,14.35
|
||||||
|
88.89,27.32 88.74,27.18 88.86,26.96 89.15,26.82 89.33,26.85 89.55,26.80 89.61,26.72 90.12,26.75 90.35,26.89 90.74,26.77 91.29,26.79 91.43,26.87 91.67,26.80 92.05,26.87 92.07,26.98 91.99,27.10 92.08,27.29 91.99,27.45 91.74,27.44 91.59,27.56 91.63,27.76
|
||||||
|
104.07,1.28 104.06,1.40 103.82,1.46 103.70,1.44 103.60,1.21
|
||||||
|
100.12,6.44 100.18,6.67 100.26,6.68 100.35,6.55 100.75,6.46 100.87,6.25 101.05,6.24 101.08,5.96 100.98,5.77 101.11,5.64 101.26,5.79 101.56,5.91 101.68,5.78 101.87,5.83 102.10,6.24
|
||||||
|
5.99,50.75 6.24,50.60 6.17,50.55 6.20,50.50 6.34,50.45 6.36,50.32 6.18,50.23 6.12,50.12
|
||||||
|
-71.78,19.72 -71.71,19.49 -71.75,19.29 -71.65,19.16 -71.81,18.99 -71.74,18.73 -71.87,18.61 -72.00,18.60 -71.76,18.34 -71.77,18.04
|
||||||
|
14.06,13.08 13.61,13.70
|
||||||
|
46.53,29.10 46.91,29.54 47.15,30.00 47.33,30.08 47.64,30.10 47.98,29.98
|
||||||
|
88.11,27.87 88.14,27.95 88.58,28.09 88.80,28.01 88.85,27.87 88.75,27.52 88.89,27.32
|
||||||
|
-90.10,13.74 -90.05,13.90 -89.55,14.24 -89.57,14.39 -89.36,14.42
|
||||||
|
124.04,-9.34 124.09,-9.42 124.28,-9.43 124.41,-9.31 124.44,-9.19
|
||||||
|
124.92,-8.94 124.94,-9.05 125.10,-9.00 125.15,-9.04 125.10,-9.19 124.96,-9.21 125.07,-9.51
|
||||||
|
115.14,4.90 115.27,4.63 115.29,4.35 115.11,4.39 115.03,4.90
|
||||||
|
109.63,2.03 109.54,1.90 109.64,1.78 109.65,1.61 110.51,0.86 111.10,1.05 111.81,1.01 111.92,1.11 112.08,1.14 112.19,1.44 112.48,1.56 112.94,1.57 113.01,1.43 113.62,1.24 113.90,1.43 114.39,1.50 114.51,1.45 114.70,1.85 114.83,1.98 114.76,2.16 114.79,2.25 115.18,2.52 115.08,2.63 115.12,2.89 115.25,3.03 115.45,3.03 115.57,3.45 115.57,3.94 115.68,4.19 115.84,4.33 116.02,4.29 116.24,4.36 116.41,4.31 116.51,4.37 117.10,4.34 117.54,4.17 117.88,4.19
|
||||||
|
10.45,46.86 10.99,46.78 11.24,46.98 11.78,46.99 12.17,47.08 12.15,46.94 12.39,46.70 13.70,46.52
|
||||||
|
13.70,46.52 13.38,46.26 13.63,46.18 13.48,46.01 13.61,45.96 13.58,45.81 13.87,45.61 13.72,45.59
|
||||||
|
29.01,-2.72 29.10,-2.60 29.30,-2.67 29.39,-2.81 29.70,-2.79 29.87,-2.72 29.93,-2.34 30.12,-2.42 30.41,-2.31 30.55,-2.40
|
||||||
|
92.57,21.98 92.63,21.31 92.57,21.26 92.33,21.44 92.18,21.29 92.32,20.79
|
||||||
|
1.43,42.60 1.29,42.71 0.70,42.85 0.63,42.69 -0.04,42.69 -0.30,42.83 -0.59,42.80 -0.76,42.94 -1.18,43.02 -1.30,43.10 -1.46,43.05 -1.41,43.24 -1.71,43.31 -1.79,43.41
|
||||||
|
1.43,42.60 1.50,42.64 1.71,42.60 1.71,42.50
|
||||||
|
1.71,42.50 2.03,42.35 2.20,42.42 2.65,42.34 2.89,42.46 3.21,42.43
|
||||||
|
1.43,42.60 1.45,42.44 1.71,42.50
|
||||||
|
74.89,37.23 75.12,37.39 74.89,37.60 74.94,37.77 74.78,38.19 74.81,38.46 74.28,38.66 74.13,38.66 74.03,38.54 73.80,38.61 73.70,38.85 73.81,38.97 73.61,39.23 73.63,39.45
|
||||||
|
74.89,37.23 74.73,37.29 74.37,37.16 74.54,37.02
|
||||||
|
20.96,40.85 21.40,40.91 21.58,40.87 21.99,41.13 22.60,41.14 22.72,41.18 22.78,41.33 22.92,41.34
|
||||||
|
21.56,42.25 21.39,42.22 21.29,42.10 21.06,42.17 20.78,42.07 20.72,41.87 20.57,41.87
|
||||||
|
22.34,42.31 22.24,42.36 21.56,42.25
|
||||||
|
22.34,42.31 22.52,42.44 22.44,42.63 22.47,42.84 22.71,42.88 22.98,43.19 22.55,43.45 22.37,43.78 22.42,44.01 22.60,44.08 22.63,44.19 22.71,44.24
|
||||||
|
22.92,41.34 23.00,41.74 22.84,41.99 22.58,42.10 22.34,42.31
|
||||||
|
18.44,42.56 18.52,42.43
|
||||||
|
18.44,42.56 17.80,42.90 17.67,42.90
|
||||||
|
78.92,33.39 79.12,33.23
|
||||||
|
20.34,42.83 20.47,42.86 20.48,42.95 20.62,43.03 20.62,43.20 20.80,43.26 20.84,43.17 21.06,43.09 21.40,42.83 21.39,42.75 21.75,42.67 21.61,42.39 21.52,42.33 21.56,42.25
|
||||||
|
20.34,42.83 20.05,42.76 20.06,42.55
|
||||||
|
19.19,43.53 19.22,43.45 19.61,43.17 20.27,42.94 20.34,42.83
|
||||||
|
-89.36,14.42 -89.12,14.37 -88.71,14.03 -88.51,13.98 -88.48,13.85 -88.15,13.99 -87.99,13.88 -87.80,13.89 -87.72,13.81 -87.78,13.52 -87.74,13.45 -87.81,13.40
|
||||||
|
-87.34,12.98 -87.01,13.01 -86.92,13.22 -86.73,13.28 -86.76,13.75 -86.33,13.77 -86.04,14.05 -85.98,13.97 -85.73,13.86 -85.68,13.98 -85.18,14.34 -85.16,14.53 -85.04,14.61 -84.99,14.75 -84.86,14.81 -84.65,14.66 -84.45,14.64 -84.24,14.75 -84.15,14.72 -83.64,14.88 -83.54,14.98 -83.16,14.99
|
||||||
|
-75.28,-0.11 -75.78,0.09 -76.07,0.35 -76.27,0.44 -76.39,0.40 -76.43,0.26 -76.49,0.24 -76.83,0.25 -77.40,0.39 -77.47,0.64 -77.65,0.72 -77.70,0.84 -77.83,0.83 -78.18,0.97 -78.86,1.46
|
||||||
|
-57.60,3.37 -57.65,3.52 -57.83,3.68 -58.03,4.00 -58.05,4.17 -57.85,4.67 -57.92,4.82 -57.88,4.88 -57.71,4.99 -57.33,5.02 -57.21,5.20 -57.32,5.34 -57.19,5.55
|
||||||
|
-54.08,3.33 -53.99,3.59 -54.35,4.05 -54.48,4.84 -54.45,5.01 -54.16,5.36
|
||||||
|
-58.41,-33.94 -58.48,-33.65 -58.38,-33.14 -58.24,-33.11 -58.13,-32.98 -58.20,-32.47
|
||||||
|
-57.61,-30.19 -57.87,-30.59 -57.81,-30.86 -57.90,-30.98 -57.89,-31.20 -58.05,-31.49 -57.99,-31.58 -58.01,-31.68 -58.19,-31.92 -58.12,-32.25 -58.20,-32.47
|
||||||
|
-54.62,-25.58 -54.44,-25.62 -54.15,-25.52 -53.89,-25.67 -53.67,-26.29 -53.75,-26.75 -53.72,-26.88 -53.84,-27.12 -54.16,-27.25 -54.33,-27.42 -54.83,-27.55 -54.91,-27.71 -55.10,-27.87 -55.73,-28.20 -55.69,-28.38 -55.89,-28.37 -56.94,-29.59 -57.22,-29.78 -57.41,-30.03 -57.61,-30.19
|
||||||
|
20.62,69.04 20.90,68.98 20.92,68.91 22.00,68.52 22.85,68.37 23.10,68.26 23.18,68.14 23.32,68.13 23.64,67.95 23.50,67.88 23.54,67.61 23.45,67.46 23.73,67.42 23.77,67.33 23.63,67.23 23.64,67.13 23.99,66.81 23.67,66.38 23.72,66.22 23.91,66.15 24.16,65.81 24.19,65.61 24.12,65.51
|
||||||
|
4.23,51.39 4.17,51.31 3.90,51.21 3.58,51.29 3.43,51.25 3.35,51.38
|
||||||
|
5.99,50.75 5.75,50.76 5.64,50.84 5.75,50.95 5.80,51.15 5.48,51.29 5.21,51.28 5.03,51.47 4.85,51.40 4.76,51.49 4.63,51.42 4.50,51.47 4.38,51.43 4.37,51.36 4.23,51.39
|
||||||
|
6.34,49.45 6.53,49.39 6.74,49.16 6.89,49.21 7.04,49.11 7.45,49.15 7.61,49.06 8.13,48.97 8.14,48.89 7.84,48.64 7.62,48.16 7.53,47.67 7.62,47.59
|
||||||
|
9.52,47.52 9.18,47.67 8.88,47.66 8.57,47.78 8.40,47.69 8.57,47.65 8.45,47.60 7.62,47.59
|
||||||
|
9.53,47.27 9.63,47.47 9.52,47.52
|
||||||
|
9.58,47.06 9.49,47.06 9.53,47.27
|
||||||
|
7.02,45.93 6.77,46.17 6.82,46.28 6.76,46.42 6.43,46.43 6.23,46.33 6.27,46.25 6.20,46.19 6.01,46.14 5.97,46.21 6.10,46.28 6.12,46.38 6.07,46.46 6.16,46.61 6.41,46.76 6.46,46.95 6.67,47.03 7.00,47.32 6.90,47.39 6.97,47.45 7.14,47.49 7.34,47.43 7.62,47.59
|
||||||
|
-7.41,37.18 -7.50,37.59 -7.44,37.73 -7.19,38.01 -7.02,38.04 -6.96,38.19 -7.11,38.18 -7.34,38.46 -7.28,38.71 -7.05,38.91 -7.00,39.06 -7.17,39.14 -7.34,39.47 -7.54,39.66 -7.12,39.68 -6.98,39.80 -6.90,40.02 -7.03,40.17 -6.81,40.34 -6.84,40.78 -6.93,41.01 -6.21,41.53 -6.31,41.64 -6.54,41.67 -6.62,41.94 -7.15,41.98 -7.21,41.90 -7.40,41.83 -7.92,41.88 -8.15,41.81 -8.22,41.90 -8.13,42.02 -8.27,42.14 -8.78,41.94
|
||||||
|
13.81,48.77 13.79,48.59 13.72,48.54 13.49,48.58 13.37,48.36 12.76,48.11 12.95,47.89 12.90,47.72 13.05,47.66 13.01,47.48 12.81,47.54 12.77,47.64 12.69,47.67 12.48,47.64 12.21,47.72 12.19,47.62 11.72,47.58 11.04,47.39 10.87,47.52 10.44,47.55 10.37,47.37 10.18,47.28 10.20,47.36 10.07,47.39 9.97,47.51 9.75,47.58 9.52,47.52
|
||||||
|
16.09,46.86 15.98,46.80 15.96,46.68 15.77,46.71 15.44,46.63 14.89,46.61 14.55,46.40 13.70,46.52
|
||||||
|
18.44,42.56 18.55,42.64 18.47,42.78 18.46,43.00 18.62,43.03 18.67,43.23 18.85,43.35 19.03,43.29 18.95,43.53 19.19,43.53
|
||||||
|
19.19,43.53 19.30,43.59 19.45,43.56 19.50,43.64 19.25,43.97 19.58,44.01 19.12,44.36 19.35,44.88 18.84,44.88 18.66,45.08 18.00,45.14 17.81,45.08 17.65,45.16 17.50,45.12 17.13,45.17 16.92,45.28 16.79,45.20 16.53,45.22 16.29,45.01 16.03,45.19 15.79,45.18 15.74,44.77 16.10,44.52 16.30,44.12 17.08,43.52 17.27,43.45 17.29,43.31 17.65,43.01 17.59,42.94
|
||||||
|
19.01,44.87 19.10,44.97 19.06,45.14 19.14,45.20 19.40,45.21 19.00,45.40 19.06,45.52 18.92,45.60 18.95,45.66 18.84,45.84 18.91,45.93
|
||||||
|
16.95,48.60 17.14,48.84 17.76,48.89 18.09,49.07 18.16,49.26 18.60,49.49 18.83,49.51
|
||||||
|
17.15,48.01 16.87,48.39 16.95,48.60
|
||||||
|
17.15,48.01 17.32,47.99 17.76,47.77 18.72,47.79 18.79,48.00 19.47,48.11 19.63,48.22 19.95,48.15 20.33,48.30 20.49,48.53 21.45,48.55 21.72,48.35 22.13,48.41
|
||||||
|
14.81,50.86 15.02,51.25 14.91,51.46 14.72,51.52 14.74,51.63 14.60,51.83 14.75,52.08 14.55,52.36 14.62,52.53 14.13,52.88 14.41,53.22 14.21,53.95
|
||||||
|
22.88,47.95 22.77,48.11 22.58,48.13 22.35,48.26 22.25,48.41 22.13,48.41
|
||||||
|
28.59,43.74 28.22,43.77 28.05,43.82 27.88,43.99 27.74,43.96 27.43,44.02 27.09,44.17 26.22,44.01 25.82,43.77 25.50,43.67 23.22,43.87 22.92,43.83 22.87,43.95 23.03,44.08 22.71,44.24
|
||||||
|
20.24,46.11 20.71,45.74 20.77,45.75 20.79,45.47 21.49,45.15 21.35,45.01 21.53,44.90 21.36,44.83
|
||||||
|
22.92,41.34 23.64,41.39 24.01,41.46 24.06,41.53 24.49,41.56 24.77,41.36 24.99,41.36 25.25,41.24 25.92,41.31 26.14,41.39 26.07,41.67 26.11,41.73 26.41,41.70 26.58,41.60 26.62,41.40 26.33,41.24 26.33,40.95 26.04,40.73
|
||||||
|
20.06,42.55 19.79,42.48 19.73,42.63 19.65,42.63 19.28,42.17 19.36,42.07 19.34,41.87
|
||||||
|
26.62,48.26 26.79,48.26 26.98,48.16 27.61,47.34 28.07,46.98 28.24,46.64 28.24,46.45 28.10,45.97 28.16,45.65 28.07,45.60 28.21,45.45
|
||||||
|
22.88,47.95 23.20,48.08 23.41,47.99 24.18,47.91 24.58,47.93 24.89,47.72 24.98,47.72 25.46,47.91 26.16,47.99 26.31,48.20 26.62,48.26
|
||||||
|
22.54,49.07 22.81,49.02 22.85,49.06 22.71,49.17 22.73,49.30 22.65,49.54 22.71,49.61 23.71,50.38 23.97,50.41 24.09,50.53 23.98,50.79 24.10,50.87 23.66,51.31 23.61,51.52
|
||||||
|
20.90,55.29 21.39,55.28 22.07,55.06 22.57,55.06 22.82,54.87 22.68,54.56 22.77,54.36
|
||||||
|
31.76,52.10 31.58,52.31 31.62,52.55 31.52,52.70 31.56,52.76 31.26,53.02 31.42,53.20 31.67,53.20 31.85,53.11 32.14,53.09 32.70,53.34 32.71,53.42 32.47,53.55 32.45,53.69 32.20,53.78 31.75,53.81 31.83,54.03 31.40,54.20 31.18,54.45 31.07,54.49 31.15,54.63 30.80,54.78 30.83,54.91 30.98,55.05 30.81,55.31 30.90,55.40 30.91,55.57 30.23,55.85 29.94,55.85 29.48,55.68 29.35,55.78 29.38,55.94 29.03,56.02 28.79,55.94 28.56,56.09 28.28,56.06 28.15,56.14
|
||||||
|
31.76,52.10 32.12,52.05 32.28,52.11 32.44,52.31 32.81,52.25 33.15,52.34 33.74,52.34 33.92,52.25 34.11,51.98 34.40,51.78 34.38,51.72 34.12,51.68 34.27,51.34 34.21,51.26 34.71,51.17 35.06,51.20 35.16,51.06 35.31,51.04 35.44,50.73 35.41,50.54 35.59,50.37 35.67,50.35 35.89,50.44 36.12,50.41 36.31,50.28 36.50,50.28 36.62,50.21 36.76,50.29 37.42,50.41 37.70,50.11 38.05,49.92 38.26,50.05 38.92,49.82 39.17,49.86 39.30,49.74 39.46,49.73 39.78,49.57 40.08,49.58 40.11,49.25 39.89,49.06 39.69,49.01 39.75,48.91 40.00,48.82 39.79,48.81 39.64,48.59 39.84,48.54 39.89,48.36 39.85,48.30 39.96,48.27 39.78,47.89 39.66,47.84 38.90,47.86 38.64,47.67 38.37,47.61 38.29,47.56 38.20,47.32 38.28,47.28 38.20,47.18 38.21,47.09
|
||||||
|
43.44,41.11 43.63,40.93 43.72,40.72 43.57,40.48 43.71,40.17 43.67,40.13 44.01,40.01 44.29,40.04 44.78,39.68
|
||||||
|
87.82,49.16 88.12,49.26 88.19,49.45 88.83,49.45 88.90,49.54 89.01,49.47 89.11,49.50 89.24,49.63 89.40,49.61 89.65,49.72 89.64,49.90 89.98,49.98 90.05,50.09 90.66,50.22 91.02,50.42 91.42,50.47 91.80,50.69 92.19,50.70 92.35,50.86 92.63,50.69 92.78,50.78 92.94,50.78 93.10,50.60 94.25,50.56 94.35,50.22 94.61,50.02 94.93,50.04 95.11,49.94 95.52,49.91 95.79,50.01 95.94,49.96 96.07,50.00 96.32,49.90 96.99,49.88 97.21,49.73 97.36,49.74 97.59,49.91 98.00,50.01 98.25,50.30 98.28,50.53 98.03,50.64 97.84,51.05 98.04,51.45 98.22,51.51 98.35,51.72 98.64,51.80 98.89,52.12 99.72,51.87 99.92,51.76 100.47,51.73 101.38,51.45 102.11,51.35 102.22,50.83 102.32,50.72 102.29,50.59 102.68,50.39 103.16,50.29 103.30,50.20 103.63,50.14 104.08,50.15 104.47,50.31 105.38,50.47 106.22,50.30 106.71,50.31 107.23,49.99 107.92,49.95 107.97,49.65 108.61,49.32 109.24,49.33 110.71,49.14 111.34,49.36 112.08,49.42 112.49,49.53 112.81,49.52 113.06,49.62 113.16,49.80 113.57,50.01 114.30,50.27 114.74,50.23 115.43,49.90 115.72,49.88 116.22,50.01 116.55,49.92 116.68,49.82
|
||||||
|
116.68,49.82 117.25,49.62 117.87,49.51 118.45,49.84 119.26,50.07 119.35,50.28 119.16,50.41 119.45,50.70 120.07,51.60 120.51,51.85 120.75,52.10 120.67,52.30 120.70,52.49 120.66,52.57 120.17,52.60 120.07,52.63 120.04,52.72 120.70,53.17 120.99,53.28 122.34,53.49 123.61,53.55 124.81,53.13 125.08,53.20 125.65,53.04 125.73,52.89 125.87,52.87 126.05,52.74 126.02,52.61 126.34,52.36 126.51,51.93 126.70,51.70 126.71,51.57 126.81,51.51 126.92,51.10 127.31,50.71 127.34,50.35 127.59,50.21 127.49,49.98 127.55,49.80 127.71,49.67 128.00,49.57 128.70,49.60 128.82,49.46 129.07,49.37 129.50,49.39 130.20,48.89 130.55,48.86 130.62,48.77 130.55,48.60 130.80,48.34 130.71,48.13 130.73,48.02 130.92,47.84 130.96,47.71 132.48,47.71 132.71,47.95 133.14,48.11 133.47,48.10 133.84,48.27 134.29,48.37 134.67,48.25 134.67,48.15 134.57,48.02 134.75,47.72 134.54,47.49 134.29,47.41 134.17,47.30 134.20,47.13 133.87,46.50 133.86,46.25 133.70,46.14 133.65,45.96 133.51,45.88 133.44,45.60 133.19,45.49 133.11,45.13 132.94,45.03 131.98,45.24 131.85,45.33 131.45,44.98 130.98,44.84 131.26,44.07 131.17,43.70 131.18,43.51 131.26,43.38 131.07,42.90 130.58,42.81 130.42,42.73 130.58,42.62 130.53,42.54
|
||||||
|
130.53,42.54 130.25,42.74 130.24,42.89 129.90,43.00 129.70,42.45 129.52,42.38 129.31,42.41 129.20,42.22 128.92,42.04 128.05,41.99 128.03,41.90 128.29,41.61 128.15,41.39 128.01,41.45 127.69,41.44 127.18,41.53 126.95,41.77 126.72,41.72 126.60,41.64 126.49,41.36 125.99,40.90 125.73,40.87 125.42,40.66 124.89,40.46 124.39,40.10 124.36,40.00
|
||||||
|
130.53,42.54 130.69,42.30
|
||||||
|
70.96,40.24 70.65,40.20 70.37,40.38 70.38,40.45 70.75,40.72 70.40,41.04 70.29,40.89 69.71,40.66 69.41,40.80 69.36,40.77 69.31,40.63 69.21,40.57 69.30,40.33 69.22,40.29 69.27,40.20 68.95,40.22 68.63,40.17 68.97,40.09 68.80,40.05 68.87,39.91 68.64,39.84 68.59,39.63 68.46,39.54 67.72,39.62 67.43,39.47 67.36,39.22 67.65,39.13 67.69,38.99 68.04,38.98 68.13,38.93 68.05,38.67 68.09,38.47 68.35,38.21 68.29,38.03 67.81,37.49 67.76,37.17
|
||||||
|
35.89,35.92 36.15,35.83 36.20,35.94 36.35,36.00 36.38,36.17 36.64,36.23 36.54,36.46 36.66,36.80 36.94,36.76 37.07,36.65 37.44,36.64 38.19,36.90 38.44,36.86 38.77,36.69 39.36,36.68 40.02,36.83 40.71,37.10 41.52,37.09 42.06,37.21 42.20,37.30 42.31,37.23 42.36,37.11
|
||||||
|
35.45,31.48 35.40,31.23 35.44,31.13 35.14,30.42 35.14,30.14 34.97,29.56
|
||||||
|
35.55,32.40 35.56,31.77 35.45,31.48
|
||||||
|
35.61,32.68 35.55,32.40
|
||||||
|
88.11,27.87 87.14,27.84 86.61,28.10 86.52,27.96 86.41,27.93 86.14,28.11 86.08,28.08 86.06,27.93 85.99,27.91 85.68,28.28 85.12,28.32 85.09,28.37 85.16,28.59 84.80,28.56 84.23,28.91 84.10,29.22 83.94,29.28 83.58,29.18 83.16,29.61 82.85,29.68 82.22,30.06 82.04,30.33 81.64,30.39 81.42,30.34 81.18,30.04 81.11,30.04 81.01,30.16
|
||||||
|
97.32,28.22 97.34,27.94 96.88,27.59 96.90,27.44 97.10,27.12 96.95,27.13 96.73,27.33 96.19,27.26 95.46,26.76 95.13,26.60 95.05,26.35 95.13,26.04 95.02,25.91 94.95,25.70 94.58,25.32 94.55,25.22 94.71,25.05 94.29,24.32 94.13,23.88 93.68,24.01 93.49,23.97 93.33,24.06 93.41,23.68 93.39,23.34 93.35,23.08 93.31,23.03 93.16,23.03 93.08,22.72 93.15,22.23 93.04,22.18 92.96,22.00 92.85,22.01 92.69,22.13 92.57,21.98
|
||||||
|
100.12,20.32 99.89,20.42 99.72,20.33 99.46,20.36 99.49,20.15 99.34,20.08 99.07,20.10 98.92,19.77 98.37,19.69 98.02,19.75 97.82,19.46 97.80,19.13 97.71,18.93 97.75,18.59 97.52,18.50 97.37,18.52 97.45,18.36 97.63,18.29 97.74,17.94 97.71,17.80 98.44,16.98 98.48,16.73 98.66,16.33 98.84,16.42 98.89,16.35 98.82,16.18 98.59,16.05 98.56,15.37 98.19,15.20 98.20,14.98 98.25,14.81 98.57,14.36 98.93,14.05 99.14,13.72 99.18,13.23 99.12,13.03 99.22,12.74 99.41,12.55 99.61,11.78 99.19,11.11 98.76,10.66 98.70,10.19
|
||||||
|
103.09,18.14 103.09,18.14
|
||||||
|
103.09,18.14 103.26,18.28 103.29,18.41 103.95,18.32 104.43,17.70 104.74,17.46 104.82,17.30 104.74,16.88 104.82,16.47 105.05,16.16 105.41,15.99 105.40,15.83 105.64,15.66 105.49,15.26 105.55,14.93 105.50,14.59 105.42,14.47 105.18,14.35
|
||||||
|
100.12,20.32 100.14,20.25 100.32,20.39 100.52,20.18 100.54,20.09 100.40,19.76 100.51,19.55 100.74,19.51 100.97,19.61 101.21,19.55 101.20,19.33 101.29,18.98 101.05,18.44 101.14,18.14 100.91,17.58 100.96,17.54 101.17,17.50 101.69,17.89 101.77,18.03 101.95,18.08 102.10,18.21 102.55,17.97 102.66,17.82 102.81,17.95 103.05,18.03 103.09,18.14
|
||||||
|
101.14,21.57 101.08,21.47 100.70,21.25 100.54,20.99 100.52,20.92 100.62,20.86 100.25,20.73 100.12,20.32
|
||||||
|
97.32,28.22 97.54,28.51 97.60,28.52 98.06,28.19 98.30,27.55 98.45,27.66 98.65,27.57 98.74,26.79 98.69,26.19 98.56,26.07 98.66,25.86 98.47,25.79 98.33,25.59 98.14,25.57 98.01,25.29 97.82,25.25 97.71,25.03 97.74,24.87 97.58,24.77 97.53,24.49 97.67,24.38 97.71,24.23 97.56,23.91 97.69,23.90 98.21,24.11 98.58,24.07 98.84,24.12 98.68,23.91 98.83,23.62 98.80,23.52 98.88,23.38 98.86,23.19 99.42,23.07 99.51,22.96 99.39,22.83 99.34,22.50 99.21,22.28 99.19,22.13 99.92,22.03 99.94,21.76 100.10,21.66 100.15,21.48 100.60,21.47 101.08,21.76 101.13,21.74 101.14,21.57
|
||||||
|
102.13,22.38 102.58,21.90 102.66,21.68 102.74,21.68 102.82,21.81 102.95,21.68 102.85,21.27 103.10,20.89 103.64,20.70 104.10,20.95 104.58,20.65 104.37,20.44 104.62,20.37 104.70,20.21 104.85,20.20 104.93,20.02 104.59,19.62 104.03,19.68 104.06,19.48 103.89,19.30 104.72,18.80 105.15,18.65 105.11,18.41 105.33,18.19 105.46,18.15 105.69,17.74 106.50,16.95 106.55,16.65 106.66,16.49 106.74,16.45 106.85,16.52 106.93,16.35 107.40,16.04 107.39,15.95 107.17,15.80 107.65,15.26 107.48,14.98 107.52,14.71
|
||||||
|
27.40,5.11 27.07,5.20 26.82,5.06 25.53,5.31 25.40,5.26 25.25,5.02 25.07,4.97 24.77,4.93 24.32,4.99 23.42,4.66 23.12,4.74 22.86,4.72 22.71,4.59 22.42,4.13 21.69,4.28 21.54,4.24 20.56,4.46 20.23,4.83 19.81,5.09 19.50,5.13 19.07,4.89 18.83,4.52 18.59,4.35 18.61,3.48
|
||||||
|
30.55,-2.40 30.42,-2.64 30.47,-2.69 30.43,-2.87 30.78,-2.98 30.79,-3.27 30.63,-3.35 30.63,-3.42 30.43,-3.59 30.15,-4.09 29.95,-4.31 29.72,-4.46 29.40,-4.45
|
||||||
|
29.01,-2.72 29.02,-2.80 29.22,-3.05 29.21,-3.83 29.40,-4.45
|
||||||
|
29.58,-1.39 29.35,-1.52 29.20,-1.72 29.13,-1.86 29.13,-2.20 28.88,-2.40 28.89,-2.64 29.01,-2.72
|
||||||
|
35.27,5.49 34.98,5.86 34.71,6.66 34.06,7.23 33.90,7.51 33.67,7.67 33.23,7.76 33.00,7.90 33.28,8.44 33.95,8.44 34.07,8.55 34.08,9.46
|
||||||
|
22.86,10.92 22.49,11.00 22.04,10.82 21.77,10.64 21.71,10.57 21.68,10.29 21.53,10.21 21.40,10.00 21.26,9.97 20.77,9.41 20.34,9.13 20.07,9.13 19.67,9.02 19.15,9.02 18.96,8.94 18.89,8.84 19.11,8.66 18.56,8.05 17.65,7.98 16.78,7.55 16.55,7.87 16.40,7.77 16.38,7.68 15.96,7.51 15.48,7.52
|
||||||
|
13.29,2.16 13.22,2.26 11.56,2.30 11.35,2.30 11.33,2.17
|
||||||
|
11.13,-3.92 11.23,-3.69 11.50,-3.52 11.69,-3.68 11.88,-3.67 11.83,-3.53 11.93,-3.32 11.72,-3.18 11.76,-2.98 11.64,-2.86 11.54,-2.84 11.61,-2.34 11.73,-2.39 11.95,-2.34 12.06,-2.41 12.45,-2.33 12.43,-1.93 12.59,-1.83 12.79,-1.93 12.99,-2.31 13.46,-2.40 13.73,-2.14 13.88,-2.33 13.89,-2.47 13.99,-2.49 14.20,-2.35 14.16,-2.22 14.25,-2.00 14.38,-1.89 14.47,-0.57 14.21,-0.43 14.07,-0.27 13.86,-0.20 13.95,0.35 14.09,0.54 14.32,0.62 14.43,0.90 14.18,1.37 13.85,1.42 13.22,1.25 13.25,1.37 13.17,1.79 13.29,2.16
|
||||||
|
15.48,7.52 15.55,7.79 15.44,7.85 15.12,8.56 14.33,9.20 13.98,9.69 14.24,9.98 14.84,9.94 15.65,10.01 15.28,10.36 15.13,10.65 15.03,11.11 15.12,11.54 15.08,11.85 14.88,12.27 14.85,12.50 14.76,12.66 14.54,12.82 14.46,13.02 14.06,13.08
|
||||||
|
14.06,13.08 14.20,12.38 14.52,12.30 14.62,12.15 14.58,11.53 14.20,11.27 13.98,11.21 13.70,10.87 13.54,10.61 13.41,10.17 13.27,10.04 13.20,9.56 12.93,9.43 12.78,8.82 12.58,8.62 12.40,8.60 12.23,8.28 12.02,7.59 11.77,7.27 11.86,7.12 11.58,6.89 11.48,6.60 11.24,6.45 11.11,6.46 11.01,6.74 10.61,7.06 10.48,6.89 10.21,6.89 10.14,7.00 9.78,6.76 9.66,6.53 9.00,5.92 8.80,5.20 8.56,4.76
|
||||||
|
3.60,11.70 3.66,11.76 3.61,11.89 3.65,12.53 3.95,12.78 4.04,12.93 4.15,13.46 4.66,13.73 5.24,13.76 5.49,13.87 6.30,13.66 6.59,13.41 6.87,13.04 7.01,13.00 7.83,13.34 8.10,13.29 8.75,12.91 9.62,12.81 10.05,13.21 10.48,13.33 11.41,13.35 11.99,13.19 12.12,13.09 12.46,13.09 12.65,13.33 12.87,13.45 13.32,13.67 13.61,13.70
|
||||||
|
1.19,6.09 0.98,6.32 0.74,6.45 0.53,6.85 0.63,7.35 0.51,7.44 0.50,7.55 0.61,7.73 0.58,8.15 0.69,8.35 0.37,8.76 0.49,8.85 0.47,9.12 0.53,9.40 0.41,9.49 0.26,9.43 0.23,9.46 0.34,9.60 0.26,9.64 0.32,9.69 0.38,10.29 -0.09,10.67 0.01,11.02 -0.07,11.12
|
||||||
|
-2.70,9.48 -2.67,9.28 -2.75,9.05 -2.60,8.80 -2.51,8.21 -2.61,8.15 -2.61,8.05 -2.79,7.93 -2.99,7.20 -3.24,6.81 -3.24,6.65 -3.00,5.71 -2.96,5.64 -2.79,5.60 -2.76,5.36 -2.82,5.15 -3.11,5.09
|
||||||
|
-5.52,10.43 -5.46,10.77 -5.49,11.04 -5.35,11.13 -5.25,11.38 -5.29,11.83 -5.11,11.97 -4.70,12.08 -4.43,12.34 -4.42,12.58 -4.48,12.67 -4.23,12.79 -4.33,13.12 -4.15,13.31 -3.95,13.40 -3.53,13.18 -3.30,13.28 -3.25,13.66 -2.95,13.65 -2.87,13.95 -2.59,14.23 -2.46,14.27 -2.11,14.17 -1.97,14.46 -1.70,14.51 -1.05,14.82 -0.76,15.05 -0.54,15.08 -0.41,15.01 -0.24,15.06 0.22,14.91
|
||||||
|
-2.70,9.48 -2.82,9.43 -2.99,9.69 -3.22,9.90 -3.79,9.92 -4.27,9.74 -4.33,9.65 -4.63,9.71 -4.97,9.93 -5.10,10.24 -5.38,10.31 -5.52,10.43
|
||||||
|
-8.49,7.56 -8.30,6.98 -8.33,6.80 -8.60,6.51 -8.29,6.32 -7.89,6.23 -7.80,5.98 -7.45,5.84 -7.40,5.55 -7.43,5.32 -7.57,5.08 -7.59,4.82 -7.54,4.35
|
||||||
|
-8.49,7.56 -8.66,7.69 -8.89,7.26 -9.12,7.22 -9.26,7.38 -9.46,7.42 -9.37,7.70 -9.52,8.35 -9.68,8.48 -9.74,8.45 -9.78,8.54 -10.06,8.43 -10.15,8.52 -10.28,8.49
|
||||||
|
-5.52,10.43 -5.84,10.39 -6.03,10.19 -6.20,10.23 -6.26,10.72 -6.43,10.67 -6.42,10.56 -6.65,10.66 -6.69,10.35 -6.95,10.34 -7.02,10.14 -7.36,10.26 -7.50,10.44 -7.66,10.43 -7.81,10.24 -7.99,10.16
|
||||||
|
-15.04,10.94 -14.68,11.51 -14.27,11.66 -13.73,11.74 -13.74,12.01 -13.95,12.18 -13.71,12.31 -13.67,12.48 -13.73,12.67
|
||||||
|
-11.39,12.40 -11.50,12.20 -11.31,12.02 -10.93,12.21 -10.71,11.90 -10.47,12.14 -10.27,12.21 -9.75,12.03 -9.66,12.14 -9.36,12.26 -9.40,12.46 -9.22,12.48 -9.04,12.40 -8.82,11.92 -8.82,11.67 -8.40,11.37 -8.67,11.01 -8.47,11.05 -8.34,10.99 -8.27,10.49 -8.01,10.32 -7.99,10.16
|
||||||
|
107.52,14.71 107.41,14.56 107.29,14.59 106.94,14.33 106.78,14.34 106.50,14.58 106.35,14.45 106.23,14.48 106.17,14.37 105.98,14.34 106.12,14.05 106.07,13.92 105.90,13.92 105.74,14.08 105.53,14.16 105.35,14.11 105.18,14.35
|
||||||
|
114.06,4.60 114.22,4.48 114.32,4.26 114.42,4.26 114.57,4.05 114.65,4.04 114.78,4.17 114.84,4.39 114.75,4.72 115.03,4.90
|
||||||
|
-88.23,15.73 -88.96,15.15 -89.14,15.07 -89.22,14.87 -89.17,14.61 -89.36,14.42
|
||||||
|
-77.37,8.66 -77.48,8.50 -77.20,7.97 -77.35,7.84 -77.35,7.71 -77.54,7.57 -77.59,7.54 -77.76,7.70 -77.74,7.54 -77.90,7.23
|
||||||
|
-60.74,5.20 -61.39,5.94 -61.13,6.21 -61.20,6.59 -61.15,6.69 -60.87,6.79 -60.72,6.77 -60.35,7.00 -60.35,7.15 -60.58,7.16 -60.63,7.21 -60.61,7.32 -60.72,7.54 -60.51,7.81 -60.38,7.83 -60.03,8.05 -59.96,8.19 -59.85,8.25 -59.83,8.31 -60.02,8.55
|
||||||
|
-60.74,5.20 -60.67,5.16 -60.60,4.95 -61.00,4.54 -61.28,4.52 -61.55,4.29 -61.82,4.20 -62.15,4.10 -62.41,4.16 -62.71,4.02 -62.76,3.67 -62.86,3.59 -62.97,3.59 -63.34,3.94 -63.53,3.89 -64.02,3.93 -64.19,4.13 -64.58,4.14 -64.79,4.28 -64.82,4.23 -64.67,4.01 -64.22,3.59 -64.22,3.20 -64.01,2.67 -64.05,2.50 -63.39,2.41 -63.43,2.16 -64.01,1.93 -64.11,1.62 -64.21,1.53 -64.30,1.46 -64.49,1.45 -64.73,1.25 -65.10,1.11 -65.47,0.69 -65.56,0.69 -65.52,0.84 -65.68,0.98 -66.06,0.79 -66.35,0.77 -66.88,1.22
|
||||||
|
52.49,41.78 53.06,42.15 54.12,42.34 54.85,41.97 55.43,41.30 55.98,41.32
|
||||||
|
55.98,41.32 57.02,41.26 57.12,41.35 57.02,41.45 56.96,41.86 57.29,42.12 57.81,42.19 58.03,42.49 58.28,42.43 58.40,42.29 58.47,42.30 58.15,42.63 58.26,42.69 58.48,42.66 58.59,42.78 58.88,42.56 59.16,42.51 59.35,42.32 59.86,42.30 59.99,42.21 59.94,41.97 60.20,41.80 60.08,41.76 60.14,41.59 60.09,41.40 60.45,41.22 60.87,41.25 61.24,41.19 61.50,41.28 61.90,41.09 62.10,40.68 62.38,40.33 62.48,39.98 63.51,39.38 63.76,39.16 64.16,38.95 64.31,38.98 65.61,38.24 65.97,38.24 66.57,38.01 66.63,37.93 66.53,37.79 66.52,37.35
|
||||||
|
49.23,46.34 48.54,46.61 48.50,46.70 48.56,46.76 48.88,46.71 48.96,46.77 48.55,47.32 48.17,47.71 47.48,47.80 47.29,47.74 47.09,47.95 47.12,48.13 47.06,48.23 46.66,48.41 46.61,48.57 46.70,48.81 47.03,49.15 46.80,49.37 46.89,49.70 46.99,49.85 47.25,50.00 47.33,50.27 47.50,50.40 47.71,50.38 48.33,49.86 48.43,49.83 48.76,49.93 48.84,50.01 48.63,50.61 48.81,50.60 49.32,50.85 49.50,51.08 49.82,51.13 50.31,51.32 50.79,51.73 51.16,51.65 51.34,51.48 51.61,51.48 52.22,51.71 52.57,51.48 53.34,51.48 54.14,51.04 54.42,50.78 54.47,50.58 54.56,50.54 54.65,50.66 54.55,50.95 54.64,51.01 55.36,50.67 55.69,50.58 56.05,50.71 56.14,50.84 56.49,51.02 56.62,50.98 57.01,51.07 57.44,50.89 57.65,50.93 57.84,51.09 58.36,51.06 58.88,50.69 59.45,50.62 59.52,50.58 59.52,50.49 59.81,50.58 59.96,50.80 60.06,50.85 60.42,50.68 60.94,50.70 61.39,50.86 61.59,51.23 61.55,51.32 61.36,51.44 60.46,51.65 60.39,51.77 60.03,51.93 60.43,52.13 60.67,52.15 60.99,52.34 60.77,52.68 61.05,52.97 61.97,52.94 62.08,53.01 62.01,53.11 61.66,53.23 61.20,53.29 61.16,53.34 61.23,53.45 61.53,53.52 61.47,53.58 61.25,53.55 60.98,53.62 61.11,53.75 61.14,53.96 61.23,54.02 61.33,54.05 61.93,53.95 63.85,54.24 64.46,54.38 64.93,54.40 65.09,54.34 65.24,54.52 65.48,54.62 65.71,54.62 65.91,54.69 66.22,54.67 68.16,54.98 68.24,55.05 68.21,55.16 68.52,55.20 68.98,55.39 69.49,55.36 70.18,55.16 70.49,55.28 70.74,55.31 70.91,55.13 71.19,54.60 71.15,54.36 71.05,54.26 71.09,54.21 71.34,54.16 72.00,54.21 72.19,54.33 72.39,54.12 72.45,53.94 72.59,54.00 72.56,54.09 72.62,54.13 72.91,54.11 73.23,53.96 73.62,54.07 73.71,54.04 73.68,53.93 73.40,53.81 73.31,53.71 73.29,53.60 73.41,53.45 73.86,53.62 74.21,53.58 74.35,53.49 74.45,53.65 74.83,53.83 75.22,53.89 75.38,53.97 75.44,54.09 76.84,54.44 76.65,54.15 76.42,54.15 76.48,54.02 77.86,53.27 79.47,51.49 79.99,50.77 80.07,50.76 80.09,50.84 80.22,50.91 80.42,50.95 80.45,51.18 80.74,51.29 80.88,51.28 80.97,51.19 81.13,51.19 81.07,50.97 81.39,50.96 81.47,50.74 82.49,50.73 82.76,50.89 83.36,50.99 83.95,50.77 84.18,50.52 84.32,50.24 84.99,50.06 85.00,49.89 85.23,49.62 86.18,49.50 86.68,49.78 86.73,49.70 86.63,49.56 86.81,49.49 87.00,49.29 87.23,49.22 87.32,49.09
|
||||||
|
34.20,31.32 34.90,29.48
|
||||||
|
33.98,4.22 34.38,4.62
|
||||||
|
41.88,3.98 41.14,3.96 40.77,4.27 39.84,3.85 39.54,3.47 39.23,3.48 38.09,3.65 36.91,4.41 36.08,4.45 35.92,4.62
|
||||||
|
23.38,-17.64 21.42,-18.00 21.11,-17.96 20.75,-18.02 20.39,-17.89 18.96,-17.80 18.72,-17.70 18.40,-17.40 14.02,-17.41 13.48,-17.04 13.10,-16.97 12.55,-17.21 12.01,-17.17 11.74,-17.25
|
||||||
|
25.26,-17.79 25.64,-17.82 26.00,-17.97 26.14,-17.91 26.33,-17.93 26.78,-18.04 27.02,-17.96 27.93,-16.90 28.76,-16.53 28.83,-16.42 28.91,-15.99 29.49,-15.70 30.40,-15.64
|
||||||
|
23.38,-17.64 24.27,-17.48 24.73,-17.52 25.00,-17.57 25.26,-17.79
|
||||||
|
31.29,-22.40 32.43,-21.30 32.35,-21.14 32.48,-20.95 32.49,-20.66 32.67,-20.52 32.99,-19.98 33.01,-19.87 32.78,-19.39 32.85,-19.10 32.70,-18.94 32.72,-18.83 32.88,-18.73 32.99,-18.36 32.97,-17.25 32.88,-16.88 32.95,-16.71 32.24,-16.45 31.94,-16.43 31.69,-16.21 31.49,-16.18 31.24,-16.02 30.44,-16.00 30.40,-15.64
|
||||||
|
12.28,-6.00 12.42,-6.05 13.18,-5.86 16.43,-5.90 16.70,-6.16 16.74,-6.62 16.92,-6.93 16.98,-7.26 17.58,-8.10 18.01,-8.11 18.56,-7.94 18.94,-8.00 19.34,-7.97 19.53,-7.14 19.66,-7.04 19.88,-6.99 20.59,-6.92 20.54,-7.18 20.61,-7.28 21.78,-7.31 21.84,-7.42 21.80,-8.11 21.90,-8.34 21.91,-8.69 21.81,-9.47 22.20,-10.04 22.27,-10.26 22.31,-10.69 22.18,-10.89 22.23,-11.12 22.31,-11.20 22.56,-11.06 23.08,-11.09 23.46,-10.97 23.83,-11.01 23.97,-10.87
|
||||||
|
-16.56,13.59 -15.51,13.59 -15.43,13.73 -15.11,13.81 -14.94,13.79 -14.41,13.50 -13.98,13.54 -13.83,13.41 -13.85,13.34 -14.25,13.24 -15.15,13.56 -15.29,13.40 -15.81,13.33 -15.83,13.16 -16.65,13.15 -16.76,13.06
|
||||||
|
0.22,14.91 0.16,14.50 0.38,14.25 0.35,14.14 0.43,13.97 0.62,13.70 0.90,13.61 1.20,13.36 0.99,13.36 0.99,13.04 1.56,12.64 1.79,12.61 1.96,12.71 2.10,12.70 2.23,12.47 2.07,12.38 2.07,12.31 2.39,11.90
|
||||||
|
18.61,3.48 18.47,3.62 18.16,3.50 17.49,3.69 16.61,3.51 16.48,3.17 16.47,2.83 16.18,2.27
|
||||||
|
16.18,2.26 16.08,2.11 16.14,1.72 16.06,1.68 15.74,1.92 15.34,1.94 15.16,2.04 14.90,2.01 14.88,2.08 14.76,2.08 14.58,2.20 14.48,2.15 13.29,2.16
|
||||||
|
15.48,7.52 15.21,7.21 14.74,6.28 14.51,6.16 14.43,6.04 14.62,5.87 14.56,5.28 14.64,5.18 14.73,4.60 15.06,4.28 15.14,4.04 15.03,4.02 15.13,3.83 15.77,3.13 15.90,3.10 16.06,2.91 16.18,2.26
|
||||||
|
33.98,4.22 33.49,3.76 33.15,3.77 33.00,3.88 32.34,3.71 32.14,3.52 31.94,3.61 31.80,3.80 31.55,3.68 31.15,3.79 30.93,3.63 30.84,3.49
|
||||||
|
13.07,-4.63 12.83,-4.74 12.45,-5.07 12.52,-5.15 12.50,-5.70 12.21,-5.76
|
||||||
|
31.95,-25.96 31.99,-24.46 31.80,-23.89 31.55,-23.48 31.29,-22.40
|
||||||
|
33.20,-14.01 30.23,-14.99 30.40,-15.64
|
||||||
|
34.96,-11.58 34.62,-11.62 34.36,-12.16 34.56,-13.36 34.66,-13.49 34.91,-13.55 35.25,-13.90 35.85,-14.67 35.89,-14.89 35.81,-15.27 35.82,-15.68 35.76,-16.06 35.36,-16.16 35.17,-16.56 35.28,-16.81 35.27,-17.12 35.06,-17.08 35.08,-16.83 34.93,-16.76 34.42,-16.25 34.40,-16.08 34.25,-15.89 34.54,-15.30 34.51,-14.60 34.33,-14.41 33.64,-14.57 33.20,-14.01
|
||||||
|
29.58,-1.39 29.56,-0.98 29.72,0.10 29.93,0.50 29.94,0.82 30.18,0.97 30.32,1.19 30.48,1.24 31.25,2.04 31.27,2.15 31.18,2.27 30.96,2.40 30.73,2.46 30.85,2.89 30.75,3.04 30.91,3.41 30.84,3.49
|
||||||
|
36.52,14.26 36.43,15.13 36.52,15.25 36.91,16.30 36.89,16.62 36.98,16.80 37.01,17.06 37.41,17.06 37.55,17.32 37.78,17.46 38.25,17.58 38.61,18.01
|
||||||
|
42.38,12.47 42.13,12.77 41.95,12.88 41.77,13.18 41.36,13.50 40.82,14.11 40.14,14.46 39.90,14.44 39.53,14.54 39.20,14.48 39.02,14.63 38.81,14.48 38.43,14.43 38.22,14.65 37.88,14.85 37.57,14.15 37.26,14.45 37.02,14.27 36.81,14.32 36.52,14.26
|
||||||
|
27.35,57.53 27.40,57.67 27.54,57.80 27.78,57.87 27.67,57.93 27.50,58.22 27.53,58.44 27.43,58.79 27.76,59.05 27.90,59.28 28.15,59.37 28.01,59.48
|
||||||
|
74.31,32.81 74.66,32.76 74.69,32.49 74.99,32.46 75.30,32.32
|
||||||
|
-84.35,10.98 -84.70,11.05 -84.91,10.95 -85.58,11.19 -85.74,11.06
|
||||||
|
-82.88,8.07 -83.03,8.34 -82.86,8.45 -82.86,8.64 -82.92,8.74 -82.73,8.92 -82.94,9.06 -82.94,9.45 -82.80,9.59 -82.64,9.51 -82.56,9.58
|
||||||
|
-66.88,1.22 -67.21,2.39 -67.62,2.79 -67.86,2.79 -67.83,2.89 -67.31,3.42 -67.66,3.86 -67.86,4.51 -67.82,5.27 -67.64,5.56 -67.58,5.83 -67.47,5.93 -67.48,6.18 -67.86,6.29 -68.47,6.16 -68.94,6.20 -69.27,6.10 -69.43,6.12 -70.13,6.95 -70.74,7.09 -71.13,6.99 -72.01,7.03 -72.21,7.37 -72.39,7.42 -72.47,7.52 -72.45,7.97 -72.36,8.09 -72.39,8.29 -72.67,8.63 -72.80,9.11 -72.96,9.14 -73.06,9.26 -73.37,9.19 -73.01,9.79 -72.87,10.49 -72.69,10.84 -72.45,11.11 -72.25,11.20 -71.96,11.67 -71.32,11.86
|
||||||
|
-68.44,-52.36 -68.46,-52.29 -69.96,-52.01 -71.97,-51.96 -71.95,-51.88 -72.41,-51.54 -72.30,-51.30 -72.38,-51.10 -72.28,-50.91 -72.34,-50.68 -72.51,-50.61 -73.15,-50.74 -73.31,-50.36 -73.50,-50.13 -73.48,-49.77
|
||||||
|
-68.63,-52.65 -68.65,-54.92 -68.06,-54.88 -67.35,-54.93 -66.48,-55.11
|
||||||
|
22.77,54.36 19.60,54.46
|
||||||
|
48.44,28.54 47.67,28.53 47.43,28.99 46.53,29.10
|
||||||
|
44.77,37.14 44.88,36.80 45.02,36.70 45.05,36.47 45.24,36.36 45.36,36.02 45.56,35.98 45.78,35.82 46.27,35.77 46.00,35.61 45.98,35.48 46.11,35.32 46.13,35.13 45.92,35.03 45.68,34.80 45.64,34.57 45.50,34.58 45.44,34.42 45.54,34.22 45.40,33.97 45.74,33.60 45.88,33.61 45.87,33.49 46.02,33.42 46.15,33.23 46.08,33.09 46.11,32.96 46.38,32.93 47.12,32.47 47.37,32.42 47.51,32.15 47.83,31.79 47.68,31.40 47.68,31.00 48.01,30.99 48.01,30.47 48.33,30.29 48.43,30.04 48.55,29.96
|
||||||
|
61.26,35.62 61.25,35.87 61.15,35.98 61.21,36.19 61.12,36.64 60.34,36.64 60.06,36.96 59.45,37.25 59.30,37.51 58.82,37.68 58.26,37.67 57.98,37.83 57.35,37.97 57.31,38.13 57.19,38.22 56.44,38.25 56.32,38.19 56.27,38.08 55.58,38.10 55.38,38.05 54.90,37.78 54.70,37.47 54.19,37.33 53.91,37.34
|
||||||
|
140.97,-2.60 140.97,-6.35 140.86,-6.74 140.98,-6.91 140.98,-9.12
|
||||||
|
9.52,30.23 9.31,30.12 9.64,29.64 9.81,29.18 9.92,27.79 9.75,27.33 9.89,26.85 9.88,26.63 9.86,26.55 9.49,26.33 9.45,26.07 10.00,25.33 10.03,25.05 10.26,24.59 10.40,24.49 10.69,24.55 11.51,24.31 11.97,23.52
|
||||||
|
-13.73,12.67 -15.20,12.68 -15.57,12.49 -16.71,12.35
|
||||||
|
-11.39,12.40 -12.40,12.34 -12.93,12.53 -13.06,12.49 -13.08,12.63 -13.73,12.67
|
||||||
|
0.90,10.99 0.49,10.95 -0.07,11.12
|
||||||
|
-139.06,60.00 -139.19,60.08 -139.08,60.34 -139.68,60.33 -139.97,60.18 -140.45,60.30 -140.53,60.22 -141.00,60.30 -141.00,69.65
|
||||||
|
-130.62,54.71 -130.21,55.03 -130.02,55.28 -130.12,55.69 -130.03,55.89 -130.06,56.07 -130.41,56.12 -130.48,56.23 -130.74,56.34 -131.58,56.60 -131.82,56.59 -131.87,56.79 -132.10,56.86 -132.03,57.03 -132.34,57.08 -132.23,57.20 -133.42,58.34 -133.40,58.41 -133.82,58.71 -134.30,58.90 -134.44,59.09 -134.94,59.29 -135.07,59.44 -135.05,59.58 -135.48,59.79 -136.32,59.60 -136.25,59.53 -136.35,59.46 -136.47,59.46 -136.47,59.28 -136.58,59.15 -136.81,59.15 -137.44,58.90 -137.52,58.92 -137.48,58.99 -137.59,59.23 -138.63,59.78 -138.71,59.90 -139.06,60.00
|
||||||
|
-123.31,48.99 -122.69,48.99
|
||||||
|
-88.89,15.89 -89.24,15.89 -89.16,17.81
|
||||||
|
-75.28,-0.11 -75.48,-0.16 -75.63,-0.12 -75.26,-0.59 -75.25,-0.95 -75.41,-0.92 -75.57,-1.53 -76.09,-2.13 -76.68,-2.56 -77.86,-2.98 -78.18,-3.35 -78.16,-3.47 -78.23,-3.49 -78.35,-3.40 -78.42,-3.78 -78.68,-4.33 -78.69,-4.56 -78.91,-4.71 -78.92,-4.86 -79.03,-4.97 -79.33,-4.93 -79.50,-4.67 -79.52,-4.54 -79.64,-4.45 -79.80,-4.48 -80.14,-4.30 -80.38,-4.46 -80.48,-4.43 -80.35,-4.21 -80.49,-4.17 -80.49,-4.01 -80.30,-4.01 -80.18,-3.88 -80.32,-3.39
|
||||||
|
-69.97,-4.24 -70.34,-3.81 -70.53,-3.87 -70.74,-3.78 -70.07,-2.75 -70.10,-2.66 -70.97,-2.21 -71.40,-2.33 -71.75,-2.15 -71.98,-2.33 -72.22,-2.40 -72.40,-2.43 -72.63,-2.35 -72.94,-2.39 -73.15,-2.28 -73.13,-2.08 -73.20,-1.83 -73.50,-1.69 -73.52,-1.45 -73.66,-1.25 -74.25,-0.97 -74.42,-0.58 -74.80,-0.20 -74.95,-0.19 -75.14,-0.05 -75.28,-0.11
|
||||||
|
-66.88,1.22 -67.08,1.19 -67.12,1.70 -67.40,2.12 -67.56,2.07 -67.82,1.79 -67.94,1.75 -68.19,1.99 -68.26,1.85 -68.18,1.72 -69.32,1.72 -69.54,1.77 -69.85,1.71 -69.85,1.06 -69.31,1.05 -69.16,0.86 -69.15,0.66 -69.28,0.63 -69.47,0.73 -70.05,0.58 -70.07,-0.14 -69.92,-0.32 -69.63,-0.51 -69.61,-0.76 -69.45,-1.00 -69.40,-1.19 -69.97,-4.24
|
||||||
|
-69.58,-10.95 -68.69,-12.50 -68.76,-12.69 -68.98,-12.88 -68.98,-13.50 -69.07,-13.68 -68.87,-14.17 -69.00,-14.27 -69.01,-14.38 -69.36,-14.80 -69.37,-14.96 -69.17,-15.24 -69.42,-15.64 -69.22,-16.15 -68.84,-16.34 -69.03,-16.48 -69.02,-16.64 -69.20,-16.77 -69.44,-17.09 -69.62,-17.20 -69.51,-17.50
|
||||||
|
-69.58,-10.95 -68.85,-11.01 -68.73,-11.12 -68.62,-11.11 -68.31,-10.98 -68.07,-10.70 -67.72,-10.68 -67.42,-10.39 -67.11,-10.27 -66.58,-9.90 -65.92,-9.79 -65.56,-9.80 -65.40,-9.71 -65.31,-9.87 -65.30,-10.15 -65.45,-10.51 -65.32,-11.02 -65.39,-11.25 -65.18,-11.65 -65.19,-11.75 -65.09,-11.74 -64.99,-11.98 -64.51,-12.25 -64.42,-12.44 -63.94,-12.53 -63.69,-12.48 -63.35,-12.68 -63.07,-12.67 -63.02,-12.81 -62.77,-13.00 -62.12,-13.16 -61.79,-13.53 -61.08,-13.49 -60.51,-13.79 -60.40,-14.02 -60.47,-14.18 -60.30,-14.62 -60.27,-15.09 -60.58,-15.10 -60.24,-15.48 -60.18,-16.27 -58.54,-16.33 -58.35,-16.28 -58.35,-16.49 -58.48,-16.70 -58.40,-17.23 -57.99,-17.51 -57.83,-17.51 -57.59,-18.12 -57.50,-18.21 -57.57,-18.28 -57.78,-18.91 -57.73,-18.92 -57.72,-19.04 -57.80,-19.08 -58.13,-19.74 -57.86,-19.98 -58.16,-20.16
|
||||||
|
-69.97,-4.24 -70.05,-4.33 -70.24,-4.30 -70.40,-4.15 -70.80,-4.17 -70.97,-4.35 -71.84,-4.50 -72.35,-4.79 -72.70,-5.07 -72.89,-5.12 -72.98,-5.63 -73.24,-6.10 -73.14,-6.47 -73.76,-6.91 -73.80,-7.08 -73.72,-7.31 -73.96,-7.38 -74.00,-7.56 -73.72,-7.78 -73.78,-7.94 -73.64,-8.07 -73.55,-8.35 -73.36,-8.48 -73.30,-8.65 -72.97,-8.99 -72.97,-9.12 -73.21,-9.41 -72.81,-9.41 -72.38,-9.51 -72.17,-9.84 -72.14,-10.01 -71.24,-9.97 -70.54,-9.44 -70.64,-9.82 -70.64,-11.01 -70.53,-10.95 -70.29,-11.06 -69.96,-10.93 -69.58,-10.95
|
||||||
|
-62.65,-22.23 -62.28,-21.07 -62.28,-20.56 -61.92,-20.06 -61.76,-19.65 -60.01,-19.30 -59.09,-19.29 -58.18,-19.82 -58.16,-20.16
|
||||||
|
-17.05,20.81 -16.96,21.33 -13.02,21.33 -13.15,22.82 -13.03,23.00 -12.62,23.27 -12.02,23.47 -12.02,26.00 -8.68,26.00 -8.68,27.29
|
||||||
|
-8.68,27.29 -4.82,25.00
|
||||||
|
-8.68,27.66 -8.66,28.72 -7.69,29.35 -7.49,29.39 -7.16,29.61 -6.64,29.57 -6.52,29.66 -6.48,29.82 -6.00,29.83 -5.45,29.96 -5.18,30.17 -4.97,30.47 -4.32,30.70 -3.99,30.91 -3.67,30.96 -3.62,31.07 -3.83,31.20 -3.79,31.36 -3.83,31.66 -3.02,31.83 -2.86,32.07 -2.45,32.13 -1.28,32.09
|
||||||
|
-8.68,27.29 -8.68,27.66
|
||||||
|
-12.28,14.81 -12.86,15.24 -12.93,15.45 -13.21,15.62 -13.41,16.06 -13.51,16.14 -13.87,16.15 -14.09,16.42 -14.30,16.58 -14.53,16.66 -14.99,16.68 -15.77,16.49 -16.24,16.53 -16.44,16.20 -16.54,15.84
|
||||||
|
-4.82,25.00 -6.59,24.99 -5.63,16.57 -5.36,16.28 -5.51,15.50 -9.18,15.50 -9.34,15.53 -9.35,15.68 -9.43,15.62 -9.45,15.46 -9.94,15.37 -10.70,15.42 -10.95,15.15 -11.50,15.64 -11.76,15.43 -11.94,14.89 -12.10,14.75 -12.28,14.81
|
||||||
|
4.23,19.14 3.40,18.99 3.26,19.01 3.11,19.15 3.26,19.41 3.20,19.79 3.13,19.85 2.41,20.06 2.22,20.25 1.75,20.33 1.61,20.56 1.17,20.82 1.15,21.10 -4.82,25.00
|
||||||
|
4.23,19.14 5.84,19.48 7.48,20.87 11.97,23.52
|
||||||
|
9.52,30.23 9.04,32.07 8.33,32.54 8.11,33.06 7.73,33.27 7.50,33.83 7.51,34.08 7.75,34.25 7.84,34.41 8.12,34.56 8.25,34.73 8.28,34.98 8.39,35.20 8.25,35.80 8.35,36.37 8.21,36.52 8.44,36.76 8.60,36.83 8.58,36.94
|
||||||
|
23.98,19.50 15.98,23.45 14.98,23.00
|
||||||
|
24.98,22.00 24.98,20.00 23.98,20.00 23.98,19.50
|
||||||
|
25.15,31.65 24.85,31.33 24.96,30.68 24.70,30.20 24.98,29.18 24.98,22.00
|
||||||
|
22.86,10.92 22.92,11.34 22.59,11.58 22.58,11.99 22.47,12.07 22.35,12.66 22.23,12.71 21.93,12.68 21.83,12.79 21.99,13.11 22.23,13.33 22.11,13.80 22.17,13.91 22.54,14.16 22.38,14.55 22.67,14.72 22.76,15.00 22.93,15.16 22.93,15.53 23.11,15.70 23.97,15.72 23.98,19.50
|
||||||
|
24.98,22.00 31.21,21.99 31.40,22.20 31.49,22.15 31.43,22.00 36.87,22.00
|
||||||
|
41.88,3.98 41.34,3.20 40.96,2.81 40.98,-0.87 41.52,-1.57 41.53,-1.70
|
||||||
|
42.92,11.00 42.66,10.60 42.84,10.20 43.07,9.93 43.18,9.88 43.48,9.38 43.62,9.34 43.98,9.01 46.98,8.00 47.98,8.00
|
||||||
|
30.75,-8.19 28.90,-8.49 28.93,-8.59 28.87,-8.79 28.40,-9.22 28.60,-9.68 28.65,-10.55 28.36,-11.48 28.38,-11.57 28.48,-11.81 29.06,-12.35 29.49,-12.42 29.51,-12.23 29.80,-12.16 29.78,-13.44 29.65,-13.41 29.63,-13.30 29.55,-13.25 29.20,-13.40 29.01,-13.37 28.73,-12.93 28.55,-12.84 28.41,-12.52 27.57,-12.23 27.42,-11.94 27.24,-11.78 27.16,-11.58 27.05,-11.62 26.95,-11.90 26.82,-11.97 26.03,-11.89 25.51,-11.75 25.35,-11.62 25.29,-11.21 24.52,-11.44 24.38,-11.42 24.34,-11.37 24.40,-11.26 24.37,-11.13 24.14,-11.03 24.08,-10.89 23.97,-10.87
|
||||||
|
23.38,-17.64 22.15,-16.60 22.04,-16.26 21.98,-15.96 21.98,-13.00 23.96,-12.99 23.88,-12.80 23.99,-12.42 23.97,-11.64 24.05,-11.41 23.97,-10.87
|
||||||
|
33.90,-1.00 33.94,0.17 34.16,0.61 34.41,0.87 34.48,1.04 34.80,1.24 34.78,1.38 34.97,1.64 34.98,1.77 34.91,2.48 34.74,2.82 34.59,2.92 34.52,3.12 34.45,3.16 34.40,3.41 34.44,3.65 34.17,3.81 34.19,3.87 33.98,4.22
|
||||||
|
33.90,-1.00 37.64,-3.05 37.69,-3.25 37.62,-3.51 39.22,-4.69
|
||||||
|
11.33,2.17 9.98,2.17 9.80,2.30
|
||||||
|
11.33,2.17 11.34,1.00 10.03,1.00 9.91,0.96 9.76,1.07 9.59,1.03
|
||||||
|
25.26,-17.79 24.91,-17.82 24.53,-18.05 24.36,-17.98 24.24,-18.02 23.60,-18.46 23.30,-18.03 23.22,-18.00 20.97,-18.32 20.98,-21.96 19.98,-22.00 19.98,-24.75
|
||||||
|
19.98,-24.78 19.98,-28.45 19.54,-28.57 19.31,-28.73 19.25,-28.90 19.16,-28.94 18.84,-28.87 18.10,-28.87 17.45,-28.70 17.35,-28.50 17.36,-28.27 17.06,-28.03 16.88,-28.13 16.76,-28.45 16.45,-28.62
|
||||||
|
19.98,-24.75 19.98,-24.78
|
||||||
|
0.90,10.99 0.79,10.71 0.76,10.39 1.33,10.00 1.39,9.36 1.60,9.05 1.62,7.00 1.53,6.99 1.60,6.61 1.78,6.29 1.62,6.22
|
||||||
|
42.80,16.37 43.17,16.69 43.18,16.81 43.12,16.94 43.16,17.21 43.24,17.27 43.19,17.36 43.42,17.52 43.92,17.32 44.16,17.40 45.15,17.43 45.54,17.30 46.31,17.23 46.73,17.27 46.98,16.95 47.14,16.95 47.44,17.11 47.58,17.45 48.17,18.16 49.04,18.58 51.98,19.00
|
||||||
|
51.98,19.00 52.73,17.30 52.80,17.27 53.09,16.65
|
||||||
|
55.19,22.70 55.64,22.00 54.98,20.00 51.98,19.00
|
||||||
|
55.19,22.70 55.10,22.62 52.56,22.93 51.59,24.08 51.57,24.29
|
||||||
|
42.36,37.11 41.79,36.60 41.42,36.51 41.30,36.38 41.25,36.07 41.35,35.64 41.22,35.29 41.19,34.77 40.99,34.43 40.69,34.33 38.77,33.37
|
||||||
|
35.79,32.73 36.37,32.39 36.82,32.32 38.77,33.37
|
||||||
|
35.61,32.68 35.79,32.73
|
||||||
|
39.15,32.12 39.29,32.24 39.25,32.35 39.04,32.31 38.98,32.47 39.06,32.49 38.77,33.37
|
||||||
|
34.95,29.35 36.07,29.20 36.48,29.50 36.76,29.87 37.47,30.00 37.65,30.33 37.98,30.50 36.96,31.49 38.96,31.99 39.15,32.12
|
||||||
|
46.53,29.10 46.36,29.06 44.69,29.20 42.07,31.08 40.37,31.94 39.15,32.12
|
||||||
|
61.59,25.20 61.66,25.75 61.75,25.84 61.84,26.23 62.13,26.37 62.24,26.36 62.31,26.49 62.44,26.56 63.16,26.65 63.30,27.15 63.17,27.25 62.76,27.25 62.81,27.50 62.76,28.24 62.56,28.24 62.35,28.41 61.89,28.55 61.62,28.79 61.32,29.37 60.84,29.86
|
||||||
|
60.84,29.86 61.81,30.91 61.76,31.29 61.66,31.38 60.82,31.50 60.83,32.25 60.56,33.06 60.56,33.14 60.92,33.51 60.51,33.64 60.49,34.09 60.64,34.31 60.89,34.32 60.73,34.52 60.95,34.65 61.08,34.86 61.15,35.09 61.10,35.27 61.19,35.31 61.28,35.51 61.26,35.62
|
||||||
|
73.73,36.89 74.04,36.83 74.54,37.02
|
||||||
|
60.84,29.86 62.48,29.41 63.57,29.50 64.10,29.39 64.39,29.54 65.10,29.56 66.18,29.84 66.31,29.97 66.24,30.11 66.35,30.80 66.40,30.91 66.60,31.02 66.83,31.26 66.92,31.31 67.45,31.23 67.74,31.34 67.58,31.51 68.02,31.68 68.16,31.80 68.44,31.75 68.60,31.80 68.87,31.63 69.08,31.74 69.28,31.94 69.24,32.43 69.41,32.68 69.50,33.02 69.92,33.11 70.26,33.29 70.28,33.37 70.13,33.62 69.87,33.90 69.89,34.01 69.99,34.05 70.33,33.96 70.65,33.95 71.05,34.05 71.10,34.37 70.97,34.53 71.62,35.18 71.55,35.29 71.60,35.41 71.57,35.55 71.40,35.88 71.19,36.04 71.23,36.12 71.62,36.44 71.77,36.43 72.25,36.73 72.62,36.83 73.73,36.89
|
||||||
|
70.95,42.25 70.72,42.17 70.58,42.04 70.42,42.08 70.10,41.82 69.15,41.43 68.99,41.21 68.58,40.88 68.57,40.62 68.42,40.62 68.05,40.81 68.11,41.03 67.94,41.20 66.71,41.18 66.50,41.99 66.01,42.00 66.10,42.99 65.80,42.88 65.50,43.31 64.91,43.71 64.44,43.55 63.21,43.63 61.99,43.49 61.16,44.17 61.01,44.39 58.56,45.56 55.98,44.99 55.98,41.32
|
||||||
|
13.61,13.70 13.45,14.38 14.37,15.75 15.47,16.91 15.74,19.90 15.96,20.35 15.59,20.73 15.54,20.87 15.61,20.95 15.18,21.52 15.17,21.92 14.98,23.00
|
||||||
|
11.97,23.52 13.48,23.18 14.22,22.62 14.98,23.00
|
||||||
|
0.22,14.91 0.29,14.98 0.95,14.98 1.30,15.27 3.00,15.34 3.06,15.43 3.50,15.36 3.52,15.48 3.84,15.70 4.12,16.36 4.23,17.00 4.23,19.14
|
||||||
|
-0.07,11.12 -0.30,11.17 -0.63,10.93 -0.70,10.99 -1.04,11.01 -2.83,11.00 -2.91,10.59 -2.79,10.43 -2.78,9.75 -2.70,9.48
|
||||||
|
-13.29,9.05 -13.03,9.10 -12.96,9.26 -12.76,9.37 -12.50,9.86 -12.43,9.90 -12.28,9.93 -12.14,9.88 -11.92,9.92 -11.91,9.99 -11.27,10.00 -10.69,9.31 -10.75,9.10 -10.62,9.06 -10.61,8.87 -10.50,8.69 -10.71,8.34 -10.56,8.32 -10.39,8.48 -10.28,8.49
|
||||||
|
5.79,49.54 6.01,49.45 6.18,49.50 6.34,49.45
|
||||||
|
56.08,26.06 56.17,26.05 56.14,25.69 56.30,25.65
|
||||||
|
30.51,-1.07 30.36,-1.07 30.10,-1.37 29.93,-1.47 29.83,-1.34 29.58,-1.39
|
||||||
|
30.51,-1.07 30.51,-1.21 30.81,-1.56 30.88,-2.14 30.83,-2.34 30.55,-2.40
|
||||||
|
33.90,-1.00 30.81,-0.99 30.51,-1.07
|
||||||
|
34.59,-11.97 34.54,-12.02 34.62,-12.07 34.59,-11.97
|
||||||
|
87.32,49.09 87.82,49.16
|
||||||
|
45.50,40.66 45.48,40.61 45.55,40.62 45.50,40.66
|
||||||
|
44.99,41.09 45.00,41.02 44.99,41.09
|
||||||
|
12.44,43.98 12.40,43.93 12.49,43.90 12.50,43.99 12.44,43.98
|
||||||
|
70.57,41.02 70.65,40.94 70.57,41.02
|
||||||
|
-117.13,32.53 -114.72,32.72 -114.84,32.51 -111.04,31.32 -108.21,31.33 -108.21,31.78 -106.45,31.77 -106.15,31.45 -104.92,30.58 -104.68,30.13 -104.62,29.85 -104.40,29.57 -103.26,29.00 -103.09,29.04 -102.89,29.22 -102.73,29.64 -102.34,29.86 -101.99,29.80 -101.55,29.81 -101.38,29.74 -100.75,29.18 -100.33,28.50 -100.30,28.33 -99.75,27.73 -99.51,27.55 -99.46,27.06 -99.23,26.76 -99.11,26.45 -98.28,26.11 -97.80,26.04 -97.38,25.87 -97.15,25.96
|
||||||
|
-92.24,14.55 -92.16,14.69 -92.16,14.96 -92.07,15.07 -92.20,15.28 -91.74,16.07 -90.45,16.07 -90.42,16.39 -90.63,16.51 -90.71,16.71 -91.41,17.26 -90.99,17.25 -90.99,17.82 -89.16,17.81 -89.13,17.97 -89.05,18.00 -88.90,17.91 -88.81,17.97 -88.46,18.48 -88.04,18.42 -88.04,18.20 -87.83,18.18
|
||||||
|
-54.62,-25.58 -54.61,-25.43 -54.44,-25.12 -54.28,-24.31 -54.32,-24.13 -54.24,-24.05 -54.63,-23.81 -55.19,-24.02 -55.42,-23.95 -55.65,-22.89 -55.62,-22.67 -55.85,-22.31 -56.25,-22.26 -56.45,-22.08 -56.63,-22.23 -56.94,-22.27 -57.57,-22.18 -57.72,-22.10 -57.96,-22.11 -57.95,-21.49 -57.83,-21.00 -58.16,-20.16
|
||||||
|
-62.65,-22.23 -62.21,-22.61 -61.80,-23.18 -61.21,-23.56 -61.03,-23.76 -60.51,-23.96 -59.89,-24.09 -59.19,-24.56 -58.37,-24.96 -57.96,-25.05 -57.64,-25.33 -57.57,-25.53 -57.75,-25.70 -57.89,-26.01 -58.11,-26.18 -58.20,-26.38 -58.19,-26.63 -58.32,-26.86 -58.62,-27.13 -58.60,-27.31 -58.17,-27.27 -57.11,-27.47 -56.60,-27.47 -56.44,-27.55 -56.16,-27.32 -55.71,-27.41 -55.43,-27.01 -55.14,-26.93 -54.83,-26.65 -54.68,-26.31 -54.62,-25.58
|
||||||
|
-62.65,-22.23 -62.84,-22.00 -63.92,-22.03 -64.27,-22.60 -64.33,-22.83 -64.61,-22.23 -64.99,-22.11 -65.77,-22.10 -66.22,-21.80 -66.37,-22.11 -66.71,-22.22 -66.80,-22.41 -67.03,-22.55 -67.19,-22.82
|
||||||
|
26.62,48.26 26.85,48.39 27.23,48.37 27.55,48.48 27.82,48.42 28.09,48.26 28.29,48.24 28.46,48.09 28.53,48.15 28.77,48.12 28.92,47.95 29.13,47.96 29.21,47.78 29.13,47.49 29.54,47.27 29.51,47.13 29.57,46.96 29.88,46.83 29.94,46.72 29.92,46.54 30.13,46.42 29.84,46.35 29.71,46.45 29.61,46.40 29.30,46.47 29.20,46.38 29.19,46.52 28.96,46.46 28.93,46.36 29.01,46.18 28.95,46.05 28.74,45.94 28.73,45.85 28.49,45.67 28.50,45.52 28.21,45.45
|
||||||
|
67.76,37.17 67.96,36.97 68.07,36.95 68.91,37.33 69.30,37.12 69.41,37.21 69.40,37.40 69.49,37.55 69.82,37.61 70.04,37.55 70.19,37.58 70.25,37.66 70.21,37.92 70.62,38.33 70.88,38.46 71.26,38.31 71.33,38.17 71.28,37.92 71.58,37.91 71.43,37.13 71.53,36.85 71.67,36.70 71.80,36.69 72.36,36.98 72.66,37.03 72.90,37.27 73.38,37.46 73.72,37.42 73.65,37.24 74.17,37.33 74.35,37.42 74.66,37.39 74.89,37.23
|
||||||
|
66.52,37.35 66.83,37.37 67.07,37.33 67.20,37.24 67.52,37.27 67.76,37.17
|
||||||
|
61.26,35.62 61.34,35.63 61.62,35.43 61.98,35.44 62.31,35.17 62.46,35.25 62.69,35.26 63.06,35.45 63.17,35.68 63.13,35.85 64.01,36.01 64.51,36.34 64.82,37.13 65.09,37.24 65.55,37.25 65.64,37.47 65.77,37.57 66.11,37.41 66.52,37.35
|
||||||
|
92.57,21.98 92.49,22.69 92.36,22.93 92.25,23.68 91.93,23.69 91.94,23.50 91.75,23.29 91.75,23.05 91.62,22.98 91.51,23.03 91.44,23.20 91.37,23.20 91.37,23.07 91.32,23.10 91.16,23.66 91.23,23.92 91.37,24.09 91.88,24.20 91.95,24.36 92.10,24.41 92.23,24.88 92.44,24.85 92.47,24.94 92.05,25.17 90.44,25.16 89.83,25.29 89.82,25.94 89.67,26.21 89.59,26.19 89.55,26.01 89.37,26.01 89.19,26.11 89.02,26.41 88.92,26.38 88.97,26.25 88.83,26.25 88.68,26.29 88.62,26.43 88.42,26.57 88.35,26.50 88.44,26.37 88.15,26.09 88.08,25.89 88.50,25.54 88.77,25.49 88.95,25.26 88.82,25.18 88.46,25.19 88.31,24.88 88.15,24.91 88.02,24.63 88.15,24.49 88.29,24.48 88.72,24.27 88.70,24.00 88.57,23.67 88.74,23.44 88.72,23.25 88.93,23.19 88.85,23.04 89.05,22.09
|
||||||
|
2.39,11.90 2.37,12.22 2.81,12.38 3.60,11.70
|
||||||
|
18.61,3.48 18.62,3.30 18.49,2.92 18.21,2.41 18.07,2.01 18.06,1.53 17.90,1.12 17.89,0.23 17.72,-0.28 17.75,-0.55 17.28,-1.00 16.88,-1.23 16.54,-1.84 16.22,-2.18 16.22,-3.03 16.15,-3.46 15.99,-3.77 15.87,-3.93 15.60,-4.03 15.27,-4.31 14.71,-4.88 14.41,-4.83 14.37,-4.59 14.45,-4.45 14.36,-4.30 13.94,-4.48 13.72,-4.45 13.66,-4.72 13.41,-4.84 13.14,-4.60 13.07,-4.63
|
||||||
|
28.98,-30.00 29.10,-29.92 29.14,-29.70 29.29,-29.57 29.39,-29.27 29.30,-29.09 28.95,-28.88 28.63,-28.58 27.74,-28.94 27.29,-29.52 27.06,-29.63 27.39,-30.32 27.75,-30.60 28.06,-30.63 28.39,-30.15 28.65,-30.13 28.98,-30.00
|
||||||
|
29.36,-22.19 29.66,-22.15 30.19,-22.29 30.92,-22.29 31.29,-22.40
|
||||||
|
19.98,-24.78 20.43,-25.15 20.79,-25.92 20.82,-26.16 20.63,-26.44 20.69,-26.82 21.69,-26.84 21.79,-26.71 22.09,-26.58 22.22,-26.39 22.60,-26.13 22.88,-25.46 23.06,-25.31 23.39,-25.29 23.89,-25.60 24.19,-25.63 24.33,-25.74 24.75,-25.82 25.44,-25.71 25.58,-25.61 25.91,-24.75 26.40,-24.61 26.84,-24.24 26.99,-23.70 27.09,-23.58 27.76,-23.20 28.21,-22.69 28.38,-22.59 28.84,-22.48 29.13,-22.21 29.36,-22.19
|
||||||
|
34.96,-11.58 35.56,-11.60 35.79,-11.45 35.91,-11.45 36.31,-11.71 36.67,-11.68 36.98,-11.57 37.37,-11.71 37.72,-11.58 37.92,-11.29 38.18,-11.28 38.49,-11.41 38.79,-11.23 39.32,-11.12 39.99,-10.82 40.46,-10.46
|
||||||
|
-84.35,10.98 -84.17,10.78 -83.92,10.74 -83.71,10.79 -83.64,10.92
|
||||||
|
28.15,56.14 27.90,56.08 27.64,55.91 27.58,55.80 27.05,55.83 26.59,55.67
|
||||||
|
28.21,45.45 28.45,45.29 28.76,45.23 28.78,45.31 29.40,45.42 29.71,45.26
|
||||||
|
21.36,44.83 21.91,44.67 22.09,44.54 22.50,44.71 22.64,44.65 22.73,44.57 22.55,44.54 22.49,44.44 22.71,44.24
|
||||||
|
56.23,25.30 56.24,25.21 56.29,25.28 56.23,25.30
|
||||||
|
32.11,-26.84 32.89,-26.85
|
||||||
|
71.74,39.98 71.67,39.95 71.75,39.91 71.79,40.00 71.74,39.98
|
||||||
|
-122.69,48.99 -95.16,48.99 -95.16,49.37 -94.85,49.30 -94.80,49.00 -94.62,48.74 -94.06,48.66 -93.71,48.53 -93.38,48.62 -93.00,48.61 -92.50,48.44 -92.41,48.28 -92.17,48.34 -92.01,48.30 -91.52,48.06 -90.92,48.21 -90.74,48.10 -90.09,48.12 -89.90,48.00 -89.46,48.00 -88.38,48.30 -84.88,46.90 -84.78,46.64 -84.56,46.46 -84.15,46.54 -84.09,46.23 -83.98,46.08 -83.62,46.12 -83.47,45.99 -83.59,45.82 -82.55,45.35 -82.14,43.57 -82.41,43.07 -82.55,42.62 -83.07,42.30 -83.15,42.14 -83.14,41.98 -83.03,41.83 -82.69,41.68 -82.44,41.67 -81.28,42.21 -80.25,42.37 -79.04,42.80 -78.92,42.91 -79.03,43.02 -79.06,43.28 -79.17,43.47 -78.85,43.58 -76.82,43.63 -76.46,44.06 -76.15,44.30 -75.00,44.97 -71.52,45.01 -71.33,45.29 -71.13,45.26 -71.00,45.34 -70.87,45.27 -70.80,45.40 -70.69,45.43 -70.70,45.55 -70.42,45.74 -70.30,45.91 -70.25,46.25 -70.07,46.44 -70.01,46.71 -69.24,47.46 -69.05,47.43 -69.05,47.27 -68.94,47.21 -68.24,47.35 -67.81,47.08 -67.80,45.73 -67.43,45.60 -67.49,45.47 -67.43,45.38 -67.47,45.28 -67.37,45.17 -67.12,45.17
|
||||||
|
34.96,-11.58 34.89,-11.39 34.77,-11.34 34.61,-11.08 34.66,-10.71 34.58,-10.53 34.52,-10.03 34.32,-9.73 34.09,-9.54 34.00,-9.50 33.95,-9.66 33.89,-9.67
|
||||||
|
107.97,21.51 107.76,21.66 107.35,21.61 107.27,21.71 107.06,21.79 106.97,21.92 106.66,21.98 106.65,22.24 106.55,22.34 106.55,22.50 106.78,22.78 106.54,22.91 106.28,22.86 106.15,22.97 105.84,22.92 105.55,23.07 105.28,23.35 104.86,23.14 104.80,22.91 104.69,22.82 104.37,22.70 104.14,22.80 104.05,22.75 103.94,22.54 103.62,22.78 103.49,22.59 103.33,22.77 102.98,22.45 102.83,22.59 102.47,22.75 102.13,22.38
|
||||||
|
7.44,43.75 7.38,43.73
|
||||||
|
12.43,41.91 12.43,41.91
|
||||||
|
113.55,22.22 113.48,22.20
|
||||||
|
114.02,22.51 114.12,22.57 114.27,22.54
|
||||||
|
70.61,39.79 70.48,39.88 70.70,39.85 70.61,39.79
|
||||||
|
71.22,39.91 71.01,39.90 71.04,39.99 70.97,40.04 70.98,40.13 71.23,40.05 71.18,39.98 71.22,39.91
|
||||||
|
145.94,43.10 145.78,43.48 145.69,43.55 145.54,43.51 145.24,43.78 145.27,43.91 145.62,44.29 145.66,44.45 145.58,44.79
|
||||||
|
-1.28,32.09 -1.23,32.11 -1.24,32.34 -1.07,32.47 -1.45,32.78 -1.68,33.32 -1.63,33.57 -1.71,33.78 -1.71,34.18 -1.79,34.37 -1.73,34.47 -1.85,34.61 -1.80,34.75 -2.13,34.97 -2.22,35.10
|
||||||
|
29.00,9.61 29.47,9.77 29.60,9.92 29.61,10.07 30.00,10.28 30.76,9.73 31.22,9.80 31.79,10.38 31.93,10.66 32.42,11.09 32.34,11.31 32.34,11.72 32.07,12.01 32.74,12.01 32.72,12.22 33.20,12.22 33.07,11.59 33.17,10.85 33.13,10.76 33.38,10.65 33.91,10.18 33.96,9.86 33.87,9.51 34.08,9.46
|
||||||
|
24.15,8.67 24.21,8.77 24.53,8.89 24.67,9.39 24.78,9.53 24.79,9.81 24.96,9.99 25.02,10.24 25.10,10.31 25.86,10.41 25.92,10.17 26.55,9.53 26.66,9.48 27.07,9.61 27.88,9.60
|
||||||
|
27.88,9.60 28.05,9.33 28.84,9.33 28.84,9.46 29.00,9.61
|
||||||
|
-56.48,1.94 -56.70,2.04 -57.20,2.85 -57.30,3.38 -57.60,3.37
|
||||||
|
-54.62,2.33 -54.40,2.46 -54.20,2.82 -54.20,3.14 -54.08,3.33
|
||||||
|
33.89,-9.67 33.70,-9.60 33.42,-9.61 33.33,-9.52 32.92,-9.41
|
||||||
|
33.59,46.10 33.66,46.22 34.22,46.10 34.45,45.97 34.69,45.98 34.79,45.89 34.80,45.79 35.00,45.73
|
||||||
|
77.05,35.11 76.77,35.66
|
||||||
|
77.05,35.11 77.80,35.50
|
||||||
|
79.12,33.23 79.38,33.15 79.33,32.98 79.50,32.73 79.38,32.57 79.22,32.50
|
||||||
|
79.22,32.50 78.92,32.36 78.70,32.60 78.41,32.56 78.49,32.23
|
||||||
|
78.49,32.23 78.73,31.98 78.72,31.88
|
||||||
|
78.72,31.88 78.69,31.74 78.80,31.62 78.73,31.47 78.74,31.32 78.84,31.30
|
||||||
|
78.84,31.30 78.97,31.33 79.01,31.41 79.11,31.40 79.37,31.08
|
||||||
|
79.37,31.08 79.57,30.95 79.79,30.97 79.91,30.90
|
||||||
|
79.91,30.90 80.19,30.76 80.19,30.57
|
||||||
|
80.19,30.57 80.68,30.41 80.99,30.24 81.01,30.16
|
||||||
|
90.33,28.12 90.35,28.24 90.22,28.28 89.98,28.31 89.80,28.24
|
||||||
|
89.80,28.24 89.48,28.06 89.12,27.62
|
||||||
|
89.12,27.62 89.02,27.51
|
||||||
|
89.02,27.51 88.89,27.32
|
||||||
|
75.30,32.32 75.32,32.22 75.25,32.14 74.74,31.95 74.56,31.82 74.51,31.71 74.59,31.47 74.52,31.19 74.63,31.03 74.34,30.89 73.90,30.44 73.93,30.22 73.81,30.09 73.38,29.93 73.23,29.55 72.90,29.03 72.34,28.75 72.13,28.35 71.95,28.18 71.87,27.96 70.80,27.71 70.69,27.77 70.63,27.94 70.40,28.03 70.14,27.85 69.90,27.47 69.54,27.12 69.47,26.80 69.51,26.74 70.15,26.51 70.10,25.91 70.26,25.71 70.65,25.67 70.65,25.42 71.05,24.69 70.97,24.57 71.04,24.40 70.72,24.24 70.58,24.28 70.55,24.42 70.49,24.41 70.10,24.29 70.02,24.19 69.81,24.17 69.56,24.27 68.83,24.26 68.78,24.31 68.73,24.27 68.72,23.96 68.38,23.95 68.17,23.86
|
||||||
|
36.69,45.62 36.69,45.43 36.59,45.30 36.64,45.26 36.51,45.20 36.61,44.95
|
||||||
|
-55.81,-31.03 -56.00,-31.08 -56.00,-30.85
|
||||||
|
-56.00,-30.85 -56.18,-30.63 -56.83,-30.11 -57.12,-30.14 -57.21,-30.28 -57.55,-30.26 -57.61,-30.19
|
||||||
|
-73.48,-49.77 -73.58,-49.58 -73.46,-49.31 -73.14,-49.30
|
||||||
|
-73.14,-49.30 -73.15,-49.19 -73.03,-49.01 -72.65,-48.84 -72.58,-48.48 -72.35,-48.37 -72.29,-48.23 -72.33,-48.11 -72.51,-47.97 -72.52,-47.88 -72.35,-47.49 -72.04,-47.24 -71.90,-47.20 -71.96,-47.02 -71.94,-46.83 -71.70,-46.65 -71.76,-46.32 -71.88,-46.16 -71.63,-45.95 -71.75,-45.84 -71.75,-45.58 -71.51,-45.51 -71.35,-45.33 -71.35,-45.23 -71.60,-44.98 -72.04,-44.90 -72.06,-44.77 -71.26,-44.76 -71.16,-44.56 -71.21,-44.44 -71.82,-44.38 -71.81,-44.11 -71.68,-43.93 -71.79,-43.75 -71.74,-43.70 -71.75,-43.59 -71.90,-43.44 -71.90,-43.35 -71.75,-43.24 -71.78,-43.17 -72.10,-43.07 -72.14,-42.58 -72.05,-42.47 -72.11,-42.25 -72.03,-42.15 -71.86,-42.15 -71.75,-42.05 -71.91,-41.65 -71.87,-40.89 -71.94,-40.79 -71.80,-40.44 -71.70,-40.34 -71.82,-40.18 -71.66,-40.02 -71.64,-39.89 -71.72,-39.64 -71.54,-39.60 -71.42,-39.29 -71.40,-38.94 -70.95,-38.74 -70.85,-38.54 -70.97,-38.45 -71.03,-38.04 -71.17,-37.76 -71.13,-37.45 -71.20,-37.30 -71.12,-37.11 -71.19,-36.84 -71.06,-36.52 -70.75,-36.39 -70.72,-36.28 -70.40,-36.06 -70.42,-35.52 -70.56,-35.25 -70.39,-35.15 -70.29,-34.73 -70.05,-34.30 -69.85,-34.22 -69.89,-33.73 -69.80,-33.40 -69.82,-33.28 -70.02,-33.27 -70.08,-33.20 -70.09,-33.03 -70.02,-32.88 -70.12,-32.81 -70.17,-32.47 -70.32,-32.27 -70.36,-32.08 -70.25,-31.96 -70.45,-31.84 -70.59,-31.57 -70.52,-31.15 -70.39,-31.12 -70.31,-31.02 -70.35,-30.90 -70.15,-30.36 -69.96,-30.36 -69.84,-30.18 -69.96,-30.08 -69.93,-29.77 -70.03,-29.32 -69.83,-29.10 -69.66,-28.41 -69.17,-27.92 -68.85,-27.15 -68.71,-27.10 -68.59,-27.14 -68.32,-26.97 -68.32,-26.88 -68.59,-26.47 -68.58,-26.35 -68.41,-26.15 -68.59,-25.42 -68.50,-25.16 -68.38,-25.09 -68.56,-24.84 -68.56,-24.75 -68.25,-24.39 -67.36,-24.03 -67.01,-23.00 -67.19,-22.82
|
||||||
|
75.32,34.58 74.30,34.77 73.96,34.65 73.81,34.42 73.81,34.33 73.97,34.24 73.92,34.04 74.25,33.99 74.22,33.89 74.00,33.79 73.98,33.72 74.15,33.51 73.99,33.22 74.30,32.99 74.31,32.81
|
||||||
|
23.61,51.52 23.54,51.71 23.63,51.81 23.65,52.04 23.18,52.29 23.41,52.52 23.84,52.66 23.92,52.77 23.86,53.11 23.48,53.94
|
||||||
|
34.08,9.46 34.16,9.85 34.31,10.19 34.28,10.53 34.34,10.66 34.57,10.88 34.77,10.75 34.93,10.86 34.96,11.28 35.11,11.82 35.67,12.62 36.13,12.76 36.16,13.09 36.39,13.63 36.52,14.26
|
||||||
|
2.39,11.90 2.29,11.69 1.98,11.42 1.43,11.45 1.32,11.30 1.15,11.25 1.08,11.08 0.99,11.08 0.90,10.99
|
||||||
|
31.95,-25.96 31.87,-25.98 31.42,-25.75 31.21,-25.84 30.80,-26.41 30.79,-26.76 30.88,-26.79 31.06,-27.11 31.47,-27.30 31.96,-27.31 31.99,-26.82 32.11,-26.84
|
||||||
|
32.11,-26.84 32.06,-26.02 31.95,-25.96
|
||||||
|
76.77,35.66 76.56,35.77 76.55,35.89 76.15,35.83 76.07,35.98 75.91,36.05 75.97,36.38
|
||||||
|
27.33,5.19 27.79,4.64 28.19,4.35 28.43,4.32 28.73,4.50 29.22,4.39 29.47,4.61 29.68,4.59 30.19,3.98 30.51,3.84 30.59,3.62 30.76,3.62 30.84,3.49
|
||||||
|
22.86,10.92 23.31,10.39 23.65,9.82 23.62,9.34 23.47,9.11 23.55,8.94 23.54,8.82 23.68,8.73 24.15,8.67
|
||||||
|
46.11,38.88 46.49,38.91
|
||||||
|
44.78,39.68 45.14,39.25 45.48,39.01 46.11,38.88
|
||||||
|
46.49,38.91 47.48,39.50 47.77,39.65 48.00,39.68 48.32,39.40 48.10,39.24 48.29,39.02 48.05,38.93 48.00,38.85 48.59,38.41 48.87,38.44
|
||||||
|
81.01,30.16 80.85,30.14 80.40,29.73 80.25,29.42 80.23,29.19 80.08,28.99 80.07,28.83 80.42,28.61 80.59,28.65 81.85,27.87 81.99,27.91 82.45,27.67 82.68,27.67 82.73,27.52 83.29,27.37 83.45,27.47 83.83,27.38 84.09,27.49 84.61,27.30 84.69,27.04 85.13,26.86 85.24,26.75 85.57,26.84 85.70,26.78 85.79,26.60 86.01,26.65 86.70,26.44 87.02,26.56 87.09,26.43 87.29,26.36 87.41,26.42 87.85,26.44 88.00,26.38 88.16,26.72 87.98,27.13 88.15,27.75 88.11,27.87
|
||||||
|
75.97,36.38 75.84,36.65 75.67,36.74 75.42,36.74 75.35,36.91 74.54,37.02
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A copy is on this computer: a check in an outlined circle. Adwaita 50
|
||||||
|
no longer ships emblem-ok-symbolic. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 8,2.5 A 5.5,5.5 0 1 1 8,13.5 A 5.5,5.5 0 1 1 8,2.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show or hide the details pane: a window with its right-hand pane
|
||||||
|
filled in. Some themes draw sidebar-show-right-symbolic as a solid block. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.5,5 H 12.5 V 11 H 9.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 564 B |
@@ -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 |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A share link: two open chain ends and the bar that joins them, laid
|
||||||
|
on the diagonal. Written out in plain coordinates: GTK's symbolic icon
|
||||||
|
renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 4.82,6.23 L 3.05,8 A 3.5,3.5 0 0 0 8,12.95 L 9.77,11.18 L 8.53,9.94 L 6.76,11.71 A 1.75,1.75 0 0 1 4.29,9.24 L 6.06,7.47 Z"/>
|
||||||
|
<path fill="#222222" d="M 6.23,4.82 L 8,3.05 A 3.5,3.5 0 0 1 12.95,8 L 11.18,9.77 L 9.94,8.53 L 11.71,6.76 A 1.75,1.75 0 0 0 9.24,4.29 L 7.47,6.06 Z"/>
|
||||||
|
<path fill="#222222" d="M 5.26,9.5 L 9.5,5.26 L 10.74,6.5 L 6.5,10.74 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show on map: a folded map in three panels. No stock theme has a
|
||||||
|
map-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 1,3.2 L 5,1.5 V 12.8 L 1,14.5 Z M 11,3.2 L 15,1.5 V 12.8 L 11,14.5 Z"/>
|
||||||
|
<path fill="#222222" opacity="0.55" d="M 6,1.5 L 10,3.2 V 14.5 L 6,12.8 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||||
|
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||||
|
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Available offline": a filled disc with a down arrow knocked out of it
|
||||||
|
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
|
||||||
|
Replaces the star, which every desktop uses for "favourite". -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
|
||||||
|
folder full of cloud-only files does not shout. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Shared with me: two people. system-users-symbolic survives only in
|
||||||
|
Adwaita's legacy folder. -->
|
||||||
|
<path fill="#222222" d="M 6,2.5 A 2.5,2.5 0 1 0 6,7.5 A 2.5,2.5 0 1 0 6,2.5 Z M 1.5,14.5 C 1.5,11 3.5,9 6,9 C 8.5,9 10.5,11 10.5,14.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 11,2.4 A 2.1,2.1 0 1 0 11,6.6 A 2.1,2.1 0 1 0 11,2.4 Z M 9.8,8.4 C 10.2,8.3 10.6,8.25 11,8.25 C 13.3,8.25 15,10 15,13.5 H 11.8 C 11.7,11.5 11,9.7 9.8,8.4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Shared by me: one node passing to two others. Adwaita 50 no longer
|
||||||
|
ships emblem-shared-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 12,10.25 A 2.25,2.25 0 1 0 12,14.75 A 2.25,2.25 0 1 0 12,10.25 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.63,8.65 L 11.63,13.15 L 12.37,11.85 L 4.37,7.35 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Syncing: two arrows chasing each other round a circle. Bundled because
|
||||||
|
Adwaita 50 no longer ships emblem-synchronizing-symbolic. The lower arrow
|
||||||
|
is the upper one turned half a circle, written out in plain coordinates:
|
||||||
|
GTK's symbolic icon renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
|
||||||
|
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
|
||||||
|
<path fill="#222222" d="M 14,8 A 6,6 0 0 1 3.09,11.44 L 4.52,10.44 A 4.25,4.25 0 0 0 12.25,8 Z"/>
|
||||||
|
<path fill="#222222" d="M 1.77,12.36 L 1.97,8.32 L 5.83,9.52 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 733 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Up to date: a check knocked out of a filled circle. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Sharing stopped: the share glyph with its last node crossed out. Not
|
||||||
|
a no-entry sign, which reads as an error. -->
|
||||||
|
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.63,8.65 L 8.23,11.25 L 8.97,9.95 L 4.37,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.28,11.02 L 13.48,15.22 L 14.72,13.98 L 10.52,9.78 Z"/>
|
||||||
|
<path fill="#222222" d="M 13.48,9.78 L 9.28,13.98 L 10.52,15.22 L 14.72,11.02 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Upload-files action: an up arrow over a tray, the pair to
|
||||||
|
pdfs-folder-upload-symbolic. Stock themes only offer `document-send`,
|
||||||
|
which reads as "send by mail". -->
|
||||||
|
<path fill="#222222" d="M 8,1 L 3.5,5.5 L 4.9,6.9 L 7,4.8 V 11 H 9 V 4.8 L 11.1,6.9 L 12.5,5.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 1,10 V 14 C 1,14.6 1.4,15 2,15 H 14 C 14.6,15 15,14.6 15,14 V 10 H 13 V 13 H 3 V 10 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<path fill="#6d4aff" d="M4 12a4 4 0 0 1 4-4h11.2a4 4 0 0 1 2.9 1.2L25 12h15a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#8a6eff" d="M4 19a3 3 0 0 1 3-3h34a3 3 0 0 1 3 3v19a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#ffffff" fill-opacity="0.18" d="M7 16h34a3 3 0 0 1 3 3v1a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v-1a3 3 0 0 1 3-3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 442 B |
@@ -6,5 +6,34 @@
|
|||||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||||
|
<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>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-cached-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-unshare-symbolic.svg</file>
|
||||||
|
<!-- The Proton theme's folder: a full-colour icon from the icon theme
|
||||||
|
can't be recoloured by CSS. -->
|
||||||
|
<file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.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
|
||||||
|
high contrast. The Proton theme is a separate, opt-in provider. -->
|
||||||
|
<gresource prefix="/de/nils/protondrivelinux">
|
||||||
|
<file>style.css</file>
|
||||||
|
<file>proton-theme.css</file>
|
||||||
|
<!-- The search launcher's own sheet, over the same palette. -->
|
||||||
|
<file>prompt.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/* The search launcher (pdfs-prompt). Colours come from the libadwaita
|
||||||
|
palette, so the launcher follows the system accent, light/dark and high
|
||||||
|
contrast like the app; the opt-in Proton theme recolours both alike. */
|
||||||
|
|
||||||
|
window.launcher-window {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-card {
|
||||||
|
background-color: var(--view-bg-color);
|
||||||
|
color: var(--view-fg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 16px 48px rgb(0 0 0 / 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
-gtk-icon-size: 20px;
|
||||||
|
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-entry {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-hint {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 4px 14px;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
background-color: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:checked {
|
||||||
|
background-color: var(--accent-bg-color);
|
||||||
|
color: var(--accent-fg-color);
|
||||||
|
border-color: var(--accent-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- results ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.results {
|
||||||
|
padding: 2px 8px 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 12px 10px 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
-gtk-icon-size: 14px;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:hover {
|
||||||
|
background-color: color-mix(in srgb, currentColor 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A soft tint rather than a solid accent bar: the text keeps its colour, so
|
||||||
|
the highlighted match stays readable on the selected row. */
|
||||||
|
.result-row:selected,
|
||||||
|
.result-row:selected:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-bg-color) 18%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-location,
|
||||||
|
.result-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:selected .result-location,
|
||||||
|
.result-row:selected .result-meta {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge,
|
||||||
|
.preview-badge {
|
||||||
|
-gtk-icon-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pinned {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-cached {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-cloud,
|
||||||
|
.badge-local {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- preview ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
padding: 16px;
|
||||||
|
background-color: color-mix(in srgb, currentColor 3%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-thumb {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-thumb .thumb {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-kind {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-key {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-action {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shortcut {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-feedback {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- footer ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
statuspage.compact > scrolledwindow > viewport > box {
|
||||||
|
margin: 24px 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* Proton theme — the Proton web apps' Snow (light) and Carbon (dark) palettes.
|
||||||
|
*
|
||||||
|
* Loaded by main.rs after libadwaita's own stylesheet, which it re-applies on
|
||||||
|
* top of the user's GTK theme so that theme's hard-coded colours can't show
|
||||||
|
* through. Only libadwaita's colour variables are set here; every widget
|
||||||
|
* derives its look from them. Values from ProtonMail/WebClients,
|
||||||
|
* packages/colors/themes/src/{snow,carbon}/standard-base.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent-bg-color: #6d4aff;
|
||||||
|
--accent-fg-color: #ffffff;
|
||||||
|
--destructive-bg-color: #dc3251;
|
||||||
|
--destructive-fg-color: #ffffff;
|
||||||
|
--error-bg-color: #dc3251;
|
||||||
|
--error-fg-color: #ffffff;
|
||||||
|
--success-bg-color: #1ea885;
|
||||||
|
--success-fg-color: #ffffff;
|
||||||
|
--warning-bg-color: #ff9900;
|
||||||
|
--warning-fg-color: rgb(0 0 0 / 80%);
|
||||||
|
|
||||||
|
--window-bg-color: #ffffff;
|
||||||
|
--window-fg-color: #0c0c14;
|
||||||
|
--view-bg-color: #ffffff;
|
||||||
|
--view-fg-color: #0c0c14;
|
||||||
|
--headerbar-bg-color: #ffffff;
|
||||||
|
--headerbar-fg-color: #0c0c14;
|
||||||
|
--headerbar-backdrop-color: #ffffff;
|
||||||
|
--sidebar-bg-color: #f5f4f2;
|
||||||
|
--sidebar-fg-color: #0c0c14;
|
||||||
|
--sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-bg-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-fg-color: #0c0c14;
|
||||||
|
--secondary-sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--card-bg-color: #f5f4f2;
|
||||||
|
--card-fg-color: #0c0c14;
|
||||||
|
--dialog-bg-color: #ffffff;
|
||||||
|
--dialog-fg-color: #0c0c14;
|
||||||
|
--popover-bg-color: #ffffff;
|
||||||
|
--popover-fg-color: #0c0c14;
|
||||||
|
--thumbnail-bg-color: #f5f4f2;
|
||||||
|
--thumbnail-fg-color: #0c0c14;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--destructive-bg-color: #f5385a;
|
||||||
|
--error-bg-color: #f5385a;
|
||||||
|
|
||||||
|
--window-bg-color: #1e1c26;
|
||||||
|
--window-fg-color: #ffffff;
|
||||||
|
--view-bg-color: #1e1c26;
|
||||||
|
--view-fg-color: #ffffff;
|
||||||
|
--headerbar-bg-color: #1e1c26;
|
||||||
|
--headerbar-fg-color: #ffffff;
|
||||||
|
--headerbar-backdrop-color: #1e1c26;
|
||||||
|
--sidebar-bg-color: #16141c;
|
||||||
|
--sidebar-fg-color: #ffffff;
|
||||||
|
--sidebar-backdrop-color: #16141c;
|
||||||
|
--secondary-sidebar-bg-color: #1a1821;
|
||||||
|
--secondary-sidebar-fg-color: #ffffff;
|
||||||
|
--secondary-sidebar-backdrop-color: #1a1821;
|
||||||
|
--card-bg-color: #292733;
|
||||||
|
--card-fg-color: #ffffff;
|
||||||
|
--dialog-bg-color: #292733;
|
||||||
|
--dialog-fg-color: #ffffff;
|
||||||
|
--popover-bg-color: #292733;
|
||||||
|
--popover-fg-color: #ffffff;
|
||||||
|
--thumbnail-bg-color: #292733;
|
||||||
|
--thumbnail-fg-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/* Proton Drive for Linux — app stylesheet.
|
||||||
|
*
|
||||||
|
* Colours come from the libadwaita palette (var(--accent-bg-color),
|
||||||
|
* var(--success-color), ...) so the app follows the system accent, light/dark
|
||||||
|
* and high contrast. The one exception is the photo viewer, which always sits
|
||||||
|
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
||||||
|
* theme is opt-in and lives in its own provider (see main.rs and
|
||||||
|
* proton-theme.css).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||||
|
|
||||||
|
/* Sidebar footer */
|
||||||
|
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||||
|
.sidebar-status { padding: 6px 8px; border-radius: 8px; }
|
||||||
|
.sidebar-status image.success { color: var(--success-color); }
|
||||||
|
.sidebar-status image.warning { color: var(--warning-color); }
|
||||||
|
.sidebar-status image.error { color: var(--error-color); }
|
||||||
|
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||||
|
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||||
|
/* A count of what waits on a page, at the end of its sidebar row */
|
||||||
|
.sidebar-badge {
|
||||||
|
min-width: 10px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: alpha(currentColor, 0.15);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync page status card: the state is the headline, the detail explains it */
|
||||||
|
.sync-card { padding: 18px 24px; }
|
||||||
|
.sync-card-icon { padding: 6px; }
|
||||||
|
.sync-card-icon.success { color: var(--success-color); }
|
||||||
|
.sync-card-icon.warning { color: var(--warning-color); }
|
||||||
|
.sync-card-icon.error { color: var(--error-color); }
|
||||||
|
|
||||||
|
/* File browser */
|
||||||
|
.file-grid { padding: 6px; }
|
||||||
|
.file-tile { padding: 8px; border-radius: 10px; }
|
||||||
|
.file-tile:hover { background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||||
|
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
|
||||||
|
.file-thumbnail { border-radius: 7px; }
|
||||||
|
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||||
|
.badge-pinned { color: var(--success-color); }
|
||||||
|
.badge-cached { color: var(--success-color); }
|
||||||
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
|
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||||
|
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||||
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
|
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||||
|
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||||
|
.files-drop-active { outline: 2px dashed var(--accent-bg-color); outline-offset: -4px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 6%, transparent); }
|
||||||
|
|
||||||
|
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||||
|
.photo-viewer-window { background-color: #111014; }
|
||||||
|
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
||||||
|
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||||
|
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||||
|
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
||||||
|
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
||||||
|
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
|
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
||||||
|
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||||
|
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||||
|
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||||
|
.viewer-nav-btn:disabled { opacity: 0; }
|
||||||
|
.photo-scrubber { background: linear-gradient(to left, alpha(var(--window-bg-color), 0.92) 40%, alpha(var(--window-bg-color), 0)); }
|
||||||
|
.photo-scrubber-knob { background-color: var(--accent-bg-color); border-radius: 2px; }
|
||||||
|
.photo-scrubber-bubble { font-weight: 600; background-color: var(--popover-bg-color); color: var(--popover-fg-color); border-radius: 999px; padding: 4px 12px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); }
|
||||||
|
.viewer-spinner { color: white; }
|
||||||
|
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
||||||
|
.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: 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; }
|
||||||
|
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||||
|
.photo-thumb { transition: transform 220ms ease; }
|
||||||
|
.photo-tile:hover .photo-thumb { transform: scale(1.02); }
|
||||||
|
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
||||||
|
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
|
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
||||||
|
.photo-placeholder { color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }
|
||||||
|
.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; }
|
||||||
|
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||||
|
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
||||||
|
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; }
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
|
.role-pill.warning-pill { color: var(--warning-color); background-color: color-mix(in srgb, var(--warning-bg-color) 20%, transparent); }
|
||||||
|
|
||||||
|
/* Activity: the feed sits on the window background as one card per row, under
|
||||||
|
* day headings, like a boxed list with its rows set apart. */
|
||||||
|
listview.activity-feed { background: none; color: inherit; padding: 0 12px 18px 12px; }
|
||||||
|
listview.activity-feed > row { padding: 0; margin-bottom: 6px; background: none; }
|
||||||
|
listview.activity-feed > header { padding: 18px 6px 6px 6px; background: none; }
|
||||||
|
listview.activity-feed row.card { border-radius: 12px; }
|
||||||
|
|
||||||
|
/* 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: sea and land come in the map's own colours; markers (drawn
|
||||||
|
* in the area's own colour) in the accent. */
|
||||||
|
.places-map-frame { border-radius: 12px; }
|
||||||
|
.places-map { color: var(--accent-bg-color); }
|
||||||
|
/* Credit on the Places street map, in the corner as on every web map. */
|
||||||
|
.places-spot-photo { padding: 0; }
|
||||||
|
.places-spot-photo picture { border-radius: 6px; overflow: hidden; }
|
||||||
|
.places-map-credit { padding: 2px 8px; border-top-left-radius: 8px; color: var(--window-fg-color); background: color-mix(in srgb, var(--window-bg-color) 80%, transparent); }
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Dates and times as the app shows them, so every page words a moment the
|
||||||
|
//! same way.
|
||||||
|
//!
|
||||||
|
//! - [`relative`] for "when did this happen" in lists: "Just now", "5 min
|
||||||
|
//! ago", "Yesterday, 14:05", "Tuesday", then a date.
|
||||||
|
//! - [`short_date`] for date columns: "Sep 23", with the year only when it is
|
||||||
|
//! not this year.
|
||||||
|
//! - [`full`] for tooltips: the weekday, date, year and time.
|
||||||
|
//!
|
||||||
|
//! The formats are `strftime` patterns that go through gettext, so they follow
|
||||||
|
//! the language picked in Preferences, not only the system locale.
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
|
||||||
|
use crate::{gettext, gettext_f, ngettext_f};
|
||||||
|
|
||||||
|
/// Seconds in a day, for the "within the last week" checks.
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
|
||||||
|
/// A past moment in epoch seconds, relative to now. Empty when the time can't
|
||||||
|
/// be read.
|
||||||
|
pub(crate) fn relative(secs: i64) -> String {
|
||||||
|
match (
|
||||||
|
glib::DateTime::from_unix_local(secs),
|
||||||
|
glib::DateTime::now_local(),
|
||||||
|
) {
|
||||||
|
(Ok(at), Ok(now)) => relative_to(&at, &now),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`relative`] for a fixed `now`, so it can be tested.
|
||||||
|
pub(crate) fn relative_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let ago = now.to_unix() - at.to_unix();
|
||||||
|
// A clock a little ahead of ours still reads as "just now".
|
||||||
|
if (-60..60).contains(&ago) {
|
||||||
|
return gettext("Just now");
|
||||||
|
}
|
||||||
|
if (60..3600).contains(&ago) {
|
||||||
|
let minutes = (ago / 60) as u64;
|
||||||
|
return ngettext_f("{n} min ago", "{n} min ago", minutes, &[]);
|
||||||
|
}
|
||||||
|
// Translators: strftime format for a time of day, such as "14:05".
|
||||||
|
let time = || format(at, &gettext("%H:%M"));
|
||||||
|
if at.ymd() == now.ymd() {
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||||
|
// Translators: {time} is a time of day, such as "14:05".
|
||||||
|
return gettext_f("Yesterday, {time}", &[("time", &time())]);
|
||||||
|
}
|
||||||
|
if (0..6 * DAY).contains(&ago) {
|
||||||
|
return format(at, "%A");
|
||||||
|
}
|
||||||
|
short_date_to(at, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A date in epoch seconds, without the time. Empty when the time can't be
|
||||||
|
/// read.
|
||||||
|
pub(crate) fn short_date(secs: i64) -> String {
|
||||||
|
match (
|
||||||
|
glib::DateTime::from_unix_local(secs),
|
||||||
|
glib::DateTime::now_local(),
|
||||||
|
) {
|
||||||
|
(Ok(at), Ok(now)) => short_date_to(&at, &now),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`short_date`] for a fixed `now`, so it can be tested.
|
||||||
|
pub(crate) fn short_date_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let pattern = if at.year() == now.year() {
|
||||||
|
// Translators: strftime format for a date in this year, such as "Sep 23".
|
||||||
|
gettext("%b %-d")
|
||||||
|
} else {
|
||||||
|
// Translators: strftime format for a date in another year, such as "Sep 23, 2025".
|
||||||
|
gettext("%b %-d, %Y")
|
||||||
|
};
|
||||||
|
format(at, &pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole moment in epoch seconds, for a tooltip. Empty when the time
|
||||||
|
/// can't be read.
|
||||||
|
pub(crate) fn full(secs: i64) -> String {
|
||||||
|
glib::DateTime::from_unix_local(secs)
|
||||||
|
.map(|at| {
|
||||||
|
// Translators: strftime format for a full date and time, such as
|
||||||
|
// "Wednesday, September 23, 2026, 14:05".
|
||||||
|
format(&at, &gettext("%A, %B %-d, %Y, %H:%M"))
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format(at: &glib::DateTime, pattern: &str) -> String {
|
||||||
|
at.format(pattern)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn at(y: i32, m: i32, d: i32, h: i32, min: i32) -> glib::DateTime {
|
||||||
|
glib::DateTime::from_local(y, m, d, h, min, 0.0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_times_get_coarser_with_age() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 14, 59), &now), "1 min ago");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 14, 55), &now), "5 min ago");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 9, 5), &now), "09:05");
|
||||||
|
assert_eq!(
|
||||||
|
relative_to(&at(2026, 9, 22, 14, 5), &now),
|
||||||
|
"Yesterday, 14:05"
|
||||||
|
);
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 20, 10, 0), &now), "Sunday");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 1, 10, 0), &now), "Sep 1");
|
||||||
|
assert_eq!(relative_to(&at(2024, 3, 5, 10, 0), &now), "Mar 5, 2024");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_moment_ago_or_slightly_ahead_is_just_now() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(relative_to(&now, &now), "Just now");
|
||||||
|
assert_eq!(
|
||||||
|
relative_to(&at(2026, 9, 23, 15, 0), &now.add_seconds(30.0).unwrap()),
|
||||||
|
"Just now"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_dates_show_the_year_only_when_it_differs() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(short_date_to(&at(2026, 1, 2, 8, 0), &now), "Jan 2");
|
||||||
|
assert_eq!(short_date_to(&at(2025, 12, 31, 8, 0), &now), "Dec 31, 2025");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! The window's side of the daemon's event feed ([`Request::Subscribe`]).
|
||||||
|
//!
|
||||||
|
//! The window used to ask the daemon for its status every two seconds and let
|
||||||
|
//! pages re-read themselves on timers. Now one thread follows the feed and hands
|
||||||
|
//! each event to the main loop: a status or transfers snapshot is painted as it
|
||||||
|
//! comes, and a change reloads the page on screen if the page shows it. A
|
||||||
|
//! window nobody can see keeps its changes for later and catches up when it is
|
||||||
|
//! focused again.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use pdfs_core::control::{Event, Feed, follow};
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// How long the conflict list waits after a change before it is read again.
|
||||||
|
/// Listing walks every node the daemon knows, and a sync pass can report
|
||||||
|
/// changes many times a second.
|
||||||
|
const CONFLICTS_DEBOUNCE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// How long the import's state waits after its progress moved before it is
|
||||||
|
/// read again.
|
||||||
|
const TAKEOUT_DEBOUNCE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How old the invitations may be when the window is focused. Another person
|
||||||
|
/// inviting this account sends the daemon no event, so focus is when they are
|
||||||
|
/// looked for.
|
||||||
|
const INVITATIONS_ON_FOCUS: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// What the feed keeps between events.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FeedState {
|
||||||
|
/// What changed while the window was out of sight.
|
||||||
|
deferred: RefCell<BTreeSet<Topic>>,
|
||||||
|
/// Whether a conflict read is already waiting out [`CONFLICTS_DEBOUNCE`].
|
||||||
|
conflicts_pending: Rc<Cell<bool>>,
|
||||||
|
/// Whether an import read is already waiting out [`TAKEOUT_DEBOUNCE`].
|
||||||
|
takeout_pending: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow the daemon for as long as `window` is open.
|
||||||
|
pub(crate) fn follow_daemon(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||||
|
let (tx, rx) = async_channel::bounded(64);
|
||||||
|
let socket = ui.dirs.control_socket();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-feed".into())
|
||||||
|
.spawn(move || follow(&socket, |feed| tx.send_blocking(feed).is_ok()));
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
tracing::error!("cannot start the event feed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = Rc::new(FeedState::default());
|
||||||
|
let ui_feed = ui.clone();
|
||||||
|
let state_feed = state.clone();
|
||||||
|
let window_feed = window.downgrade();
|
||||||
|
let rx_feed = rx.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok(feed) = rx_feed.recv().await {
|
||||||
|
let hidden = window_feed.upgrade().is_none_or(|w| window_hidden(&w));
|
||||||
|
on_feed(&ui_feed, &state_feed, feed, hidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch up as soon as the user comes back to the window.
|
||||||
|
let ui_focus = ui.clone();
|
||||||
|
window.connect_is_active_notify(move |window| {
|
||||||
|
if !window.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let topics: Vec<Topic> = state.deferred.take().into_iter().collect();
|
||||||
|
if !topics.is_empty() {
|
||||||
|
follow_changes(&ui_focus, &state, &topics);
|
||||||
|
}
|
||||||
|
refresh_quota(&ui_focus);
|
||||||
|
refresh_invitations_badge(&ui_focus, INVITATIONS_ON_FOCUS);
|
||||||
|
});
|
||||||
|
// Closing the channel ends the loop above, and the thread with the next
|
||||||
|
// event it tries to hand over.
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
rx.close();
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_feed(ui: &Rc<Ui>, state: &Rc<FeedState>, feed: Feed, hidden: bool) {
|
||||||
|
match feed {
|
||||||
|
// The daemon follows up with its whole state, so there is nothing to
|
||||||
|
// ask for here.
|
||||||
|
Feed::Connected | Feed::Event(Event::Heartbeat) => {}
|
||||||
|
Feed::Event(Event::Status(status)) => {
|
||||||
|
paint_status(ui, *status);
|
||||||
|
// Uploads are what move the quota, and they move the status too.
|
||||||
|
refresh_quota(ui);
|
||||||
|
}
|
||||||
|
// Painted even out of sight: the end of a batch is what sends the
|
||||||
|
// "Sync complete" notification.
|
||||||
|
Feed::Event(Event::Transfers { items, jobs }) => {
|
||||||
|
repaint_transfers(ui, &items, &jobs);
|
||||||
|
follow_takeout(ui, state, &jobs);
|
||||||
|
}
|
||||||
|
Feed::Event(Event::Changed { topics }) if hidden => {
|
||||||
|
state.deferred.borrow_mut().extend(topics);
|
||||||
|
}
|
||||||
|
Feed::Event(Event::Changed { topics }) => follow_changes(ui, state, &topics),
|
||||||
|
// Ask once, which paints "not connected" or "not responding", and clear
|
||||||
|
// progress that can no longer move.
|
||||||
|
Feed::Disconnected => {
|
||||||
|
refresh_status(ui);
|
||||||
|
repaint_transfers(ui, &[], &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload what `topics` says changed: the sidebar badges wherever the user
|
||||||
|
/// is, and the page on screen if it shows one of them.
|
||||||
|
fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
||||||
|
let changed = |topic| topics.contains(&topic);
|
||||||
|
if changed(Topic::Conflicts) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
debounce(&state.conflicts_pending, CONFLICTS_DEBOUNCE, move || {
|
||||||
|
refresh_conflicts(&ui);
|
||||||
|
if activity_visible(&ui) {
|
||||||
|
refresh_activity_conflicts(&ui, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if changed(Topic::Shares) {
|
||||||
|
refresh_invitations_badge(ui, Duration::ZERO);
|
||||||
|
}
|
||||||
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
|
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
||||||
|
// The timeline is left alone: reloading it would throw away the place
|
||||||
|
// the user scrolled to. F5 reloads it on request.
|
||||||
|
Some("gallery")
|
||||||
|
if changed(Topic::Photos)
|
||||||
|
&& ui.gallery.content.visible_child_name().as_deref() == Some("albums") =>
|
||||||
|
{
|
||||||
|
load_albums(ui)
|
||||||
|
}
|
||||||
|
Some("trash") if changed(Topic::Trash) => load_trash(ui),
|
||||||
|
Some("shared") if changed(Topic::Shares) => load_shared(ui),
|
||||||
|
Some("sharedbyme") if changed(Topic::Shares) || changed(Topic::Files) => {
|
||||||
|
load_shared_by_me(ui)
|
||||||
|
}
|
||||||
|
Some("devices") if changed(Topic::Devices) => load_devices(ui),
|
||||||
|
Some("locations") => follow_sync_view(ui, topics),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the import's state again while its progress moves, and once more after
|
||||||
|
/// it stops, which is how a finished import is noticed. Nothing is read while
|
||||||
|
/// no import runs and the page is elsewhere.
|
||||||
|
fn follow_takeout(ui: &Rc<Ui>, state: &FeedState, jobs: &[JobItem]) {
|
||||||
|
let importing = jobs.iter().any(|job| job.title == IMPORT_JOB);
|
||||||
|
if importing
|
||||||
|
|| ui.takeout.running.get()
|
||||||
|
|| ui.stack.visible_child_name().as_deref() == Some("takeout")
|
||||||
|
{
|
||||||
|
let ui = ui.clone();
|
||||||
|
debounce(&state.takeout_pending, TAKEOUT_DEBOUNCE, move || {
|
||||||
|
refresh_takeout(&ui)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` once `delay` has passed, unless a run is already waiting. The run
|
||||||
|
/// sees everything that happened up to it, so nothing is missed by skipping.
|
||||||
|
fn debounce(pending: &Rc<Cell<bool>>, delay: Duration, f: impl FnOnce() + 'static) {
|
||||||
|
if pending.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pending = pending.clone();
|
||||||
|
glib::timeout_add_local_once(delay, move || {
|
||||||
|
pending.set(false);
|
||||||
|
f();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
//! Activity entries as the feed words them.
|
||||||
|
//!
|
||||||
|
//! The daemon logs each entry in plain English for its own log and the CLI:
|
||||||
|
//! a target ("report.pdf", "3 item(s)", "sync conflict in Docs") and a detail
|
||||||
|
//! ("to /Documents", "was notes.txt", "2 uploaded, 1 deleted"). This module
|
||||||
|
//! reads the shapes the daemon writes and says them again in the user's
|
||||||
|
//! language. A shape it does not know, which is mostly an error message,
|
||||||
|
//! shows as the daemon wrote it.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// What a feed row says about one entry.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct Described {
|
||||||
|
pub(crate) title: String,
|
||||||
|
/// Subtitle parts, shown joined by " · ".
|
||||||
|
pub(crate) details: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title and subtitle parts for `entry`.
|
||||||
|
pub(crate) fn describe(entry: &ActivityEntry) -> Described {
|
||||||
|
let title = entry_title(entry);
|
||||||
|
let mut details = Vec::new();
|
||||||
|
if entry.kind == ActivityKind::Conflict
|
||||||
|
&& let Some(stamp) = conflict_stamp(&entry.target)
|
||||||
|
{
|
||||||
|
let when = dates::relative(stamp);
|
||||||
|
if !when.is_empty() {
|
||||||
|
// Translators: {time} is when a conflicting copy was made, such as
|
||||||
|
// "Sep 21" or "5 min ago".
|
||||||
|
details.push(gettext_f("Made {time}", &[("time", &when)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(detail) = detail_label(entry) {
|
||||||
|
details.insert(0, detail);
|
||||||
|
}
|
||||||
|
Described { title, details }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row title: what happened to what, one sentence per case so a
|
||||||
|
/// translator can place the name where their language needs it.
|
||||||
|
fn entry_title(entry: &ActivityEntry) -> String {
|
||||||
|
use ActivityKind::*;
|
||||||
|
let target = entry.target.as_str();
|
||||||
|
let ok = entry.ok;
|
||||||
|
|
||||||
|
// Entries whose target is a count or a fixed phrase rather than a name.
|
||||||
|
match (entry.kind, target) {
|
||||||
|
(Upload, "Google Photos import") => {
|
||||||
|
return if ok {
|
||||||
|
gettext("Imported from Google Photos")
|
||||||
|
} else {
|
||||||
|
gettext("Couldn't import from Google Photos")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(Upload, "bulk upload") => return gettext("Couldn't upload files"),
|
||||||
|
(Upload, "add sync folder") => return gettext("Couldn't add a sync folder"),
|
||||||
|
(Download, "restore folders") => return gettext("Couldn't restore folders"),
|
||||||
|
(Unshare, "shared item") => return gettext("Left a shared item"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if matches!(entry.kind, Restore | DeleteForever | EmptyTrash)
|
||||||
|
&& let Some(n) = leading_count(target, &["item(s)", "item", "items"])
|
||||||
|
{
|
||||||
|
return match entry.kind {
|
||||||
|
Restore => ngettext_f("Restored {n} item", "Restored {n} items", n, &[]),
|
||||||
|
DeleteForever => ngettext_f(
|
||||||
|
"Deleted {n} item forever",
|
||||||
|
"Deleted {n} items forever",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Emptied the Trash ({n} item)",
|
||||||
|
"Emptied the Trash ({n} items)",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if entry.kind == Download
|
||||||
|
&& let Some(rest) = target.strip_prefix("restored ")
|
||||||
|
&& let Some(n) = leading_count(rest, &["folder(s)"])
|
||||||
|
{
|
||||||
|
return ngettext_f("Restored {n} folder", "Restored {n} folders", n, &[]);
|
||||||
|
}
|
||||||
|
if entry.kind == Sync
|
||||||
|
&& let Some(title) = sync_title(target)
|
||||||
|
{
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name) = target_name(target) else {
|
||||||
|
return match entry.kind {
|
||||||
|
Share => gettext("Shared an item"),
|
||||||
|
PublicLink => gettext("Created a link to an item"),
|
||||||
|
Unshare if entry.detail == "link removed" => gettext("Removed a link to an item"),
|
||||||
|
Unshare => gettext("Stopped sharing an item"),
|
||||||
|
_ => activity_title(entry.kind, ok, target),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if entry.kind == Unshare && entry.detail == "link removed" {
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
return gettext_f("Removed the link to {name}", &[("name", &name)]);
|
||||||
|
}
|
||||||
|
if entry.kind == Conflict
|
||||||
|
&& let Some(base) = conflict_base(&name)
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return gettext_f("Conflicting copy of {name}", &[("name", &base)]);
|
||||||
|
}
|
||||||
|
activity_title(entry.kind, ok, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title of a sync pass or mode switch, whose target is a phrase.
|
||||||
|
fn sync_title(target: &str) -> Option<String> {
|
||||||
|
if let Some(name) = target.strip_prefix("sync conflict in ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Conflicting changes in {name}",
|
||||||
|
&[("name", name)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(name) = target.strip_prefix("sync incomplete for ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Some items in {name} didn't sync",
|
||||||
|
&[("name", name)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(name) = target.strip_prefix("sync failed for ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f("Couldn't sync {name}", &[("name", name)]));
|
||||||
|
}
|
||||||
|
if let Some(name) = target.strip_prefix("couldn't switch ") {
|
||||||
|
// Translators: {name} is a synced folder's name.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Couldn't change how {name} syncs",
|
||||||
|
&[("name", name)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(path) = target.strip_suffix(" is now on-demand") {
|
||||||
|
let name = file_name(path);
|
||||||
|
// Translators: {name} is a folder's name.
|
||||||
|
return Some(gettext_f("{name} is now online only", &[("name", name)]));
|
||||||
|
}
|
||||||
|
if let Some(path) = target.strip_suffix(" is mirroring again; downloading") {
|
||||||
|
let name = file_name(path);
|
||||||
|
// Translators: {name} is a folder's name.
|
||||||
|
return Some(gettext_f("{name} is synced again", &[("name", name)]));
|
||||||
|
}
|
||||||
|
if target.starts_with("already ") {
|
||||||
|
return Some(gettext("Sync mode was already set"));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row title for an activity on a named item.
|
||||||
|
pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String {
|
||||||
|
let args = [("name", name)];
|
||||||
|
match (kind, ok) {
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Upload, true) => gettext_f("Uploaded {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Upload, false) => gettext_f("Couldn't upload {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Download, true) => gettext_f("Downloaded {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Download, false) => gettext_f("Couldn't download {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Sync, true) => gettext_f("Synced {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Sync, false) => gettext_f("Couldn't sync {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Rename, true) => gettext_f("Renamed {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Rename, false) => gettext_f("Couldn't rename {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
||||||
|
// Translators: {name} is a folder name.
|
||||||
|
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||||
|
// Translators: {name} is a folder name.
|
||||||
|
(ActivityKind::CreateFolder, false) => gettext_f("Couldn't create folder {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Trash, true) => gettext_f("Moved {name} to the Trash", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Trash, false) => gettext_f("Couldn't move {name} to the Trash", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Restore, true) => gettext_f("Restored {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Restore, false) => gettext_f("Couldn't restore {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::DeleteForever, true) => gettext_f("Deleted {name} forever", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::DeleteForever, false) => gettext_f("Couldn't delete {name}", &args),
|
||||||
|
(ActivityKind::EmptyTrash, true) => gettext("Emptied the Trash"),
|
||||||
|
(ActivityKind::EmptyTrash, false) => gettext("Couldn't empty the Trash"),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Share, true) => gettext_f("Shared {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Share, false) => gettext_f("Couldn't share {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::PublicLink, true) => gettext_f("Created a link to {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::PublicLink, false) => gettext_f("Couldn't create a link to {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Unshare, true) => gettext_f("Stopped sharing {name}", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
(ActivityKind::Unshare, false) => gettext_f("Couldn't stop sharing {name}", &args),
|
||||||
|
// Translators: {name} is the name of the conflicting copy.
|
||||||
|
(ActivityKind::Conflict, _) => gettext_f("Conflicting copy {name}", &args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subtitle's first part: the daemon's detail in the user's language, or
|
||||||
|
/// `None` when it only repeats the title.
|
||||||
|
fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
||||||
|
let detail = entry.detail.trim();
|
||||||
|
let name = |s: &str| file_name(s).to_string();
|
||||||
|
match detail {
|
||||||
|
""
|
||||||
|
| "uploaded"
|
||||||
|
| "created"
|
||||||
|
| "trashed"
|
||||||
|
| "trashed from the mount"
|
||||||
|
| "stopped sharing"
|
||||||
|
| "left"
|
||||||
|
| "link created"
|
||||||
|
| "link removed"
|
||||||
|
| "access removed"
|
||||||
|
| "removed" => {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
"removed on Drive" => return Some(gettext("Removed on Proton Drive")),
|
||||||
|
"removed locally" => return Some(gettext("Removed on this computer")),
|
||||||
|
"replaced by a rename from the mount" => {
|
||||||
|
return Some(gettext("Replaced by a renamed file"));
|
||||||
|
}
|
||||||
|
"destination folder no longer exists; the file was left in place" => {
|
||||||
|
return Some(gettext(
|
||||||
|
"The destination folder no longer exists, so the file stayed where it was",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if entry.kind == ActivityKind::Upload
|
||||||
|
&& entry.target == "Google Photos import"
|
||||||
|
&& entry.ok
|
||||||
|
&& let Some(summary) = import_summary(detail)
|
||||||
|
{
|
||||||
|
return Some(summary);
|
||||||
|
}
|
||||||
|
if entry.kind == ActivityKind::Sync
|
||||||
|
&& let Some(summary) = sync_summary(detail)
|
||||||
|
{
|
||||||
|
return Some(summary);
|
||||||
|
}
|
||||||
|
if let Some(dest) = detail.strip_prefix("to ") {
|
||||||
|
// Translators: {name} is the folder an item was moved into.
|
||||||
|
return Some(gettext_f("To {name}", &[("name", &folder_name(dest))]));
|
||||||
|
}
|
||||||
|
if let Some(old) = detail.strip_prefix("was ") {
|
||||||
|
// Translators: {name} is an item's previous name.
|
||||||
|
return Some(gettext_f("Previously {name}", &[("name", &name(old))]));
|
||||||
|
}
|
||||||
|
if detail.starts_with("restored version ") {
|
||||||
|
return Some(gettext("Restored an earlier version"));
|
||||||
|
}
|
||||||
|
if detail.starts_with("deleted version ") {
|
||||||
|
return Some(gettext("Deleted an earlier version"));
|
||||||
|
}
|
||||||
|
if let Some(rest) = detail.strip_prefix("saved version ")
|
||||||
|
&& let Some((_, path)) = rest.split_once(" to ")
|
||||||
|
{
|
||||||
|
// Translators: {path} is where an earlier version was saved.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Saved an earlier version to {path}",
|
||||||
|
&[("path", &tilde_path(path))],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(copy) = detail.strip_prefix("kept both; the copy is now ") {
|
||||||
|
// Translators: {name} is the new name of the conflicting copy.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Kept both; the copy is now {name}",
|
||||||
|
&[("name", copy)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail
|
||||||
|
.strip_prefix("kept ")
|
||||||
|
.and_then(|rest| rest.strip_suffix("; the copy is in Trash"))
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file that was kept.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Kept {name}; the copy is in the Trash",
|
||||||
|
&[("name", base)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if detail.starts_with("kept the conflict copy ") {
|
||||||
|
return Some(gettext("Kept the conflicting copy"));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail.strip_prefix("kept ") {
|
||||||
|
// Translators: {name} is the file that was kept.
|
||||||
|
return Some(gettext_f("Kept {name}", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail.strip_prefix("auto-removed duplicate of ") {
|
||||||
|
// Translators: {name} is the file the removed copy was identical to.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Same as {name}, so it was removed",
|
||||||
|
&[("name", base)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) = detail.strip_prefix("destination already had that name; moved as ") {
|
||||||
|
// Translators: {name} is the name the item was given instead.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"The name was taken, so it was moved as {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) = detail.strip_prefix("name was taken remotely; renamed to ") {
|
||||||
|
// Translators: {name} is the name the item was given instead.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"The name was taken on Proton Drive, so it was renamed to {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) = detail.strip_prefix("name was taken remotely; created as ") {
|
||||||
|
// Translators: {name} is the name the item was given instead.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"The name was taken on Proton Drive, so it was created as {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(alt) =
|
||||||
|
detail.strip_prefix("its folder was trashed remotely; created in the root as ")
|
||||||
|
{
|
||||||
|
// Translators: {name} is the name the item was given.
|
||||||
|
return Some(gettext_f(
|
||||||
|
"Its folder was moved to the Trash on Proton Drive, so it was created at the top as {name}",
|
||||||
|
&[("name", alt)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail.strip_prefix("differs from ") {
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail
|
||||||
|
.strip_prefix("no original ")
|
||||||
|
.and_then(|rest| rest.strip_suffix(" to reconcile against"))
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return Some(gettext_f("{name} no longer exists", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some(base) = detail
|
||||||
|
.strip_prefix("identical to ")
|
||||||
|
.and_then(|rest| rest.split_once(';').map(|(base, _)| base))
|
||||||
|
{
|
||||||
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
|
return Some(gettext_f("Same as {name}", &[("name", base)]));
|
||||||
|
}
|
||||||
|
if let Some((n, role)) = detail
|
||||||
|
.split_once(" recipient(s) as ")
|
||||||
|
.and_then(|(n, role)| Some((n.parse::<u64>().ok()?, role)))
|
||||||
|
{
|
||||||
|
return Some(match role {
|
||||||
|
"viewer" => ngettext_f("{n} person can view", "{n} people can view", n, &[]),
|
||||||
|
"editor" => ngettext_f("{n} person can edit", "{n} people can edit", n, &[]),
|
||||||
|
_ => ngettext_f("{n} person invited", "{n} people invited", n, &[]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(n) = leading_count(detail, &["file(s) kept as conflict copies"]) {
|
||||||
|
return Some(ngettext_f(
|
||||||
|
"{n} file kept as a conflicting copy",
|
||||||
|
"{n} files kept as conflicting copies",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(n) = leading_count(detail, &["item(s) failed; will retry"]) {
|
||||||
|
return Some(ngettext_f(
|
||||||
|
"{n} item failed and will be tried again",
|
||||||
|
"{n} items failed and will be tried again",
|
||||||
|
n,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(capitalize(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
||||||
|
/// list of counts. `None` when a part is not one the daemon writes.
|
||||||
|
fn sync_summary(detail: &str) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for part in detail.split(", ") {
|
||||||
|
let (n, label) = part.split_once(' ')?;
|
||||||
|
let n = n.parse::<u64>().ok()?;
|
||||||
|
parts.push(match label {
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"downloaded" => ngettext_f("{n} downloaded", "{n} downloaded", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"folder(s) created" => ngettext_f("{n} folder created", "{n} folders created", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"deleted" => ngettext_f("{n} deleted", "{n} deleted", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"conflicted" => ngettext_f("{n} in conflict", "{n} in conflict", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass; the files are
|
||||||
|
// still open in another app.
|
||||||
|
"deferred (open for write)" => ngettext_f("{n} still open", "{n} still open", n, &[]),
|
||||||
|
// Translators: part of a list summing up a sync pass.
|
||||||
|
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
||||||
|
_ => return None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(parts.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Google Photos import summary such as "40 uploaded · 2 already there".
|
||||||
|
fn import_summary(detail: &str) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for part in detail.split(" · ") {
|
||||||
|
if part == "cancelled" {
|
||||||
|
parts.push(gettext("Cancelled"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (n, label) = part.split_once(' ')?;
|
||||||
|
let n = n.parse::<u64>().ok()?;
|
||||||
|
parts.push(match label {
|
||||||
|
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
||||||
|
// Translators: photos an import skipped because they were already
|
||||||
|
// in Proton Drive.
|
||||||
|
"already there" => ngettext_f("{n} already there", "{n} already there", n, &[]),
|
||||||
|
"albums" => ngettext_f("{n} album", "{n} albums", n, &[]),
|
||||||
|
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
||||||
|
_ => return None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(parts.join(" · "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number in front of `text` when the rest is one of `units`, as in
|
||||||
|
/// "3 item(s)".
|
||||||
|
fn leading_count(text: &str, units: &[&str]) -> Option<u64> {
|
||||||
|
let (n, unit) = text.split_once(' ')?;
|
||||||
|
units.contains(&unit).then_some(())?;
|
||||||
|
n.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name to show for a target: the last part of a path, or `None` when
|
||||||
|
/// the daemon logged a node id because it had no path.
|
||||||
|
fn target_name(target: &str) -> Option<String> {
|
||||||
|
let looks_like_uid =
|
||||||
|
target.len() >= 40 && target.matches('~').count() == 1 && !target.contains(['/', ' ', '.']);
|
||||||
|
if target.is_empty() || looks_like_uid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(file_name(target).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder a destination path names, with the root read as "My files".
|
||||||
|
fn folder_name(dest: &str) -> String {
|
||||||
|
let name = file_name(dest.trim_end_matches('/'));
|
||||||
|
if name.is_empty() {
|
||||||
|
gettext("My files")
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file a `name (sync-conflict <stamp>).ext` copy was made from.
|
||||||
|
pub(crate) fn conflict_base(name: &str) -> Option<String> {
|
||||||
|
let (start, close) = conflict_marker(name)?;
|
||||||
|
Some(format!("{}{}", &name[..start], &name[close + 1..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a conflicting copy was made, from the stamp in its name.
|
||||||
|
fn conflict_stamp(name: &str) -> Option<i64> {
|
||||||
|
let (start, close) = conflict_marker(name)?;
|
||||||
|
let inner = &name[start + MARKER.len()..close];
|
||||||
|
inner.split('-').next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER: &str = " (sync-conflict ";
|
||||||
|
|
||||||
|
/// Where the ` (sync-conflict <stamp>[-<n>])` marker starts and where its
|
||||||
|
/// closing parenthesis is. The same shape the daemon reads back.
|
||||||
|
fn conflict_marker(name: &str) -> Option<(usize, usize)> {
|
||||||
|
let start = name.find(MARKER)?;
|
||||||
|
let after = start + MARKER.len();
|
||||||
|
let close = after + name[after..].find(')')?;
|
||||||
|
let inner = &name[after..close];
|
||||||
|
let mut parts = inner.splitn(2, '-');
|
||||||
|
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||||
|
if !digits(parts.next()?) || parts.next().is_some_and(|s| !digits(s)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((start, close))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
||||||
|
ActivityEntry {
|
||||||
|
time: 0,
|
||||||
|
kind,
|
||||||
|
target: target.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
ok,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn details_that_repeat_the_title_are_dropped() {
|
||||||
|
let d = describe(&entry(ActivityKind::Upload, "a.txt", "uploaded", true));
|
||||||
|
assert_eq!(d.title, "Uploaded a.txt");
|
||||||
|
assert!(d.details.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paths_and_counts_read_as_names_and_numbers() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Move,
|
||||||
|
"/mnt/a.txt",
|
||||||
|
"to /mnt/Docs",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Moved a.txt");
|
||||||
|
assert_eq!(d.details, vec!["To Docs"]);
|
||||||
|
let d = describe(&entry(ActivityKind::Restore, "3 item(s)", "", true));
|
||||||
|
assert_eq!(d.title, "Restored 3 items");
|
||||||
|
let d = describe(&entry(ActivityKind::EmptyTrash, "1 item", "", true));
|
||||||
|
assert_eq!(d.title, "Emptied the Trash (1 item)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_say_what_could_not_happen() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"a.txt",
|
||||||
|
"quota exceeded",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Couldn't upload a.txt");
|
||||||
|
assert_eq!(d.details, vec!["Quota exceeded"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_passes_are_summed_up() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Sync,
|
||||||
|
"Docs",
|
||||||
|
"2 uploaded, 1 folder(s) created",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Synced Docs");
|
||||||
|
assert_eq!(d.details, vec!["2 uploaded, 1 folder created"]);
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Sync,
|
||||||
|
"sync incomplete for Docs",
|
||||||
|
"2 item(s) failed; will retry",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Some items in Docs didn't sync");
|
||||||
|
assert_eq!(d.details, vec!["2 items failed and will be tried again"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_conflicting_copy_names_its_original() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
"notes (sync-conflict 1700000000).txt",
|
||||||
|
"differs from notes.txt",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Conflicting copy of notes.txt");
|
||||||
|
assert_eq!(d.details[0], "Differs from notes.txt");
|
||||||
|
assert_eq!(d.details.len(), 2);
|
||||||
|
assert_eq!(conflict_base("a (sync-conflict 12-2)"), Some("a".into()));
|
||||||
|
assert_eq!(conflict_base("a (sync-conflict x).txt"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_ids_are_not_shown_as_names() {
|
||||||
|
let uid = format!("{}~{}", "A".repeat(30), "B".repeat(30));
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Share,
|
||||||
|
&uid,
|
||||||
|
"1 recipient(s) as viewer",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(d.title, "Shared an item");
|
||||||
|
assert_eq!(d.details, vec!["1 person can view"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_details_show_as_written() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Download,
|
||||||
|
"a.txt",
|
||||||
|
"network timeout",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(d.details, vec!["Network timeout"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,23 +16,35 @@ const COVER_EDGE: i32 = 200;
|
|||||||
|
|
||||||
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||||
close_album(ui);
|
show_album_grid(ui);
|
||||||
ui.gallery.content.set_visible_child_name("albums");
|
|
||||||
load_albums(ui);
|
load_albums(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch to the album grid as it was last filled, keeping its scroll
|
||||||
|
/// 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");
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the daemon for the album listing and rebuild the grid.
|
/// Ask the daemon for the album listing and rebuild the grid.
|
||||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||||
if ui.gallery.albums_loading.get() {
|
if ui.gallery.albums_loading.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.albums_loading.set(true);
|
ui.gallery.albums_loading.set(true);
|
||||||
albums_status(
|
// A grid already on screen stays there while it refreshes, rather than
|
||||||
ui,
|
// blinking to a loading page and losing its scroll position.
|
||||||
"view-grid-symbolic",
|
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
||||||
"Loading albums…",
|
albums_status(
|
||||||
"Reading your Proton Drive albums.",
|
ui,
|
||||||
);
|
"view-grid-symbolic",
|
||||||
|
&gettext("Loading albums…"),
|
||||||
|
&gettext("Reading your Proton Drive albums."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
@@ -45,33 +57,33 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
})) => albums_status(
|
})) => albums_status(
|
||||||
&ui,
|
&ui,
|
||||||
"image-missing-symbolic",
|
"image-missing-symbolic",
|
||||||
"No photo library",
|
&gettext("No photo library"),
|
||||||
"This Proton account doesn't have Photos enabled.",
|
&gettext("This Proton account doesn't have Photos enabled."),
|
||||||
),
|
),
|
||||||
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||||
&ui,
|
&ui,
|
||||||
"view-grid-symbolic",
|
"view-grid-symbolic",
|
||||||
"No albums",
|
&gettext("No albums"),
|
||||||
"Albums you create in Proton Photos appear here.",
|
&gettext("Create one with New Album, or in Proton Photos."),
|
||||||
),
|
),
|
||||||
Ok(Ok(Response::Albums { items, .. })) => {
|
Ok(Ok(Response::Albums { items, .. })) => {
|
||||||
fill_albums(&ui, &items);
|
fill_albums(&ui, &items);
|
||||||
ui.gallery.albums_stack.set_visible_child_name("grid");
|
ui.gallery.albums_stack.set_visible_child_name("grid");
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
|
||||||
albums_status(
|
albums_status(
|
||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"Couldn't load albums",
|
&gettext("Couldn't load albums"),
|
||||||
&message,
|
&message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
||||||
&ui,
|
&ui,
|
||||||
"network-offline-symbolic",
|
"network-offline-symbolic",
|
||||||
"Not connected",
|
&gettext("Not connected"),
|
||||||
"The Proton Drive mount service didn't respond.",
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -80,15 +92,34 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||||
/// (newest activity first).
|
/// (newest activity first).
|
||||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||||
|
let scroll = ui
|
||||||
|
.gallery
|
||||||
|
.albums
|
||||||
|
.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.albums.first_child() {
|
while let Some(child) = ui.gallery.albums.first_child() {
|
||||||
ui.gallery.albums.remove(&child);
|
ui.gallery.albums.remove(&child);
|
||||||
}
|
}
|
||||||
|
*ui.gallery.album_list.borrow_mut() = albums.to_vec();
|
||||||
|
if ui.gallery.album.borrow().is_none() {
|
||||||
|
ui.gallery.title.set_subtitle(&ngettext_f(
|
||||||
|
"{n} album",
|
||||||
|
"{n} albums",
|
||||||
|
albums.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
for album in albums {
|
for album in albums {
|
||||||
ui.gallery.albums.append(&album_card(ui, album));
|
ui.gallery.albums.append(&album_card(ui, album));
|
||||||
}
|
}
|
||||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||||
// batch that fills them is the gallery's own.
|
// batch that fills them is the gallery's own.
|
||||||
schedule_thumbs(ui);
|
schedule_thumbs(ui);
|
||||||
|
// A refresh rebuilds the cards; put the view back where the user was.
|
||||||
|
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||||
|
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One album as a clickable card: its cover, its name, and how many photos it
|
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||||
@@ -150,28 +181,413 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
|||||||
want_cover(ui, uid, &picture);
|
want_cover(ui, uid, &picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context = gtk4::GestureClick::builder().button(3).build();
|
||||||
|
let ui_context = ui.clone();
|
||||||
|
let menu_album = album.clone();
|
||||||
|
context.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||||
|
if let Some(anchor) = gesture.widget() {
|
||||||
|
show_album_menu(&ui_context, &menu_album, &anchor, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
button.add_controller(context);
|
||||||
|
|
||||||
let ui_open = ui.clone();
|
let ui_open = ui.clone();
|
||||||
let album = album.clone();
|
let album = album.clone();
|
||||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||||
button
|
button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The album card's menu. Albums shared with us belong to someone else, so
|
||||||
|
/// they only open.
|
||||||
|
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(&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(&gettext("Rename…"), move || {
|
||||||
|
prompt_rename_album(&ui_c, &album_c)
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||||
|
menu.item(&gettext("Delete Album…"), move || {
|
||||||
|
confirm_delete_album(&ui_c, &album_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.popup_at(anchor, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for an album name. `on_name` gets the trimmed name; Create stays off
|
||||||
|
/// while the entry is blank.
|
||||||
|
fn prompt_album_name(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
heading: &str,
|
||||||
|
action: &str,
|
||||||
|
current: &str,
|
||||||
|
on_name: impl Fn(String) + 'static,
|
||||||
|
) {
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let row = adw::EntryRow::builder()
|
||||||
|
.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", &gettext("Cancel"));
|
||||||
|
dialog.add_response("ok", action);
|
||||||
|
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_response_enabled("ok", !current.trim().is_empty());
|
||||||
|
dialog.set_default_response(Some("ok"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let dialog_typed = dialog.clone();
|
||||||
|
row.connect_changed(move |row| {
|
||||||
|
dialog_typed.set_response_enabled("ok", !row.text().trim().is_empty());
|
||||||
|
});
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
let name = row.text().trim().to_string();
|
||||||
|
if resp == "ok" && !name.is_empty() {
|
||||||
|
on_name(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
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();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::AlbumCreated { uid })) => {
|
||||||
|
if photos.is_empty() {
|
||||||
|
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, &gettext("Couldn't create the album"), &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't create the album"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
gettext("Album renamed"),
|
||||||
|
gettext("Couldn't rename the album"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
|
let Some(win) = ui_window(ui) else { return };
|
||||||
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
|
confirm_destructive(
|
||||||
|
&win,
|
||||||
|
&gettext("Delete Album?"),
|
||||||
|
// Translators: {name} is the album's name.
|
||||||
|
&gettext_f(
|
||||||
|
"“{name}” is deleted. Its photos stay in your timeline.",
|
||||||
|
&[("name", &album.name)],
|
||||||
|
),
|
||||||
|
&gettext("Delete"),
|
||||||
|
move || {
|
||||||
|
run_album_request(
|
||||||
|
&ui_c,
|
||||||
|
Request::DeleteAlbum {
|
||||||
|
uid: uid.clone(),
|
||||||
|
delete_photos: false,
|
||||||
|
},
|
||||||
|
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: String, failed: String) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||||
|
let ui = ui.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
toast(&ui, &done);
|
||||||
|
reload_album_grid(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refill the album grid if it is on screen; otherwise it reloads when shown.
|
||||||
|
fn reload_album_grid(ui: &Rc<Ui>) {
|
||||||
|
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||||
|
load_albums(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer our own albums to add `photos` to, plus a new one.
|
||||||
|
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let albums: Vec<AlbumInfo> = match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Albums { items, .. })) => {
|
||||||
|
items.into_iter().filter(|album| !album.shared).collect()
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't load albums"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if albums.is_empty() {
|
||||||
|
prompt_new_album(&ui, photos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = gtk4::ListBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.build();
|
||||||
|
list.add_css_class("boxed-list");
|
||||||
|
for album in &albums {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(glib::markup_escape_text(&album.name))
|
||||||
|
.subtitle(album_subtitle(album))
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
list.append(&row);
|
||||||
|
}
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.max_content_height(360)
|
||||||
|
.child(&list)
|
||||||
|
.build();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Add to Album"))
|
||||||
|
.body(match photos.len() {
|
||||||
|
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", &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());
|
||||||
|
list.connect_row_activated(move |_, row| {
|
||||||
|
if let Some(album) = albums.get(row.index().max(0) as usize) {
|
||||||
|
dialog_c.close();
|
||||||
|
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
dialog.connect_response(Some("new"), move |_, _| {
|
||||||
|
prompt_new_album(&ui_c, photos.clone())
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(&ui).as_ref());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::AddToAlbum { uid: album, photos },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||||
|
if let Some(failure) = failed.first() {
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Some photos couldn't be added"),
|
||||||
|
&failure.message,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
&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, &gettext("Couldn't add to the album"), &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't add to the album"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of the open album. They leave the view at once and come
|
||||||
|
/// back if the server refuses; the timeline keeps them either way.
|
||||||
|
pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<String>) {
|
||||||
|
let removed = remove_photos(ui, &photos);
|
||||||
|
set_selection_mode(ui, false);
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::RemoveFromAlbum {
|
||||||
|
uid: album.uid.clone(),
|
||||||
|
photos,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match reply {
|
||||||
|
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||||
|
if !failed.is_empty() {
|
||||||
|
let kept: Vec<PhotoItem> = removed
|
||||||
|
.iter()
|
||||||
|
.filter(|photo| failed.iter().any(|f| f.uid == photo.uid))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
restore_photos(&ui, kept);
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Some photos couldn't be removed from the album"),
|
||||||
|
&failed[0].message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if changed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The open album's count, as the header shows it.
|
||||||
|
let mut open = ui.gallery.album.borrow_mut();
|
||||||
|
if let Some(album) = open.as_mut() {
|
||||||
|
album.photo_count = album.photo_count.saturating_sub(changed.len());
|
||||||
|
ui.gallery.title.set_subtitle(&album_subtitle(album));
|
||||||
|
}
|
||||||
|
drop(open);
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
&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,
|
||||||
|
&gettext("Couldn't remove from the album"),
|
||||||
|
&message,
|
||||||
|
kind,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
restore_photos(&ui, removed);
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't remove from the album"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// "12 photos", plus where the album came from when it isn't ours.
|
/// "12 photos", plus where the album came from when it isn't ours.
|
||||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||||
let photos = match album.photo_count {
|
let count = album.photo_count as u64;
|
||||||
1 => "1 photo".to_string(),
|
|
||||||
n => format!("{n} photos"),
|
|
||||||
};
|
|
||||||
if album.shared {
|
if album.shared {
|
||||||
format!("{photos} · shared with me")
|
ngettext_f(
|
||||||
|
"{n} photo · shared with me",
|
||||||
|
"{n} photos · shared with me",
|
||||||
|
count,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
photos
|
ngettext_f("{n} photo", "{n} photos", count, &[])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||||
/// same texture cache, same decode pacing as a timeline tile.
|
/// 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) {
|
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||||
picture.set_paintable(Some(texture));
|
picture.set_paintable(Some(texture));
|
||||||
return;
|
return;
|
||||||
@@ -192,17 +608,17 @@ fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
|||||||
/// Open one album in the gallery: the timeline view, paged from the album
|
/// Open one album in the gallery: the timeline view, paged from the album
|
||||||
/// instead, with the filters that don't apply to it out of the way.
|
/// instead, with the filters that don't apply to it out of the way.
|
||||||
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||||
ui.gallery.title.set_label(&album.name);
|
ui.gallery.title.set_title(&album.name);
|
||||||
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
ui.gallery.title.set_subtitle(&album_subtitle(&album));
|
||||||
ui.gallery.subtitle.set_visible(true);
|
|
||||||
*ui.gallery.album.borrow_mut() = Some(album);
|
*ui.gallery.album.borrow_mut() = Some(album);
|
||||||
|
ui.gallery.timeline_stale.set(true);
|
||||||
|
|
||||||
// An album page carries no kind or date filter, and Upload targets the
|
// An album page carries no kind or date filter, and Upload targets the
|
||||||
// timeline rather than an album — hide those rather than offer controls that
|
// timeline rather than an album — hide those rather than offer controls that
|
||||||
// would silently do something else. The Photos/Albums switcher goes too: an
|
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||||
// album is a level below it, and back is the way out.
|
// album is a level below it, and back is the way out.
|
||||||
ui.gallery.filters.set_visible(false);
|
ui.gallery.filters.set_visible(false);
|
||||||
ui.gallery.view_switch.set_visible(false);
|
ui.gallery.view_switch.widget().set_visible(false);
|
||||||
ui.gallery.upload.set_visible(false);
|
ui.gallery.upload.set_visible(false);
|
||||||
ui.gallery.back.set_visible(true);
|
ui.gallery.back.set_visible(true);
|
||||||
// A stale kind/date filter would otherwise be sent with the album request.
|
// A stale kind/date filter would otherwise be sent with the album request.
|
||||||
@@ -219,9 +635,8 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
|||||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_label("Gallery");
|
ui.gallery.title.set_title(&gettext("Photos"));
|
||||||
ui.gallery.filters.set_visible(true);
|
ui.gallery.view_switch.widget().set_visible(true);
|
||||||
ui.gallery.view_switch.set_visible(true);
|
|
||||||
ui.gallery.upload.set_visible(true);
|
ui.gallery.upload.set_visible(true);
|
||||||
ui.gallery.back.set_visible(false);
|
ui.gallery.back.set_visible(false);
|
||||||
}
|
}
|
||||||
@@ -238,28 +653,46 @@ fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
|||||||
/// whole content area; back leaves an open album for the grid it came from.
|
/// whole content area; back leaves an open album for the grid it came from.
|
||||||
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||||
let ui_albums = ui.clone();
|
let ui_albums = ui.clone();
|
||||||
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
|
ui.gallery.view_switch.connect_changed(move |view| {
|
||||||
if btn.is_active() {
|
if view == VIEW_ALBUMS {
|
||||||
show_albums(&ui_albums);
|
show_albums(&ui_albums);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the button being switched *on* acts: the group fires `toggled` for
|
|
||||||
// the one going off too, and acting on both would load twice.
|
|
||||||
let ui_photos = ui.clone();
|
let ui_photos = ui.clone();
|
||||||
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
|
ui.gallery.view_switch.connect_changed(move |view| {
|
||||||
if !btn.is_active() {
|
if view != VIEW_TIMELINE {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The timeline is reloaded rather than restored, because an open album
|
|
||||||
// left the model holding its own photos.
|
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
|
close_place(&ui_photos);
|
||||||
|
ui_photos.gallery.filters.set_visible(true);
|
||||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||||
load_gallery(&ui_photos, false);
|
// The timeline comes back as it was left unless an album took over the
|
||||||
|
// model since; then it has to be reloaded.
|
||||||
|
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
||||||
|
{
|
||||||
|
load_gallery(&ui_photos, false);
|
||||||
|
} else {
|
||||||
|
update_gallery_subtitle(&ui_photos);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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();
|
let ui_back = ui.clone();
|
||||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||||
show_albums(&ui_back);
|
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(&ngettext_f(
|
||||||
|
"{n} album",
|
||||||
|
"{n} albums",
|
||||||
|
count as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
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)>>;
|
||||||
|
|
||||||
|
/// How often the dialog asks again while the daemon hashes thumbnails.
|
||||||
|
const HASHING_POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// What the finder looks for.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Finder {
|
||||||
|
/// Byte-identical copies, going by the server's content hash.
|
||||||
|
Exact,
|
||||||
|
/// Shots that look alike, going by their thumbnails.
|
||||||
|
Similar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialog's parts that a search fills in.
|
||||||
|
struct FinderView {
|
||||||
|
ui: Rc<Ui>,
|
||||||
|
status: adw::StatusPage,
|
||||||
|
progress: gtk4::ProgressBar,
|
||||||
|
note: gtk4::Label,
|
||||||
|
stack: gtk4::Stack,
|
||||||
|
trash: gtk4::Button,
|
||||||
|
toolbar: adw::ToolbarView,
|
||||||
|
/// Bumped by every new search and when the dialog closes, so a reply or a
|
||||||
|
/// poll for an older one is dropped.
|
||||||
|
generation: Cell<u32>,
|
||||||
|
/// The Trash button's handler for the review on show.
|
||||||
|
clicked: RefCell<Option<glib::SignalHandlerId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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. The Similar switch
|
||||||
|
/// looks for shots that look alike instead.
|
||||||
|
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||||
|
let progress = gtk4::ProgressBar::builder()
|
||||||
|
.show_text(true)
|
||||||
|
.width_request(280)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let note = gtk4::Label::builder()
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk4::Justification::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("dim-label");
|
||||||
|
let status_extra = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
status_extra.append(&progress);
|
||||||
|
status_extra.append(¬e);
|
||||||
|
let status = adw::StatusPage::builder()
|
||||||
|
.icon_name("edit-copy-symbolic")
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&status_extra)
|
||||||
|
.build();
|
||||||
|
status.add_css_class("compact");
|
||||||
|
|
||||||
|
let stack = gtk4::Stack::new();
|
||||||
|
stack.add_named(&status, Some("status"));
|
||||||
|
|
||||||
|
let trash = gtk4::Button::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// Exact first, then Similar: the index is the finder.
|
||||||
|
let switch = ToggleGroup::new(&[
|
||||||
|
(
|
||||||
|
&pgettext("duplicates", "Exact"),
|
||||||
|
Some(&gettext("Photos stored more than once")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&pgettext("duplicates", "Similar"),
|
||||||
|
Some(&gettext("Photos that look alike")),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(&switch.widget()));
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
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 view = Rc::new(FinderView {
|
||||||
|
ui: ui.clone(),
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
note,
|
||||||
|
stack,
|
||||||
|
trash,
|
||||||
|
toolbar,
|
||||||
|
generation: Cell::new(0),
|
||||||
|
clicked: RefCell::new(None),
|
||||||
|
});
|
||||||
|
let view_closed = view.clone();
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
view_closed
|
||||||
|
.generation
|
||||||
|
.set(view_closed.generation.get().wrapping_add(1));
|
||||||
|
});
|
||||||
|
let view_switch = view.clone();
|
||||||
|
switch.connect_changed(move |index| {
|
||||||
|
let finder = if index == 1 {
|
||||||
|
Finder::Similar
|
||||||
|
} else {
|
||||||
|
Finder::Exact
|
||||||
|
};
|
||||||
|
search(&view_switch, finder);
|
||||||
|
});
|
||||||
|
search(&view, Finder::Exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what the dialog shows and look again, for `finder`.
|
||||||
|
fn search(view: &Rc<FinderView>, finder: Finder) {
|
||||||
|
let generation = view.generation.get().wrapping_add(1);
|
||||||
|
view.generation.set(generation);
|
||||||
|
if let Some(review) = view.stack.child_by_name("review") {
|
||||||
|
view.stack.remove(&review);
|
||||||
|
}
|
||||||
|
if let Some(handler) = view.clicked.take() {
|
||||||
|
view.trash.disconnect(handler);
|
||||||
|
}
|
||||||
|
view.toolbar.set_reveal_bottom_bars(false);
|
||||||
|
view.status.set_icon_name(Some("edit-copy-symbolic"));
|
||||||
|
view.status.set_title(&match finder {
|
||||||
|
Finder::Exact => gettext("Looking for duplicates…"),
|
||||||
|
Finder::Similar => gettext("Looking for similar photos…"),
|
||||||
|
});
|
||||||
|
view.status.set_description(None);
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
view.note.set_visible(false);
|
||||||
|
view.stack.set_visible_child_name("status");
|
||||||
|
ask(view, finder, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for `finder`'s sets and show them, or, while it is still
|
||||||
|
/// hashing thumbnails, how far it has got — and ask again in a moment.
|
||||||
|
fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
|
||||||
|
let request = match finder {
|
||||||
|
Finder::Exact => Request::PhotoDuplicates,
|
||||||
|
Finder::Similar => Request::PhotoSimilar,
|
||||||
|
};
|
||||||
|
let rx = spawn_request(view.ui.dirs.control_socket(), request);
|
||||||
|
let view = view.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
if view.generation.get() != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = match finder {
|
||||||
|
Finder::Exact => gettext("Couldn't look for duplicates"),
|
||||||
|
Finder::Similar => gettext("Couldn't look for similar photos"),
|
||||||
|
};
|
||||||
|
let (sets, uncompared) = match reply {
|
||||||
|
Ok(Ok(Response::PhotoDuplicates { sets })) => (sets, 0),
|
||||||
|
Ok(Ok(Response::PhotoSimilar {
|
||||||
|
hashing: Some(MappingProgress { done, total }),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
view.status.set_title(&gettext("Comparing photos…"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"Photos are compared by their thumbnails. The first time takes a while.",
|
||||||
|
)));
|
||||||
|
view.progress.set_fraction(if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
done as f64 / total as f64
|
||||||
|
});
|
||||||
|
view.progress.set_text(Some(&gettext_f(
|
||||||
|
// Translators: {done} and {total} are numbers of photos.
|
||||||
|
"{done} of {total}",
|
||||||
|
&[("done", &thousands(done)), ("total", &thousands(total))],
|
||||||
|
)));
|
||||||
|
view.progress.set_visible(true);
|
||||||
|
glib::timeout_add_local_once(HASHING_POLL, move || {
|
||||||
|
if view.generation.get() == generation {
|
||||||
|
ask(&view, finder, generation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing: None,
|
||||||
|
uncompared,
|
||||||
|
})) => (sets, uncompared),
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
|
view.status.set_title(&failed);
|
||||||
|
view.status.set_description(Some(&message));
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
|
view.status.set_title(&failed);
|
||||||
|
view.status
|
||||||
|
.set_description(Some(&gettext("The Proton Drive service didn't respond.")));
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
if sets.is_empty() {
|
||||||
|
view.status.set_icon_name(Some("pdfs-synced-symbolic"));
|
||||||
|
match finder {
|
||||||
|
Finder::Exact => {
|
||||||
|
view.status.set_title(&gettext("No duplicates"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"Every photo in your library is stored once.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Finder::Similar => {
|
||||||
|
view.status.set_title(&gettext("No similar photos"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"No two photos in your library look alike.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uncompared > 0 {
|
||||||
|
view.note.set_label(&uncompared_note(uncompared));
|
||||||
|
view.note.set_visible(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (review, handler) = duplicate_review(&view.ui, sets, &view.trash, finder, uncompared);
|
||||||
|
view.clicked.replace(Some(handler));
|
||||||
|
view.stack.add_named(&review, Some("review"));
|
||||||
|
view.stack.set_visible_child_name("review");
|
||||||
|
view.toolbar.set_reveal_bottom_bars(true);
|
||||||
|
schedule_thumbs(&view.ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says how many photos the similar finder left out for want of a thumbnail.
|
||||||
|
fn uncompared_note(n: usize) -> String {
|
||||||
|
ngettext_f(
|
||||||
|
"{n} photo couldn't be compared because it has no thumbnail yet.",
|
||||||
|
"{n} photos couldn't be compared because they have no thumbnail yet.",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The review list: one section per set, each photo a thumbnail with a Keep
|
||||||
|
/// toggle. Wires `trash` to move every photo not kept to Trash, and returns
|
||||||
|
/// that handler so a new search can take it off again.
|
||||||
|
///
|
||||||
|
/// Exact copies start with only the first of each set kept. Similar photos
|
||||||
|
/// all start kept: photos that look alike can still be different shots.
|
||||||
|
fn duplicate_review(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
sets: Vec<Vec<PhotoItem>>,
|
||||||
|
trash: >k4::Button,
|
||||||
|
finder: Finder,
|
||||||
|
uncompared: usize,
|
||||||
|
) -> (gtk4::Widget, glib::SignalHandlerId) {
|
||||||
|
let intro = gtk4::Label::builder()
|
||||||
|
.label(match finder {
|
||||||
|
Finder::Exact => gettext(
|
||||||
|
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
|
||||||
|
),
|
||||||
|
Finder::Similar => gettext(
|
||||||
|
"These photos look alike. They can be copies of one photo or different shots of one moment, so all of them are kept until you untick them. The photos 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);
|
||||||
|
if uncompared > 0 {
|
||||||
|
let note = gtk4::Label::builder()
|
||||||
|
.label(uncompared_note(uncompared))
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("dim-label");
|
||||||
|
content.append(¬e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
let title = match finder {
|
||||||
|
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
|
||||||
|
Finder::Exact => ngettext_f(
|
||||||
|
"{date}, {n} copy",
|
||||||
|
"{date}, {n} copies",
|
||||||
|
set.len() as u64,
|
||||||
|
&[("date", &date)],
|
||||||
|
),
|
||||||
|
// Translators: heading of one set of photos that look alike; {date} is the day the first was taken.
|
||||||
|
Finder::Similar => ngettext_f(
|
||||||
|
"{date}, {n} photo",
|
||||||
|
"{date}, {n} photos",
|
||||||
|
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 || finder == Finder::Similar);
|
||||||
|
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(&match finder {
|
||||||
|
Finder::Exact => ngettext_f(
|
||||||
|
"Move {n} copy to Trash",
|
||||||
|
"Move {n} copies to Trash",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
Finder::Similar => ngettext_f(
|
||||||
|
"Move {n} photo to Trash",
|
||||||
|
"Move {n} photos 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();
|
||||||
|
let handler = trash.connect_clicked(move |button| {
|
||||||
|
let uids = doomed(&toggles.borrow());
|
||||||
|
if uids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// An exact copy is one file of its shot; a similar photo stands for
|
||||||
|
// its whole shot, raw and all.
|
||||||
|
trash_photos(&ui_trash, uids, finder == Finder::Exact);
|
||||||
|
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(), handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
@@ -6,34 +6,41 @@ pub(crate) struct LoginState {
|
|||||||
pub(crate) email: adw::EntryRow,
|
pub(crate) email: adw::EntryRow,
|
||||||
pub(crate) password: adw::PasswordEntryRow,
|
pub(crate) password: adw::PasswordEntryRow,
|
||||||
pub(crate) login_button: gtk4::Button,
|
pub(crate) login_button: gtk4::Button,
|
||||||
|
/// Spins inside the Sign in button while a sign-in is running.
|
||||||
|
pub(crate) login_spinner: Spinner,
|
||||||
pub(crate) login_status: gtk4::Label,
|
pub(crate) login_status: gtk4::Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The login page: an email row, password row, a primary "Sign in" button and a
|
/// Where the login page's account links go.
|
||||||
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
const SIGNUP_URL: &str = "https://account.proton.me/drive/signup";
|
||||||
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(crate) fn build_login_page() -> (
|
/// The login page: an email row, password row, a primary "Sign in" button, a
|
||||||
gtk4::Widget,
|
/// status label and links to create an account or reset the password, centred
|
||||||
(
|
/// in a clamp. The 2FA code is prompted lazily in a dialog (see [`prompt_2fa`])
|
||||||
adw::EntryRow,
|
/// only when the account actually requires it.
|
||||||
adw::PasswordEntryRow,
|
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||||
gtk4::Button,
|
|
||||||
gtk4::Label,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
let group = adw::PreferencesGroup::builder()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Sign in to Proton")
|
.title(gettext("Sign in to Proton"))
|
||||||
.description("Use your Proton account to connect Drive.")
|
.description(gettext("Use your Proton account to connect Drive."))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let email = adw::EntryRow::builder().title("Email or username").build();
|
let email = adw::EntryRow::builder()
|
||||||
let password = adw::PasswordEntryRow::builder().title("Password").build();
|
.title(gettext("Email or username"))
|
||||||
|
.build();
|
||||||
|
let password = adw::PasswordEntryRow::builder()
|
||||||
|
.title(gettext("Password"))
|
||||||
|
.build();
|
||||||
group.add(&email);
|
group.add(&email);
|
||||||
group.add(&password);
|
group.add(&password);
|
||||||
|
|
||||||
|
let login_spinner = spinner();
|
||||||
|
login_spinner.set_visible(false);
|
||||||
|
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
button_content.append(&login_spinner);
|
||||||
|
button_content.append(>k4::Label::new(Some(&gettext("Sign in"))));
|
||||||
let login_button = gtk4::Button::builder()
|
let login_button = gtk4::Button::builder()
|
||||||
.label("Sign in")
|
.child(&button_content)
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
login_button.add_css_class("suggested-action");
|
login_button.add_css_class("suggested-action");
|
||||||
@@ -46,9 +53,8 @@ pub(crate) fn build_login_page() -> (
|
|||||||
login_status.add_css_class("dim-label");
|
login_status.add_css_class("dim-label");
|
||||||
|
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
let logo = gtk4::Image::from_icon_name(APP_ID);
|
||||||
logo.set_pixel_size(64);
|
logo.set_pixel_size(96);
|
||||||
logo.add_css_class("brand-icon");
|
|
||||||
let title = gtk4::Label::new(Some("Proton Drive"));
|
let title = gtk4::Label::new(Some("Proton Drive"));
|
||||||
title.add_css_class("brand-title");
|
title.add_css_class("brand-title");
|
||||||
header.append(&logo);
|
header.append(&logo);
|
||||||
@@ -64,6 +70,17 @@ pub(crate) fn build_login_page() -> (
|
|||||||
inner.append(&group);
|
inner.append(&group);
|
||||||
inner.append(&login_button);
|
inner.append(&login_button);
|
||||||
inner.append(&login_status);
|
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,
|
||||||
|
&gettext("Create account"),
|
||||||
|
));
|
||||||
|
links.append(>k4::LinkButton::with_label(
|
||||||
|
RESET_PASSWORD_URL,
|
||||||
|
&gettext("Forgot password?"),
|
||||||
|
));
|
||||||
|
inner.append(&links);
|
||||||
|
|
||||||
let clamp = adw::Clamp::builder()
|
let clamp = adw::Clamp::builder()
|
||||||
.maximum_size(420)
|
.maximum_size(420)
|
||||||
@@ -73,7 +90,13 @@ pub(crate) fn build_login_page() -> (
|
|||||||
|
|
||||||
(
|
(
|
||||||
scroll.upcast(),
|
scroll.upcast(),
|
||||||
(email, password, login_button, login_status),
|
LoginState {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
login_button,
|
||||||
|
login_spinner,
|
||||||
|
login_status,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +122,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
if username.is_empty() || password.is_empty() {
|
if username.is_empty() || password.is_empty() {
|
||||||
ui.login
|
ui.login
|
||||||
.login_status
|
.login_status
|
||||||
.set_text("Enter your email and password.");
|
.set_text(&gettext("Enter your email and password."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.login.login_button.set_sensitive(false);
|
set_signing_in(&ui, true);
|
||||||
ui.login.login_status.set_text("Signing in…");
|
ui.login.login_status.set_text(&gettext("Signing in…"));
|
||||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||||
|
|
||||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||||
@@ -129,7 +152,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
ui_hv
|
ui_hv
|
||||||
.login
|
.login
|
||||||
.login_status
|
.login_status
|
||||||
.set_text("Complete the verification to continue…");
|
.set_text(&gettext("Complete the verification to continue…"));
|
||||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -139,8 +162,8 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
let result = rx
|
let result = rx
|
||||||
.recv()
|
.recv()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
.unwrap_or_else(|_| Err(gettext("login cancelled")));
|
||||||
ui.login.login_button.set_sensitive(true);
|
set_signing_in(&ui, false);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui.login.login_status.set_text("");
|
ui.login.login_status.set_text("");
|
||||||
@@ -148,18 +171,55 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
// Cache the new identity so `refresh` never hits the keyring.
|
// Cache the new identity so `refresh` never hits the keyring.
|
||||||
*ui.session.borrow_mut() = auth::load().ok();
|
*ui.session.borrow_mut() = auth::load().ok();
|
||||||
// Enable+start the mount service now that we have a session.
|
// Enable+start the mount service now that we have a session.
|
||||||
service::enable_start();
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
Err(e) => ui
|
Err(e) => ui.login.login_status.set_text(&e),
|
||||||
.login
|
|
||||||
.login_status
|
|
||||||
.set_text(&format!("Sign-in failed: {e}")),
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lock the form and spin the button while a sign-in runs.
|
||||||
|
fn set_signing_in(ui: &Rc<Ui>, running: bool) {
|
||||||
|
ui.login.login_button.set_sensitive(!running);
|
||||||
|
ui.login.email.set_sensitive(!running);
|
||||||
|
ui.login.password.set_sensitive(!running);
|
||||||
|
ui.login.login_spinner.set_visible(running);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to tell the person when sign-in fails. Proton's own API messages are
|
||||||
|
/// written for people ("Incorrect login credentials…"), so they pass through;
|
||||||
|
/// everything else is mapped from its kind rather than shown as a debug string.
|
||||||
|
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 => {
|
||||||
|
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(_)) => {
|
||||||
|
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") => {
|
||||||
|
gettext("Sign-in cancelled: no two-factor code was entered.")
|
||||||
|
}
|
||||||
|
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)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||||
/// runtime. Returns two channels: the first yields the final login result once;
|
/// runtime. Returns two channels: the first yields the final login result once;
|
||||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||||
@@ -223,7 +283,7 @@ pub(crate) fn spawn_login(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| login_error_message(&e))
|
||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
@@ -235,20 +295,22 @@ pub(crate) fn spawn_login(
|
|||||||
/// the worker reads as a cancelled login.
|
/// the worker reads as a cancelled login.
|
||||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Two-factor authentication")
|
.heading(gettext("Two-Factor Authentication"))
|
||||||
.body("Enter the code from your authenticator app.")
|
.body(gettext(
|
||||||
|
"Enter the code from your authenticator app, or one of your recovery codes.",
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
let entry = adw::EntryRow::builder()
|
let entry = adw::EntryRow::builder()
|
||||||
.title("Authentication code")
|
.title(gettext("Authentication code"))
|
||||||
.activates_default(true)
|
.activates_default(true)
|
||||||
.build();
|
.build();
|
||||||
group.add(&entry);
|
group.add(&entry);
|
||||||
dialog.set_extra_child(Some(&group));
|
dialog.set_extra_child(Some(&group));
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("confirm", "Confirm");
|
dialog.add_response("confirm", &gettext("Confirm"));
|
||||||
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("confirm"));
|
dialog.set_default_response(Some("confirm"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
@@ -268,17 +330,53 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
|||||||
dialog.present(parent.as_ref());
|
dialog.present(parent.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||||
/// isn't left running without credentials), forget the stored session, and drop
|
/// daemon isn't left running without credentials), forget the stored session,
|
||||||
/// back to the login page.
|
/// and drop back to the login page.
|
||||||
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||||
|
let Some(window) = ui_window(ui) else { return };
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
button.connect_clicked(move |_| {
|
confirm_destructive(
|
||||||
service::disable_stop();
|
&window,
|
||||||
if let Err(e) = auth::logout() {
|
&gettext("Sign Out?"),
|
||||||
tracing::error!("logout failed: {e}");
|
&gettext(
|
||||||
}
|
"Proton Drive will disconnect and stop syncing until you sign in again. Files already on this computer stay where they are.",
|
||||||
*ui.session.borrow_mut() = None;
|
),
|
||||||
refresh(&ui);
|
&gettext("Sign Out"),
|
||||||
});
|
move || {
|
||||||
|
// systemctl and the keyring both block, so run them on a worker.
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = gio::spawn_blocking(|| {
|
||||||
|
service::disable_stop();
|
||||||
|
auth::logout().map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if let Ok(Err(e)) = result {
|
||||||
|
tracing::error!("logout failed: {e}");
|
||||||
|
}
|
||||||
|
*ui.session.borrow_mut() = None;
|
||||||
|
refresh(&ui);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::login_error_message;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_prompt_says_which_step_was_skipped() {
|
||||||
|
let totp = pdfs_core::Error::Other("two-factor entry cancelled".into());
|
||||||
|
assert!(login_error_message(&totp).contains("two-factor code"));
|
||||||
|
let hv = pdfs_core::Error::Other("verification cancelled".into());
|
||||||
|
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unmapped_error_keeps_its_text() {
|
||||||
|
let error = pdfs_core::Error::Other("something odd".into());
|
||||||
|
assert_eq!(login_error_message(&error), "Sign-in failed: something odd");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
//! The online street map under the Places map: vector tiles from OpenFreeMap,
|
||||||
|
//! fetched and drawn on a worker thread, and handed to the map as finished
|
||||||
|
//! images.
|
||||||
|
//!
|
||||||
|
//! It is off unless turned on in Preferences, since every tile fetched tells
|
||||||
|
//! OpenFreeMap which part of the world is being looked at. OpenFreeMap needs
|
||||||
|
//! no account or key, and its tiles are OpenStreetMap data in the
|
||||||
|
//! OpenMapTiles schema; both have to be credited wherever the map shows.
|
||||||
|
//!
|
||||||
|
//! Tiles are kept on disk under the cache directory, per build of the tile
|
||||||
|
//! set. A build never changes, so a cached tile is good until OpenFreeMap
|
||||||
|
//! publishes the next one, and then the old build's directory goes. Town and
|
||||||
|
//! country names are not baked into the images: they come back as [`Label`]s
|
||||||
|
//! for the map to set on top, where it can keep them upright, sharp and apart
|
||||||
|
//! from each other.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gtk4::cairo;
|
||||||
|
|
||||||
|
use super::mvt::{self, Kind, Layer};
|
||||||
|
|
||||||
|
/// Where OpenFreeMap describes its current tile set.
|
||||||
|
const TILEJSON_URL: &str = "https://tiles.openfreemap.org/planet";
|
||||||
|
|
||||||
|
/// The deepest zoom OpenFreeMap has tiles for. Closer in, a tile is drawn
|
||||||
|
/// from part of its ancestor at this zoom.
|
||||||
|
pub(crate) const DATA_MAX_ZOOM: u8 = 14;
|
||||||
|
|
||||||
|
/// A tile's edge in logical px at the zoom it was made for.
|
||||||
|
pub(crate) const TILE_EDGE: f64 = 256.0;
|
||||||
|
|
||||||
|
/// How many tiles are fetched at once.
|
||||||
|
const PARALLEL_FETCHES: usize = 6;
|
||||||
|
|
||||||
|
/// How long one fetch may take.
|
||||||
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// Past this size the tile cache is emptied rather than left to grow.
|
||||||
|
const DISK_BUDGET: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// One tile of the Web Mercator grid: at zoom `z` the world is `2^z` tiles
|
||||||
|
/// wide and tall, counted from the north-west.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct TileKey {
|
||||||
|
pub(crate) z: u8,
|
||||||
|
pub(crate) x: u32,
|
||||||
|
pub(crate) y: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileKey {
|
||||||
|
pub(crate) fn parent(self) -> Option<Self> {
|
||||||
|
(self.z > 0).then(|| Self {
|
||||||
|
z: self.z - 1,
|
||||||
|
x: self.x / 2,
|
||||||
|
y: self.y / 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile whose data this one is drawn from.
|
||||||
|
fn data(self) -> Self {
|
||||||
|
let mut key = self;
|
||||||
|
while key.z > DATA_MAX_ZOOM {
|
||||||
|
key = key.parent().unwrap_or(key);
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colours of the map, for a light or a dark window.
|
||||||
|
pub(crate) struct Palette {
|
||||||
|
pub(crate) land: u32,
|
||||||
|
pub(crate) water: u32,
|
||||||
|
wood: u32,
|
||||||
|
grass: u32,
|
||||||
|
ice: u32,
|
||||||
|
sand: u32,
|
||||||
|
residential: u32,
|
||||||
|
industrial: u32,
|
||||||
|
building: u32,
|
||||||
|
motorway: u32,
|
||||||
|
primary: u32,
|
||||||
|
road: u32,
|
||||||
|
casing: u32,
|
||||||
|
rail: u32,
|
||||||
|
path: u32,
|
||||||
|
pub(crate) border: u32,
|
||||||
|
state_border: u32,
|
||||||
|
pub(crate) coast: u32,
|
||||||
|
pub(crate) text: u32,
|
||||||
|
pub(crate) halo: u32,
|
||||||
|
pub(crate) water_text: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const LIGHT: Palette = Palette {
|
||||||
|
land: 0xf4f2ee,
|
||||||
|
water: 0xb3cde3,
|
||||||
|
wood: 0xd3e5c5,
|
||||||
|
grass: 0xdcebcf,
|
||||||
|
ice: 0xf2f6f9,
|
||||||
|
sand: 0xefe7d2,
|
||||||
|
residential: 0xebe7e2,
|
||||||
|
industrial: 0xebe4e8,
|
||||||
|
building: 0xdcd5ce,
|
||||||
|
motorway: 0xf2c98f,
|
||||||
|
primary: 0xfbe4ad,
|
||||||
|
road: 0xffffff,
|
||||||
|
casing: 0xd2cbc1,
|
||||||
|
rail: 0xb5afa8,
|
||||||
|
path: 0xbfae9c,
|
||||||
|
border: 0x9d94b5,
|
||||||
|
state_border: 0xc2bbd2,
|
||||||
|
coast: 0x9fbbd4,
|
||||||
|
text: 0x34313d,
|
||||||
|
halo: 0xffffff,
|
||||||
|
water_text: 0x4f7398,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const DARK: Palette = Palette {
|
||||||
|
land: 0x24222d,
|
||||||
|
water: 0x141c2b,
|
||||||
|
wood: 0x212c26,
|
||||||
|
grass: 0x232d27,
|
||||||
|
ice: 0x2c313a,
|
||||||
|
sand: 0x2d2b27,
|
||||||
|
residential: 0x292733,
|
||||||
|
industrial: 0x2b2833,
|
||||||
|
building: 0x322f3c,
|
||||||
|
motorway: 0x7a6647,
|
||||||
|
primary: 0x625a4b,
|
||||||
|
road: 0x46434f,
|
||||||
|
casing: 0x1a1822,
|
||||||
|
rail: 0x4e4a5a,
|
||||||
|
path: 0x3f3b49,
|
||||||
|
border: 0x857ca0,
|
||||||
|
state_border: 0x5c556f,
|
||||||
|
coast: 0x2c3b52,
|
||||||
|
text: 0xe7e4ef,
|
||||||
|
halo: 0x16141c,
|
||||||
|
water_text: 0x86a6cb,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn palette(dark: bool) -> &'static Palette {
|
||||||
|
if dark { &DARK } else { &LIGHT }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_rgb(cr: &cairo::Context, color: u32, alpha: f64) {
|
||||||
|
cr.set_source_rgba(
|
||||||
|
f64::from((color >> 16) & 0xff) / 255.0,
|
||||||
|
f64::from((color >> 8) & 0xff) / 255.0,
|
||||||
|
f64::from(color & 0xff) / 255.0,
|
||||||
|
alpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of place a name stands for, which sets how it is written and
|
||||||
|
/// which names win when they would overlap.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub(crate) enum LabelClass {
|
||||||
|
Country,
|
||||||
|
State,
|
||||||
|
City,
|
||||||
|
Town,
|
||||||
|
Village,
|
||||||
|
Suburb,
|
||||||
|
Hamlet,
|
||||||
|
Water,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelClass {
|
||||||
|
fn from_place(class: &str) -> Option<Self> {
|
||||||
|
Some(match class {
|
||||||
|
"country" => Self::Country,
|
||||||
|
"state" | "province" => Self::State,
|
||||||
|
"city" => Self::City,
|
||||||
|
"town" => Self::Town,
|
||||||
|
"village" => Self::Village,
|
||||||
|
"suburb" | "quarter" => Self::Suburb,
|
||||||
|
"hamlet" | "neighbourhood" | "isolated_dwelling" => Self::Hamlet,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom from which such names are shown at all.
|
||||||
|
pub(crate) fn min_zoom(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Country | Self::Water | Self::City => 0,
|
||||||
|
Self::State => 4,
|
||||||
|
Self::Town => 8,
|
||||||
|
Self::Village => 11,
|
||||||
|
Self::Suburb => 12,
|
||||||
|
Self::Hamlet => 14,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom past which such names are left out again: a country's name
|
||||||
|
/// is no help once its towns are.
|
||||||
|
pub(crate) fn max_zoom(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Country => 7,
|
||||||
|
Self::State => 9,
|
||||||
|
_ => u8::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name to set on the map, at its place in world space (0..1 both ways).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Label {
|
||||||
|
pub(crate) x: f64,
|
||||||
|
pub(crate) y: f64,
|
||||||
|
pub(crate) text: String,
|
||||||
|
pub(crate) class: LabelClass,
|
||||||
|
/// Lower is more important, within a class.
|
||||||
|
pub(crate) rank: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished tile: premultiplied ARGB pixels, as cairo keeps them.
|
||||||
|
pub(crate) struct Raster {
|
||||||
|
pub(crate) key: TileKey,
|
||||||
|
pub(crate) dark: bool,
|
||||||
|
pub(crate) edge: i32,
|
||||||
|
pub(crate) pixels: Vec<u8>,
|
||||||
|
pub(crate) labels: Vec<Label>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Raster {
|
||||||
|
pub(crate) fn surface(self) -> Option<cairo::ImageSurface> {
|
||||||
|
let stride = self.edge * 4;
|
||||||
|
cairo::ImageSurface::create_for_data(
|
||||||
|
self.pixels,
|
||||||
|
cairo::Format::ARgb32,
|
||||||
|
self.edge,
|
||||||
|
self.edge,
|
||||||
|
stride,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum Done {
|
||||||
|
Tile(Raster),
|
||||||
|
/// Nothing came: no network, or no such tile. The outline shows there.
|
||||||
|
Failed(TileKey),
|
||||||
|
/// The tile went off screen before its turn.
|
||||||
|
Dropped(TileKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
key: TileKey,
|
||||||
|
dark: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The worker thread making tiles. Dropping it stops the thread once the
|
||||||
|
/// fetches under way are done.
|
||||||
|
pub(crate) struct Tiles {
|
||||||
|
jobs: async_channel::Sender<Job>,
|
||||||
|
wanted: Arc<Mutex<HashSet<TileKey>>>,
|
||||||
|
/// Device pixels per logical px the tiles are made at.
|
||||||
|
pub(crate) scale: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tiles {
|
||||||
|
/// Start the worker, caching under `cache_dir`, drawing names in
|
||||||
|
/// `language` where the map has them. Finished tiles arrive on the
|
||||||
|
/// returned channel.
|
||||||
|
pub(crate) fn start(
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
language: String,
|
||||||
|
scale: i32,
|
||||||
|
) -> (Self, async_channel::Receiver<Done>) {
|
||||||
|
let (jobs, job_rx) = async_channel::unbounded();
|
||||||
|
let (done_tx, done_rx) = async_channel::unbounded();
|
||||||
|
let wanted = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let worker = Worker {
|
||||||
|
dir: cache_dir,
|
||||||
|
language,
|
||||||
|
scale,
|
||||||
|
wanted: wanted.clone(),
|
||||||
|
done: done_tx,
|
||||||
|
};
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("map-tiles".into())
|
||||||
|
.spawn(move || worker.run(job_rx));
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
tracing::warn!("map tiles: cannot start the worker: {e}");
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
jobs,
|
||||||
|
wanted,
|
||||||
|
scale,
|
||||||
|
},
|
||||||
|
done_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request(&self, key: TileKey, dark: bool) {
|
||||||
|
let _ = self.jobs.try_send(Job { key, dark });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tiles on screen now. A queued tile that isn't is skipped.
|
||||||
|
pub(crate) fn set_wanted(&self, keys: HashSet<TileKey>) {
|
||||||
|
if let Ok(mut wanted) = self.wanted.lock() {
|
||||||
|
*wanted = keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Worker {
|
||||||
|
dir: PathBuf,
|
||||||
|
language: String,
|
||||||
|
scale: i32,
|
||||||
|
wanted: Arc<Mutex<HashSet<TileKey>>>,
|
||||||
|
done: async_channel::Sender<Done>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
fn run(self, jobs: async_channel::Receiver<Job>) {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("map tiles: no runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.user_agent(concat!(
|
||||||
|
"proton-drive-linux/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (",
|
||||||
|
env!("CARGO_PKG_REPOSITORY"),
|
||||||
|
")"
|
||||||
|
))
|
||||||
|
.timeout(FETCH_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("map tiles: no HTTP client: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let worker = Arc::new(self);
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let source = Arc::new(tokio::sync::Mutex::new(None::<String>));
|
||||||
|
let slots = Arc::new(tokio::sync::Semaphore::new(PARALLEL_FETCHES));
|
||||||
|
while let Ok(job) = jobs.recv().await {
|
||||||
|
let Ok(permit) = slots.clone().acquire_owned().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let (worker, client, source) = (worker.clone(), client.clone(), source.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let done = worker.make(&client, &source, job).await;
|
||||||
|
let _ = worker.done.send(done).await;
|
||||||
|
drop(permit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Let the tiles under way finish before the runtime goes.
|
||||||
|
let _ = slots.acquire_many(PARALLEL_FETCHES as u32).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn make(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
source: &tokio::sync::Mutex<Option<String>>,
|
||||||
|
job: Job,
|
||||||
|
) -> Done {
|
||||||
|
let key = job.key;
|
||||||
|
let still_wanted = self.wanted.lock().map(|w| w.contains(&key)).unwrap_or(true);
|
||||||
|
if !still_wanted {
|
||||||
|
return Done::Dropped(key);
|
||||||
|
}
|
||||||
|
let Some(data) = self.load(client, source, key.data()).await else {
|
||||||
|
return Done::Failed(key);
|
||||||
|
};
|
||||||
|
let worker = self.clone();
|
||||||
|
let made = tokio::task::spawn_blocking(move || {
|
||||||
|
let layers = mvt::decode(&data)?;
|
||||||
|
render(&layers, key, job.dark, worker.scale, &worker.language)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match made {
|
||||||
|
Ok(Some(raster)) => Done::Tile(raster),
|
||||||
|
_ => Done::Failed(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile's data, from the disk cache or else from OpenFreeMap.
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
source: &tokio::sync::Mutex<Option<String>>,
|
||||||
|
key: TileKey,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let template = {
|
||||||
|
let mut source = source.lock().await;
|
||||||
|
if source.is_none() {
|
||||||
|
*source = self.tile_source(client).await;
|
||||||
|
}
|
||||||
|
source.clone()?
|
||||||
|
};
|
||||||
|
let path = self
|
||||||
|
.build_dir(&template)?
|
||||||
|
.join(key.z.to_string())
|
||||||
|
.join(key.x.to_string())
|
||||||
|
.join(format!("{}.pbf", key.y));
|
||||||
|
if let Ok(data) = tokio::fs::read(&path).await {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
let url = template
|
||||||
|
.replace("{z}", &key.z.to_string())
|
||||||
|
.replace("{x}", &key.x.to_string())
|
||||||
|
.replace("{y}", &key.y.to_string());
|
||||||
|
let reply = client.get(&url).send().await.ok()?;
|
||||||
|
if !reply.status().is_success() {
|
||||||
|
tracing::debug!("map tile {url}: {}", reply.status());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = reply.bytes().await.ok()?.to_vec();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::write(&path, &data).await;
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL template of OpenFreeMap's current build, remembered on disk so
|
||||||
|
/// the cached tiles still show without a network.
|
||||||
|
async fn tile_source(&self, client: &reqwest::Client) -> Option<String> {
|
||||||
|
let saved = self.dir.join("tiles.json");
|
||||||
|
let fetched = async {
|
||||||
|
let reply = client.get(TILEJSON_URL).send().await.ok()?;
|
||||||
|
if !reply.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
reply.bytes().await.ok()
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let (json, fresh) = match fetched {
|
||||||
|
Some(json) => (json.to_vec(), true),
|
||||||
|
None => (tokio::fs::read(&saved).await.ok()?, false),
|
||||||
|
};
|
||||||
|
let template = tile_template(&json)?;
|
||||||
|
if fresh {
|
||||||
|
let _ = tokio::fs::create_dir_all(&self.dir).await;
|
||||||
|
let _ = tokio::fs::write(&saved, &json).await;
|
||||||
|
let (dir, build) = (self.dir.clone(), build_name(&template)?);
|
||||||
|
let _ = tokio::task::spawn_blocking(move || prune(&dir, &build)).await;
|
||||||
|
}
|
||||||
|
Some(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dir(&self, template: &str) -> Option<PathBuf> {
|
||||||
|
Some(self.dir.join(build_name(template)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first tile URL template in a TileJSON document.
|
||||||
|
fn tile_template(json: &[u8]) -> Option<String> {
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(json).ok()?;
|
||||||
|
let template = doc.get("tiles")?.get(0)?.as_str()?;
|
||||||
|
template
|
||||||
|
.starts_with("https://")
|
||||||
|
.then(|| template.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile set's build, the path segment before `/{z}`: a safe directory
|
||||||
|
/// name, or `None`.
|
||||||
|
fn build_name(template: &str) -> Option<String> {
|
||||||
|
let head = template.split("/{z}").next()?;
|
||||||
|
let name = head.rsplit('/').next()?;
|
||||||
|
let safe = !name.is_empty()
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
&& name != "."
|
||||||
|
&& name != "..";
|
||||||
|
safe.then(|| name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the cached builds other than `keep`, and `keep` itself once it
|
||||||
|
/// holds more than [`DISK_BUDGET`].
|
||||||
|
fn prune(dir: &Path, keep: &str) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stale = entry.file_name() != keep || dir_size(&path) > DISK_BUDGET;
|
||||||
|
if stale && let Err(e) = std::fs::remove_dir_all(&path) {
|
||||||
|
tracing::debug!("map tiles: cannot remove {}: {e}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_size(dir: &Path) -> u64 {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| match entry.metadata() {
|
||||||
|
Ok(meta) if meta.is_dir() => dir_size(&entry.path()),
|
||||||
|
Ok(meta) => meta.len(),
|
||||||
|
Err(_) => 0,
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw tile `key` from the layers of its data tile, at `scale` device px
|
||||||
|
/// per logical px, and gather the names on it.
|
||||||
|
fn render(
|
||||||
|
layers: &[Layer],
|
||||||
|
key: TileKey,
|
||||||
|
dark: bool,
|
||||||
|
scale: i32,
|
||||||
|
language: &str,
|
||||||
|
) -> Option<Raster> {
|
||||||
|
let edge = (TILE_EDGE as i32) * scale.max(1);
|
||||||
|
let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, edge, edge).ok()?;
|
||||||
|
let labels = {
|
||||||
|
let cr = cairo::Context::new(&surface).ok()?;
|
||||||
|
cr.scale(f64::from(scale), f64::from(scale));
|
||||||
|
let painter = Painter {
|
||||||
|
cr: &cr,
|
||||||
|
key,
|
||||||
|
data: key.data(),
|
||||||
|
colors: palette(dark),
|
||||||
|
};
|
||||||
|
painter.paint(layers);
|
||||||
|
gather_labels(layers, key.data(), language)
|
||||||
|
};
|
||||||
|
surface.flush();
|
||||||
|
let pixels = surface.take_data().ok()?.to_vec();
|
||||||
|
Some(Raster {
|
||||||
|
key,
|
||||||
|
dark,
|
||||||
|
edge,
|
||||||
|
pixels,
|
||||||
|
labels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Painter<'a> {
|
||||||
|
cr: &'a cairo::Context,
|
||||||
|
key: TileKey,
|
||||||
|
data: TileKey,
|
||||||
|
colors: &'static Palette,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Painter<'_> {
|
||||||
|
fn paint(&self, layers: &[Layer]) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
set_rgb(self.cr, c.land, 1.0);
|
||||||
|
let _ = self.cr.paint();
|
||||||
|
|
||||||
|
let layer = |name: &str| layers.iter().find(|l| l.name == name);
|
||||||
|
if let Some(layer) = layer("landcover") {
|
||||||
|
self.fill(layer, |class| match class {
|
||||||
|
"wood" | "forest" => Some(c.wood),
|
||||||
|
"grass" | "wetland" => Some(c.grass),
|
||||||
|
"ice" | "glacier" => Some(c.ice),
|
||||||
|
"sand" | "beach" => Some(c.sand),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("landuse") {
|
||||||
|
self.fill(layer, |class| match class {
|
||||||
|
"residential" | "suburb" | "neighbourhood" => Some(c.residential),
|
||||||
|
"industrial" | "commercial" | "retail" | "railway" => Some(c.industrial),
|
||||||
|
"stadium" | "pitch" | "playground" | "cemetery" => Some(c.grass),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("park") {
|
||||||
|
self.fill(layer, |_| Some(c.grass));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("water") {
|
||||||
|
self.fill(layer, |_| Some(c.water));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("waterway") {
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
let (base, from) = match class {
|
||||||
|
"river" => (1.2, 8),
|
||||||
|
"canal" => (1.0, 12),
|
||||||
|
"stream" => (0.6, 13),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(z >= from).then(|| (c.water, grow(base, z), false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if z >= 13
|
||||||
|
&& let Some(layer) = layer("building")
|
||||||
|
{
|
||||||
|
self.fill(layer, |_| Some(c.building));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("transportation") {
|
||||||
|
self.roads(layer);
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("boundary") {
|
||||||
|
self.borders(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map tile units of the data tile onto this tile's logical px.
|
||||||
|
fn to_px(&self, layer: &Layer, (u, v): (f64, f64)) -> (f64, f64) {
|
||||||
|
let k = f64::from(1u32 << (self.key.z - self.data.z));
|
||||||
|
let ox = f64::from(self.key.x) - f64::from(self.data.x) * k;
|
||||||
|
let oy = f64::from(self.key.y) - f64::from(self.data.y) * k;
|
||||||
|
(
|
||||||
|
(u / layer.extent * k - ox) * TILE_EDGE,
|
||||||
|
(v / layer.extent * k - oy) * TILE_EDGE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace(&self, layer: &Layer, part: &[(f64, f64)], close: bool) {
|
||||||
|
for (i, &point) in part.iter().enumerate() {
|
||||||
|
let (x, y) = self.to_px(layer, point);
|
||||||
|
if i == 0 {
|
||||||
|
self.cr.move_to(x, y);
|
||||||
|
} else {
|
||||||
|
self.cr.line_to(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
self.cr.close_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the layer's polygons in the colour `color` picks by class.
|
||||||
|
fn fill(&self, layer: &Layer, color: impl Fn(&str) -> Option<u32>) {
|
||||||
|
self.cr.set_fill_rule(cairo::FillRule::EvenOdd);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Polygon {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = layer.text(feature, "class").unwrap_or("");
|
||||||
|
let Some(rgb) = color(class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, true);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, rgb, 1.0);
|
||||||
|
let _ = self.cr.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke the layer's lines as `style` says by class: colour, width in
|
||||||
|
/// px, and whether dashed.
|
||||||
|
fn stroke(&self, layer: &Layer, style: impl Fn(&str) -> Option<(u32, f64, bool)>) {
|
||||||
|
self.cr.set_line_cap(cairo::LineCap::Round);
|
||||||
|
self.cr.set_line_join(cairo::LineJoin::Round);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Line {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = layer.text(feature, "class").unwrap_or("");
|
||||||
|
let Some((rgb, width, dashed)) = style(class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, false);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, rgb, 1.0);
|
||||||
|
self.cr.set_line_width(width);
|
||||||
|
let dash = [width * 2.0, width * 2.0];
|
||||||
|
self.cr.set_dash(if dashed { &dash } else { &[] }, 0.0);
|
||||||
|
let _ = self.cr.stroke();
|
||||||
|
}
|
||||||
|
self.cr.set_dash(&[], 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roads, minor first so the major ones cross over them, each on a
|
||||||
|
/// darker casing once they are wide enough for one.
|
||||||
|
fn roads(&self, layer: &Layer) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
let road = |class: &str| -> Option<(u32, f64, u8)> {
|
||||||
|
let (color, width, rank) = match class {
|
||||||
|
"motorway" if z >= 6 => (c.motorway, grow(2.2, z), 4),
|
||||||
|
"trunk" if z >= 7 => (c.motorway, grow(2.2, z), 4),
|
||||||
|
"primary" if z >= 8 => (c.primary, grow(1.8, z), 3),
|
||||||
|
"secondary" if z >= 10 => (c.road, grow(1.4, z), 2),
|
||||||
|
"tertiary" if z >= 11 => (c.road, grow(1.4, z), 2),
|
||||||
|
"minor" | "busway" if z >= 12 => (c.road, grow(1.0, z), 1),
|
||||||
|
"service" if z >= 14 => (c.road, grow(0.6, z), 0),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((color, width, rank))
|
||||||
|
};
|
||||||
|
for rank in 0..=4u8 {
|
||||||
|
let pick = |class: &str| road(class).filter(|r| r.2 == rank);
|
||||||
|
if z >= 12 {
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
pick(class).map(|(_, width, _)| (c.casing, width + 1.5, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
pick(class).map(|(color, width, _)| (color, width, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.stroke(layer, |class| match class {
|
||||||
|
"rail" | "transit" if z >= 10 => Some((c.rail, grow(0.7, z).min(2.5), false)),
|
||||||
|
"path" | "track" if z >= 14 => Some((c.path, 0.8, true)),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn borders(&self, layer: &Layer) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
self.cr.set_line_cap(cairo::LineCap::Round);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Line || layer.number(feature, "maritime") == Some(1.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (color, width, dashed) = match layer.number(feature, "admin_level") {
|
||||||
|
Some(level) if level <= 2.0 => {
|
||||||
|
(c.border, (0.9 * 1.12f64.powi(i32::from(z))).min(2.5), false)
|
||||||
|
}
|
||||||
|
Some(level) if level <= 4.0 && z >= 5 => (c.state_border, 0.8, true),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let dashed = dashed || layer.number(feature, "disputed") == Some(1.0);
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, false);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, color, 1.0);
|
||||||
|
self.cr.set_line_width(width);
|
||||||
|
self.cr
|
||||||
|
.set_dash(if dashed { &[3.0, 2.5] } else { &[] }, 0.0);
|
||||||
|
let _ = self.cr.stroke();
|
||||||
|
}
|
||||||
|
self.cr.set_dash(&[], 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line width that grows with the zoom, as the things it stands for get
|
||||||
|
/// bigger on screen.
|
||||||
|
fn grow(base: f64, z: u8) -> f64 {
|
||||||
|
(base * 1.4f64.powi(i32::from(z) - 10)).clamp(0.5, 24.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names of towns, countries and seas on data tile `data`, in world
|
||||||
|
/// space. Names in the tile's buffer beyond its edge are left to the
|
||||||
|
/// neighbour they belong to.
|
||||||
|
fn gather_labels(layers: &[Layer], data: TileKey, language: &str) -> Vec<Label> {
|
||||||
|
let n = f64::from(1u32 << data.z);
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
for layer in layers {
|
||||||
|
let water = match layer.name.as_str() {
|
||||||
|
"place" => false,
|
||||||
|
"water_name" => true,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Point {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = if water {
|
||||||
|
LabelClass::Water
|
||||||
|
} else {
|
||||||
|
let Some(class) = layer
|
||||||
|
.text(feature, "class")
|
||||||
|
.and_then(LabelClass::from_place)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
class
|
||||||
|
};
|
||||||
|
let Some(text) = label_name(layer, feature, language) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let rank = layer.number(feature, "rank").unwrap_or(99.0);
|
||||||
|
for &(u, v) in feature.parts.iter().flatten() {
|
||||||
|
if !(0.0..layer.extent).contains(&u) || !(0.0..layer.extent).contains(&v) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
labels.push(Label {
|
||||||
|
x: (f64::from(data.x) + u / layer.extent) / n,
|
||||||
|
y: (f64::from(data.y) + v / layer.extent) / n,
|
||||||
|
text: text.clone(),
|
||||||
|
class,
|
||||||
|
rank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feature's name in `language` where the map has it, else in Latin
|
||||||
|
/// letters, else as written locally.
|
||||||
|
fn label_name(layer: &Layer, feature: &mvt::Feature, language: &str) -> Option<String> {
|
||||||
|
[
|
||||||
|
format!("name:{language}"),
|
||||||
|
format!("name_{language}"),
|
||||||
|
"name:latin".to_owned(),
|
||||||
|
"name".to_owned(),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| layer.text(feature, key).filter(|name| !name.is_empty()))
|
||||||
|
.map(|name| name.replace('\n', " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_tiles_draw_from_their_ancestor_at_the_data_zoom() {
|
||||||
|
let key = TileKey {
|
||||||
|
z: 16,
|
||||||
|
x: 34_336,
|
||||||
|
y: 22_437,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
key.data(),
|
||||||
|
TileKey {
|
||||||
|
z: 14,
|
||||||
|
x: 8_584,
|
||||||
|
y: 5_609
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let shallow = TileKey { z: 3, x: 4, y: 2 };
|
||||||
|
assert_eq!(shallow.data(), shallow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_build_comes_from_the_tile_template() {
|
||||||
|
let json = br#"{"tiles":["https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf"]}"#;
|
||||||
|
let template = tile_template(json).unwrap();
|
||||||
|
assert_eq!(build_name(&template).as_deref(), Some("20260913_164504_pt"));
|
||||||
|
assert_eq!(build_name("https://example.org/../{z}/{x}/{y}.pbf"), None);
|
||||||
|
assert_eq!(
|
||||||
|
tile_template(br#"{"tiles":["http://plain.example/{z}"]}"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_build_is_pruned_and_the_current_one_kept() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-map-prune-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("old/1/0")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("new/1/0")).unwrap();
|
||||||
|
std::fs::write(dir.join("new/1/0/0.pbf"), b"tile").unwrap();
|
||||||
|
std::fs::write(dir.join("tiles.json"), b"{}").unwrap();
|
||||||
|
prune(&dir, "new");
|
||||||
|
assert!(!dir.join("old").exists());
|
||||||
|
assert!(dir.join("new/1/0/0.pbf").exists());
|
||||||
|
assert!(dir.join("tiles.json").exists());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roads_widen_as_the_map_zooms_in() {
|
||||||
|
assert!(grow(1.4, 14) > grow(1.4, 10));
|
||||||
|
assert_eq!(grow(2.2, 30), 24.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
pub(crate) mod activity;
|
pub(crate) mod activity;
|
||||||
|
pub(crate) mod activity_text;
|
||||||
pub(crate) mod albums;
|
pub(crate) mod albums;
|
||||||
pub(crate) mod browser;
|
pub(crate) mod browser;
|
||||||
pub(crate) mod devices;
|
pub(crate) mod devices;
|
||||||
|
pub(crate) mod duplicates;
|
||||||
pub(crate) mod locations;
|
pub(crate) mod locations;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
|
pub(crate) mod map_tiles;
|
||||||
|
pub(crate) mod mvt;
|
||||||
pub(crate) mod photo_viewer;
|
pub(crate) mod photo_viewer;
|
||||||
pub(crate) mod photos;
|
pub(crate) mod photos;
|
||||||
|
pub(crate) mod places;
|
||||||
|
pub(crate) mod places_map;
|
||||||
pub(crate) mod shared;
|
pub(crate) mod shared;
|
||||||
pub(crate) mod shared_by_me;
|
pub(crate) mod shared_by_me;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
//! A reader for Mapbox Vector Tiles, the format the online Places map's tiles
|
||||||
|
//! come in: protobuf, one message per tile, holding named layers of point,
|
||||||
|
//! line and polygon features with key/value tags.
|
||||||
|
//!
|
||||||
|
//! Only what drawing needs is read — layer names, tags and geometry — and a
|
||||||
|
//! tile that doesn't parse is `None` rather than an error: the map then shows
|
||||||
|
//! the outline underneath it. The format is at
|
||||||
|
//! <https://github.com/mapbox/vector-tile-spec>.
|
||||||
|
|
||||||
|
/// One layer of a tile, such as `water` or `transportation`.
|
||||||
|
pub(crate) struct Layer {
|
||||||
|
pub(crate) name: String,
|
||||||
|
/// The tile's width in geometry units; 4096 almost everywhere.
|
||||||
|
pub(crate) extent: f64,
|
||||||
|
keys: Vec<String>,
|
||||||
|
values: Vec<Value>,
|
||||||
|
pub(crate) features: Vec<Feature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum Value {
|
||||||
|
Text(String),
|
||||||
|
Number(f64),
|
||||||
|
Bool(bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Kind {
|
||||||
|
Point,
|
||||||
|
Line,
|
||||||
|
Polygon,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Feature {
|
||||||
|
pub(crate) kind: Kind,
|
||||||
|
/// Pairs of indices into the layer's keys and values.
|
||||||
|
tags: Vec<(u32, u32)>,
|
||||||
|
/// Points, lines or polygon rings, in tile units. A point feature holds
|
||||||
|
/// one part with all its points.
|
||||||
|
pub(crate) parts: Vec<Vec<(f64, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layer {
|
||||||
|
/// The value `feature` carries for `key`.
|
||||||
|
pub(crate) fn tag(&self, feature: &Feature, key: &str) -> Option<&Value> {
|
||||||
|
feature.tags.iter().find_map(|&(k, v)| {
|
||||||
|
(self.keys.get(k as usize)? == key).then(|| self.values.get(v as usize))?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn text(&self, feature: &Feature, key: &str) -> Option<&str> {
|
||||||
|
match self.tag(feature, key)? {
|
||||||
|
Value::Text(text) => Some(text),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn number(&self, feature: &Feature, key: &str) -> Option<f64> {
|
||||||
|
match self.tag(feature, key)? {
|
||||||
|
Value::Number(n) => Some(*n),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every layer of the tile in `data`.
|
||||||
|
pub(crate) fn decode(data: &[u8]) -> Option<Vec<Layer>> {
|
||||||
|
let mut tile = Reader::new(data);
|
||||||
|
let mut layers = Vec::new();
|
||||||
|
while let Some((field, wire)) = tile.field()? {
|
||||||
|
match field {
|
||||||
|
3 => layers.push(decode_layer(tile.bytes()?)?),
|
||||||
|
_ => tile.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(layers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_layer(data: &[u8]) -> Option<Layer> {
|
||||||
|
let mut layer = Layer {
|
||||||
|
name: String::new(),
|
||||||
|
extent: 4096.0,
|
||||||
|
keys: Vec::new(),
|
||||||
|
values: Vec::new(),
|
||||||
|
features: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
match field {
|
||||||
|
1 => layer.name = reader.text()?,
|
||||||
|
2 => {
|
||||||
|
if let Some(feature) = decode_feature(reader.bytes()?) {
|
||||||
|
layer.features.push(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => layer.keys.push(reader.text()?),
|
||||||
|
4 => layer.values.push(decode_value(reader.bytes()?)?),
|
||||||
|
5 => layer.extent = reader.varint()? as f64,
|
||||||
|
_ => reader.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_value(data: &[u8]) -> Option<Value> {
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
let mut value = Value::Bool(false);
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
value = match field {
|
||||||
|
1 => Value::Text(reader.text()?),
|
||||||
|
2 => Value::Number(f64::from(f32::from_bits(reader.fixed32()?))),
|
||||||
|
3 => Value::Number(f64::from_bits(reader.fixed64()?)),
|
||||||
|
4 | 5 => Value::Number(reader.varint()? as i64 as f64),
|
||||||
|
6 => Value::Number(zigzag(reader.varint()?) as f64),
|
||||||
|
7 => Value::Bool(reader.varint()? != 0),
|
||||||
|
_ => {
|
||||||
|
reader.skip(wire)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feature, or `None` for one this map can't draw (unknown geometry type,
|
||||||
|
/// or broken).
|
||||||
|
fn decode_feature(data: &[u8]) -> Option<Feature> {
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
let mut kind = None;
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
let mut geometry = Vec::new();
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
match field {
|
||||||
|
2 => {
|
||||||
|
let packed = reader.packed()?;
|
||||||
|
tags = packed
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&[k, v]| (k as u32, v as u32))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
kind = match reader.varint()? {
|
||||||
|
1 => Some(Kind::Point),
|
||||||
|
2 => Some(Kind::Line),
|
||||||
|
3 => Some(Kind::Polygon),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => geometry = reader.packed()?,
|
||||||
|
_ => reader.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = kind?;
|
||||||
|
Some(Feature {
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
parts: decode_geometry(kind, &geometry)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the command stream of a feature's geometry into its parts. Positions
|
||||||
|
/// are deltas from the previous one, carried across parts.
|
||||||
|
fn decode_geometry(kind: Kind, commands: &[u64]) -> Option<Vec<Vec<(f64, f64)>>> {
|
||||||
|
let mut parts: Vec<Vec<(f64, f64)>> = Vec::new();
|
||||||
|
let (mut x, mut y) = (0i64, 0i64);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < commands.len() {
|
||||||
|
let command = commands[i] & 7;
|
||||||
|
let count = (commands[i] >> 3) as usize;
|
||||||
|
i += 1;
|
||||||
|
match command {
|
||||||
|
// MoveTo starts a part — except for points, which all share one.
|
||||||
|
1 | 2 => {
|
||||||
|
if i + 2 * count > commands.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for n in 0..count {
|
||||||
|
x += zigzag(commands[i + 2 * n]);
|
||||||
|
y += zigzag(commands[i + 2 * n + 1]);
|
||||||
|
let starts = command == 1 && (kind != Kind::Point || parts.is_empty());
|
||||||
|
if starts {
|
||||||
|
parts.push(Vec::new());
|
||||||
|
}
|
||||||
|
parts.last_mut()?.push((x as f64, y as f64));
|
||||||
|
}
|
||||||
|
i += 2 * count;
|
||||||
|
}
|
||||||
|
// ClosePath: the ring's end joins its start, which drawing does.
|
||||||
|
7 => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zigzag(n: u64) -> i64 {
|
||||||
|
((n >> 1) as i64) ^ -((n & 1) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protobuf wire format, as far as vector tiles use it.
|
||||||
|
struct Reader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn new(data: &'a [u8]) -> Self {
|
||||||
|
Self { data, at: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next field's number and wire type; `Some(None)` at the end, `None`
|
||||||
|
/// on a broken message.
|
||||||
|
fn field(&mut self) -> Option<Option<(u64, u8)>> {
|
||||||
|
if self.at >= self.data.len() {
|
||||||
|
return Some(None);
|
||||||
|
}
|
||||||
|
let key = self.varint()?;
|
||||||
|
Some(Some((key >> 3, (key & 7) as u8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn varint(&mut self) -> Option<u64> {
|
||||||
|
let mut value = 0u64;
|
||||||
|
for shift in (0..64).step_by(7) {
|
||||||
|
let byte = *self.data.get(self.at)?;
|
||||||
|
self.at += 1;
|
||||||
|
value |= u64::from(byte & 0x7f) << shift;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||||
|
let end = self.at.checked_add(n)?;
|
||||||
|
let bytes = self.data.get(self.at..end)?;
|
||||||
|
self.at = end;
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&mut self) -> Option<&'a [u8]> {
|
||||||
|
let len = usize::try_from(self.varint()?).ok()?;
|
||||||
|
self.take(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&mut self) -> Option<String> {
|
||||||
|
Some(String::from_utf8_lossy(self.bytes()?).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed32(&mut self) -> Option<u32> {
|
||||||
|
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed64(&mut self) -> Option<u64> {
|
||||||
|
Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed(&mut self) -> Option<Vec<u64>> {
|
||||||
|
let mut inner = Reader::new(self.bytes()?);
|
||||||
|
let mut values = Vec::new();
|
||||||
|
while inner.at < inner.data.len() {
|
||||||
|
values.push(inner.varint()?);
|
||||||
|
}
|
||||||
|
Some(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip(&mut self, wire: u8) -> Option<()> {
|
||||||
|
match wire {
|
||||||
|
0 => {
|
||||||
|
self.varint()?;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
self.take(8)?;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
self.bytes()?;
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
self.take(4)?;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn varint(mut n: u64, out: &mut Vec<u8>) {
|
||||||
|
while n >= 0x80 {
|
||||||
|
out.push((n as u8) | 0x80);
|
||||||
|
n >>= 7;
|
||||||
|
}
|
||||||
|
out.push(n as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(number: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||||
|
varint(number << 3 | 2, out);
|
||||||
|
varint(bytes.len() as u64, out);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed(values: &[u64]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for &v in values {
|
||||||
|
varint(v, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zz(n: i64) -> u64 {
|
||||||
|
((n << 1) ^ (n >> 63)) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile with one `water` layer holding one square tagged class=lake.
|
||||||
|
fn lake_tile() -> Vec<u8> {
|
||||||
|
let mut feature = Vec::new();
|
||||||
|
field(2, &packed(&[0, 0]), &mut feature);
|
||||||
|
varint(3 << 3, &mut feature);
|
||||||
|
varint(3, &mut feature);
|
||||||
|
let square = [
|
||||||
|
1 | 1 << 3,
|
||||||
|
zz(10),
|
||||||
|
zz(10),
|
||||||
|
2 | 3 << 3,
|
||||||
|
zz(20),
|
||||||
|
zz(0),
|
||||||
|
zz(0),
|
||||||
|
zz(20),
|
||||||
|
zz(-20),
|
||||||
|
zz(0),
|
||||||
|
7 | 1 << 3,
|
||||||
|
];
|
||||||
|
field(4, &packed(&square), &mut feature);
|
||||||
|
|
||||||
|
let mut value = Vec::new();
|
||||||
|
field(1, b"lake", &mut value);
|
||||||
|
|
||||||
|
let mut layer = Vec::new();
|
||||||
|
field(1, b"water", &mut layer);
|
||||||
|
field(2, &feature, &mut layer);
|
||||||
|
field(3, b"class", &mut layer);
|
||||||
|
field(4, &value, &mut layer);
|
||||||
|
varint(5 << 3, &mut layer);
|
||||||
|
varint(4096, &mut layer);
|
||||||
|
|
||||||
|
let mut tile = Vec::new();
|
||||||
|
field(3, &layer, &mut tile);
|
||||||
|
tile
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_polygon_decodes_with_its_tags() {
|
||||||
|
let layers = decode(&lake_tile()).unwrap();
|
||||||
|
let [water] = &layers[..] else {
|
||||||
|
panic!("one layer expected");
|
||||||
|
};
|
||||||
|
assert_eq!(water.name, "water");
|
||||||
|
assert_eq!(water.extent, 4096.0);
|
||||||
|
let feature = &water.features[0];
|
||||||
|
assert_eq!(feature.kind, Kind::Polygon);
|
||||||
|
assert_eq!(water.text(feature, "class"), Some("lake"));
|
||||||
|
assert_eq!(water.text(feature, "name"), None);
|
||||||
|
assert_eq!(
|
||||||
|
feature.parts,
|
||||||
|
vec![vec![(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cut_off_tile_is_rejected() {
|
||||||
|
let tile = lake_tile();
|
||||||
|
assert!(decode(&tile[..tile.len() - 3]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn points_of_one_feature_share_a_part() {
|
||||||
|
let commands = [1 | 2 << 3, zz(5), zz(5), zz(1), zz(-1)];
|
||||||
|
let parts = decode_geometry(Kind::Point, &commands).unwrap();
|
||||||
|
assert_eq!(parts, vec![vec![(5.0, 5.0), (6.0, 4.0)]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// Edge length in px of a photo in the list of photos taken on one spot.
|
||||||
|
const SPOT_EDGE: i32 = 96;
|
||||||
|
|
||||||
|
/// 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 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);
|
||||||
|
// The map marks the town with the same photo.
|
||||||
|
cover_map_marker(ui, &place.cover.uid, &picture);
|
||||||
|
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
let place = place.clone();
|
||||||
|
button.connect_clicked(move |_| open_place(&ui_open, place.clone()));
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the map the photo `picture` shows once it is there.
|
||||||
|
fn cover_map_marker(ui: &Rc<Ui>, uid: &str, picture: >k4::Picture) {
|
||||||
|
let map = Rc::downgrade(&ui.gallery.places_map);
|
||||||
|
let set = move |picture: >k4::Picture, uid: &str| {
|
||||||
|
let texture = picture.paintable().and_downcast::<gtk4::gdk::Texture>();
|
||||||
|
if let (Some(map), Some(texture)) = (map.upgrade(), texture) {
|
||||||
|
map.set_cover(uid, &texture);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set(picture, uid);
|
||||||
|
let uid = uid.to_owned();
|
||||||
|
picture.connect_paintable_notify(move |picture| set(picture, &uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the map the thumbnail of a photo it marks on its own, through the
|
||||||
|
/// gallery's thumbnail pipeline.
|
||||||
|
fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
||||||
|
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&photo.uid) {
|
||||||
|
ui.gallery.places_map.set_cover(&photo.uid, texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A town's card may be waiting for the same photo: share its picture
|
||||||
|
// rather than take its place in the queue.
|
||||||
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
|
let picture = waiting.unwrap_or_else(|| {
|
||||||
|
let picture = gtk4::Picture::new();
|
||||||
|
want_thumb(ui, photo, &picture);
|
||||||
|
picture
|
||||||
|
});
|
||||||
|
cover_map_marker(ui, &photo.uid, &picture);
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the photos taken on one spot of the map in a popover at `(x, y)`;
|
||||||
|
/// clicking one opens it in its town.
|
||||||
|
fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f64, f64)) {
|
||||||
|
let Some((place, _)) = photos.first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let popover = gtk4::Popover::new();
|
||||||
|
popover.set_parent(ui.gallery.places_map.widget());
|
||||||
|
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
||||||
|
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
content.set_margin_top(6);
|
||||||
|
content.set_margin_bottom(6);
|
||||||
|
content.set_margin_start(6);
|
||||||
|
content.set_margin_end(6);
|
||||||
|
let title = gtk4::Label::builder()
|
||||||
|
.label(&place.name)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
title.add_css_class("heading");
|
||||||
|
let count = gtk4::Label::builder()
|
||||||
|
.label(ngettext_f(
|
||||||
|
"{n} photo",
|
||||||
|
"{n} photos",
|
||||||
|
photos.len() as u64,
|
||||||
|
&[],
|
||||||
|
))
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
count.add_css_class("dim-label");
|
||||||
|
count.add_css_class("caption");
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&count);
|
||||||
|
|
||||||
|
let grid = gtk4::FlowBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.homogeneous(true)
|
||||||
|
.min_children_per_line(1)
|
||||||
|
.max_children_per_line(4)
|
||||||
|
.row_spacing(4)
|
||||||
|
.column_spacing(4)
|
||||||
|
.build();
|
||||||
|
for (place, photo) in photos {
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.width_request(SPOT_EDGE)
|
||||||
|
.height_request(SPOT_EDGE)
|
||||||
|
.build();
|
||||||
|
spot_thumb(ui, &photo, &picture);
|
||||||
|
let button = gtk4::Button::builder()
|
||||||
|
.child(&picture)
|
||||||
|
.tooltip_text(format_capture_time(photo.capture_time))
|
||||||
|
.build();
|
||||||
|
button.add_css_class("flat");
|
||||||
|
button.add_css_class("places-spot-photo");
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
let popover_open = popover.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
popover_open.popdown();
|
||||||
|
open_place_photo(&ui_open, place.clone(), photo.uid.clone());
|
||||||
|
});
|
||||||
|
grid.append(&button);
|
||||||
|
}
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
let scroller = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_width(true)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.max_content_height(360)
|
||||||
|
.child(&grid)
|
||||||
|
.build();
|
||||||
|
content.append(&scroller);
|
||||||
|
popover.set_child(Some(&content));
|
||||||
|
// Gone with its last use; its parent is the map, which stays.
|
||||||
|
popover.connect_closed(|popover| {
|
||||||
|
let popover = popover.clone();
|
||||||
|
glib::idle_add_local_once(move || popover.unparent());
|
||||||
|
});
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||||
|
/// already waiting for.
|
||||||
|
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
|
match waiting {
|
||||||
|
Some(waiting) => {
|
||||||
|
waiting
|
||||||
|
.bind_property("paintable", picture, "paintable")
|
||||||
|
.sync_create()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
None => want_thumb(ui, photo, picture),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the map the photos of town `id` with where each was taken.
|
||||||
|
fn load_place_locations(ui: &Rc<Ui>, id: u32) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PlaceLocations { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::PhotoLocations { items })) => {
|
||||||
|
ui.gallery.places_map.set_locations(id, items)
|
||||||
|
}
|
||||||
|
// The map keeps showing the town as one marker.
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
tracing::debug!("place locations failed: {message}")
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => tracing::debug!("place locations: no reply"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `place` and then photo `uid` in it, paging through the town until
|
||||||
|
/// the photo has loaded.
|
||||||
|
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
||||||
|
open_place(ui, place);
|
||||||
|
view_when_loaded(ui, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
|
||||||
|
let ui_page = ui.clone();
|
||||||
|
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||||
|
let ui = ui_page;
|
||||||
|
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
||||||
|
open_photo_viewer(&ui, uid);
|
||||||
|
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
|
||||||
|
view_when_loaded(&ui, uid);
|
||||||
|
ui.gallery.burst.set(true);
|
||||||
|
load_gallery(&ui, true);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.widget().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.widget().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.view_switch.connect_changed(move |view| {
|
||||||
|
if view == VIEW_PLACES {
|
||||||
|
show_places(&ui_places);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_layout = ui.clone();
|
||||||
|
ui.gallery.places_layout.connect_changed(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 map = &ui.gallery.places_map;
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
map.connect_open(move |place| open_place(&ui_open, place));
|
||||||
|
let ui_photo = ui.clone();
|
||||||
|
map.connect_open_photo(move |place, uid| open_place_photo(&ui_photo, place, uid));
|
||||||
|
let ui_locations = ui.clone();
|
||||||
|
map.connect_want_locations(move |id| load_place_locations(&ui_locations, id));
|
||||||
|
let ui_thumb = ui.clone();
|
||||||
|
map.connect_want_thumb(move |photo| want_map_thumb(&ui_thumb, &photo));
|
||||||
|
let ui_spot = ui.clone();
|
||||||
|
map.connect_open_spot(move |photos, at| show_spot_photos(&ui_spot, photos, at));
|
||||||
|
map.set_online(ui.dirs.load_config().online_map, ui.dirs.cache_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to the Places map and zoom in on `(latitude, longitude)`, where a
|
||||||
|
/// photo in the viewer was taken.
|
||||||
|
pub(crate) fn show_on_map(ui: &Rc<Ui>, latitude: f64, longitude: f64) {
|
||||||
|
ui.gallery.places_layout.set_active(PLACES_MAP);
|
||||||
|
if ui.gallery.view_switch.active() == VIEW_PLACES {
|
||||||
|
// Already in Places, perhaps inside a town: back out to the listing.
|
||||||
|
show_places(ui);
|
||||||
|
} else {
|
||||||
|
ui.gallery.view_switch.set_active(VIEW_PLACES);
|
||||||
|
}
|
||||||
|
ui.gallery.places_map.show_spot(latitude, longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_layout.active() == PLACES_MAP {
|
||||||
|
"map"
|
||||||
|
} else {
|
||||||
|
"grid"
|
||||||
|
};
|
||||||
|
ui.gallery.places_stack.set_visible_child_name(layout);
|
||||||
|
}
|
||||||
@@ -1,58 +1,104 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct SharedByMeState {
|
pub(crate) struct SharedByMeState {
|
||||||
// Shared (by me) page: one section listing the items I've shared, each with a
|
// Shared by me page: the items I have shared, as a file list whose columns
|
||||||
// copy-link / manage affordance. Rebuilt wholesale on each load.
|
// say where each lives, who has it and what its public link is doing.
|
||||||
|
pub(crate) files: FileList,
|
||||||
|
/// "list", or "status" while the page loads, is empty or can't be read.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
/// "12 items" under the page title.
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) inflight: Cell<bool>,
|
/// Which items to show, indexed like [`SHARE_FILTERS`].
|
||||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
pub(crate) filter: gtk4::DropDown,
|
||||||
|
/// The last listing, unfiltered. The list holds [`DirEntry`]s; the columns
|
||||||
|
/// and menus look a row's people and link up here by uid.
|
||||||
|
pub(crate) items: Rc<RefCell<Vec<SharedItem>>>,
|
||||||
|
/// Runs the loads; the rows stay up while one runs.
|
||||||
|
pub(crate) loader: Rc<Loader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedByMeState {
|
||||||
|
pub(crate) fn new(widgets: &SharedByMeWidgets) -> Self {
|
||||||
|
SharedByMeState {
|
||||||
|
files: widgets.files.clone(),
|
||||||
|
content: widgets.content.clone(),
|
||||||
|
status: widgets.status.clone(),
|
||||||
|
retry: widgets.retry.clone(),
|
||||||
|
subtitle: widgets.subtitle.clone(),
|
||||||
|
filter: widgets.filter.clone(),
|
||||||
|
items: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
loader: Loader::new(&widgets.content),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Shared (by-me) page's load/repaint touch.
|
/// Widgets the Shared (by-me) page's load/repaint touch.
|
||||||
pub(crate) struct SharedByMeWidgets {
|
pub(crate) struct SharedByMeWidgets {
|
||||||
|
pub(crate) files: FileList,
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
|
pub(crate) filter: gtk4::DropDown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Shared page: one section listing the items I have shared with others —
|
/// Which shared items the page shows.
|
||||||
/// each row summarizing who has access and carrying its public link (copy/open)
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
/// and a Manage shortcut into the per-node Share dialog.
|
pub(crate) enum ShareFilter {
|
||||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
All,
|
||||||
let title = gtk4::Label::builder()
|
/// Items with a public link.
|
||||||
.label("Shared")
|
Links,
|
||||||
.halign(gtk4::Align::Start)
|
/// Items shared with people, invited or already in.
|
||||||
.hexpand(true)
|
People,
|
||||||
.build();
|
/// Items whose public link has expired.
|
||||||
title.add_css_class("title-2");
|
Expired,
|
||||||
let refresh = refresh_button();
|
}
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
||||||
header.set_hexpand(true);
|
|
||||||
header.append(&title);
|
|
||||||
header.append(&refresh);
|
|
||||||
|
|
||||||
let group = adw::PreferencesGroup::new();
|
/// The filters in the order the header's drop-down lists them.
|
||||||
let clamp = adw::Clamp::builder().child(&group).build();
|
const SHARE_FILTERS: [ShareFilter; 4] = [
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
ShareFilter::All,
|
||||||
.vexpand(true)
|
ShareFilter::Links,
|
||||||
.child(&clamp)
|
ShareFilter::People,
|
||||||
.build();
|
ShareFilter::Expired,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn share_filter_label(filter: ShareFilter) -> String {
|
||||||
|
match filter {
|
||||||
|
ShareFilter::All => gettext("All Shared Items"),
|
||||||
|
ShareFilter::Links => gettext("Links Only"),
|
||||||
|
ShareFilter::People => gettext("People Only"),
|
||||||
|
ShareFilter::Expired => gettext("Expired Links"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Shared by me page: every item I have shared with people or by link.
|
||||||
|
/// Activating one opens its Share dialog, since managing access is what this
|
||||||
|
/// page is for; opening the item itself is in the menu.
|
||||||
|
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||||
|
let files = FileList::new();
|
||||||
|
files.show_list(true);
|
||||||
|
let layout = layout_button(&files);
|
||||||
|
let labels: Vec<String> = SHARE_FILTERS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(share_filter_label)
|
||||||
|
.collect();
|
||||||
|
let filter =
|
||||||
|
gtk4::DropDown::from_strings(&labels.iter().map(String::as_str).collect::<Vec<_>>());
|
||||||
|
filter.set_valign(gtk4::Align::Center);
|
||||||
|
filter.set_tooltip_text(Some(&gettext("Show only some shared items")));
|
||||||
|
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
.label("Retry")
|
.label(gettext("Retry"))
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
retry.add_css_class("pill");
|
retry.add_css_class("pill");
|
||||||
retry.add_css_class("suggested-action");
|
retry.add_css_class("suggested-action");
|
||||||
retry.set_visible(false);
|
retry.set_visible(false);
|
||||||
let status = adw::StatusPage::builder()
|
let status = adw::StatusPage::builder()
|
||||||
.icon_name("emblem-shared-symbolic")
|
.icon_name("pdfs-share-symbolic")
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&retry)
|
.child(&retry)
|
||||||
.build();
|
.build();
|
||||||
@@ -61,7 +107,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
|||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&scroll, Some("list"));
|
content.add_named(&files.views, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -69,30 +115,210 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
|||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
|
let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner);
|
||||||
|
header.pack_start(&filter);
|
||||||
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
SharedByMeWidgets {
|
SharedByMeWidgets {
|
||||||
|
files,
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
group,
|
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
subtitle,
|
||||||
|
filter,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Shared (by-me) page's retry button.
|
/// Install the file list, its columns, the filter and the retry button.
|
||||||
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: >k4::Button) {
|
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, widgets: &SharedByMeWidgets) {
|
||||||
|
let files = &ui.shared_by_me.files;
|
||||||
|
files.wire(
|
||||||
|
ui,
|
||||||
|
FileListBehavior {
|
||||||
|
activate: open_share_dialog,
|
||||||
|
entry_menu: shared_by_me_menu,
|
||||||
|
bulk_menu: shared_by_me_bulk_menu,
|
||||||
|
background_menu: shared_by_me_background_menu,
|
||||||
|
badges: false,
|
||||||
|
drag_and_drop: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let items = &ui.shared_by_me.items;
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&gettext("Location"), |e| {
|
||||||
|
location_label(&e.path)
|
||||||
|
}));
|
||||||
|
let people = items.clone();
|
||||||
|
files
|
||||||
|
.column_view
|
||||||
|
.append_column(&text_column(&pgettext("column", "People"), move |e| {
|
||||||
|
find_item(&people, &e.uid)
|
||||||
|
.map(|item| people_label(&item))
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
}));
|
||||||
|
files.column_view.append_column(&link_column(ui));
|
||||||
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
widgets
|
||||||
service::restart();
|
.retry
|
||||||
load_shared_by_me(&ui_retry);
|
.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
|
||||||
|
let ui_filter = ui.clone();
|
||||||
|
widgets.filter.connect_selected_notify(move |_| {
|
||||||
|
let items = ui_filter.shared_by_me.items.borrow().clone();
|
||||||
|
repaint_shared_by_me(&ui_filter, &items);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shared item a row stands for.
|
||||||
|
fn find_item(items: &RefCell<Vec<SharedItem>>, uid: &str) -> Option<SharedItem> {
|
||||||
|
items.borrow().iter().find(|item| item.uid == uid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Link column: what the public link is doing, and a button that copies
|
||||||
|
/// it while it works.
|
||||||
|
fn link_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
let ui_setup = ui.clone();
|
||||||
|
factory.connect_setup(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.hexpand(true)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
label.add_css_class("dim-label");
|
||||||
|
let copy = gtk4::Button::builder()
|
||||||
|
.icon_name("pdfs-link-symbolic")
|
||||||
|
.tooltip_text(gettext("Copy link"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
copy.add_css_class("flat");
|
||||||
|
// Read at click time: the view recycles this cell for other rows.
|
||||||
|
let (ui, item_c) = (ui_setup.clone(), item.clone());
|
||||||
|
copy.connect_clicked(move |btn| {
|
||||||
|
let Some(obj) = item_c.item().and_downcast::<BoxedAnyObject>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||||
|
if let Some(url) = find_item(&ui.shared_by_me.items, &uid)
|
||||||
|
.and_then(|item| item.link)
|
||||||
|
.and_then(|link| link.url)
|
||||||
|
{
|
||||||
|
btn.clipboard().set_text(&url);
|
||||||
|
flash_copied(btn, "pdfs-link-symbolic");
|
||||||
|
toast(&ui, &gettext("Link copied"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||||
|
cell.append(&label);
|
||||||
|
cell.append(©);
|
||||||
|
item.set_child(Some(&cell));
|
||||||
|
});
|
||||||
|
let items = ui.shared_by_me.items.clone();
|
||||||
|
factory.connect_bind(move |_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let label = cell.first_child().and_downcast::<gtk4::Label>().unwrap();
|
||||||
|
let copy = cell.last_child().unwrap();
|
||||||
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||||
|
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||||
|
let link = find_item(&items, &uid).and_then(|item| item.link);
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let text = link_label(link.as_ref(), now);
|
||||||
|
label.set_label(&text);
|
||||||
|
label.set_tooltip_text(Some(&text));
|
||||||
|
copy.set_visible(
|
||||||
|
link.as_ref()
|
||||||
|
.is_some_and(|l| l.url.is_some() && !link_expired(l, now)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Link")), Some(factory));
|
||||||
|
column.set_resizable(true);
|
||||||
|
column
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a shared item lives in My files: its folder, "My files" at the top,
|
||||||
|
/// or a dash when the daemon can't place it.
|
||||||
|
pub(crate) fn location_label(path: &str) -> String {
|
||||||
|
match path.rsplit_once('/') {
|
||||||
|
Some((parent, _)) => parent.to_string(),
|
||||||
|
None if path.is_empty() => "—".to_string(),
|
||||||
|
None => gettext("My files"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who can reach a shared item through an invitation.
|
||||||
|
pub(crate) fn people_label(item: &SharedItem) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if item.member_count > 0 {
|
||||||
|
// 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 {
|
||||||
|
// 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 parts.is_empty() {
|
||||||
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_expired(link: &PublicLinkInfo, now: i64) -> bool {
|
||||||
|
link.expires.is_some_and(|at| at <= now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a public link is doing at `now` (epoch seconds): working, working
|
||||||
|
/// until a date, or expired, and whether a password guards it.
|
||||||
|
pub(crate) fn link_label(link: Option<&PublicLinkInfo>, now: i64) -> String {
|
||||||
|
let Some(link) = link else {
|
||||||
|
return "—".to_string();
|
||||||
|
};
|
||||||
|
let state = match link.expires {
|
||||||
|
Some(at) if at <= now => pgettext("link", "Expired"),
|
||||||
|
// Translators: {date} is when a public link stops working.
|
||||||
|
Some(at) => gettext_f("Expires {date}", &[("date", &dates::short_date(at))]),
|
||||||
|
None if link.created > 0 => gettext_f(
|
||||||
|
// Translators: {date} is when the public link was created.
|
||||||
|
"Active since {date}",
|
||||||
|
&[("date", &dates::short_date(link.created))],
|
||||||
|
),
|
||||||
|
None => pgettext("link", "Active"),
|
||||||
|
};
|
||||||
|
if link.has_password {
|
||||||
|
[state, gettext("Password protected")].join(" · ")
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `item` passes `filter` at `now` (epoch seconds).
|
||||||
|
pub(crate) fn matches_filter(item: &SharedItem, filter: ShareFilter, now: i64) -> bool {
|
||||||
|
match filter {
|
||||||
|
ShareFilter::All => true,
|
||||||
|
ShareFilter::Links => item.link.is_some(),
|
||||||
|
ShareFilter::People => item.member_count + item.invite_count > 0,
|
||||||
|
ShareFilter::Expired => item.link.as_ref().is_some_and(|l| link_expired(l, now)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the Shared (by-me) list.
|
/// Show a status page in place of the Shared (by-me) list.
|
||||||
pub(crate) fn shared_by_me_status(
|
pub(crate) fn shared_by_me_status(
|
||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
@@ -106,50 +332,57 @@ pub(crate) fn shared_by_me_status(
|
|||||||
ui.shared_by_me.status.set_description(Some(description));
|
ui.shared_by_me.status.set_description(Some(description));
|
||||||
ui.shared_by_me.retry.set_visible(retry);
|
ui.shared_by_me.retry.set_visible(retry);
|
||||||
ui.shared_by_me.content.set_visible_child_name("status");
|
ui.shared_by_me.content.set_visible_child_name("status");
|
||||||
|
ui.shared_by_me.subtitle.set_subtitle("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the shared-by-me listing and repaint the page.
|
/// Fetch the shared-by-me listing and repaint the page.
|
||||||
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||||
if ui.shared_by_me.inflight.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cancel_file_thumbnails(ui);
|
cancel_file_thumbnails(ui);
|
||||||
ui.shared_by_me.inflight.set(true);
|
let ui_p = ui.clone();
|
||||||
shared_by_me_status(
|
let ticket = ui.shared_by_me.loader.refresh(move || {
|
||||||
ui,
|
ui_p.shared_by_me.files.model.remove_all();
|
||||||
"emblem-shared-symbolic",
|
shared_by_me_status(
|
||||||
"Loading…",
|
&ui_p,
|
||||||
"Reading what you've shared.",
|
"pdfs-share-symbolic",
|
||||||
false,
|
&gettext("Loading…"),
|
||||||
);
|
&gettext("Reading what you've shared."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
ui.shared_by_me.inflight.set(false);
|
if !ticket.is_current() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !matches!(result, Ok(Ok(Response::SharedByMe { .. }))) {
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::SharedByMe { items })) => {
|
Ok(Ok(Response::SharedByMe { items })) => {
|
||||||
|
let changed = changed_items(&ui.shared_by_me.items.borrow(), &items);
|
||||||
|
*ui.shared_by_me.items.borrow_mut() = items.clone();
|
||||||
repaint_shared_by_me(&ui, &items);
|
repaint_shared_by_me(&ui, &items);
|
||||||
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
|
rebind_rows(&ui, &changed);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"Couldn't read your shares",
|
&gettext("Couldn't read your shares"),
|
||||||
&message,
|
&message,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
Ok(Ok(_)) => shared_by_me_status(
|
Ok(Ok(_)) => shared_by_me_status(
|
||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"Couldn't read your shares",
|
&gettext("Couldn't read your shares"),
|
||||||
"Unexpected reply from the mount service.",
|
&gettext("Unexpected reply from the Proton Drive service."),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
Ok(Err(_)) | Err(_) => {
|
Ok(Err(_)) | Err(_) => {
|
||||||
ui.shared_by_me.loaded_at.set(None);
|
|
||||||
shared_by_me_unreachable(&ui);
|
shared_by_me_unreachable(&ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,129 +391,216 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
/// The daemon didn't answer the Shared (by-me) page.
|
/// The daemon didn't answer the Shared (by-me) page.
|
||||||
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
|
||||||
shared_by_me_status(
|
|
||||||
ui,
|
|
||||||
"network-offline-symbolic",
|
|
||||||
"Not connected",
|
|
||||||
"The Proton Drive mount service isn't running.",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shared_by_me_status(
|
|
||||||
ui,
|
|
||||||
"folder-remote-symbolic",
|
|
||||||
"Connecting…",
|
|
||||||
"Waiting for the Proton Drive mount service to come up.",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
|
||||||
load_shared_by_me(&ui);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the Shared (by-me) section from a fresh listing.
|
/// Show the items that pass the header's filter.
|
||||||
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||||
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
|
|
||||||
ui.shared_by_me.group.remove(&row);
|
|
||||||
}
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
shared_by_me_status(
|
shared_by_me_status(
|
||||||
ui,
|
ui,
|
||||||
"emblem-shared-symbolic",
|
"pdfs-share-symbolic",
|
||||||
"Nothing shared yet",
|
&gettext("Nothing shared yet"),
|
||||||
"Items you share with people or by link show up here.",
|
&gettext("Items you share with people or by link show up here."),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let filter = SHARE_FILTERS
|
||||||
|
.get(ui.shared_by_me.filter.selected() as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(ShareFilter::All);
|
||||||
|
let now = glib::real_time() / 1_000_000;
|
||||||
|
let shown: Vec<DirEntry> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| matches_filter(item, filter, now))
|
||||||
|
.map(shared_item_as_entry)
|
||||||
|
.collect();
|
||||||
|
if shown.is_empty() {
|
||||||
|
shared_by_me_status(
|
||||||
|
ui,
|
||||||
|
"pdfs-share-symbolic",
|
||||||
|
&gettext("No Matching Items"),
|
||||||
|
&gettext("None of your shared items match this filter."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ui.shared_by_me.files.model.remove_all();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.shared_by_me.content.set_visible_child_name("list");
|
ui.shared_by_me.content.set_visible_child_name("list");
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
ui.shared_by_me.subtitle.set_subtitle(&ngettext_f(
|
||||||
for item in items {
|
"{n} item",
|
||||||
let entry = shared_item_as_entry(item);
|
"{n} items",
|
||||||
let row = adw::ActionRow::builder()
|
shown.len() as u64,
|
||||||
.title(&item.name)
|
&[],
|
||||||
.subtitle(shared_item_summary(item))
|
));
|
||||||
.build();
|
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
// and the scroll position.
|
||||||
|
replace_items(&ui.shared_by_me.files.model, &shown);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy / open the public link, when there is one with a recovered URL.
|
/// The uids in `new` whose people or link differ from `old`. Their rows show
|
||||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
/// the same [`DirEntry`] as before, so the list would not repaint them.
|
||||||
let copy = gtk4::Button::builder()
|
fn changed_items(old: &[SharedItem], new: &[SharedItem]) -> HashSet<String> {
|
||||||
.icon_name("edit-copy-symbolic")
|
new.iter()
|
||||||
.tooltip_text("Copy link")
|
.filter(|item| old.iter().find(|o| o.uid == item.uid) != Some(*item))
|
||||||
.valign(gtk4::Align::Center)
|
.map(|item| item.uid.clone())
|
||||||
.build();
|
.collect()
|
||||||
copy.add_css_class("flat");
|
}
|
||||||
let ui_copy = ui.clone();
|
|
||||||
let url_copy = url.clone();
|
/// Have the list bind the rows of `uids` again, to repaint their columns.
|
||||||
copy.connect_clicked(move |btn| {
|
fn rebind_rows(ui: &Rc<Ui>, uids: &HashSet<String>) {
|
||||||
btn.clipboard().set_text(&url_copy);
|
let model = &ui.shared_by_me.files.model;
|
||||||
toast(&ui_copy, "Link copied");
|
for pos in 0..model.n_items() {
|
||||||
});
|
if entry_at(Some(model), pos).is_some_and(|e| uids.contains(&e.uid)) {
|
||||||
let open = gtk4::Button::builder()
|
model.items_changed(pos, 1, 1);
|
||||||
.icon_name("external-link-symbolic")
|
|
||||||
.tooltip_text("Open link")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
open.add_css_class("flat");
|
|
||||||
let url_open = url.clone();
|
|
||||||
open.connect_clicked(move |_| open_uri(&url_open));
|
|
||||||
row.add_suffix(©);
|
|
||||||
row.add_suffix(&open);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage opens the per-node Share dialog. It used to be offered only for
|
|
||||||
// an item whose path the daemon could resolve; the dialog now addresses a
|
|
||||||
// pathless node by uid, so every shared item can be managed from here.
|
|
||||||
let manage = gtk4::Button::builder()
|
|
||||||
.label("Manage")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
manage.add_css_class("flat");
|
|
||||||
let ui_manage = ui.clone();
|
|
||||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
|
||||||
row.add_suffix(&manage);
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
|
||||||
rows.push(row.upcast());
|
|
||||||
}
|
}
|
||||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
/// A shared item's right-click menu: manage it, open it, its link, and Stop
|
||||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
/// Sharing. Manage Access opens the Share dialog, which addresses a pathless
|
||||||
let mut parts = Vec::new();
|
/// node by uid, so every shared item can be managed from here.
|
||||||
if item.member_count > 0 {
|
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
parts.push(format!(
|
let mut menu = ActionMenu::new();
|
||||||
"{} {}",
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
item.member_count,
|
menu.item(&gettext("Manage Access…"), move || {
|
||||||
if item.member_count == 1 {
|
open_share_dialog(&ui_c, &entry_c)
|
||||||
"person"
|
});
|
||||||
} else {
|
// A node the daemon can place in my tree opens like it would in My Files.
|
||||||
"people"
|
if !entry.path.is_empty() {
|
||||||
}
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
));
|
menu.item(&pgettext("verb", "Open"), move || {
|
||||||
|
open_shared_by_me(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Show in My Files"), move || {
|
||||||
|
show_in_my_files(&ui_c, &entry_c)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if item.invite_count > 0 {
|
let now = glib::real_time() / 1_000_000;
|
||||||
parts.push(format!("{} pending", item.invite_count));
|
let url = find_item(&ui.shared_by_me.items, &entry.uid)
|
||||||
|
.and_then(|item| item.link)
|
||||||
|
.filter(|link| !link_expired(link, now))
|
||||||
|
.and_then(|link| link.url);
|
||||||
|
if let Some(url) = url {
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, url_c) = (ui.clone(), url.clone());
|
||||||
|
menu.item(&gettext("Copy Link"), move || {
|
||||||
|
ui_c.stack.clipboard().set_text(&url_c);
|
||||||
|
toast(&ui_c, &gettext("Link copied"));
|
||||||
|
});
|
||||||
|
menu.item(&gettext("Open Link"), move || open_uri(&url));
|
||||||
}
|
}
|
||||||
if item.link.is_some() {
|
menu.section();
|
||||||
parts.push("Public link".to_string());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
}
|
menu.item(&gettext("Stop Sharing…"), move || {
|
||||||
if parts.is_empty() {
|
prompt_stop_sharing(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
"Shared".to_string()
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_by_me_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Stop Sharing…"), move || {
|
||||||
|
prompt_stop_sharing(&ui_c, &entries)
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_by_me_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.shared_by_me.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then remove every member, invitation and the public link.
|
||||||
|
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
|
let body = match entries {
|
||||||
|
// Translators: {name} is the shared item's name.
|
||||||
|
[one] => gettext_f(
|
||||||
|
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||||
|
&[("name", &one.name)],
|
||||||
|
),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Everyone you invited loses access to {n} item, pending invitations are withdrawn and its public link stops working.",
|
||||||
|
"Everyone you invited loses access to {n} items, pending invitations are withdrawn and their public links stop working.",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Stop Sharing?"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
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");
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
let done = match entries {
|
||||||
|
// Translators: {name} is the item's name.
|
||||||
|
[one] => gettext_f("Stopped sharing {name}", &[("name", &one.name)]),
|
||||||
|
_ => ngettext_f(
|
||||||
|
"Stopped sharing {n} item",
|
||||||
|
"Stopped sharing {n} items",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
|
dialog.connect_response(Some("stop"), move |_, _| {
|
||||||
|
let done = done.clone();
|
||||||
|
run_requests(
|
||||||
|
&ui_c,
|
||||||
|
uids.iter()
|
||||||
|
.map(|uid| Request::StopSharingByUid { uid: uid.clone() })
|
||||||
|
.collect(),
|
||||||
|
gettext_noop("Couldn't stop sharing"),
|
||||||
|
move |ui| {
|
||||||
|
reload_listing(ui);
|
||||||
|
toast(ui, &done);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||||
|
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
if entry.is_dir {
|
||||||
|
open_in_my_files(ui, entry.path.clone());
|
||||||
} else {
|
} else {
|
||||||
parts.join(" · ")
|
activate_entry(ui, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
|
/// Go to the folder holding a shared item in My Files.
|
||||||
/// open on it. Only used when the item's path is known.
|
pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let parent = entry
|
||||||
|
.path
|
||||||
|
.rsplit_once('/')
|
||||||
|
.map(|(parent, _)| parent.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
open_in_my_files(ui, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`DirEntry`] from a [`SharedItem`], for the file list and the
|
||||||
|
/// Share dialog, which addresses a pathless node by uid.
|
||||||
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
@@ -296,5 +616,80 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
shared_by: String::new(),
|
shared_by: String::new(),
|
||||||
shared_at: 0,
|
shared_at: 0,
|
||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn item(members: usize, invites: usize, link: Option<PublicLinkInfo>) -> SharedItem {
|
||||||
|
SharedItem {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
name: "Budget.ods".into(),
|
||||||
|
is_dir: false,
|
||||||
|
modified: 0,
|
||||||
|
path: "Work/Budget.ods".into(),
|
||||||
|
member_count: members,
|
||||||
|
invite_count: invites,
|
||||||
|
link,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(expires: Option<i64>, has_password: bool) -> PublicLinkInfo {
|
||||||
|
PublicLinkInfo {
|
||||||
|
id: "l".into(),
|
||||||
|
url: Some("https://drive.proton.me/urls/X#y".into()),
|
||||||
|
role: "viewer".into(),
|
||||||
|
expires,
|
||||||
|
has_password,
|
||||||
|
created: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_says_whether_it_still_works() {
|
||||||
|
assert_eq!(link_label(None, 100), "—");
|
||||||
|
assert_eq!(link_label(Some(&link(None, false)), 100), "Active");
|
||||||
|
assert_eq!(link_label(Some(&link(Some(100), false)), 100), "Expired");
|
||||||
|
assert!(link_label(Some(&link(Some(200), false)), 100).starts_with("Expires "));
|
||||||
|
assert_eq!(
|
||||||
|
link_label(Some(&link(None, true)), 100),
|
||||||
|
"Active · Password protected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_filters_pick_links_people_and_expired_links() {
|
||||||
|
let people = item(2, 0, None);
|
||||||
|
let invited = item(0, 1, None);
|
||||||
|
let live = item(0, 0, Some(link(None, false)));
|
||||||
|
let expired = item(0, 0, Some(link(Some(50), false)));
|
||||||
|
let pick = |filter| {
|
||||||
|
[&people, &invited, &live, &expired]
|
||||||
|
.iter()
|
||||||
|
.map(|i| matches_filter(i, filter, 100))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(pick(ShareFilter::All), [true, true, true, true]);
|
||||||
|
assert_eq!(pick(ShareFilter::Links), [false, false, true, true]);
|
||||||
|
assert_eq!(pick(ShareFilter::People), [true, true, false, false]);
|
||||||
|
assert_eq!(pick(ShareFilter::Expired), [false, false, false, true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_location_is_the_folder_in_my_files() {
|
||||||
|
assert_eq!(location_label("Work/Budget.ods"), "Work");
|
||||||
|
assert_eq!(location_label("Budget.ods"), "My files");
|
||||||
|
assert_eq!(location_label(""), "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn people_counts_members_and_pending_invitations() {
|
||||||
|
assert_eq!(people_label(&item(0, 0, None)), "—");
|
||||||
|
assert_eq!(people_label(&item(1, 0, None)), "1 person");
|
||||||
|
assert_eq!(people_label(&item(2, 3, None)), "2 people · 3 pending");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
||||||
//! Photos.
|
//! Photos.
|
||||||
//!
|
//!
|
||||||
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
|
//! Reached from the Photos page's import button (and its empty timeline),
|
||||||
//! import button), because it is a one-off migration rather than something the
|
//! because it is a one-off migration rather than something the
|
||||||
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
||||||
//! for two reasons: an export is a *set* of archives that has to be staged and
|
//! for two reasons: an export is a *set* of archives that has to be staged and
|
||||||
//! checked before anything is sent, and the import that follows runs for hours,
|
//! checked before anything is sent, and the import that follows runs for hours,
|
||||||
@@ -15,16 +15,16 @@
|
|||||||
//! Import — staging is local, so a mis-drop costs nothing.
|
//! Import — staging is local, so a mis-drop costs nothing.
|
||||||
//!
|
//!
|
||||||
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||||
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
|
//! stages paths, launches, asks [`Request::ImportStatus`] for the final report,
|
||||||
//! and reads live progress off the import's job in the transfer snapshot the
|
//! and reads live progress off the import's job in the transfer snapshot the
|
||||||
//! Settings tick already fetches (see [`takeout_progress`]).
|
//! daemon already pushes (see [`takeout_progress`]).
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||||
/// Matching on it is how this page borrows the progress the Settings tick is
|
/// Matching on it is how this page borrows the progress the daemon already
|
||||||
/// already polling instead of adding a second poll of its own.
|
/// pushes instead of asking for it separately.
|
||||||
const IMPORT_JOB: &str = "Importing Google Photos";
|
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
|
||||||
|
|
||||||
/// What the archive list says before anything is staged. `repaint_archives`
|
/// What the archive list says before anything is staged. `repaint_archives`
|
||||||
/// swaps in the count and total once there is something to report, and swaps
|
/// swaps in the count and total once there is something to report, and swaps
|
||||||
@@ -70,8 +70,11 @@ fn save_staged(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \
|
fn empty_list_description() -> String {
|
||||||
re-running an interrupted import is safe.";
|
gettext(
|
||||||
|
"Photos already in Proton Photos are matched by name and content and skipped, so re-running an interrupted import is safe.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct TakeoutState {
|
pub(crate) struct TakeoutState {
|
||||||
/// Archives staged for this import, in the order they were added. Sent as
|
/// Archives staged for this import, in the order they were added. Sent as
|
||||||
@@ -126,37 +129,26 @@ pub(crate) struct TakeoutWidgets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||||
// Header: a back button out to Settings, since this page is reached from
|
// Header: a back button out to Photos, since this page is reached from
|
||||||
// there and has no sidebar row of its own to navigate back with.
|
// there and has no sidebar row of its own to navigate back with.
|
||||||
let back_button = gtk4::Button::builder()
|
let back_button = gtk4::Button::builder()
|
||||||
.icon_name("go-previous-symbolic")
|
.icon_name("go-previous-symbolic")
|
||||||
.tooltip_text("Back to Settings")
|
.tooltip_text(gettext("Back to Photos"))
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
back_button.add_css_class("flat");
|
back_button.add_css_class("flat");
|
||||||
|
|
||||||
let title = gtk4::Label::builder()
|
|
||||||
.label("Import from Google Photos")
|
|
||||||
.halign(gtk4::Align::Start)
|
|
||||||
.build();
|
|
||||||
title.add_css_class("title-2");
|
|
||||||
let subtitle = gtk4::Label::builder()
|
let subtitle = gtk4::Label::builder()
|
||||||
.label("Add a Google Takeout export to your Proton Photos timeline.")
|
.label(gettext(
|
||||||
|
"Add a Google Takeout export to your Proton Photos timeline.",
|
||||||
|
))
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.xalign(0.0)
|
.xalign(0.0)
|
||||||
.build();
|
.build();
|
||||||
subtitle.add_css_class("dim-label");
|
subtitle.add_css_class("dim-label");
|
||||||
|
|
||||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
let subtitle_clamp = adw::Clamp::builder().child(&subtitle).build();
|
||||||
titles.set_hexpand(true);
|
|
||||||
titles.append(&title);
|
|
||||||
titles.append(&subtitle);
|
|
||||||
|
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
||||||
header.append(&back_button);
|
|
||||||
header.append(&titles);
|
|
||||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
|
||||||
|
|
||||||
// Drop zone. It is a hint, not the only way in — the button under it does
|
// Drop zone. It is a hint, not the only way in — the button under it does
|
||||||
// the same thing for anyone not dragging, and the drop target covers the
|
// the same thing for anyone not dragging, and the drop target covers the
|
||||||
@@ -165,18 +157,18 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
drop_icon.set_pixel_size(48);
|
drop_icon.set_pixel_size(48);
|
||||||
drop_icon.add_css_class("dim-label");
|
drop_icon.add_css_class("dim-label");
|
||||||
let drop_title = gtk4::Label::builder()
|
let drop_title = gtk4::Label::builder()
|
||||||
.label("Drop your Takeout .zip files here")
|
.label(gettext("Drop your Takeout .zip files here"))
|
||||||
.build();
|
.build();
|
||||||
drop_title.add_css_class("title-4");
|
drop_title.add_css_class("title-4");
|
||||||
let drop_hint = gtk4::Label::builder()
|
let drop_hint = gtk4::Label::builder()
|
||||||
.label("Add every part of the export at once — a photo and its metadata often sit in different parts.")
|
.label(gettext("Add every part of the export at once — a photo and its metadata often sit in different parts."))
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.justify(gtk4::Justification::Center)
|
.justify(gtk4::Justification::Center)
|
||||||
.max_width_chars(48)
|
.max_width_chars(48)
|
||||||
.build();
|
.build();
|
||||||
drop_hint.add_css_class("dim-label");
|
drop_hint.add_css_class("dim-label");
|
||||||
let choose_button = gtk4::Button::builder()
|
let choose_button = gtk4::Button::builder()
|
||||||
.label("Choose files…")
|
.label(gettext("Choose files…"))
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
choose_button.add_css_class("pill");
|
choose_button.add_css_class("pill");
|
||||||
@@ -194,13 +186,13 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
// group carries a placeholder row while empty so it never renders as a bare
|
// group carries a placeholder row while empty so it never renders as a bare
|
||||||
// title.
|
// title.
|
||||||
let list_group = adw::PreferencesGroup::builder()
|
let list_group = adw::PreferencesGroup::builder()
|
||||||
.title("Archives to import")
|
.title(gettext("Archives to import"))
|
||||||
.description(EMPTY_LIST_DESCRIPTION)
|
.description(empty_list_description())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let clear_button = gtk4::Button::builder()
|
let clear_button = gtk4::Button::builder()
|
||||||
.label("Clear")
|
.label(gettext("Clear"))
|
||||||
.tooltip_text("Remove every staged archive")
|
.tooltip_text(gettext("Remove every staged archive"))
|
||||||
.build();
|
.build();
|
||||||
clear_button.add_css_class("flat");
|
clear_button.add_css_class("flat");
|
||||||
|
|
||||||
@@ -208,16 +200,22 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
// what an import *would* do, without uploading a byte. It is offered first
|
// what an import *would* do, without uploading a byte. It is offered first
|
||||||
// because the import behind it is hours long and hard to take back.
|
// because the import behind it is hours long and hard to take back.
|
||||||
let scan_button = gtk4::Button::builder()
|
let scan_button = gtk4::Button::builder()
|
||||||
.label("Scan")
|
.label(pgettext("verb", "Scan"))
|
||||||
.tooltip_text("Check the archives and report what would be imported, without uploading")
|
.tooltip_text(gettext(
|
||||||
|
"Check the archives and report what would be imported, without uploading",
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
scan_button.add_css_class("pill");
|
scan_button.add_css_class("pill");
|
||||||
let import_button = gtk4::Button::builder().label("Import").build();
|
let import_button = gtk4::Button::builder()
|
||||||
|
.label(pgettext("verb", "Import"))
|
||||||
|
.build();
|
||||||
import_button.add_css_class("pill");
|
import_button.add_css_class("pill");
|
||||||
import_button.add_css_class("suggested-action");
|
import_button.add_css_class("suggested-action");
|
||||||
let cancel_button = gtk4::Button::builder()
|
let cancel_button = gtk4::Button::builder()
|
||||||
.label("Stop import")
|
.label(gettext("Stop import"))
|
||||||
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop")
|
.tooltip_text(gettext(
|
||||||
|
"Finish the photo on the wire, file what was uploaded, then stop",
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
cancel_button.add_css_class("pill");
|
cancel_button.add_css_class("pill");
|
||||||
cancel_button.add_css_class("destructive-action");
|
cancel_button.add_css_class("destructive-action");
|
||||||
@@ -235,9 +233,9 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
actions.append(&import_button);
|
actions.append(&import_button);
|
||||||
|
|
||||||
// Progress: the import's job from the transfer snapshot, so this page shows
|
// Progress: the import's job from the transfer snapshot, so this page shows
|
||||||
// the same numbers the Settings Activity list does.
|
// the same numbers the Sync page's Transfers list does.
|
||||||
let progress_group = adw::PreferencesGroup::builder()
|
let progress_group = adw::PreferencesGroup::builder()
|
||||||
.title("Import in progress")
|
.title(gettext("Import in progress"))
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
@@ -259,7 +257,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
// outlives the window being on this page, and "what happened" is the whole
|
// outlives the window being on this page, and "what happened" is the whole
|
||||||
// reason to come back to it.
|
// reason to come back to it.
|
||||||
let summary_group = adw::PreferencesGroup::builder()
|
let summary_group = adw::PreferencesGroup::builder()
|
||||||
.title("Last import")
|
.title(gettext("Last import"))
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -284,8 +282,10 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
page.set_margin_bottom(18);
|
page.set_margin_bottom(18);
|
||||||
page.set_margin_start(18);
|
page.set_margin_start(18);
|
||||||
page.set_margin_end(18);
|
page.set_margin_end(18);
|
||||||
page.append(&header_clamp);
|
page.append(&subtitle_clamp);
|
||||||
page.append(&scroll);
|
page.append(&scroll);
|
||||||
|
let (frame, header, _) = page_frame(&gettext("Import from Google Photos"), &page);
|
||||||
|
header.pack_start(&back_button);
|
||||||
|
|
||||||
let widgets = TakeoutWidgets {
|
let widgets = TakeoutWidgets {
|
||||||
list_group: list_group.clone(),
|
list_group: list_group.clone(),
|
||||||
@@ -302,7 +302,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
|||||||
progress_bar: progress_bar.clone(),
|
progress_bar: progress_bar.clone(),
|
||||||
summary_group: summary_group.clone(),
|
summary_group: summary_group.clone(),
|
||||||
};
|
};
|
||||||
(page.upcast(), widgets)
|
(frame.upcast(), widgets)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||||
@@ -341,10 +341,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
widgets.choose_button.connect_clicked(move |_| {
|
widgets.choose_button.connect_clicked(move |_| {
|
||||||
// Multi-select on purpose: the whole export goes in one request.
|
// Multi-select on purpose: the whole export goes in one request.
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
.title("Select your Google Takeout archives")
|
.title(gettext("Select your Google Takeout archives"))
|
||||||
.build();
|
.build();
|
||||||
let filter = gtk4::FileFilter::new();
|
let filter = gtk4::FileFilter::new();
|
||||||
filter.set_name(Some("Takeout archives"));
|
filter.set_name(Some(&gettext("Takeout archives")));
|
||||||
filter.add_pattern("*.zip");
|
filter.add_pattern("*.zip");
|
||||||
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
||||||
filters.append(&filter);
|
filters.append(&filter);
|
||||||
@@ -384,15 +384,16 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
let count = ui_import.takeout.archives.borrow().len();
|
let count = ui_import.takeout.archives.borrow().len();
|
||||||
let ui = ui_import.clone();
|
let ui = ui_import.clone();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Import this export?")
|
.heading(gettext("Import This Export?"))
|
||||||
.body(format!(
|
.body(ngettext_f(
|
||||||
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
|
"{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.",
|
||||||
appear in the export. This can take hours; you can stop it at any point and \
|
"{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.",
|
||||||
what has been uploaded stays. Photos you already have are skipped.",
|
count as u64,
|
||||||
|
&[],
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("import", "Import");
|
dialog.add_response("import", &pgettext("verb", "Import"));
|
||||||
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
@@ -413,14 +414,14 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"),
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Stopping the import…")),
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't stop the import", &message, kind);
|
toast_failure(&ui, &gettext("Couldn't stop the import"), &message, kind);
|
||||||
}
|
}
|
||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
"Couldn't stop the import",
|
&gettext("Couldn't stop the import"),
|
||||||
"The mount service didn't respond.",
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -429,7 +430,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.back_button
|
.back_button
|
||||||
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
|
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("gallery"));
|
||||||
|
|
||||||
// Restore whatever was staged when the window last closed, before the first
|
// Restore whatever was staged when the window last closed, before the first
|
||||||
// paint, so the page comes up as the user left it.
|
// paint, so the page comes up as the user left it.
|
||||||
@@ -470,10 +471,15 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
if rejected > 0 {
|
if rejected > 0 {
|
||||||
toast(
|
toast(
|
||||||
ui,
|
ui,
|
||||||
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
&gettext(
|
||||||
|
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if added > 0 {
|
} else if added > 0 {
|
||||||
toast(ui, &format!("Added {added} archive(s)"));
|
toast(
|
||||||
|
ui,
|
||||||
|
&ngettext_f("Added {n} archive", "Added {n} archives", added as u64, &[]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,8 +494,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
|||||||
if staged.is_empty() {
|
if staged.is_empty() {
|
||||||
ui.takeout
|
ui.takeout
|
||||||
.list_group
|
.list_group
|
||||||
.set_description(Some(EMPTY_LIST_DESCRIPTION));
|
.set_description(Some(&empty_list_description()));
|
||||||
let row = dim_row("No archives added yet.");
|
let row = dim_row(&gettext("No archives added yet."));
|
||||||
ui.takeout.list_group.add(&row);
|
ui.takeout.list_group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
} else {
|
} else {
|
||||||
@@ -507,7 +513,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
|||||||
.build();
|
.build();
|
||||||
let remove = gtk4::Button::builder()
|
let remove = gtk4::Button::builder()
|
||||||
.icon_name("edit-delete-symbolic")
|
.icon_name("edit-delete-symbolic")
|
||||||
.tooltip_text("Remove from this import")
|
.tooltip_text(gettext("Remove from this import"))
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
remove.add_css_class("flat");
|
remove.add_css_class("flat");
|
||||||
@@ -525,10 +531,14 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
|||||||
ui.takeout.list_group.add(&row);
|
ui.takeout.list_group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
}
|
}
|
||||||
ui.takeout.list_group.set_description(Some(&format!(
|
let size = human_bytes(total);
|
||||||
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
|
let note = empty_list_description();
|
||||||
staged.len(),
|
// Translators: {size} is a file size such as "4.2 MB"; {note} is the sentence about skipped duplicates.
|
||||||
human_bytes(total)
|
ui.takeout.list_group.set_description(Some(&ngettext_f(
|
||||||
|
"{n} archive, {size} in total. {note}",
|
||||||
|
"{n} archives, {size} in total. {note}",
|
||||||
|
staged.len() as u64,
|
||||||
|
&[("size", &size), ("note", ¬e)],
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
*ui.takeout.rows.borrow_mut() = rows;
|
*ui.takeout.rows.borrow_mut() = rows;
|
||||||
@@ -579,39 +589,40 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
|||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
ui.takeout.running.set(true);
|
ui.takeout.running.set(true);
|
||||||
|
ui.gallery.import_banner.set_revealed(true);
|
||||||
ui.takeout.summary_group.set_visible(false);
|
ui.takeout.summary_group.set_visible(false);
|
||||||
ui.takeout.progress_group.set_visible(true);
|
ui.takeout.progress_group.set_visible(true);
|
||||||
ui.takeout.progress_bar.set_fraction(0.0);
|
ui.takeout.progress_bar.set_fraction(0.0);
|
||||||
ui.takeout.progress_label.set_text(if dry_run {
|
ui.takeout.progress_label.set_text(&if dry_run {
|
||||||
"Reading archives…"
|
gettext("Reading archives…")
|
||||||
} else {
|
} else {
|
||||||
"Starting import…"
|
gettext("Starting import…")
|
||||||
});
|
});
|
||||||
sync_takeout_actions(&ui);
|
sync_takeout_actions(&ui);
|
||||||
toast(
|
toast(
|
||||||
&ui,
|
&ui,
|
||||||
if dry_run {
|
&if dry_run {
|
||||||
"Scanning the export…"
|
gettext("Scanning the export…")
|
||||||
} else {
|
} else {
|
||||||
"Import started — you can leave this page, it keeps running."
|
gettext("Import started — you can leave this page, it keeps running.")
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't start the import", &message, kind);
|
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
||||||
}
|
}
|
||||||
_ => toast_error(
|
_ => toast_error(
|
||||||
&ui,
|
&ui,
|
||||||
"Couldn't start the import",
|
&gettext("Couldn't start the import"),
|
||||||
"The mount service didn't respond.",
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the daemon for the import's state while the page is on screen, and paint
|
/// Ask the daemon for the import's state, and paint the report once a run has
|
||||||
/// the report once a run has finished. Cheap and inflight-guarded, like the other
|
/// finished. Asked on arrival and while import progress keeps arriving. Cheap
|
||||||
/// tick polls.
|
/// and inflight-guarded.
|
||||||
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||||
if ui.takeout.inflight.get() {
|
if ui.takeout.inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -626,6 +637,7 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let was_running = ui.takeout.running.replace(running);
|
let was_running = ui.takeout.running.replace(running);
|
||||||
|
ui.gallery.import_banner.set_revealed(running);
|
||||||
if running {
|
if running {
|
||||||
ui.takeout.progress_group.set_visible(true);
|
ui.takeout.progress_group.set_visible(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -636,23 +648,35 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
|||||||
if was_running {
|
if was_running {
|
||||||
let dry = ui.takeout.dry_run.get();
|
let dry = ui.takeout.dry_run.get();
|
||||||
let headline = if dry {
|
let headline = if dry {
|
||||||
"Scan finished"
|
gettext("Scan finished")
|
||||||
} else {
|
} else {
|
||||||
"Import finished"
|
gettext("Import finished")
|
||||||
};
|
};
|
||||||
toast(&ui, headline);
|
toast(&ui, &headline);
|
||||||
if !dry {
|
if !dry {
|
||||||
let body = match &summary {
|
let body = match &summary {
|
||||||
Some(s) if s.failed > 0 => {
|
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
||||||
format!("{} photos added, {} failed.", s.uploaded, s.failed)
|
Some(s) if s.failed > 0 => ngettext_f(
|
||||||
}
|
"{n} photo added, {failed} failed.",
|
||||||
Some(s) if s.cancelled => {
|
"{n} photos added, {failed} failed.",
|
||||||
format!("Stopped after {} photos.", s.uploaded)
|
s.uploaded as u64,
|
||||||
}
|
&[("failed", &s.failed.to_string())],
|
||||||
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded),
|
),
|
||||||
None => "The Google Photos import has finished.".to_string(),
|
Some(s) if s.cancelled => ngettext_f(
|
||||||
|
"Stopped after {n} photo.",
|
||||||
|
"Stopped after {n} photos.",
|
||||||
|
s.uploaded as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
Some(s) => ngettext_f(
|
||||||
|
"{n} photo added to Proton Photos.",
|
||||||
|
"{n} photos added to Proton Photos.",
|
||||||
|
s.uploaded as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
None => gettext("The Google Photos import has finished."),
|
||||||
};
|
};
|
||||||
notify("takeout-import", headline, &body);
|
notify("takeout-import", &headline, &body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,69 +696,72 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
ui.takeout.summary_group.remove(&row);
|
ui.takeout.summary_group.remove(&row);
|
||||||
}
|
}
|
||||||
let dry = ui.takeout.dry_run.get();
|
let dry = ui.takeout.dry_run.get();
|
||||||
ui.takeout
|
ui.takeout.summary_group.set_title(&if dry {
|
||||||
.summary_group
|
gettext("Scan result")
|
||||||
.set_title(if dry { "Scan result" } else { "Last import" });
|
|
||||||
ui.takeout.summary_group.set_description(Some(if dry {
|
|
||||||
"Nothing was uploaded — this is what an import would do."
|
|
||||||
} else if summary.cancelled {
|
|
||||||
"Stopped early. Everything uploaded so far is in your timeline; run it again to \
|
|
||||||
continue where it left off."
|
|
||||||
} else {
|
} else {
|
||||||
"Finished."
|
gettext("Last import")
|
||||||
|
});
|
||||||
|
ui.takeout.summary_group.set_description(Some(&if dry {
|
||||||
|
gettext("Nothing was uploaded — this is what an import would do.")
|
||||||
|
} else if summary.cancelled {
|
||||||
|
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
||||||
|
} else {
|
||||||
|
gettext("Finished.")
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Only lines that carry information: a run with no failures should not have
|
// Only lines that carry information: a run with no failures should not have
|
||||||
// to show a "0 failed" row for the user to read past.
|
// to show a "0 failed" row for the user to read past.
|
||||||
let mut lines: Vec<(String, String)> = vec![
|
let mut lines: Vec<(String, String)> = vec![
|
||||||
("Photos in the export".into(), summary.found.to_string()),
|
(gettext("Photos in the export"), summary.found.to_string()),
|
||||||
(
|
(
|
||||||
if dry { "Would upload" } else { "Uploaded" }.into(),
|
if dry {
|
||||||
|
gettext("Would upload")
|
||||||
|
} else {
|
||||||
|
gettext("Uploaded")
|
||||||
|
},
|
||||||
summary.uploaded.to_string(),
|
summary.uploaded.to_string(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if summary.duplicates > 0 {
|
if summary.duplicates > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
"Already in Proton Photos".into(),
|
gettext("Already in Proton Photos"),
|
||||||
summary.duplicates.to_string(),
|
summary.duplicates.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.albums_created > 0 {
|
if summary.albums_created > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
if dry {
|
if dry {
|
||||||
"Albums to create"
|
gettext("Albums to create")
|
||||||
} else {
|
} else {
|
||||||
"Albums created"
|
gettext("Albums created")
|
||||||
}
|
},
|
||||||
.into(),
|
|
||||||
summary.albums_created.to_string(),
|
summary.albums_created.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.album_links > 0 {
|
if summary.album_links > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
"Photos filed into albums".into(),
|
gettext("Photos filed into albums"),
|
||||||
summary.album_links.to_string(),
|
summary.album_links.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.skipped_trashed > 0 {
|
if summary.skipped_trashed > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
"Skipped (in Google's trash)".into(),
|
gettext("Skipped (in Google's trash)"),
|
||||||
summary.skipped_trashed.to_string(),
|
summary.skipped_trashed.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.bytes > 0 {
|
if summary.bytes > 0 {
|
||||||
lines.push((
|
lines.push((
|
||||||
if dry {
|
if dry {
|
||||||
"Data to upload"
|
gettext("Data to upload")
|
||||||
} else {
|
} else {
|
||||||
"Data uploaded"
|
gettext("Data uploaded")
|
||||||
}
|
},
|
||||||
.into(),
|
|
||||||
human_bytes(summary.bytes),
|
human_bytes(summary.bytes),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if summary.failed > 0 {
|
if summary.failed > 0 {
|
||||||
lines.push(("Failed".into(), summary.failed.to_string()));
|
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
@@ -751,8 +778,8 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
ui.takeout.summary_group.set_visible(true);
|
ui.takeout.summary_group.set_visible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint the import's progress from the transfer snapshot the Settings tick
|
/// Paint the import's progress from the transfer snapshot the daemon pushed,
|
||||||
/// already fetched, so this page costs no extra poll. A job with no known total
|
/// so this page costs no extra request. A job with no known total
|
||||||
/// pulses instead of showing a fraction, the same as the Activity list.
|
/// pulses instead of showing a fraction, the same as the Activity list.
|
||||||
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||||
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
||||||
@@ -763,17 +790,22 @@ pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
|||||||
ui.takeout
|
ui.takeout
|
||||||
.progress_bar
|
.progress_bar
|
||||||
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
|
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
|
||||||
ui.takeout
|
let done = job.done.to_string();
|
||||||
.progress_label
|
let total = job.total.to_string();
|
||||||
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail));
|
// Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
|
||||||
|
let text = gettext_f(
|
||||||
|
"{done} of {total} — {detail}",
|
||||||
|
&[("done", &done), ("total", &total), ("detail", &job.detail)],
|
||||||
|
);
|
||||||
|
ui.takeout.progress_label.set_text(&text);
|
||||||
} else {
|
} else {
|
||||||
ui.takeout.progress_bar.pulse();
|
ui.takeout.progress_bar.pulse();
|
||||||
ui.takeout
|
ui.takeout
|
||||||
.progress_label
|
.progress_label
|
||||||
.set_text(if job.detail.is_empty() {
|
.set_text(&if job.detail.is_empty() {
|
||||||
"Working…"
|
gettext("Working…")
|
||||||
} else {
|
} else {
|
||||||
&job.detail
|
job.detail.clone()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,65 +3,54 @@ use crate::*;
|
|||||||
pub(crate) struct TrashState {
|
pub(crate) struct TrashState {
|
||||||
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
||||||
// keeps no current-directory state — it re-lists from the daemon on show.
|
// keeps no current-directory state — it re-lists from the daemon on show.
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
/// Empties the trash; insensitive while it is empty (or unread).
|
/// Empties the trash; hidden while it is empty (or unread).
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
/// "12 items" under the page title.
|
/// "12 items" under the page title.
|
||||||
pub(crate) subtitle: gtk4::Label,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
|
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||||
|
pub(crate) selection_bar: gtk4::Revealer,
|
||||||
|
pub(crate) selection_label: gtk4::Label,
|
||||||
|
/// Runs the loads; the rows stay up while one runs.
|
||||||
|
pub(crate) loader: Rc<Loader>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The widgets of the Trash page that a load repaints.
|
/// The widgets of the Trash page that a load repaints.
|
||||||
pub(crate) struct TrashWidgets {
|
pub(crate) struct TrashWidgets {
|
||||||
pub(crate) model: gio::ListStore,
|
pub(crate) files: FileList,
|
||||||
pub(crate) list: gtk4::ListView,
|
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) subtitle: adw::WindowTitle,
|
||||||
pub(crate) subtitle: gtk4::Label,
|
pub(crate) selection_bar: gtk4::Revealer,
|
||||||
|
pub(crate) selection_label: gtk4::Label,
|
||||||
|
pub(crate) restore_selected: gtk4::Button,
|
||||||
|
pub(crate) delete_selected: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
/// The Trash page: everything Drive is holding in the trash, as a list with
|
||||||
/// row offering Restore and Delete Forever, with Empty Trash in the header.
|
/// the folder each item came from and when it was deleted, or as a grid, where
|
||||||
|
/// photos are easier to recognise. Restore and Delete Permanently sit in the
|
||||||
|
/// right-click menu and, for a selection, in a bottom bar; Empty Trash is in
|
||||||
|
/// the header while there is something to empty.
|
||||||
///
|
///
|
||||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
/// A trashed node has no path inside the mount — the daemon forgets it when it
|
||||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
/// is trashed — so unlike the Files page this one addresses entries by uid. The
|
||||||
/// re-lists from the server rather than from a cached listing. Row rendering needs
|
/// list's factories need the [`Ui`] handle, so they are installed in
|
||||||
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
|
/// [`wire_trash`].
|
||||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
|
||||||
|
|
||||||
let title = gtk4::Label::builder()
|
|
||||||
.label("Trash")
|
|
||||||
.halign(gtk4::Align::Start)
|
|
||||||
.build();
|
|
||||||
title.add_css_class("title-2");
|
|
||||||
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
|
||||||
subtitle.add_css_class("dim-label");
|
|
||||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
|
||||||
titles.set_hexpand(true);
|
|
||||||
titles.append(&title);
|
|
||||||
titles.append(&subtitle);
|
|
||||||
|
|
||||||
let empty = gtk4::Button::builder()
|
let empty = gtk4::Button::builder()
|
||||||
.label("Empty Trash")
|
.label(gettext("Empty Trash…"))
|
||||||
.valign(gtk4::Align::Center)
|
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
||||||
.sensitive(false)
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
empty.add_css_class("destructive-action");
|
|
||||||
let refresh = refresh_button();
|
|
||||||
|
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
||||||
header.append(&titles);
|
|
||||||
header.append(&refresh);
|
|
||||||
header.append(&empty);
|
|
||||||
|
|
||||||
let retry = gtk4::Button::builder()
|
let retry = gtk4::Button::builder()
|
||||||
.label("Retry")
|
.label(gettext("Retry"))
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
retry.add_css_class("pill");
|
retry.add_css_class("pill");
|
||||||
@@ -74,20 +63,16 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
// No selection model: every action lives on the row it acts on, so a
|
// The list first: where an item came from and when it went are what tell
|
||||||
// mis-aimed Delete Forever isn't one click away from a stale selection.
|
// two copies of a file apart here.
|
||||||
let list = gtk4::ListView::builder()
|
let files = FileList::new();
|
||||||
.model(>k4::NoSelection::new(Some(model.clone())))
|
files.show_list(true);
|
||||||
.build();
|
let layout = layout_button(&files);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
|
||||||
.vexpand(true)
|
|
||||||
.child(&list)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&scroll, Some("list"));
|
content.add_named(&files.views, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -95,142 +80,212 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
|
// Bulk Delete Permanently always confirms with the count, so a stale
|
||||||
|
// selection can't delete anything silently.
|
||||||
|
let selection_label = gtk4::Label::new(None);
|
||||||
|
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));
|
||||||
|
bar.pack_start(&restore_selected);
|
||||||
|
bar.pack_end(&delete_selected);
|
||||||
|
let selection_bar = gtk4::Revealer::builder()
|
||||||
|
.transition_type(gtk4::RevealerTransitionType::SlideUp)
|
||||||
|
.child(&bar)
|
||||||
|
.build();
|
||||||
|
inner.append(&selection_bar);
|
||||||
|
|
||||||
|
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
||||||
|
header.pack_start(&empty);
|
||||||
|
header.pack_end(&layout);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
TrashWidgets {
|
TrashWidgets {
|
||||||
model,
|
files,
|
||||||
list,
|
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
retry,
|
retry,
|
||||||
empty,
|
empty,
|
||||||
refresh,
|
|
||||||
subtitle,
|
subtitle,
|
||||||
|
selection_bar,
|
||||||
|
selection_label,
|
||||||
|
restore_selected,
|
||||||
|
delete_selected,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
/// Install the list's factories and columns, the Empty Trash button and the
|
||||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
/// selection bar.
|
||||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Button) {
|
let files = &ui.trash.files;
|
||||||
let factory = gtk4::SignalListItemFactory::new();
|
files.wire(
|
||||||
let ui_setup = ui.clone();
|
ui,
|
||||||
factory.connect_setup(move |_, item| {
|
FileListBehavior {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
activate: activate_trashed,
|
||||||
|
entry_menu: trash_entry_menu,
|
||||||
let thumbnail = file_thumbnail_widget(40, 32);
|
bulk_menu: trash_bulk_menu,
|
||||||
let name = gtk4::Label::builder()
|
background_menu: trash_background_menu,
|
||||||
.halign(gtk4::Align::Start)
|
badges: false,
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
drag_and_drop: false,
|
||||||
.build();
|
},
|
||||||
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
);
|
||||||
meta.add_css_class("dim-label");
|
files
|
||||||
meta.add_css_class("caption");
|
.column_view
|
||||||
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
.append_column(&text_column(&gettext("Original Location"), |e| {
|
||||||
text.set_hexpand(true);
|
trashed_from_label(e)
|
||||||
text.set_valign(gtk4::Align::Center);
|
}));
|
||||||
text.append(&name);
|
files
|
||||||
text.append(&meta);
|
.column_view
|
||||||
|
.append_column(&text_column(&pgettext("column", "Deleted"), |e| {
|
||||||
let restore = gtk4::Button::builder()
|
if e.trashed_at > 0 {
|
||||||
.icon_name("edit-undo-symbolic")
|
dates::relative(e.trashed_at)
|
||||||
.tooltip_text("Restore to its original folder")
|
} else {
|
||||||
.valign(gtk4::Align::Center)
|
"—".to_string()
|
||||||
.build();
|
|
||||||
restore.add_css_class("flat");
|
|
||||||
let purge = gtk4::Button::builder()
|
|
||||||
.icon_name("edit-delete-symbolic")
|
|
||||||
.tooltip_text("Delete permanently")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
purge.add_css_class("flat");
|
|
||||||
|
|
||||||
let ui_restore = ui_setup.clone();
|
|
||||||
let item_restore = item.clone();
|
|
||||||
restore.connect_clicked(move |_| {
|
|
||||||
if let Some(entry) = bound_entry(&item_restore) {
|
|
||||||
restore_entry(&ui_restore, &entry);
|
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
let ui_purge = ui_setup.clone();
|
files
|
||||||
let item_purge = item.clone();
|
.column_view
|
||||||
purge.connect_clicked(move |_| {
|
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||||
if let Some(entry) = bound_entry(&item_purge) {
|
if e.is_dir {
|
||||||
prompt_delete_forever(&ui_purge, &entry);
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
human_bytes(e.size)
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
||||||
row.set_margin_top(6);
|
|
||||||
row.set_margin_bottom(6);
|
|
||||||
row.set_margin_start(6);
|
|
||||||
row.set_margin_end(6);
|
|
||||||
row.append(&thumbnail);
|
|
||||||
row.append(&text);
|
|
||||||
row.append(&restore);
|
|
||||||
row.append(&purge);
|
|
||||||
item.set_child(Some(&row));
|
|
||||||
});
|
|
||||||
factory.connect_bind({
|
|
||||||
let ui = ui.clone();
|
|
||||||
move |_, item| {
|
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
||||||
let Some(entry) = bound_entry(item) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
||||||
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
|
||||||
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
|
||||||
name.set_label(&entry.name);
|
|
||||||
}
|
|
||||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
|
||||||
let kind = if entry.is_dir {
|
|
||||||
"Folder".to_string()
|
|
||||||
} else {
|
|
||||||
human_bytes(entry.size)
|
|
||||||
};
|
|
||||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
list.set_factory(Some(&factory));
|
|
||||||
|
|
||||||
let ui_empty = ui.clone();
|
let ui_empty = ui.clone();
|
||||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
widgets
|
||||||
|
.empty
|
||||||
|
.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||||
|
|
||||||
|
let ui_sel = ui.clone();
|
||||||
|
files
|
||||||
|
.selection
|
||||||
|
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
||||||
|
let ui_restore = ui.clone();
|
||||||
|
widgets.restore_selected.connect_clicked(move |_| {
|
||||||
|
let entries = ui_restore.trash.files.selected();
|
||||||
|
if !entries.is_empty() {
|
||||||
|
restore_entries(&ui_restore, &entries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ui_delete = ui.clone();
|
||||||
|
widgets.delete_selected.connect_clicked(move |_| {
|
||||||
|
let entries = ui_delete.trash.files.selected();
|
||||||
|
if !entries.is_empty() {
|
||||||
|
prompt_delete_forever(&ui_delete, &entries);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
/// Where a trashed entry goes back to on restore, for the Original Location
|
||||||
/// (recycled) row.
|
/// column.
|
||||||
pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
fn trashed_from_label(entry: &DirEntry) -> String {
|
||||||
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
|
match entry.trashed_from.as_deref() {
|
||||||
let entry = obj.borrow::<DirEntry>().clone();
|
None => "—".to_string(),
|
||||||
Some(entry)
|
Some("") => gettext("My files"),
|
||||||
|
Some(path) => path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trashed file can't be opened; say what would let it be.
|
||||||
|
fn activate_trashed(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
&gettext_f("Restore “{name}” to open it", &[("name", &entry.name)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&pgettext("verb", "Restore"), move || {
|
||||||
|
restore_entries(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item(&gettext("Delete Permanently…"), move || {
|
||||||
|
prompt_delete_forever(&ui_c, std::slice::from_ref(&entry_c))
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
menu.labelled_section(&ngettext_f(
|
||||||
|
"{n} selected",
|
||||||
|
"{n} selected",
|
||||||
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
|
menu.item(&pgettext("verb", "Restore"), move || {
|
||||||
|
restore_entries(&ui_c, &batch)
|
||||||
|
});
|
||||||
|
menu.section();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Delete Permanently…"), move || {
|
||||||
|
prompt_delete_forever(&ui_c, &entries)
|
||||||
|
});
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Select All"), move || {
|
||||||
|
ui_c.trash.files.selection.select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||||
|
if ui.trash.files.model.n_items() > 0 {
|
||||||
|
menu.section();
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item(&gettext("Empty Trash…"), move || {
|
||||||
|
prompt_empty_trash(&ui_c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reveal the selection bar while anything is selected and count what is.
|
||||||
|
fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||||
|
let count = ui.trash.files.selection.selection().size() as usize;
|
||||||
|
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||||
|
if count > 0 {
|
||||||
|
ui.trash.selection_label.set_label(&ngettext_f(
|
||||||
|
"{n} item selected",
|
||||||
|
"{n} items selected",
|
||||||
|
count as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the trash listing and repaint the page.
|
/// Fetch the trash listing and repaint the page.
|
||||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||||
cancel_file_thumbnails(ui);
|
cancel_file_thumbnails(ui);
|
||||||
// Drop the old rows first: a stale row here would offer Restore on something
|
// The rows stay up while the trash is read again. Trashed items are
|
||||||
// that may already be gone.
|
// addressed by uid, so acting on one that has gone meanwhile fails
|
||||||
ui.trash.model.remove_all();
|
// cleanly rather than hitting something else.
|
||||||
ui.trash.empty.set_sensitive(false);
|
let ui_p = ui.clone();
|
||||||
trash_status(
|
let ticket = ui.trash.loader.refresh(move || {
|
||||||
ui,
|
clear_trash(&ui_p);
|
||||||
"user-trash-symbolic",
|
ui_p.trash.empty.set_sensitive(false);
|
||||||
"Loading…",
|
trash_status(
|
||||||
"Reading the trash.",
|
&ui_p,
|
||||||
false,
|
"user-trash-symbolic",
|
||||||
);
|
&gettext("Loading…"),
|
||||||
|
&gettext("Reading the trash."),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
||||||
@@ -238,20 +293,26 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
|
if !ticket.is_current() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !matches!(result, Ok(Ok(Response::Entries { .. }))) {
|
||||||
|
clear_trash(&ui);
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
||||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"Couldn't read the trash",
|
&gettext("Couldn't read the trash"),
|
||||||
&message,
|
&message,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
Ok(Ok(_)) => trash_status(
|
Ok(Ok(_)) => trash_status(
|
||||||
&ui,
|
&ui,
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"Couldn't read the trash",
|
&gettext("Couldn't read the trash"),
|
||||||
"Unexpected reply from the mount service.",
|
&gettext("Unexpected reply from the Proton Drive service."),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
||||||
@@ -262,29 +323,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
|||||||
/// The daemon didn't answer. Same split as the Files page: still starting (poll
|
/// The daemon didn't answer. Same split as the Files page: still starting (poll
|
||||||
/// again, no button) versus actually down (Retry, which restarts the service).
|
/// again, no button) versus actually down (Retry, which restarts the service).
|
||||||
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
service_unreachable(ui, "trash", trash_status, load_trash);
|
||||||
trash_status(
|
|
||||||
ui,
|
|
||||||
"network-offline-symbolic",
|
|
||||||
"Not connected",
|
|
||||||
"The Proton Drive mount service isn't running.",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trash_status(
|
|
||||||
ui,
|
|
||||||
"folder-remote-symbolic",
|
|
||||||
"Connecting…",
|
|
||||||
"Waiting for the Proton Drive mount service to come up.",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("trash") {
|
|
||||||
load_trash(&ui);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the trash list.
|
/// Show a status page in place of the trash list.
|
||||||
@@ -294,63 +333,135 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
|||||||
ui.trash.status.set_description(Some(description));
|
ui.trash.status.set_description(Some(description));
|
||||||
ui.trash.retry.set_visible(retry);
|
ui.trash.retry.set_visible(retry);
|
||||||
ui.trash.content.set_visible_child_name("status");
|
ui.trash.content.set_visible_child_name("status");
|
||||||
ui.trash.subtitle.set_label("");
|
ui.trash.subtitle.set_subtitle("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
/// Repopulate the trash, most recently deleted first: the order in which a user
|
||||||
/// user looks for what they just deleted.
|
/// looks for what they just deleted.
|
||||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
ui.trash.model.remove_all();
|
// An empty Trash has nothing to empty, so the button goes away rather than
|
||||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
// sitting there greyed out.
|
||||||
|
ui.trash.empty.set_sensitive(true);
|
||||||
|
ui.trash.empty.set_visible(!entries.is_empty());
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
trash_status(
|
trash_status(
|
||||||
ui,
|
ui,
|
||||||
"user-trash-symbolic",
|
"user-trash-symbolic",
|
||||||
"Trash is empty",
|
&gettext("Trash is empty"),
|
||||||
"Items you delete from Proton Drive show up here.",
|
&gettext("Items you delete from Proton Drive show up here."),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
clear_trash(ui);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.trash.content.set_visible_child_name("list");
|
ui.trash.content.set_visible_child_name("list");
|
||||||
ui.trash.subtitle.set_label(&match entries.len() {
|
ui.trash.subtitle.set_subtitle(&ngettext_f(
|
||||||
1 => "1 item".to_string(),
|
"{n} item",
|
||||||
n => format!("{n} items"),
|
"{n} items",
|
||||||
});
|
entries.len() as u64,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
|
||||||
let mut sorted = entries.to_vec();
|
let mut sorted = entries.to_vec();
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
sort_trash(&mut sorted);
|
||||||
for entry in sorted {
|
// Only the rows that changed are swapped, so a refresh keeps the
|
||||||
ui.trash.model.append(&BoxedAnyObject::new(entry));
|
// selection and the scroll position.
|
||||||
}
|
replace_items(&ui.trash.files.model, &sorted);
|
||||||
|
sync_trash_selection(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore one trashed entry to the folder it was trashed from.
|
/// Newest deletion first. Entries with no known deletion time go last, newest
|
||||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
/// modification first among themselves.
|
||||||
let name = entry.name.clone();
|
fn sort_trash(entries: &mut [DirEntry]) {
|
||||||
run_mutation(
|
entries.sort_by_key(|e| {
|
||||||
|
(
|
||||||
|
e.trashed_at == 0,
|
||||||
|
std::cmp::Reverse(e.trashed_at),
|
||||||
|
std::cmp::Reverse(e.modified),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every row, and with them the selection and its bar.
|
||||||
|
fn clear_trash(ui: &Rc<Ui>) {
|
||||||
|
ui.trash.files.model.remove_all();
|
||||||
|
sync_trash_selection(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore trashed entries to the folders they were trashed from. When they
|
||||||
|
/// all came from one known folder, the toast offers to show it.
|
||||||
|
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
|
let message = 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,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let folder = common_origin(entries);
|
||||||
|
run_mutation_then(
|
||||||
ui,
|
ui,
|
||||||
Request::Restore {
|
Request::Restore {
|
||||||
uids: vec![entry.uid.clone()],
|
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||||
|
},
|
||||||
|
gettext_noop("Couldn't restore"),
|
||||||
|
move |ui| match folder {
|
||||||
|
Some(folder) => toast_action(ui, &message, &gettext("Show"), move |ui| {
|
||||||
|
open_in_my_files(ui, folder.clone())
|
||||||
|
}),
|
||||||
|
None => toast(ui, &message),
|
||||||
},
|
},
|
||||||
format!("Restored “{name}”"),
|
|
||||||
"Couldn't restore",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
|
/// The folder every entry goes back to, when that is one known folder.
|
||||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
fn common_origin(entries: &[DirEntry]) -> Option<String> {
|
||||||
|
let first = entries.first()?.trashed_from.clone()?;
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.all(|e| e.trashed_from.as_deref() == Some(first.as_str()))
|
||||||
|
.then_some(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 win = ui_window(ui);
|
||||||
let uid = entry.uid.clone();
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
let name = entry.name.clone();
|
let count = entries.len() as u64;
|
||||||
|
let (body, done) = match entries {
|
||||||
|
[one] => {
|
||||||
|
let args = [("name", one.name.as_str())];
|
||||||
|
(
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
gettext_f("Permanently delete “{name}”? This cannot be undone.", &args),
|
||||||
|
// Translators: {name} is a file or folder name.
|
||||||
|
gettext_f("Deleted “{name}” permanently", &args),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
ngettext_f(
|
||||||
|
"Permanently delete {n} item? This cannot be undone.",
|
||||||
|
"Permanently delete {n} items? This cannot be undone.",
|
||||||
|
count,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
ngettext_f(
|
||||||
|
"Deleted {n} item permanently",
|
||||||
|
"Deleted {n} items permanently",
|
||||||
|
count,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Delete permanently")
|
.heading(gettext("Delete Permanently?"))
|
||||||
.body(format!(
|
.body(body)
|
||||||
"Permanently delete “{name}”? This cannot be undone."
|
|
||||||
))
|
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("delete", "Delete Permanently");
|
dialog.add_response("delete", &gettext("Delete Permanently"));
|
||||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
@@ -360,11 +471,9 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
if resp == "delete" {
|
if resp == "delete" {
|
||||||
run_mutation(
|
run_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::DeleteForever {
|
Request::DeleteForever { uids: uids.clone() },
|
||||||
uids: vec![uid.clone()],
|
done.clone(),
|
||||||
},
|
gettext_noop("Couldn't delete"),
|
||||||
format!("Deleted “{name}” permanently"),
|
|
||||||
"Couldn't delete",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -374,15 +483,18 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
/// Confirm, then permanently delete everything in the trash.
|
/// Confirm, then permanently delete everything in the trash.
|
||||||
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let count = ui.trash.model.n_items();
|
let count = ui.trash.files.model.n_items();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Empty Trash")
|
.heading(gettext("Empty Trash?"))
|
||||||
.body(format!(
|
.body(ngettext_f(
|
||||||
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
|
"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();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
dialog.add_response("empty", "Empty Trash");
|
dialog.add_response("empty", &gettext("Empty Trash"));
|
||||||
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
|
||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
@@ -393,10 +505,66 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
|||||||
run_mutation(
|
run_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::EmptyTrash,
|
Request::EmptyTrash,
|
||||||
"Trash emptied".to_string(),
|
gettext("Trash emptied"),
|
||||||
"Couldn't empty the trash",
|
gettext_noop("Couldn't empty the trash"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn trashed(name: &str, trashed_at: i64, modified: i64, from: Option<&str>) -> DirEntry {
|
||||||
|
DirEntry {
|
||||||
|
name: name.into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 0,
|
||||||
|
modified,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: name.into(),
|
||||||
|
path: String::new(),
|
||||||
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
|
trashed_at,
|
||||||
|
trashed_from: from.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_lists_newest_deletion_first_and_undated_last() {
|
||||||
|
let mut entries = vec![
|
||||||
|
trashed("undated-old", 0, 1, None),
|
||||||
|
trashed("older", 10, 50, None),
|
||||||
|
trashed("undated-new", 0, 9, None),
|
||||||
|
trashed("newest", 20, 5, None),
|
||||||
|
];
|
||||||
|
sort_trash(&mut entries);
|
||||||
|
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["newest", "older", "undated-new", "undated-old"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_offers_a_folder_only_when_all_share_one() {
|
||||||
|
let docs = trashed("a", 1, 1, Some("Documents"));
|
||||||
|
let docs2 = trashed("b", 1, 1, Some("Documents"));
|
||||||
|
let root = trashed("c", 1, 1, Some(""));
|
||||||
|
let unknown = trashed("d", 1, 1, None);
|
||||||
|
assert_eq!(
|
||||||
|
common_origin(&[docs.clone(), docs2]).as_deref(),
|
||||||
|
Some("Documents")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
common_origin(std::slice::from_ref(&root)).as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert_eq!(common_origin(&[docs.clone(), root]), None);
|
||||||
|
assert_eq!(common_origin(&[docs, unknown]), None);
|
||||||
|
assert_eq!(common_origin(&[]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ pub(crate) fn prompt_human_verification(
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title("Verification")
|
.title(gettext("Verification"))
|
||||||
.content_width(420)
|
.content_width(420)
|
||||||
.content_height(560)
|
.content_height(560)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||