Compare commits

...
14 Commits
Author SHA1 Message Date
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
52 changed files with 41675 additions and 3090 deletions

No files matched your search

Generated
+8 -8
View File
@@ -3449,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "2.2.2" version = "2.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3464,7 +3464,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "2.2.2" version = "2.3.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dbus-secret-service-keyring-store", "dbus-secret-service-keyring-store",
@@ -3488,7 +3488,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "2.2.2" version = "2.3.0"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"fuser", "fuser",
@@ -3508,7 +3508,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "2.2.2" version = "2.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3746,9 +3746,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.6.6" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757" checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3773,9 +3773,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.6.6" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce" checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"async-trait", "async-trait",
+3 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "2.2.2" version = "2.3.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.6.6" } proton-sdk = { version = "0.7.0" }
proton-drive-rs = { version = "0.6.6" } proton-drive-rs = { version = "0.7.0" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
+1
View File
@@ -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. - **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 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. - **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines (public domain), so no map tiles are fetched; install `iso-codes` for country names in your language.
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token. - **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. - **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. - **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
+1
View File
@@ -2074,6 +2074,7 @@ fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
kind: None, kind: None,
range: None, range: None,
favorites, favorites,
not_in_album: false,
})? { })? {
CtlResponse::Photos { CtlResponse::Photos {
available: false, .. available: false, ..
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
+130 -2
View File
@@ -67,6 +67,10 @@ pub enum Request {
/// get the whole timeline, as before. /// get the whole timeline, as before.
#[serde(default)] #[serde(default)]
favorites: bool, 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 /// 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 /// live photo and its clip. Replies with [`Response::Photos`], holding one
@@ -80,6 +84,25 @@ pub enum Request {
#[serde(default)] #[serde(default)]
kind: Option<PhotoKind>, kind: Option<PhotoKind>,
}, },
/// Photos taken on today's date in earlier years, newest first, at most
/// `limit` of them — the "On this day" strip above the timeline. Replies
/// with [`Response::Photos`]; a front-end groups them by year itself.
PhotosOnThisDay { limit: usize },
/// Sets of photos that are byte-identical copies of each other, going by
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
PhotoDuplicates,
/// The towns the photos were taken in, the one with the most photos first.
/// Only photos whose uploader recorded a location count. Replies with
/// [`Response::Places`].
PhotoPlaces,
/// Fetch a page of the photos taken in one town, newest first. `id` is the
/// town's id from [`Request::PhotoPlaces`]. Replies with
/// [`Response::Photos`], like an album page.
PlacePhotos {
id: u32,
offset: usize,
limit: usize,
},
/// List the account's photo albums, newest activity first, including the /// List the account's photo albums, newest activity first, including the
/// albums other people share with us. Metadata only — an album's cover /// albums other people share with us. Metadata only — an album's cover
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo. /// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
@@ -126,8 +149,14 @@ pub enum Request {
/// ///
/// A photo that belongs to a group takes the whole group with it: trashing /// 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 /// the JPEG of a shot and leaving its RAW behind is not what anyone means by
/// deleting that photo. /// deleting that photo. With `files_only` set, exactly the listed files
TrashNodes { uids: Vec<String> }, /// 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 /// Fetch thumbnails for ordinary Drive image files shown outside the Photos
/// timeline. The modification time is the cache validity tag, so replacing /// timeline. The modification time is the cache validity tag, so replacing
/// an image can never reuse its previous revision's thumbnail. /// an image can never reuse its previous revision's thumbnail.
@@ -1418,6 +1447,35 @@ pub struct AlbumInfo {
pub shared: bool, pub shared: bool,
} }
/// One town in a [`Response::Places`] listing.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PlaceInfo {
/// The town's GeoNames id — what [`Request::PlacePhotos`] takes.
pub id: u32,
/// The town's name, as the town itself spells it.
pub name: String,
/// ISO 3166-1 alpha-2 code of the town's country.
pub country: String,
/// Where the town is, in decimal degrees, for the map. Zero from a daemon
/// older than the map.
#[serde(default)]
pub latitude: f64,
#[serde(default)]
pub longitude: f64,
/// How many photos were taken there, counting a RAW+JPEG shot once.
pub photo_count: usize,
/// The newest photo taken there, shown as the place's cover.
pub cover: PhotoItem,
}
/// How many of the timeline's photos a running refresh has read the metadata
/// (and so the location) of, out of how many. Part of [`Response::Places`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MappingProgress {
pub done: usize,
pub total: usize,
}
/// One photo in a [`Request::PhotosTimeline`] page. /// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem { pub struct PhotoItem {
@@ -1776,6 +1834,17 @@ pub enum Response {
/// The months the timeline spans (reply to [`Request::PhotoMonths`]), /// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first. /// newest first.
PhotoMonths { months: Vec<PhotoMonth> }, PhotoMonths { months: Vec<PhotoMonth> },
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
/// first. Each set lists the copy worth keeping first.
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
/// and how far a running timeline refresh has got reading photo locations
/// — `None` when none is reading any.
Places {
items: Vec<PlaceInfo>,
#[serde(default)]
mapping: Option<MappingProgress>,
},
/// Thumbnails for a [`Request::PhotoThumbs`] batch. /// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> }, Thumbs { items: Vec<PhotoThumb> },
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid /// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
@@ -2172,7 +2241,17 @@ mod tests {
fn trash_requests_and_outcomes_roundtrip() { fn trash_requests_and_outcomes_roundtrip() {
let request = Request::TrashNodes { let request = Request::TrashNodes {
uids: vec!["volume~first".into(), "volume~second".into()], 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(); let line = serde_json::to_string(&request).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line"); assert!(!line.contains('\n'), "wire form must be a single line");
let decoded: Request = serde_json::from_str(&line).unwrap(); let decoded: Request = serde_json::from_str(&line).unwrap();
@@ -2459,6 +2538,54 @@ mod tests {
} }
} }
/// A place listing and a place page cross the wire intact: a mangled id
/// would open the wrong town.
#[test]
fn places_requests_and_reply_roundtrip() {
for req in [
Request::PhotoPlaces,
Request::PlacePhotos {
id: 2950159,
offset: 60,
limit: 30,
},
] {
let line = serde_json::to_string(&req).unwrap();
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2,
"cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#;
let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else {
panic!("not a place listing");
};
assert_eq!(items.len(), 1);
assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE"));
assert_eq!(items[0].cover.uid, "vol~b2");
// A daemon from before the progress field still parses: no mapping.
assert_eq!(mapping, None);
let reply = Response::Places {
items: Vec::new(),
mapping: Some(MappingProgress {
done: 120,
total: 480,
}),
};
let line = serde_json::to_string(&reply).unwrap();
let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else {
panic!("not a place listing");
};
assert_eq!(
mapping,
Some(MappingProgress {
done: 120,
total: 480
})
);
}
/// The sharing and devices requests must survive the same line-delimited JSON /// 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. /// round-trip: a mangled id or role would touch the wrong share or member.
#[test] #[test]
@@ -2790,6 +2917,7 @@ mod tests {
decoded, decoded,
Request::PhotosTimeline { Request::PhotosTimeline {
favorites: false, favorites: false,
not_in_album: false,
kind: None, kind: None,
range: None, range: None,
.. ..
+34 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 32; pub(super) const SCHEMA_VERSION: i64 = 33;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -284,6 +284,22 @@ impl Db {
} }
} }
} }
if current < 33 {
// Same guards as V26-V31.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V33)?;
}
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -973,3 +989,20 @@ UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
/// decoder, which could only fail, and that failure was recorded as the /// decoder, which could only fail, and that failure was recorded as the
/// permanent "no thumbnail" verdict. Run once per table in `{table}`. /// 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"; 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;
";
+1 -1
View File
@@ -59,7 +59,7 @@ pub use ops::{
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, 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, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
}; };
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow}; pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow; pub use pins::PinRow;
pub use sync::{StoredSyncEntry, StoredSyncFolder}; pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash; pub use trash::StoredTrash;
+176 -5
View File
@@ -3,7 +3,7 @@
use rusqlite::params; use rusqlite::params;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use super::Db; use super::Db;
use crate::Result; use crate::Result;
@@ -42,6 +42,15 @@ pub struct StoredPhoto {
pub has_raw: bool, pub has_raw: bool,
} }
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
/// many photos it holds, and the newest of them.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPlace {
pub id: u32,
pub count: usize,
pub cover: StoredPhoto,
}
/// One row of a timeline replacement. Everything but the uid and the capture /// One row of a timeline replacement. Everything but the uid and the capture
/// time is "what this refresh learned": a `None` keeps whatever is already /// time is "what this refresh learned": a `None` keeps whatever is already
/// stored rather than clearing it, because the timeline DTO carries only the uid /// stored rather than clearing it, because the timeline DTO carries only the uid
@@ -58,6 +67,8 @@ pub struct TimelineRow {
/// The main photo this one is *related* to (a live-photo video, a burst /// The main photo this one is *related* to (a live-photo video, a burst
/// sibling), when the server says it belongs to one. /// sibling), when the server says it belongs to one.
pub main_uid: Option<String>, pub main_uid: Option<String>,
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
pub location: Option<(f64, f64)>,
/// When this refresh resolved the photo's node, if it did at all. `Some` /// When this refresh resolved the photo's node, if it did at all. `Some`
/// makes every other field authoritative — including a `None` that means /// makes every other field authoritative — including a `None` that means
/// "the server no longer reports one" — while `None` means the refresh /// "the server no longer reports one" — while `None` means the refresh
@@ -86,6 +97,7 @@ struct Learned {
favorite: bool, favorite: bool,
content_hash: Option<String>, content_hash: Option<String>,
main_uid: Option<String>, main_uid: Option<String>,
location: Option<(f64, f64)>,
/// When this photo's node was last resolved, kept so a refresh that skips it /// When this photo's node was last resolved, kept so a refresh that skips it
/// does not make it look unresolved again. /// does not make it look unresolved again.
resolved_at: Option<i64>, resolved_at: Option<i64>,
@@ -281,7 +293,7 @@ impl Db {
let learned: HashMap<String, Learned> = { let learned: HashMap<String, Learned> = {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \ "SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
main_uid, resolved_at FROM photos", main_uid, resolved_at, latitude, longitude FROM photos",
)?; )?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map([], |r| {
Ok(( Ok((
@@ -295,6 +307,9 @@ impl Db {
content_hash: r.get(6)?, content_hash: r.get(6)?,
main_uid: r.get(7)?, main_uid: r.get(7)?,
resolved_at: r.get(8)?, resolved_at: r.get(8)?,
location: r
.get::<_, Option<f64>>(9)?
.zip(r.get::<_, Option<f64>>(10)?),
}, },
)) ))
})?; })?;
@@ -319,12 +334,14 @@ impl Db {
carried.favorite = row.favorite.unwrap_or(false); carried.favorite = row.favorite.unwrap_or(false);
carried.content_hash = row.content_hash.clone(); carried.content_hash = row.content_hash.clone();
carried.main_uid = row.main_uid.clone(); carried.main_uid = row.main_uid.clone();
carried.location = row.location;
} else { } else {
carried.name = row.name.clone().or(carried.name); carried.name = row.name.clone().or(carried.name);
carried.media_type = row.media_type.clone().or(carried.media_type); carried.media_type = row.media_type.clone().or(carried.media_type);
carried.favorite = row.favorite.unwrap_or(carried.favorite); carried.favorite = row.favorite.unwrap_or(carried.favorite);
carried.content_hash = row.content_hash.clone().or(carried.content_hash); carried.content_hash = row.content_hash.clone().or(carried.content_hash);
carried.main_uid = row.main_uid.clone().or(carried.main_uid); carried.main_uid = row.main_uid.clone().or(carried.main_uid);
carried.location = row.location.or(carried.location);
} }
carried.resolved_at = row.resolved_at.or(carried.resolved_at); carried.resolved_at = row.resolved_at.or(carried.resolved_at);
// The tab this photo lands in is derived here, once, so a page or // The tab this photo lands in is derived here, once, so a page or
@@ -355,8 +372,10 @@ impl Db {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT INTO photos "INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite, (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
content_hash, main_uid, group_key, resolved_at) content_hash, main_uid, group_key, resolved_at, latitude, longitude,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", place_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
?16)",
)?; )?;
for (seq, ((row, carried, kind), group_key)) in for (seq, ((row, carried, kind), group_key)) in
resolved.iter().zip(group_keys.iter()).enumerate() resolved.iter().zip(group_keys.iter()).enumerate()
@@ -375,6 +394,12 @@ impl Db {
carried.main_uid, carried.main_uid,
group_key, group_key,
carried.resolved_at, carried.resolved_at,
carried.location.map(|(lat, _)| lat),
carried.location.map(|(_, lon)| lon),
carried
.location
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
.map(|city| city.id),
])?; ])?;
} }
} }
@@ -447,6 +472,14 @@ impl Db {
albums.execute([uid])?; albums.execute([uid])?;
} }
} }
// A group whose representative went is left pointing at nothing, which
// drops its other files off every page. Make them stand alone until the
// next refresh groups them again.
tx.execute(
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
AND group_key NOT IN (SELECT uid FROM photos)",
[],
)?;
tx.commit()?; tx.commit()?;
Ok(removed) Ok(removed)
} }
@@ -455,7 +488,8 @@ impl Db {
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set, /// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the /// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave. /// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to 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 /// The page holds one row per *group*: the RAW and the JPEG of one shot are
/// one tile. The Raw tab is the exception and lists every raw file, because /// one tile. The Raw tab is the exception and lists every raw file, because
@@ -467,6 +501,7 @@ impl Db {
kind: Option<crate::control::PhotoKind>, kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>, range: Option<(i64, i64)>,
favorites: bool, favorites: bool,
not_in_album: bool,
) -> Result<Vec<StoredPhoto>> { ) -> Result<Vec<StoredPhoto>> {
let conn = self.read(); let conn = self.read();
// Built up so any combination of the optional filters is one indexed // Built up so any combination of the optional filters is one indexed
@@ -490,6 +525,15 @@ impl Db {
if favorites { if favorites {
conds.push("p.favorite = 1".to_string()); conds.push("p.favorite = 1".to_string());
} }
// By group, not by file: a shot filed as its JPEG is filed, even though
// its RAW was never added anywhere.
if not_in_album {
conds.push(
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
.to_string(),
);
}
if !conds.is_empty() { if !conds.is_empty() {
sql.push_str(" WHERE "); sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND ")); sql.push_str(&conds.join(" AND "));
@@ -577,6 +621,133 @@ impl Db {
Ok(rows) Ok(rows)
} }
/// Photos taken on `today`'s calendar day in earlier years, newest first —
/// the "On this day" strip. `today` is epoch seconds: its local month and day
/// pick the date, and its own year is left out, because today's photos are
/// what the timeline already opens on. One row per group, like the timeline.
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
ORDER BY p.seq LIMIT ?2"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![today, limit as i64], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Every town photos were taken in, with how many and the newest one as its
/// cover, most photographed first. Counted per group, like the timeline;
/// photos without a location, or taken far from any town, are in none.
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
let rows: Vec<(u32, usize, String)> = {
let conn = self.read();
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
// is the newest photo of the place.
let mut stmt = conn.prepare(
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
)?;
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
})?
.collect::<rusqlite::Result<_>>()?
};
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
let mut covers: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
Ok(rows
.into_iter()
.filter_map(|(id, count, uid)| {
Some(StoredPlace {
id,
count,
cover: covers.remove(&uid)?,
})
})
.collect())
}
/// One page of the photos taken in a town, newest first, one per group.
pub fn photos_at_place(
&self,
id: u32,
offset: usize,
limit: usize,
) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Sets of photos with the same content hash, each set ordered with the
/// copy worth keeping first, and the sets newest first.
///
/// The copy to keep is the one in the most albums, then the one that is part
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
/// not), then the earliest in server order. At most one file per group is
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
/// set left with one member is no set.
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
let rows: Vec<(String, String, String)> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
WHERE p.content_hash IN (SELECT content_hash FROM photos \
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
ORDER BY p.content_hash, \
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
p.seq",
)?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?
};
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
let mut stored: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
let mut hash_of_set: Option<&str> = None;
let mut groups_in_set: HashSet<&str> = HashSet::new();
for (uid, hash, group) in &rows {
if hash_of_set != Some(hash.as_str()) {
hash_of_set = Some(hash);
groups_in_set.clear();
sets.push(Vec::new());
}
if !groups_in_set.insert(group) {
continue;
}
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
set.push(photo);
}
}
sets.retain(|set| set.len() > 1);
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
Ok(sets)
}
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`. /// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> { pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind; use crate::control::PhotoKind;
+241 -17
View File
@@ -140,7 +140,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"], ["p0", "p1", "p2"],
@@ -163,7 +163,14 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// The counts break down by tab, and a filtered page returns only its tab. // The counts break down by tab, and a filtered page returns only its tab.
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0)); assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
let videos = db let videos = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false) .photos_page(
0,
10,
Some(crate::control::PhotoKind::Video),
None,
false,
false,
)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -176,7 +183,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// A date-range page keeps only the window's photos: [150, 350) is p1+p2, // A date-range page keeps only the window's photos: [150, 350) is p1+p2,
// not p0 at 400. Combined with a kind filter both conditions apply. // not p0 at 400. Combined with a kind filter both conditions apply.
let ranged = db let ranged = db
.photos_page(0, 10, None, Some((150, 350)), false) .photos_page(0, 10, None, Some((150, 350)), false, false)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -189,6 +196,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
Some(crate::control::PhotoKind::Video), Some(crate::control::PhotoKind::Video),
Some((150, 350)), Some((150, 350)),
false, false,
false,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -218,7 +226,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
}, },
]) ])
.unwrap(); .unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap(); let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
assert_eq!( assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"], ["p1"],
@@ -236,7 +244,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
TimelineRow::new("p2", 200), TimelineRow::new("p2", 200),
]) ])
.unwrap(); .unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap(); let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
assert_eq!( assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2"], ["p2"],
@@ -245,6 +253,211 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite); assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
} }
/// The strip shows the same calendar day in earlier years only: not today's
/// own photos, not the day before, and one tile per RAW+JPEG shot.
#[test]
fn on_this_day_finds_the_same_date_in_earlier_years() {
const DAY: i64 = 86_400;
// Noon UTC, so the local date is the same in every common time zone.
let today = 1_781_524_800; // 2026-06-15 12:00 UTC
let year_ago = today - 365 * DAY; // 2025-06-15
let three_years_ago = today - (365 * 3 + 1) * DAY; // 2023-06-15, past a 29 February
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow::new("today", today),
TimelineRow {
name: Some("IMG_1.JPG".into()),
..TimelineRow::new("jpeg", year_ago)
},
TimelineRow {
name: Some("IMG_1.CR2".into()),
..TimelineRow::new("raw", year_ago)
},
TimelineRow::new("day-before", year_ago - DAY),
TimelineRow::new("older", three_years_ago),
])
.unwrap();
let found = db.photos_on_this_day(today, 10).unwrap();
assert_eq!(
found.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "older"]
);
}
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
TimelineRow {
name: Some(name.into()),
content_hash: Some(hash.into()),
..TimelineRow::new(uid, capture_time)
}
}
fn located(uid: &str, capture_time: i64, name: &str, at: (f64, f64)) -> TimelineRow {
TimelineRow {
name: Some(name.into()),
location: Some(at),
resolved_at: Some(1),
..TimelineRow::new(uid, capture_time)
}
}
const BERLIN: (f64, f64) = (52.52, 13.405);
const PARIS: (f64, f64) = (48.857, 2.352);
/// Places count shots, not files, lead with the most photographed town, and
/// leave out photos with no location or taken far from any town.
#[test]
fn places_group_photos_by_the_nearest_town() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
located("b2", 500, "IMG_5.JPG", BERLIN),
located("p1", 400, "IMG_4.JPG", PARIS),
located("b1-jpeg", 300, "IMG_3.JPG", BERLIN),
located("b1-raw", 300, "IMG_3.CR2", BERLIN),
located("sea", 200, "IMG_2.JPG", (0.0, -30.0)),
TimelineRow::new("nowhere", 100),
])
.unwrap();
let places = db.photo_places().unwrap();
let summary: Vec<(&str, usize, &str)> = places
.iter()
.map(|place| {
let city = crate::places::city(place.id).expect("a known town");
(city.name, place.count, place.cover.uid.as_str())
})
.collect();
assert_eq!(summary, [("Berlin", 2, "b2"), ("Paris", 1, "p1")]);
let berlin: Vec<String> = db
.photos_at_place(places[0].id, 0, 10)
.unwrap()
.into_iter()
.map(|p| p.uid)
.collect();
assert_eq!(berlin, ["b2", "b1-jpeg"]);
}
/// A refresh that skips an already-resolved photo keeps where it was taken; one
/// that reads the node again takes what the node now says.
#[test]
fn a_skipped_refresh_keeps_a_photos_location() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[located("a", 100, "IMG_1.JPG", BERLIN)])
.unwrap();
db.photos_replace(&[TimelineRow::new("a", 100)]).unwrap();
assert_eq!(db.photo_places().unwrap().len(), 1);
db.photos_replace(&[TimelineRow {
resolved_at: Some(2),
..TimelineRow::new("a", 100)
}])
.unwrap();
assert!(db.photo_places().unwrap().is_empty());
}
/// A copy an album holds is the one kept; the files of one RAW+JPEG shot are
/// never copies of each other; a hash only one file carries is no set.
#[test]
fn duplicates_list_the_copy_to_keep_first() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
hashed("copy", 500, "IMG_2.JPG", "h2"),
hashed("filed", 400, "IMG_2(1).JPG", "h2"),
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
hashed("raw", 300, "IMG_1.CR2", "h1"),
hashed("stray", 200, "IMG_1(1).CR2", "h1"),
hashed("alone", 100, "IMG_3.JPG", "h3"),
])
.unwrap();
db.album_photos_replace("a1", &[("filed".into(), 400, None, None)])
.unwrap();
let sets = db.photo_duplicates().unwrap();
let uids: Vec<Vec<&str>> = sets
.iter()
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
.collect();
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
}
/// Trashing the file a group is shown as leaves the rest of the group on the
/// timeline instead of pointing at a photo that is gone.
#[test]
fn deleting_a_group_representative_keeps_its_other_files() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
hashed("raw", 300, "IMG_1.CR2", "h2"),
])
.unwrap();
assert_eq!(
db.photos_page(0, 10, None, None, false, false)
.unwrap()
.len(),
1
);
db.photos_delete(&["jpeg".into()]).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["raw"]
);
}
#[test]
fn not_in_album_hides_photos_any_album_holds() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow::new("p1", 300),
TimelineRow::new("p2", 200),
TimelineRow::new("p3", 100),
])
.unwrap();
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
.unwrap();
db.album_photos_replace("a2", &[("p3".into(), 100, None, None)])
.unwrap();
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2"]
);
}
/// Filing the JPEG of a RAW+JPEG shot files the shot: the grid shows it as
/// one tile, and that tile is in an album.
#[test]
fn not_in_album_counts_a_raw_pair_as_filed() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("IMG_1234.JPG".into()),
..TimelineRow::new("jpeg", 300)
},
TimelineRow {
name: Some("img_1234.cr2".into()),
..TimelineRow::new("raw", 300)
},
TimelineRow {
name: Some("IMG_9999.JPG".into()),
..TimelineRow::new("alone", 200)
},
])
.unwrap();
db.album_photos_replace("a1", &[("raw".into(), 300, None, None)])
.unwrap();
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["alone"]
);
}
#[test] #[test]
fn photos_page_slices_the_timeline_in_order() { fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
@@ -253,12 +466,16 @@ fn photos_page_slices_the_timeline_in_order() {
.collect(); .collect();
db.photos_replace(&items).unwrap(); db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2, None, None, false).unwrap(); let page = db.photos_page(2, 2, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"] ["p2", "p3"]
); );
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty()); assert!(
db.photos_page(9, 2, None, None, false, false)
.unwrap()
.is_empty()
);
} }
#[test] #[test]
@@ -4077,7 +4294,7 @@ fn photos_delete_removes_the_photo_and_its_album_membership() {
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1); assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1", "p3"], ["p1", "p3"],
@@ -4117,7 +4334,7 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "alone"], ["jpeg", "alone"],
@@ -4130,7 +4347,14 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
// The Raw tab answers "which raws do I have", so it lists files. // The Raw tab answers "which raws do I have", so it lists files.
let raws = db let raws = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false) .photos_page(
0,
10,
Some(crate::control::PhotoKind::Raw),
None,
false,
false,
)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -4175,7 +4399,7 @@ fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["cover", "notes", "other"], ["cover", "notes", "other"],
@@ -4214,7 +4438,7 @@ fn a_shared_name_alone_does_not_group_photos() {
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 3, "three shots, three tiles"); assert_eq!(page.len(), 3, "three shots, three tiles");
assert!(page.iter().all(|p| p.group_size == 1)); assert!(page.iter().all(|p| p.group_size == 1));
} }
@@ -4237,7 +4461,7 @@ fn the_servers_photo_relation_groups_a_live_photo() {
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["main"] ["main"]
@@ -4339,7 +4563,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
} }
let db = Db::open(&path).unwrap(); let db = Db::open(&path).unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!( assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "raw"], ["jpeg", "raw"],
@@ -4360,7 +4584,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
}, },
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 1); assert_eq!(page.len(), 1);
assert_eq!(page[0].uid, "jpeg"); assert_eq!(page[0].uid, "jpeg");
@@ -4389,14 +4613,14 @@ fn photos_replace_keeps_a_skipped_photos_name() {
}, },
]) ])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 1, "one shot, two files"); assert_eq!(page.len(), 1, "one shot, two files");
assert!(page[0].has_raw); assert!(page[0].has_raw);
// The next refresh resolves neither, because both are already resolved. // The next refresh resolves neither, because both are already resolved.
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)]) db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
.unwrap(); .unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap(); let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(page.len(), 1, "the group survives a skipped refresh"); assert_eq!(page.len(), 1, "the group survives a skipped refresh");
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG")); assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
assert!(page[0].has_raw, "the raw file is still a raw file"); assert!(page[0].has_raw, "the raw file is still a raw file");
+1
View File
@@ -14,6 +14,7 @@ pub mod localindex;
pub mod menu; pub mod menu;
pub mod mounts; pub mod mounts;
pub mod opener; pub mod opener;
pub mod places;
pub mod profile; pub mod profile;
pub mod sdkcache; pub mod sdkcache;
pub mod search; pub mod search;
+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}"
);
}
}
+34
View File
@@ -175,6 +175,40 @@ impl Core {
Ok(page.into_iter().map(|p| self.photo_item(p)).collect()) Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
} }
/// Make sure every album of ours has its contents on disk, for a filter
/// that asks which photos no album holds. Contents are otherwise fetched
/// the first time an album is opened, so an album never opened would read
/// as empty and its photos as unfiled.
///
/// Shared-with-me albums are skipped: their photos live on the sharer's
/// volume and are never in our timeline. The freshness stamps are no guide
/// here — any album change clears all of them — so only an album whose
/// server count disagrees with an empty local copy is waited for.
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
if self.db.albums_count().map_err(CoreError::from)? == 0
&& !self.rt.block_on(self.refresh_albums())?
{
return Ok(());
}
let albums = self.db.albums_list().map_err(CoreError::from)?;
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
let stored = self
.db
.album_photos_count(&album.uid)
.map_err(CoreError::from)?;
if stored > 0 {
continue;
}
let Some(uid) = parse_uid(&album.uid) else {
continue;
};
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
warn!(album = %album.uid, %error, "fetching an album's contents failed");
}
}
Ok(())
}
/// Re-enumerate one album's photos and replace its persisted contents. /// Re-enumerate one album's photos and replace its persisted contents.
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> { pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
let photos = self.photos(); let photos = self.photos();
+42 -3
View File
@@ -440,7 +440,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
kind, kind,
range, range,
favorites, favorites,
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) { not_in_album,
}) => match core.photos_timeline(
offset,
clamp_limit(limit),
kind,
range,
favorites,
not_in_album,
) {
Ok(Some(items)) => CtlResponse::Photos { Ok(Some(items)) => CtlResponse::Photos {
available: true, available: true,
items, items,
@@ -453,6 +461,37 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
Ok(items) => CtlResponse::Places {
items,
mapping: core.timeline_progress(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PlacePhotos { id, offset, limit }) => {
match core.place_photos(id, offset, clamp_limit(limit)) {
Ok(items) => CtlResponse::Photos {
available: true,
items,
counts: None,
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
match core.photos_on_this_day(clamp_limit(limit)) {
Ok(items) => CtlResponse::Photos {
available: true,
items,
counts: None,
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) { Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
Ok(items) => CtlResponse::Photos { Ok(items) => CtlResponse::Photos {
available: true, available: true,
@@ -546,8 +585,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed), items: core.photo_thumbs(&parsed),
} }
} }
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) { Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed) { Ok(parsed) => match core.trash_photos(&parsed, files_only) {
Ok((trashed, failed)) => CtlResponse::Trashed { Ok((trashed, failed)) => CtlResponse::Trashed {
trashed, trashed,
failed: failed failed: failed
+4
View File
@@ -550,6 +550,10 @@ struct Core {
/// already running, so a burst of page requests against a stale listing kicks /// already running, so a burst of page requests against a stale listing kicks
/// off one refresh rather than one per request. /// off one refresh rather than one per request.
timeline_refreshing: Arc<AtomicBool>, timeline_refreshing: Arc<AtomicBool>,
/// How far the running timeline refresh has got resolving photo metadata,
/// as `(done, total)` photos; `None` when no refresh is resolving any. The
/// Places view shows it while a library's locations are first read.
timeline_progress: Arc<Mutex<Option<(usize, usize)>>>,
/// The same, for the album listing. /// The same, for the album listing.
albums_refreshing: Arc<AtomicBool>, albums_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>, trash_refreshing: Arc<AtomicBool>,
+1
View File
@@ -391,6 +391,7 @@ pub fn mount(
upload_times: Arc::new(Mutex::new(HashMap::new())), upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())), upload_cancel: Arc::new(Mutex::new(HashMap::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)), timeline_refreshing: Arc::new(AtomicBool::new(false)),
timeline_progress: Arc::new(Mutex::new(None)),
albums_refreshing: Arc::new(AtomicBool::new(false)), albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)), trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()), trash_progress: Arc::new(tokio::sync::Notify::new()),
+122 -7
View File
@@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
use base64::Engine as _; use base64::Engine as _;
use pdfs_core::control::{ use pdfs_core::control::{
FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, ThumbnailBuildStatus, is_raw_image_name, FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoThumb, PlaceInfo,
is_thumbnail_image_name, ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name,
}; };
use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
@@ -601,6 +601,16 @@ fn extract_raw_preview(
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>); pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
/// Formats supported by the local thumbnail decoder and exposed by the GUI. /// Formats supported by the local thumbnail decoder and exposed by the GUI.
/// Clears the timeline refresh's progress when dropped, so an error or a
/// cancelled refresh doesn't leave a bar standing.
struct ProgressGuard<'a>(&'a parking_lot::Mutex<Option<(usize, usize)>>);
impl Drop for ProgressGuard<'_> {
fn drop(&mut self) {
*self.0.lock() = None;
}
}
/// What a timeline enrichment pass resolves for one photo, beyond what the /// What a timeline enrichment pass resolves for one photo, beyond what the
/// timeline listing already carries. /// timeline listing already carries.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -609,6 +619,8 @@ struct PhotoMeta {
media_type: Option<String>, media_type: Option<String>,
favorite: bool, favorite: bool,
content_hash: Option<String>, content_hash: Option<String>,
/// Where the photo was taken, as its uploader recorded it.
location: Option<(f64, f64)>,
} }
impl Core { impl Core {
@@ -637,6 +649,7 @@ impl Core {
kind: Option<PhotoKind>, kind: Option<PhotoKind>,
range: Option<(i64, i64)>, range: Option<(i64, i64)>,
favorites: bool, favorites: bool,
not_in_album: bool,
) -> CoreResult<Option<Vec<PhotoItem>>> { ) -> CoreResult<Option<Vec<PhotoItem>>> {
let count = self.db.photos_count().map_err(CoreError::from)?; let count = self.db.photos_count().map_err(CoreError::from)?;
if count == 0 { if count == 0 {
@@ -653,10 +666,16 @@ impl Core {
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) { } else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_timeline_refresh(); self.spawn_timeline_refresh();
} }
// The filter is only as good as the album contents on disk, and those
// are fetched lazily, per album opened. Fill in the missing ones before
// the first page; later pages reuse what that fetch stored.
if not_in_album && offset == 0 {
self.ensure_album_contents()?;
}
let page = self let page = self
.db .db
.photos_page(offset, limit, kind, range, favorites) .photos_page(offset, limit, kind, range, favorites, not_in_album)
.map_err(CoreError::from)?; .map_err(CoreError::from)?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect())) Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
} }
@@ -722,6 +741,66 @@ impl Core {
Ok(()) Ok(())
} }
/// The timeline's duplicate sets, as wire items (see
/// [`db::Db::photo_duplicates`]).
pub(crate) fn photo_duplicates(&self) -> CoreResult<Vec<Vec<PhotoItem>>> {
let sets = self.db.photo_duplicates().map_err(CoreError::from)?;
Ok(sets
.into_iter()
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
.collect())
}
/// The towns the photos were taken in, as wire items. A town the bundled
/// table no longer knows (the stored id came from an older table) is left
/// out rather than shown without a name.
pub(crate) fn photo_places(&self) -> CoreResult<Vec<PlaceInfo>> {
let places = self.db.photo_places().map_err(CoreError::from)?;
Ok(places
.into_iter()
.filter_map(|place| {
let city = pdfs_core::places::city(place.id)?;
Some(PlaceInfo {
id: place.id,
name: city.name.to_string(),
country: city.country.to_string(),
latitude: city.latitude,
longitude: city.longitude,
photo_count: place.count,
cover: self.photo_item(place.cover),
})
})
.collect())
}
/// A page of the photos taken in town `id`, as wire items.
pub(crate) fn place_photos(
&self,
id: u32,
offset: usize,
limit: usize,
) -> CoreResult<Vec<PhotoItem>> {
let photos = self
.db
.photos_at_place(id, offset, limit)
.map_err(CoreError::from)?;
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
}
/// Photos taken on today's date in earlier years, as wire items. Served
/// from the stored timeline only: the strip is a nicety, not worth a wait
/// on the network, and the timeline's own load keeps that store fresh.
pub(crate) fn photos_on_this_day(&self, limit: usize) -> CoreResult<Vec<PhotoItem>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let photos = self
.db
.photos_on_this_day(now, limit)
.map_err(CoreError::from)?;
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
}
/// Every file of one photo's group, in server order, as wire items. A photo /// Every file of one photo's group, in server order, as wire items. A photo
/// the timeline does not hold comes back empty, which the lightbox reads as /// the timeline does not hold comes back empty, which the lightbox reads as
/// "nothing to switch between". /// "nothing to switch between".
@@ -801,12 +880,23 @@ impl Core {
/// A photo that is one file of a group takes its whole group with it. The /// A photo that is one file of a group takes its whole group with it. The
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to /// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
/// delete the shot — leaving the RAW behind would put the photo back on the /// delete the shot — leaving the RAW behind would put the photo back on the
/// Raw tab and nowhere else. /// Raw tab and nowhere else. `files_only` turns that off, for removing
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> { /// one copy of a shot that is stored twice.
pub(crate) fn trash_photos(
&self,
uids: &[NodeUid],
files_only: bool,
) -> CoreResult<TrashOutcome> {
if uids.is_empty() { if uids.is_empty() {
return Ok((Vec::new(), Vec::new())); return Ok((Vec::new(), Vec::new()));
} }
let uids = &self.expand_photo_groups(uids); let expanded;
let uids = if files_only {
uids
} else {
expanded = self.expand_photo_groups(uids);
&expanded
};
for uid in uids { for uid in uids {
self.require_photo_writable(uid) self.require_photo_writable(uid)
.map_err(|errno| self.errno_error(errno, "trash access"))?; .map_err(|errno| self.errno_error(errno, "trash access"))?;
@@ -1690,7 +1780,14 @@ impl Core {
// can land in different chunks, and one of them may not resolve at all. // can land in different chunks, and one of them may not resolve at all.
let mut main_of: HashMap<String, String> = HashMap::new(); let mut main_of: HashMap<String, String> = HashMap::new();
let mut stopped = false; let mut stopped = false;
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) { // Photos read before count as done, so the bar picks up where an
// interrupted pass left off. Cleared however the pass ends.
let skipped = items.len() - uids.len();
let progress = ProgressGuard(&self.timeline_progress);
if !uids.is_empty() {
*progress.0.lock() = Some((skipped, items.len()));
}
for (index, chunk) in uids.chunks(TIMELINE_ENRICH_CHUNK).enumerate() {
// Teardown cancels the runtime under this loop, and every chunk // Teardown cancels the runtime under this loop, and every chunk
// still in flight then fails its decrypt task — a burst of // still in flight then fails its decrypt task — a burst of
// "skipping undecryptable photo ... task was cancelled" warnings // "skipping undecryptable photo ... task was cancelled" warnings
@@ -1717,6 +1814,11 @@ impl Core {
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite)); .is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
let key = node.uid.to_string(); let key = node.uid.to_string();
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone()); let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
let location = node
.photo
.as_ref()
.and_then(|p| p.location)
.map(|l| (l.latitude, l.longitude));
if let Some(photo) = node.photo.as_ref() { if let Some(photo) = node.photo.as_ref() {
if let Some(main) = photo.main_photo_uid.as_ref() { if let Some(main) = photo.main_photo_uid.as_ref() {
main_of.insert(key.clone(), main.to_string()); main_of.insert(key.clone(), main.to_string());
@@ -1732,13 +1834,17 @@ impl Core {
media_type, media_type,
favorite, favorite,
content_hash, content_hash,
location,
}, },
); );
} }
} }
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"), Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
} }
let done = (skipped + (index + 1) * TIMELINE_ENRICH_CHUNK).min(items.len());
*progress.0.lock() = Some((done, items.len()));
} }
drop(progress);
let rows: Vec<db::TimelineRow> = items let rows: Vec<db::TimelineRow> = items
.iter() .iter()
@@ -1753,6 +1859,7 @@ impl Core {
media_type: m.media_type, media_type: m.media_type,
favorite: Some(m.favorite), favorite: Some(m.favorite),
content_hash: m.content_hash, content_hash: m.content_hash,
location: m.location,
main_uid, main_uid,
resolved_at: Some(resolved_at), resolved_at: Some(resolved_at),
..db::TimelineRow::new(key, it.capture_time) ..db::TimelineRow::new(key, it.capture_time)
@@ -1799,6 +1906,14 @@ impl Core {
}); });
} }
/// How far the running timeline refresh has got reading photo metadata, or
/// `None` when no refresh is reading any.
pub(crate) fn timeline_progress(&self) -> Option<MappingProgress> {
self.timeline_progress
.lock()
.map(|(done, total)| MappingProgress { done, total })
}
/// Whether a timeline refresh is running. A front-end that asked for one /// Whether a timeline refresh is running. A front-end that asked for one
/// watches this to know when the new timeline is worth re-reading. /// watches this to know when the new timeline is worth re-reading.
pub(crate) fn timeline_refreshing(&self) -> bool { pub(crate) fn timeline_refreshing(&self) -> bool {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- "Filters": a funnel, wide at the top and narrowing to a short spout.
No stock icon theme ships one under a standard name. -->
<path fill="#222222" d="M 1.5,2 H 14.5 L 9.5,8.5 V 13.5 L 6.5,15 V 8.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -7,6 +7,7 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-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-offline-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
<!-- The app icon, from the one file packaging installs, so the in-app brand <!-- The app icon, from the one file packaging installs, so the in-app brand
+13 -2
View File
@@ -58,8 +58,8 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); } .viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
/* Photos grid */ /* 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; } .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-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; } .photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); } .photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; } .photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
.photo-thumb { transition: transform 220ms ease; } .photo-thumb { transition: transform 220ms ease; }
@@ -71,8 +71,12 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; } .photo-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-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 { 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-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); } .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 { padding: 0; border-radius: 16px; transition: background 180ms ease; }
.album-card:hover { background: alpha(currentColor, 0.07); } .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-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; }
@@ -82,3 +86,10 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */ /* 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 { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
/* The Places map: water a faint wash of the text colour, bubbles (drawn in
* the area's own colour) in the accent. */
.places-map-frame { border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); }
.places-map { color: var(--accent-bg-color); }
+64 -107
View File
@@ -9,10 +9,13 @@ use pages::activity::*;
use pages::albums::*; use pages::albums::*;
use pages::browser::*; use pages::browser::*;
use pages::devices::*; use pages::devices::*;
use pages::duplicates::*;
use pages::locations::*; use pages::locations::*;
use pages::login::*; use pages::login::*;
use pages::photo_viewer::*; use pages::photo_viewer::*;
use pages::photos::*; use pages::photos::*;
use pages::places::*;
use pages::places_map::*;
use pages::shared::*; use pages::shared::*;
use pages::shared_by_me::*; use pages::shared_by_me::*;
use pages::status::*; use pages::status::*;
@@ -25,7 +28,7 @@ use widgets::share_dialog::*;
use widgets::thumbnails::*; use widgets::thumbnails::*;
use widgets::versions_dialog::*; use widgets::versions_dialog::*;
pub(crate) use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext}; pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
@@ -53,10 +56,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, send, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -301,10 +305,14 @@ fn load_proton_theme() {
}; };
let provider = gtk4::CssProvider::new(); let provider = gtk4::CssProvider::new();
provider.load_from_resource("/de/nils/protondrivelinux/style.css"); provider.load_from_resource("/de/nils/protondrivelinux/style.css");
// Above the user's own gtk.css, which often imports a whole theme: its
// `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every
// photo tile and push the grid past the window. Every rule in the sheet is
// scoped to the app's own classes, so nothing else the user styled changes.
gtk4::style_context_add_provider_for_display( gtk4::style_context_add_provider_for_display(
&display, &display,
&provider, &provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
); );
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons"); gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
} }
@@ -522,10 +530,8 @@ fn build_window(app: &adw::Application) {
model: gallery_widgets.model.clone(), model: gallery_widgets.model.clone(),
groups: gallery_widgets.groups.clone(), groups: gallery_widgets.groups.clone(),
row_height: Cell::new(ROW_DEFAULT), row_height: Cell::new(ROW_DEFAULT),
learned_ratios: RefCell::new(HashMap::new()),
scrolling_down: Cell::new(true), scrolling_down: Cell::new(true),
scroll_offset: Cell::new(0.0), scroll_offset: Cell::new(0.0),
assumed_ratios: RefCell::new(HashSet::new()),
content: gallery_widgets.content.clone(), content: gallery_widgets.content.clone(),
status: gallery_widgets.status.clone(), status: gallery_widgets.status.clone(),
retry: gallery_widgets.retry.clone(), retry: gallery_widgets.retry.clone(),
@@ -548,6 +554,19 @@ fn build_window(app: &adw::Application) {
albums_btn: gallery_widgets.albums_btn.clone(), albums_btn: gallery_widgets.albums_btn.clone(),
view_switch: gallery_widgets.view_switch.clone(), view_switch: gallery_widgets.view_switch.clone(),
albums_loading: Cell::new(false), albums_loading: Cell::new(false),
places: gallery_widgets.places.clone(),
places_stack: gallery_widgets.places_stack.clone(),
places_status: gallery_widgets.places_status.clone(),
places_btn: gallery_widgets.places_btn.clone(),
places_loading: Cell::new(false),
places_mapping: gallery_widgets.places_mapping.clone(),
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
places_poll: RefCell::new(None),
places_map: gallery_widgets.places_map.clone(),
places_map_btn: gallery_widgets.places_map_btn.clone(),
place_count: Cell::new(0),
place: RefCell::new(None),
album: RefCell::new(None), album: RefCell::new(None),
timeline_stale: Cell::new(false), timeline_stale: Cell::new(false),
album_list: RefCell::new(Vec::new()), album_list: RefCell::new(Vec::new()),
@@ -557,11 +576,20 @@ fn build_window(app: &adw::Application) {
tabs: gallery_widgets.tabs.clone(), tabs: gallery_widgets.tabs.clone(),
counts: Cell::new(None), counts: Cell::new(None),
dates: gallery_widgets.dates.clone(), dates: gallery_widgets.dates.clone(),
filter_btn: gallery_widgets.filter_btn.clone(),
filter_count: gallery_widgets.filter_count.clone(),
clear_filters: gallery_widgets.clear_filters.clone(),
date_ranges: RefCell::new(vec![None]), date_ranges: RefCell::new(vec![None]),
range: Cell::new(None), range: Cell::new(None),
favorites_btn: gallery_widgets.favorites_btn.clone(), favorites_row: gallery_widgets.favorites_row.clone(),
favorites: Cell::new(false), favorites: Cell::new(false),
not_in_album_row: gallery_widgets.not_in_album_row.clone(),
not_in_album: Cell::new(false),
memories: gallery_widgets.memories.clone(),
memory_cards: gallery_widgets.memory_cards.clone(),
has_memories: Cell::new(false),
date_suppress: Cell::new(false), date_suppress: Cell::new(false),
reload_pending: Cell::new(false),
loading: Cell::new(false), loading: Cell::new(false),
has_more: Cell::new(false), has_more: Cell::new(false),
page_waiters: RefCell::new(Vec::new()), page_waiters: RefCell::new(Vec::new()),
@@ -580,6 +608,7 @@ fn build_window(app: &adw::Application) {
list: gallery_widgets.list.clone(), list: gallery_widgets.list.clone(),
selecting: Cell::new(false), selecting: Cell::new(false),
selected: RefCell::new(HashSet::new()), selected: RefCell::new(HashSet::new()),
select_anchor: RefCell::new(None),
select_btn: gallery_widgets.select_btn.clone(), select_btn: gallery_widgets.select_btn.clone(),
select_bar: gallery_widgets.select_bar.clone(), select_bar: gallery_widgets.select_bar.clone(),
select_label: gallery_widgets.select_label.clone(), select_label: gallery_widgets.select_label.clone(),
@@ -718,7 +747,12 @@ fn build_window(app: &adw::Application) {
&gallery_widgets.empty_upload, &gallery_widgets.empty_upload,
&gallery_widgets.empty_import, &gallery_widgets.empty_import,
); );
let ui_duplicates = ui.clone();
gallery_widgets
.duplicates
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
wire_albums(&ui); wire_albums(&ui);
wire_places(&ui);
wire_trash(&ui, &trash_widgets); wire_trash(&ui, &trash_widgets);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
wire_shared_by_me(&ui, &shared_by_me_widgets.retry); wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
@@ -1027,12 +1061,12 @@ fn reload_current_page(ui: &Rc<Ui>) {
refresh_then(ui, RefreshScope::Dir { path }, load_browser); refresh_then(ui, RefreshScope::Dir { path }, load_browser);
} }
// One scope covers the whole photos view, so Refresh reloads whichever of // One scope covers the whole photos view, so Refresh reloads whichever of
// the two — album grid or timeline/album — is actually on screen. // the views — a grid, or the timeline, an album or a place — is on screen.
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| { Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") { match ui.gallery.content.visible_child_name().as_deref() {
load_albums(ui); Some("albums") => load_albums(ui),
} else { Some("places") => load_places(ui),
load_gallery(ui, false); _ => load_gallery(ui, false),
} }
}), }),
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash), Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
@@ -1321,6 +1355,22 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.", "This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
)), )),
); );
dialog.add_legal_section(
"GeoNames",
None,
gtk4::License::Custom,
Some(&gettext(
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
)),
);
dialog.add_legal_section(
"Natural Earth",
None,
gtk4::License::Custom,
Some(&gettext(
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/\">Natural Earth</a> land outlines, which are in the public domain.",
)),
);
dialog.add_legal_section( dialog.add_legal_section(
"proton-sdk-rs", "proton-sdk-rs",
Some("© 2026 Nils Pukropp (contact@narl.io)"), Some("© 2026 Nils Pukropp (contact@narl.io)"),
@@ -1718,89 +1768,6 @@ mod tests {
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window); assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
} }
/// The full width a row occupies, gaps included.
fn row_width(widths: &[i32]) -> i32 {
widths.iter().sum::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
}
/// Ratios of a typical mixed day: landscape phone shots, a portrait, a
/// square crop.
fn mixed() -> Vec<f64> {
vec![
1.5, 1.5, 0.75, 1.0, 1.33, 1.5, 0.75, 1.78, 1.0, 1.5, 1.5, 1.33,
]
}
#[test]
fn every_full_row_spans_the_content_width() {
// The point of justifying: no ragged right margin at any width, at any
// zoom, for any mix of shapes.
for width in [640, 900, 1000, 1440, 1920, 2560] {
let rows = plan_rows(&mixed(), width, ROW_DEFAULT);
// The last row is deliberately short, so it is not part of this.
for (_, widths) in rows.iter().take(rows.len() - 1) {
assert_eq!(row_width(widths), width, "row does not span {width}px");
}
}
}
#[test]
fn a_tile_keeps_its_photos_shape() {
// Each tile is its photo's ratio at the row's height, which is what
// makes cropping unnecessary.
let ratios = mixed();
let rows = plan_rows(&ratios, 1440, ROW_DEFAULT);
let (height, widths) = &rows[0];
for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) {
let laid_out = *width as f64 / *height as f64;
assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}");
}
}
#[test]
fn rows_land_near_the_target_height() {
// A row is closed as soon as it no longer fits at the target, so it is
// never taller than the target and never far below it.
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
let rows = plan_rows(&mixed(), 1600, target);
for (height, _) in rows.iter().take(rows.len() - 1) {
assert!(
*height <= target,
"{height}px exceeds the {target}px target"
);
assert!(*height > target / 2, "{height}px is far under {target}px");
}
}
}
#[test]
fn a_short_day_is_not_stretched_across_the_window() {
// Two photos are two photos, not two half-window tiles.
let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT);
assert_eq!(rows.len(), 1);
let (height, widths) = &rows[0];
assert_eq!(*height, ROW_DEFAULT);
assert!(row_width(widths) < 1920 / 2);
}
#[test]
fn zooming_in_puts_fewer_photos_in_a_row() {
let small = plan_rows(&mixed(), 1200, ROW_MIN);
let big = plan_rows(&mixed(), 1200, ROW_MAX);
assert!(small[0].1.len() > big[0].1.len());
}
#[test]
fn every_photo_is_laid_out_exactly_once() {
let ratios = mixed();
let placed: usize = plan_rows(&ratios, 1000, ROW_DEFAULT)
.iter()
.map(|(_, widths)| widths.len())
.sum();
assert_eq!(placed, ratios.len());
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
}
/// One file of a shot, for the group-switch tests. /// One file of a shot, for the group-switch tests.
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem { fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
PhotoItem { PhotoItem {
@@ -1853,14 +1820,4 @@ mod tests {
"the video" "the video"
); );
} }
#[test]
fn a_window_narrower_than_one_photo_still_lays_it_out() {
// A tile of at least one px, rather than a zero-width widget or a
// division by zero.
for width in [0, 40] {
let rows = plan_rows(&[1.5, 1.5, 1.5], width, ROW_DEFAULT);
assert!(rows.iter().all(|(h, w)| *h > 0 && w.iter().all(|w| *w > 0)));
}
}
} }
+19 -3
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. /// position. The timeline's filters do not apply to albums, so they go.
fn show_album_grid(ui: &Rc<Ui>) { fn show_album_grid(ui: &Rc<Ui>) {
close_album(ui); close_album(ui);
close_place(ui);
ui.gallery.filters.set_visible(false); ui.gallery.filters.set_visible(false);
ui.gallery.content.set_visible_child_name("albums"); ui.gallery.content.set_visible_child_name("albums");
} }
@@ -468,6 +469,16 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
), ),
); );
} }
// 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); reload_album_grid(&ui);
} }
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
@@ -572,7 +583,7 @@ fn album_subtitle(album: &AlbumInfo) -> String {
/// Queue an album cover for the gallery's own thumbnail machinery: same batching, /// Queue an album cover for the gallery's own thumbnail machinery: same batching,
/// same texture cache, same decode pacing as a timeline tile. /// same texture cache, same decode pacing as a timeline tile.
fn want_cover(ui: &Rc<Ui>, uid: String, picture: &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) { if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
picture.set_paintable(Some(texture)); picture.set_paintable(Some(texture));
return; return;
@@ -652,6 +663,7 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
return; return;
} }
close_album(&ui_photos); close_album(&ui_photos);
close_place(&ui_photos);
ui_photos.gallery.filters.set_visible(true); ui_photos.gallery.filters.set_visible(true);
ui_photos.gallery.content.set_visible_child_name("timeline"); ui_photos.gallery.content.set_visible_child_name("timeline");
// The timeline comes back as it was left unless an album took over the // The timeline comes back as it was left unless an album took over the
@@ -664,10 +676,14 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
} }
}); });
// Back returns to the grid the album was opened from, scrolled where it // Back returns to the grid the album or place was opened from, scrolled
// was, instead of reloading it. // where it was, instead of reloading it.
let ui_back = ui.clone(); let ui_back = ui.clone();
ui.gallery.back.clone().connect_clicked(move |_| { ui.gallery.back.clone().connect_clicked(move |_| {
if ui_back.gallery.place.borrow().is_some() {
show_place_grid(&ui_back);
return;
}
show_album_grid(&ui_back); show_album_grid(&ui_back);
let count = ui_back.gallery.album_list.borrow().len(); let count = ui_back.gallery.album_list.borrow().len();
ui_back.gallery.title.set_subtitle(&ngettext_f( ui_back.gallery.title.set_subtitle(&ngettext_f(
+246
View File
@@ -0,0 +1,246 @@
use crate::*;
/// Side of a copy's thumbnail in the duplicates dialog, in px.
const COPY_SIZE: i32 = 150;
/// Each set's copies with their Keep toggles.
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
/// Open the duplicate finder: every set of photos stored more than once, side
/// by side, with the copy worth keeping ticked. The copies left unticked go to
/// Trash — one file each, never the rest of their shot.
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
let status = adw::StatusPage::builder()
.icon_name("edit-copy-symbolic")
.title(gettext("Looking for duplicates…"))
.vexpand(true)
.build();
status.add_css_class("compact");
let stack = gtk4::Stack::new();
stack.add_named(&status, Some("status"));
let trash = gtk4::Button::builder()
.label(gettext("Move to Trash"))
.halign(gtk4::Align::End)
.sensitive(false)
.build();
trash.add_css_class("destructive-action");
trash.add_css_class("pill");
let bottom = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
bottom.add_css_class("toolbar");
bottom.set_halign(gtk4::Align::End);
bottom.append(&trash);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new());
toolbar.set_content(Some(&stack));
toolbar.add_bottom_bar(&bottom);
toolbar.set_reveal_bottom_bars(false);
let dialog = adw::Dialog::builder()
.title(gettext("Duplicates"))
.content_width(760)
.content_height(640)
.child(&toolbar)
.build();
dialog.present(ui_window(ui).as_ref());
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sets = match rx.recv().await {
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
Ok(Ok(Response::Error { message, .. })) => {
status.set_icon_name(Some("dialog-warning-symbolic"));
status.set_title(&gettext("Couldn't look for duplicates"));
status.set_description(Some(&message));
return;
}
_ => {
status.set_icon_name(Some("dialog-warning-symbolic"));
status.set_title(&gettext("Couldn't look for duplicates"));
status.set_description(Some(&gettext("The mount service didn't respond.")));
return;
}
};
if sets.is_empty() {
status.set_icon_name(Some("emblem-ok-symbolic"));
status.set_title(&gettext("No duplicates"));
status.set_description(Some(&gettext(
"Every photo in your library is stored once.",
)));
return;
}
let review = duplicate_review(&ui, sets, &trash);
stack.add_named(&review, Some("review"));
stack.set_visible_child_name("review");
toolbar.set_reveal_bottom_bars(true);
schedule_thumbs(&ui);
});
}
/// The review list: one section per set, each copy a thumbnail with a Keep
/// toggle. Wires `trash` to move every copy not kept to Trash.
fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button) -> gtk4::Widget {
let intro = gtk4::Label::builder()
.label(gettext(
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
))
.wrap(true)
.xalign(0.0)
.build();
intro.add_css_class("dim-label");
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(18);
content.set_margin_end(18);
content.append(&intro);
// Every copy's toggle, by set, so the button can count what goes.
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
for set in sets {
let Some(first) = set.first() else {
continue;
};
let date = section_heading(first.capture_time, Grouping::Day);
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
let title = ngettext_f(
"{date}, {n} copy",
"{date}, {n} copies",
set.len() as u64,
&[("date", &date)],
);
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
heading.add_css_class("heading");
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
let mut set_toggles = Vec::new();
for (index, photo) in set.into_iter().enumerate() {
let (card, keep) = copy_card(ui, &photo, index == 0);
row.append(&card);
set_toggles.push((photo, keep));
}
let row_scroll = gtk4::ScrolledWindow::builder()
.vscrollbar_policy(gtk4::PolicyType::Never)
.propagate_natural_height(true)
.child(&row)
.build();
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
section.append(&heading);
section.append(&row_scroll);
content.append(&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(&ngettext_f(
"Move {n} copy to Trash",
"Move {n} copies to Trash",
n as u64,
&[],
));
trash.set_sensitive(n > 0);
}
};
sync();
for set in toggles.borrow().iter() {
for (_, keep) in set {
let toggles = toggles.clone();
let sync = sync.clone();
keep.connect_toggled(move |keep| {
// A set always keeps a copy: unticking the last one would trash
// the photo itself, which is not what removing duplicates means.
if !keep.is_active()
&& toggles.borrow().iter().any(|set| {
set.iter().any(|(_, t)| t == keep)
&& set.iter().all(|(_, t)| !t.is_active())
})
{
keep.set_active(true);
return;
}
sync();
});
}
}
let ui_trash = ui.clone();
trash.connect_clicked(move |button| {
let uids = doomed(&toggles.borrow());
if uids.is_empty() {
return;
}
trash_photos(&ui_trash, uids, true);
if let Some(dialog) = button
.ancestor(adw::Dialog::static_type())
.and_downcast::<adw::Dialog>()
{
dialog.close();
}
});
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vexpand(true)
.child(&content)
.build();
scroll.upcast()
}
/// The uids of every copy not kept.
fn doomed(toggles: &SetToggles) -> Vec<String> {
toggles
.iter()
.flatten()
.filter(|(_, keep)| !keep.is_active())
.map(|(photo, _)| photo.uid.clone())
.collect()
}
/// One copy: its thumbnail, its file name, and whether to keep it.
fn copy_card(ui: &Rc<Ui>, photo: &PhotoItem, keep: bool) -> (gtk4::Box, gtk4::ToggleButton) {
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Cover)
.can_shrink(true)
.build();
let placeholder = gtk4::Image::builder()
.icon_name("image-x-generic-symbolic")
.pixel_size(24)
.build();
placeholder.add_css_class("photo-placeholder");
let overlay = gtk4::Overlay::new();
overlay.set_size_request(COPY_SIZE, COPY_SIZE);
overlay.set_child(Some(&placeholder));
overlay.add_overlay(&picture);
overlay.set_overflow(gtk4::Overflow::Hidden);
overlay.add_css_class("memory-cover");
want_thumb(ui, photo, &picture);
let name = photo.name.clone().unwrap_or_default();
let label = gtk4::Label::builder()
.label(&name)
.tooltip_text(&name)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.max_width_chars(18)
.build();
label.add_css_class("caption");
let toggle = gtk4::ToggleButton::builder()
.label(gettext("Keep"))
.active(keep)
.build();
toggle.add_css_class("pill");
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
card.set_size_request(COPY_SIZE, -1);
card.append(&overlay);
card.append(&label);
card.append(&toggle);
(card, toggle)
}
+3
View File
@@ -2,10 +2,13 @@ pub(crate) mod activity;
pub(crate) mod albums; pub(crate) mod albums;
pub(crate) mod browser; pub(crate) mod browser;
pub(crate) mod devices; pub(crate) mod devices;
pub(crate) mod duplicates;
pub(crate) mod locations; pub(crate) mod locations;
pub(crate) mod login; pub(crate) mod login;
pub(crate) mod photo_viewer; pub(crate) mod photo_viewer;
pub(crate) mod photos; pub(crate) mod photos;
pub(crate) mod places;
pub(crate) mod places_map;
pub(crate) mod shared; pub(crate) mod shared;
pub(crate) mod shared_by_me; pub(crate) mod shared_by_me;
pub(crate) mod status; pub(crate) mod status;
+143 -3
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. /// The furthest the lightbox zooms in, as a multiple of the image's own pixels.
const MAX_ZOOM: f64 = 8.0; 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 /// 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. /// next batch in, so reaching the end rarely means waiting for it.
const PAGE_AHEAD: u32 = 10; const PAGE_AHEAD: u32 = 10;
@@ -75,6 +81,12 @@ pub(crate) struct Viewer {
pub(crate) chrome: Vec<gtk4::Revealer>, pub(crate) chrome: Vec<gtk4::Revealer>,
pub(crate) chrome_hover: Cell<bool>, pub(crate) chrome_hover: Cell<bool>,
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>, 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 /// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
@@ -220,6 +232,8 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
stop_video(viewer); stop_video(viewer);
viewer.media.set_visible_child_name("photo"); viewer.media.set_visible_child_name("photo");
reset_zoom(viewer); 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. // Looked up before the group is refetched, which empties it.
let item = viewer_item(ui, viewer, &uid); let item = viewer_item(ui, viewer, &uid);
@@ -244,6 +258,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
Request::OpenPhoto { uid: uid.clone() }, Request::OpenPhoto { uid: uid.clone() },
); );
let viewer = viewer.clone(); let viewer = viewer.clone();
let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = rx.recv().await; let result = rx.recv().await;
// The user may have moved on while this was in flight; that photo's own // The user may have moved on while this was in flight; that photo's own
@@ -254,6 +269,9 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.stop(); viewer.spinner.stop();
viewer.spinner.set_visible(false); viewer.spinner.set_visible(false);
viewer.loading.set(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| { let fail = |message: &str| {
viewer.status.set_label(message); viewer.status.set_label(message);
@@ -295,6 +313,92 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
}); });
} }
/// 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 /// Stop whatever the player was playing, so stepping off a video (or closing
/// the lightbox) doesn't leave its sound running. /// the lightbox) doesn't leave its sound running.
fn stop_video(viewer: &Rc<Viewer>) { fn stop_video(viewer: &Rc<Viewer>) {
@@ -813,7 +917,13 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
if let Some(album) = ui.gallery.album.borrow().as_ref() { if let Some(album) = ui.gallery.album.borrow().as_ref() {
return Some(album.photo_count); 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; return None;
} }
let (photos, videos, raw) = ui.gallery.counts.get()?; let (photos, videos, raw) = ui.gallery.counts.get()?;
@@ -985,6 +1095,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("flat"); favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn"); 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` // Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo. // decides that per photo.
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo")); let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
@@ -1002,6 +1120,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.append(&group_btn); top_bar.append(&group_btn);
top_bar.append(&favorite_btn); top_bar.append(&favorite_btn);
top_bar.append(&info_toggle); top_bar.append(&info_toggle);
top_bar.append(&slideshow_btn);
top_bar.append(&download_btn); top_bar.append(&download_btn);
top_bar.append(&delete_btn); top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn); top_bar.append(&open_ext_btn);
@@ -1128,6 +1247,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()], chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
chrome_hover: Cell::new(false), chrome_hover: Cell::new(false),
chrome_source: RefCell::new(None), 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); let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
@@ -1241,6 +1363,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let viewer_closing = viewer.clone(); let viewer_closing = viewer.clone();
window.connect_close_request(move |_| { window.connect_close_request(move |_| {
stop_video(&viewer_closing); stop_video(&viewer_closing);
disarm_slide(&viewer_closing);
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() { if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
source.remove(); source.remove();
} }
@@ -1254,6 +1377,16 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.set_reveal_child(toggle.is_active()); .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(); let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false)); info_close.connect_clicked(move |_| toggle_off.set_active(false));
@@ -1317,7 +1450,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
delete_btn.connect_clicked(move |_| { delete_btn.connect_clicked(move |_| {
let uid = viewer_delete.uid.borrow().clone(); let uid = viewer_delete.uid.borrow().clone();
w_delete.close(); w_delete.close();
trash_photos(&ui_delete, vec![uid]); trash_photos(&ui_delete, vec![uid], false);
}); });
let ui_group = ui.clone(); let ui_group = ui.clone();
@@ -1423,6 +1556,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP), Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
Some("0" | "KP_0") => reset_zoom(&viewer_key), Some("0" | "KP_0") => reset_zoom(&viewer_key),
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None), 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 Some("i") => viewer_key
.info_toggle .info_toggle
.set_active(!viewer_key.info_toggle.is_active()), .set_active(!viewer_key.info_toggle.is_active()),
@@ -1436,7 +1576,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("Delete" | "KP_Delete") => { Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone(); let uid = viewer_key.uid.borrow().clone();
w_key.close(); w_key.close();
trash_photos(&ui_key, vec![uid]); trash_photos(&ui_key, vec![uid], false);
} }
Some("Escape" | "q") => w_key.close(), Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(), Some("w") if ctrl => w_key.close(),
File diff suppressed because it is too large. Load diff
+319
View File
@@ -0,0 +1,319 @@
//! The Places view of the Photos page: a grid of the towns the photos were
//! taken in, and the place that opens when one is clicked.
//!
//! An open place works like an open album: it is parked in
//! [`GalleryState::place`], and [`load_gallery`] pages
//! [`Request::PlacePhotos`] instead of the timeline.
use crate::*;
/// Edge length in px of a place's cover, the same as an album's.
const COVER_EDGE: i32 = 200;
/// How often the listing is asked for again while photo locations are read.
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
/// Show the Places grid and (re)load it. Called when the Places toggle goes on.
pub(crate) fn show_places(ui: &Rc<Ui>) {
show_place_grid(ui);
load_places(ui);
}
/// Switch to the place grid as it was last filled, keeping its scroll
/// position. The timeline's filters do not apply to places, so they go.
pub(crate) fn show_place_grid(ui: &Rc<Ui>) {
close_place(ui);
close_album(ui);
ui.gallery.filters.set_visible(false);
ui.gallery.content.set_visible_child_name("places");
ui.gallery
.title
.set_subtitle(&places_subtitle(ui.gallery.place_count.get()));
}
/// Ask the daemon for the place listing and rebuild the grid.
pub(crate) fn load_places(ui: &Rc<Ui>) {
if ui.gallery.places_loading.get() {
return;
}
ui.gallery.places_loading.set(true);
// A grid already on screen stays there while it refreshes.
if ui.gallery.places_stack.visible_child_name().as_deref() != Some("grid") {
places_status(
ui,
"mark-location-symbolic",
&gettext("Loading places…"),
&gettext("Finding where your photos were taken."),
);
}
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoPlaces);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.gallery.places_loading.set(false);
let mapping = match &result {
Ok(Ok(Response::Places { mapping, .. })) => *mapping,
_ => None,
};
sync_mapping(&ui, mapping);
match result {
Ok(Ok(Response::Places { items, .. })) if items.is_empty() && mapping.is_some() => {
ui.gallery.place_count.set(0);
places_status(
&ui,
"mark-location-symbolic",
&gettext("Mapping your photos…"),
&gettext("Places show up here once the locations of your photos are read."),
)
}
Ok(Ok(Response::Places { items, .. })) if items.is_empty() => {
ui.gallery.place_count.set(0);
if ui.gallery.place.borrow().is_none() {
ui.gallery.title.set_subtitle("");
}
places_status(
&ui,
"mark-location-symbolic",
&gettext("No places"),
&gettext(
"Photos that carry the location they were taken at show up here, grouped by town.",
),
)
}
Ok(Ok(Response::Places { items, .. })) => {
fill_places(&ui, &items);
ui.gallery.places_map.set_places(&items);
show_places_layout(&ui);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &gettext("Couldn't load places"), &message, kind);
places_status(
&ui,
"dialog-warning-symbolic",
&gettext("Couldn't load places"),
&message,
);
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => places_status(
&ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service didn't respond."),
),
}
});
}
/// Show how far the daemon has got reading photo locations, and ask again in a
/// moment while it is still reading — the new places land once it is done.
fn sync_mapping(ui: &Rc<Ui>, mapping: Option<MappingProgress>) {
if let Some(source) = ui.gallery.places_poll.take() {
source.remove();
}
ui.gallery.places_mapping.set_visible(mapping.is_some());
let Some(MappingProgress { done, total }) = mapping else {
return;
};
ui.gallery.places_mapping_label.set_label(&gettext_f(
// Translators: {done} and {total} are numbers of photos.
"Mapping photos: {done} of {total}",
&[("done", &thousands(done)), ("total", &thousands(total))],
));
ui.gallery.places_mapping_bar.set_fraction(if total == 0 {
0.0
} else {
done as f64 / total as f64
});
let ui_poll = ui.clone();
let source = glib::timeout_add_local_once(MAPPING_POLL, move || {
ui_poll.gallery.places_poll.take();
let on_places = ui_poll.gallery.content.visible_child_name().as_deref() == Some("places");
if on_places && ui_poll.gallery.place.borrow().is_none() {
load_places(&ui_poll);
}
});
ui.gallery.places_poll.replace(Some(source));
}
/// Replace the grid's cards with `places`, the one with the most photos first.
fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
let scroll = ui
.gallery
.places
.ancestor(gtk4::ScrolledWindow::static_type())
.and_downcast::<gtk4::ScrolledWindow>();
let position = scroll.as_ref().map(|s| s.vadjustment().value());
while let Some(child) = ui.gallery.places.first_child() {
ui.gallery.places.remove(&child);
}
ui.gallery.place_count.set(places.len());
if ui.gallery.place.borrow().is_none() {
ui.gallery
.title
.set_subtitle(&places_subtitle(places.len()));
}
for place in places {
ui.gallery.places.append(&place_card(ui, place));
}
schedule_thumbs(ui);
if let (Some(scroll), Some(position)) = (scroll, position) {
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
}
}
pub(crate) fn places_subtitle(count: usize) -> String {
ngettext_f("{n} place", "{n} places", count as u64, &[])
}
/// "Germany · 12 photos": where the town is, and how much was taken there.
pub(crate) fn place_subtitle(place: &PlaceInfo) -> String {
ngettext_f(
// Translators: {country} is a country name, {n} a number of photos.
"{country} · {n} photo",
"{country} · {n} photos",
place.photo_count as u64,
&[("country", &country_name(&place.country))],
)
}
/// One place as a clickable card: its newest photo, the town, and the country
/// with the number of photos taken there.
fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Cover)
.can_shrink(true)
.hexpand(true)
.vexpand(true)
.build();
picture.add_css_class("photo-thumb");
let placeholder = gtk4::Image::builder()
.icon_name("mark-location-symbolic")
.pixel_size(32)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
placeholder.add_css_class("photo-placeholder");
let cover = gtk4::Overlay::new();
cover.set_child(Some(&placeholder));
cover.add_overlay(&picture);
cover.set_size_request(COVER_EDGE, COVER_EDGE);
cover.set_overflow(gtk4::Overflow::Hidden);
cover.add_css_class("album-cover");
let name = gtk4::Label::builder()
.label(&place.name)
.halign(gtk4::Align::Start)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
name.add_css_class("heading");
let subtitle = place_subtitle(place);
let count = gtk4::Label::builder()
.label(&subtitle)
.halign(gtk4::Align::Start)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
count.add_css_class("dim-label");
count.add_css_class("caption");
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
card.append(&cover);
card.append(&name);
card.append(&count);
let button = gtk4::Button::builder().child(&card).build();
button.add_css_class("flat");
button.add_css_class("album-card");
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
want_cover(ui, place.cover.uid.clone(), &picture);
let ui_open = ui.clone();
let place = place.clone();
button.connect_clicked(move |_| open_place(&ui_open, place.clone()));
button
}
/// Open one place in the gallery: the timeline view, paged from that town.
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
ui.gallery.title.set_title(&place.name);
ui.gallery.title.set_subtitle(&place_subtitle(&place));
*ui.gallery.place.borrow_mut() = Some(place);
ui.gallery.timeline_stale.set(true);
// As in an album: no filters, no Upload, no view switch — back leads out.
ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.set_visible(false);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
ui.gallery.kind.set(None);
ui.gallery.range.set(None);
ui.gallery.content.set_visible_child_name("timeline");
load_gallery(ui, false);
}
/// Leave an open place, restoring the timeline's header. A no-op when no place
/// is open.
pub(crate) fn close_place(ui: &Rc<Ui>) {
if ui.gallery.place.borrow_mut().take().is_none() {
return;
}
ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
}
/// Show the place grid's own status page (loading, empty, or an error).
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.places_status.set_icon_name(Some(icon));
ui.gallery.places_status.set_title(title);
ui.gallery.places_status.set_description(Some(description));
ui.gallery.places_stack.set_visible_child_name("status");
}
/// Wire the Places toggle of the view switcher.
pub(crate) fn wire_places(ui: &Rc<Ui>) {
let ui_places = ui.clone();
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
if btn.is_active() {
show_places(&ui_places);
}
});
let ui_layout = ui.clone();
ui.gallery.places_map_btn.connect_toggled(move |_| {
// A status page (loading, empty, an error) stays until a listing
// replaces it; only the grid and the map swap.
if ui_layout
.gallery
.places_stack
.visible_child_name()
.as_deref()
!= Some("status")
{
show_places_layout(&ui_layout);
}
});
let ui_open = ui.clone();
ui.gallery
.places_map
.connect_open(move |place| open_place(&ui_open, place));
}
/// Show the places as cards or on the map, as the Grid/Map toggle says.
fn show_places_layout(ui: &Rc<Ui>) {
let layout = if ui.gallery.places_map_btn.is_active() {
"map"
} else {
"grid"
};
ui.gallery.places_stack.set_visible_child_name(layout);
}
+773
View File
@@ -0,0 +1,773 @@
//! The map of the Places view: the towns the photos were taken in, drawn as
//! bubbles over an outline of the world's land.
//!
//! The map is drawn here rather than loaded from a tile server: no tile source
//! with terms that allow an app like this one to use it is free, and a town
//! bubble needs no more detail than coastlines. The land is Natural Earth's
//! 1:50m land polygons (public domain), trimmed into `data/land.txt` by
//! `scripts/natural-earth-land.py`.
//!
//! The projection is Web Mercator, the one every other map uses, so the
//! world looks the way people expect. Towns that would overlap on screen merge
//! into one bubble counting all their photos; clicking it zooms in until they
//! part, clicking a single town opens it.
use std::sync::OnceLock;
use crate::*;
const LAND: &str = include_str!("../../../data/land.txt");
/// Latitude past which Mercator is cut off, as on every web map.
const MAX_LATITUDE: f64 = 85.0;
/// How far in the map zooms, as a multiple of the whole world fitting the
/// widget's width.
const MAX_ZOOM: f64 = 2048.0;
/// How close "show all" zooms onto a single town, the same way.
const FIT_MAX_ZOOM: f64 = 64.0;
/// The zoom step of one scroll notch and of the zoom buttons.
const ZOOM_STEP: f64 = 1.25;
const BUTTON_ZOOM_STEP: f64 = 2.0;
/// A press that moves less than this many px is a click, not a drag.
const CLICK_SLOP: f64 = 4.0;
/// Coastline points closer than this many px to the last one drawn are
/// skipped: at world zoom most of the outline would fall onto one pixel.
const MIN_SEGMENT: f64 = 0.75;
/// A point in projected world space: `x` and `y` both run 0..1, from the
/// antimeridian eastwards and from the north edge southwards.
#[derive(Clone, Copy, Debug, PartialEq)]
struct World {
x: f64,
y: f64,
}
fn project(latitude: f64, longitude: f64) -> World {
let lat = latitude.clamp(-MAX_LATITUDE, MAX_LATITUDE).to_radians();
World {
x: (longitude + 180.0) / 360.0,
y: (1.0 - (lat.tan() + 1.0 / lat.cos()).ln() / std::f64::consts::PI) / 2.0,
}
}
/// One land outline, projected, with its bounding box for culling.
struct Ring {
points: Vec<World>,
min: World,
max: World,
}
fn land() -> &'static [Ring] {
static LAND_RINGS: OnceLock<Vec<Ring>> = OnceLock::new();
LAND_RINGS.get_or_init(|| LAND.lines().filter_map(parse_ring).collect())
}
fn parse_ring(line: &str) -> Option<Ring> {
let points: Vec<World> = line
.split(' ')
.filter_map(|pair| {
let (lon, lat) = pair.split_once(',')?;
Some(project(lat.parse().ok()?, lon.parse().ok()?))
})
.collect();
let first = *points.first()?;
let (min, max) = points.iter().fold((first, first), |(min, max), p| {
(
World {
x: min.x.min(p.x),
y: min.y.min(p.y),
},
World {
x: max.x.max(p.x),
y: max.y.max(p.y),
},
)
});
Some(Ring { points, min, max })
}
/// Radius in px of a bubble holding `photos` photos: grows with the count,
/// but slowly, so a home town with thousands doesn't cover a continent.
fn bubble_radius(photos: usize) -> f64 {
7.0 + 3.0 * (photos.max(1) as f64).ln()
}
/// What the map shows: which part of the world, at what size.
#[derive(Clone, Copy, Debug)]
struct View {
/// The world point at the middle of the widget.
center: World,
/// px per world unit: the whole world is this wide on screen.
scale: f64,
}
/// One bubble as last drawn: where, how big, and the places it stands for
/// (indices into [`MapState::places`], the one with the most photos first).
struct Bubble {
x: f64,
y: f64,
radius: f64,
photos: usize,
members: Vec<usize>,
}
struct MapState {
places: Vec<(PlaceInfo, World)>,
view: Cell<View>,
/// False until the view has been fitted to the places at a real size, and
/// again whenever new places arrive before the user moved the map.
fitted: Cell<bool>,
moved: Cell<bool>,
bubbles: RefCell<Vec<Bubble>>,
pointer: Cell<(f64, f64)>,
/// The view a drag started from.
drag_origin: Cell<Option<View>>,
/// The scale a pinch started from.
pinch_origin: Cell<Option<View>>,
}
/// What clicking a town on the map does.
type OpenPlace = Box<dyn Fn(PlaceInfo)>;
/// The Places map widget.
pub(crate) struct PlacesMap {
root: gtk4::Overlay,
area: gtk4::DrawingArea,
state: RefCell<MapState>,
on_open: RefCell<Option<OpenPlace>>,
}
impl PlacesMap {
pub(crate) fn new() -> Rc<Self> {
let area = gtk4::DrawingArea::builder()
.hexpand(true)
.vexpand(true)
.has_tooltip(true)
.build();
area.add_css_class("places-map");
let zoom_in = map_button("zoom-in-symbolic", &gettext("Zoom In"));
let zoom_out = map_button("zoom-out-symbolic", &gettext("Zoom Out"));
let fit = map_button("zoom-fit-best-symbolic", &gettext("Show All Places"));
let zoom = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
zoom.add_css_class("linked");
zoom.append(&zoom_in);
zoom.append(&zoom_out);
let buttons = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
buttons.set_halign(gtk4::Align::End);
buttons.set_valign(gtk4::Align::Start);
buttons.set_margin_top(12);
buttons.set_margin_end(12);
buttons.append(&zoom);
buttons.append(&fit);
let root = gtk4::Overlay::new();
root.set_child(Some(&area));
root.add_overlay(&buttons);
root.set_overflow(gtk4::Overflow::Hidden);
root.add_css_class("places-map-frame");
let map = Rc::new(Self {
root,
area,
state: RefCell::new(MapState {
places: Vec::new(),
view: Cell::new(View {
center: World { x: 0.5, y: 0.5 },
scale: 1.0,
}),
fitted: Cell::new(false),
moved: Cell::new(false),
bubbles: RefCell::new(Vec::new()),
pointer: Cell::new((0.0, 0.0)),
drag_origin: Cell::new(None),
pinch_origin: Cell::new(None),
}),
on_open: RefCell::new(None),
});
let weak = Rc::downgrade(&map);
map.area.set_draw_func(move |area, cr, width, height| {
if let Some(map) = weak.upgrade() {
map.draw(area, cr, f64::from(width), f64::from(height));
}
});
map.wire(&zoom_in, &zoom_out, &fit);
map
}
pub(crate) fn widget(&self) -> &gtk4::Overlay {
&self.root
}
/// What clicking a town does.
pub(crate) fn connect_open(&self, f: impl Fn(PlaceInfo) + 'static) {
self.on_open.replace(Some(Box::new(f)));
}
/// Show `places`. The view is fitted to them unless the user has already
/// moved the map, so a refresh while they look around leaves it be.
pub(crate) fn set_places(&self, places: &[PlaceInfo]) {
let mut state = self.state.borrow_mut();
state.places = places
.iter()
.filter(|p| p.latitude != 0.0 || p.longitude != 0.0)
.map(|p| (p.clone(), project(p.latitude, p.longitude)))
.collect();
if !state.moved.get() {
state.fitted.set(false);
}
drop(state);
self.area.queue_draw();
}
fn wire(self: &Rc<Self>, zoom_in: &gtk4::Button, zoom_out: &gtk4::Button, fit: &gtk4::Button) {
let weak = Rc::downgrade(self);
zoom_in.connect_clicked(move |_| {
if let Some(map) = weak.upgrade() {
map.zoom_at_middle(BUTTON_ZOOM_STEP);
}
});
let weak = Rc::downgrade(self);
zoom_out.connect_clicked(move |_| {
if let Some(map) = weak.upgrade() {
map.zoom_at_middle(1.0 / BUTTON_ZOOM_STEP);
}
});
let weak = Rc::downgrade(self);
fit.connect_clicked(move |_| {
if let Some(map) = weak.upgrade() {
let state = map.state.borrow();
state.moved.set(false);
state.fitted.set(false);
drop(state);
map.area.queue_draw();
}
});
let motion = gtk4::EventControllerMotion::new();
let weak = Rc::downgrade(self);
motion.connect_motion(move |_, x, y| {
if let Some(map) = weak.upgrade() {
map.state.borrow().pointer.set((x, y));
let over = map.bubble_at(x, y).is_some();
map.area
.set_cursor_from_name(Some(if over { "pointer" } else { "default" }));
}
});
self.area.add_controller(motion);
let scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
let weak = Rc::downgrade(self);
scroll.connect_scroll(move |_, _, dy| {
let Some(map) = weak.upgrade() else {
return glib::Propagation::Proceed;
};
let (x, y) = map.state.borrow().pointer.get();
map.zoom_at(x, y, ZOOM_STEP.powf(-dy));
glib::Propagation::Stop
});
self.area.add_controller(scroll);
// One drag gesture for both panning and clicking: a press that barely
// moves is a click on whatever bubble it started on.
let drag = gtk4::GestureDrag::new();
let weak = Rc::downgrade(self);
drag.connect_drag_begin(move |_, _, _| {
if let Some(map) = weak.upgrade() {
let state = map.state.borrow();
state.drag_origin.set(Some(state.view.get()));
}
});
let weak = Rc::downgrade(self);
drag.connect_drag_update(move |_, dx, dy| {
let Some(map) = weak.upgrade() else {
return;
};
if dx.hypot(dy) < CLICK_SLOP {
return;
}
let state = map.state.borrow();
let Some(origin) = state.drag_origin.get() else {
return;
};
state.moved.set(true);
state.view.set(View {
center: World {
x: origin.center.x - dx / origin.scale,
y: origin.center.y - dy / origin.scale,
},
..origin
});
drop(state);
map.clamp_view();
map.area.queue_draw();
});
let weak = Rc::downgrade(self);
drag.connect_drag_end(move |gesture, dx, dy| {
let Some(map) = weak.upgrade() else {
return;
};
map.state.borrow().drag_origin.set(None);
if dx.hypot(dy) >= CLICK_SLOP {
return;
}
if let Some((x, y)) = gesture.start_point() {
map.click(x, y);
}
});
self.area.add_controller(drag);
let pinch = gtk4::GestureZoom::new();
let weak = Rc::downgrade(self);
pinch.connect_begin(move |_, _| {
if let Some(map) = weak.upgrade() {
let state = map.state.borrow();
state.pinch_origin.set(Some(state.view.get()));
}
});
let weak = Rc::downgrade(self);
pinch.connect_scale_changed(move |gesture, scale| {
let Some(map) = weak.upgrade() else {
return;
};
let Some(origin) = map.state.borrow().pinch_origin.get() else {
return;
};
let (x, y) = gesture
.bounding_box_center()
.unwrap_or_else(|| map.middle());
let now = map.state.borrow().view.get().scale;
map.zoom_at(x, y, origin.scale * scale / now);
});
self.area.add_controller(pinch);
let weak = Rc::downgrade(self);
self.area
.connect_query_tooltip(move |_, x, y, _keyboard, tooltip| {
let Some(map) = weak.upgrade() else {
return false;
};
let Some(text) = map.tooltip_at(f64::from(x), f64::from(y)) else {
return false;
};
tooltip.set_text(Some(&text));
true
});
}
fn middle(&self) -> (f64, f64) {
(
f64::from(self.area.width()) / 2.0,
f64::from(self.area.height()) / 2.0,
)
}
/// The whole world's width at the least zoom: the widget's own width.
fn min_scale(&self) -> f64 {
f64::from(self.area.width().max(self.area.height()).max(1))
}
fn zoom_at_middle(&self, factor: f64) {
let (x, y) = self.middle();
self.zoom_at(x, y, factor);
}
/// Zoom by `factor`, keeping the world point under `(x, y)` where it is.
fn zoom_at(&self, x: f64, y: f64, factor: f64) {
let min = self.min_scale();
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
{
let state = self.state.borrow();
let view = state.view.get();
let scale = (view.scale * factor).clamp(min, min * MAX_ZOOM);
let under = World {
x: view.center.x + (x - width / 2.0) / view.scale,
y: view.center.y + (y - height / 2.0) / view.scale,
};
state.moved.set(true);
state.view.set(View {
center: World {
x: under.x - (x - width / 2.0) / scale,
y: under.y - (y - height / 2.0) / scale,
},
scale,
});
}
self.clamp_view();
self.area.queue_draw();
}
/// Keep the world on screen: the map can't be dragged off into nothing.
fn clamp_view(&self) {
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
let state = self.state.borrow();
let mut view = state.view.get();
view.center.x = clamp_axis(view.center.x, width / 2.0 / view.scale);
view.center.y = clamp_axis(view.center.y, height / 2.0 / view.scale);
state.view.set(view);
}
/// Frame every place, or the whole world when there are none.
fn fit(&self, width: f64, height: f64) {
let min = self.min_scale();
let state = self.state.borrow();
let view = match fit_view(&state.places, width, height) {
Some(view) => View {
scale: view.scale.clamp(min, min * FIT_MAX_ZOOM),
..view
},
None => View {
center: World { x: 0.5, y: 0.5 },
scale: min,
},
};
state.view.set(view);
state.fitted.set(true);
drop(state);
self.clamp_view();
}
fn draw(&self, area: &gtk4::DrawingArea, cr: &gtk4::cairo::Context, width: f64, height: f64) {
if width < 1.0 || height < 1.0 {
return;
}
if !self.state.borrow().fitted.get() {
self.fit(width, height);
}
let state = self.state.borrow();
// Land in the text colour at low alpha, so it reads in light and dark
// alike; bubbles in the accent colour the stylesheet gives the area.
let text = area.parent().map_or_else(|| area.color(), |p| p.color());
let accent = area.color();
let bubbles = paint(
cr,
state.view.get(),
(width, height),
&state.places,
(&text, &accent),
);
state.bubbles.replace(bubbles);
}
}
/// Draw the land and the bubbles for `places` as seen through `view`, and
/// hand back the bubbles for hit-testing.
fn paint(
cr: &gtk4::cairo::Context,
view: View,
(width, height): (f64, f64),
places: &[(PlaceInfo, World)],
(text, accent): (&gtk4::gdk::RGBA, &gtk4::gdk::RGBA),
) -> Vec<Bubble> {
let to_screen = |p: World| {
(
(p.x - view.center.x) * view.scale + width / 2.0,
(p.y - view.center.y) * view.scale + height / 2.0,
)
};
let min = World {
x: view.center.x - width / 2.0 / view.scale,
y: view.center.y - height / 2.0 / view.scale,
};
let max = World {
x: view.center.x + width / 2.0 / view.scale,
y: view.center.y + height / 2.0 / view.scale,
};
for ring in land() {
if ring.max.x < min.x || ring.min.x > max.x || ring.max.y < min.y || ring.min.y > max.y {
continue;
}
let mut last: Option<(f64, f64)> = None;
for &point in &ring.points {
let (x, y) = to_screen(point);
match last {
None => cr.move_to(x, y),
Some((lx, ly)) if (x - lx).hypot(y - ly) < MIN_SEGMENT => continue,
Some(_) => cr.line_to(x, y),
}
last = Some((x, y));
}
cr.close_path();
}
set_color(cr, text, 0.12);
let _ = cr.fill_preserve();
set_color(cr, text, 0.25);
cr.set_line_width(0.75);
let _ = cr.stroke();
let screen: Vec<(f64, f64)> = places.iter().map(|(_, p)| to_screen(*p)).collect();
let counts: Vec<usize> = places.iter().map(|(p, _)| p.photo_count).collect();
let bubbles = cluster(&screen, &counts, width, height);
// Cairo's own text is enough for a few digits, and needs no layout.
cr.select_font_face(
"Sans",
gtk4::cairo::FontSlant::Normal,
gtk4::cairo::FontWeight::Bold,
);
for bubble in &bubbles {
// The last label left a current point; an arc would line up from it.
cr.new_path();
cr.arc(
bubble.x,
bubble.y,
bubble.radius,
0.0,
std::f64::consts::TAU,
);
set_color(cr, accent, 0.85);
let _ = cr.fill_preserve();
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
cr.set_line_width(1.5);
let _ = cr.stroke();
let label = compact_count(bubble.photos);
cr.set_font_size(bubble.radius.clamp(7.0, 14.0));
let Ok(extents) = cr.text_extents(&label) else {
continue;
};
if extents.width() > bubble.radius * 1.8 {
continue;
}
cr.move_to(
bubble.x - extents.width() / 2.0 - extents.x_bearing(),
bubble.y - extents.height() / 2.0 - extents.y_bearing(),
);
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
let _ = cr.show_text(&label);
}
bubbles
}
impl PlacesMap {
/// The bubble under `(x, y)`, the topmost (last drawn) first.
fn bubble_at(&self, x: f64, y: f64) -> Option<Vec<usize>> {
let state = self.state.borrow();
let bubbles = state.bubbles.borrow();
bubbles
.iter()
.rev()
.find(|b| (b.x - x).hypot(b.y - y) <= b.radius)
.map(|b| b.members.clone())
}
fn click(&self, x: f64, y: f64) {
let Some(members) = self.bubble_at(x, y) else {
return;
};
if let [only] = members[..] {
let place = self.state.borrow().places[only].0.clone();
if let Some(open) = self.on_open.borrow().as_ref() {
open(place);
}
return;
}
// A merged bubble zooms in on its towns until they part.
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
let target = {
let state = self.state.borrow();
let chosen: Vec<(PlaceInfo, World)> =
members.iter().map(|&i| state.places[i].clone()).collect();
fit_view(&chosen, width, height)
};
let Some(target) = target else {
return;
};
let min = self.min_scale();
{
let state = self.state.borrow();
let view = state.view.get();
state.moved.set(true);
state.view.set(View {
center: target.center,
// At least one real step in, even when the towns sit on top of
// each other at every zoom the fit would pick.
scale: target
.scale
.max(view.scale * BUTTON_ZOOM_STEP)
.clamp(min, min * MAX_ZOOM),
});
}
self.clamp_view();
self.area.queue_draw();
}
/// "Berlin" over "Germany · 12 photos" for a town, or the number of places
/// over the first few of their names for a merged bubble.
fn tooltip_at(&self, x: f64, y: f64) -> Option<String> {
let members = self.bubble_at(x, y)?;
let state = self.state.borrow();
if let [only] = members[..] {
let place = &state.places[only].0;
return Some(format!("{}\n{}", place.name, place_subtitle(place)));
}
let mut lines = vec![places_subtitle(members.len())];
lines.extend(
members
.iter()
.take(TOOLTIP_NAMES)
.map(|&i| state.places[i].0.name.clone()),
);
if members.len() > TOOLTIP_NAMES {
lines.push("…".to_owned());
}
Some(lines.join("\n"))
}
}
/// How many town names a merged bubble's tooltip lists.
const TOOLTIP_NAMES: usize = 5;
fn map_button(icon: &str, tooltip: &str) -> gtk4::Button {
let button = gtk4::Button::from_icon_name(icon);
button.set_tooltip_text(Some(tooltip));
button.add_css_class("osd");
button
}
/// Keep `center` so that half a view, `half`, either side stays inside 0..1 —
/// or centred, when the view is wider than the world.
fn clamp_axis(center: f64, half: f64) -> f64 {
if half * 2.0 >= 1.0 {
0.5
} else {
center.clamp(half, 1.0 - half)
}
}
/// The view framing `places` in a `width` × `height` widget, with room for
/// the bubbles at the edges. `None` when there is nothing to frame.
fn fit_view(places: &[(PlaceInfo, World)], width: f64, height: f64) -> Option<View> {
let first = places.first()?.1;
let (min, max) = places.iter().fold((first, first), |(min, max), (_, p)| {
(
World {
x: min.x.min(p.x),
y: min.y.min(p.y),
},
World {
x: max.x.max(p.x),
y: max.y.max(p.y),
},
)
});
// A margin of the largest bubble on every side, plus the zoom buttons'
// column on the right.
let margin = 2.0 * bubble_radius(places.iter().map(|(p, _)| p.photo_count).max()?) + 48.0;
let span_x = (max.x - min.x).max(1e-9);
let span_y = (max.y - min.y).max(1e-9);
let scale =
((width - 2.0 * margin).max(1.0) / span_x).min((height - 2.0 * margin).max(1.0) / span_y);
Some(View {
center: World {
x: (min.x + max.x) / 2.0,
y: (min.y + max.y) / 2.0,
},
scale,
})
}
/// Merge places whose bubbles would overlap on screen, the one with the most
/// photos claiming its neighbours. Places off screen are left out.
fn cluster(screen: &[(f64, f64)], counts: &[usize], width: f64, height: f64) -> Vec<Bubble> {
let mut order: Vec<usize> = (0..screen.len()).collect();
order.sort_by(|a, b| counts[*b].cmp(&counts[*a]));
let mut bubbles: Vec<Bubble> = Vec::new();
for index in order {
let (x, y) = screen[index];
let reach = bubble_radius(counts[index]);
if x < -reach || y < -reach || x > width + reach || y > height + reach {
continue;
}
match bubbles
.iter_mut()
.find(|b| (b.x - x).hypot(b.y - y) < b.radius + reach * 0.5)
{
Some(bubble) => {
bubble.photos += counts[index];
bubble.radius = bubble_radius(bubble.photos);
bubble.members.push(index);
}
None => bubbles.push(Bubble {
x,
y,
radius: reach,
photos: counts[index],
members: vec![index],
}),
}
}
// Smallest last, so a small bubble next to a big one stays clickable.
bubbles.sort_by(|a, b| b.radius.total_cmp(&a.radius));
bubbles
}
/// "7", "840", "1.2k", "35k": short enough to fit inside a bubble. Digits
/// and the "k" read the same in every language the app ships.
fn compact_count(n: usize) -> String {
match n {
0..1_000 => n.to_string(),
1_000..10_000 => format!("{:.1}k", n as f64 / 1_000.0),
_ => format!("{}k", n / 1_000),
}
}
fn set_color(cr: &gtk4::cairo::Context, color: &gtk4::gdk::RGBA, alpha: f32) {
cr.set_source_rgba(
f64::from(color.red()),
f64::from(color.green()),
f64::from(color.blue()),
f64::from(color.alpha() * alpha),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_puts_the_equator_and_meridian_in_the_middle() {
let p = project(0.0, 0.0);
assert!((p.x - 0.5).abs() < 1e-9 && (p.y - 0.5).abs() < 1e-9);
// North is up, east is right.
let berlin = project(52.52, 13.40);
assert!(berlin.x > 0.5 && berlin.y < 0.5);
}
#[test]
fn every_land_ring_parses() {
assert_eq!(land().len(), LAND.lines().count());
assert!(land().iter().all(|ring| ring.points.len() >= 4));
}
#[test]
fn overlapping_towns_merge_into_one_bubble() {
let screen = [(100.0, 100.0), (104.0, 102.0), (400.0, 300.0)];
let counts = [3, 10, 1];
let bubbles = cluster(&screen, &counts, 800.0, 600.0);
assert_eq!(bubbles.len(), 2);
let merged = bubbles.iter().find(|b| b.members.len() == 2).unwrap();
// The town with more photos claims the other and keeps its position.
assert_eq!(merged.members, vec![1, 0]);
assert_eq!((merged.x, merged.photos), (104.0, 13));
}
#[test]
fn towns_off_screen_get_no_bubble() {
let bubbles = cluster(&[(-500.0, 10.0)], &[1], 800.0, 600.0);
assert!(bubbles.is_empty());
}
#[test]
fn counts_shorten_to_fit_a_bubble() {
assert_eq!(compact_count(840), "840");
assert_eq!(compact_count(1_240), "1.2k");
assert_eq!(compact_count(35_900), "35k");
}
}
+20
View File
@@ -64,11 +64,31 @@ pub(crate) fn init() {
tracing::warn!("bindtextdomain failed: {e}"); tracing::warn!("bindtextdomain failed: {e}");
} }
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8"); 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) { if let Err(e) = gettextrs::textdomain(DOMAIN) {
tracing::warn!("textdomain failed: {e}"); 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 /// 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 /// 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 /// a configured locale also gets its message locale moved to a real one. Both
+34
View File
@@ -11,6 +11,39 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [2.3.0] — 2026-09-28
Schema: **33**. Photos gain their location; the first timeline refresh after updating reads
the metadata of the whole library once (nothing is downloaded), and the Places view shows how
far it has got.
### Added
- **Places.** A third Photos view groups photos by the town they were taken in, as cards or on a
world map. Towns are looked up offline in bundled GeoNames data (CC BY 4.0), and the map is
drawn offline from Natural Earth land outlines (public domain), so no map tiles are fetched.
Install `iso-codes`, a new optional dependency, for country names in your language.
- **On this day.** A strip at the top of the timeline shows a card for every earlier year with
photos taken on today's date.
- **Find duplicates.** Photos can list every set of byte-identical copies and move the copies
you don't keep to Trash. The copy in the most albums is kept by default.
- **Photos in no album.** A filter narrows the timeline to photos no album holds yet.
- **One Filters popover.** Favorites, "in no album" and the month jump now share a Filters
button, which shows how many filters are on and can clear them all at once.
- **Zoom-level grouping and range select.** Zooming out groups the timeline by month, then by
year. Shift+click selects a range, and dragging across the timeline selects what it sweeps.
- **Slideshow.** The photo viewer plays the loaded photos full screen, with F5 or a top-bar
button.
- **App icon in the app.** The sidebar header and the login page show the app's own icon.
### Fixed
- **Photos grid.** The timeline is laid out as square tiles that fill the window's width
exactly, and a theme's own `gtk.css` can no longer pad the tiles. It stays at its top, and at
the photo you were looking at, when the window is resized or zoomed.
- **Timeline shaking near the top.** Scrolling up to the "On this day" strip no longer makes the
whole page jump between two heights.
- **Filters while loading.** Changing a filter while a page was still loading could leave the
timeline showing the old filter.
## [2.2.2] — 2026-09-27 ## [2.2.2] — 2026-09-27
Schema: **32** (unchanged). Schema: **32** (unchanged).
@@ -1047,6 +1080,7 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[2.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.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.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.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
+2 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=2.2.2 pkgver=2.3.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
@@ -21,6 +21,7 @@ provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime. # Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service' optdepends=('gnome-keyring: credential storage via Secret Service'
'ffmpeg: video thumbnails' 'ffmpeg: video thumbnails'
'iso-codes: country names in your language under Places'
'xdg-utils: "Open folder" action from the tray' 'xdg-utils: "Open folder" action from the tray'
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)' 'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
'foot: terminal for pdfs-prompt --fzf started from a keybinding') 'foot: terminal for pdfs-prompt --fzf started from a keybinding')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 2.2.1 pkgver = 2.2.2
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
@@ -15,26 +15,27 @@ pkgbase = proton-drive-for-linux-bin
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails optdepends = ffmpeg: video thumbnails
optdepends = iso-codes: country names in your language under Places
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=2.2.1 provides = proton-drive-for-linux=2.2.2
provides = proton-drive-linux=2.2.1 provides = proton-drive-linux=2.2.2
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug options = !debug
source = proton-drive-linux-2.2.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.1/proton-drive-linux-2.2.1-x86_64.tar.gz source = proton-drive-linux-2.2.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.2/proton-drive-linux-2.2.2-x86_64.tar.gz
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/packaging/io.narl.proton-drive-linux.desktop source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/packaging/io.narl.proton-drive-linux-tray.desktop source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/packaging/io.narl.proton-drive-linux.svg source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/io.narl.proton-drive-linux.svg
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/packaging/proton-drive.service source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/packaging/proton-drive.service
source = LICENSE-2.2.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/LICENSE source = LICENSE-2.2.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.2/LICENSE
sha256sums = 69f0d6996feb95b18d81bb96e9373c61fddb64ab17b50c94cd83ef8d5e0a1425 sha256sums = a8b856464c3c235699def1b22b5b07ad099f61257c557f034bcc10ca9a76a890
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123 sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=2.2.1 pkgver=2.2.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') arch=('x86_64')
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails' 'ffmpeg: video thumbnails'
'iso-codes: country names in your language under Places'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -30,7 +31,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive.service::$_raw/packaging/proton-drive.service" "proton-drive.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('69f0d6996feb95b18d81bb96e9373c61fddb64ab17b50c94cd83ef8d5e0a1425' sha256sums=('a8b856464c3c235699def1b22b5b07ad099f61257c557f034bcc10ca9a76a890'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 2.2.1.r0.g5109427 pkgver = 2.2.2.r0.g0b81b76
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
@@ -18,13 +18,14 @@ pkgbase = proton-drive-for-linux-git
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails optdepends = ffmpeg: video thumbnails
optdepends = iso-codes: country names in your language under Places
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=2.2.1.r0.g5109427 provides = proton-drive-for-linux=2.2.2.r0.g0b81b76
provides = proton-drive-linux=2.2.1.r0.g5109427 provides = proton-drive-linux=2.2.2.r0.g0b81b76
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=2.2.1.r0.g5109427 pkgver=2.2.2.r0.g0b81b76
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -12,6 +12,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
makedepends=('cargo' 'git' 'gettext') makedepends=('cargo' 'git' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails' 'ffmpeg: video thumbnails'
'iso-codes: country names in your language under Places'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 2.2.1 pkgver = 2.2.2
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
@@ -17,18 +17,19 @@ pkgbase = proton-drive-for-linux
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails optdepends = ffmpeg: video thumbnails
optdepends = iso-codes: country names in your language under Places
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=2.2.1 provides = proton-drive-linux=2.2.2
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto options = !lto
source = proton-drive-for-linux-2.2.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.1.tar.gz source = proton-drive-for-linux-2.2.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.2.tar.gz
sha256sums = 4635716908232896bfb0bf16d0f10300dc2eb59c27c7693607a21eabcac2edff sha256sums = 23d9a3477f1dccc399bf8e90754c1be64302da86c1a220790e543d2e33de4d26
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=2.2.1 pkgver=2.2.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') arch=('x86_64')
@@ -12,6 +12,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
makedepends=('cargo' 'gettext') makedepends=('cargo' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails' 'ffmpeg: video thumbnails'
'iso-codes: country names in your language under Places'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -22,7 +23,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('4635716908232896bfb0bf16d0f10300dc2eb59c27c7693607a21eabcac2edff') sha256sums=('23d9a3477f1dccc399bf8e90754c1be64302da86c1a220790e543d2e33de4d26')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"
+1
View File
@@ -39,6 +39,7 @@ Requires: xdg-utils
# DE-specific; do not Require a single desktop environment. # DE-specific; do not Require a single desktop environment.
Recommends: gnome-keyring Recommends: gnome-keyring
Recommends: iso-codes
Recommends: gnome-shell-extension-appindicator Recommends: gnome-shell-extension-appindicator
Recommends: kwallet Recommends: kwallet
+3
View File
@@ -4,10 +4,13 @@ crates/pdfs-gui/src/app/pages/activity.rs
crates/pdfs-gui/src/app/pages/albums.rs crates/pdfs-gui/src/app/pages/albums.rs
crates/pdfs-gui/src/app/pages/browser.rs crates/pdfs-gui/src/app/pages/browser.rs
crates/pdfs-gui/src/app/pages/devices.rs crates/pdfs-gui/src/app/pages/devices.rs
crates/pdfs-gui/src/app/pages/duplicates.rs
crates/pdfs-gui/src/app/pages/locations.rs crates/pdfs-gui/src/app/pages/locations.rs
crates/pdfs-gui/src/app/pages/login.rs crates/pdfs-gui/src/app/pages/login.rs
crates/pdfs-gui/src/app/pages/photo_viewer.rs crates/pdfs-gui/src/app/pages/photo_viewer.rs
crates/pdfs-gui/src/app/pages/photos.rs crates/pdfs-gui/src/app/pages/photos.rs
crates/pdfs-gui/src/app/pages/places.rs
crates/pdfs-gui/src/app/pages/places_map.rs
crates/pdfs-gui/src/app/pages/shared.rs crates/pdfs-gui/src/app/pages/shared.rs
crates/pdfs-gui/src/app/pages/shared_by_me.rs crates/pdfs-gui/src/app/pages/shared_by_me.rs
crates/pdfs-gui/src/app/pages/status.rs crates/pdfs-gui/src/app/pages/status.rs
+502 -293
View File
File diff suppressed because it is too large. Load diff
+501 -293
View File
File diff suppressed because it is too large. Load diff
+503 -293
View File
File diff suppressed because it is too large. Load diff
+501 -293
View File
File diff suppressed because it is too large. Load diff
+502 -293
View File
File diff suppressed because it is too large. Load diff
+502 -293
View File
File diff suppressed because it is too large. Load diff
+488 -293
View File
File diff suppressed because it is too large. Load diff
+507 -293
View File
File diff suppressed because it is too large. Load diff
+502 -293
View File
File diff suppressed because it is too large. Load diff
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Regenerate crates/pdfs-core/data/cities15000.tsv from a GeoNames dump.
Usage:
curl -LO https://download.geonames.org/export/dump/cities15000.zip
unzip cities15000.zip
scripts/geonames-cities.py cities15000.txt crates/pdfs-core/data/cities15000.tsv
GeoNames publishes the dump under CC BY 4.0; the About dialog credits it.
The output keeps id, name, latitude, longitude and country code, sorted by id.
Two kinds of rows are dropped, so that a photo lands in the town people would
name rather than in a part of it:
- Feature codes for sections of a city (PPLX, such as Kreuzberg) and for
places that no longer exist (PPLH, PPLQ, PPLW).
- Districts that GeoNames lists as towns of their own but that are named after
a town at least five times their size within 20 km in the same country,
such as "Paris 15 Vaugirard" or "Marseille 01".
"""
import math
import sys
SKIPPED_FEATURES = {"PPLX", "PPLH", "PPLQ", "PPLW"}
DISTRICT_RADIUS_KM = 20
DISTRICT_RATIO = 5
def distance_km(a, b):
lat1, lat2 = math.radians(a[0]), math.radians(b[0])
dlon = math.radians(b[1] - a[1])
h = (
math.sin((lat2 - lat1) / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
return 2 * 6371 * math.asin(min(1, math.sqrt(h)))
def main(source, target):
towns = []
with open(source, encoding="utf-8") as dump:
for line in dump:
f = line.rstrip("\n").split("\t")
if f[7] in SKIPPED_FEATURES:
continue
towns.append(
{
"id": int(f[0]),
"name": f[1],
"at": (float(f[4]), float(f[5])),
"country": f[8],
"population": int(f[14] or 0),
}
)
cells = {}
for town in towns:
key = (math.floor(town["at"][0]), math.floor(town["at"][1]))
cells.setdefault(key, []).append(town)
def is_district(town):
row, column = math.floor(town["at"][0]), math.floor(town["at"][1])
for dlat in (-1, 0, 1):
for dlon in (-1, 0, 1):
for other in cells.get((row + dlat, column + dlon), []):
if (
other is not town
and other["country"] == town["country"]
and town["name"].startswith(other["name"] + " ")
and other["population"] >= DISTRICT_RATIO * town["population"]
and distance_km(town["at"], other["at"]) <= DISTRICT_RADIUS_KM
):
return True
return False
kept = sorted((t for t in towns if not is_district(t)), key=lambda t: t["id"])
with open(target, "w", encoding="utf-8") as out:
for t in kept:
lat, lon = t["at"]
out.write(f"{t['id']}\t{t['name']}\t{lat:.4f}\t{lon:.4f}\t{t['country']}\n")
print(f"{len(kept)} towns written", file=sys.stderr)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Regenerate crates/pdfs-gui/data/land.txt from Natural Earth's land polygons.
Usage:
curl -LO https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_land.geojson
scripts/natural-earth-land.py ne_50m_land.geojson crates/pdfs-gui/data/land.txt
Natural Earth is in the public domain; the About dialog credits it anyway.
The output is one ring per line, as space-separated "longitude,latitude"
pairs rounded to two decimals (about a kilometre). Each ring is simplified
with Ramer-Douglas-Peucker to TOLERANCE degrees, which is finer than the map
can show at its closest zoom, and rings left with fewer than four points
(islets smaller than the tolerance) are dropped. Holes (lakes) are dropped
too: the map fills the land and draws nothing on top of it.
"""
import json
import sys
TOLERANCE = 0.04
def point_line_distance(p, a, b):
(x, y), (x1, y1), (x2, y2) = p, a, b
dx, dy = x2 - x1, y2 - y1
if dx == 0 and dy == 0:
return ((x - x1) ** 2 + (y - y1) ** 2) ** 0.5
t = max(0, min(1, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy)))
return ((x - x1 - t * dx) ** 2 + (y - y1 - t * dy) ** 2) ** 0.5
def simplify(points):
"""Ramer-Douglas-Peucker, iterative so long coastlines don't recurse deep."""
keep = [False] * len(points)
keep[0] = keep[-1] = True
stack = [(0, len(points) - 1)]
while stack:
first, last = stack.pop()
worst, index = 0.0, None
for i in range(first + 1, last):
d = point_line_distance(points[i], points[first], points[last])
if d > worst:
worst, index = d, i
if index is not None and worst > TOLERANCE:
keep[index] = True
stack.append((first, index))
stack.append((index, last))
return [p for p, k in zip(points, keep) if k]
def main(source, target):
features = json.load(open(source))["features"]
lines = []
for feature in features:
geometry = feature["geometry"]
polygons = (
geometry["coordinates"]
if geometry["type"] == "MultiPolygon"
else [geometry["coordinates"]]
)
for polygon in polygons:
ring = simplify(polygon[0])
if len(ring) < 4:
continue
lines.append(" ".join(f"{lon:.2f},{lat:.2f}" for lon, lat in ring))
with open(target, "w") as out:
out.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])