Compare commits

...
52 Commits
Author SHA1 Message Date
nvrl 61266fc9b1 chore(release): 2.5.0 2026-09-28 16:37:23 +02:00
nvrl d18655538e feat(core,fuse,gui): push daemon events, polish Photos, adopt newer widgets
The app and the tray stop polling. pdfs-core adds Request::Subscribe: the
connection stays open and carries Event lines (status, transfers, and
Changed with a Topic), with a heartbeat while quiet. In pdfs-fuse, an
EventHub gives each subscriber a mailbox, code that changes something
publishes its topic, and a sampler publishes the status, transfers and sync
progress when they differ. The app follows the feed on one thread, paints
status and transfers as they arrive, and reloads the page on screen when its
topic changes, including after changes made with the pdfs CLI. A hidden
window keeps the changes and catches up when focused. Front-ends fall back
to polling a daemon that predates subscriptions. Refresh buttons leave the
page headers; F5 and Ctrl+R still reload from the server.

Photos gets a scrubber overlaid on the right edge of the timeline in place
of the slider and scrollbar. It shows the years, appears when the pointer
comes near or the timeline scrolls, names the month under the pointer and
jumps to it on drag. Upload becomes a split button whose menu holds the
Google Photos import. Tiles are pooled per row and rebound instead of
rebuilt, hover scales the thumbnail to 1.02 without a shadow, and tiles
lose their tooltip.

On libadwaita 1.8 or newer the app uses AdwSpinner, AdwToggleGroup,
AdwButtonRow and AdwShortcutsDialog, and keeps look-alike stand-ins on
older releases (src/compat.rs, widgets/compat.rs). It still builds against
libadwaita 1.5 and picks at run time, creating the newer widgets by class
name through GtkBuilder, so every package gets them. PDFS_STAND_IN_WIDGETS
forces the stand-ins.

The Sync and share-link icons draw in full again: GTK drops group
transforms from symbolic icons, so their coordinates are baked into the
paths. All catalogs are updated and complete.
2026-09-28 16:37:23 +02:00
nvrl da7ea0ec56 feat(core,gui): split Sync into views, tidy Activity, rework Computers
The Sync page gets Overview, Folders and History views under a switcher in
the header. Overview holds the status card, conflicts, transfers and the
queue; Folders holds the folder list, with Add Folder and a popover that
explains the sync modes; History is the former Activity page, which leaves
the sidebar. A banner in Folders and History points back to Overview when
changes fail or conflict.

Activity hides save-by-rename temporaries (pdfs-core gains
syncignore::is_scratch_name), with a toggle to show them again. Runs of
similar events fold into one expandable row, "Show Older" pages further back
through what the daemon keeps, and titles and details are translated
sentences built from the daemon's targets instead of its raw text.
Conflict copies say when they were made.

Computers lists every device in one group, this computer first with a
"This computer" pill; its row opens Sync's folder list. Another computer's
row browses its backup read-only in the shared file list, and its menu
offers Browse Files, Restore, Rename, "Continue This Backup Here…" (the old
identity adoption, now explained in its dialog) and Remove. Subtitles read
"last backup" or "no backups yet", and an empty list links to the download
page. DirEntry derives Default for the browse view's synthesized entries.

The sidebar shows badges for sync problems and waiting invitations, polled
every five minutes away from their pages. All catalogs are updated and
complete.
2026-09-28 15:14:59 +02:00
nvrl 127b71efd0 feat(core,fuse,gui): rebuild Trash and shared pages on one file list
The list and grid view from My files moves into widgets/file_list.rs as
FileList, so Trash, Shared with me and Shared by me share its columns,
selection, context menus, keyboard handling and list/grid switch instead of
each drawing its own rows.

Trash shows where each item came from and when it was deleted. Proton does
not expose the trash time through the SDK yet, so the daemon records when it
first sees a node in the trash (migration V35, trash_seen) and reports that;
the date becomes exact once proton-sdk-rs exposes Node::trash_time. Restoring
offers a Show action, and opening a trashed item explains that it needs to be
restored first.

Shared with me splits files and saved links into two views. Files show who
shared them, with a warning pill for unverified senders, the access they give
and when they were shared. Pending invitations sit in a banner that opens a
dialog to accept or decline each one. Several items can be left at once.
Saved links get their own list with open, copy and remove actions.

Shared by me gains a filter (all, links, people, expired links) and columns
for location, people and link state, with a copy button for live links.
Several items can stop being shared at once, the page refreshes after the
share dialog closes, and a reload rebinds only the rows that changed so the
selection survives.

All catalogs are updated and complete.
2026-09-28 14:35:37 +02:00
nvrl 2339b17d97 feat(core,gui): keep pages up while they reload
Pages no longer swap their content for a "Loading…" status on every load.
A new Loader keeps the last rows on screen and only shows the loading
state if the reply takes longer than 300 ms; a newer load always
supersedes an older one, so a late reply is dropped instead of painted.
Reloading what is on screen keeps it usable; loading something else greys
the old rows out and clears the selection, since their paths would
resolve against the new folder.

My files, search results and Trash now swap only the rows that changed,
so selection, scroll position and thumbnails survive a refresh. Activity
is one sectioned ListView: open conflicts first, then a section per day,
with the filter bar pinned above the list.

Polls give up after 5 s instead of 120 s. A daemon that is running but
doesn't answer now reads as "Not responding" in the app and the tray
rather than as disconnected.
2026-09-28 13:51:28 +02:00
nvrl bd4f797d1f feat(gui): polish pass over wording, icons and responsiveness
The Sync page gives each folder one card with its state, a Synced /
Online only switch and a menu for the rest; switching either way asks
first and the download prompt shows the free space on the disk.

Enabling, disabling and restarting the Proton Drive service ran
systemctl on GTK's main thread and froze the window. They now run in
the background, and every Retry button goes through one shared restart
helper.

Ten icons are bundled under resources/icons for glyphs no stock theme
has or that read ambiguously (sync, share, people, link, menu, map and
so on). A new test fails when the app or search prompt asks for a
symbolic icon that is neither bundled nor on a list checked against
Adwaita 50, libadwaita and GTK, or when a bundled icon is missing from
the resource manifest. The tray's sync icon, which Adwaita 50 no longer
ships, is now view-refresh.

Wording follows one glossary, recorded in docs/TRANSLATING.md: "Proton
Drive service" instead of "mount service" or "daemon", "Online only"
instead of "On-demand", "Proton Drive folder" instead of "mountpoint".
Dialog headings and buttons use title case and page titles match the
sidebar. Dates go through one formatter (app/dates.rs) and sizes
through i18n::human_bytes, and the cache label is translated.

The status tick stops while the window is hidden or minimized and runs
every fifth tick while it is unfocused, refreshing as soon as it gains
focus. "Sync complete" is announced only for large batches and only
when the window isn't focused. Smaller fixes: Empty Trash hides when
there is nothing to empty, copy buttons confirm with a check mark, the
sidebar status spins until the first report, and Photos moves Import
and Find Duplicates into a menu beside a labelled Select toggle.

All catalogs are updated and complete.
2026-09-28 13:26:59 +02:00
nvrl e872cad058 ci: move to Node 24 versions of checkout, upload-artifact and action-gh-release
actions/checkout@v4, actions/upload-artifact@v4 and
softprops/action-gh-release@v2 still target Node.js 20, which the runners
now force onto Node.js 24 with a deprecation warning. Their current majors
(checkout v7, upload-artifact v7, action-gh-release v3) run on Node.js 24.
2026-09-28 12:16:54 +02:00
Nils Pukropp 081c8fb889 chore(aur): update packages to 2.4.0 2026-09-28 07:33:52 +00:00
nvrl da35a05b84 chore(release): 2.4.0 2026-09-28 09:16:54 +02:00
nvrl a90eeecda9 feat(core,fuse,gui): find photos that look alike
The duplicate finder gains an Exact / Similar switch. Similar asks the
daemon for PhotoSimilar, which starts a background pass that hashes the
thumbnail of every shot not hashed yet and replies at once with the sets
found so far and how far hashing has got. The dialog shows a progress bar
and asks again until the pass is done, then lists the sets.

The pass takes thumbnails from the server only: a photo without one is
skipped for the run instead of downloaded whole, and the dialog says how
many photos could not be compared. Decoding and hashing are spread over
the machine's cores.

Every similar photo starts ticked Keep, since photos that look alike can
be different shots. A similar photo stands for its whole shot, so one not
kept goes to Trash with the rest of its shot.
2026-09-28 09:16:54 +02:00
nvrl c98ed0d83b feat(core): perceptual hashes for finding similar photos
A 64-bit difference hash of each thumbnail, stored in the new
photos.similar_hash column (schema 34), and the queries the similar-photo
finder needs: what is left to hash, progress, and the sets of shots that
look alike. Sets made only of byte-identical copies are left to the
duplicate finder. A refresh keeps a hash unless the photo's bytes changed.

The daemon pass, the control request and the GUI are not built yet; see
PLAN.md.
2026-09-28 09:16:54 +02:00
Nils Pukropp c46b2c1082 chore(aur): update packages to 2.3.0 2026-09-28 01:42:04 +00:00
nvrl fde9c2d271 feat(core,fuse,gui): Proton theme, photos on the map and an opt-in street map
- The Proton accent preference becomes a whole Proton theme that also
  holds against a GTK theme imported from gtk.css; folders get Proton's
  purple folder icon while it is on.
- Places map markers show one of each town's photos with a count, and
  zoomed in on a town every photo sits where it was taken
  (Request::PlaceLocations). Photos taken on one spot are listed in a
  popover. "Show on map" in the photo viewer goes to the Places map.
- The built-in map draws country borders, sea and land in their own
  colours.
- An opt-in street map from OpenFreeMap (vector tiles drawn with cairo,
  cached on disk up to 256 MB), with the OpenStreetMap credit on the map
  and in About.
- Translations for all catalogs; changelog folded into 2.3.0.
2026-09-28 03:12:57 +02:00
nvrl 4fd14eb745 chore(release): 2.3.0 2026-09-28 02:12:59 +02:00
nvrl b3c03a7162 feat(core,fuse,gui): places on a world map
The Places view gets a Grid/Map toggle. The map shows a bubble per town,
sized by its number of photos, over the outline of the world's land. Towns
whose bubbles would overlap merge into one that counts all their photos, and
clicking it zooms in until they part; clicking a single town opens it like a
card does. Drag to pan, scroll or pinch to zoom, and the buttons zoom in,
zoom out or frame every place again. Hovering names the town, or the first
few towns of a merged bubble.

No tile server is involved: none that is free has terms allowing an app like
this one to use it. The map is drawn from Natural Earth's 1:50m land
polygons (public domain), trimmed and simplified by
scripts/natural-earth-land.py into crates/pdfs-gui/data/land.txt, in Web
Mercator. The About dialog credits Natural Earth.

PlaceInfo carries the town's latitude and longitude; a daemon older than the
map sends none, and its towns are left off the map.
2026-09-28 02:12:59 +02:00
nvrl 8749d6c2c6 feat(core,fuse,gui): show how far photo locations have been read
The first timeline refresh after the Places update reads the metadata of
the whole library, and until it is done the Places view has nothing to show
and no sign that anything is happening.

The refresh now records how many photos it has resolved out of how many,
counting photos read by an earlier pass as done. Response::Places carries
it as mapping (absent from older daemons, which parse as None). The Places
view shows "Mapping photos: 120 of 480" with a bar while it is set, asks
again every two seconds, and loads the new places once the pass ends. An
empty listing during the pass says the photos are being mapped rather than
that there are no places.
2026-09-28 02:12:59 +02:00
nvrl 4ee2aed29b fix(gui): scroll the "On this day" strip with the photos timeline
The strip sat above the timeline's list and was hidden as soon as the list
left its top. Hiding it grew the viewport, which moved the list back to its
top and brought the strip back, so scrolling up near the top shook the whole
page between the two heights.

The strip is now the first row of the list (GalleryRow::Memories), so it
scrolls away with the grid and never resizes the viewport. It is still only
there atop the whole, unfiltered timeline.
2026-09-28 02:12:59 +02:00
nvrl 753056418c feat(core,fuse,gui): places view from photo locations
Photos now know where they were taken. proton-drive-rs 0.7.0 reads the
Location from a photo's extended attributes, and the timeline refresh stores
it with the photo (schema v33). The migration marks every photo unresolved
once, so the next refresh backfills locations from node metadata without
downloading anything.

Towns are looked up offline: pdfs-core bundles GeoNames' cities15000 table,
trimmed by scripts/geonames-cities.py, and files each photo under the
nearest town within 50 km. The new PhotoPlaces and PlacePhotos requests list
the towns and page one town's photos.

The Photos page gets a third view, Places, with a card per town (newest
photo, town, country, count). A place opens in the gallery like an album.
Country names come translated from the iso-codes catalogs, now an optional
dependency. The About dialog credits GeoNames (CC BY 4.0).
2026-09-28 02:12:59 +02:00
nvrl 2757977fd6 feat(gui): one Filters popover for the photos timeline
Favorites, "not in an album" and the month move from separate pills and a
dropdown into a Filters popover next to the kind tabs. The kind tabs stay
in the row because their counts are worth a glance. The button carries a
badge with the number of active filters, kind included, and the popover
has a "Clear filters" action that resets everything in one reload.

Flipping a filter while a page was still loading used to drop the reload,
leaving the timeline answering the old filter. The request is now queued
and the stale page is discarded.
2026-09-28 02:12:59 +02:00
nvrl 2f14c1688f feat(core,fuse,gui): find and trash duplicate photos
A "Find duplicates" button in the Photos header opens a review of every
set of files that share a content hash. Each set shows its copies side by
side with a Keep toggle; the copy in the most albums, then in the larger
RAW+JPEG group, then the oldest, is kept by default, and a set can't lose
its last kept copy. The copies not kept move to Trash one file at a time.

- core: Db::photo_duplicates, Request::PhotoDuplicates, and a files_only
  flag on TrashNodes (defaults to false, so old clients still trash whole
  groups).
- core: deleting a group's representative now clears the group key of its
  remaining files instead of leaving them pointing at a missing uid.
- fuse: handlers for both requests.
2026-09-28 02:12:59 +02:00
nvrl 7dbf818d83 feat(core,fuse,gui): "On this day" strip above the photos timeline
Add a PhotosOnThisDay control request, served from the stored timeline:
photos whose local capture date matches today's in earlier years, one
row per group. The GUI shows a card per year ("3 years ago") with the
year's newest photo as its cover; clicking a card scrolls the timeline
to that day. The strip is revealed only at the top of the whole,
unfiltered timeline, so it gives the grid its room back once you
scroll or filter.
2026-09-28 02:12:59 +02:00
nvrl c3ec82392a fix(gui): keep the photos timeline at its top across a relayout
Anchoring on the first photo row after a relayout scrolled the heading
above it out of view, so the timeline opened a heading's height down.
2026-09-28 02:12:58 +02:00
nvrl ce4656e46a fix(gui): square photo grid laid out to the viewport
Lay the timeline out as a grid of square tiles that spans the
scrolled window's width exactly, instead of justified rows sized from
learned aspect ratios. Track the width from the horizontal adjustment
so the widest row can no longer push the window wider, and put the
scrubber beside the list rather than over it.

Load the app stylesheet above user priority: a theme imported from the
user's gtk.css padded every button, tiles included. Keep the top photo
at its screen offset across a relayout, and only drop thumbnail
requests the unbound row still owns.
2026-09-28 02:12:58 +02:00
nvrl 557eeb5f1d feat(gui): slideshow in the photo viewer
A new top-bar button, or F5, plays the loaded photos as a slideshow: it goes
fullscreen, hides the controls and moves on every five seconds. A video plays
to its end first, and the slideshow waits for each photo to load before it
starts counting. Stepping by hand restarts the count. Escape stops the
slideshow instead of closing the viewer, and fullscreen is left only if the
slideshow entered it. The slideshow ends on the last photo of the timeline.
2026-09-28 02:12:58 +02:00
nvrl b47e6c91a0 feat(gui): group the photos timeline by zoom level, select ranges
Zooming out now changes how the timeline is sectioned: by day at the default
row height, by month further out and by year at the smallest tiles, which
gain one more zoom step. Under a month or year heading the tile caption shows
the day instead of the clock time.

Shift+click selects every photo from the last one clicked, and dragging
across the timeline selects the photos swept over, scrolling at the top and
bottom edges. Sweeping back gives photos up again.
2026-09-28 02:12:58 +02:00
nvrl 5c1da9d7a5 feat(core,fuse,gui,cli): filter the photos timeline to photos in no album
A new pill next to the favorites filter narrows the timeline to the photos
no album of ours holds yet, so what is left to file is easy to find. A
RAW+JPEG pair counts as filed when any of its files is in an album.

PhotosTimeline gains an optional not_in_album flag; older front-ends omit it
and get the whole timeline. Album contents are fetched lazily, so the first
filtered page fetches the contents of any album not yet listed. Photos added
to an album while the filter is on leave the timeline at once.
2026-09-28 02:12:58 +02:00
Nils Pukropp 0135c29a4f chore(aur): update packages to 2.2.2 2026-09-27 21:44:59 +00:00
nvrl 0b81b76cb7 feat(gui): branded app icon in the sidebar header and login
The sidebar header and the login page used the generic folder-remote-symbolic
icon. Ship the app icon from packaging/ in the GUI resources and use it in
both places, so the in-app brand matches the launcher even when the app runs
uninstalled.
2026-09-27 23:28:00 +02:00
nvrl 7470bd89f3 chore(release): 2.2.2 2026-09-27 23:28:00 +02:00
nvrl e6cb52339d fix(fuse,core): list and resolve conflict copies in synced folders, log each once (B99)
The conflict sweep flagged divergent copies anywhere in the tree but kept its
dedupe set in memory, so every daemon start logged them again. The conflict
listing only covered My Files, so copies in on-demand sync folders were never
listed, and the Activity page showed them as resolved while they were still on disk.

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

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

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

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

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

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

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

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

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

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

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
name: AUR
on:
# The -git package computes its pkgver from git at build time, so a new commit
# upstream needs no AUR push. Only a changed -git recipe is published.
push:
branches: [main]
paths:
- 'packaging/aur/proton-drive-for-linux-git/**'
# Called by release.yml once the GitHub release and its assets exist.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
AUR_SSH_PRIVATE_KEY:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
required: false
type: string
permissions:
contents: write
concurrency:
group: aur
cancel-in-progress: false
jobs:
publish:
name: Publish AUR packages
runs-on: ubuntu-24.04
container: archlinux:base-devel
env:
# makepkg refuses to run as root, so the packaging steps run as `builder`.
HOME: /home/builder
VERSION: ${{ inputs.version }}
steps:
- name: Install packaging tools
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
- name: Checkout repository
uses: actions/checkout@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
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
rust:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install native dependencies
run: |
+16 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install system dependencies
run: |
@@ -31,6 +31,7 @@ jobs:
libsecret-1-dev \
libdbus-1-dev \
desktop-file-utils \
gettext \
rpm
- name: Set up Rust
@@ -84,6 +85,7 @@ jobs:
cp target/release/pdfs-tray dist/binaries/
cp target/release/pdfs-app dist/binaries/
cp target/release/pdfs-prompt dist/binaries/
po/build.sh dist/binaries/locale
# Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
@@ -110,6 +112,7 @@ jobs:
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale
# Calculate Installed-Size in KB for the control file
INSTALLED_SIZE=$(du -sk deb | cut -f1)
@@ -178,7 +181,7 @@ jobs:
- name: Upload Workflow Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: proton-drive-linux-builds
path: |
@@ -189,7 +192,7 @@ jobs:
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
*.deb
@@ -199,3 +202,13 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
aur:
name: Publish AUR packages
needs: build-release
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/aur.yml
with:
version: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
Generated
+131 -8
View File
@@ -467,6 +467,12 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1814,6 +1820,26 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gettext-rs"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf"
dependencies = [
"gettext-sys",
"locale_config",
]
[[package]]
name = "gettext-sys"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9"
dependencies = [
"cc",
"temp-dir",
]
[[package]]
name = "ghash"
version = "0.5.1"
@@ -2829,6 +2855,19 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "locale_config"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934"
dependencies = [
"lazy_static",
"objc",
"objc-foundation",
"regex",
"winapi",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2868,6 +2907,15 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3189,6 +3237,35 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "ocb3"
version = "0.1.0"
@@ -3312,6 +3389,31 @@ dependencies = [
"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]]
name = "parking"
version = "2.2.1"
@@ -3372,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pdfs-cli"
version = "2.0.0"
version = "2.5.0"
dependencies = [
"anyhow",
"clap",
@@ -3387,7 +3489,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "2.0.0"
version = "2.5.0"
dependencies = [
"async-trait",
"dbus-secret-service-keyring-store",
@@ -3411,7 +3513,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "2.0.0"
version = "2.5.0"
dependencies = [
"base64 0.23.1",
"fuser",
@@ -3431,15 +3533,18 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "2.0.0"
version = "2.5.0"
dependencies = [
"anyhow",
"async-channel",
"gettext-rs",
"gtk4",
"kamadak-exif",
"ksni",
"libadwaita",
"pangocairo",
"pdfs-core",
"reqwest",
"serde",
"serde_json",
"tokio",
@@ -3668,9 +3773,9 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.6.6"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757"
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3695,9 +3800,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.6.6"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce"
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
dependencies = [
"aes-gcm 0.11.1",
"async-trait",
@@ -4055,6 +4160,18 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -4796,6 +4913,12 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "temp-dir"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964"
[[package]]
name = "tempfile"
version = "3.27.0"
+3 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "2.0.0"
version = "2.5.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
proton-sdk = { version = "0.6.6" }
proton-drive-rs = { version = "0.6.6" }
proton-sdk = { version = "0.7.0" }
proton-drive-rs = { version = "0.7.0" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
+12 -3
View File
@@ -55,7 +55,7 @@ pdfs-app # the desktop app
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) · [Translating](docs/TRANSLATING.md) ·
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
@@ -75,6 +75,7 @@ pdfs-app # the desktop app
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines and country borders (public domain). Each town is marked with one of its photos, and zoomed in, every photo sits where it was taken. A street map from [OpenFreeMap](https://openfreemap.org/) (© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors) can be turned on under Appearance; it is off by default because the tile server learns which parts of the world you look at. Install `iso-codes` for country names in your language.
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
@@ -348,11 +349,15 @@ Version 1.0 strengthens the boundaries around local-only data and destructive re
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
<td align="center" width="50%"><img src="images/restore_devices.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search Launcher Prompt (GTK)" width="100%"><br><sub><b>Search Launcher Prompt (GTK)</b></sub></td>
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search Launcher Prompt (fzf)" width="100%"><br><sub><b>Search Launcher Prompt (fzf)</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/activity.png" alt="Activity" width="100%"><br><sub><b>Activity</b></sub></td>
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
</tr>
</table>
@@ -499,6 +504,10 @@ Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
vulnerability privately.
The app, tray and search prompt are available in English, German, Spanish, French, Italian, Dutch,
Polish and Brazilian Portuguese. [docs/TRANSLATING.md](docs/TRANSLATING.md) explains how to improve
a translation or add a language.
## License
Released under the [MIT License](LICENSE).
+3
View File
@@ -2074,6 +2074,7 @@ fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
kind: None,
range: None,
favorites,
not_in_album: false,
})? {
CtlResponse::Photos {
available: false, ..
@@ -3363,6 +3364,8 @@ mod login_wait_tests {
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
};
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
entry.shared_by = "alice@proton.me".into();
File diff suppressed because it is too large. Load diff
+250
View File
@@ -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
1 AD Andorra
2 AE United Arab Emirates
3 AF Afghanistan
4 AG Antigua and Barbuda
5 AI Anguilla
6 AL Albania
7 AM Armenia
8 AO Angola
9 AQ Antarctica
10 AR Argentina
11 AS American Samoa
12 AT Austria
13 AU Australia
14 AW Aruba
15 AX Åland Islands
16 AZ Azerbaijan
17 BA Bosnia and Herzegovina
18 BB Barbados
19 BD Bangladesh
20 BE Belgium
21 BF Burkina Faso
22 BG Bulgaria
23 BH Bahrain
24 BI Burundi
25 BJ Benin
26 BL Saint Barthélemy
27 BM Bermuda
28 BN Brunei Darussalam
29 BO Bolivia, Plurinational State of
30 BQ Bonaire, Sint Eustatius and Saba
31 BR Brazil
32 BS Bahamas
33 BT Bhutan
34 BV Bouvet Island
35 BW Botswana
36 BY Belarus
37 BZ Belize
38 CA Canada
39 CC Cocos (Keeling) Islands
40 CD Congo, The Democratic Republic of the
41 CF Central African Republic
42 CG Congo
43 CH Switzerland
44 CI Côte d'Ivoire
45 CK Cook Islands
46 CL Chile
47 CM Cameroon
48 CN China
49 CO Colombia
50 CR Costa Rica
51 CU Cuba
52 CV Cabo Verde
53 CW Curaçao
54 CX Christmas Island
55 CY Cyprus
56 CZ Czechia
57 DE Germany
58 DJ Djibouti
59 DK Denmark
60 DM Dominica
61 DO Dominican Republic
62 DZ Algeria
63 EC Ecuador
64 EE Estonia
65 EG Egypt
66 EH Western Sahara
67 ER Eritrea
68 ES Spain
69 ET Ethiopia
70 FI Finland
71 FJ Fiji
72 FK Falkland Islands (Malvinas)
73 FM Micronesia, Federated States of
74 FO Faroe Islands
75 FR France
76 GA Gabon
77 GB United Kingdom
78 GD Grenada
79 GE Georgia
80 GF French Guiana
81 GG Guernsey
82 GH Ghana
83 GI Gibraltar
84 GL Greenland
85 GM Gambia
86 GN Guinea
87 GP Guadeloupe
88 GQ Equatorial Guinea
89 GR Greece
90 GS South Georgia and the South Sandwich Islands
91 GT Guatemala
92 GU Guam
93 GW Guinea-Bissau
94 GY Guyana
95 HK Hong Kong
96 HM Heard Island and McDonald Islands
97 HN Honduras
98 HR Croatia
99 HT Haiti
100 HU Hungary
101 ID Indonesia
102 IE Ireland
103 IL Israel
104 IM Isle of Man
105 IN India
106 IO British Indian Ocean Territory
107 IQ Iraq
108 IR Iran, Islamic Republic of
109 IS Iceland
110 IT Italy
111 JE Jersey
112 JM Jamaica
113 JO Jordan
114 JP Japan
115 KE Kenya
116 KG Kyrgyzstan
117 KH Cambodia
118 KI Kiribati
119 KM Comoros
120 KN Saint Kitts and Nevis
121 KP Korea, Democratic People's Republic of
122 KR Korea, Republic of
123 KW Kuwait
124 KY Cayman Islands
125 KZ Kazakhstan
126 LA Lao People's Democratic Republic
127 LB Lebanon
128 LC Saint Lucia
129 LI Liechtenstein
130 LK Sri Lanka
131 LR Liberia
132 LS Lesotho
133 LT Lithuania
134 LU Luxembourg
135 LV Latvia
136 LY Libya
137 MA Morocco
138 MC Monaco
139 MD Moldova, Republic of
140 ME Montenegro
141 MF Saint Martin (French part)
142 MG Madagascar
143 MH Marshall Islands
144 MK North Macedonia
145 ML Mali
146 MM Myanmar
147 MN Mongolia
148 MO Macao
149 MP Northern Mariana Islands
150 MQ Martinique
151 MR Mauritania
152 MS Montserrat
153 MT Malta
154 MU Mauritius
155 MV Maldives
156 MW Malawi
157 MX Mexico
158 MY Malaysia
159 MZ Mozambique
160 NA Namibia
161 NC New Caledonia
162 NE Niger
163 NF Norfolk Island
164 NG Nigeria
165 NI Nicaragua
166 NL Netherlands
167 NO Norway
168 NP Nepal
169 NR Nauru
170 NU Niue
171 NZ New Zealand
172 OM Oman
173 PA Panama
174 PE Peru
175 PF French Polynesia
176 PG Papua New Guinea
177 PH Philippines
178 PK Pakistan
179 PL Poland
180 PM Saint Pierre and Miquelon
181 PN Pitcairn
182 PR Puerto Rico
183 PS Palestine, State of
184 PT Portugal
185 PW Palau
186 PY Paraguay
187 QA Qatar
188 RE Réunion
189 RO Romania
190 RS Serbia
191 RU Russian Federation
192 RW Rwanda
193 SA Saudi Arabia
194 SB Solomon Islands
195 SC Seychelles
196 SD Sudan
197 SE Sweden
198 SG Singapore
199 SH Saint Helena, Ascension and Tristan da Cunha
200 SI Slovenia
201 SJ Svalbard and Jan Mayen
202 SK Slovakia
203 SL Sierra Leone
204 SM San Marino
205 SN Senegal
206 SO Somalia
207 SR Suriname
208 SS South Sudan
209 ST Sao Tome and Principe
210 SV El Salvador
211 SX Sint Maarten (Dutch part)
212 SY Syrian Arab Republic
213 SZ Eswatini
214 TC Turks and Caicos Islands
215 TD Chad
216 TF French Southern Territories
217 TG Togo
218 TH Thailand
219 TJ Tajikistan
220 TK Tokelau
221 TL Timor-Leste
222 TM Turkmenistan
223 TN Tunisia
224 TO Tonga
225 TR Türkiye
226 TT Trinidad and Tobago
227 TV Tuvalu
228 TW Taiwan, Province of China
229 TZ Tanzania, United Republic of
230 UA Ukraine
231 UG Uganda
232 UM United States Minor Outlying Islands
233 US United States
234 UY Uruguay
235 UZ Uzbekistan
236 VA Holy See (Vatican City State)
237 VC Saint Vincent and the Grenadines
238 VE Venezuela, Bolivarian Republic of
239 VG Virgin Islands, British
240 VI Virgin Islands, U.S.
241 VN Viet Nam
242 VU Vanuatu
243 WF Wallis and Futuna
244 WS Samoa
245 XK Kosovo
246 YE Yemen
247 YT Mayotte
248 ZA South Africa
249 ZM Zambia
250 ZW Zimbabwe
+38 -8
View File
@@ -128,11 +128,12 @@ pub struct AppConfig {
/// field.
#[serde(default)]
pub prompt: Option<PromptConfig>,
/// Paint the desktop app in Proton purple instead of the system accent
/// colour. `None` means "follow the system". Defaulted for configs
/// predating the field.
#[serde(default)]
pub proton_accent: Option<bool>,
/// 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)]
@@ -142,6 +143,17 @@ pub struct AppConfig {
/// back on. Defaulted for configs predating the field.
#[serde(default)]
pub tray_hidden: bool,
/// Language for the desktop app, tray and search prompt, as a gettext code
/// such as `de` or `pt_BR`. `None` means "follow the system locale".
/// Defaulted for configs predating the field.
#[serde(default)]
pub language: Option<String>,
/// 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.
@@ -211,9 +223,11 @@ impl Default for AppConfig {
conflict_sweep: None,
open_with: None,
prompt: None,
proton_accent: None,
proton_theme: None,
files_view: FilesView::default(),
tray_hidden: false,
language: None,
online_map: false,
}
}
}
@@ -560,7 +574,7 @@ mod tests {
conflict_sweep: Some(SweepMode::Enforce),
open_with: None,
prompt: None,
proton_accent: Some(true),
proton_theme: Some(true),
files_view: FilesView {
list: true,
sort: FileSort::Modified,
@@ -568,13 +582,17 @@ mod tests {
folders_first: false,
},
tray_hidden: true,
language: Some("de".to_string()),
online_map: true,
};
let json = serde_json::to_string(&config).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.user_agent, "test-agent/1.0");
assert_eq!(decoded.proton_accent, Some(true));
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);
@@ -590,6 +608,14 @@ mod tests {
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]
fn test_default_app_config() {
let default_config = AppConfig::default();
@@ -661,6 +687,10 @@ mod tests {
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert!(decoded.ignore_patterns.is_none());
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
assert!(
decoded.language.is_none(),
"an old config follows the system"
);
assert_eq!(
decoded.resolved_ignore_patterns().len(),
DEFAULT_IGNORE_PATTERNS.len(),
+532 -13
View File
@@ -31,6 +31,13 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
/// answers.
const READ_TIMEOUT: Duration = Duration::from_secs(120);
/// Cap on the read half for the requests front ends poll ([`Request::is_poll`]).
/// The daemon answers those from memory or its database, so a reply that takes
/// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would
/// freeze the poller's read-out for two minutes; failing fast lets it say "not
/// responding" and ask again on its next tick.
const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5);
/// A command sent from the CLI to the daemon.
#[derive(Serialize, Deserialize, Debug)]
pub enum Request {
@@ -67,6 +74,10 @@ pub enum Request {
/// get the whole timeline, as before.
#[serde(default)]
favorites: bool,
/// Restrict the page to photos no album of ours holds — what is left to
/// file. Older front-ends omit it and get the whole timeline, as before.
#[serde(default)]
not_in_album: bool,
},
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
/// live photo and its clip. Replies with [`Response::Photos`], holding one
@@ -80,6 +91,34 @@ pub enum Request {
#[serde(default)]
kind: Option<PhotoKind>,
},
/// Photos taken on today's date in earlier years, newest first, at most
/// `limit` of them — the "On this day" strip above the timeline. Replies
/// with [`Response::Photos`]; a front-end groups them by year itself.
PhotosOnThisDay { limit: usize },
/// Sets of photos that are byte-identical copies of each other, going by
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
PhotoDuplicates,
/// Sets of shots that look alike going by their thumbnails: resized or
/// re-encoded copies, and bursts of one moment. Starts hashing the
/// thumbnails not hashed yet in the background and replies at once with
/// the sets found so far; a front-end asks again until the reply's
/// `hashing` is `None`. Replies with [`Response::PhotoSimilar`].
PhotoSimilar,
/// The towns the photos were taken in, the one with the most photos first.
/// Only photos whose uploader recorded a location count. Replies with
/// [`Response::Places`].
PhotoPlaces,
/// Fetch a page of the photos taken in one town, newest first. `id` is the
/// town's id from [`Request::PhotoPlaces`]. Replies with
/// [`Response::Photos`], like an album page.
PlacePhotos {
id: u32,
offset: usize,
limit: usize,
},
/// Every photo taken in one town with where exactly it was taken, for the
/// map's close-up. Replies with [`Response::PhotoLocations`].
PlaceLocations { id: u32 },
/// List the account's photo albums, newest activity first, including the
/// albums other people share with us. Metadata only — an album's cover
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
@@ -126,8 +165,14 @@ pub enum Request {
///
/// A photo that belongs to a group takes the whole group with it: trashing
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
/// deleting that photo.
TrashNodes { uids: Vec<String> },
/// deleting that photo. With `files_only` set, exactly the listed files
/// go and their groups stay: the duplicate finder removes one copy of a
/// shot, not the shot. Older front-ends omit it and trash whole groups.
TrashNodes {
uids: Vec<String>,
#[serde(default)]
files_only: bool,
},
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
/// timeline. The modification time is the cache validity tag, so replacing
/// an image can never reuse its previous revision's thumbnail.
@@ -429,10 +474,12 @@ pub enum Request {
/// Retry a backed-off queued op now instead of waiting out its backoff;
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
RetryPendingOp { id: Option<i64> },
/// List the `(sync-conflict …)` copies under My Files. Replies with
/// List the `(sync-conflict …)` copies under My Files and in synced
/// folders. Replies with
/// [`Response::Conflicts`].
ListConflicts,
/// Resolve one conflict copy (`path` mountpoint-relative or absolute).
/// Resolve one conflict copy (`path` mountpoint-relative, or absolute for a
/// copy in a synced folder).
/// Everything removed goes to the Proton trash. Replies with
/// [`Response::Ok`].
ResolveConflict { path: String, keep: ConflictKeep },
@@ -632,8 +679,88 @@ pub enum Request {
/// passes, …), so a front-end can show a running "what happened" feed
/// without re-deriving it from anywhere.
ListActivity { limit: usize },
// ---- push -------------------------------------------------------------
/// Keep the connection open and stream [`Event`] lines instead of one
/// [`Response`]. See [`subscribe`] for the client side.
Subscribe,
}
/// Something a front-end shows that the daemon can say has changed, so the
/// front-end reads it again. Carried by [`Event::Changed`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Topic {
/// Folder listings in the mount: something was created, renamed, moved,
/// trashed or pinned, here or on another device.
Files,
/// The photos timeline or albums.
Photos,
Trash,
/// Shares, links, invitations and saved links, in either direction.
Shares,
/// The account's computers.
Devices,
/// Synced folders, their mode and their live progress.
Locations,
/// The pending-op queue ([`Request::ListPendingOps`]).
Queue,
Conflicts,
/// The activity log gained entries.
Activity,
/// A topic from a newer daemon, which this front-end cannot act on.
#[serde(other)]
Unknown,
}
impl Topic {
/// Every topic a front-end can act on. A new subscription is told all of
/// them have changed, so it catches up on whatever happened while it was
/// not connected.
pub const ALL: [Topic; 9] = [
Topic::Files,
Topic::Photos,
Topic::Trash,
Topic::Shares,
Topic::Devices,
Topic::Locations,
Topic::Queue,
Topic::Conflicts,
Topic::Activity,
];
}
/// One line on a [`Request::Subscribe`] connection.
///
/// The two things front ends show continuously, status and transfers, come
/// with their contents, so following them costs no requests. Everything else is
/// a [`Topic`] to re-read.
#[derive(Serialize, Deserialize, Debug)]
pub enum Event {
/// A fresh [`Response::Status`]. Sent when a subscription starts and
/// whenever any of its fields changes.
Status(Box<Response>),
/// A fresh [`Response::Transfers`] snapshot. Sent when a subscription
/// starts and whenever the snapshot changes, which is continuously while
/// something moves.
Transfers {
items: Vec<TransferItem>,
jobs: Vec<JobItem>,
},
/// These topics changed. Changes close together arrive as one event.
Changed { topics: Vec<Topic> },
/// Nothing happened. Sent every [`HEARTBEAT`] so a client can tell a quiet
/// daemon from a stuck one.
Heartbeat,
}
/// How often a quiet subscription sends [`Event::Heartbeat`].
pub const HEARTBEAT: Duration = Duration::from_secs(10);
/// How long [`Subscription::next_event`] waits for a line before it gives the
/// daemon up as stuck: a few missed heartbeats.
const SUBSCRIBE_READ_TIMEOUT: Duration = Duration::from_secs(25);
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
@@ -699,11 +826,12 @@ pub struct DeviceInfo {
/// One `(sync-conflict …)` copy and the file it is a copy of (in
/// [`Response::Conflicts`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ConflictInfo {
/// Mountpoint-relative path of the copy.
/// Mountpoint-relative path of the copy, or its absolute local path when it
/// sits in a synced folder.
pub path: String,
/// Mountpoint-relative path of the file it is a copy of.
/// Path of the file it is a copy of, in the same form as `path`.
pub original_path: String,
/// False when the original is gone and only the copy is left.
pub original_exists: bool,
@@ -857,7 +985,7 @@ pub struct ShareEntry {
}
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PublicLinkInfo {
/// Public-link id — the handle for [`Request::RemovePublicLink`].
pub id: String,
@@ -870,6 +998,9 @@ pub struct PublicLinkInfo {
pub expires: Option<i64>,
/// Whether a custom password additionally protects the link.
pub has_password: bool,
/// When the link was made, epoch seconds; 0 when unknown.
#[serde(default)]
pub created: i64,
}
/// One invitation addressed to me (in [`Response::Invitations`]).
@@ -902,7 +1033,7 @@ pub struct BookmarkInfo {
/// the share's state so the "Shared" view renders in one pass: how many people
/// have access, how many invitations are still pending, and the public link if
/// the node has one.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct SharedItem {
/// Node uid in `volume~link` form — the handle for opening the node's share
/// (via its mount path) or its details.
@@ -984,7 +1115,7 @@ pub enum ActivityKind {
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
/// first. Records a mutation or transfer the daemon performed, with enough
/// context to read as a sentence: "Uploaded report.pdf to /docs".
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ActivityEntry {
/// When it happened, epoch seconds.
pub time: i64,
@@ -1042,7 +1173,7 @@ pub struct TransferItem {
}
/// One entry in a [`Request::ListDir`] listing.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct DirEntry {
/// Decrypted node name.
pub name: String,
@@ -1090,6 +1221,16 @@ pub struct DirEntry {
/// warning worth showing.
#[serde(default)]
pub shared_by_unverified: bool,
/// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0
/// when unknown, and for anything not in the trash. The API does not report
/// it to this client, so the daemon notes when it first saw the node there.
#[serde(default)]
pub trashed_at: i64,
/// The folder a [`Request::ListTrash`] entry goes back to on restore, as a
/// mountpoint-relative path (`""` for the root). `None` when that folder is
/// not known, and for anything not in the trash.
#[serde(default)]
pub trashed_from: Option<String>,
}
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
@@ -1415,6 +1556,45 @@ pub struct AlbumInfo {
pub shared: bool,
}
/// One town in a [`Response::Places`] listing.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PlaceInfo {
/// The town's GeoNames id — what [`Request::PlacePhotos`] takes.
pub id: u32,
/// The town's name, as the town itself spells it.
pub name: String,
/// ISO 3166-1 alpha-2 code of the town's country.
pub country: String,
/// Where the town is, in decimal degrees, for the map. Zero from a daemon
/// older than the map.
#[serde(default)]
pub latitude: f64,
#[serde(default)]
pub longitude: f64,
/// How many photos were taken there, counting a RAW+JPEG shot once.
pub photo_count: usize,
/// The newest photo taken there, shown as the place's cover.
pub cover: PhotoItem,
}
/// One photo and where it was taken, in decimal degrees. Part of
/// [`Response::PhotoLocations`].
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoLocation {
pub photo: PhotoItem,
pub latitude: f64,
pub longitude: f64,
}
/// How many of the timeline's photos a running refresh has read the metadata
/// (and so the location) of, out of how many. Part of [`Response::Places`],
/// and of [`Response::PhotoSimilar`] for how many shots have been hashed.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MappingProgress {
pub done: usize,
pub total: usize,
}
/// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem {
@@ -1773,6 +1953,31 @@ pub enum Response {
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first.
PhotoMonths { months: Vec<PhotoMonth> },
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
/// first. Each set lists the copy worth keeping first.
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
/// The sets of alike shots (reply to [`Request::PhotoSimilar`]), newest
/// first, each in capture order; and how far hashing has got, `None`
/// once no pass is running. `uncompared` counts the shots left out
/// because no thumbnail could be read for them.
PhotoSimilar {
sets: Vec<Vec<PhotoItem>>,
#[serde(default)]
hashing: Option<MappingProgress>,
#[serde(default)]
uncompared: usize,
},
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
/// and how far a running timeline refresh has got reading photo locations
/// — `None` when none is reading any.
Places {
items: Vec<PlaceInfo>,
#[serde(default)]
mapping: Option<MappingProgress>,
},
/// The photos of one town, each with its location (reply to
/// [`Request::PlaceLocations`]).
PhotoLocations { items: Vec<PhotoLocation> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
@@ -1941,13 +2146,45 @@ impl ErrorKind {
}
}
impl Request {
/// Whether this is one of the cheap state reads that front ends repeat on a
/// timer. The daemon answers these without touching the network, so they
/// get the short [`POLL_READ_TIMEOUT`].
pub fn is_poll(&self) -> bool {
matches!(
self,
Request::Status
| Request::GetQueueStatus
| Request::ListLocations
| Request::ListPendingOps
| Request::ListConflicts
| Request::ListActivity { .. }
| Request::ThumbnailBuildStatus
| Request::PhotosRefreshStatus
| Request::RedateStatus
| Request::ImportStatus
)
}
/// How long to wait for the daemon's reply to this request.
pub fn read_timeout(&self) -> Duration {
if self.is_poll() {
POLL_READ_TIMEOUT
} else {
READ_TIMEOUT
}
}
}
/// Send one [`Request`] to the daemon listening on `socket` and read its
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and
/// one for which [`crate::Error::is_timeout`] holds if the daemon took longer
/// than [`Request::read_timeout`] to answer.
///
/// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(READ_TIMEOUT))?;
stream.set_read_timeout(Some(req.read_timeout()))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
let mut line = serde_json::to_vec(req)?;
line.push(b'\n');
@@ -1959,10 +2196,232 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
Ok(serde_json::from_str(resp.trim())?)
}
/// An open [`Request::Subscribe`] connection. See [`subscribe`].
pub struct Subscription {
reader: BufReader<UnixStream>,
line: String,
}
/// Start following the daemon listening on `socket`. Fails if no daemon is
/// listening. A daemon too old to know [`Request::Subscribe`] answers it with an
/// error, which the first [`Subscription::next_event`] returns as
/// [`crate::Error::Other`].
pub fn subscribe(socket: &Path) -> Result<Subscription> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(SUBSCRIBE_READ_TIMEOUT))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
let mut line = serde_json::to_vec(&Request::Subscribe)?;
line.push(b'\n');
stream.write_all(&line)?;
stream.flush()?;
Ok(Subscription {
reader: BufReader::new(stream),
line: String::new(),
})
}
impl Subscription {
/// Wait for the next event. `Ok(None)` means the daemon closed the
/// connection; a timeout means it went [`SUBSCRIBE_READ_TIMEOUT`] without
/// even a heartbeat. A line from a newer daemon that this client cannot
/// read is skipped rather than ending the subscription.
pub fn next_event(&mut self) -> Result<Option<Event>> {
loop {
self.line.clear();
if self.reader.read_line(&mut self.line)? == 0 {
return Ok(None);
}
let line = self.line.trim();
if let Ok(event) = serde_json::from_str::<Event>(line) {
return Ok(Some(event));
}
if let Ok(Response::Error { message, .. }) = serde_json::from_str::<Response>(line) {
return Err(crate::Error::Other(message));
}
}
}
}
/// What [`follow`] reports.
#[derive(Debug)]
pub enum Feed {
/// A subscription started. The events after it describe the daemon's
/// current state, so a front-end catches up from them.
Connected,
Event(Event),
/// The subscription ended: the daemon stopped, or stopped answering.
Disconnected,
}
/// How often [`follow`] polls a daemon that cannot push events.
const LEGACY_POLL: Duration = Duration::from_secs(3);
/// The longest [`follow`] waits between attempts to reach a daemon.
const RECONNECT_MAX: Duration = Duration::from_secs(5);
/// Follow the daemon on `socket` for as long as `on` returns true, across
/// daemon restarts. Blocks, so run it on a thread of its own.
///
/// A daemon that predates [`Request::Subscribe`] (still running after an
/// upgrade, until its service restarts) is polled for its status and transfers
/// instead, so a front-end sees the same events either way, only later.
pub fn follow(socket: &Path, mut on: impl FnMut(Feed) -> bool) {
let mut connected = false;
let mut backoff = Duration::from_secs(1);
loop {
let ended = match subscribe(socket) {
Ok(mut subscription) => {
let mut first = true;
loop {
match subscription.next_event() {
Ok(Some(event)) => {
if first {
first = false;
connected = true;
backoff = Duration::from_secs(1);
if !on(Feed::Connected) {
return;
}
}
if !on(Feed::Event(event)) {
return;
}
}
Ok(None) => break Ok(()),
Err(e) => break Err(e),
}
}
}
Err(e) => Err(e),
};
if let Err(crate::Error::Other(_)) = ended {
if !connected {
connected = true;
if !on(Feed::Connected) {
return;
}
}
if !poll_legacy(socket, &mut on) {
return;
}
std::thread::sleep(LEGACY_POLL);
continue;
}
if connected {
connected = false;
if !on(Feed::Disconnected) {
return;
}
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(RECONNECT_MAX);
}
}
/// One round of [`follow`]'s fallback: the status and transfers a subscription
/// would have pushed. Returns what `on` returned.
fn poll_legacy(socket: &Path, on: &mut impl FnMut(Feed) -> bool) -> bool {
match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => {
if !on(Feed::Event(Event::Status(Box::new(status)))) {
return false;
}
}
_ => return true,
}
if let Ok(Response::Transfers { items, jobs }) = send(socket, &Request::GetQueueStatus) {
return on(Feed::Event(Event::Transfers { items, jobs }));
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polls_wait_briefly_and_real_work_waits_long() {
assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT);
assert_eq!(
Request::ListActivity { limit: 200 }.read_timeout(),
POLL_READ_TIMEOUT
);
assert_eq!(
Request::OpenFile {
path: "a.txt".into(),
uid: None,
}
.read_timeout(),
READ_TIMEOUT
);
}
/// A daemon on a fresh socket that reads the subscription request and
/// answers with `lines`, then hangs up.
fn fake_daemon(name: &str, lines: &'static str) -> std::path::PathBuf {
let socket = std::env::temp_dir().join(format!("pdfs-{name}-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket);
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut request = String::new();
reader.read_line(&mut request).unwrap();
assert!(matches!(
serde_json::from_str::<Request>(request.trim()),
Ok(Request::Subscribe)
));
reader.get_mut().write_all(lines.as_bytes()).unwrap();
});
socket
}
#[test]
fn a_subscription_skips_what_it_cannot_read() {
let socket = fake_daemon(
"subscribe",
"not json\n\
{\"Changed\":{\"topics\":[\"files\",\"from_a_newer_daemon\"]}}\n\
{\"NewEvent\":{}}\n\
\"Heartbeat\"\n",
);
let mut subscription = subscribe(&socket).unwrap();
let first = subscription.next_event().unwrap();
let second = subscription.next_event().unwrap();
let end = subscription.next_event().unwrap();
let _ = std::fs::remove_file(&socket);
match first {
Some(Event::Changed { topics }) => {
assert_eq!(topics, vec![Topic::Files, Topic::Unknown]);
}
other => panic!("expected a change, got {other:?}"),
}
assert!(matches!(second, Some(Event::Heartbeat)), "{second:?}");
assert!(end.is_none(), "{end:?}");
}
#[test]
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
let mut subscription = subscribe(&socket).unwrap();
let answer = subscription.next_event();
let _ = std::fs::remove_file(&socket);
assert!(matches!(answer, Err(crate::Error::Other(_))), "{answer:?}");
}
#[test]
fn a_daemon_that_never_answers_times_out_as_not_responding() {
let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket);
// Bound but never accepted: connect succeeds, no reply ever comes.
let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let started = std::time::Instant::now();
let err = send(&socket, &Request::Status).unwrap_err();
let _ = std::fs::remove_file(&socket);
assert!(err.is_timeout(), "{err}");
assert!(started.elapsed() < READ_TIMEOUT);
}
#[test]
fn photo_kind_classifies_by_extension_then_mime() {
use PhotoKind::*;
@@ -2169,7 +2628,17 @@ mod tests {
fn trash_requests_and_outcomes_roundtrip() {
let request = Request::TrashNodes {
uids: vec!["volume~first".into(), "volume~second".into()],
files_only: false,
};
// An older front-end's request trashes whole groups.
let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap();
assert!(matches!(
old,
Request::TrashNodes {
files_only: false,
..
}
));
let line = serde_json::to_string(&request).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let decoded: Request = serde_json::from_str(&line).unwrap();
@@ -2456,6 +2925,55 @@ mod tests {
}
}
/// A place listing and a place page cross the wire intact: a mangled id
/// would open the wrong town.
#[test]
fn places_requests_and_reply_roundtrip() {
for req in [
Request::PhotoPlaces,
Request::PlacePhotos {
id: 2950159,
offset: 60,
limit: 30,
},
Request::PlaceLocations { id: 2950159 },
] {
let line = serde_json::to_string(&req).unwrap();
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2,
"cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#;
let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else {
panic!("not a place listing");
};
assert_eq!(items.len(), 1);
assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE"));
assert_eq!(items[0].cover.uid, "vol~b2");
// A daemon from before the progress field still parses: no mapping.
assert_eq!(mapping, None);
let reply = Response::Places {
items: Vec::new(),
mapping: Some(MappingProgress {
done: 120,
total: 480,
}),
};
let line = serde_json::to_string(&reply).unwrap();
let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else {
panic!("not a place listing");
};
assert_eq!(
mapping,
Some(MappingProgress {
done: 120,
total: 480
})
);
}
/// The sharing and devices requests must survive the same line-delimited JSON
/// round-trip: a mangled id or role would touch the wrong share or member.
#[test]
@@ -2787,6 +3305,7 @@ mod tests {
decoded,
Request::PhotosTimeline {
favorites: false,
not_in_album: false,
kind: None,
range: None,
..
+11
View File
@@ -29,6 +29,17 @@ impl Db {
Ok(())
}
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
/// the daemon report something once across restarts rather than once per
/// run, without keeping a second table in step with the log.
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
let kind = serde_json::to_string(&kind)?;
let conn = self.read();
let mut stmt =
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
Ok(stmt.exists(params![kind, target])?)
}
/// The most recent activity, newest first, capped at `limit` entries. Rows
/// whose stored `kind` no longer parses (written by an older build) are
/// skipped rather than failing the whole read.
+108 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 31;
pub(super) const SCHEMA_VERSION: i64 = 35;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -270,6 +270,68 @@ impl Db {
tx.execute_batch(MIGRATION_V31)?;
}
}
if current < 32 {
// Data only, so safe to repeat; each table is guarded for a very
// old fixture that never had it.
for table in ["photos", "album_photos"] {
let has_table: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, i64>(0),
)? > 0;
if has_table {
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
}
}
}
if current < 33 {
// Same guards as V26-V31.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V33)?;
}
}
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(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -952,3 +1014,48 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
";
/// V32: give videos written off as un-thumbnailable another chance.
///
/// Before local video thumbnails, the fallback fed a video's bytes to the image
/// decoder, which could only fail, and that failure was recorded as the
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
/// Schema v33: where each photo was taken.
///
/// `latitude` and `longitude` come from the location other clients write into
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
///
/// Every photo is marked unresolved, so the next refresh reads the location of
/// the whole library once. That is the backfill: the metadata is on the node
/// the refresh reads anyway, and nothing has to be downloaded.
const MIGRATION_V33: &str = "
ALTER TABLE photos ADD COLUMN latitude REAL;
ALTER TABLE photos ADD COLUMN longitude REAL;
ALTER TABLE photos ADD COLUMN place_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
UPDATE photos SET resolved_at = NULL;
";
/// 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;
";
+1 -1
View File
@@ -59,7 +59,7 @@ pub use ops::{
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
+307 -5
View File
@@ -3,7 +3,7 @@
use rusqlite::params;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::Db;
use crate::Result;
@@ -42,6 +42,15 @@ pub struct StoredPhoto {
pub has_raw: bool,
}
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
/// many photos it holds, and the newest of them.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPlace {
pub id: u32,
pub count: usize,
pub cover: StoredPhoto,
}
/// One row of a timeline replacement. Everything but the uid and the capture
/// time is "what this refresh learned": a `None` keeps whatever is already
/// stored rather than clearing it, because the timeline DTO carries only the uid
@@ -58,6 +67,8 @@ pub struct TimelineRow {
/// The main photo this one is *related* to (a live-photo video, a burst
/// sibling), when the server says it belongs to one.
pub main_uid: Option<String>,
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
pub location: Option<(f64, f64)>,
/// When this refresh resolved the photo's node, if it did at all. `Some`
/// makes every other field authoritative — including a `None` that means
/// "the server no longer reports one" — while `None` means the refresh
@@ -86,6 +97,9 @@ struct Learned {
favorite: bool,
content_hash: 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
/// does not make it look unresolved again.
resolved_at: Option<i64>,
@@ -281,7 +295,7 @@ impl Db {
let learned: HashMap<String, Learned> = {
let mut stmt = tx.prepare(
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
main_uid, resolved_at FROM photos",
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
)?;
let rows = stmt.query_map([], |r| {
Ok((
@@ -295,6 +309,10 @@ impl Db {
content_hash: r.get(6)?,
main_uid: r.get(7)?,
resolved_at: r.get(8)?,
location: r
.get::<_, Option<f64>>(9)?
.zip(r.get::<_, Option<f64>>(10)?),
similar_hash: r.get(11)?,
},
))
})?;
@@ -317,14 +335,20 @@ impl Db {
carried.name = row.name.clone();
carried.media_type = row.media_type.clone();
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.main_uid = row.main_uid.clone();
carried.location = row.location;
} else {
carried.name = row.name.clone().or(carried.name);
carried.media_type = row.media_type.clone().or(carried.media_type);
carried.favorite = row.favorite.unwrap_or(carried.favorite);
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
carried.location = row.location.or(carried.location);
}
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
// The tab this photo lands in is derived here, once, so a page or
@@ -355,8 +379,10 @@ impl Db {
let mut stmt = tx.prepare(
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
content_hash, main_uid, group_key, resolved_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
place_id, 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
resolved.iter().zip(group_keys.iter()).enumerate()
@@ -375,6 +401,13 @@ impl Db {
carried.main_uid,
group_key,
carried.resolved_at,
carried.location.map(|(lat, _)| lat),
carried.location.map(|(_, lon)| lon),
carried
.location
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
.map(|city| city.id),
carried.similar_hash,
])?;
}
}
@@ -447,6 +480,14 @@ impl Db {
albums.execute([uid])?;
}
}
// A group whose representative went is left pointing at nothing, which
// drops its other files off every page. Make them stand alone until the
// next refresh groups them again.
tx.execute(
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
AND group_key NOT IN (SELECT uid FROM photos)",
[],
)?;
tx.commit()?;
Ok(removed)
}
@@ -455,7 +496,8 @@ impl Db {
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favorited photos.
/// `favorites` restricts the page to favorited photos, and `not_in_album`
/// to the shots no album holds any file of.
///
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
/// one tile. The Raw tab is the exception and lists every raw file, because
@@ -467,6 +509,7 @@ impl Db {
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
not_in_album: bool,
) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
// Built up so any combination of the optional filters is one indexed
@@ -490,6 +533,15 @@ impl Db {
if favorites {
conds.push("p.favorite = 1".to_string());
}
// By group, not by file: a shot filed as its JPEG is filed, even though
// its RAW was never added anywhere.
if not_in_album {
conds.push(
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
.to_string(),
);
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
@@ -577,6 +629,256 @@ impl Db {
Ok(rows)
}
/// Photos taken on `today`'s calendar day in earlier years, newest first —
/// the "On this day" strip. `today` is epoch seconds: its local month and day
/// pick the date, and its own year is left out, because today's photos are
/// what the timeline already opens on. One row per group, like the timeline.
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
ORDER BY p.seq LIMIT ?2"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![today, limit as i64], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Every town photos were taken in, with how many and the newest one as its
/// cover, most photographed first. Counted per group, like the timeline;
/// photos without a location, or taken far from any town, are in none.
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
let rows: Vec<(u32, usize, String)> = {
let conn = self.read();
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
// is the newest photo of the place.
let mut stmt = conn.prepare(
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
)?;
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
})?
.collect::<rusqlite::Result<_>>()?
};
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
let mut covers: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
Ok(rows
.into_iter()
.filter_map(|(id, count, uid)| {
Some(StoredPlace {
id,
count,
cover: covers.remove(&uid)?,
})
})
.collect())
}
/// One page of the photos taken in a town, newest first, one per group.
pub fn photos_at_place(
&self,
id: u32,
offset: usize,
limit: usize,
) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// 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)`.
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind;
+411 -17
View File
@@ -140,7 +140,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
@@ -163,7 +163,14 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// The counts break down by tab, and a filtered page returns only its tab.
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
let videos = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
.photos_page(
0,
10,
Some(crate::control::PhotoKind::Video),
None,
false,
false,
)
.unwrap();
assert_eq!(
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -176,7 +183,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
// not p0 at 400. Combined with a kind filter both conditions apply.
let ranged = db
.photos_page(0, 10, None, Some((150, 350)), false)
.photos_page(0, 10, None, Some((150, 350)), false, false)
.unwrap();
assert_eq!(
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -189,6 +196,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
Some(crate::control::PhotoKind::Video),
Some((150, 350)),
false,
false,
)
.unwrap();
assert_eq!(
@@ -218,7 +226,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
},
])
.unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"],
@@ -236,7 +244,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
TimelineRow::new("p2", 200),
])
.unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2"],
@@ -245,6 +253,305 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
}
/// The strip shows the same calendar day in earlier years only: not today's
/// own photos, not the day before, and one tile per RAW+JPEG shot.
#[test]
fn on_this_day_finds_the_same_date_in_earlier_years() {
const DAY: i64 = 86_400;
// Noon UTC, so the local date is the same in every common time zone.
let today = 1_781_524_800; // 2026-06-15 12:00 UTC
let year_ago = today - 365 * DAY; // 2025-06-15
let three_years_ago = today - (365 * 3 + 1) * DAY; // 2023-06-15, past a 29 February
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow::new("today", today),
TimelineRow {
name: Some("IMG_1.JPG".into()),
..TimelineRow::new("jpeg", year_ago)
},
TimelineRow {
name: Some("IMG_1.CR2".into()),
..TimelineRow::new("raw", year_ago)
},
TimelineRow::new("day-before", year_ago - DAY),
TimelineRow::new("older", three_years_ago),
])
.unwrap();
let found = db.photos_on_this_day(today, 10).unwrap();
assert_eq!(
found.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "older"]
);
}
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
TimelineRow {
name: Some(name.into()),
content_hash: Some(hash.into()),
..TimelineRow::new(uid, capture_time)
}
}
fn located(uid: &str, capture_time: i64, name: &str, at: (f64, f64)) -> TimelineRow {
TimelineRow {
name: Some(name.into()),
location: Some(at),
resolved_at: Some(1),
..TimelineRow::new(uid, capture_time)
}
}
const BERLIN: (f64, f64) = (52.52, 13.405);
const PARIS: (f64, f64) = (48.857, 2.352);
/// Places count shots, not files, lead with the most photographed town, and
/// leave out photos with no location or taken far from any town.
#[test]
fn places_group_photos_by_the_nearest_town() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
located("b2", 500, "IMG_5.JPG", BERLIN),
located("p1", 400, "IMG_4.JPG", PARIS),
located("b1-jpeg", 300, "IMG_3.JPG", BERLIN),
located("b1-raw", 300, "IMG_3.CR2", BERLIN),
located("sea", 200, "IMG_2.JPG", (0.0, -30.0)),
TimelineRow::new("nowhere", 100),
])
.unwrap();
let places = db.photo_places().unwrap();
let summary: Vec<(&str, usize, &str)> = places
.iter()
.map(|place| {
let city = crate::places::city(place.id).expect("a known town");
(city.name, place.count, place.cover.uid.as_str())
})
.collect();
assert_eq!(summary, [("Berlin", 2, "b2"), ("Paris", 1, "p1")]);
let berlin: Vec<String> = db
.photos_at_place(places[0].id, 0, 10)
.unwrap()
.into_iter()
.map(|p| p.uid)
.collect();
assert_eq!(berlin, ["b2", "b1-jpeg"]);
}
/// A 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]
fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap();
@@ -253,12 +560,16 @@ fn photos_page_slices_the_timeline_in_order() {
.collect();
db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2, None, None, false).unwrap();
let page = db.photos_page(2, 2, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"]
);
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
assert!(
db.photos_page(9, 2, None, None, false, false)
.unwrap()
.is_empty()
);
}
#[test]
@@ -272,6 +583,7 @@ fn trash_replace_lists_folders_first() {
size: 10,
mtime: 1,
parent_uid: Some("t2".into()),
trashed_at: Some(100),
},
StoredTrash {
uid: "t2".into(),
@@ -280,6 +592,7 @@ fn trash_replace_lists_folders_first() {
size: 0,
mtime: 2,
parent_uid: None,
trashed_at: None,
},
])
.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.
db.trash_replace(&[]).unwrap();
assert!(db.trash_list().unwrap().is_empty());
@@ -1414,6 +1736,29 @@ fn activity_reads_back_newest_first() {
assert_eq!(db.activity_list(1).unwrap().len(), 1);
}
#[test]
fn activity_has_finds_only_matching_kind_and_target() {
let db = Db::open_in_memory().unwrap();
db.activity_add(&activity(
"a (sync-conflict 1).txt",
ActivityKind::Conflict,
false,
))
.unwrap();
db.activity_add(&activity("b.txt", ActivityKind::Upload, true))
.unwrap();
assert!(
db.activity_has(ActivityKind::Conflict, "a (sync-conflict 1).txt")
.unwrap()
);
assert!(
!db.activity_has(ActivityKind::Upload, "a (sync-conflict 1).txt")
.unwrap()
);
assert!(!db.activity_has(ActivityKind::Conflict, "b.txt").unwrap());
}
#[test]
fn activity_prunes_to_the_keep_limit() {
let db = Db::open_in_memory().unwrap();
@@ -4054,7 +4399,7 @@ fn photos_delete_removes_the_photo_and_its_album_membership() {
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1", "p3"],
@@ -4094,7 +4439,7 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "alone"],
@@ -4107,7 +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.
let raws = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false)
.photos_page(
0,
10,
Some(crate::control::PhotoKind::Raw),
None,
false,
false,
)
.unwrap();
assert_eq!(
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -4152,7 +4504,7 @@ fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["cover", "notes", "other"],
@@ -4191,7 +4543,7 @@ fn a_shared_name_alone_does_not_group_photos() {
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 3, "three shots, three tiles");
assert!(page.iter().all(|p| p.group_size == 1));
}
@@ -4214,7 +4566,7 @@ fn the_servers_photo_relation_groups_a_live_photo() {
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["main"]
@@ -4235,6 +4587,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
/// A database written before grouping existed opens, keeps its timeline, and
/// shows every photo — a row with no `group_key` is its own group, which is how
/// the gallery behaved before the column.
#[test]
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v31-fixture-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
{
let db = Db::open(&path).unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("VID-20260804-WA0023.mp4".into()),
..TimelineRow::new("video", 300)
},
TimelineRow {
name: Some("broken.jpg".into()),
..TimelineRow::new("photo", 200)
},
])
.unwrap();
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
.unwrap();
}
let db = Db::open(&path).unwrap();
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
assert_eq!(
state("photo"),
THUMB_NONE,
"an undecodable photo stays written off"
);
drop(db);
let _ = std::fs::remove_file(&path);
}
#[test]
fn migration_v30_leaves_a_v29_timeline_intact() {
let path = std::env::temp_dir().join(format!(
@@ -4274,7 +4668,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
}
let db = Db::open(&path).unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "raw"],
@@ -4295,7 +4689,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
},
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 1);
assert_eq!(page[0].uid, "jpeg");
@@ -4324,14 +4718,14 @@ fn photos_replace_keeps_a_skipped_photos_name() {
},
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 1, "one shot, two files");
assert!(page[0].has_raw);
// The next refresh resolves neither, because both are already resolved.
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
assert!(page[0].has_raw, "the raw file is still a raw file");
+25 -4
View File
@@ -1,5 +1,7 @@
//! Trashed nodes, persisted so the trash view opens without a round-trip.
use std::collections::HashMap;
use rusqlite::params;
use super::Db;
@@ -15,6 +17,9 @@ pub struct StoredTrash {
/// row written before schema v29, and for a node whose parent the server
/// did not report.
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 {
@@ -24,8 +29,8 @@ impl Db {
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for item in items {
stmt.execute(params![
@@ -34,7 +39,8 @@ impl Db {
item.is_dir as i64,
item.size,
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>> {
let conn = self.read();
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",
)?;
let rows = stmt.query_map([], |r| {
@@ -58,6 +64,7 @@ impl Db {
size: r.get(3)?,
mtime: r.get(4)?,
parent_uid: r.get(5)?,
trashed_at: r.get(6)?,
})
})?;
let mut items = Vec::new();
@@ -81,5 +88,19 @@ impl Db {
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) -----------------------------------------
}
+14
View File
@@ -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>;
/// A failure on its way to a front-end: prose for the user, plus the
+2
View File
@@ -14,11 +14,13 @@ pub mod localindex;
pub mod menu;
pub mod mounts;
pub mod opener;
pub mod places;
pub mod profile;
pub mod sdkcache;
pub mod search;
pub mod service;
pub mod shell;
pub mod similar;
pub mod syncignore;
pub mod takeout;
pub mod tray;
+185
View File
@@ -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}"
);
}
}
+193
View File
@@ -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]]);
}
}
+39
View File
@@ -96,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
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.
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]
fn finished_names_are_not_transient() {
for name in [
+40 -4
View File
@@ -15,7 +15,7 @@
use std::collections::{HashMap, HashSet};
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::{CoreError, CoreResult};
@@ -137,10 +137,12 @@ impl Core {
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_albums().await {
warn!(error = %e, "background album refresh failed");
}
let refreshed = core.refresh_albums().await;
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())
}
/// Make sure every album of ours has its contents on disk, for a filter
/// that asks which photos no album holds. Contents are otherwise fetched
/// the first time an album is opened, so an album never opened would read
/// as empty and its photos as unfiled.
///
/// Shared-with-me albums are skipped: their photos live on the sharer's
/// volume and are never in our timeline. The freshness stamps are no guide
/// here — any album change clears all of them — so only an album whose
/// server count disagrees with an empty local copy is waited for.
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
if self.db.albums_count().map_err(CoreError::from)? == 0
&& !self.rt.block_on(self.refresh_albums())?
{
return Ok(());
}
let albums = self.db.albums_list().map_err(CoreError::from)?;
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
let stored = self
.db
.album_photos_count(&album.uid)
.map_err(CoreError::from)?;
if stored > 0 {
continue;
}
let Some(uid) = parse_uid(&album.uid) else {
continue;
};
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
warn!(album = %album.uid, %error, "fetching an album's contents failed");
}
}
Ok(())
}
/// Re-enumerate one album's photos and replace its persisted contents.
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
let photos = self.photos();
+5
View File
@@ -422,6 +422,10 @@ pub(super) async fn run_event_sync(
cursor = Some(applied);
}
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;
};
apply_photo_events(&core, &events);
core.events.publish(&[Topic::Photos]);
// One cursor write per batch, after the batch is applied: a crash in
// between replays events whose effects are idempotent.
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
+28 -5
View File
@@ -14,15 +14,38 @@
//! that is open, being written, or still owed an upload, for the same reason the
//! sweep does: the node is about to change under the decision.
//!
//! Only copies under My Files are handled here. A copy inside a mirror folder is
//! a local file first; the sync engine owns it.
//! Copies under My Files are named by their mountpoint-relative path. Copies
//! inside a synced folder are named by their absolute local path, so a resolve
//! request routes to the on-demand mount that serves them. A copy inside a
//! mirror folder is listed too, but it is a local file first: the sync engine
//! owns it, so it is resolved by editing the folder, not through here.
use super::*;
use pdfs_core::control::{ConflictInfo, ConflictKeep};
use sweep::conflict_base_name;
impl Core {
/// Every conflict copy under My Files, with the file it is a copy of.
/// Where the user finds a conflict copy: its mountpoint-relative path under
/// My Files, or its absolute local path inside a synced folder. `None` for a
/// node no location of this machine shows, such as one in another
/// computer's device tree.
///
/// The sweep only flags what this can name, and the listing uses it too. A
/// flagged copy the listing cannot see reads as resolved in the activity
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
let uid = uid.to_string();
if let Ok(Some(path)) = self
.db
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
{
return Some(path);
}
let drive_path = self.db.node_path(&uid).ok().flatten()?;
roots.resolve(&drive_path)
}
/// Every conflict copy this machine shows, with the file it is a copy of.
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
let stored = self
.db
@@ -37,13 +60,13 @@ impl Core {
for node in &nodes {
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
}
let root = self.primary_root_uid.to_string();
let roots = self.search_roots();
let mut items = Vec::new();
for node in &nodes {
let Some(base) = conflict_base_name(&node.name) else {
continue;
};
let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) else {
let Some(path) = self.conflict_path(&roots, &node.uid) else {
continue;
};
let original = by_parent_name
+260 -34
View File
@@ -17,8 +17,8 @@ use std::time::{Duration, Instant};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
TransferDirection, TrashFailure,
ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest,
Response as CtlResponse, Topic, TransferDirection, TrashFailure,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -185,6 +185,38 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
}
/// [`route_to_mount`] for a conflict copy. The listing names a copy inside a
/// synced folder by its absolute local path (`docs/BUGS.md` B99), so the
/// resolve has to run against the on-demand mount serving it. A mirror folder
/// has no mount to run against: its copy is an ordinary local file.
fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
let p = Path::new(path);
let mirrored = core
.db
.sync_folder_list()
.unwrap_or_default()
.iter()
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
if mirrored {
CoreError::invalid(format!(
"{path} is in a mirrored folder; rename or delete the local file there"
))
} else {
e
}
})?;
if routed
.1
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
{
Ok(routed)
} else {
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
}
}
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
/// must live under `mountpoint`; a relative path is taken as already relative to
/// the mount root.
@@ -254,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) {
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
warn!(error = %e, "control: could not set request timeout");
@@ -278,32 +459,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
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
// inside the daemon shows up by name in `pdfs diagnostics`.
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
Ok(CtlRequest::Status) => {
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,
}
let response = match request {
Ok(CtlRequest::Status) => status_response(core, username, mountpoint),
Ok(CtlRequest::Subscribe) => {
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
}
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.pin(&rel) {
@@ -408,7 +578,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
kind,
range,
favorites,
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
not_in_album,
}) => match core.photos_timeline(
offset,
clamp_limit(limit),
kind,
range,
favorites,
not_in_album,
) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
@@ -421,6 +599,49 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::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(items) => CtlResponse::Photos {
available: true,
@@ -514,8 +735,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed) {
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
Ok((trashed, failed)) => CtlResponse::Trashed {
trashed,
failed: failed
@@ -1123,13 +1344,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(items) => CtlResponse::Conflicts { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ResolveConflict { path, keep }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.resolve_conflict(&rel, &keep) {
Ok(message) => CtlResponse::Ok { message },
Ok(CtlRequest::ResolveConflict { path, keep }) => {
match route_conflict(core, mountpoint, &path) {
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
}
}
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
Ok(items) => CtlResponse::PendingOps { items },
Err(e) => CtlResponse::error(e),
@@ -1499,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.
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) {
Ok(v) => v,
Err(e) => {
+10 -3
View File
@@ -945,6 +945,7 @@ impl Core {
if !meta.complete {
return Err("queued create holds an incomplete blob".into());
}
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
let guard = self
.transfers
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
@@ -955,7 +956,7 @@ impl Core {
media_type_for(name),
reader,
meta.len as i64,
Vec::new(),
thumbnails,
None,
false,
))?;
@@ -1145,6 +1146,7 @@ impl Core {
}
let alt = conflict_name(&name, now_secs());
let thumbnails = self.upload_thumbnails(blob, &alt);
let guard =
self.transfers
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1155,7 +1157,7 @@ impl Core {
media_type_for(&alt),
reader,
meta.len as i64,
Vec::new(),
thumbnails,
None,
false,
))?;
@@ -1182,6 +1184,10 @@ impl Core {
format!("{reason}; local changes uploaded as {alt}"),
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");
Ok(())
}
@@ -1438,6 +1444,7 @@ impl Core {
}
let name = self.node_name(&uid);
let thumbnails = self.upload_thumbnails(&blob, &name);
let guard =
self.transfers
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1451,7 +1458,7 @@ impl Core {
&uid,
reader,
meta.len as i64,
Vec::new(),
thumbnails,
None,
));
let took = started.elapsed();
+321
View File
@@ -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)));
}
}
+94 -9
View File
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
use pdfs_core::control::{
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
ThumbnailBuildStatus, TransferDirection,
ThumbnailBuildStatus, Topic, TransferDirection,
};
use pdfs_core::db::{
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
@@ -86,6 +86,7 @@ use control::run_control_socket;
mod devices;
mod diagnostics;
mod drain;
mod events;
mod filesystem;
pub use filesystem::ProtonFs;
mod mount;
@@ -271,6 +272,12 @@ const PHOTOS_AVAILABLE: &str = "photos_available";
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
const ALBUM_SYNCED_PREFIX: &str = "album_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_SYNCED_MS: &str = "shared_with_me_synced_ms";
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
@@ -550,17 +557,25 @@ struct Core {
/// already running, so a burst of page requests against a stale listing kicks
/// off one refresh rather than one per request.
timeline_refreshing: Arc<AtomicBool>,
/// How far the running timeline refresh has got resolving photo metadata,
/// as `(done, total)` photos; `None` when no refresh is resolving any. The
/// Places view shows it while a library's locations are first read.
timeline_progress: Arc<Mutex<Option<(usize, usize)>>>,
/// The same, for the album listing.
albums_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
/// 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
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
/// instead of polling or blocking for the whole run. See
/// [`Core::await_trash_refresh`].
trash_progress: Arc<tokio::sync::Notify>,
/// Conflict copies the sweep has already flagged as needing attention this
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
/// Conflict copies the sweep has already checked against the activity log
/// this run, so each pass does not ask the database again. The log itself
/// is what keeps a copy from being flagged again after a restart. See
/// [`Core::run_conflict_sweep_loop`] and `docs/BUGS.md` B99.
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
/// Whether the conflict sweep may actually trash the duplicates it finds, or
/// only report them. Resolved once at mount from config + environment
@@ -651,6 +666,9 @@ struct Core {
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
/// across the FUSE session and the control-socket task.
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
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
indexing: Arc<AtomicBool>,
@@ -3807,6 +3825,8 @@ impl Core {
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
})
.collect())
}
@@ -4398,12 +4418,21 @@ impl Core {
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
.db
.trash_list()
.map_err(CoreError::from)?
.into_iter()
.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,
is_dir: item.is_dir,
size: item.size.max(0) as u64,
@@ -4442,6 +4471,18 @@ impl Core {
"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() {
self.db.trash_replace(&[]).map_err(CoreError::from)?;
}
@@ -4462,6 +4503,7 @@ impl Core {
// The folder the node will go back to, which is also what tells
// a restore whether that folder is itself in the trash.
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
// rather than a mix of this refresh and the last one.
@@ -4476,6 +4518,7 @@ impl Core {
}
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
let _ = self.db.set_state_i64(TRASH_COMPLETE_MS, now_ms());
info!(
count = items.len(),
elapsed_ms = started.elapsed().as_millis() as u64,
@@ -4494,10 +4537,12 @@ impl Core {
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_trash().await {
warn!(error = %e, "background trash refresh failed");
}
let refreshed = core.refresh_trash().await;
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.
core.trash_progress.notify_waiters();
});
@@ -4788,6 +4833,14 @@ impl Core {
};
if let Err(e) = self.db.activity_add(&entry) {
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]);
}
}
@@ -4989,6 +5042,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
role: role_to_str(link.role).to_string(),
expires: link.expiration_time,
has_password: link.has_custom_password,
created: link.creation_time,
}
}
@@ -5421,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
/// always restored before its children.
///
@@ -6212,8 +6285,8 @@ mod tests {
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention,
parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
rename_needs_queue, require_node_parent_access, require_rename_access,
@@ -6356,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.
#[test]
fn a_cyclic_listing_terminates() {
+19
View File
@@ -391,6 +391,8 @@ pub fn mount(
upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
timeline_progress: Arc::new(Mutex::new(None)),
similar_hashing: Arc::new(AtomicBool::new(false)),
albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
@@ -414,6 +416,7 @@ pub fn mount(
notifier: Arc::new(OnceLock::new()),
session_live: Arc::new(AtomicBool::new(false)),
transfers: TransferRegistry::new(),
events: Arc::new(Default::default()),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_tx,
@@ -566,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
// after the listener, because its liveness probe is a round trip over that
// socket.
File diff suppressed because it is too large. Load diff
+2
View File
@@ -73,6 +73,8 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
shared_by,
shared_at,
shared_by_unverified,
trashed_at: 0,
trashed_from: None,
}
}
+22 -3
View File
@@ -109,6 +109,7 @@ impl Core {
by_parent_name.insert((parent, node.name.as_str()), node);
}
let roots = self.search_roots();
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
for node in &nodes {
if node.trashed || node.is_folder() {
@@ -132,7 +133,16 @@ impl Core {
self.remove_conflict_copy(node, &base);
}
}
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
// Only flag what the conflict listing can show. Anything else
// would sit in the feed as "resolved" with no way to act on
// it (`docs/BUGS.md` B99).
SweepAction::Flag(detail) => {
if self.conflict_path(&roots, &node.uid).is_some() {
self.flag_conflict(&node.uid, &node.name, detail);
} else {
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
}
}
}
}
}
@@ -273,12 +283,21 @@ impl Core {
busy
}
/// Surface a conflict copy that needs the user's attention, at most once per
/// daemon run so the activity feed is not spammed each pass.
/// Surface a conflict copy that needs the user's attention, once. The
/// in-memory set spares the database each pass; the activity log itself is
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
/// B99). A copy's name carries its own timestamp, so the name identifies it.
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
if !self.conflict_notified.lock().insert(uid.clone()) {
return;
}
match self.db.activity_has(ActivityKind::Conflict, name) {
Ok(false) => {}
Ok(true) => return,
// Cannot tell: a repeat entry is harmless, a missing one hides a
// conflict.
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
}
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
self.log_activity(ActivityKind::Conflict, name, detail, false);
}
+8 -2
View File
@@ -1226,6 +1226,9 @@ impl Core {
// Taken from the descriptor being streamed, so it names the content that
// actually goes up rather than whatever is at the path when we finish.
let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), name.to_string())
.await;
// Count the bytes as they are read, so this upload shows up in
// `GetQueueStatus` next to a manual one. The uid isn't known until the
// draft is sealed, so the transfer registers without one.
@@ -1246,7 +1249,7 @@ impl Core {
crate::media_type_for(name),
reader,
meta.len() as i64,
Vec::new(),
thumbnails,
Some(mtime),
false,
)
@@ -1274,6 +1277,9 @@ impl Core {
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
let mtime = system_mtime(&meta);
let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
.await;
let reader = OwnedCountingReader::new(
file,
self.transfers.begin(
@@ -1284,7 +1290,7 @@ impl Core {
),
);
self.client
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
.upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
.await
.map_err(|e| format!("upload revision {rel}: {e}"))?;
warn_if_torn(rel, &path, streamed);
+18 -5
View File
@@ -389,11 +389,11 @@ impl<R: Read> Read for CountingReader<'_, R> {
&& cancel.load(Ordering::Relaxed)
{
// Not `Ok(0)`: a short read is how this reader says "end of file",
// and the SDK would seal a truncated revision from it.
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"upload superseded by a newer write",
));
// and the SDK would seal a truncated revision from it. Not
// `Interrupted` either: every reader loop, the SDK's included,
// retries that kind, and this flag never clears, so the drain
// thread spun forever with the transfer stuck at 0 bytes (B98).
return Err(std::io::Error::other("upload superseded by a newer write"));
}
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
@@ -433,4 +433,17 @@ mod tests {
assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
}
#[test]
fn a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry() {
let reg = TransferRegistry::new();
let guard = reg.begin("f", "", TransferDirection::Upload, 4);
let cancel = Arc::new(AtomicBool::new(true));
let mut reader = CountingReader::new(&b"data"[..], &guard).with_cancel(cancel);
// `read_exact` retries `Interrupted` the way the SDK's block loop does,
// so this hangs rather than fails if the cancel asks for a retry.
let err = reader.read_exact(&mut [0; 4]).unwrap_err();
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
assert_eq!(guard.entry.done.load(Ordering::Relaxed), 0);
}
}
+4 -1
View File
@@ -69,6 +69,9 @@ async fn run_uploads(
format!("destination access denied: errno {}", error.code()),
));
}
let thumbnails = core
.upload_thumbnails_async(t.path.clone(), t.name.clone())
.await;
match core
.client
.upload_file_from(
@@ -77,7 +80,7 @@ async fn run_uploads(
media_type_for(&t.name),
reader,
t.size as i64,
Vec::new(),
thumbnails,
mtime,
false,
)
+6
View File
@@ -35,3 +35,9 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" }
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"] }
+2
View File
@@ -26,4 +26,6 @@ fn main() {
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!("cargo:rerun-if-changed=resources/icons");
println!("cargo:rerun-if-changed=resources/style.css");
println!("cargo:rerun-if-changed=resources/proton-theme.css");
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
}
+393
View File
@@ -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
File diff suppressed because it is too large. Load diff
@@ -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">
<!-- 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,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

+19 -1
View File
@@ -7,13 +7,31 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
<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 purple accent is a separate, opt-in provider. -->
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>
</gresource>
</gresources>
@@ -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;
}
}
+50 -13
View File
@@ -4,12 +4,12 @@
* 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
* purple accent is opt-in and lives in its own provider (see main.rs).
* 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; }
.brand-icon { color: #6d4aff; }
/* Sidebar footer */
.sidebar-footer { padding: 6px 12px 12px 12px; }
@@ -19,6 +19,22 @@
.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; }
@@ -27,13 +43,11 @@
.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(--yellow-3); }
.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, progressbar.browser-status-meter trough { min-width: 104px; }
scale.browser-status-meter trough { min-height: 6px; }
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
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); }
@@ -52,19 +66,19 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.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 { min-width: 20px; padding: 0 2px; }
.photo-scrubber value { font-weight: 600; background-color: var(--popover-bg-color); border-radius: 6px; padding: 2px 8px; }
.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: 4px 2px 8px 2px; }
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
.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.06); }
.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; }
@@ -72,14 +86,37 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
.memory-card { padding: 0; border-radius: 12px; }
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
.memory-label { font-weight: 700; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); }
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
.album-card:hover { background: alpha(currentColor, 0.07); }
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
.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 { padding-left: 18px; padding-right: 18px; }
.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); }
+139
View File
@@ -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");
}
}
+179
View File
@@ -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();
});
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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"]);
}
}
+133 -81
View File
@@ -24,6 +24,7 @@ pub(crate) fn show_albums(ui: &Rc<Ui>) {
/// position. The timeline's filters do not apply to albums, so they go.
fn show_album_grid(ui: &Rc<Ui>) {
close_album(ui);
close_place(ui);
ui.gallery.filters.set_visible(false);
ui.gallery.content.set_visible_child_name("albums");
}
@@ -40,8 +41,8 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
albums_status(
ui,
"view-grid-symbolic",
"Loading albums…",
"Reading your Proton Drive albums.",
&gettext("Loading albums…"),
&gettext("Reading your Proton Drive albums."),
);
}
@@ -56,33 +57,33 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
})) => albums_status(
&ui,
"image-missing-symbolic",
"No photo library",
"This Proton account doesn't have Photos enabled.",
&gettext("No photo library"),
&gettext("This Proton account doesn't have Photos enabled."),
),
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
&ui,
"view-grid-symbolic",
"No albums",
"Create one with New Album, or in Proton Photos.",
&gettext("No albums"),
&gettext("Create one with New Album, or in Proton Photos."),
),
Ok(Ok(Response::Albums { items, .. })) => {
fill_albums(&ui, &items);
ui.gallery.albums_stack.set_visible_child_name("grid");
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't load albums", &message, kind);
toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
albums_status(
&ui,
"dialog-warning-symbolic",
"Couldn't load albums",
&gettext("Couldn't load albums"),
&message,
);
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
&ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service didn't respond.",
&gettext("Not connected"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -102,9 +103,12 @@ fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
}
*ui.gallery.album_list.borrow_mut() = albums.to_vec();
if ui.gallery.album.borrow().is_none() {
ui.gallery
.title
.set_subtitle(&count_noun(albums.len(), "album", "albums"));
ui.gallery.title.set_subtitle(&ngettext_f(
"{n} album",
"{n} albums",
albums.len() as u64,
&[],
));
}
for album in albums {
ui.gallery.albums.append(&album_card(ui, album));
@@ -199,14 +203,18 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::Widget, x: f64, y: f64) {
let mut menu = ActionMenu::new();
let (ui_c, album_c) = (ui.clone(), album.clone());
menu.item("Open", move || open_album(&ui_c, album_c.clone()));
menu.item(&pgettext("verb", "Open"), move || {
open_album(&ui_c, album_c.clone())
});
if !album.shared {
menu.section();
let (ui_c, album_c) = (ui.clone(), album.clone());
menu.item("Rename…", move || prompt_rename_album(&ui_c, &album_c));
menu.item(&gettext("Rename…"), move || {
prompt_rename_album(&ui_c, &album_c)
});
menu.section();
let (ui_c, album_c) = (ui.clone(), album.clone());
menu.item("Delete Album…", move || {
menu.item(&gettext("Delete Album…"), move || {
confirm_delete_album(&ui_c, &album_c)
});
}
@@ -226,13 +234,13 @@ fn prompt_album_name(
let dialog = adw::AlertDialog::builder().heading(heading).build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
.title("Album name")
.title(gettext("Album name"))
.activates_default(true)
.build();
row.set_text(current);
group.add(&row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("ok", action);
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
dialog.set_response_enabled("ok", !current.trim().is_empty());
@@ -254,7 +262,8 @@ fn prompt_album_name(
/// Ask for a name and create an album, then add `photos` to it, if any.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
let ui_c = ui.clone();
prompt_album_name(ui, "New Album", "Create", "", move |name| {
let (heading, action) = (gettext("New Album"), gettext("Create"));
prompt_album_name(ui, &heading, &action, "", move |name| {
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
let ui = ui_c.clone();
let photos = photos.clone();
@@ -265,19 +274,19 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
match reply {
Ok(Ok(Response::AlbumCreated { uid })) => {
if photos.is_empty() {
toast(&ui, "Album created");
toast(&ui, &gettext("Album created"));
reload_album_grid(&ui);
} else {
add_to_album(&ui, uid, photos);
}
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't create the album", &message, kind)
toast_failure(&ui, &gettext("Couldn't create the album"), &message, kind)
}
_ => toast_error(
&ui,
"Couldn't create the album",
"The mount service didn't respond.",
&gettext("Couldn't create the album"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -286,15 +295,16 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone());
prompt_album_name(ui, "Rename Album", "Rename", &album.name, move |name| {
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
run_album_request(
&ui_c,
Request::RenameAlbum {
uid: uid.clone(),
name,
},
"Album renamed",
"Couldn't rename the album",
gettext("Album renamed"),
gettext("Couldn't rename the album"),
);
});
}
@@ -304,12 +314,13 @@ fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone());
confirm_destructive(
&win,
"Delete Album?",
&format!(
"“{}” is deleted. Its photos stay in your timeline.",
album.name
&gettext("Delete Album?"),
// Translators: {name} is the album's name.
&gettext_f(
"“{name}” is deleted. Its photos stay in your timeline.",
&[("name", &album.name)],
),
"Delete",
&gettext("Delete"),
move || {
run_album_request(
&ui_c,
@@ -317,15 +328,15 @@ fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
uid: uid.clone(),
delete_photos: false,
},
"Album deleted",
"Couldn't delete the album",
gettext("Album deleted"),
gettext("Couldn't delete the album"),
)
},
);
}
/// Send an album rename or delete and refresh the grid once it lands.
fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed: &'static str) {
fn run_album_request(ui: &Rc<Ui>, request: Request, done: String, failed: String) {
let rx = spawn_request(ui.dirs.control_socket(), request);
let ui = ui.clone();
ui.busy_begin();
@@ -334,11 +345,17 @@ fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed:
ui.busy_end();
match reply {
Ok(Ok(Response::Ok { .. })) => {
toast(&ui, done);
toast(&ui, &done);
reload_album_grid(&ui);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
@@ -360,14 +377,14 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
items.into_iter().filter(|album| !album.shared).collect()
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't load albums", &message, kind);
toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
return;
}
_ => {
toast_error(
&ui,
"Couldn't load albums",
"The mount service didn't respond.",
&gettext("Couldn't load albums"),
&gettext("The Proton Drive service didn't respond."),
);
return;
}
@@ -396,15 +413,20 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
.child(&list)
.build();
let dialog = adw::AlertDialog::builder()
.heading("Add to Album")
.heading(gettext("Add to Album"))
.body(match photos.len() {
1 => "Choose an album for this photo.".to_string(),
n => format!("Choose an album for {n} photos."),
1 => gettext("Choose an album for this photo."),
n => ngettext_f(
"Choose an album for {n} photo.",
"Choose an album for {n} photos.",
n as u64,
&[],
),
})
.extra_child(&scroll)
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("new", "New Album…");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("new", &gettext("New Album…"));
dialog.set_close_response("cancel");
let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone());
@@ -435,25 +457,41 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
match reply {
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
if let Some(failure) = failed.first() {
toast_error(&ui, "Some photos couldn't be added", &failure.message);
toast_error(
&ui,
&gettext("Some photos couldn't be added"),
&failure.message,
);
} else {
toast(
&ui,
&format!(
"Added {} to the album",
count_noun(changed.len(), "photo", "photos")
&ngettext_f(
"Added {n} photo to the album",
"Added {n} photos to the album",
changed.len() as u64,
&[],
),
);
}
// Filed photos no longer belong in a timeline of unfiled ones.
// The daemon's filter drops them too, so the paging offset stays
// in step with what is left.
if ui.gallery.not_in_album.get() && ui.gallery.album.borrow().is_none() {
ui.gallery
.selected
.borrow_mut()
.retain(|uid| !changed.contains(uid));
remove_photos(&ui, &changed);
}
reload_album_grid(&ui);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't add to the album", &message, kind)
toast_failure(&ui, &gettext("Couldn't add to the album"), &message, kind)
}
_ => toast_error(
&ui,
"Couldn't add to the album",
"The mount service didn't respond.",
&gettext("Couldn't add to the album"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -487,7 +525,7 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
restore_photos(&ui, kept);
toast_error(
&ui,
"Some photos couldn't be removed from the album",
&gettext("Some photos couldn't be removed from the album"),
&failed[0].message,
);
}
@@ -503,22 +541,29 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
drop(open);
toast(
&ui,
&format!(
"Removed {} from the album",
count_noun(changed.len(), "photo", "photos")
&ngettext_f(
"Removed {n} photo from the album",
"Removed {n} photos from the album",
changed.len() as u64,
&[],
),
);
}
Ok(Ok(Response::Error { message, kind })) => {
restore_photos(&ui, removed);
toast_failure(&ui, "Couldn't remove from the album", &message, kind)
toast_failure(
&ui,
&gettext("Couldn't remove from the album"),
&message,
kind,
)
}
_ => {
restore_photos(&ui, removed);
toast_error(
&ui,
"Couldn't remove from the album",
"The mount service didn't respond.",
&gettext("Couldn't remove from the album"),
&gettext("The Proton Drive service didn't respond."),
)
}
}
@@ -527,20 +572,22 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
/// "12 photos", plus where the album came from when it isn't ours.
fn album_subtitle(album: &AlbumInfo) -> String {
let photos = match album.photo_count {
1 => "1 photo".to_string(),
n => format!("{n} photos"),
};
let count = album.photo_count as u64;
if album.shared {
format!("{photos} · shared with me")
ngettext_f(
"{n} photo · shared with me",
"{n} photos · shared with me",
count,
&[],
)
} else {
photos
ngettext_f("{n} photo", "{n} photos", count, &[])
}
}
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
/// same texture cache, same decode pacing as a timeline tile.
fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
picture.set_paintable(Some(texture));
return;
@@ -571,7 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
// would silently do something else. The Photos/Albums switcher goes too: an
// album is a level below it, and back is the way out.
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.back.set_visible(true);
// A stale kind/date filter would otherwise be sent with the album request.
@@ -588,8 +635,8 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
if ui.gallery.album.borrow_mut().take().is_none() {
return;
}
ui.gallery.title.set_title("Photos");
ui.gallery.view_switch.set_visible(true);
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);
}
@@ -606,20 +653,19 @@ 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.
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
let ui_albums = ui.clone();
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
if btn.is_active() {
ui.gallery.view_switch.connect_changed(move |view| {
if view == VIEW_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();
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
if !btn.is_active() {
ui.gallery.view_switch.connect_changed(move |view| {
if view != VIEW_TIMELINE {
return;
}
close_album(&ui_photos);
close_place(&ui_photos);
ui_photos.gallery.filters.set_visible(true);
ui_photos.gallery.content.set_visible_child_name("timeline");
// The timeline comes back as it was left unless an album took over the
@@ -632,15 +678,21 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
}
});
// Back returns to the grid the album was opened from, scrolled where it
// was, instead of reloading it.
// Back returns to the grid the album or place was opened from, scrolled
// where it was, instead of reloading it.
let ui_back = ui.clone();
ui.gallery.back.clone().connect_clicked(move |_| {
if ui_back.gallery.place.borrow().is_some() {
show_place_grid(&ui_back);
return;
}
show_album_grid(&ui_back);
let count = ui_back.gallery.album_list.borrow().len();
ui_back
.gallery
.title
.set_subtitle(&count_noun(count, "album", "albums"));
ui_back.gallery.title.set_subtitle(&ngettext_f(
"{n} album",
"{n} albums",
count as u64,
&[],
));
});
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+473
View File
@@ -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(&note);
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: &gtk4::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(&note);
}
// 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(&section);
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)
}
File diff suppressed because it is too large. Load diff
+63 -46
View File
@@ -7,7 +7,7 @@ pub(crate) struct LoginState {
pub(crate) password: adw::PasswordEntryRow,
pub(crate) login_button: gtk4::Button,
/// Spins inside the Sign in button while a sign-in is running.
pub(crate) login_spinner: gtk4::Spinner,
pub(crate) login_spinner: Spinner,
pub(crate) login_status: gtk4::Label,
}
@@ -21,19 +21,24 @@ const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
/// only when the account actually requires it.
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
let group = adw::PreferencesGroup::builder()
.title("Sign in to Proton")
.description("Use your Proton account to connect Drive.")
.title(gettext("Sign in to Proton"))
.description(gettext("Use your Proton account to connect Drive."))
.build();
let email = adw::EntryRow::builder().title("Email or username").build();
let password = adw::PasswordEntryRow::builder().title("Password").build();
let email = adw::EntryRow::builder()
.title(gettext("Email or username"))
.build();
let password = adw::PasswordEntryRow::builder()
.title(gettext("Password"))
.build();
group.add(&email);
group.add(&password);
let login_spinner = gtk4::Spinner::builder().visible(false).build();
let 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(&gtk4::Label::new(Some("Sign in")));
button_content.append(&gtk4::Label::new(Some(&gettext("Sign in"))));
let login_button = gtk4::Button::builder()
.child(&button_content)
.halign(gtk4::Align::Center)
@@ -48,9 +53,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
login_status.add_css_class("dim-label");
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
logo.set_pixel_size(64);
logo.add_css_class("brand-icon");
let logo = gtk4::Image::from_icon_name(APP_ID);
logo.set_pixel_size(96);
let title = gtk4::Label::new(Some("Proton Drive"));
title.add_css_class("brand-title");
header.append(&logo);
@@ -68,10 +72,13 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
inner.append(&login_status);
let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
links.set_halign(gtk4::Align::Center);
links.append(&gtk4::LinkButton::with_label(SIGNUP_URL, "Create account"));
links.append(&gtk4::LinkButton::with_label(
SIGNUP_URL,
&gettext("Create account"),
));
links.append(&gtk4::LinkButton::with_label(
RESET_PASSWORD_URL,
"Forgot password?",
&gettext("Forgot password?"),
));
inner.append(&links);
@@ -115,12 +122,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
if username.is_empty() || password.is_empty() {
ui.login
.login_status
.set_text("Enter your email and password.");
.set_text(&gettext("Enter your email and password."));
return;
}
set_signing_in(&ui, true);
ui.login.login_status.set_text("Signing in…");
ui.login.login_status.set_text(&gettext("Signing in…"));
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
@@ -145,7 +152,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
ui_hv
.login
.login_status
.set_text("Complete the verification to continue…");
.set_text(&gettext("Complete the verification to continue…"));
prompt_human_verification(&ui_hv, &url, token_tx);
}
});
@@ -155,7 +162,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
let result = rx
.recv()
.await
.unwrap_or_else(|_| Err("login cancelled".into()));
.unwrap_or_else(|_| Err(gettext("login cancelled")));
set_signing_in(&ui, false);
match result {
Ok(()) => {
@@ -164,7 +171,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
// Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session.
service::enable_start();
let _ = gio::spawn_blocking(service::enable_start).await;
refresh(&ui);
}
Err(e) => ui.login.login_status.set_text(&e),
@@ -179,7 +186,6 @@ fn set_signing_in(ui: &Rc<Ui>, running: bool) {
ui.login.email.set_sensitive(!running);
ui.login.password.set_sensitive(!running);
ui.login.login_spinner.set_visible(running);
ui.login.login_spinner.set_spinning(running);
}
/// What to tell the person when sign-in fails. Proton's own API messages are
@@ -189,28 +195,28 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
use pdfs_core::proton_sdk::ProtonError;
match error {
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
"Too many sign-in attempts. Wait a few minutes and try again.".to_string()
gettext("Too many sign-in attempts. Wait a few minutes and try again.")
}
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
api.message.clone()
}
pdfs_core::Error::Proton(ProtonError::Transport(_)) => {
"Couldn't reach Proton. Check your internet connection and try again.".to_string()
}
pdfs_core::Error::Keyring(_) => {
"Signed in, but the session couldn't be saved to the system keyring. Make sure a \
keyring (GNOME Keyring or KWallet) is running and unlocked."
.to_string()
gettext("Couldn't reach Proton. Check your internet connection and try again.")
}
pdfs_core::Error::Keyring(_) => gettext(
"Signed in, but the session couldn't be saved to the system keyring. Make sure a keyring (GNOME Keyring or KWallet) is running and unlocked.",
),
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
"Sign-in cancelled: no two-factor code was entered.".to_string()
gettext("Sign-in cancelled: no two-factor code was entered.")
}
pdfs_core::Error::Other(message) if message.contains("verification") => {
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new \
one."
.to_string()
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
),
other => {
let error = other.to_string();
// Translators: {error} is an error message, usually in English.
gettext_f("Sign-in failed: {error}", &[("error", &error)])
}
other => format!("Sign-in failed: {other}"),
}
}
@@ -289,20 +295,22 @@ pub(crate) fn spawn_login(
/// the worker reads as a cancelled login.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder()
.heading("Two-factor authentication")
.body("Enter the code from your authenticator app, or one of your recovery codes.")
.heading(gettext("Two-Factor Authentication"))
.body(gettext(
"Enter the code from your authenticator app, or one of your recovery codes.",
))
.build();
let group = adw::PreferencesGroup::new();
let entry = adw::EntryRow::builder()
.title("Authentication code")
.title(gettext("Authentication code"))
.activates_default(true)
.build();
group.add(&entry);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("confirm", "Confirm");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("confirm", &gettext("Confirm"));
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("confirm"));
dialog.set_close_response("cancel");
@@ -330,17 +338,26 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
let ui = ui.clone();
confirm_destructive(
&window,
"Sign Out?",
"Proton Drive will disconnect and stop syncing until you sign in again. \
Files already on this computer stay where they are.",
"Sign Out",
&gettext("Sign Out?"),
&gettext(
"Proton Drive will disconnect and stop syncing until you sign in again. Files already on this computer stay where they are.",
),
&gettext("Sign Out"),
move || {
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
// 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);
});
},
);
}
+877
View File
@@ -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);
}
}
+6
View File
@@ -1,11 +1,17 @@
pub(crate) mod activity;
pub(crate) mod activity_text;
pub(crate) mod albums;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod duplicates;
pub(crate) mod locations;
pub(crate) mod login;
pub(crate) mod map_tiles;
pub(crate) mod mvt;
pub(crate) mod photo_viewer;
pub(crate) mod photos;
pub(crate) mod places;
pub(crate) mod places_map;
pub(crate) mod shared;
pub(crate) mod shared_by_me;
pub(crate) mod status;
+386
View File
@@ -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)]]);
}
}
+335 -75
View File
@@ -9,6 +9,12 @@ const ZOOM_STEP: f64 = 1.25;
/// The furthest the lightbox zooms in, as a multiple of the image's own pixels.
const MAX_ZOOM: f64 = 8.0;
/// How long a slideshow shows each photo. A video plays to its end instead.
const SLIDESHOW_INTERVAL: Duration = Duration::from_secs(5);
/// How often a slideshow checks whether the video on screen has finished.
const SLIDESHOW_VIDEO_POLL: Duration = Duration::from_millis(500);
/// How close to the end of the loaded photos stepping forward starts paging the
/// next batch in, so reaching the end rarely means waiting for it.
const PAGE_AHEAD: u32 = 10;
@@ -25,7 +31,7 @@ pub(crate) struct Viewer {
pub(crate) scroller: gtk4::ScrolledWindow,
pub(crate) picture: gtk4::Picture,
pub(crate) video: gtk4::Video,
pub(crate) spinner: gtk4::Spinner,
pub(crate) spinner: Spinner,
pub(crate) status: gtk4::Label,
pub(crate) title: gtk4::Label,
pub(crate) counter: gtk4::Label,
@@ -75,15 +81,22 @@ pub(crate) struct Viewer {
pub(crate) chrome: Vec<gtk4::Revealer>,
pub(crate) chrome_hover: Cell<bool>,
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
/// The slideshow toggle; the timer that moves it on; and whether it put
/// the window in fullscreen, so stopping it only leaves a fullscreen it
/// entered itself.
pub(crate) slideshow: gtk4::ToggleButton,
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
pub(crate) slide_fullscreened: Cell<bool>,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
/// label/value pairs for the details panel.
#[derive(Default)]
pub(crate) struct ExifInfo {
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
/// EXIF at all, which is normal for screenshots and re-encoded images.
pub(crate) fields: Vec<(&'static str, String)>,
/// `("Camera", "Apple iPhone 15")` and friends, the label already
/// translated; empty when the file has no EXIF at all, which is normal for
/// screenshots and re-encoded images.
pub(crate) fields: Vec<(String, String)>,
/// Decimal degrees, if the photo is geotagged.
pub(crate) coords: Option<(f64, f64)>,
}
@@ -146,7 +159,7 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
if let Some(next) = next_group_member(&items, &uid) {
viewer
.group_btn
.set_tooltip_text(Some(&format!("Show {}", member_label(next))));
.set_tooltip_text(Some(&member_tooltip(next)));
}
*viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true);
@@ -173,6 +186,21 @@ pub(crate) fn member_label(item: &PhotoItem) -> String {
}
}
/// The group switch's tooltip, naming the file it will show next.
fn member_tooltip(item: &PhotoItem) -> String {
match item.name.as_deref() {
Some(name) if !name.is_empty() => {
// Translators: tooltip of the button that switches to another file of the same photo; {name} is a file name.
gettext_f("Show {name}", &[("name", &member_label(item))])
}
_ => match item.kind {
PhotoKind::Raw => gettext("Show the raw file"),
PhotoKind::Video => gettext("Show the video"),
PhotoKind::Photo => gettext("Show the photo"),
},
}
}
/// The gallery's entry for `uid`, or the shot member it names when the group
/// switch has left the timeline's own photo (a RAW is not in the timeline).
fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem> {
@@ -196,7 +224,6 @@ fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem>
/// plays in place once its file is down.
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true);
viewer.spinner.start();
viewer.status.set_visible(false);
viewer.loading.set(true);
*viewer.path.borrow_mut() = None;
@@ -204,6 +231,8 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
stop_video(viewer);
viewer.media.set_visible_child_name("photo");
reset_zoom(viewer);
// A slideshow waits for this photo to land before it starts counting.
disarm_slide(viewer);
// Looked up before the group is refetched, which empties it.
let item = viewer_item(ui, viewer, &uid);
@@ -228,6 +257,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
Request::OpenPhoto { uid: uid.clone() },
);
let viewer = viewer.clone();
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
// The user may have moved on while this was in flight; that photo's own
@@ -235,9 +265,11 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
if *viewer.uid.borrow() != uid {
return;
}
viewer.spinner.stop();
viewer.spinner.set_visible(false);
viewer.loading.set(false);
// Armed whether or not the photo rendered: a slideshow steps past a
// photo it could not show rather than stalling on it.
arm_slide(&ui, &viewer);
let fail = |message: &str| {
viewer.status.set_label(message);
@@ -269,16 +301,102 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
}
Err(e) => {
tracing::error!("Failed to load texture for {path}: {e}");
fail("Couldn't render this photo.");
fail(&gettext("Couldn't render this photo."));
}
},
Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail("Unexpected reply from the mount service."),
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."),
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
}
});
}
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
/// screen counted down once it has loaded.
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if !viewer.window.is_fullscreen() {
viewer.window.fullscreen();
viewer.slide_fullscreened.set(true);
}
viewer
.slideshow
.set_icon_name("media-playback-pause-symbolic");
hide_chrome(viewer);
if !viewer.loading.get() {
arm_slide(ui, viewer);
}
}
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
fn stop_slideshow(viewer: &Rc<Viewer>) {
disarm_slide(viewer);
if viewer.slide_fullscreened.replace(false) {
viewer.window.unfullscreen();
}
viewer
.slideshow
.set_icon_name("media-playback-start-symbolic");
if viewer.slideshow.is_active() {
viewer.slideshow.set_active(false);
}
}
/// Move a running slideshow on from the photo on screen: after
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
disarm_slide(viewer);
if !viewer.slideshow.is_active() {
return;
}
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
let (ui, weak) = (ui.clone(), Rc::downgrade(viewer));
let source = if playing_video {
glib::timeout_add_local(SLIDESHOW_VIDEO_POLL, move || {
let Some(viewer) = weak.upgrade() else {
return glib::ControlFlow::Break;
};
let ended = viewer
.video
.media_stream()
.is_some_and(|stream| stream.is_ended());
if !ended {
return glib::ControlFlow::Continue;
}
// This timer is finishing, so its id must not be removed again.
viewer.slide_source.borrow_mut().take();
next_slide(&ui, &viewer);
glib::ControlFlow::Break
})
} else {
glib::timeout_add_local_once(SLIDESHOW_INTERVAL, move || {
let Some(viewer) = weak.upgrade() else {
return;
};
viewer.slide_source.borrow_mut().take();
next_slide(&ui, &viewer);
})
};
*viewer.slide_source.borrow_mut() = Some(source);
}
fn disarm_slide(viewer: &Rc<Viewer>) {
if let Some(source) = viewer.slide_source.borrow_mut().take() {
source.remove();
}
}
/// Step the slideshow forward, or end it at the last photo there is.
fn next_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let model = &ui.gallery.model;
let last = find_photo_index(model, &viewer.uid.borrow())
.is_some_and(|index| index + 1 >= model.n_items());
if last && !ui.gallery.has_more.get() {
stop_slideshow(viewer);
return;
}
navigate_photo(ui, viewer, 1);
}
/// Stop whatever the player was playing, so stepping off a video (or closing
/// the lightbox) doesn't leave its sound running.
fn stop_video(viewer: &Rc<Viewer>) {
@@ -326,7 +444,7 @@ pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
viewer.info_rows.remove(&row);
}
let label = gtk4::Label::builder()
.label("Reading photo details…")
.label(gettext("Reading photo details…"))
.halign(gtk4::Align::Start)
.build();
label.add_css_class("dim-label");
@@ -340,15 +458,16 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
viewer.info_rows.remove(&row);
}
let mut fields: Vec<(&str, String)> = Vec::new();
let mut fields: Vec<(String, String)> = Vec::new();
if let Ok(meta) = std::fs::metadata(path) {
fields.push(("Size", human_bytes(meta.len())));
// Translators: label of a photo's file size in the details panel.
fields.push((pgettext("property", "Size"), human_bytes(meta.len())));
}
fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone())));
fields.extend(info.fields);
if fields.is_empty() {
let label = gtk4::Label::builder()
.label("This photo carries no metadata.")
.label(gettext("This photo carries no metadata."))
.halign(gtk4::Align::Start)
.wrap(true)
.build();
@@ -360,7 +479,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
group.add_css_class("boxed-list");
for (label, value) in fields {
let row = adw::ActionRow::builder()
.title(label)
.title(label.as_str())
.subtitle(value)
.subtitle_selectable(true)
.build();
@@ -378,7 +497,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
/// disk. Anything missing is simply left out — phone screenshots and re-encoded
/// images legitimately carry no EXIF at all.
pub(crate) fn read_exif(path: &str) -> ExifInfo {
let mut fields: Vec<(&'static str, String)> = Vec::new();
let mut fields: Vec<(String, String)> = Vec::new();
let mut coords = None;
let reader = match std::fs::File::open(path) {
@@ -402,52 +521,112 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
.get_field(tag, exif::In::PRIMARY)
.map(|f| f.display_value().with_unit(&reader).to_string())
};
// Text tags, without the quotes `display_value` wraps each string in and
// the NUL padding some cameras leave behind.
let ascii = |tag: exif::Tag| {
let field = reader.get_field(tag, exif::In::PRIMARY)?;
let exif::Value::Ascii(parts) = &field.value else {
return None;
};
let text = parts
.iter()
.map(|part| String::from_utf8_lossy(part).into_owned())
.collect::<Vec<_>>()
.join(" ");
let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
(!text.is_empty()).then(|| text.to_string())
};
if let Some(size) = field(exif::Tag::PixelXDimension)
.zip(field(exif::Tag::PixelYDimension))
.map(|(w, h)| format!("{w} × {h}"))
// Translators: a photo's pixel dimensions, such as "4032 × 3024".
.map(|(w, h)| gettext_f("{width} × {height}", &[("width", &w), ("height", &h)]))
{
fields.push(("Dimensions", size));
// Translators: label of a photo's pixel dimensions in the details panel.
fields.push((gettext("Dimensions"), size));
}
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
fields.push(("Taken", taken));
if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) {
// Translators: label of when a photo was taken, in the details panel.
fields.push((pgettext("photo property", "Taken"), taken));
}
let camera = [exif::Tag::Make, exif::Tag::Model]
.iter()
.filter_map(|tag| field(*tag))
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
fields.push(("Camera", camera));
if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
// Translators: label of the camera model in a photo's details panel.
fields.push((gettext("Camera"), camera));
}
if let Some(lens) = field(exif::Tag::LensModel) {
fields.push(("Lens", lens));
if let Some(lens) = ascii(exif::Tag::LensModel) {
// Translators: label of the lens model in a photo's details panel.
fields.push((gettext("Lens"), lens));
}
let shutter = reader
.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY)
.and_then(|f| match &f.value {
exif::Value::Rational(r) => r.first().map(|r| r.to_f64()),
_ => None,
})
.filter(|seconds| seconds.is_finite() && *seconds > 0.0)
.map(format_shutter);
let iso = reader
.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
// Translators: a photo's ISO sensitivity, such as "ISO 400".
.map(|n| gettext_f("ISO {value}", &[("value", &n.to_string())]));
let exposure: Vec<String> = [
exif::Tag::FNumber,
exif::Tag::ExposureTime,
exif::Tag::PhotographicSensitivity,
exif::Tag::FocalLength,
field(exif::Tag::FNumber),
shutter,
iso,
field(exif::Tag::FocalLength),
]
.iter()
.filter_map(|tag| field(*tag))
.into_iter()
.flatten()
.collect();
if !exposure.is_empty() {
fields.push(("Exposure", exposure.join(" · ")));
// Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel.
fields.push((gettext("Exposure"), exposure.join(" · ")));
}
if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)
&& let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)
{
fields.push(("Location", format!("{lat:.5}, {lon:.5}")));
let (lat_text, lon_text) = (format!("{lat:.5}"), format!("{lon:.5}"));
// Translators: label of where a photo was taken, in the details panel.
let label = pgettext("photo property", "Location");
// Translators: GPS coordinates in decimal degrees, such as "52.52000, 13.40500".
let value = gettext_f(
"{latitude}, {longitude}",
&[("latitude", &lat_text), ("longitude", &lon_text)],
);
fields.push((label, value));
coords = Some((lat, lon));
}
ExifInfo { fields, coords }
}
/// "Google Pixel 6" from Make "Google" and Model "Pixel 6", but just "Canon EOS
/// R5" where the model already names the maker.
fn camera_name(make: Option<String>, model: Option<String>) -> Option<String> {
match (make, model) {
(Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => {
Some(model)
}
(Some(make), Some(model)) => Some(format!("{make} {model}")),
(make, model) => make.or(model),
}
}
/// A shutter speed the way cameras print it: "1/250 s" below a second, "2.5 s"
/// from one up.
fn format_shutter(seconds: f64) -> String {
if seconds >= 0.95 {
let text = format!("{seconds:.1}");
format!("{} s", text.strip_suffix(".0").unwrap_or(&text))
} else {
format!("1/{} s", (1.0 / seconds).round())
}
}
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
/// decimal degrees, negating for the S/W hemispheres.
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
@@ -605,7 +784,7 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
/// Copy the photo on screen to where the user picks, under its Drive name.
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &str, name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("Save a Copy")
.title(gettext("Save a Copy"))
.initial_name(name)
.build();
let source = source.to_string();
@@ -625,10 +804,15 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &st
|| dest.display().to_string(),
|name| name.to_string_lossy().into_owned(),
);
toast(&ui, &format!("Saved “{saved}”"));
// Translators: {name} is the saved file's name.
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
}
Ok(Err(e)) => toast_error(&ui, "Couldn't save the copy", &e.to_string()),
Err(_) => toast_error(&ui, "Couldn't save the copy", "The copy was interrupted."),
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => toast_error(
&ui,
&gettext("Couldn't save the copy"),
&gettext("The copy was interrupted."),
),
}
});
});
@@ -731,7 +915,13 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
if let Some(album) = ui.gallery.album.borrow().as_ref() {
return Some(album.photo_count);
}
if ui.gallery.range.get().is_some() || ui.gallery.favorites.get() {
if let Some(place) = ui.gallery.place.borrow().as_ref() {
return Some(place.photo_count);
}
if ui.gallery.range.get().is_some()
|| ui.gallery.favorites.get()
|| ui.gallery.not_in_album.get()
{
return None;
}
let (photos, videos, raw) = ui.gallery.counts.get()?;
@@ -749,9 +939,21 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<usize>) -> String {
let (at, loaded) = (index as usize + 1, loaded as usize);
match total {
_ if !more => format!("{} of {}", thousands(at), thousands(loaded)),
Some(total) if total > loaded => format!("{} of {}", thousands(at), thousands(total)),
_ => format!("{} of {}+", thousands(at), thousands(loaded)),
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
_ if !more => gettext_f(
"{position} of {total}",
&[("position", &thousands(at)), ("total", &thousands(loaded))],
),
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
Some(total) if total > loaded => gettext_f(
"{position} of {total}",
&[("position", &thousands(at)), ("total", &thousands(total))],
),
// Translators: the lightbox counter while more photos are still loading, such as "12 of 200+"; {loaded} is how many are loaded so far.
_ => gettext_f(
"{position} of {loaded}+",
&[("position", &thousands(at)), ("loaded", &thousands(loaded))],
),
}
}
@@ -777,7 +979,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
.title("Photo")
.title(gettext("Photo"))
.modal(true)
.transient_for(&parent)
.default_width(1100)
@@ -824,7 +1026,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Previous (←)")
.tooltip_text(gettext("Previous (←)"))
.build();
prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat");
@@ -838,7 +1040,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text("Next (→)")
.tooltip_text(gettext("Next (→)"))
.build();
next_btn.add_css_class("circular");
next_btn.add_css_class("flat");
@@ -876,7 +1078,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_toggle = gtk4::ToggleButton::builder()
.icon_name("info-outline-symbolic")
.tooltip_text("Details (i)")
.tooltip_text(gettext("Details (i)"))
.valign(gtk4::Align::Center)
.build();
info_toggle.add_css_class("flat");
@@ -884,21 +1086,30 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let favorite_btn = gtk4::ToggleButton::builder()
.icon_name("non-starred-symbolic")
.tooltip_text("Favorite")
// Translators: tooltip of the star button that marks a photo as a favorite.
.tooltip_text(pgettext("verb", "Favorite"))
.valign(gtk4::Align::Center)
.build();
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let slideshow_btn = gtk4::ToggleButton::builder()
.icon_name("media-playback-start-symbolic")
.tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center)
.build();
slideshow_btn.add_css_class("flat");
slideshow_btn.add_css_class("viewer-action-btn");
// Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo.
let group_btn = action("view-paged-symbolic", "Other files of this photo");
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
group_btn.set_visible(false);
let download_btn = action("document-save-symbolic", "Save a Copy…");
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
let open_ext_btn = action("document-open-symbolic", "Open With…");
let close_btn = action("window-close-symbolic", "Close (Esc)");
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
close_btn.add_css_class("viewer-close-btn");
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
@@ -907,6 +1118,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.append(&group_btn);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&slideshow_btn);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
@@ -921,8 +1133,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_map = gtk4::Button::builder()
.child(
&adw::ButtonContent::builder()
.label("Show on map")
.icon_name("map-symbolic")
.label(gettext("Show on map"))
.icon_name("pdfs-map-symbolic")
.build(),
)
.halign(gtk4::Align::Start)
@@ -931,7 +1143,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_map.set_visible(false);
let info_title = gtk4::Label::builder()
.label("Details")
.label(gettext("Details"))
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
@@ -939,7 +1151,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_close = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Hide details")
.tooltip_text(gettext("Hide details"))
.build();
info_close.add_css_class("flat");
info_close.add_css_class("circular");
@@ -970,17 +1182,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_panel.set_width_request(320);
info_panel.append(&info_scroll);
// Set explicitly: the title's hexpand would otherwise propagate up and have
// the revealer take half the window even while it is closed.
let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.hexpand(false)
.child(&info_panel)
.build();
let spinner = gtk4::Spinner::builder()
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.width_request(48)
.height_request(48)
.build();
let spinner = spinner();
spinner.set_halign(gtk4::Align::Center);
spinner.set_valign(gtk4::Align::Center);
spinner.set_size_request(48, 48);
spinner.add_css_class("viewer-spinner");
overlay.add_overlay(&spinner);
@@ -1030,6 +1243,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
chrome_hover: Cell::new(false),
chrome_source: RefCell::new(None),
slideshow: slideshow_btn.clone(),
slide_source: RefCell::new(None),
slide_fullscreened: Cell::new(false),
});
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
@@ -1143,6 +1359,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let viewer_closing = viewer.clone();
window.connect_close_request(move |_| {
stop_video(&viewer_closing);
disarm_slide(&viewer_closing);
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
source.remove();
}
@@ -1156,15 +1373,27 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.set_reveal_child(toggle.is_active());
});
let ui_slides = ui.clone();
let viewer_slides = viewer.clone();
slideshow_btn.connect_toggled(move |toggle| {
if toggle.is_active() {
start_slideshow(&ui_slides, &viewer_slides);
} else {
stop_slideshow(&viewer_slides);
}
});
let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false));
let ui_map = ui.clone();
let viewer_map = viewer.clone();
let w_map = window.clone();
info_map.connect_clicked(move |_| {
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
open_path(&format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
));
let coords = *viewer_map.coords.borrow();
if let Some((lat, lon)) = coords {
w_map.close();
show_on_map(&ui_map, lat, lon);
}
});
@@ -1193,10 +1422,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
None
}
Ok(Ok(Response::Error { message, .. })) => Some(message),
_ => Some("The mount service didn't respond.".to_string()),
_ => Some(gettext("The Proton Drive service didn't respond.")),
};
if let Some(detail) = failed {
toast_error(&ui_result, "Couldn't change the favorite", &detail);
toast_error(
&ui_result,
&gettext("Couldn't change the favorite"),
&detail,
);
// The server refused, so the button must go back to describing
// what is actually stored — without firing this handler again.
if *viewer_result.uid.borrow() == uid {
@@ -1215,7 +1448,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
delete_btn.connect_clicked(move |_| {
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid]);
trash_photos(&ui_delete, vec![uid], false);
});
let ui_group = ui.clone();
@@ -1321,6 +1554,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
Some("0" | "KP_0") => reset_zoom(&viewer_key),
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
Some("F5") => viewer_key
.slideshow
.set_active(!viewer_key.slideshow.is_active()),
// Escape ends a slideshow before it closes anything.
Some("Escape") if viewer_key.slideshow.is_active() => {
viewer_key.slideshow.set_active(false)
}
Some("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
@@ -1334,7 +1574,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid]);
trash_photos(&ui_key, vec![uid], false);
}
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
@@ -1372,7 +1612,27 @@ pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
#[cfg(test)]
mod tests {
use super::{named_link, position_label};
use super::{camera_name, format_shutter, named_link, position_label};
#[test]
fn shutter_speeds_read_like_a_camera() {
assert_eq!(format_shutter(1.0 / 18.869_704_689_121_615), "1/19 s");
assert_eq!(format_shutter(0.001), "1/1000 s");
assert_eq!(format_shutter(2.5), "2.5 s");
assert_eq!(format_shutter(1.0), "1 s");
}
#[test]
fn camera_name_does_not_repeat_the_maker() {
let s = |v: &str| Some(v.to_string());
assert_eq!(camera_name(s("Google"), s("Pixel 6")), s("Google Pixel 6"));
assert_eq!(
camera_name(s("Canon"), s("Canon EOS R5")),
s("Canon EOS R5")
);
assert_eq!(camera_name(None, s("iPhone 15")), s("iPhone 15"));
assert_eq!(camera_name(None, None), None);
}
#[test]
fn the_counter_counts_the_library_while_pages_are_left() {
File diff suppressed because it is too large. Load diff
+514
View File
@@ -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: &gtk4::Picture) {
let map = Rc::downgrade(&ui.gallery.places_map);
let set = move |picture: &gtk4::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(&gtk4::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: &gtk4::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);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+524 -192
View File
@@ -1,48 +1,104 @@
use crate::*;
pub(crate) struct SharedByMeState {
// Shared (by me) page: one section listing the items I've shared, each with a
// copy-link / manage affordance. Rebuilt wholesale on each load.
// Shared by me page: the items I have shared, as a file list whose columns
// 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) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
/// "12 items" under the page title.
pub(crate) subtitle: adw::WindowTitle,
/// Which items to show, indexed like [`SHARE_FILTERS`].
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.
pub(crate) struct SharedByMeWidgets {
pub(crate) files: FileList,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
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 —
/// each row summarizing who has access, opening the item when activated, with
/// Copy link and a menu for the link and the per-node Share dialog.
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
let refresh = refresh_button();
/// Which shared items the page shows.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ShareFilter {
All,
/// Items with a public link.
Links,
/// Items shared with people, invited or already in.
People,
/// Items whose public link has expired.
Expired,
}
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
/// The filters in the order the header's drop-down lists them.
const SHARE_FILTERS: [ShareFilter; 4] = [
ShareFilter::All,
ShareFilter::Links,
ShareFilter::People,
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()
.label("Retry")
.label(gettext("Retry"))
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.icon_name("pdfs-share-symbolic")
.vexpand(true)
.child(&retry)
.build();
@@ -51,39 +107,218 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
let content = gtk4::Stack::new();
content.set_vexpand(true);
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"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(18);
inner.set_margin_bottom(18);
inner.set_margin_start(18);
inner.set_margin_end(18);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&content);
let (frame, header, _) = page_frame("Shared by Me", &inner);
header.pack_end(&refresh);
let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner);
header.pack_start(&filter);
header.pack_end(&layout);
(
frame.upcast(),
SharedByMeWidgets {
files,
content,
status,
group,
retry,
refresh,
subtitle,
filter,
},
)
}
/// Install the Shared (by-me) page's retry button.
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: &gtk4::Button) {
/// Install the file list, its columns, the filter and the retry 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();
retry.connect_clicked(move |_| {
service::restart();
load_shared_by_me(&ui_retry);
widgets
.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(&copy);
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.
pub(crate) fn shared_by_me_status(
ui: &Rc<Ui>,
@@ -97,50 +332,57 @@ pub(crate) fn shared_by_me_status(
ui.shared_by_me.status.set_description(Some(description));
ui.shared_by_me.retry.set_visible(retry);
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.
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
if ui.shared_by_me.inflight.get() {
return;
}
cancel_file_thumbnails(ui);
ui.shared_by_me.inflight.set(true);
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"Loading…",
"Reading what you've shared.",
false,
);
let ui_p = ui.clone();
let ticket = ui.shared_by_me.loader.refresh(move || {
ui_p.shared_by_me.files.model.remove_all();
shared_by_me_status(
&ui_p,
"pdfs-share-symbolic",
&gettext("Loading…"),
&gettext("Reading what you've shared."),
false,
);
});
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
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 {
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);
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
rebind_rows(&ui, &changed);
}
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read your shares",
&gettext("Couldn't read your shares"),
&message,
false,
),
Ok(Ok(_)) => shared_by_me_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read your shares",
"Unexpected reply from the mount service.",
&gettext("Couldn't read your shares"),
&gettext("Unexpected reply from the Proton Drive service."),
false,
),
Ok(Err(_)) | Err(_) => {
ui.shared_by_me.loaded_at.set(None);
shared_by_me_unreachable(&ui);
}
}
@@ -149,144 +391,190 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
/// The daemon didn't answer the Shared (by-me) page.
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
}
/// 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]) {
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
ui.shared_by_me.group.remove(&row);
}
if items.is_empty() {
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"Nothing shared yet",
"Items you share with people or by link show up here.",
"pdfs-share-symbolic",
&gettext("Nothing shared yet"),
&gettext("Items you share with people or by link show up here."),
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;
}
ui.shared_by_me.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for item in items {
let entry = shared_item_as_entry(item);
let row = adw::ActionRow::builder()
.title(&item.name)
.subtitle(shared_item_summary(item))
.build();
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
// A node the daemon can place in my tree opens like it would in My Files.
if !item.path.is_empty() {
row.set_activatable(true);
let ui_open = ui.clone();
let entry_open = entry.clone();
row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open));
}
let url = item.link.as_ref().and_then(|l| l.url.clone());
// The one action people come here for most gets its own button.
if let Some(url) = url.clone() {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text("Copy link")
.valign(gtk4::Align::Center)
.build();
copy.add_css_class("flat");
let ui_copy = ui.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url);
toast(&ui_copy, "Link copied");
});
row.add_suffix(&copy);
}
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
ui.shared_by_me.group.add(&row);
rows.push(row.upcast());
}
*ui.shared_by_me.rows.borrow_mut() = rows;
ui.shared_by_me.subtitle.set_subtitle(&ngettext_f(
"{n} item",
"{n} items",
shown.len() as u64,
&[],
));
// Only the rows that changed are swapped, so a refresh keeps the selection
// and the scroll position.
replace_items(&ui.shared_by_me.files.model, &shown);
}
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
/// Stop Sharing.
///
/// Manage access opens the per-node Share dialog, which addresses a pathless
/// The uids in `new` whose people or link differ from `old`. Their rows show
/// the same [`DirEntry`] as before, so the list would not repaint them.
fn changed_items(old: &[SharedItem], new: &[SharedItem]) -> HashSet<String> {
new.iter()
.filter(|item| old.iter().find(|o| o.uid == item.uid) != Some(*item))
.map(|item| item.uid.clone())
.collect()
}
/// Have the list bind the rows of `uids` again, to repaint their columns.
fn rebind_rows(ui: &Rc<Ui>, uids: &HashSet<String>) {
let model = &ui.shared_by_me.files.model;
for pos in 0..model.n_items() {
if entry_at(Some(model), pos).is_some_and(|e| uids.contains(&e.uid)) {
model.items_changed(pos, 1, 1);
}
}
}
/// A shared item's right-click menu: manage it, open it, its link, and Stop
/// Sharing. Manage Access opens the Share dialog, which addresses a pathless
/// node by uid, so every shared item can be managed from here.
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
let mut items: Vec<(&str, MenuAction)> = Vec::new();
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Manage Access…"), move || {
open_share_dialog(&ui_c, &entry_c)
});
// A node the daemon can place in my tree opens like it would in My Files.
if !entry.path.is_empty() {
let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push(("Open", Box::new(move || open_shared_by_me(&ui_c, &entry_c))));
menu.item(&pgettext("verb", "Open"), move || {
open_shared_by_me(&ui_c, &entry_c)
});
let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push((
"Show in My Files",
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
));
menu.item(&gettext("Show in My Files"), move || {
show_in_my_files(&ui_c, &entry_c)
});
}
let now = glib::real_time() / 1_000_000;
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 {
items.push(("Open Link", Box::new(move || open_uri(&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));
}
menu.section();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push((
"Manage Access…",
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
menu.item(&gettext("Stop Sharing…"), move || {
prompt_stop_sharing(&ui_c, std::slice::from_ref(&entry_c))
});
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, entry_c) = (ui.clone(), entry.clone());
items.push((
"Stop Sharing…",
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
));
more_menu_button(items)
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>, entry: &DirEntry) {
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("Stop sharing?")
.body(format!(
"Everyone you invited loses access to “{}”, pending invitations are \
withdrawn and its public link stops working.",
entry.name
))
.heading(gettext("Stop Sharing?"))
.body(body)
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("stop", "Stop Sharing");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", &gettext("Stop Sharing"));
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui_c = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.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 |_, _| {
run_mutation(
let done = done.clone();
run_requests(
&ui_c,
Request::StopSharingByUid { uid: uid.clone() },
format!("Stopped sharing {name}"),
"Couldn't stop sharing",
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());
@@ -295,9 +583,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
/// 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 {
ui.stack.set_visible_child_name("browser");
ui.browser.search.set_text("");
browse_to(ui, entry.path.clone());
open_in_my_files(ui, entry.path.clone());
} else {
activate_entry(ui, entry);
}
@@ -310,40 +596,11 @@ pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
.rsplit_once('/')
.map(|(parent, _)| parent.to_string())
.unwrap_or_default();
ui.stack.set_visible_child_name("browser");
ui.browser.search.set_text("");
browse_to(ui, parent);
open_in_my_files(ui, parent);
}
/// A one-line summary of who can reach a shared item, for its row subtitle.
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
let mut parts = Vec::new();
if item.member_count > 0 {
parts.push(format!(
"{} {}",
item.member_count,
if item.member_count == 1 {
"person"
} else {
"people"
}
));
}
if item.invite_count > 0 {
parts.push(format!("{} pending", item.invite_count));
}
if item.link.is_some() {
parts.push("Public link".to_string());
}
if parts.is_empty() {
"Shared".to_string()
} else {
parts.join(" · ")
}
}
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
/// open on it. Only used when the item's path is known.
/// 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 {
DirEntry {
name: item.name.clone(),
@@ -359,5 +616,80 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
shared_by: String::new(),
shared_at: 0,
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");
}
}
File diff suppressed because it is too large. Load diff
+141 -102
View File
@@ -15,16 +15,16 @@
//! Import — staging is local, so a mis-drop costs nothing.
//!
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
//! stages paths, launches, asks [`Request::ImportStatus`] for the final report,
//! and reads live progress off the import's job in the transfer snapshot the
//! status tick already fetches (see [`takeout_progress`]).
//! daemon already pushes (see [`takeout_progress`]).
use crate::*;
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
/// Matching on it is how this page borrows the progress the status tick is
/// already polling instead of adding a second poll of its own.
const IMPORT_JOB: &str = "Importing Google Photos";
/// Matching on it is how this page borrows the progress the daemon already
/// pushes instead of asking for it separately.
pub(crate) const IMPORT_JOB: &str = "Importing Google Photos";
/// What the archive list says before anything is staged. `repaint_archives`
/// swaps in the count and total once there is something to report, and swaps
@@ -70,8 +70,11 @@ fn save_staged(ui: &Rc<Ui>) {
}
}
const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \
re-running an interrupted import is safe.";
fn empty_list_description() -> String {
gettext(
"Photos already in Proton Photos are matched by name and content and skipped, so re-running an interrupted import is safe.",
)
}
pub(crate) struct TakeoutState {
/// Archives staged for this import, in the order they were added. Sent as
@@ -130,13 +133,15 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// there and has no sidebar row of its own to navigate back with.
let back_button = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Back to Photos")
.tooltip_text(gettext("Back to Photos"))
.valign(gtk4::Align::Center)
.build();
back_button.add_css_class("flat");
let subtitle = gtk4::Label::builder()
.label("Add a Google Takeout export to your Proton Photos timeline.")
.label(gettext(
"Add a Google Takeout export to your Proton Photos timeline.",
))
.halign(gtk4::Align::Start)
.wrap(true)
.xalign(0.0)
@@ -152,18 +157,18 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
drop_icon.set_pixel_size(48);
drop_icon.add_css_class("dim-label");
let drop_title = gtk4::Label::builder()
.label("Drop your Takeout .zip files here")
.label(gettext("Drop your Takeout .zip files here"))
.build();
drop_title.add_css_class("title-4");
let drop_hint = gtk4::Label::builder()
.label("Add every part of the export at once — a photo and its metadata often sit in different parts.")
.label(gettext("Add every part of the export at once — a photo and its metadata often sit in different parts."))
.wrap(true)
.justify(gtk4::Justification::Center)
.max_width_chars(48)
.build();
drop_hint.add_css_class("dim-label");
let choose_button = gtk4::Button::builder()
.label("Choose files…")
.label(gettext("Choose files…"))
.halign(gtk4::Align::Center)
.build();
choose_button.add_css_class("pill");
@@ -181,13 +186,13 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// group carries a placeholder row while empty so it never renders as a bare
// title.
let list_group = adw::PreferencesGroup::builder()
.title("Archives to import")
.description(EMPTY_LIST_DESCRIPTION)
.title(gettext("Archives to import"))
.description(empty_list_description())
.build();
let clear_button = gtk4::Button::builder()
.label("Clear")
.tooltip_text("Remove every staged archive")
.label(gettext("Clear"))
.tooltip_text(gettext("Remove every staged archive"))
.build();
clear_button.add_css_class("flat");
@@ -195,16 +200,22 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// what an import *would* do, without uploading a byte. It is offered first
// because the import behind it is hours long and hard to take back.
let scan_button = gtk4::Button::builder()
.label("Scan")
.tooltip_text("Check the archives and report what would be imported, without uploading")
.label(pgettext("verb", "Scan"))
.tooltip_text(gettext(
"Check the archives and report what would be imported, without uploading",
))
.build();
scan_button.add_css_class("pill");
let import_button = gtk4::Button::builder().label("Import").build();
let import_button = gtk4::Button::builder()
.label(pgettext("verb", "Import"))
.build();
import_button.add_css_class("pill");
import_button.add_css_class("suggested-action");
let cancel_button = gtk4::Button::builder()
.label("Stop import")
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop")
.label(gettext("Stop import"))
.tooltip_text(gettext(
"Finish the photo on the wire, file what was uploaded, then stop",
))
.build();
cancel_button.add_css_class("pill");
cancel_button.add_css_class("destructive-action");
@@ -224,7 +235,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// Progress: the import's job from the transfer snapshot, so this page shows
// the same numbers the Sync page's Transfers list does.
let progress_group = adw::PreferencesGroup::builder()
.title("Import in progress")
.title(gettext("Import in progress"))
.visible(false)
.build();
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
@@ -246,7 +257,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// outlives the window being on this page, and "what happened" is the whole
// reason to come back to it.
let summary_group = adw::PreferencesGroup::builder()
.title("Last import")
.title(gettext("Last import"))
.visible(false)
.build();
@@ -273,7 +284,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
page.set_margin_end(18);
page.append(&subtitle_clamp);
page.append(&scroll);
let (frame, header, _) = page_frame("Import from Google Photos", &page);
let (frame, header, _) = page_frame(&gettext("Import from Google Photos"), &page);
header.pack_start(&back_button);
let widgets = TakeoutWidgets {
@@ -330,10 +341,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
widgets.choose_button.connect_clicked(move |_| {
// Multi-select on purpose: the whole export goes in one request.
let dialog = gtk4::FileDialog::builder()
.title("Select your Google Takeout archives")
.title(gettext("Select your Google Takeout archives"))
.build();
let filter = gtk4::FileFilter::new();
filter.set_name(Some("Takeout archives"));
filter.set_name(Some(&gettext("Takeout archives")));
filter.add_pattern("*.zip");
let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter);
@@ -373,16 +384,16 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let count = ui_import.takeout.archives.borrow().len();
let ui = ui_import.clone();
let dialog = adw::AlertDialog::builder()
.heading("Import this export?")
.body(format!(
"{} will be uploaded to Proton Photos, creating albums as they appear in \
the export. This can take hours; you can stop it at any point and what has \
been uploaded stays. Photos you already have are skipped.",
count_noun(count, "archive", "archives")
.heading(gettext("Import This Export?"))
.body(ngettext_f(
"{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
"{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
count as u64,
&[],
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("import", "Import");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("import", &pgettext("verb", "Import"));
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -403,14 +414,14 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"),
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Stopping the import…")),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't stop the import", &message, kind);
toast_failure(&ui, &gettext("Couldn't stop the import"), &message, kind);
}
_ => toast_error(
&ui,
"Couldn't stop the import",
"The mount service didn't respond.",
&gettext("Couldn't stop the import"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -460,12 +471,14 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
if rejected > 0 {
toast(
ui,
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
&gettext(
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
),
);
} else if added > 0 {
toast(
ui,
&format!("Added {}", count_noun(added, "archive", "archives")),
&ngettext_f("Added {n} archive", "Added {n} archives", added as u64, &[]),
);
}
}
@@ -481,8 +494,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
if staged.is_empty() {
ui.takeout
.list_group
.set_description(Some(EMPTY_LIST_DESCRIPTION));
let row = dim_row("No archives added yet.");
.set_description(Some(&empty_list_description()));
let row = dim_row(&gettext("No archives added yet."));
ui.takeout.list_group.add(&row);
rows.push(row.upcast());
} else {
@@ -500,7 +513,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
.build();
let remove = gtk4::Button::builder()
.icon_name("edit-delete-symbolic")
.tooltip_text("Remove from this import")
.tooltip_text(gettext("Remove from this import"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
@@ -518,10 +531,14 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
ui.takeout.list_group.add(&row);
rows.push(row.upcast());
}
ui.takeout.list_group.set_description(Some(&format!(
"{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
count_noun(staged.len(), "archive", "archives"),
human_bytes(total)
let size = human_bytes(total);
let note = empty_list_description();
// Translators: {size} is a file size such as "4.2 MB"; {note} is the sentence about skipped duplicates.
ui.takeout.list_group.set_description(Some(&ngettext_f(
"{n} archive, {size} in total. {note}",
"{n} archives, {size} in total. {note}",
staged.len() as u64,
&[("size", &size), ("note", &note)],
)));
}
*ui.takeout.rows.borrow_mut() = rows;
@@ -572,39 +589,40 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
match result {
Ok(Ok(Response::Ok { .. })) => {
ui.takeout.running.set(true);
ui.gallery.import_banner.set_revealed(true);
ui.takeout.summary_group.set_visible(false);
ui.takeout.progress_group.set_visible(true);
ui.takeout.progress_bar.set_fraction(0.0);
ui.takeout.progress_label.set_text(if dry_run {
"Reading archives…"
ui.takeout.progress_label.set_text(&if dry_run {
gettext("Reading archives…")
} else {
"Starting import…"
gettext("Starting import…")
});
sync_takeout_actions(&ui);
toast(
&ui,
if dry_run {
"Scanning the export…"
&if dry_run {
gettext("Scanning the export…")
} else {
"Import started — you can leave this page, it keeps running."
gettext("Import started — you can leave this page, it keeps running.")
},
);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't start the import", &message, kind);
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
}
_ => toast_error(
&ui,
"Couldn't start the import",
"The mount service didn't respond.",
&gettext("Couldn't start the import"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
/// Poll the daemon for the import's state while the page is on screen, and paint
/// the report once a run has finished. Cheap and inflight-guarded, like the other
/// tick polls.
/// Ask the daemon for the import's state, and paint the report once a run has
/// finished. Asked on arrival and while import progress keeps arriving. Cheap
/// and inflight-guarded.
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
if ui.takeout.inflight.get() {
return;
@@ -619,6 +637,7 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
return;
};
let was_running = ui.takeout.running.replace(running);
ui.gallery.import_banner.set_revealed(running);
if running {
ui.takeout.progress_group.set_visible(true);
} else {
@@ -629,23 +648,35 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
if was_running {
let dry = ui.takeout.dry_run.get();
let headline = if dry {
"Scan finished"
gettext("Scan finished")
} else {
"Import finished"
gettext("Import finished")
};
toast(&ui, headline);
toast(&ui, &headline);
if !dry {
let body = match &summary {
Some(s) if s.failed > 0 => {
format!("{} photos added, {} failed.", s.uploaded, s.failed)
}
Some(s) if s.cancelled => {
format!("Stopped after {} photos.", s.uploaded)
}
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded),
None => "The Google Photos import has finished.".to_string(),
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
Some(s) if s.failed > 0 => ngettext_f(
"{n} photo added, {failed} failed.",
"{n} photos added, {failed} failed.",
s.uploaded as u64,
&[("failed", &s.failed.to_string())],
),
Some(s) if s.cancelled => ngettext_f(
"Stopped after {n} photo.",
"Stopped after {n} photos.",
s.uploaded as u64,
&[],
),
Some(s) => ngettext_f(
"{n} photo added to Proton Photos.",
"{n} photos added to Proton Photos.",
s.uploaded as u64,
&[],
),
None => gettext("The Google Photos import has finished."),
};
notify("takeout-import", headline, &body);
notify("takeout-import", &headline, &body);
}
}
}
@@ -665,69 +696,72 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
ui.takeout.summary_group.remove(&row);
}
let dry = ui.takeout.dry_run.get();
ui.takeout
.summary_group
.set_title(if dry { "Scan result" } else { "Last import" });
ui.takeout.summary_group.set_description(Some(if dry {
"Nothing was uploaded — this is what an import would do."
} else if summary.cancelled {
"Stopped early. Everything uploaded so far is in your timeline; run it again to \
continue where it left off."
ui.takeout.summary_group.set_title(&if dry {
gettext("Scan result")
} else {
"Finished."
gettext("Last import")
});
ui.takeout.summary_group.set_description(Some(&if dry {
gettext("Nothing was uploaded — this is what an import would do.")
} else if summary.cancelled {
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
} else {
gettext("Finished.")
}));
// Only lines that carry information: a run with no failures should not have
// to show a "0 failed" row for the user to read past.
let mut lines: Vec<(String, String)> = vec![
("Photos in the export".into(), summary.found.to_string()),
(gettext("Photos in the export"), summary.found.to_string()),
(
if dry { "Would upload" } else { "Uploaded" }.into(),
if dry {
gettext("Would upload")
} else {
gettext("Uploaded")
},
summary.uploaded.to_string(),
),
];
if summary.duplicates > 0 {
lines.push((
"Already in Proton Photos".into(),
gettext("Already in Proton Photos"),
summary.duplicates.to_string(),
));
}
if summary.albums_created > 0 {
lines.push((
if dry {
"Albums to create"
gettext("Albums to create")
} else {
"Albums created"
}
.into(),
gettext("Albums created")
},
summary.albums_created.to_string(),
));
}
if summary.album_links > 0 {
lines.push((
"Photos filed into albums".into(),
gettext("Photos filed into albums"),
summary.album_links.to_string(),
));
}
if summary.skipped_trashed > 0 {
lines.push((
"Skipped (in Google's trash)".into(),
gettext("Skipped (in Google's trash)"),
summary.skipped_trashed.to_string(),
));
}
if summary.bytes > 0 {
lines.push((
if dry {
"Data to upload"
gettext("Data to upload")
} else {
"Data uploaded"
}
.into(),
gettext("Data uploaded")
},
human_bytes(summary.bytes),
));
}
if summary.failed > 0 {
lines.push(("Failed".into(), summary.failed.to_string()));
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
}
let mut rows = Vec::new();
@@ -744,8 +778,8 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
ui.takeout.summary_group.set_visible(true);
}
/// Paint the import's progress from the transfer snapshot the status tick
/// already fetched, so this page costs no extra poll. A job with no known total
/// Paint the import's progress from the transfer snapshot the daemon pushed,
/// so this page costs no extra request. A job with no known total
/// pulses instead of showing a fraction, the same as the Activity list.
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
@@ -756,17 +790,22 @@ pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
ui.takeout
.progress_bar
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
ui.takeout
.progress_label
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail));
let done = job.done.to_string();
let total = job.total.to_string();
// Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
let text = gettext_f(
"{done} of {total} — {detail}",
&[("done", &done), ("total", &total), ("detail", &job.detail)],
);
ui.takeout.progress_label.set_text(&text);
} else {
ui.takeout.progress_bar.pulse();
ui.takeout
.progress_label
.set_text(if job.detail.is_empty() {
"Working…"
.set_text(&if job.detail.is_empty() {
gettext("Working…")
} else {
&job.detail
job.detail.clone()
});
}
}
+340 -238
View File
@@ -3,58 +3,54 @@ use crate::*;
pub(crate) struct TrashState {
// 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.
pub(crate) model: gio::ListStore,
pub(crate) files: FileList,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
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,
/// "12 items" under the page title.
pub(crate) subtitle: adw::WindowTitle,
pub(crate) selection: gtk4::MultiSelection,
/// 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.
pub(crate) struct TrashWidgets {
pub(crate) model: gio::ListStore,
pub(crate) list: gtk4::ListView,
pub(crate) files: FileList,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) empty: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) subtitle: adw::WindowTitle,
pub(crate) selection: gtk4::MultiSelection,
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
/// row offering Restore and Delete Forever, with Empty Trash in the header. A
/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a
/// bottom bar.
/// The Trash page: everything Drive is holding in the trash, as a list with
/// 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
/// trashed — so unlike the Files page this one addresses entries by uid and always
/// re-lists from the server rather than from a cached listing. Row rendering needs
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
/// A trashed node has no path inside the mount — the daemon forgets it when it
/// is trashed — so unlike the Files page this one addresses entries by uid. The
/// list's factories need the [`Ui`] handle, so they are installed in
/// [`wire_trash`].
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
let model = gio::ListStore::new::<BoxedAnyObject>();
let empty = gtk4::Button::builder()
.label("Empty Trash…")
.tooltip_text("Permanently delete everything in the Trash")
.sensitive(false)
.label(gettext("Empty Trash…"))
.tooltip_text(gettext("Permanently delete everything in the Trash"))
.visible(false)
.build();
let refresh = refresh_button();
let retry = gtk4::Button::builder()
.label("Retry")
.label(gettext("Retry"))
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
@@ -67,31 +63,30 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
.build();
status.add_css_class("compact");
// Bulk Delete Forever always confirms with the count, so a stale selection
// can't delete anything silently.
let selection = gtk4::MultiSelection::new(Some(model.clone()));
let list = gtk4::ListView::builder().model(&selection).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&list)
.build();
// The list first: where an item came from and when it went are what tell
// two copies of a file apart here.
let files = FileList::new();
files.show_list(true);
let layout = layout_button(&files);
let content = gtk4::Stack::new();
content.set_vexpand(true);
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"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(18);
inner.set_margin_bottom(18);
inner.set_margin_start(18);
inner.set_margin_end(18);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
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("Restore");
let delete_selected = gtk4::Button::with_label("Delete Permanently…");
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
delete_selected.add_css_class("destructive-action");
let bar = gtk4::ActionBar::new();
bar.set_center_widget(Some(&selection_label));
@@ -103,22 +98,19 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
.build();
inner.append(&selection_bar);
let (frame, header, subtitle) = page_frame("Trash", &inner);
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
header.pack_start(&empty);
header.pack_end(&refresh);
header.pack_end(&layout);
(
frame.upcast(),
TrashWidgets {
model,
list,
files,
content,
status,
retry,
empty,
refresh,
subtitle,
selection,
selection_bar,
selection_label,
restore_selected,
@@ -127,167 +119,173 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
)
}
/// Install the row factory and the Empty Trash button. The row's two buttons read
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
/// captured copy, so a recycled row always acts on the item it currently shows.
/// Install the list's factories and columns, the Empty Trash button and the
/// selection bar.
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
let list = &widgets.list;
let empty = &widgets.empty;
let factory = gtk4::SignalListItemFactory::new();
let ui_setup = ui.clone();
factory.connect_setup(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let thumbnail = file_thumbnail_widget(40, 32);
let name = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
meta.add_css_class("dim-label");
meta.add_css_class("caption");
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
text.set_hexpand(true);
text.set_valign(gtk4::Align::Center);
text.append(&name);
text.append(&meta);
let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Restore to its original folder")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let purge = gtk4::Button::builder()
.icon_name("edit-delete-symbolic")
.tooltip_text("Delete permanently")
.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 files = &ui.trash.files;
files.wire(
ui,
FileListBehavior {
activate: activate_trashed,
entry_menu: trash_entry_menu,
bulk_menu: trash_bulk_menu,
background_menu: trash_background_menu,
badges: false,
drag_and_drop: false,
},
);
files
.column_view
.append_column(&text_column(&gettext("Original Location"), |e| {
trashed_from_label(e)
}));
files
.column_view
.append_column(&text_column(&pgettext("column", "Deleted"), |e| {
if e.trashed_at > 0 {
dates::relative(e.trashed_at)
} else {
"—".to_string()
}
});
let ui_purge = ui_setup.clone();
let item_purge = item.clone();
purge.connect_clicked(move |_| {
if let Some(entry) = bound_entry(&item_purge) {
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
}));
files
.column_view
.append_column(&text_column(&pgettext("column", "Size"), |e| {
if e.is_dir {
"—".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();
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
widgets
.empty
.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
let ui_sel = ui.clone();
widgets
files
.selection
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
let ui_restore = ui.clone();
widgets.restore_selected.connect_clicked(move |_| {
let entries = selected_trash(&ui_restore);
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 = selected_trash(&ui_delete);
let entries = ui_delete.trash.files.selected();
if !entries.is_empty() {
prompt_delete_forever(&ui_delete, &entries);
}
});
}
/// The trashed entries currently selected, in list order.
fn selected_trash(ui: &Rc<Ui>) -> Vec<DirEntry> {
let selected = ui.trash.selection.selection();
(0..selected.size())
.filter_map(|i| ui.trash.model.item(selected.nth(i as u32)))
.filter_map(|obj| obj.downcast::<BoxedAnyObject>().ok())
.map(|obj| obj.borrow::<DirEntry>().clone())
.collect()
/// Where a trashed entry goes back to on restore, for the Original Location
/// column.
fn trashed_from_label(entry: &DirEntry) -> String {
match entry.trashed_from.as_deref() {
None => "—".to_string(),
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.selection.selection().size() as usize;
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(&format!("{} selected", count_noun(count, "item", "items")));
ui.trash.selection_label.set_label(&ngettext_f(
"{n} item selected",
"{n} items selected",
count as u64,
&[],
));
}
}
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
/// (recycled) row.
pub(crate) fn bound_entry(item: &gtk4::ListItem) -> Option<DirEntry> {
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
let entry = obj.borrow::<DirEntry>().clone();
Some(entry)
}
/// Fetch the trash listing and repaint the page.
pub(crate) fn load_trash(ui: &Rc<Ui>) {
cancel_file_thumbnails(ui);
// Drop the old rows first: a stale row here would offer Restore on something
// that may already be gone.
ui.trash.model.remove_all();
ui.trash.empty.set_sensitive(false);
sync_trash_selection(ui);
trash_status(
ui,
"user-trash-symbolic",
"Loading…",
"Reading the trash.",
false,
);
// The rows stay up while the trash is read again. Trashed items are
// addressed by uid, so acting on one that has gone meanwhile fails
// cleanly rather than hitting something else.
let ui_p = ui.clone();
let ticket = ui.trash.loader.refresh(move || {
clear_trash(&ui_p);
ui_p.trash.empty.set_sensitive(false);
trash_status(
&ui_p,
"user-trash-symbolic",
&gettext("Loading…"),
&gettext("Reading the trash."),
false,
);
});
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
@@ -295,20 +293,26 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
if !ticket.is_current() {
return;
}
if !matches!(result, Ok(Ok(Response::Entries { .. }))) {
clear_trash(&ui);
}
match result {
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
Ok(Ok(Response::Error { message, .. })) => trash_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read the trash",
&gettext("Couldn't read the trash"),
&message,
false,
),
Ok(Ok(_)) => trash_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read the trash",
"Unexpected reply from the mount service.",
&gettext("Couldn't read the trash"),
&gettext("Unexpected reply from the Proton Drive service."),
false,
),
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
@@ -319,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
/// again, no button) versus actually down (Retry, which restarts the service).
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "trash", trash_status, load_trash);
}
/// Show a status page in place of the trash list.
@@ -354,70 +336,132 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
ui.trash.subtitle.set_subtitle("");
}
/// Repopulate the trash list, most recently modified first — the order in which a
/// user looks for what they just deleted.
/// Repopulate the trash, most recently deleted first: the order in which a user
/// looks for what they just deleted.
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
ui.trash.model.remove_all();
sync_trash_selection(ui);
ui.trash.empty.set_sensitive(!entries.is_empty());
// An empty Trash has nothing to empty, so the button goes away rather than
// sitting there greyed out.
ui.trash.empty.set_sensitive(true);
ui.trash.empty.set_visible(!entries.is_empty());
if entries.is_empty() {
trash_status(
ui,
"user-trash-symbolic",
"Trash is empty",
"Items you delete from Proton Drive show up here.",
&gettext("Trash is empty"),
&gettext("Items you delete from Proton Drive show up here."),
false,
);
clear_trash(ui);
return;
}
ui.trash.content.set_visible_child_name("list");
ui.trash
.subtitle
.set_subtitle(&count_noun(entries.len(), "item", "items"));
ui.trash.subtitle.set_subtitle(&ngettext_f(
"{n} item",
"{n} items",
entries.len() as u64,
&[],
));
let mut sorted = entries.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
for entry in sorted {
ui.trash.model.append(&BoxedAnyObject::new(entry));
}
sort_trash(&mut sorted);
// Only the rows that changed are swapped, so a refresh keeps the
// 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.
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
restore_entries(ui, std::slice::from_ref(entry));
/// Newest deletion first. Entries with no known deletion time go last, newest
/// modification first among themselves.
fn sort_trash(entries: &mut [DirEntry]) {
entries.sort_by_key(|e| {
(
e.trashed_at == 0,
std::cmp::Reverse(e.trashed_at),
std::cmp::Reverse(e.modified),
)
});
}
/// Restore trashed entries to the folders they were trashed from.
/// 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]) {
run_mutation(
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,
Request::Restore {
uids: entries.iter().map(|e| e.uid.clone()).collect(),
},
format!("Restored {}", entries_label(entries)),
"Couldn't restore",
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),
},
);
}
/// “name” for one entry, “3 items” for several.
fn entries_label(entries: &[DirEntry]) -> String {
match entries {
[one] => format!("“{}”", one.name),
_ => count_noun(entries.len(), "item", "items"),
}
/// The folder every entry goes back to, when that is one known folder.
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 uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
let name = entries_label(entries);
let count = entries.len() as u64;
let (body, done) = match entries {
[one] => {
let args = [("name", one.name.as_str())];
(
// Translators: {name} is a file or folder name.
gettext_f("Permanently delete “{name}”? This cannot be undone.", &args),
// Translators: {name} is a file or folder name.
gettext_f("Deleted “{name}” permanently", &args),
)
}
_ => (
ngettext_f(
"Permanently delete {n} item? This cannot be undone.",
"Permanently delete {n} items? This cannot be undone.",
count,
&[],
),
ngettext_f(
"Deleted {n} item permanently",
"Deleted {n} items permanently",
count,
&[],
),
),
};
let dialog = adw::AlertDialog::builder()
.heading("Delete Permanently")
.body(format!("Permanently delete {name}? This cannot be undone."))
.heading(gettext("Delete Permanently?"))
.body(body)
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("delete", "Delete Permanently");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("delete", &gettext("Delete Permanently"));
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -428,8 +472,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
run_mutation(
&ui,
Request::DeleteForever { uids: uids.clone() },
format!("Deleted {name} permanently"),
"Couldn't delete",
done.clone(),
gettext_noop("Couldn't delete"),
);
}
});
@@ -439,16 +483,18 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
/// Confirm, then permanently delete everything in the trash.
pub(crate) fn prompt_empty_trash(ui: &Rc<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()
.heading("Empty Trash")
.body(format!(
"Permanently delete all {} in the trash? This cannot be undone.",
count_noun(count as usize, "item", "items")
.heading(gettext("Empty Trash?"))
.body(ngettext_f(
"Permanently delete all {n} item in the trash? This cannot be undone.",
"Permanently delete all {n} items in the trash? This cannot be undone.",
count as u64,
&[],
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("empty", "Empty Trash");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("empty", &gettext("Empty Trash"));
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -459,10 +505,66 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
run_mutation(
&ui,
Request::EmptyTrash,
"Trash emptied".to_string(),
"Couldn't empty the trash",
gettext("Trash emptied"),
gettext_noop("Couldn't empty the trash"),
);
}
});
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);
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ pub(crate) fn prompt_human_verification(
.build();
let dialog = adw::Dialog::builder()
.title("Verification")
.title(gettext("Verification"))
.content_width(420)
.content_height(560)
.build();
+188
View File
@@ -0,0 +1,188 @@
//! Reloading a page without flashing it.
//!
//! Pages used to swap their content for a "Loading…" status page on every
//! load, so every sidebar click and every folder change blinked. A [`Loader`]
//! keeps the last content on screen while the request runs and shows the
//! loading state only if the reply is slow. [`replace_items`] then swaps in the
//! new rows by changing only the ones that differ, so selection and scroll
//! position survive a refresh.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use gtk4::gio;
use gtk4::glib;
use gtk4::glib::BoxedAnyObject;
use gtk4::glib::object::Cast;
use gtk4::prelude::*;
/// How long a load may run before the page gives up its old content for a
/// loading state. Most replies arrive well within this, so the page updates in
/// place.
const LOADING_GRACE: Duration = Duration::from_millis(300);
/// One page's loads. Each [`Loader::refresh`] or [`Loader::replace`]
/// supersedes the one before it.
pub(crate) struct Loader {
content: gtk4::Widget,
generation: Cell<u64>,
placeholder: RefCell<Option<glib::SourceId>>,
}
impl Loader {
/// A loader for the page whose content is `content`.
pub(crate) fn new(content: &impl IsA<gtk4::Widget>) -> Rc<Self> {
Rc::new(Loader {
content: content.clone().upcast(),
generation: Cell::new(0),
placeholder: RefCell::new(None),
})
}
/// Start a load of what the page already shows. The content stays up and
/// usable; `placeholder` runs if the load is still going after
/// [`LOADING_GRACE`].
pub(crate) fn refresh(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
self.begin(false, placeholder)
}
/// Start a load of something else, such as another folder. The old
/// content stays up but insensitive, since acting on it would act on the
/// wrong thing; `placeholder` runs if the load is still going after
/// [`LOADING_GRACE`].
pub(crate) fn replace(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
self.begin(true, placeholder)
}
fn begin(self: &Rc<Self>, stale: bool, placeholder: impl FnOnce() + 'static) -> LoadTicket {
let generation = self.generation.get().wrapping_add(1);
self.generation.set(generation);
self.cancel_placeholder();
self.content.set_sensitive(!stale);
let loader = Rc::downgrade(self);
let source = glib::timeout_add_local_once(LOADING_GRACE, move || {
let Some(loader) = loader.upgrade() else {
return;
};
loader.placeholder.borrow_mut().take();
loader.content.set_sensitive(true);
placeholder();
});
*self.placeholder.borrow_mut() = Some(source);
LoadTicket {
loader: self.clone(),
generation,
}
}
fn cancel_placeholder(&self) {
if let Some(source) = self.placeholder.borrow_mut().take() {
source.remove();
}
}
}
/// A load in flight. Dropping it ends the load: the pending loading state is
/// cancelled and the content is usable again, unless a newer load has taken
/// over in the meantime.
pub(crate) struct LoadTicket {
loader: Rc<Loader>,
generation: u64,
}
impl LoadTicket {
/// Whether no newer load has started since this one. A superseded load's
/// reply is stale and must not be painted.
pub(crate) fn is_current(&self) -> bool {
self.loader.generation.get() == self.generation
}
}
impl Drop for LoadTicket {
fn drop(&mut self) {
if self.is_current() {
self.loader.cancel_placeholder();
self.loader.content.set_sensitive(true);
}
}
}
/// Make `model`, a list of [`BoxedAnyObject`]s holding `T`, hold `items`,
/// replacing only the run of items that changed, in one `items-changed`
/// signal. Rows before and after that run keep their objects, so the views
/// keep their widgets, the selection and the scroll position.
pub(crate) fn replace_items<T: Clone + PartialEq + 'static>(model: &gio::ListStore, items: &[T]) {
let held = |i: usize, item: &T| {
model
.item(i as u32)
.and_downcast::<BoxedAnyObject>()
.is_some_and(|object| *object.borrow::<T>() == *item)
};
let Some((start, removed, added)) = changed_run(model.n_items() as usize, items, held) else {
return;
};
let added: Vec<BoxedAnyObject> = items[added]
.iter()
.cloned()
.map(BoxedAnyObject::new)
.collect();
model.splice(start as u32, removed as u32, &added);
}
/// The one run that differs between an old list of `old_len` items and `new`:
/// where it starts, how many old items it covers, and which new items replace
/// them. `held(i, item)` says whether old item `i` equals `item`. `None` when
/// the lists are equal.
fn changed_run<T>(
old_len: usize,
new: &[T],
held: impl Fn(usize, &T) -> bool,
) -> Option<(usize, usize, std::ops::Range<usize>)> {
let shared = old_len.min(new.len());
let prefix = (0..shared).take_while(|&i| held(i, &new[i])).count();
let suffix = (0..shared - prefix)
.take_while(|&k| held(old_len - 1 - k, &new[new.len() - 1 - k]))
.count();
let removed = old_len - prefix - suffix;
let added = prefix..new.len() - suffix;
(removed > 0 || !added.is_empty()).then_some((prefix, removed, added))
}
#[cfg(test)]
mod tests {
use super::changed_run;
fn run(old: &[u32], new: &[u32]) -> Option<(usize, usize, Vec<u32>)> {
changed_run(old.len(), new, |i, item| old[i] == *item)
.map(|(start, removed, added)| (start, removed, new[added].to_vec()))
}
#[test]
fn equal_lists_change_nothing() {
assert_eq!(run(&[1, 2, 3], &[1, 2, 3]), None);
assert_eq!(run(&[], &[]), None);
}
#[test]
fn only_the_differing_middle_is_replaced() {
assert_eq!(run(&[1, 2, 3, 4], &[1, 9, 4]), Some((1, 2, vec![9])));
assert_eq!(run(&[1, 2, 3], &[1, 2, 5, 3]), Some((2, 0, vec![5])));
assert_eq!(run(&[1, 2, 3], &[1, 3]), Some((1, 1, vec![])));
}
#[test]
fn growing_or_shrinking_at_either_end() {
assert_eq!(run(&[1, 2], &[0, 1, 2]), Some((0, 0, vec![0])));
assert_eq!(run(&[1, 2], &[1, 2, 3]), Some((2, 0, vec![3])));
assert_eq!(run(&[1, 2, 3], &[]), Some((0, 3, vec![])));
assert_eq!(run(&[], &[1]), Some((0, 0, vec![1])));
}
#[test]
fn repeated_items_are_not_counted_twice() {
// The prefix takes both 1s; the suffix must not reuse them.
assert_eq!(run(&[1, 1], &[1, 1, 1]), Some((2, 0, vec![1])));
assert_eq!(run(&[1, 1, 1], &[1, 1]), Some((2, 1, vec![])));
}
}
+311
View File
@@ -0,0 +1,311 @@
//! The app's side of `src/compat.rs`: widgets libadwaita 1.6–1.8 brought, with
//! a stand-in for 1.5. [`has_adw_1_8`] picks the real ones at run time.
use crate::*;
/// A segmented control: a row of options of which exactly one is on, told
/// apart by index in the order they were given. `AdwToggleGroup` on
/// libadwaita 1.8, linked toggle buttons before.
#[derive(Clone)]
pub(crate) struct ToggleGroup {
widget: gtk4::Widget,
options: Options,
}
#[derive(Clone)]
enum Options {
/// The group's `AdwToggle`s, reached by property name.
Toggles(Rc<[glib::Object]>),
Buttons(Rc<[gtk4::ToggleButton]>),
}
impl ToggleGroup {
/// A group of `(label, tooltip)` options, with the first one on.
pub(crate) fn new(options: &[(&str, Option<&str>)]) -> Self {
if has_adw_1_8() {
let children: String = (0..options.len())
.map(|i| format!(r#"<child><object class="AdwToggle" id="toggle{i}"/></child>"#))
.collect();
let builder = build_ui(&format!(
r#"<object class="AdwToggleGroup" id="group">{children}</object>"#
));
let toggles = options
.iter()
.enumerate()
.map(|(i, (label, tooltip))| {
let toggle: glib::Object = built(&builder, &format!("toggle{i}"));
toggle.set_property("label", *label);
if let Some(tooltip) = tooltip {
toggle.set_property("tooltip", *tooltip);
}
toggle
})
.collect();
let widget: gtk4::Widget = built(&builder, "group");
// A toggle group starts with nothing on.
widget.set_property("active", 0u32);
Self {
widget,
options: Options::Toggles(toggles),
}
} else {
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
bar.add_css_class("linked");
let buttons: Rc<[gtk4::ToggleButton]> = options
.iter()
.map(|(label, tooltip)| {
let button = gtk4::ToggleButton::with_label(label);
button.set_tooltip_text(*tooltip);
bar.append(&button);
button
})
.collect();
if let Some((first, rest)) = buttons.split_first() {
first.set_active(true);
// Chaining each to the first is what GTK turns into mutual
// exclusion.
for button in rest {
button.set_group(Some(first));
}
}
Self {
widget: bar.upcast(),
options: Options::Buttons(buttons),
}
}
}
/// The widget to pack.
pub(crate) fn widget(&self) -> gtk4::Widget {
self.widget.clone()
}
/// Give the options pill-shaped ends.
pub(crate) fn set_round(&self) {
match &self.options {
Options::Toggles(_) => self.widget.add_css_class("round"),
Options::Buttons(buttons) => {
for button in buttons.iter() {
button.add_css_class("pill");
}
}
}
}
/// The option that is on.
pub(crate) fn active(&self) -> u32 {
match &self.options {
Options::Toggles(_) => self.widget.property("active"),
Options::Buttons(buttons) => {
buttons.iter().position(|b| b.is_active()).unwrap_or(0) as u32
}
}
}
/// Turn option `index` on, which turns the one that was on off.
pub(crate) fn set_active(&self, index: u32) {
match &self.options {
Options::Toggles(_) => self.widget.set_property("active", index),
Options::Buttons(buttons) => {
if let Some(button) = buttons.get(index as usize) {
button.set_active(true);
}
}
}
}
pub(crate) fn set_label(&self, index: u32, label: &str) {
match &self.options {
Options::Toggles(toggles) => {
if let Some(toggle) = toggles.get(index as usize) {
toggle.set_property("label", label);
}
}
Options::Buttons(buttons) => {
if let Some(button) = buttons.get(index as usize) {
button.set_label(label);
}
}
}
}
/// Let option `index` be picked, or not.
pub(crate) fn set_enabled(&self, index: u32, enabled: bool) {
match &self.options {
Options::Toggles(toggles) => {
if let Some(toggle) = toggles.get(index as usize) {
toggle.set_property("enabled", enabled);
}
}
Options::Buttons(buttons) => {
if let Some(button) = buttons.get(index as usize) {
button.set_sensitive(enabled);
}
}
}
}
/// Call `f` with the option that has just come on, whether the user or
/// [`Self::set_active`] turned it on.
pub(crate) fn connect_changed(&self, f: impl Fn(u32) + 'static) {
match &self.options {
Options::Toggles(_) => {
self.widget
.connect_notify_local(Some("active"), move |group, _| {
f(group.property("active"))
});
}
Options::Buttons(buttons) => {
let f = Rc::new(f);
for (index, button) in buttons.iter().enumerate() {
let f = f.clone();
// The group also fires `toggled` for the button going off;
// only the one coming on counts.
button.connect_toggled(move |button| {
if button.is_active() {
f(index as u32);
}
});
}
}
}
}
}
/// A list row that is a button: a centred title and an icon after it.
/// `AdwButtonRow` on libadwaita 1.8, an activatable `adw::ActionRow` before.
pub(crate) fn button_row(
title: &str,
end_icon: &str,
on_activated: impl Fn() + 'static,
) -> gtk4::ListBoxRow {
if has_adw_1_8() {
let builder = build_ui(r#"<object class="AdwButtonRow" id="row"/>"#);
let row: adw::PreferencesRow = built(&builder, "row");
row.set_title(title);
row.set_property("end-icon-name", end_icon);
row.connect_local("activated", false, move |_| {
on_activated();
None
});
row.upcast()
} else {
let row = adw::ActionRow::builder()
.title(title)
.activatable(true)
.build();
row.add_suffix(&gtk4::Image::from_icon_name(end_icon));
row.connect_activated(move |_| on_activated());
row.upcast()
}
}
/// Show the keyboard shortcuts window over `window`. See [`shortcuts_dialog`].
pub(crate) fn present_shortcuts(
window: &impl IsA<gtk4::Widget>,
groups: &[(String, Vec<(&str, String)>)],
) {
shortcuts_dialog(groups).present(Some(window));
}
/// The keyboard shortcuts window: `groups` of `(accelerator, what it does)`
/// under a heading each, all already translated. `AdwShortcutsDialog` on
/// libadwaita 1.8, a preferences page of rows before.
fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog {
if has_adw_1_8() {
let sections: String = groups
.iter()
.enumerate()
.map(|(s, (_, keys))| {
let items: String = (0..keys.len())
.map(|k| {
format!(r#"<child><object class="AdwShortcutsItem" id="item{s}_{k}"/></child>"#)
})
.collect();
format!(
r#"<child><object class="AdwShortcutsSection" id="section{s}">{items}</object></child>"#
)
})
.collect();
let builder = build_ui(&format!(
r#"<object class="AdwShortcutsDialog" id="dialog">{sections}</object>"#
));
for (s, (title, keys)) in groups.iter().enumerate() {
let section: glib::Object = built(&builder, &format!("section{s}"));
section.set_property("title", title.as_str());
for (k, (accel, action)) in keys.iter().enumerate() {
let item: glib::Object = built(&builder, &format!("item{s}_{k}"));
item.set_property("title", action.as_str());
item.set_property("accelerator", *accel);
}
}
return built(&builder, "dialog");
}
let page = adw::PreferencesPage::new();
for (title, keys) in groups {
let group = adw::PreferencesGroup::builder().title(title).build();
for (accel, action) in keys {
let row = adw::ActionRow::builder().title(action).build();
row.add_suffix(
&gtk4::ShortcutLabel::builder()
.accelerator(*accel)
.valign(gtk4::Align::Center)
.build(),
);
group.add(&row);
}
page.add(&group);
}
adw::Dialog::builder()
.title(gettext("Keyboard Shortcuts"))
.content_width(460)
.content_height(620)
.child(&{
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new());
toolbar.set_content(Some(&page));
toolbar
})
.build()
}
#[cfg(test)]
mod tests {
use super::*;
/// The newer widgets are made by class name and set up by property and
/// signal name, so a typo in any of those only shows when they are built.
/// Needs a display and libadwaita 1.8; passes trivially without either.
#[test]
fn newer_widgets_build_by_class_name() {
if adw::init().is_err() || !has_adw_1_8() {
return;
}
let group = ToggleGroup::new(&[("One", None), ("Two", Some("The second"))]);
group.set_round();
group.set_label(1, "Deux");
group.set_enabled(1, true);
assert_eq!(group.widget().type_().name(), "AdwToggleGroup");
assert_eq!(group.active(), 0);
let changed = Rc::new(Cell::new(None));
group.connect_changed({
let changed = changed.clone();
move |index| changed.set(Some(index))
});
group.set_active(1);
assert_eq!((group.active(), changed.get()), (1, Some(1)));
let row = button_row("Row", "go-down-symbolic", || {});
assert_eq!(row.type_().name(), "AdwButtonRow");
assert_eq!(spinner().type_().name(), "AdwSpinner");
let dialog = shortcuts_dialog(&[(
"General".to_owned(),
vec![("<Control>q", "Quit".to_owned())],
)]);
assert_eq!(dialog.type_().name(), "AdwShortcutsDialog");
}
}
+75 -47
View File
@@ -49,13 +49,13 @@ pub(crate) struct DetailsWidgets {
/// [`DirEntry`] and [`wire_details`] connects the buttons.
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle = gtk4::ToggleButton::builder()
.icon_name("sidebar-show-right-symbolic")
.tooltip_text("Details (Alt+Enter)")
.icon_name("pdfs-details-pane-symbolic")
.tooltip_text(gettext("Details (Alt+Enter)"))
.build();
let close_button = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Close details")
.tooltip_text(gettext("Close details"))
.halign(gtk4::Align::End)
.build();
close_button.add_css_class("flat");
@@ -81,19 +81,21 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
head.append(&name);
head.append(&kind);
let props = adw::PreferencesGroup::builder().title("Info").build();
let props = adw::PreferencesGroup::builder()
.title(gettext("Info"))
.build();
let size_row = adw::ActionRow::builder()
.title("Size")
.title(pgettext("column", "Size"))
.subtitle("—")
.build();
size_row.add_css_class("property");
let modified_row = adw::ActionRow::builder()
.title("Modified")
.title(pgettext("column", "Modified"))
.subtitle("—")
.build();
modified_row.add_css_class("property");
let path_row = adw::ActionRow::builder()
.title("Location")
.title(gettext("Location"))
.subtitle("—")
.subtitle_lines(3)
.build();
@@ -102,22 +104,28 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
props.add(&modified_row);
props.add(&path_row);
let offline = adw::PreferencesGroup::builder().title("Offline").build();
let offline = adw::PreferencesGroup::builder()
.title(pgettext("state", "Offline"))
.build();
let pin_row = adw::SwitchRow::builder()
.title("Available offline")
.subtitle("Keep a copy on this computer, even without a connection.")
.title(gettext("Available offline"))
.subtitle(gettext(
"Keep a copy on this computer, even without a connection.",
))
.build();
offline.add(&pin_row);
let sharing = adw::PreferencesGroup::builder().title("Sharing").build();
let sharing = adw::PreferencesGroup::builder()
.title(gettext("Sharing"))
.build();
let sharing_row = adw::ActionRow::builder()
.title("Access")
.title(gettext("Access"))
.subtitle("—")
.build();
sharing_row.add_css_class("property");
sharing.add(&sharing_row);
let share_button = gtk4::Button::builder().label("Share…").build();
let copy_link_button = gtk4::Button::builder().label("Copy Link").build();
let share_button = gtk4::Button::builder().label(gettext("Share…")).build();
let copy_link_button = gtk4::Button::builder().label(gettext("Copy Link")).build();
let share_actions = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(6)
@@ -128,25 +136,31 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
share_actions.append(&copy_link_button);
sharing.add(&share_actions);
let versions_group = adw::PreferencesGroup::builder().title("Versions").build();
let versions_group = adw::PreferencesGroup::builder()
.title(gettext("Versions"))
.build();
let versions_row = adw::ActionRow::builder()
.title("History")
.title(gettext("History"))
.subtitle("—")
.build();
versions_row.add_css_class("property");
versions_group.add(&versions_row);
let versions_button = gtk4::Button::builder()
.label("Versions…")
.label(gettext("Versions…"))
.margin_top(6)
.build();
versions_group.add(&versions_button);
let open_button = gtk4::Button::builder().label("Open").build();
let open_button = gtk4::Button::builder()
.label(pgettext("verb", "Open"))
.build();
open_button.add_css_class("suggested-action");
open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build();
let rename_button = gtk4::Button::builder().label(gettext("Rename")).build();
rename_button.add_css_class("pill");
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
let trash_button = gtk4::Button::builder()
.label(gettext("Move to Trash"))
.build();
trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill");
@@ -178,7 +192,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
// itself would move the files under the pointer.
let empty_close = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Close details")
.tooltip_text(gettext("Close details"))
.halign(gtk4::Align::End)
.margin_top(12)
.margin_end(12)
@@ -188,9 +202,9 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle_off = toggle.clone();
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
let empty_status = adw::StatusPage::builder()
.icon_name("sidebar-show-right-symbolic")
.title("No item selected")
.description("Select a file or folder to see its details.")
.icon_name("pdfs-details-pane-symbolic")
.title(gettext("No item selected"))
.description(gettext("Select a file or folder to see its details."))
.vexpand(true)
.build();
empty_status.add_css_class("compact");
@@ -345,10 +359,13 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
ui.details.details_suppress.set(true);
let d = &ui.details.details;
d.icon.set_icon_name(Some(icon_base_for(entry)));
d.icon.set_icon_name(Some(icon_for(entry)));
d.name.set_label(&entry.name);
d.kind
.set_label(if entry.is_dir { "Folder" } else { "File" });
d.kind.set_label(&if entry.is_dir {
gettext("Folder")
} else {
gettext("File")
});
d.size_row.set_subtitle(&if entry.is_dir {
"—".to_string()
} else {
@@ -356,24 +373,27 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
});
d.size_row.set_visible(!entry.is_dir);
d.modified_row
.set_subtitle(&format_modified(entry.modified));
.set_subtitle(&dates::short_date(entry.modified));
let rel = entry_rel(ui, entry);
let parent = match rel.rfind('/') {
Some(i) => &rel[..i],
None => "",
};
d.path_row.set_subtitle(if parent.is_empty() {
"Proton Drive"
d.path_row.set_subtitle(&if parent.is_empty() {
gettext("Proton Drive")
} else {
parent
parent.to_string()
});
// Pinning is per file: the context menu and the selection bar offer it for
// files only, so the pane must not offer it for folders either.
d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir);
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
d.open_button.set_label(&if entry.is_dir {
gettext("Open folder")
} else {
pgettext("verb", "Open")
});
// Only files have revisions, and only a connected daemon can fetch them.
let mounted = *ui.mounted.borrow();
d.versions_group.set_visible(!entry.is_dir);
@@ -414,7 +434,7 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
Ok(Ok(Response::Share { entries, link })) => {
sharing_summary(entries.len(), link.is_some())
}
_ => "Couldn't load sharing".to_string(),
_ => gettext("Couldn't load sharing"),
};
if details_showing(&ui_c, &uid) {
ui_c.details.details.sharing_row.set_subtitle(&text);
@@ -436,7 +456,7 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
glib::spawn_future_local(async move {
let text = match rx.recv().await {
Ok(Ok(Response::Revisions { items })) => versions_summary(&items),
_ => "Couldn't load versions".to_string(),
_ => gettext("Couldn't load versions"),
};
if details_showing(&ui_c, &uid) {
ui_c.details.details.versions_row.set_subtitle(&text);
@@ -454,16 +474,17 @@ fn details_showing(ui: &Rc<Ui>, uid: &str) -> bool {
/// "Only you", "3 people", "Public link" or both.
pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
let people = match people {
0 => None,
1 => Some("1 person".to_string()),
n => Some(format!("{n} people")),
};
let n = people as u64;
match (people, link) {
(None, false) => "Only you".to_string(),
(None, true) => "Anyone with the link".to_string(),
(Some(p), false) => format!("Shared with {p}"),
(Some(p), true) => format!("Shared with {p} · Public link"),
(0, false) => gettext("Only you"),
(0, true) => gettext("Anyone with the link"),
(_, false) => ngettext_f("Shared with {n} person", "Shared with {n} people", n, &[]),
(_, true) => ngettext_f(
"Shared with {n} person · Public link",
"Shared with {n} people · Public link",
n,
&[],
),
}
}
@@ -471,9 +492,16 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
let latest = items.iter().map(|r| r.created).max();
match (items.len(), latest) {
(0, _) | (_, None) => "No earlier versions".to_string(),
(1, Some(t)) => format!("1 version · {}", format_modified(t)),
(n, Some(t)) => format!("{n} versions · latest {}", format_modified(t)),
(0, _) | (_, None) => gettext("No earlier versions"),
// Translators: {date} is the date of the only version.
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &dates::short_date(t))]),
// Translators: {date} is the date of the newest version.
(n, Some(t)) => ngettext_f(
"{n} version · latest {date}",
"{n} versions · latest {date}",
n as u64,
&[("date", &dates::short_date(t))],
),
}
}
@@ -0,0 +1,522 @@
//! The file list every file page shows: an icon grid and a column list over one
//! model and one selection, with thumbnails, context menus, rubberband selection
//! and click-to-deselect. My files, Trash, Shared with me and Shared by me build
//! one each and differ only in their columns, menus and what activating an entry
//! does (see [`FileListBehavior`]).
use crate::*;
/// Where a page's entries go when the user acts on them. Plain function pointers:
/// each page has one of these, fixed for the life of the window.
#[derive(Clone, Copy)]
pub(crate) struct FileListBehavior {
/// Double-click or Enter on an entry.
pub(crate) activate: fn(&Rc<Ui>, &DirEntry),
/// The right-click menu for one entry.
pub(crate) entry_menu: fn(&Rc<Ui>, &DirEntry) -> ActionMenu,
/// The right-click menu for a multi-selection the clicked entry is part of.
pub(crate) bulk_menu: fn(&Rc<Ui>, Vec<DirEntry>) -> ActionMenu,
/// The right-click menu on empty space.
pub(crate) background_menu: fn(&Rc<Ui>) -> ActionMenu,
/// Paint the offline/cached/online-only badge. Only entries in the mounted
/// tree have a local state worth showing.
pub(crate) badges: bool,
/// Entries can be dragged onto folders to move them.
pub(crate) drag_and_drop: bool,
}
/// A realised grid cell's thumbnail and name label.
type GridTile = (glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>);
/// One page's grid and list. Cheap to clone: every field is a GObject or an
/// `Rc`, so a clone is the same list.
#[derive(Clone)]
pub(crate) struct FileList {
/// The entries, as [`BoxedAnyObject`]-wrapped [`DirEntry`]s.
pub(crate) model: gio::ListStore,
/// One selection for both views: switching layout keeps what is selected.
pub(crate) selection: gtk4::MultiSelection,
pub(crate) grid: gtk4::GridView,
pub(crate) column_view: gtk4::ColumnView,
/// The two views, named "grid" and "list".
pub(crate) views: gtk4::Stack,
thumbnail_size: Rc<Cell<i32>>,
/// Weak references to realised grid cells. A new thumbnail size resizes only
/// these visible, recycled surfaces instead of invalidating the whole model.
tiles: Rc<RefCell<Vec<GridTile>>>,
behavior: Rc<Cell<Option<FileListBehavior>>>,
}
impl FileList {
/// Assemble the empty views. The factories need the [`Ui`] handle, so they
/// are installed later by [`FileList::wire`].
pub(crate) fn new() -> Self {
let model = gio::ListStore::new::<BoxedAnyObject>();
// Multi-select: acting on a batch is the common case, and doing it one
// confirmation dialog at a time is not a workflow. Rubberband drags a
// selection box from empty space, as a file manager does.
let selection = gtk4::MultiSelection::new(Some(model.clone()));
let grid = gtk4::GridView::builder()
.model(&selection)
.min_columns(2)
.max_columns(32)
.enable_rubberband(true)
.build();
grid.add_css_class("file-grid");
let grid_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&grid)
.build();
let column_view = gtk4::ColumnView::builder()
.model(&selection)
.enable_rubberband(true)
.build();
column_view.add_css_class("data-table");
let column_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&column_view)
.build();
let views = gtk4::Stack::new();
views.set_vexpand(true);
views.add_named(&grid_scroll, Some("grid"));
views.add_named(&column_scroll, Some("list"));
FileList {
model,
selection,
grid,
column_view,
views,
thumbnail_size: Rc::new(Cell::new(GRID_THUMB_DEFAULT)),
tiles: Rc::new(RefCell::new(Vec::new())),
behavior: Rc::new(Cell::new(None)),
}
}
/// Install the grid tiles, the Name column, activation, the context menus
/// and click-to-deselect. The page appends its other columns afterwards.
pub(crate) fn wire(&self, ui: &Rc<Ui>, behavior: FileListBehavior) {
self.behavior.set(Some(behavior));
for view in [
self.grid.clone().upcast::<gtk4::Widget>(),
self.column_view.clone().upcast(),
] {
self.attach_background_menu(ui, &view, behavior);
self.attach_background_deselect(&view);
}
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup({
let (ui, list) = (ui.clone(), self.clone());
move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let size = list.thumbnail_size.get();
let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
// Keep the sync-state badge inside the thumbnail surface.
let badge = gtk4::Image::builder()
.pixel_size(18)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Start)
.margin_top(2)
.margin_end(2)
.visible(false)
.build();
badge.add_css_class("file-badge");
thumbnail.add_overlay(&badge);
// `WordChar` rather than the default `Word`: a name with no
// spaces offers no word-break opportunity, so word wrapping
// cannot break it at all and the label asks for its full natural
// width instead — one tile stretches to the width of the window
// and the grid collapses to a single column. Allowing a mid-word
// break is what keeps the two-line-then-ellipsis budget below
// enforceable for *every* name rather than only the ones that
// happen to have spaces.
let label = gtk4::Label::builder()
.ellipsize(gtk4::pango::EllipsizeMode::End)
.justify(gtk4::Justification::Center)
.max_width_chars(13)
.width_chars(13)
.wrap(true)
.wrap_mode(gtk4::pango::WrapMode::WordChar)
.lines(2)
.build();
list.tiles
.borrow_mut()
.push((thumbnail.downgrade(), label.downgrade()));
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
tile.add_css_class("file-tile");
tile.append(&thumbnail);
tile.append(&label);
list.attach_context_menu(&ui, item, &tile, behavior);
if behavior.drag_and_drop {
attach_drag(&ui, item, &tile);
attach_drop(&ui, item, &tile);
}
item.set_child(Some(&tile));
}
});
factory.connect_bind({
let (ui, list) = (ui.clone(), self.clone());
move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let badge = thumbnail
.last_child()
.and_downcast::<gtk4::Image>()
.unwrap();
let label = thumbnail
.next_sibling()
.and_downcast::<gtk4::Label>()
.unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
resize_grid_tile(&thumbnail, &label, list.thumbnail_size.get());
bind_file_thumbnail(&ui, &thumbnail, &entry, false);
label.set_label(&entry.name);
label.set_tooltip_text(Some(&entry.name));
if behavior.badges {
apply_badge(&badge, &entry);
}
}
});
self.grid.set_factory(Some(&factory));
let ui_grid = ui.clone();
self.grid.connect_activate(move |grid, pos| {
if let Some(entry) = entry_at(grid.model().as_ref(), pos) {
(behavior.activate)(&ui_grid, &entry);
}
});
self.column_view
.append_column(&self.name_column(ui, behavior));
let ui_col = ui.clone();
self.column_view.connect_activate(move |view, pos| {
if let Some(entry) = entry_at(view.model().as_ref(), pos) {
(behavior.activate)(&ui_col, &entry);
}
});
}
/// Every highlighted entry, in model order.
///
/// Walks the model rather than the selection bitset: a listing is at most a
/// few thousand rows, and asking each position whether it is selected keeps
/// this free of bitset-iterator lifetimes for no measurable cost.
pub(crate) fn selected(&self) -> Vec<DirEntry> {
(0..self.selection.n_items())
.filter(|i| self.selection.is_selected(*i))
.filter_map(|i| entry_at(Some(&self.selection), i))
.collect()
}
/// Show the grid, or the list.
pub(crate) fn show_list(&self, list: bool) {
self.views
.set_visible_child_name(if list { "list" } else { "grid" });
}
/// Whether the list, rather than the grid, is on screen.
pub(crate) fn shows_list(&self) -> bool {
self.views.visible_child_name().as_deref() == Some("list")
}
/// Resize the grid's thumbnails. Only realised cells are touched; the rest
/// pick the size up when they are bound.
pub(crate) fn set_thumbnail_size(&self, size: i32) {
if self.thumbnail_size.replace(size) == size {
return;
}
self.tiles
.borrow_mut()
.retain(|(thumbnail_ref, label_ref)| {
let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade())
else {
return false;
};
resize_grid_tile(&thumbnail, &label, size);
true
});
self.grid.queue_resize();
}
/// The Menu key or Shift+F10: the menu for the selection, or the background
/// menu when nothing is selected, opened at the focused item.
pub(crate) fn popup_keyboard_menu(&self, ui: &Rc<Ui>) {
let Some(behavior) = self.behavior.get() else {
return;
};
let view = self
.views
.visible_child()
.unwrap_or_else(|| self.views.clone().upcast());
// The focused cell is where the eye is; without one, the middle of the view.
let (x, y) = view
.root()
.and_then(|root| root.focus())
.filter(|focus| focus.is_ancestor(&view))
.and_then(|focus| focus.compute_bounds(&view))
.map(|b| {
(
(b.x() + b.width() / 2.0) as f64,
(b.y() + b.height() / 2.0) as f64,
)
})
.unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0));
let selected = self.selected();
let menu = match selected.len() {
0 => (behavior.background_menu)(ui),
1 => (behavior.entry_menu)(ui, &selected[0]),
_ => (behavior.bulk_menu)(ui, selected),
};
menu.popup_at(&view, x, y);
}
/// The Name column: a small thumbnail with its local-state badge overlaid,
/// followed by the name and the same right-click menu the grid tiles carry.
fn name_column(&self, ui: &Rc<Ui>, behavior: FileListBehavior) -> gtk4::ColumnViewColumn {
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup({
let (ui, list) = (ui.clone(), self.clone());
move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let thumbnail = file_thumbnail_widget(28, 16);
let badge = gtk4::Image::builder()
.pixel_size(14)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Start)
.visible(false)
.build();
badge.add_css_class("file-badge");
thumbnail.add_overlay(&badge);
// Ellipsized so the Name column can be *narrower* than its
// longest name. Without it the label's minimum width is the whole
// string, the column inherits that minimum, and one long name
// pushes the other columns off the right edge of the window for
// every row.
let label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
cell.append(&thumbnail);
cell.append(&label);
list.attach_context_menu(&ui, item, &cell, behavior);
if behavior.drag_and_drop {
attach_drag(&ui, item, &cell);
attach_drop(&ui, item, &cell);
}
item.set_child(Some(&cell));
}
});
factory.connect_bind({
let ui = ui.clone();
move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let badge = thumbnail
.last_child()
.and_downcast::<gtk4::Image>()
.unwrap();
let label = thumbnail
.next_sibling()
.and_downcast::<gtk4::Label>()
.unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
label.set_label(&entry.name);
label.set_tooltip_text(Some(&entry.name));
if behavior.badges {
apply_badge(&badge, &entry);
}
}
});
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory));
column.set_expand(true);
column
}
/// Pop the menu for whatever entry `item` is bound to when a cell is
/// right-clicked. Capturing the [`gtk4::ListItem`] rather than a snapshot of
/// the entry keeps the menu right as the view recycles cells while
/// scrolling. The click is claimed, so the background menu does not open on
/// top of it.
fn attach_context_menu(
&self,
ui: &Rc<Ui>,
item: &gtk4::ListItem,
anchor: &gtk4::Box,
behavior: FileListBehavior,
) {
let gesture = gtk4::GestureClick::new();
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
let (ui, list, item, target) = (ui.clone(), self.clone(), item.clone(), anchor.clone());
gesture.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk4::EventSequenceState::Claimed);
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
let entry = obj.borrow::<DirEntry>().clone();
// Right-clicking a row that is part of a multi-selection acts on
// the batch; right-clicking outside one acts on the row.
let selected = list.selected();
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
(behavior.bulk_menu)(&ui, selected).popup_at(&target, x, y);
} else {
(behavior.entry_menu)(&ui, &entry).popup_at(&target, x, y);
}
}
});
anchor.add_controller(gesture);
}
/// Right-clicking the empty space of a view offers the page's own actions.
fn attach_background_menu(&self, ui: &Rc<Ui>, view: &gtk4::Widget, behavior: FileListBehavior) {
let gesture = gtk4::GestureClick::new();
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
let (ui, target) = (ui.clone(), view.clone());
gesture.connect_pressed(move |_, _, x, y| {
(behavior.background_menu)(&ui).popup_at(&target, x, y);
});
view.add_controller(gesture);
}
/// Clicking the empty space of a view drops the selection, as in a file
/// manager. GTK's list views leave it alone, so a stray highlight otherwise
/// sticks around until another item is clicked.
fn attach_background_deselect(&self, view: &gtk4::Widget) {
let gesture = gtk4::GestureClick::new();
gesture.set_button(gtk4::gdk::BUTTON_PRIMARY);
// Capture sees the press before an item's own gesture claims it; this
// only looks, so the item still gets its click.
gesture.set_propagation_phase(gtk4::PropagationPhase::Capture);
let (selection, target) = (self.selection.clone(), view.clone());
gesture.connect_pressed(move |gesture, _, x, y| {
let modifiers = gesture.current_event_state();
if modifiers.intersects(
gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK,
) {
return;
}
if !on_item(&target, x, y) {
selection.unselect_all();
}
});
view.add_controller(gesture);
}
}
/// A header button that switches `list` between grid and list. It offers the
/// other layout, the way a toggle reads.
pub(crate) fn layout_button(list: &FileList) -> gtk4::Button {
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
let sync = |button: &gtk4::Button, list: bool| {
button.set_icon_name(if list {
"view-grid-symbolic"
} else {
"view-list-symbolic"
});
button.set_tooltip_text(Some(&if list {
gettext("Show as grid (Ctrl+1)")
} else {
gettext("Show as list (Ctrl+2)")
}));
};
sync(&button, list.shows_list());
let weak = button.downgrade();
list.views.connect_visible_child_name_notify(move |views| {
if let Some(button) = weak.upgrade() {
sync(
&button,
views.visible_child_name().as_deref() == Some("list"),
);
}
});
let views = list.views.clone();
button.connect_clicked(move |_| {
let to_list = views.visible_child_name().as_deref() != Some("list");
views.set_visible_child_name(if to_list { "list" } else { "grid" });
});
button
}
/// Build a trailing text column whose cell text is derived from each [`DirEntry`]
/// by `render`.
pub(crate) fn text_column(
title: &str,
render: impl Fn(&DirEntry) -> String + 'static,
) -> gtk4::ColumnViewColumn {
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
label.add_css_class("dim-label");
item.set_child(Some(&label));
});
factory.connect_bind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let label = item.child().and_downcast::<gtk4::Label>().unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
let text = render(&entry);
label.set_tooltip_text(Some(&text));
label.set_label(&text);
});
gtk4::ColumnViewColumn::new(Some(title), Some(factory))
}
fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::Label, size: i32) {
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
let name_width = (size / 6 + 1).clamp(8, 24);
label.set_width_chars(name_width);
label.set_max_width_chars(name_width);
}
fn grid_fallback_size(thumbnail_size: i32) -> i32 {
(thumbnail_size * 8 / 9).clamp(24, thumbnail_size)
}
/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list
/// row ("row"), or a column header ("header"), which has clicks of its own.
fn on_item(view: &gtk4::Widget, x: f64, y: f64) -> bool {
let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT);
while let Some(w) = widget {
if &w == view {
return false;
}
if matches!(w.css_name().as_str(), "child" | "row" | "header") {
return true;
}
widget = w.parent();
}
false
}
/// Every page's file list.
pub(crate) fn file_lists(ui: &Ui) -> [&FileList; 4] {
[
&ui.browser.files,
&ui.trash.files,
&ui.shared.files,
&ui.shared_by_me.files,
]
}
/// The file list of the page on screen, if it has one.
pub(crate) fn visible_file_list(ui: &Ui) -> Option<&FileList> {
match ui.stack.visible_child_name()?.as_str() {
"browser" => Some(&ui.browser.files),
"trash" => Some(&ui.trash.files),
"sharedbyme" => Some(&ui.shared_by_me.files),
"shared" if ui.shared.views.visible_child_name().as_deref() == Some("files") => {
Some(&ui.shared.files)
}
_ => None,
}
}
+2 -13
View File
@@ -1,8 +1,5 @@
use crate::*;
/// An action run by an [`ActionMenu`] item.
pub(crate) type MenuAction = Box<dyn Fn()>;
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
/// what they were opened for (a row, a selection, a photo). Each item gets its
/// own action in a private `menu` group, installed on whatever hosts the
@@ -120,7 +117,8 @@ impl ActionMenu {
let (menu, group) = self.finish();
let button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic")
.tooltip_text("More")
// Translators: tooltip of a button that opens a menu of further actions.
.tooltip_text(gettext("More"))
.valign(gtk4::Align::Center)
.menu_model(&menu)
.build();
@@ -147,12 +145,3 @@ fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
}
host
}
/// A flat ⋮ button whose menu lists `items` as (label, action).
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
let mut menu = ActionMenu::new();
for (label, run) in items {
menu.item(label, run);
}
menu.button()
}
+3
View File
@@ -1,5 +1,8 @@
pub(crate) mod compat;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
pub(crate) mod scrubber;
pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
pub(crate) mod versions_dialog;
+403
View File
@@ -0,0 +1,403 @@
//! The Photos timeline's month scrubber, laid over the grid's right edge as in
//! Google Photos: a thin tick where each year starts, a knob at the month on
//! screen, and a bubble naming the month under the pointer. It stays out of
//! the way until the pointer nears the edge or the timeline scrolls.
use crate::*;
use gtk4::cairo;
/// Width of the strip the scrubber draws in, in px.
const STRIP_WIDTH: i32 = 72;
/// How close to the right edge the pointer has to come to show the scrubber.
const EDGE: f64 = 96.0;
/// Space kept free above the first and below the last month, in px.
const PAD: f64 = 16.0;
/// How long the scrubber stays up after the timeline stops scrolling.
const LINGER: Duration = Duration::from_millis(1500);
#[derive(Default)]
struct State {
/// How many months the strip spans, newest at the top.
months: usize,
/// Where each year starts, as `(month index, label)`.
years: Vec<(usize, String)>,
/// The month the knob marks.
value: usize,
/// Whether the timeline has months to move between at all.
available: bool,
/// The pointer is near the right edge.
near: bool,
/// The month under the pointer while it is on the strip.
hover: Option<usize>,
/// Where the running drag started, in the strip's coordinates.
drag_from: Option<f64>,
/// Keeps the scrubber up for a moment after a scroll.
linger: Option<glib::SourceId>,
label: Option<Rc<dyn Fn(usize) -> String>>,
on_pick: Option<Rc<dyn Fn(usize)>>,
}
/// The scrubber's widgets and state. Cheap to clone: every clone is the same
/// scrubber.
#[derive(Clone)]
pub(crate) struct Scrubber {
revealer: gtk4::Revealer,
area: gtk4::DrawingArea,
knob: gtk4::Box,
bubble: gtk4::Label,
/// The timeline the scrubber lies over.
scroll: Option<gtk4::ScrolledWindow>,
state: Rc<RefCell<State>>,
}
impl Scrubber {
/// Build the scrubber over `host`, whose child is the scrolled timeline.
pub(crate) fn new(host: &gtk4::Overlay) -> Self {
let scroll = host.child().and_downcast::<gtk4::ScrolledWindow>();
let area = gtk4::DrawingArea::builder()
.width_request(STRIP_WIDTH)
.vexpand(true)
.build();
area.add_css_class("photo-scrubber");
let knob = gtk4::Box::builder()
.width_request(24)
.height_request(4)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Start)
.margin_end(4)
.can_target(false)
.build();
knob.add_css_class("photo-scrubber-knob");
let strip = gtk4::Overlay::new();
strip.set_child(Some(&area));
strip.add_overlay(&knob);
let revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::Crossfade)
.transition_duration(150)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Fill)
.can_target(false)
.child(&strip)
.build();
let bubble = gtk4::Label::builder()
.halign(gtk4::Align::End)
.valign(gtk4::Align::Start)
.margin_end(STRIP_WIDTH)
.can_target(false)
.visible(false)
.build();
bubble.add_css_class("photo-scrubber-bubble");
host.add_overlay(&revealer);
host.add_overlay(&bubble);
let scrubber = Self {
revealer,
area,
knob,
bubble,
scroll,
state: Rc::default(),
};
scrubber.wire(host);
scrubber
}
fn wire(&self, host: &gtk4::Overlay) {
let this = self.clone();
self.area.set_draw_func(move |area, cr, width, height| {
this.draw(area, cr, width, height);
});
let this = self.clone();
self.area.connect_resize(move |_, _, _| this.place_knob());
// Near the edge is judged on the whole timeline, not the strip: the
// strip takes no input while it is hidden.
let edge = gtk4::EventControllerMotion::new();
let this = self.clone();
let host_motion = host.clone();
edge.connect_motion(move |_, x, _| {
let near = x >= f64::from(host_motion.width()) - EDGE;
if this.state.borrow().near != near {
this.state.borrow_mut().near = near;
this.sync_reveal();
}
});
let this = self.clone();
edge.connect_leave(move |_| {
this.state.borrow_mut().near = false;
this.sync_reveal();
});
host.add_controller(edge);
let hover = gtk4::EventControllerMotion::new();
let this = self.clone();
hover.connect_motion(move |_, _, y| {
let index = this.index_at(y);
this.state.borrow_mut().hover = index;
this.sync_bubble();
});
let this = self.clone();
hover.connect_leave(move |_| {
this.state.borrow_mut().hover = None;
this.sync_bubble();
});
self.area.add_controller(hover);
// A click is a drag that goes nowhere, so this covers both.
let drag = gtk4::GestureDrag::new();
let this = self.clone();
drag.connect_drag_begin(move |_, _, y| {
this.state.borrow_mut().drag_from = Some(y);
this.pick(y, true);
});
let this = self.clone();
drag.connect_drag_update(move |_, _, dy| {
let from = this.state.borrow().drag_from;
if let Some(from) = from {
this.pick(from + dy, false);
}
});
let this = self.clone();
drag.connect_drag_end(move |_, _, _| {
this.state.borrow_mut().drag_from = None;
this.sync_reveal();
this.sync_bubble();
});
self.area.add_controller(drag);
}
/// Span `months` months, newest first, with a tick and a label where each
/// of `years` starts. Moves the knob back to the top.
pub(crate) fn set_months(&self, months: usize, years: Vec<(usize, String)>) {
{
let mut state = self.state.borrow_mut();
state.months = months;
state.years = years;
state.value = 0;
}
self.area.queue_draw();
self.place_knob();
}
/// Whether there is anything to scrub through. The scrubber never shows
/// while there is not.
pub(crate) fn set_available(&self, available: bool) {
self.state.borrow_mut().available = available;
// The scrubber takes the scrollbar's place at the edge; both at once
// would fight over the same strip.
if let Some(scroll) = &self.scroll {
scroll.set_vscrollbar_policy(if available {
gtk4::PolicyType::External
} else {
gtk4::PolicyType::Automatic
});
}
self.sync_reveal();
}
pub(crate) fn is_available(&self) -> bool {
self.state.borrow().available
}
/// Mark month `index`, unless the user has the scrubber in hand: the
/// timeline's scroll position must not move the knob out from under them.
pub(crate) fn set_value(&self, index: usize) {
{
let mut state = self.state.borrow_mut();
if state.drag_from.is_some() || state.hover.is_some() || state.value == index {
return;
}
state.value = index;
}
self.place_knob();
}
/// How a month index reads in the bubble, such as "March 2024".
pub(crate) fn set_label_func(&self, label: impl Fn(usize) -> String + 'static) {
self.state.borrow_mut().label = Some(Rc::new(label));
}
/// Call `f` with the month the user points the scrubber at.
pub(crate) fn connect_pick(&self, f: impl Fn(usize) + 'static) {
self.state.borrow_mut().on_pick = Some(Rc::new(f));
}
/// Show the scrubber for a moment, as the timeline scrolls, so it can be
/// found without hunting for the edge — and on a touch screen at all.
pub(crate) fn flash(&self) {
if !self.is_available() {
return;
}
if let Some(source) = self.state.borrow_mut().linger.take() {
source.remove();
}
let this = self.clone();
let source = glib::timeout_add_local_once(LINGER, move || {
this.state.borrow_mut().linger = None;
this.sync_reveal();
});
self.state.borrow_mut().linger = Some(source);
self.sync_reveal();
}
fn sync_reveal(&self) {
let show = {
let state = self.state.borrow();
state.available && (state.near || state.drag_from.is_some() || state.linger.is_some())
};
self.revealer.set_reveal_child(show);
// A hidden revealer still claims its strip of the grid; let the clicks
// through to the tiles underneath.
self.revealer.set_can_target(show);
if !show {
self.state.borrow_mut().hover = None;
self.bubble.set_visible(false);
}
}
/// The bubble names the month being dragged to, or else the one under
/// the pointer.
fn sync_bubble(&self) {
let (index, label) = {
let state = self.state.borrow();
let index = if state.drag_from.is_some() {
Some(state.value)
} else {
state.hover
};
(index, state.label.clone())
};
let (Some(index), Some(label)) = (index, label) else {
self.bubble.set_visible(false);
return;
};
self.bubble.set_label(&label(index));
let (_, height, _, _) = self.bubble.measure(gtk4::Orientation::Vertical, -1);
let y = self.y_of(index) - f64::from(height) / 2.0;
self.bubble.set_margin_top(y.max(0.0) as i32);
self.bubble.set_visible(true);
}
/// Move the knob to month `y` points at, and tell the page when that is a
/// different month — or when a new drag starts, so a click jumps even to
/// the month already marked.
fn pick(&self, y: f64, starting: bool) {
let Some(index) = self.index_at(y) else {
return;
};
let (changed, on_pick) = {
let mut state = self.state.borrow_mut();
let changed = state.value != index;
state.value = index;
(changed, state.on_pick.clone())
};
self.place_knob();
self.sync_bubble();
if let (true, Some(on_pick)) = (changed || starting, on_pick) {
on_pick(index);
}
}
fn place_knob(&self) {
let value = self.state.borrow().value;
let y = self.y_of(value) - 2.0;
self.knob.set_margin_top(y.max(0.0) as i32);
}
/// Where month `index` sits on the strip.
fn y_of(&self, index: usize) -> f64 {
let months = self.state.borrow().months;
month_y(index, months, f64::from(self.area.height()))
}
/// The month at height `y` on the strip, if it spans any.
fn index_at(&self, y: f64) -> Option<usize> {
let months = self.state.borrow().months;
month_at(y, months, f64::from(self.area.height()))
}
fn draw(&self, area: &gtk4::DrawingArea, cr: &cairo::Context, width: i32, height: i32) {
let state = self.state.borrow();
let color = area.color();
let (width, height) = (f64::from(width), f64::from(height));
let attrs = gtk4::pango::AttrList::new();
attrs.insert(gtk4::pango::AttrFloat::new_scale(0.8));
attrs.insert(gtk4::pango::AttrInt::new_weight(gtk4::pango::Weight::Bold));
// The bottom of the last label drawn: a year crowded against the one
// above it keeps its tick but not its label.
let mut clear_below = f64::NEG_INFINITY;
for (index, year) in &state.years {
let y = month_y(*index, state.months, height);
cr.set_source_rgba(
f64::from(color.red()),
f64::from(color.green()),
f64::from(color.blue()),
0.5,
);
cr.rectangle(width - 12.0, y.round() - 0.5, 8.0, 1.0);
let _ = cr.fill();
let layout = area.create_pango_layout(Some(year));
layout.set_attributes(Some(&attrs));
let (_, logical) = layout.pixel_extents();
let (w, h) = (f64::from(logical.width()), f64::from(logical.height()));
let top = y - h / 2.0;
if top < clear_below {
continue;
}
clear_below = top + h + 2.0;
cr.set_source_rgba(
f64::from(color.red()),
f64::from(color.green()),
f64::from(color.blue()),
0.8,
);
cr.move_to(width - 16.0 - w, top);
pangocairo::functions::show_layout(cr, &layout);
}
}
}
/// Where month `index` of `months` sits on a strip `height` px tall: evenly
/// spaced, newest at the top.
fn month_y(index: usize, months: usize, height: f64) -> f64 {
let span = (height - 2.0 * PAD).max(0.0);
if months < 2 {
return PAD;
}
PAD + span * index as f64 / (months - 1) as f64
}
/// The month nearest height `y` on a strip `height` px tall.
fn month_at(y: f64, months: usize, height: f64) -> Option<usize> {
let last = months.checked_sub(1)?;
let span = height - 2.0 * PAD;
if span <= 0.0 {
return Some(0);
}
let fraction = ((y - PAD) / span).clamp(0.0, 1.0);
Some((fraction * last as f64).round() as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_scrubber_maps_heights_back_to_their_months() {
let height = 432.0;
for index in 0..12 {
assert_eq!(
month_at(month_y(index, 12, height), 12, height),
Some(index)
);
}
}
#[test]
fn the_scrubber_clamps_past_either_end() {
assert_eq!(month_at(-50.0, 12, 432.0), Some(0));
assert_eq!(month_at(900.0, 12, 432.0), Some(11));
assert_eq!(month_at(10.0, 0, 432.0), None);
}
}
+131 -70
View File
@@ -1,7 +1,19 @@
use crate::*;
/// The roles offered in the Share dialog's dropdowns, in index order.
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"];
/// The role labels offered in the Share dialog's dropdowns, in index order.
pub(crate) fn share_roles() -> [String; 3] {
[
pgettext("role", "Viewer"),
pgettext("role", "Editor"),
pgettext("role", "Admin"),
]
}
/// A dropdown model from translated labels.
fn string_list(labels: &[String]) -> gtk4::StringList {
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
gtk4::StringList::new(&labels)
}
/// Map a role dropdown index to the wire role string.
pub(crate) fn role_index_to_wire(idx: u32) -> &'static str {
@@ -24,10 +36,11 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
/// The expiry choices offered when creating a public link, in dropdown order,
/// with their lifetime in days (`None` never expires).
pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [
("Never", None),
("1 day", Some(1)),
("7 days", Some(7)),
("30 days", Some(30)),
// Translators: a public link that never expires.
(gettext_noop("Never"), None),
(gettext_noop("1 day"), Some(1)),
(gettext_noop("7 days"), Some(7)),
(gettext_noop("30 days"), Some(30)),
];
/// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`.
@@ -84,7 +97,7 @@ impl Busy {
pub(crate) fn start(button: &gtk4::Button) -> Self {
let label = button.label();
let icon = button.icon_name();
let spinner = gtk4::Spinner::builder().spinning(true).build();
let spinner = spinner();
match &label {
Some(text) => {
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
@@ -235,7 +248,11 @@ pub(crate) struct ShareDialog {
/// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() {
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
toast_error(
ui,
&gettext("Can't share"),
&gettext("Proton Drive isn't connected."),
);
return;
}
// A node with no path is not a bug here: Shared and Shared-with-me list
@@ -248,26 +265,35 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name)));
header.set_title_widget(Some(&adw::WindowTitle::new(
&pgettext("verb", "Share"),
&entry.name,
)));
toolbar.add_top_bar(&header);
// Invite section: emails + role + optional message.
let invite_group = adw::PreferencesGroup::builder()
.title("Invite people")
.description("Proton and non-Proton email addresses, separated by spaces or commas.")
.title(gettext("Invite people"))
.description(gettext(
"Proton and non-Proton email addresses, separated by spaces or commas.",
))
.build();
let email_row = adw::EntryRow::builder().title("Email addresses").build();
let role_model = gtk4::StringList::new(&SHARE_ROLES);
let email_row = adw::EntryRow::builder()
.title(gettext("Email addresses"))
.build();
let role_model = string_list(&share_roles());
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_wrap = adw::ActionRow::builder().title("Role").build();
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
role_wrap.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder().title("Message (optional)").build();
let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)"))
.build();
let invite_btn = gtk4::Button::builder()
.label("Send Invitations")
.label(gettext("Send Invitations"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
@@ -283,10 +309,10 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
invite_group.add(&invite_wrap);
let people = adw::PreferencesGroup::builder()
.title("People with access")
.title(gettext("People with access"))
.build();
let link_group = adw::PreferencesGroup::builder()
.title("Public link")
.title(gettext("Public link"))
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
@@ -306,7 +332,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder()
.title("Share")
.title(pgettext("verb", "Share"))
.content_width(480)
.content_height(560)
.child(&toolbar)
@@ -330,7 +356,11 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
match &parsed {
Err(bad) => {
row.add_css_class("error");
row.set_tooltip_text(Some(&format!("“{bad}” isn't an email address")));
// Translators: {text} is what the user typed in place of an email address.
row.set_tooltip_text(Some(&gettext_f(
"“{text}” isn't an email address",
&[("text", bad)],
)));
}
Ok(_) => {
row.remove_css_class("error");
@@ -367,8 +397,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
share_dialog_op(
&state_inv,
state_inv.target.invite(emails, role, message),
"Invitations sent",
"Couldn't send invitations",
&gettext("Invitations sent"),
&gettext("Couldn't send invitations"),
Some(Busy::start(btn)),
Some(Box::new(move || {
email_clear.set_text("");
@@ -377,6 +407,14 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
);
});
// Shared by me lists what this dialog changes: reload it when it is the
// page underneath. Any other page reads it again on the next visit.
let ui_closed = ui.clone();
dialog.connect_closed(move |_| {
if ui_closed.stack.visible_child_name().as_deref() == Some("sharedbyme") {
load_shared_by_me(&ui_closed);
}
});
share_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
}
@@ -392,12 +430,12 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
repaint_share_link(&state, link.as_ref());
}
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load sharing", &message)
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
}
_ => toast_error(
&state.ui,
"Couldn't load sharing",
"The mount service didn't respond.",
&gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -410,7 +448,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
if entries.is_empty() {
let row = dim_row("No one else has access yet.");
let row = dim_row(&gettext("No one else has access yet."));
state.people.add(&row);
rows.push(row.upcast());
*state.people_rows.borrow_mut() = rows;
@@ -418,9 +456,9 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
}
for entry in entries {
let subtitle = match entry.kind {
ShareEntryKind::Member => "Member".to_string(),
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(),
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(),
ShareEntryKind::Member => gettext("Member"),
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
};
let row = adw::ActionRow::builder()
.title(&entry.email)
@@ -433,7 +471,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
entry.kind,
ShareEntryKind::Member | ShareEntryKind::ProtonInvite
) {
let model = gtk4::StringList::new(&SHARE_ROLES);
let model = string_list(&share_roles());
let drop = gtk4::DropDown::builder()
.model(&model)
.selected(role_wire_to_index(&entry.role))
@@ -450,8 +488,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
kind,
role_index_to_wire(d.selected()).to_string(),
),
"Role updated",
"Couldn't update the role",
&gettext("Role updated"),
&gettext("Couldn't update the role"),
None,
None,
);
@@ -459,7 +497,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
row.add_suffix(&drop);
} else {
let label = gtk4::Label::builder()
.label(capitalize(&entry.role))
.label(role_label(&entry.role).unwrap_or_else(|| capitalize(&entry.role)))
.valign(gtk4::Align::Center)
.build();
label.add_css_class("dim-label");
@@ -468,7 +506,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove access")
.tooltip_text(gettext("Remove access"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
@@ -482,15 +520,19 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let button = btn.clone();
confirm_destructive(
btn,
"Remove Access?",
&format!("{who} will no longer be able to open this item."),
"Remove",
&gettext("Remove Access?"),
// Translators: {email} is the address of the person losing access.
&gettext_f(
"{email} will no longer be able to open this item.",
&[("email", &who)],
),
&gettext("Remove"),
move || {
share_dialog_op(
&state,
state.target.remove_entry(id.clone(), kind),
"Access removed",
"Couldn't remove access",
&gettext("Access removed"),
&gettext("Couldn't remove access"),
Some(Busy::start(&button)),
None,
);
@@ -516,16 +558,23 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
match link {
Some(link) => {
let url = link.url.clone().unwrap_or_default();
let mut subtitle = format!("Anyone with the link ({})", capitalize(&link.role));
let role = role_label(&link.role).unwrap_or_else(|| capitalize(&link.role));
// Translators: {role} is the link's role, such as "Viewer".
let mut subtitle = gettext_f("Anyone with the link ({role})", &[("role", &role)]);
if link.has_password {
subtitle.push_str(" · password-protected");
subtitle.push_str(" · ");
subtitle.push_str(&gettext("password-protected"));
}
if let Some(expires) = link.expires {
subtitle.push_str(" · ");
subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000));
}
let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url })
.title(if url.is_empty() {
gettext("Public link")
} else {
url.clone()
})
.subtitle(&subtitle)
.build();
row.add_css_class("property");
@@ -533,7 +582,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
if !url.is_empty() {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text("Copy link")
.tooltip_text(gettext("Copy link"))
.valign(gtk4::Align::Center)
.build();
copy.add_css_class("flat");
@@ -541,14 +590,15 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let url_copy = url.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy);
toast(&state_copy.ui, "Link copied");
flash_copied(btn, "edit-copy-symbolic");
toast(&state_copy.ui, &gettext("Link copied"));
});
row.add_suffix(&copy);
}
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove link")
.tooltip_text(gettext("Remove link"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
@@ -560,16 +610,15 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let button = btn.clone();
confirm_destructive(
btn,
"Remove Public Link?",
"The link stops working for everyone who has it. A new link will have \
a different address.",
"Remove Link",
&gettext("Remove Public Link?"),
&gettext("The link stops working for everyone who has it. A new link will have a different address."),
&gettext("Remove Link"),
move || {
share_dialog_op(
&state,
state.target.remove_link(id.clone()),
"Public link removed",
"Couldn't remove the link",
&gettext("Public link removed"),
&gettext("Couldn't remove the link"),
Some(Busy::start(&button)),
None,
);
@@ -582,27 +631,30 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
rows.push(row.upcast());
}
None => {
let role_model = gtk4::StringList::new(&["Viewer", "Editor"]);
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_row = adw::ActionRow::builder().title("Link role").build();
let role_row = adw::ActionRow::builder()
.title(gettext("Link role"))
.build();
role_row.add_suffix(&role_drop);
let pw_row = adw::PasswordEntryRow::builder()
.title("Password (optional)")
.title(gettext("Password (optional)"))
.build();
let expiry_names: Vec<&str> = LINK_EXPIRY.iter().map(|(name, _)| *name).collect();
let expiry_names: Vec<String> =
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
let expiry_drop = gtk4::DropDown::builder()
.model(&gtk4::StringList::new(&expiry_names))
.model(&string_list(&expiry_names))
.selected(0)
.valign(gtk4::Align::Center)
.build();
let expiry_row = adw::ActionRow::builder().title("Expires").build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder()
.label("Create Public Link")
.label(gettext("Create Public Link"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
@@ -666,16 +718,16 @@ pub(crate) fn share_dialog_create_link(
drop(busy);
match reply {
Ok(Ok(Response::PublicLink { .. })) => {
toast(&state.ui, "Public link created");
toast(&state.ui, &gettext("Public link created"));
share_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't create the link", &message)
toast_error(&state.ui, &gettext("Couldn't create the link"), &message)
}
_ => toast_error(
&state.ui,
"Couldn't create the link",
"The mount service didn't respond.",
&gettext("Couldn't create the link"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -687,13 +739,14 @@ pub(crate) fn share_dialog_create_link(
pub(crate) fn share_dialog_op(
state: &Rc<ShareDialog>,
req: Request,
done: &'static str,
failed: &'static str,
done: &str,
failed: &str,
busy: Option<Busy>,
on_success: Option<Box<dyn Fn()>>,
) {
let rx = spawn_request(state.ui.dirs.control_socket(), req);
let state = state.clone();
let (done, failed) = (done.to_string(), failed.to_string());
glib::spawn_future_local(async move {
let reply = rx.recv().await;
drop(busy);
@@ -702,14 +755,18 @@ pub(crate) fn share_dialog_op(
if let Some(cb) = on_success {
cb();
}
toast(&state.ui, done);
toast(&state.ui, &done);
share_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message),
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
_ => {
// A role dropdown that failed is now out of sync with the server;
// reload to snap it back.
toast_error(&state.ui, failed, "The mount service didn't respond.");
toast_error(
&state.ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
);
share_dialog_reload(&state);
}
}
@@ -720,14 +777,18 @@ pub(crate) fn share_dialog_op(
/// working, or that it already has.
pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String {
if expires <= now {
return "expired".to_string();
// Translators: a public link past its expiry date.
return gettext("expired");
}
// Translators: strftime format for a link's expiry date; "%x" is the locale's date, such as "09/23/26".
let format = gettext("%x");
let date = glib::DateTime::from_unix_local(expires)
.ok()
.and_then(|at| at.format("%x").ok())
.and_then(|at| at.format(&format).ok())
.map(|s| s.to_string())
.unwrap_or_default();
format!("expires {date}")
// Translators: {date} is the date a public link stops working.
gettext_f("expires {date}", &[("date", &date)])
}
#[cfg(test)]
+1 -14
View File
@@ -173,7 +173,7 @@ pub(crate) fn bind_file_thumbnail(
let fallback_name = if symbolic_fallback {
format!("{}-symbolic", icon_base_for(entry))
} else {
icon_base_for(entry).to_string()
icon_for(entry).to_string()
};
fallback.set_icon_name(Some(&fallback_name));
fallback.set_visible(true);
@@ -214,19 +214,6 @@ pub(crate) fn bind_file_thumbnail(
schedule_file_thumbs(ui);
}
/// Create and immediately bind a thumbnail for a non-recycled row.
pub(crate) fn file_thumbnail(
ui: &Rc<Ui>,
entry: &DirEntry,
size: i32,
fallback_size: i32,
symbolic_fallback: bool,
) -> gtk4::Overlay {
let widget = file_thumbnail_widget(size, fallback_size);
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
widget
}
/// Whether the file name denotes an image format for which Proton or the local
/// decoder can reasonably provide a thumbnail.
pub(crate) fn is_image_name(name: &str) -> bool {
@@ -85,11 +85,19 @@ pub(crate) struct VersionsDialog {
/// Open the Versions dialog for a file.
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() {
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
toast_error(
ui,
&gettext("Can't load versions"),
&gettext("Proton Drive isn't connected."),
);
return;
}
if entry.is_dir {
toast_error(ui, "No versions", "Folders don't have versions.");
toast_error(
ui,
&gettext("No versions"),
&gettext("Folders don't have versions."),
);
return;
}
let target = if entry.path.is_empty() {
@@ -100,12 +108,17 @@ pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
header.set_title_widget(Some(&adw::WindowTitle::new(
&gettext("Versions"),
&entry.name,
)));
toolbar.add_top_bar(&header);
let group = adw::PreferencesGroup::builder()
.title("Version history")
.description("Proton Drive keeps earlier versions of a file until you delete them.")
.title(gettext("Version history"))
.description(gettext(
"Proton Drive keeps earlier versions of a file until you delete them.",
))
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
@@ -123,7 +136,7 @@ pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder()
.title("Versions")
.title(gettext("Versions"))
.content_width(520)
.content_height(520)
.child(&toolbar)
@@ -149,12 +162,12 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
match rx.recv().await {
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load versions", &message)
toast_error(&state.ui, &gettext("Couldn't load versions"), &message)
}
_ => toast_error(
&state.ui,
"Couldn't load versions",
"The mount service didn't respond.",
&gettext("Couldn't load versions"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -167,7 +180,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
if items.is_empty() {
let row = dim_row("No earlier versions.");
let row = dim_row(&gettext("No earlier versions."));
state.group.add(&row);
rows.push(row.upcast());
*state.rows.borrow_mut() = rows;
@@ -186,11 +199,18 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
// when they were saved instead.
let (title, mut subtitle) = if item.is_active {
(
"Current version".to_string(),
format!("{} · {}", activity_time(item.created), human_bytes(size)),
gettext("Current version"),
// Translators: {time} is when the version was saved, {size} its file size.
gettext_f(
"{time} · {size}",
&[
("time", &dates::relative(item.created)),
("size", &human_bytes(size)),
],
),
)
} else {
(activity_time(item.created), human_bytes(size))
(dates::relative(item.created), human_bytes(size))
};
if let Some(email) = &item.signed_by {
subtitle.push_str(" · ");
@@ -203,7 +223,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
let save = gtk4::Button::builder()
.icon_name("document-save-symbolic")
.tooltip_text("Save a copy…")
.tooltip_text(gettext("Save a copy…"))
.valign(gtk4::Align::Center)
.build();
save.add_css_class("flat");
@@ -215,7 +235,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
if !item.is_active {
let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Restore this version")
.tooltip_text(gettext("Restore this version"))
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
@@ -226,7 +246,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
let delete = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Delete this version permanently")
.tooltip_text(gettext("Delete this version permanently"))
.valign(gtk4::Align::Center)
.build();
delete.add_css_class("flat");
@@ -246,14 +266,15 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
/// every device on the account, not only this one.
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Restore version")
.body(format!(
"Make this version the current content of “{}”? The version it replaces stays in the history.",
state.name
.heading(gettext("Restore Version?"))
// Translators: {name} is a file name.
.body(gettext_f(
"Make this version the current content of “{name}”? The version it replaces stays in the history.",
&[("name", &state.name)],
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("restore", "Restore");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("restore", &pgettext("verb", "Restore"));
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -270,8 +291,8 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
state.target.restore(revision_id.clone()),
// The server applies the swap in the background, so the wording
// promises the request, not the result.
"Restoring that version",
"Couldn't restore that version",
&gettext("Restoring that version"),
&gettext("Couldn't restore that version"),
);
});
dialog.present(window.as_ref());
@@ -280,11 +301,13 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
/// Confirm, then permanently delete one revision.
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Delete version")
.body("Delete this version permanently? Its content can't be recovered.")
.heading(gettext("Delete Version?"))
.body(gettext(
"Delete this version permanently? Its content can't be recovered.",
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("delete", "Delete");
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("delete", &pgettext("verb", "Delete"));
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -299,8 +322,8 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
versions_dialog_op(
&state,
state.target.delete(revision_id.clone()),
"Version deleted",
"Couldn't delete that version",
&gettext("Version deleted"),
&gettext("Couldn't delete that version"),
);
});
dialog.present(window.as_ref());
@@ -314,7 +337,7 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let window = ui_window(&state.ui);
let dialog = gtk4::FileDialog::builder()
.title("Save version")
.title(gettext("Save version"))
.initial_name(&state.name)
.build();
let state = state.clone();
@@ -324,16 +347,16 @@ fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
toast_error(
&state.ui,
"Couldn't save that version",
"That location isn't a local file.",
&gettext("Couldn't save that version"),
&gettext("That location isn't a local file."),
);
return;
};
versions_dialog_op(
&state,
state.target.save_as(revision_id.clone(), dest),
"Version saved",
"Couldn't save that version",
&gettext("Version saved"),
&gettext("Couldn't save that version"),
);
});
}
@@ -351,7 +374,11 @@ fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str,
versions_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
_ => toast_error(
&state.ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
+55
View File
@@ -0,0 +1,55 @@
//! Widgets that newer libadwaita releases bring, with a stand-in built from
//! what the oldest supported release (1.5, as on Ubuntu 24.04) has.
//!
//! The app builds against 1.5 and picks at run time: when the libadwaita it
//! runs with is 1.8 or newer, the real widgets are made by class name through
//! a `gtk4::Builder`, since the 1.5 bindings have no types for them. Their
//! properties and signals are then reached by name. Call sites never care
//! which one they got.
use std::sync::OnceLock;
use gtk4::prelude::*;
/// Set to anything to use the stand-ins even on a new libadwaita, to see the
/// app as it looks on the oldest supported release.
const STAND_INS_ENV: &str = "PDFS_STAND_IN_WIDGETS";
/// Whether the libadwaita the app runs with has the 1.8 widgets.
pub(crate) fn has_adw_1_8() -> bool {
static HAS: OnceLock<bool> = OnceLock::new();
*HAS.get_or_init(|| {
std::env::var_os(STAND_INS_ENV).is_none()
&& (adw::major_version(), adw::minor_version()) >= (1, 8)
})
}
/// Build `xml`, a GtkBuilder interface naming classes the bindings lack. It
/// is only ever one this module wrote, so a failure is a bug here.
pub(crate) fn build_ui(xml: &str) -> gtk4::Builder {
gtk4::Builder::from_string(&format!("<interface>{xml}</interface>"))
}
/// The object `id` from a builder [`build_ui`] made.
pub(crate) fn built<T: IsA<gtk4::glib::Object>>(builder: &gtk4::Builder, id: &str) -> T {
builder
.object(id)
.unwrap_or_else(|| panic!("the compat UI has no object {id}"))
}
/// A busy spinner. It spins whenever it is shown: show and hide it with
/// `set_visible` rather than starting and stopping it.
pub(crate) type Spinner = gtk4::Widget;
/// A new [`Spinner`], shown: `AdwSpinner` on libadwaita 1.8, a spinning
/// `gtk4::Spinner` before.
pub(crate) fn spinner() -> Spinner {
if has_adw_1_8() {
let builder = build_ui(r#"<object class="AdwSpinner" id="spinner"/>"#);
built(&builder, "spinner")
} else {
// Spinning costs nothing while hidden: an unmapped widget draws no
// frames.
gtk4::Spinner::builder().spinning(true).build().upcast()
}
}
+9 -5
View File
@@ -16,8 +16,8 @@ use pdfs_core::config::AppDirs;
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
use pdfs_core::opener::OpenWith;
use crate::Hit;
use crate::query;
use crate::{Hit, gettext, gettext_f};
/// Guard against a launcher that keeps handing back text we cannot satisfy —
/// without it a scripted (non-interactive) menu could spin forever.
@@ -90,8 +90,10 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
/// shows which round you are in.
fn title(query: Option<&str>) -> String {
match query {
Some(text) => format!("Drive: {text} › "),
None => "Search Drive › ".to_string(),
// Translators: the launcher prompt while refining a search; {query} is the text searched for. Keep the trailing space.
Some(text) => gettext_f("Drive: {query} › ", &[("query", text)]),
// Translators: the launcher prompt; keep the trailing space, the input follows it directly.
None => gettext("Search Drive › "),
}
}
@@ -107,8 +109,10 @@ fn title(query: Option<&str>) -> String {
/// wording is parenthesised and avoids words that read like a filename query.
fn placeholder(query: Option<&str>) -> String {
match query {
Some(text) => format!("(no matches for “{text}”)"),
None => "(no pinned files — type to search)".to_string(),
// Translators: the only row of an empty launcher list; {query} is the text searched for. Keep the parentheses.
Some(text) => gettext_f("(no matches for “{query}”)", &[("query", text)]),
// Translators: the only row of an empty launcher list. Keep the parentheses, and avoid words that look like a file name.
None => gettext("(no pinned files — type to search)"),
}
}
+6 -4
View File
@@ -32,13 +32,14 @@ use pdfs_core::menu::{self, PromptConfig};
use pdfs_core::opener::OpenWith;
use crate::query;
use crate::{Hit, SEARCH_DEBOUNCE};
use crate::{Hit, SEARCH_DEBOUNCE, gettext, gettext_noop};
/// Separates the visible half of a feed line from its payload.
const FIELD: char = '\t';
/// What the launcher prompt reads, matching the dmenu front end's wording.
const PROMPT: &str = "Search Drive › ";
// Translators: the launcher prompt; keep the trailing space, the input follows it directly.
const PROMPT: &str = gettext_noop("Search Drive › ");
pub(crate) struct Options {
/// Search for this immediately instead of opening on the pinned files.
@@ -166,7 +167,8 @@ fn interactive(query: Option<String>) -> Result<(), String> {
/// empty list with no explanation, so a failure becomes a visible row instead.
pub(crate) fn feed(query: Option<&str>) {
let Ok(dirs) = AppDirs::new() else {
println!("(cannot resolve app dirs)");
// Translators: shown as a row in the search list. Keep the parentheses.
println!("{}", gettext("(cannot resolve app dirs)"));
return;
};
let limit = dirs.load_config().resolved_prompt().resolved_menu_limit();
@@ -219,7 +221,7 @@ fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec<String> {
.iter()
.map(|arg| (*arg).to_string())
.collect();
argv.push(format!("--prompt={PROMPT}"));
argv.push(format!("--prompt={}", gettext(PROMPT)));
// `reload:` rather than `reload(…)`: the unparenthesised form runs to the
// end of the argument, so an install path containing a bracket cannot
// truncate the command fzf parses out of it.
+391
View File
@@ -0,0 +1,391 @@
//! Translation layer shared by `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
//!
//! Strings go through GNU gettext in the `pdfs` text domain. [`init`] runs first
//! in every binary: it applies the language picked in Preferences (if any) on
//! top of the system locale, then binds the domain to the installed catalogs.
//!
//! Messages with values use named placeholders (`{name}`) filled in by
//! [`gettext_f`] and [`ngettext_f`], so translators can reorder them. `po/`
//! holds the template and the catalogs; see `docs/TRANSLATING.md`.
// Each binary includes this module and uses a different part of it.
#![allow(dead_code)]
use gettextrs::LocaleCategory;
use pdfs_core::config::AppDirs;
/// The gettext text domain, matching `po/pdfs.pot` and the installed `.mo` name.
pub(crate) const DOMAIN: &str = "pdfs";
/// Overrides the catalog directory at run time, for running from a source tree
/// (`PDFS_LOCALEDIR=target/locale`).
const LOCALEDIR_ENV: &str = "PDFS_LOCALEDIR";
/// Where packages install the catalogs unless the build sets `PDFS_LOCALEDIR`.
const DEFAULT_LOCALEDIR: &str = "/usr/share/locale";
/// Languages the app ships a catalog for, as (gettext code, native name). The
/// Preferences picker lists them after "System Default". US English is the
/// source language and needs no catalog. Names stay untranslated so a user can
/// always find their own language.
pub(crate) const LANGUAGES: &[(&str, &str)] = &[
("de", "Deutsch"),
("en_GB", "English (UK)"),
("en_US", "English (US)"),
("es", "Español"),
("fr", "Français"),
("it", "Italiano"),
("nl", "Nederlands"),
("pl", "Polski"),
("pt_BR", "Português (Brasil)"),
];
/// Set up the locale and the text domain. Call first thing in `main`, before
/// any thread starts: an explicit language is applied through the process
/// environment, so that GTK's own strings follow it too.
pub(crate) fn init() {
let language = AppDirs::new()
.ok()
.and_then(|dirs| dirs.load_config().language)
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
gettextrs::setlocale(LocaleCategory::LcAll, "");
if let Some(code) = language {
apply_language(&code);
}
let dir = std::env::var(LOCALEDIR_ENV)
.ok()
.filter(|dir| !dir.is_empty())
.unwrap_or_else(|| {
option_env!("PDFS_LOCALEDIR")
.unwrap_or(DEFAULT_LOCALEDIR)
.to_string()
});
if let Err(e) = gettextrs::bindtextdomain(DOMAIN, dir) {
tracing::warn!("bindtextdomain failed: {e}");
}
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
// Country names come translated from the iso-codes catalogs, which live in
// the system's own locale directory.
let _ = gettextrs::bind_textdomain_codeset(ISO_3166_DOMAIN, "UTF-8");
if let Err(e) = gettextrs::textdomain(DOMAIN) {
tracing::warn!("textdomain failed: {e}");
}
}
/// The iso-codes text domain that translates ISO 3166-1 country names.
const ISO_3166_DOMAIN: &str = "iso_3166-1";
/// The name of the country with ISO 3166-1 code `code`, in the user's language.
/// The English names are the iso-codes project's own, so that project's
/// catalogs translate them; Kosovo has no ISO code of its own and is
/// translated here. An unknown code is shown as it is.
pub(crate) fn country_name(code: &str) -> String {
if code == "XK" {
return gettext("Kosovo");
}
match pdfs_core::places::country_name(code) {
Some(name) => gettextrs::dgettext(ISO_3166_DOMAIN, name),
None => code.to_string(),
}
}
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
/// a configured locale also gets its message locale moved to a real one. Both
/// go into the environment, because GTK calls `setlocale` again during its own
/// start.
fn apply_language(code: &str) {
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
unsafe { std::env::set_var("LANGUAGE", code) };
let current = gettextrs::setlocale(LocaleCategory::LcMessages, "")
.map(|name| String::from_utf8_lossy(&name).into_owned())
.unwrap_or_default();
if !is_c_locale(&current) {
return;
}
for candidate in message_locales(code) {
if gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()).is_some() {
// SAFETY: as above, still single-threaded.
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
return;
}
}
}
fn is_c_locale(name: &str) -> bool {
let base = name.split('.').next().unwrap_or(name);
matches!(base, "" | "C" | "POSIX")
}
/// Locales to try, in order, as the message locale for `code`: the language's
/// own locale, then US English, which any locale-enabled system has and which
/// is enough for gettext to honour `LANGUAGE`.
fn message_locales(code: &str) -> Vec<String> {
let full = if code.contains('_') {
code.to_string()
} else {
format!("{code}_{}", code.to_uppercase())
};
let mut locales = vec![format!("{full}.UTF-8")];
if full != "en_US" {
locales.push("en_US.UTF-8".to_string());
}
locales
}
/// Mark `msgid` for extraction without translating it yet, for tables of
/// labels built at compile time. Pass the value through [`gettext`] where it is
/// shown.
pub(crate) const fn gettext_noop(msgid: &'static str) -> &'static str {
msgid
}
/// Translate `msgid`.
pub(crate) fn gettext(msgid: &str) -> String {
gettextrs::gettext(msgid)
}
/// Translate `msgid` in context `context`, for short strings whose meaning
/// depends on where they appear ("Open" the verb against "Open" the state).
pub(crate) fn pgettext(context: &str, msgid: &str) -> String {
gettextrs::pgettext(context, msgid)
}
/// Translate the singular or plural form for `n`.
pub(crate) fn ngettext(singular: &str, plural: &str, n: u64) -> String {
gettextrs::ngettext(singular, plural, plural_count(n))
}
/// Translate `msgid`, then fill its `{name}` placeholders from `args`.
pub(crate) fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String {
fill(&gettext(msgid), args)
}
/// Translate the form for `n`, then fill `{n}` with the count and the other
/// placeholders from `args`.
pub(crate) fn ngettext_f(singular: &str, plural: &str, n: u64, args: &[(&str, &str)]) -> String {
let count = n.to_string();
let filled = fill(&ngettext(singular, plural, n), args);
fill(&filled, &[("n", &count)])
}
/// The translated form of [`pdfs_core::control::pending_summary`]: what is
/// still waiting to go up, or `None` when nothing is.
pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
match (uploads, changes) {
(0, 0) => None,
(uploads, 0) => Some(ngettext_f(
"{n} upload queued",
"{n} uploads queued",
uploads,
&[],
)),
(0, changes) => Some(ngettext_f(
"{n} change queued",
"{n} changes queued",
changes,
&[],
)),
(uploads, changes) => {
let uploads = ngettext_f("{n} upload", "{n} uploads", uploads, &[]);
let changes = ngettext_f("{n} change", "{n} changes", changes, &[]);
// Translators: {uploads} is "3 uploads", {changes} is "2 changes".
Some(gettext_f(
"{uploads}, {changes} queued",
&[("uploads", &uploads), ("changes", &changes)],
))
}
}
}
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
/// `512 bytes` below one KiB).
///
/// GLib does the formatting, so the units, the plural of "bytes" and the
/// decimal separator follow the user's language from GLib's own catalog. GLib
/// separates number and unit with a no-break space; it is turned back into a
/// plain space so the text matches what the rest of the UI builds around it.
pub(crate) fn human_bytes(bytes: u64) -> String {
gtk4::glib::format_size_full(bytes, gtk4::glib::FormatSizeFlags::IEC_UNITS)
.replace('\u{a0}', " ")
}
/// gettext takes the count as `u32`. Counts past that pick the same form as
/// the largest one, which is the plural in every shipped language.
fn plural_count(n: u64) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
/// Replace each `{key}` in `template` with its value. Unknown placeholders are
/// left as they are, so a translation mistake shows up rather than vanishing.
fn fill(template: &str, args: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (key, value) in args {
out = out.replace(&format!("{{{key}}}"), value);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fill_replaces_named_placeholders_in_any_order() {
assert_eq!(
fill("{b} before {a}", &[("a", "x"), ("b", "y")]),
"y before x"
);
}
#[test]
fn fill_keeps_unknown_placeholders() {
assert_eq!(fill("{missing} {a}", &[("a", "1")]), "{missing} 1");
}
#[test]
fn ngettext_f_fills_the_count() {
assert_eq!(ngettext_f("{n} file", "{n} files", 1, &[]), "1 file");
assert_eq!(
ngettext_f(
"{n} file in {dir}",
"{n} files in {dir}",
3,
&[("dir", "A")]
),
"3 files in A"
);
}
#[test]
fn pending_summary_matches_the_core_wording() {
for (uploads, changes) in [(0, 0), (1, 0), (3, 0), (0, 1), (2, 4)] {
assert_eq!(
pending_summary(uploads, changes),
pdfs_core::control::pending_summary(uploads, changes)
);
}
}
#[test]
fn plural_count_saturates() {
assert_eq!(plural_count(7), 7);
assert_eq!(plural_count(u64::MAX), u32::MAX);
}
#[test]
fn language_codes_are_unique_and_sorted() {
let codes: Vec<&str> = LANGUAGES.iter().map(|(code, _)| *code).collect();
let mut sorted = codes.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(codes, sorted);
}
fn repo_file(path: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(path)
}
fn linguas() -> Vec<String> {
std::fs::read_to_string(repo_file("po/LINGUAS"))
.unwrap()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect()
}
#[test]
fn every_offered_language_has_a_catalog() {
let linguas = linguas();
for (code, _) in LANGUAGES {
// US English is the source language.
if *code != "en_US" {
assert!(
linguas.iter().any(|lang| lang == code),
"{code} is not in po/LINGUAS"
);
}
}
for lang in &linguas {
assert!(
repo_file(&format!("po/{lang}.po")).exists(),
"po/{lang}.po is missing"
);
}
}
#[test]
fn potfiles_lists_every_source_that_translates() {
let listed = std::fs::read_to_string(repo_file("po/POTFILES.in")).unwrap();
let listed: Vec<&str> = listed.lines().map(str::trim).collect();
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut stack = vec![src];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|ext| ext != "rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
if !text.contains("gettext") {
continue;
}
let relative = path.strip_prefix(env!("CARGO_MANIFEST_DIR")).unwrap();
let name = format!("crates/pdfs-gui/{}", relative.display());
assert!(
listed.contains(&name.as_str()),
"{name} is not in po/POTFILES.in"
);
}
}
}
/// `msgfmt --check` rejects a translation whose `{placeholders}` differ from
/// the English, besides syntax errors. Skipped where gettext is not installed.
#[test]
fn catalogs_compile_with_matching_placeholders() {
let msgfmt = std::process::Command::new("msgfmt")
.arg("--version")
.output();
if msgfmt.is_err() {
eprintln!("msgfmt not installed; skipping the catalog check");
return;
}
for lang in linguas() {
let output = std::process::Command::new("msgfmt")
.args(["--check", "--output-file=/dev/null"])
.arg(repo_file(&format!("po/{lang}.po")))
.output()
.unwrap();
assert!(
output.status.success(),
"po/{lang}.po: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn c_locales_are_detected() {
assert!(is_c_locale("C"));
assert!(is_c_locale("POSIX"));
assert!(is_c_locale("C.UTF-8"));
assert!(is_c_locale("C.utf8"));
assert!(!is_c_locale("de_DE.UTF-8"));
}
#[test]
fn message_locales_start_with_the_language_region() {
assert_eq!(message_locales("de"), ["de_DE.UTF-8", "en_US.UTF-8"]);
assert_eq!(message_locales("pt_BR"), ["pt_BR.UTF-8", "en_US.UTF-8"]);
assert_eq!(message_locales("en_US"), ["en_US.UTF-8"]);
}
}
+180 -67
View File
@@ -13,7 +13,7 @@
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use ksni::blocking::TrayMethods;
@@ -22,12 +22,16 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
const POLL_INTERVAL: Duration = Duration::from_secs(3);
mod i18n;
use i18n::{gettext, gettext_f, ngettext_f};
/// How long the tray lets a burst of events arrive before it repaints. Progress
/// moves several times a second, which is more than a tray menu can show.
const COALESCE: Duration = Duration::from_secs(1);
/// The one word the icon says about the drive, derived from each poll.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -52,7 +56,7 @@ impl Phase {
fn icon(self) -> &'static str {
match self {
Phase::Synced => "folder-remote-symbolic",
Phase::Syncing => "emblem-synchronizing-symbolic",
Phase::Syncing => "view-refresh-symbolic",
Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-symbolic",
Phase::Attention => "dialog-warning-symbolic",
@@ -89,7 +93,14 @@ struct DriveState {
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => match jobs.first() {
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total),
Some(j) if j.total > 0 => {
let (done, total) = (j.done.to_string(), j.total.to_string());
// Translators: {title} names a running job such as a scan; {done} and {total} are counts.
gettext_f(
"{title} ({done} of {total})",
&[("title", &j.title), ("done", &done), ("total", &total)],
)
}
Some(j) => format!("{}…", j.title),
None => String::new(),
},
@@ -112,8 +123,10 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
.count();
let up = items.len() - down;
match (down, up) {
(d, 0) => format!("↓ {d} downloading"),
(0, u) => format!("↑ {u} uploading"),
// Translators: {n} files are downloading; keep the arrow.
(d, 0) => ngettext_f("↓ {n} downloading", "↓ {n} downloading", d as u64, &[]),
// Translators: {n} files are uploading; keep the arrow.
(0, u) => ngettext_f("↑ {n} uploading", "↑ {n} uploading", u as u64, &[]),
(d, u) => format!("↓ {d} · ↑ {u}"),
}
}
@@ -129,51 +142,20 @@ struct DriveTray {
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(Response::Status {
mountpoint,
pinned,
online,
pending_uploads,
pending_changes,
failing_ops,
paused,
..
}) => {
// Same daemon is up, so a cheap follow-up poll gives the sync line.
Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request gives the sync line.
let sync = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
};
let queued = pending_summary(pending_uploads, pending_changes);
let phase = phase_of(
paused,
failing_ops,
online,
queued.is_some() || !sync.is_empty(),
);
DriveState {
line: match (online, queued) {
_ if paused => "Sync paused".to_string(),
_ if failing_ops > 0 => match failing_ops {
1 => "1 change needs attention".to_string(),
n => format!("{n} changes need attention"),
},
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
(false, Some(q)) => format!("Offline — {q}"),
},
phase,
mounted: true,
paused,
mountpoint: PathBuf::from(mountpoint),
sync,
failing: failing_ops,
}
status_state(&status, sync, default_mountpoint)
}
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => DriveState {
line: "Mount: unexpected daemon response".into(),
Ok(_) => unexpected_reply(default_mountpoint),
// The daemon is up but stuck: say so rather than "not connected", and
// keep the mount actions, which still work on the folder itself.
Err(e) if e.is_timeout() => DriveState {
line: gettext("Proton Drive isn't responding"),
phase: Phase::Attention,
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
@@ -185,11 +167,19 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
Err(_) => {
let (line, phase) = match auth::load() {
Ok(s) => (
format!("Logged in as {} — not mounted", s.username),
// Translators: {username} is the Proton account name.
gettext_f(
"Signed in as {username} — not connected",
&[("username", &s.username)],
),
Phase::Disconnected,
),
Err(pdfs_core::Error::NotLoggedIn) => ("Not signed in".into(), Phase::SignedOut),
Err(e) => (format!("Error: {e}"), Phase::Attention),
Err(pdfs_core::Error::NotLoggedIn) => (gettext("Not signed in"), Phase::SignedOut),
Err(e) => (
// Translators: {error} is an error message, in English.
gettext_f("Error: {error}", &[("error", &e.to_string())]),
Phase::Attention,
),
};
DriveState {
line,
@@ -204,6 +194,86 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
}
}
/// What the tray says when the daemon answers with something it did not ask
/// for: up, but odd.
fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
DriveState {
line: gettext("Proton Drive sent an unexpected reply"),
phase: Phase::Attention,
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
paused: false,
failing: 0,
}
}
/// The state a running daemon's [`Response::Status`] describes, with `sync`
/// saying what it is moving right now.
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
let Response::Status {
mountpoint,
pinned,
online,
pending_uploads,
pending_changes,
failing_ops,
paused,
..
} = status
else {
return unexpected_reply(default_mountpoint);
};
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
let phase = phase_of(
paused,
failing_ops,
online,
queued.is_some() || !sync.is_empty(),
);
DriveState {
line: match (online, queued) {
_ if paused => gettext("Sync paused"),
_ if failing_ops > 0 => ngettext_f(
"{n} change needs attention",
"{n} changes need attention",
failing_ops,
&[],
),
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
(true, None) => ngettext_f(
"Up to date in {mountpoint} ({n} pinned)",
"Up to date in {mountpoint} ({n} pinned)",
pinned as u64,
&[("mountpoint", mountpoint)],
),
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
(true, Some(q)) => ngettext_f(
"Syncing — {queued} ({n} pinned)",
"Syncing — {queued} ({n} pinned)",
pinned as u64,
&[("queued", &q)],
),
// Translators: {n} is the number of pinned items.
(false, None) => ngettext_f(
"Offline — cached files only ({n} pinned)",
"Offline — cached files only ({n} pinned)",
pinned as u64,
&[],
),
// Translators: {queued} is a queue summary such as "3 uploads queued".
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
},
phase,
mounted: true,
paused,
mountpoint: PathBuf::from(mountpoint),
sync,
failing: failing_ops,
}
}
/// The phase a running mount is in. A pause is the user's own doing and says
/// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
@@ -222,10 +292,8 @@ fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
/// "3 issues — View": the menu item that opens the Sync page's queue.
fn issues_label(failing: u64) -> String {
match failing {
1 => "1 issue — View".to_string(),
n => format!("{n} issues — View"),
}
// Translators: a menu item that opens the list of failing changes.
ngettext_f("{n} issue — View", "{n} issues — View", failing, &[])
}
fn open_folder(mountpoint: &Path) {
@@ -331,14 +399,14 @@ impl Tray for DriveTray {
match self.state.phase {
// Nothing to mount without a session; the app's sign-in page is the
// way in.
Phase::SignedOut => items.push(item("Sign In…", |_| open_manager(&[]))),
_ => items.push(item("Open Proton Drive", |_| open_manager(&[]))),
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
}
if self.state.mounted {
items.push(
StandardItem {
label: "Open Folder".into(),
label: gettext("Open Folder"),
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
..Default::default()
}
@@ -349,7 +417,11 @@ impl Tray for DriveTray {
let paused = self.state.paused;
items.push(
StandardItem {
label: if paused { "Resume Sync" } else { "Pause Sync" }.into(),
label: if paused {
gettext("Resume Sync")
} else {
gettext("Pause Sync")
},
activate: Box::new(move |this: &mut Self| {
let request = Request::SetSyncPaused {
paused: !paused,
@@ -367,22 +439,22 @@ impl Tray for DriveTray {
);
} else if self.state.phase == Phase::Disconnected {
// Enable+start the service so it mounts now and on future logins.
items.push(item("Connect", |this| {
items.push(item(&gettext("Connect"), |this| {
service::enable_start();
this.state.line = "Connecting…".into();
this.state.line = gettext("Connecting…");
}));
}
items.push(MenuItem::Separator);
items.push(item("Hide Tray Icon", |_| hide_tray()));
items.push(item(&gettext("Hide Tray Icon"), |_| hide_tray()));
if self.state.mounted {
// Stopping unmounts the drive, so the app asks first; the tray has no
// GTK of its own to ask with.
items.push(item("Stop Proton Drive…", |_| {
items.push(item(&gettext("Stop Proton Drive…"), |_| {
open_manager(&["--confirm-stop"])
}));
} else {
items.push(item("Quit", |_| std::process::exit(0)));
items.push(item(&gettext("Quit"), |_| std::process::exit(0)));
}
items
}
@@ -394,6 +466,7 @@ fn main() -> Result<()> {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
i18n::init();
let dirs = AppDirs::new().context("resolve app dirs")?;
// Hidden from its own menu: the session's autostart still runs the tray, and
@@ -448,13 +521,53 @@ fn main() -> Result<()> {
.spawn()
.context("start tray service")?;
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
// Follow the daemon forever, pushing each fresh state into the tray so the
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
loop {
std::thread::sleep(POLL_INTERVAL);
let st = poll_state(&socket, &default_mountpoint);
let (tx, rx) = std::sync::mpsc::channel();
let feed_socket = socket.clone();
std::thread::spawn(move || follow(&feed_socket, |feed| tx.send(feed).is_ok()));
let mut heard = Heard::default();
while let Ok(feed) = rx.recv() {
heard.take(feed);
let until = Instant::now() + COALESCE;
while let Ok(feed) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
heard.take(feed);
}
let st = match &heard.status {
Some(status) => status_state(
status,
sync_line(&heard.items, &heard.jobs),
&default_mountpoint,
),
// Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint),
};
handle.update(move |t: &mut DriveTray| t.state = st);
}
Ok(())
}
/// What the tray has heard from the daemon's event feed.
#[derive(Default)]
struct Heard {
/// The last status, or `None` once the daemon has gone.
status: Option<Response>,
items: Vec<TransferItem>,
jobs: Vec<JobItem>,
}
impl Heard {
fn take(&mut self, feed: Feed) {
match feed {
Feed::Event(Event::Status(status)) => self.status = Some(*status),
Feed::Event(Event::Transfers { items, jobs }) => {
self.items = items;
self.jobs = jobs;
}
Feed::Disconnected => *self = Self::default(),
Feed::Connected | Feed::Event(Event::Changed { .. } | Event::Heartbeat) => {}
}
}
}
#[cfg(test)]
+93 -76
View File
@@ -23,6 +23,10 @@ use pdfs_core::menu::PromptMode;
use pdfs_core::opener::{self, OpenWith};
mod activation;
mod compat;
mod i18n;
use compat::{Spinner, spinner};
use i18n::{gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext};
mod dmenu;
mod fzf;
mod query;
@@ -51,11 +55,13 @@ enum Filter {
/// The chip row, in Tab-cycle order.
const FILTERS: [(Filter, &str); 5] = [
(Filter::All, "All"),
(Filter::Folders, "Folders"),
(Filter::Documents, "Documents"),
(Filter::Images, "Images"),
(Filter::Media, "Media"),
// Translators: a filter chip that shows every kind of result.
(Filter::All, gettext_noop("All")),
(Filter::Folders, gettext_noop("Folders")),
(Filter::Documents, gettext_noop("Documents")),
(Filter::Images, gettext_noop("Images")),
// Translators: a filter chip for audio and video files.
(Filter::Media, gettext_noop("Media")),
];
impl Filter {
@@ -162,9 +168,13 @@ impl Hit {
None => {
let parent = parent_of(&h.path);
if parent.is_empty() {
"My files".to_string()
gettext("My files")
} else {
format!("My files / {}", parent.replace('/', " / "))
// Translators: {path} is a folder path inside the user's Drive, such as "Work / Taxes".
gettext_f(
"My files / {path}",
&[("path", &parent.replace('/', " / "))],
)
}
}
},
@@ -227,7 +237,7 @@ struct Ui {
opener: OpenWith,
entry: gtk4::Entry,
spinner: gtk4::Spinner,
spinner: Spinner,
stack: gtk4::Stack,
scroller: gtk4::ScrolledWindow,
results: gtk4::Box,
@@ -425,6 +435,7 @@ fn main() -> glib::ExitCode {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
i18n::init();
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.iter().any(|arg| arg == "-h" || arg == "--help") {
@@ -506,7 +517,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let window = adw::ApplicationWindow::builder()
.application(app)
.title("Search Proton Drive")
.title(gettext("Search Proton Drive"))
.default_width(720)
.default_height(540)
.resizable(false)
@@ -527,17 +538,18 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
search_bar.append(&search_icon);
let entry = gtk4::Entry::builder()
.placeholder_text("Search in Drive and on this computer")
.placeholder_text(gettext("Search in Drive and on this computer"))
.hexpand(true)
.build();
entry.add_css_class("search-entry");
search_bar.append(&entry);
let spinner = gtk4::Spinner::new();
let spinner = spinner();
spinner.set_visible(false);
search_bar.append(&spinner);
let esc = gtk4::Label::new(Some("Esc"));
// Translators: the Escape key, shown as a hint that it closes the window.
let esc = gtk4::Label::new(Some(&gettext("Esc")));
esc.add_css_class("key-hint");
search_bar.append(&esc);
card.append(&search_bar);
@@ -551,9 +563,9 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
results.add_css_class("results");
let search_section = Section::new("Best matches", "system-search-symbolic");
let search_section = Section::new(&gettext("Best matches"), "system-search-symbolic");
let drive_section = Section::new("Proton Drive", "folder-remote-symbolic");
let local_section = Section::new("This computer", "drive-harddisk-symbolic");
let local_section = Section::new(&gettext("This computer"), "drive-harddisk-symbolic");
search_section.attach(&results);
drive_section.attach(&results);
local_section.attach(&results);
@@ -567,7 +579,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let placeholder = adw::StatusPage::builder()
.icon_name("system-search-symbolic")
.title("No results")
.title(gettext("No results"))
.build();
placeholder.add_css_class("compact");
@@ -575,12 +587,12 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
// is still usable for nothing else, so it owns the whole view.
let offline = adw::StatusPage::builder()
.icon_name("network-offline-symbolic")
.title("Proton Drive is not running")
.description("Start the mount daemon to search your Drive.")
.title(gettext("Proton Drive is not running"))
.description(gettext("Start Proton Drive to search your files."))
.build();
offline.add_css_class("compact");
let retry = gtk4::Button::builder()
.label("Retry")
.label(gettext("Retry"))
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
@@ -601,14 +613,15 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
footer.add_css_class("footer");
let hint = gtk4::Label::builder()
.label("Connecting…")
.label(gettext("Connecting…"))
.xalign(0.0)
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
hint.add_css_class("footer-text");
footer.append(&hint);
let keys = gtk4::Label::new(Some("↑↓ navigate · ↵ open · Tab filter"));
// Translators: keyboard hints in the footer; keep the arrow and Enter symbols.
let keys = gtk4::Label::new(Some(&gettext("↑↓ navigate · ↵ open · Tab filter")));
keys.add_css_class("footer-text");
footer.append(&keys);
card.append(&footer);
@@ -646,7 +659,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
for (filter, label) in FILTERS {
let chip = gtk4::ToggleButton::builder()
.label(label)
.label(gettext(label))
.active(filter == Filter::All)
.build();
chip.add_css_class("chip");
@@ -813,7 +826,6 @@ impl Ui {
self.query_id.set(self.query_id.get() + 1);
self.pending.set(0);
self.open_pending.set(false);
self.spinner.stop();
self.spinner.set_visible(false);
self.suppress_entry_change.set(true);
self.entry.set_text("");
@@ -852,7 +864,7 @@ impl Ui {
/// Ask the daemon for status and, if it answers, load the suggested (pinned)
/// files. Runs off-thread: a dead daemon must not freeze the first frame.
fn connect(self: &Rc<Self>) {
self.hint.set_label("Connecting…");
self.hint.set_label(&gettext("Connecting…"));
let rx = spawn_request(self.socket.clone(), Request::Status);
let ui = self.clone();
glib::spawn_future_local(async move {
@@ -865,7 +877,7 @@ impl Ui {
}
_ => {
ui.entry.set_sensitive(false);
ui.hint.set_label("Daemon offline");
ui.hint.set_label(&gettext("Proton Drive isn't running"));
ui.stack.set_visible_child_name("offline");
}
}
@@ -876,10 +888,9 @@ impl Ui {
/// the offline page and lock the entry; the retry button re-runs `connect`.
fn go_offline(self: &Rc<Self>) {
self.pending.set(0);
self.spinner.stop();
self.spinner.set_visible(false);
self.entry.set_sensitive(false);
self.hint.set_label("Daemon offline");
self.hint.set_label(&gettext("Proton Drive isn't running"));
self.stack.set_visible_child_name("offline");
}
@@ -979,7 +990,6 @@ impl Ui {
self.query_id.set(id);
self.pending.set(requests);
self.spinner.set_visible(true);
self.spinner.start();
id
}
@@ -992,7 +1002,6 @@ impl Ui {
let left = self.pending.get().saturating_sub(1);
self.pending.set(left);
if left == 0 {
self.spinner.stop();
self.spinner.set_visible(false);
}
true
@@ -1072,7 +1081,8 @@ impl Ui {
visible.extend(local);
if searching {
rank_hits(&mut visible);
self.search_section.set_rows(&visible, Some("Best matches"));
self.search_section
.set_rows(&visible, Some(&gettext("Best matches")));
self.drive_section.set_rows(&[], None);
self.local_section.set_rows(&[], None);
} else {
@@ -1081,8 +1091,10 @@ impl Ui {
.iter()
.take_while(|hit| matches!(hit, Hit::Drive(_)))
.count();
self.drive_section
.set_rows(&visible[..drive_count], Some("Pinned in Proton Drive"));
self.drive_section.set_rows(
&visible[..drive_count],
Some(&gettext("Pinned in Proton Drive")),
);
self.local_section.set_rows(&visible[drive_count..], None);
}
@@ -1093,18 +1105,20 @@ impl Ui {
*self.rendered_query.borrow_mut() = query;
if total == 0 {
self.placeholder.set_title(if searching {
"No results"
self.placeholder.set_title(&if searching {
gettext("No results")
} else {
"Search your Drive"
gettext("Search your Drive")
});
self.placeholder
.set_description(Some(match (searching, self.indexing.get()) {
(true, true) => {
"Still indexing this computer — local results will fill in shortly."
}
(true, false) => "Try a different search, or another filter.",
_ => "Start typing to search Proton Drive and the files on this computer.",
.set_description(Some(&match (searching, self.indexing.get()) {
(true, true) => gettext(
"Still indexing this computer — local results will fill in shortly.",
),
(true, false) => gettext("Try a different search, or another filter."),
_ => gettext(
"Start typing to search Proton Drive and the files on this computer.",
),
}));
self.stack.set_visible_child_name("empty");
self.cursor.set(None);
@@ -1129,18 +1143,23 @@ impl Ui {
fn status_text(&self, total: usize, searching: bool) -> String {
if !searching {
return match total {
0 => "No pinned files".to_string(),
1 => "1 pinned file".to_string(),
n => format!("{n} pinned files"),
0 => gettext("No pinned files"),
n => ngettext_f("{n} pinned file", "{n} pinned files", n as u64, &[]),
};
}
let drive = self.drive_count();
let local = total - drive;
let mut text = format!("{drive} in Drive · {local} on this computer");
let drive = self.drive_count().to_string();
let local = (total - self.drive_count()).to_string();
let args = [("drive", drive.as_str()), ("local", local.as_str())];
if self.indexing.get() {
text.push_str(" · indexing…");
// Translators: result counts in the footer while this computer is still being indexed; {drive} and {local} are numbers.
gettext_f(
"{drive} in Drive · {local} on this computer · indexing…",
&args,
)
} else {
// Translators: result counts in the footer; {drive} and {local} are numbers.
gettext_f("{drive} in Drive · {local} on this computer", &args)
}
text
}
/// Move the cursor by `delta` rows, wrapping at both ends.
@@ -1271,8 +1290,9 @@ impl Ui {
self.opening.set(true);
self.spinner.set_visible(true);
self.spinner.start();
self.hint.set_label(&format!("Opening {}…", drive.name));
// Translators: {name} is a file name.
let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]);
self.hint.set_label(&opening);
let rx = spawn_request(
self.socket.clone(),
@@ -1284,7 +1304,6 @@ impl Ui {
let ui = self.clone();
glib::spawn_future_local(async move {
let reply = rx.recv().await;
ui.spinner.stop();
ui.spinner.set_visible(false);
ui.opening.set(false);
match reply {
@@ -1293,9 +1312,13 @@ impl Ui {
ui.dismiss();
}
Ok(Ok(Response::Error { message, .. })) => {
ui.hint.set_label(&format!("Could not open: {message}"));
// Translators: {message} is an error from the daemon, in English.
ui.hint.set_label(&gettext_f(
"Could not open: {message}",
&[("message", &message)],
));
}
_ => ui.hint.set_label("Could not reach the daemon"),
_ => ui.hint.set_label(&gettext("Couldn't reach Proton Drive")),
}
});
}
@@ -1343,7 +1366,7 @@ fn build_row(hit: &Hit) -> gtk4::ListBoxRow {
meta.append(&pin);
}
if !hit.is_dir() && hit.size() > 0 {
let size = gtk4::Label::new(Some(&format_size(hit.size())));
let size = gtk4::Label::new(Some(&human_bytes(hit.size())));
size.add_css_class("result-meta");
meta.append(&size);
}
@@ -1403,8 +1426,10 @@ fn home_relative(parent: &str) -> String {
.ok()
.map(|rel| rel.display().to_string())
}) {
Some(rel) if rel.is_empty() => "Home".to_string(),
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
// Translators: the user's home folder, as the location of a result.
Some(rel) if rel.is_empty() => gettext("Home"),
// Translators: {path} is a folder path inside the home folder, folder names separated by " / ".
Some(rel) => gettext_f("Home / {path}", &[("path", &rel.replace('/', " / "))]),
None => parent.to_string(),
}
}
@@ -1416,20 +1441,6 @@ fn parent_of(path: &str) -> String {
.unwrap_or_default()
}
fn format_size(bytes: u64) -> String {
const UNITS: [(u64, &str); 3] = [
(1024 * 1024 * 1024, "GB"),
(1024 * 1024, "MB"),
(1024, "KB"),
];
for (scale, unit) in UNITS {
if bytes >= scale {
return format!("{:.1} {unit}", bytes as f64 / scale as f64);
}
}
format!("{bytes} B")
}
/// Coarse relative age, in the granularity a launcher row has room for.
fn format_age(epoch_secs: i64) -> String {
let now = std::time::SystemTime::now()
@@ -1437,12 +1448,18 @@ fn format_age(epoch_secs: i64) -> String {
.map_or(0, |d| d.as_secs() as i64);
let diff = now - epoch_secs;
match diff {
d if d < 60 => "now".to_string(),
d if d < 3600 => format!("{}m", d / 60),
d if d < 86_400 => format!("{}h", d / 3600),
d if d < 2_592_000 => format!("{}d", d / 86_400),
d if d < 31_536_000 => format!("{}mo", d / 2_592_000),
d => format!("{}y", d / 31_536_000),
// Translators: the age of a file modified under a minute ago.
d if d < 60 => pgettext("age", "now"),
// Translators: a compact file age; {count} is a number of minutes.
d if d < 3600 => gettext_f("{count}m", &[("count", &(d / 60).to_string())]),
// Translators: a compact file age; {count} is a number of hours.
d if d < 86_400 => gettext_f("{count}h", &[("count", &(d / 3600).to_string())]),
// Translators: a compact file age; {count} is a number of days.
d if d < 2_592_000 => gettext_f("{count}d", &[("count", &(d / 86_400).to_string())]),
// Translators: a compact file age; {count} is a number of months.
d if d < 31_536_000 => gettext_f("{count}mo", &[("count", &(d / 2_592_000).to_string())]),
// Translators: a compact file age; {count} is a number of years.
d => gettext_f("{count}y", &[("count", &(d / 31_536_000).to_string())]),
}
}
+8 -2
View File
@@ -17,7 +17,7 @@ use pdfs_core::control::{
use pdfs_core::opener::{self, OpenWith};
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
use crate::{Hit, file_name, gettext_f, is_document, is_image, is_media, rank_hits};
/// Where the mount lives, for turning a Drive path into something an
/// application can open. Falls back to the configured default so a momentarily
@@ -164,7 +164,13 @@ pub(crate) fn open(
/// One control-socket round-trip, with the daemon-is-down case phrased for a
/// terminal rather than a status page.
pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> {
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
send(socket, &request).map_err(|e| {
// Translators: shown as a row in the search list; {error} is a system error, in English.
gettext_f(
"cannot reach the Proton Drive daemon: {error}",
&[("error", &e.to_string())],
)
})
}
#[cfg(test)]
+160
View File
@@ -0,0 +1,160 @@
//! Every symbolic icon the app window and the search prompt ask for must
//! exist on a stock GNOME desktop.
//!
//! An icon name that no theme ships shows up as a missing-image glyph, and
//! nothing fails until someone looks. So each `"…-symbolic"` literal in the
//! sources has to be either one of the `pdfs-*` icons bundled in
//! `resources/icons` (and listed in the gresource manifest), or on
//! [`STOCK`], a list of names checked against Adwaita 50, libadwaita and
//! GTK's own built-in icons.
//!
//! The tray is left out: its icons are drawn by the panel, from the panel's
//! theme, not from our resources.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
/// Stock symbolic icons the app may use. Before adding a name, check that
/// Adwaita has it outside its `legacy` folder, or that libadwaita or GTK
/// ship it themselves.
const STOCK: &[&str] = &[
// libadwaita
"adw-external-link-symbolic",
// GTK's built-in icons
"info-outline-symbolic",
// Adwaita
"checkbox-checked-symbolic",
"checkbox-symbolic",
"computer-symbolic",
"dialog-error-symbolic",
"dialog-warning-symbolic",
"document-edit-symbolic",
"document-open-recent-symbolic",
"document-open-symbolic",
"document-save-symbolic",
"drive-harddisk-symbolic",
"edit-copy-symbolic",
"edit-delete-symbolic",
"edit-find-symbolic",
"edit-undo-symbolic",
"folder-download-symbolic",
"folder-new-symbolic",
"folder-open-symbolic",
"folder-remote-symbolic",
"folder-symbolic",
"go-down-symbolic",
"go-jump-symbolic",
"go-next-symbolic",
"go-previous-symbolic",
"go-up-symbolic",
"image-missing-symbolic",
"image-x-generic-symbolic",
"list-add-symbolic",
"mark-location-symbolic",
"media-playback-pause-symbolic",
"media-playback-start-symbolic",
"network-idle-symbolic",
"network-offline-symbolic",
"non-starred-symbolic",
"object-select-symbolic",
"preferences-system-symbolic",
"process-stop-symbolic",
"starred-symbolic",
"system-search-symbolic",
"text-x-generic-symbolic",
"user-trash-symbolic",
"view-grid-symbolic",
"view-list-symbolic",
"view-more-symbolic",
"view-paged-symbolic",
"view-pin-symbolic",
"view-refresh-symbolic",
"window-close-symbolic",
"zoom-fit-best-symbolic",
"zoom-in-symbolic",
"zoom-out-symbolic",
];
fn crate_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
/// Every `"name-symbolic"` string literal in `source`. Found by suffix rather
/// than by pairing quotes, so an escaped quote or a `'"'` elsewhere in the
/// file can't hide one.
fn symbolic_literals(source: &str) -> Vec<String> {
const SUFFIX: &str = "-symbolic\"";
let is_name = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
source
.match_indices(SUFFIX)
.filter_map(|(at, _)| {
let head = &source[..at];
let start = head.trim_end_matches(is_name).len();
(start > 0 && head[..start].ends_with('"'))
.then(|| format!("{}-symbolic", &head[start..]))
})
.collect()
}
/// The `pdfs-*` icons in `resources/icons`, by name.
fn bundled() -> BTreeSet<String> {
let mut names = BTreeSet::new();
let root = crate_dir().join("resources/icons/scalable");
for context in fs::read_dir(root).unwrap() {
for icon in fs::read_dir(context.unwrap().path()).unwrap() {
let path = icon.unwrap().path();
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
names.insert(name.to_string());
}
}
}
names
}
#[test]
fn every_symbolic_icon_exists_on_a_stock_desktop() {
let mut files = vec![crate_dir().join("src/prompt.rs")];
rust_files(&crate_dir().join("src/app"), &mut files);
let bundled = bundled();
let mut unknown = BTreeSet::new();
for file in files {
let source = fs::read_to_string(&file).unwrap();
for name in symbolic_literals(&source) {
if !bundled.contains(&name) && !STOCK.contains(&name.as_str()) {
let file = file
.strip_prefix(crate_dir())
.unwrap()
.display()
.to_string();
unknown.insert(format!("{name} ({file})"));
}
}
}
assert!(
unknown.is_empty(),
"icons that are neither bundled nor on the stock list:\n {}",
unknown.into_iter().collect::<Vec<_>>().join("\n ")
);
}
#[test]
fn every_bundled_icon_is_in_the_resource_manifest() {
let manifest = fs::read_to_string(crate_dir().join("resources/pdfs.gresource.xml")).unwrap();
let missing: Vec<String> = bundled()
.into_iter()
.filter(|name| name.starts_with("pdfs-") && !manifest.contains(&format!("/{name}.svg<")))
.collect();
assert!(missing.is_empty(), "not in pdfs.gresource.xml: {missing:?}");
}
+61
View File
@@ -12,6 +12,67 @@ Conventions:
---
## B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved"
**Status:** Fixed (unverified). No daemon carrying the change has been restarted against the
affected copies yet.
**Found:** 2026-09-27. The Activity page listed the same six conflict copies every day, e.g.
`Conflict [EMBER] Oshi no Ko S2 - 12 (sync-conflict 1786815864).mkv` with `Differs from … · 24 times
since Sep 20, 13:13 · Resolved`. `find` showed all six copies still on disk, in `~/Videos` and
`~/Documents`. Both are on-demand sync folders. `pdfs conflicts` answered `No conflicts.`. The
journal showed the daemon mounting at 17:29:33, and the entries were stamped 17:30. That is the
sweep's 30-second warmup.
**Where:** `crates/pdfs-fuse/src/sweep.rs` (`flag_conflict`, `sweep_conflicts_once`),
`crates/pdfs-fuse/src/conflicts.rs` (`list_conflicts`), `crates/pdfs-fuse/src/control.rs`
(`ResolveConflict`).
**Cause.** There were two faults.
- The sweep kept its "already flagged" set only in memory. Every daemon start forgot it, so the
first pass logged every open conflict again.
- The sweep flags copies anywhere in the account's own tree. `list_conflicts` only reported copies
below My Files, and resolving only accepted paths under the primary mount. A copy in a synced
folder was logged but never listed, so the GUI read it as resolved. It also could not be
resolved from the GUI or the CLI.
**Fix.** `Core::conflict_path` names a copy by its mountpoint-relative path under My Files, or
by its absolute local path inside a synced folder. It reuses the roots that search already
resolves hits through. The listing uses it, and the sweep flags only copies it can name. So the
feed never holds a conflict the listing cannot answer for. `ResolveConflict` routes an absolute
path to the on-demand mount that serves it. For a copy in a mirror folder, it says to edit the
local file instead. Before logging, `flag_conflict` checks the activity log
(`Db::activity_has`), so a restart does not log the same copy again. The copy's name carries its
own timestamp, so the name identifies the copy. No schema change.
Regression test: `activity_has_finds_only_matching_kind_and_target`.
---
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
superseded upload yet.
**Found:** 2026-09-23. The Sync page showed `Unconfirmed 654050.crdownload — 0 bytes of 23.8 MiB`
for hours after Brave had finished and renamed the download. `pdfs transfers` listed the same
entry, `pdfs diagnostics` showed every worker idle, and `/proc/<pid>/task` showed `pdfs-drain-1`
in state `R` at 100% CPU, holding a deleted staging blob open.
**Where:** `crates/pdfs-fuse/src/transfers.rs` (`CountingReader::read`).
**Cause.** A write that lands while a revision is on the wire cancels that upload through the
flag `CountingReader` checks before every read. The reader reported the cancel as
`ErrorKind::Interrupted`. Every read loop treats that kind as "try again", including the SDK's
`read_full_block`, and the flag never clears, so the loop never ended. The `TransferGuard` was
never dropped, so the transfer stayed in the registry, and the drain thread was lost until the
daemon restarted. A browser download is the natural trigger: the `.crdownload` file keeps growing,
so each new write supersedes the upload of the previous one.
**Fix.** The cancel is reported as `ErrorKind::Other`, which the SDK turns into an error.
`drain_revision` already recognises a cancelled upload and returns without counting a failure.
Regression test: `a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry`.
---
## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
+214
View File
@@ -11,6 +11,213 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
## [2.5.0] — 2026-09-28
A polish pass over the desktop app: the daemon pushes changes instead of being polled, pages
stay up while they reload, Trash and the shared pages use the same file list as My files, the
Sync page is split into views, the Computers page can browse another computer's backup, and
Photos gains a timeline scrubber.
Schema: **35**. The daemon notes when it first sees an item in the trash, since Proton does not
report the trash time to this client yet.
### Added
- **Browse other computers.** Opening another computer on the Computers page shows its backup
read-only, in the same list and grid as My files, and files open with a double-click.
- **Trash shows where items came from and when they were deleted.** The date is when this client
first saw the item in the trash until Proton reports the exact time. Restoring offers a Show
action, and opening a trashed item explains that it must be restored first.
- **Shared with me** shows who shared each item, with a warning for unverified senders, the
access it gives and when it was shared. Pending invitations wait in a banner, several items can
be left at once, and saved links have their own view.
- **Shared by me** gains a filter (links, people, expired links), columns for location, people
and link state, a copy button for live links, and stopping several shares at once.
- **Sidebar badges** count sync problems and waiting invitations.
### Changed
- **The app and the tray no longer poll.** The daemon pushes its status, its transfers and what
changed over one control connection (`Request::Subscribe`), and the window reloads the page on
screen when that page's data changes, including changes made with the `pdfs` CLI. A hidden
window keeps the changes and catches up when it is focused. Front-ends fall back to polling a
daemon that predates subscriptions until its service restarts.
- **Refresh buttons are gone** from the page headers now that pages follow the daemon. F5 and
Ctrl+R still reload the page from the server.
- **Photos timeline scrubber.** A strip along the right edge of the timeline shows the years
and appears when the pointer comes near or the timeline scrolls. Hovering names the month
under the pointer, and dragging jumps to it. It replaces the slider above the grid and the
scrollbar.
- **Upload is a split button** on the Photos page. Its menu holds "Import from Google Photos…",
and the page menu keeps "Find Duplicates…".
- **Quieter photo tiles.** Hovering enlarges the thumbnail slightly instead of lifting the tile
with a shadow, and tiles no longer show a tooltip. Tiles are reused while scrolling rather than
rebuilt for every row.
- **Newer libadwaita widgets where available.** On libadwaita 1.8 or newer the app uses its
spinner, toggle groups, button rows and shortcuts dialog, and on older releases it keeps
look-alike stand-ins. The choice is made at run time, so every package gets them and the app
still builds and runs on libadwaita 1.5. Set `PDFS_STAND_IN_WIDGETS=1` to see the stand-ins
on a newer system.
- **Sync page views.** Overview holds the status, conflicts, transfers and queue; Folders holds
the folder list, with a short explanation of the sync modes; History is the former Activity
page. A banner points back to Overview when changes fail or conflict. Each folder is one card
with a Synced / Online only switch, and switching either way asks first.
- **Activity reads better.** Temporary files that editors save through are hidden (a menu shows
them again), runs of similar events fold into one expandable row, entries are full translated
sentences, and "Show Older" reaches further back.
- **Computers** lists every computer in one group, this one first. "Use This Computer's
Identity" is now "Continue This Backup Here", with an explanation in its dialog, and "never
synced" reads "no backups yet".
- **Pages stay up while they reload.** A loading state shows only when a reply takes longer than
300 ms, and a refresh keeps the selection, scroll position and thumbnails.
- **One glossary.** "Proton Drive service" replaces "mount service" and "daemon", and "Online
only" replaces "On-demand"; dialogs use title case, and dates and sizes are formatted one way.
- **Bundled icons** replace glyphs that stock icon themes lack or draw ambiguously.
### Fixed
- **Enabling, disabling or restarting the service no longer freezes the window.**
- **A daemon that runs but doesn't answer** reads as "Not responding" instead of disconnected,
and requests to it give up after 5 s instead of 120 s.
- The Sync and share-link icons drew only half of themselves on recent GTK versions.
## [2.4.0] — 2026-09-28
Schema: **34**. Photos gain a `similar_hash` column for the similar-photo finder. It stays
empty until the finder is first opened, so updating reads and downloads nothing.
### Added
- **Similar photos.** The duplicate finder gains a Similar switch that also finds resized or
re-encoded copies and shots of one moment, by comparing the thumbnails. The first search
hashes every thumbnail the server has, with a progress bar; photos without one are not
downloaded and are counted as not compared. Every photo in a set starts ticked Keep, since
photos that look alike can be different shots, and a photo not kept goes to Trash with the
rest of its shot.
- **Photos on the map.** Every town is marked with one of its photos and a count, and nearby
towns merge into one marker until you zoom in. Zoomed in on a town, each photo gets its own
marker where it was taken; clicking one opens it in the viewer, and clicking photos taken
on one spot lists them all. "Show on map" in the photo viewer now goes to that spot on the
Places map instead of a web browser.
- **Street map for Places.** An opt-in setting under Appearance draws the map from
[OpenFreeMap](https://openfreemap.org/) (OpenStreetMap data), with streets, water, parks and
town names in your language. It is off by default because the tile server learns which parts
of the world you look at. Tiles are cached under the cache directory (at most 256 MB), and
the built-in outline shows wherever they haven't loaded.
- **Proton theme.** The "Proton purple accent" preference is now a whole Proton theme: the
Proton web apps' light and dark colours, not just the accent. It also holds when `gtk.css`
imports another GTK theme (Catppuccin, Fluent, ...), whose own colours used to show through
almost everywhere. Folders get Proton's purple folder icon rather than the icon theme's.
Anyone who had the accent turned on gets the theme.
- **Country borders on the Places map.** The offline map draws country borders, with sea and
land in their own colours.
## [2.3.0] — 2026-09-28
Schema: **33**. Photos gain their location; the first timeline refresh after updating reads
the metadata of the whole library once (nothing is downloaded), and the Places view shows how
far it has got.
### Added
- **Places.** A third Photos view groups photos by the town they were taken in, as cards or on a
world map. Towns are looked up offline in bundled GeoNames data (CC BY 4.0), and the map is
drawn offline from Natural Earth land outlines (public domain), so no map tiles are fetched.
Install `iso-codes`, a new optional dependency, for country names in your language.
- **On this day.** A strip at the top of the timeline shows a card for every earlier year with
photos taken on today's date.
- **Find duplicates.** Photos can list every set of byte-identical copies and move the copies
you don't keep to Trash. The copy in the most albums is kept by default.
- **Photos in no album.** A filter narrows the timeline to photos no album holds yet.
- **One Filters popover.** Favorites, "in no album" and the month jump now share a Filters
button, which shows how many filters are on and can clear them all at once.
- **Zoom-level grouping and range select.** Zooming out groups the timeline by month, then by
year. Shift+click selects a range, and dragging across the timeline selects what it sweeps.
- **Slideshow.** The photo viewer plays the loaded photos full screen, with F5 or a top-bar
button.
- **App icon in the app.** The sidebar header and the login page show the app's own icon.
### Fixed
- **Photos grid.** The timeline is laid out as square tiles that fill the window's width
exactly, and a theme's own `gtk.css` can no longer pad the tiles. It stays at its top, and at
the photo you were looking at, when the window is resized or zoomed.
- **Timeline shaking near the top.** Scrolling up to the "On this day" strip no longer makes the
whole page jump between two heights.
- **Filters while loading.** Changing a filter while a page was still loading could leave the
timeline showing the old filter.
## [2.2.2] — 2026-09-27
Schema: **32** (unchanged).
### Fixed
- **Conflicts in synced folders.** A conflict copy inside a synced folder is now listed under
Needs attention and can be resolved there or with `pdfs conflicts`. It used to be logged again
after every restart and marked "Resolved" while it was still on disk (B99).
## [2.2.1] — 2026-09-24
Schema: **32** (unchanged).
### Added
- **Legal information in About.** The About dialog now links to the source code and to Proton's
terms of service and privacy policy, and its Legal page carries the full disclaimer: this client
is unofficial, not affiliated with or endorsed by Proton AG, and comes without warranty.
## [2.2.0] — 2026-09-23
Schema: **32**. Videos that earlier got no thumbnail are tried again.
### Added
- **Video thumbnails.** Videos without a thumbnail from Drive now show a frame from the clip in
Photos. This needs `ffmpeg`, a new optional dependency.
- **Uploads carry a thumbnail.** Photos and videos uploaded from this client now include one, so
they show a preview in Proton's own apps as well, without downloading the whole file.
### Fixed
- **The Photos gallery no longer jumps** while thumbnails load, when zooming, or when switching
tabs and back.
- **The month scrubber no longer collides with the scrollbar.**
- **The photo details panel is no longer empty.**
- **Photo details read like a camera's:** "Google Pixel 6" instead of quoted EXIF values, shutter
speeds such as 1/19 s, and a labelled ISO.
## [2.1.1] — 2026-09-23
Schema: **31** (unchanged).
### Fixed
- **Uploads no longer get stuck at 0 bytes.** When a file changed again while its previous
version was uploading, such as a browser download that is still growing, the old upload could
hang forever. It kept one background thread busy at full CPU and showed as "Uploading" in the
Transfers list until the daemon restarted.
## [2.1.0] — 2026-09-23
The desktop app, the tray and the search prompt speak German, British English, Spanish, French,
Italian, Dutch, Polish and Brazilian Portuguese, picked from the system language or chosen in
Preferences.
Schema: **31** (unchanged).
### Added
- **Translations.** The desktop app, the tray and the search prompt are translated into German,
British English, Spanish, French, Italian, Dutch, Polish and Brazilian Portuguese, and follow the
system language. Counts use each language's plural rules, and dates use its own formats. The
desktop files carry translated descriptions and search keywords. The `pdfs` CLI and messages
from the daemon stay in English. [docs/TRANSLATING.md](TRANSLATING.md) describes how to improve a
translation or add a language. A few labels on the Photos page (the filter tabs, the gallery
subtitle and the heading shown when a filtered timeline is empty) are still in English.
- **Language preference.** Preferences → General → Appearance → Language overrides the system
language for the app, the tray and the prompt, from their next start.
### Changed
- **Packages install translations.** The Arch, AUR, Debian and Fedora packages install the compiled
catalogs under `/usr/share/locale`, and the release tarball carries them in `locale/`. Building
the source packages now needs `gettext`.
## [2.0.0] — 2026-09-23
A redesign of the desktop app: a new shell with a Preferences dialog, keyboard-driven context
menus, a docked details pane, a rewritten photo viewer with infinite scroll, album management,
stop sharing and link expiry, a redesigned Shared with Me page, per-device restore, a tray that
shows the sync state, and a new app icon.
Schema: **31** (unchanged).
### Added
@@ -970,6 +1177,13 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem.
[2.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.4.0
[2.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.3.0
[2.2.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
[1.11.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.0
[1.10.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.2
+91
View File
@@ -0,0 +1,91 @@
# Translating Proton Drive for Linux
The desktop app (`pdfs-app`), the tray (`pdfs-tray`) and the search prompt (`pdfs-prompt`) are
translated with GNU gettext in the `pdfs` text domain. The `pdfs` command-line tool and the daemon's
own messages stay in English, because scripts parse their output.
US English is the source language. The shipped catalogs are listed in [`po/LINGUAS`](../po/LINGUAS):
German, British English, Spanish, French, Italian, Dutch, Polish and Brazilian Portuguese.
## Picking a language
The apps follow the system locale (`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`). Preferences →
General → Appearance → **Language** overrides that for the app, the tray and the prompt. The choice is
stored as `language` in `config.json` and takes effect the next time they start.
## Layout
| Path | Purpose |
| --- | --- |
| `crates/pdfs-gui/src/i18n.rs` | Locale setup and the `gettext`, `gettext_f`, `ngettext_f`, `pgettext` and `gettext_noop` helpers |
| `po/POTFILES.in` | Sources that `xgettext` scans |
| `po/pdfs.pot` | The template, generated from the sources |
| `po/<lang>.po` | One catalog per language |
| `po/update.sh` | Regenerates the template and merges it into every catalog |
| `po/build.sh <localedir>` | Compiles the catalogs to `<localedir>/<lang>/LC_MESSAGES/pdfs.mo` |
## Updating an existing translation
1. Run `po/update.sh`. It regenerates `po/pdfs.pot` and merges new and changed strings into each
catalog, marking changed ones `fuzzy`.
2. Edit `po/<lang>.po` with a PO editor (Poedit, Lokalize, GNOME Translation Editor) or a text
editor. Translate the empty entries and review every `fuzzy` one, then remove its `fuzzy` flag.
3. Check the catalog with `msgfmt --check --statistics -o /dev/null po/<lang>.po`.
4. Try it from the source tree:
```bash
po/build.sh target/locale
PDFS_LOCALEDIR=target/locale LANGUAGE=<lang> cargo run -p pdfs-gui --bin pdfs-app
```
## Adding a language
1. Add the code (for example `sv` or `pt_PT`) to `po/LINGUAS`, keeping the list sorted.
2. Run `po/update.sh`. It creates `po/<lang>.po` from the template with `msginit`.
3. Fill in the `Plural-Forms` header if `msginit` did not, and translate the catalog.
4. Add the code and the language's own name to `LANGUAGES` in `crates/pdfs-gui/src/i18n.rs`, so the
Preferences picker offers it.
5. Add `GenericName[<lang>]`, `Comment[<lang>]` and `Keywords[<lang>]` lines to the two desktop files
in `packaging/`.
`cargo test -p pdfs-gui` checks that every language in the picker has a catalog, that
`po/POTFILES.in` lists every source file that calls gettext, and that every catalog passes
`msgfmt --check`.
## Rules for translators
- Keep every `{placeholder}` from the English text. You may move it, but not rename or drop it.
`msgfmt --check` rejects a catalog where they differ. Plural forms keep `{n}` in every form.
- Read the `#.` translator comments above an entry. They explain placeholders and date formats.
- Date entries such as `%b %e` are `strftime` formats for GLib's `DateTime::format`. Translate them
into the order and separators your language uses, not into literal dates.
- A `msgctxt` such as `verb` or `column` tells you which meaning of a short word is meant.
- Keep product names as they are: Proton Drive, Proton, Google Takeout.
- Use the same term for the same thing across the catalog, and follow the terminology of your
desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel.
- The English UI uses one term per concept. Pick one translation for each and keep it:
| Concept | English term |
|---|---|
| The root of the user's Drive | My files |
| The background service | Proton Drive service (never "daemon" or "mount") |
| A folder with a full copy on this computer | Synced folder |
| A folder whose files download when opened | Online-only folder |
| A single file kept on this computer | Available offline |
| A single file that is not on this computer | Online only |
| Where My files appears on disk | Proton Drive folder |
| Stop syncing a folder | Stop syncing |
| Two versions of one file | Conflict |
## Rules for developers
- Wrap every user-visible GUI string: `gettext("Move to Trash")`.
- Never build a sentence from translated pieces or with `format!`. Use named placeholders:
`gettext_f("Moved {name} to Trash", &[("name", &name)])`.
- Counts use `ngettext_f("{n} item", "{n} items", count, &[])`; `{n}` is filled in for you.
- Give short, ambiguous words a context: `pgettext("verb", "Open")`.
- Mark labels in `const` tables with `gettext_noop` and translate them where they are shown.
- The message id must be a single string literal on one line, since `xgettext` reads literals only.
- Add a `// Translators:` comment on the line above a call whose meaning is not obvious.
- Do not translate log messages, CLI output, action or icon names, or values the daemon sends back.
- Run `po/update.sh` after changing strings and commit the updated template and catalogs.
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more