mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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).
This commit is contained in:
1 parent
2757977fd6
commit
753056418c
40 files changed
+35708
-2360
No files matched your search
Generated
+4
-4
@@ -3746,9 +3746,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.6.6"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757"
|
||||
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3773,9 +3773,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.6.6"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce"
|
||||
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.6.6" }
|
||||
proton-drive-rs = { version = "0.6.6" }
|
||||
proton-sdk = { version = "0.7.0" }
|
||||
proton-drive-rs = { version = "0.7.0" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -75,6 +75,7 @@ pdfs-app # the desktop app
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0); install `iso-codes` for country names in your language.
|
||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
@@ -91,6 +91,18 @@ pub enum Request {
|
||||
/// Sets of photos that are byte-identical copies of each other, going by
|
||||
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
||||
PhotoDuplicates,
|
||||
/// The towns the photos were taken in, the one with the most photos first.
|
||||
/// Only photos whose uploader recorded a location count. Replies with
|
||||
/// [`Response::Places`].
|
||||
PhotoPlaces,
|
||||
/// Fetch a page of the photos taken in one town, newest first. `id` is the
|
||||
/// town's id from [`Request::PhotoPlaces`]. Replies with
|
||||
/// [`Response::Photos`], like an album page.
|
||||
PlacePhotos {
|
||||
id: u32,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
},
|
||||
/// List the account's photo albums, newest activity first, including the
|
||||
/// albums other people share with us. Metadata only — an album's cover
|
||||
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||
@@ -1435,6 +1447,21 @@ pub struct AlbumInfo {
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// One town in a [`Response::Places`] listing.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PlaceInfo {
|
||||
/// The town's GeoNames id — what [`Request::PlacePhotos`] takes.
|
||||
pub id: u32,
|
||||
/// The town's name, as the town itself spells it.
|
||||
pub name: String,
|
||||
/// ISO 3166-1 alpha-2 code of the town's country.
|
||||
pub country: String,
|
||||
/// How many photos were taken there, counting a RAW+JPEG shot once.
|
||||
pub photo_count: usize,
|
||||
/// The newest photo taken there, shown as the place's cover.
|
||||
pub cover: PhotoItem,
|
||||
}
|
||||
|
||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoItem {
|
||||
@@ -1796,6 +1823,8 @@ pub enum Response {
|
||||
/// 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`]).
|
||||
Places { items: Vec<PlaceInfo> },
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
Thumbs { items: Vec<PhotoThumb> },
|
||||
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
||||
@@ -2489,6 +2518,33 @@ 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 } = 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");
|
||||
}
|
||||
|
||||
/// The sharing and devices requests must survive the same line-delimited JSON
|
||||
/// round-trip: a mangled id or role would touch the wrong share or member.
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 32;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 33;
|
||||
|
||||
impl Db {
|
||||
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(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
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
|
||||
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||
|
||||
/// Schema v33: where each photo was taken.
|
||||
///
|
||||
/// `latitude` and `longitude` come from the location other clients write into
|
||||
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||
///
|
||||
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||
/// the whole library once. That is the backfill: the metadata is on the node
|
||||
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||
const MIGRATION_V33: &str = "
|
||||
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||
UPDATE photos SET resolved_at = NULL;
|
||||
";
|
||||
@@ -59,7 +59,7 @@ pub use ops::{
|
||||
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -42,6 +42,15 @@ pub struct StoredPhoto {
|
||||
pub has_raw: bool,
|
||||
}
|
||||
|
||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||
/// many photos it holds, and the newest of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPlace {
|
||||
pub id: u32,
|
||||
pub count: usize,
|
||||
pub cover: StoredPhoto,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||
@@ -58,6 +67,8 @@ pub struct TimelineRow {
|
||||
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||
/// sibling), when the server says it belongs to one.
|
||||
pub main_uid: Option<String>,
|
||||
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
|
||||
pub location: Option<(f64, f64)>,
|
||||
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||
/// makes every other field authoritative — including a `None` that means
|
||||
/// "the server no longer reports one" — while `None` means the refresh
|
||||
@@ -86,6 +97,7 @@ struct Learned {
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
main_uid: Option<String>,
|
||||
location: Option<(f64, f64)>,
|
||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||
/// does not make it look unresolved again.
|
||||
resolved_at: Option<i64>,
|
||||
@@ -281,7 +293,7 @@ impl Db {
|
||||
let learned: HashMap<String, Learned> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||
main_uid, resolved_at FROM photos",
|
||||
main_uid, resolved_at, latitude, longitude FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
@@ -295,6 +307,9 @@ impl Db {
|
||||
content_hash: r.get(6)?,
|
||||
main_uid: r.get(7)?,
|
||||
resolved_at: r.get(8)?,
|
||||
location: r
|
||||
.get::<_, Option<f64>>(9)?
|
||||
.zip(r.get::<_, Option<f64>>(10)?),
|
||||
},
|
||||
))
|
||||
})?;
|
||||
@@ -319,12 +334,14 @@ impl Db {
|
||||
carried.favorite = row.favorite.unwrap_or(false);
|
||||
carried.content_hash = row.content_hash.clone();
|
||||
carried.main_uid = row.main_uid.clone();
|
||||
carried.location = row.location;
|
||||
} else {
|
||||
carried.name = row.name.clone().or(carried.name);
|
||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||
carried.location = row.location.or(carried.location);
|
||||
}
|
||||
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
@@ -355,8 +372,10 @@ impl Db {
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
content_hash, main_uid, group_key, resolved_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||
place_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||
?16)",
|
||||
)?;
|
||||
for (seq, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
@@ -375,6 +394,12 @@ impl Db {
|
||||
carried.main_uid,
|
||||
group_key,
|
||||
carried.resolved_at,
|
||||
carried.location.map(|(lat, _)| lat),
|
||||
carried.location.map(|(_, lon)| lon),
|
||||
carried
|
||||
.location
|
||||
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||
.map(|city| city.id),
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -617,6 +642,61 @@ impl Db {
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -293,6 +293,70 @@ fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
pub mod opener;
|
||||
pub mod places;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
|
||||
//! position, without asking any service.
|
||||
//!
|
||||
//! The towns are GeoNames' `cities15000` dump (every place with more than
|
||||
//! 15,000 people), trimmed to id, name, position and country in
|
||||
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
|
||||
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
|
||||
//! lands in the town people would name. GeoNames publishes the dump under
|
||||
//! CC BY 4.0, which is why the About dialog credits it.
|
||||
//!
|
||||
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
|
||||
//! as the `iso-codes` project spells it, so a front end can translate it
|
||||
//! through that project's `iso_3166-1` gettext domain.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How far from the nearest town a photo may be and still count as taken
|
||||
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
|
||||
pub const MAX_DISTANCE_KM: f64 = 50.0;
|
||||
|
||||
/// Mean Earth radius in km, for the haversine distance.
|
||||
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||
|
||||
const CITIES: &str = include_str!("../data/cities15000.tsv");
|
||||
const COUNTRIES: &str = include_str!("../data/countries.tsv");
|
||||
|
||||
/// One town of the table.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct City {
|
||||
/// The GeoNames id, stable across dumps.
|
||||
pub id: u32,
|
||||
pub name: &'static str,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
/// ISO 3166-1 alpha-2 country code.
|
||||
pub country: &'static str,
|
||||
}
|
||||
|
||||
/// The towns, and an index of them by whole-degree cell.
|
||||
struct Table {
|
||||
cities: Vec<City>,
|
||||
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||
}
|
||||
|
||||
fn table() -> &'static Table {
|
||||
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
|
||||
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||
for (index, city) in cities.iter().enumerate() {
|
||||
cells
|
||||
.entry(cell(city.latitude, city.longitude))
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
Table { cities, cells }
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_city(line: &'static str) -> Option<City> {
|
||||
let mut fields = line.split('\t');
|
||||
Some(City {
|
||||
id: fields.next()?.parse().ok()?,
|
||||
name: fields.next()?,
|
||||
latitude: fields.next()?.parse().ok()?,
|
||||
longitude: fields.next()?.parse().ok()?,
|
||||
country: fields.next()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
|
||||
(latitude.floor() as i32, longitude.floor() as i32)
|
||||
}
|
||||
|
||||
/// Great-circle distance between two positions, in km.
|
||||
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
|
||||
let dlat = lat2 - lat1;
|
||||
let dlon = (b.1 - a.1).to_radians();
|
||||
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
|
||||
}
|
||||
|
||||
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
|
||||
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
|
||||
if !latitude.is_finite() || !longitude.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let table = table();
|
||||
// A degree of latitude is about 111 km everywhere; a degree of longitude
|
||||
// shrinks towards the poles, so more cells are needed to cover the radius.
|
||||
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
|
||||
let lon_span = {
|
||||
let scale = latitude.to_radians().cos().abs().max(0.01);
|
||||
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
|
||||
};
|
||||
let (row, column) = cell(latitude, longitude);
|
||||
let mut best: Option<(f64, &City)> = None;
|
||||
for dlat in -lat_span..=lat_span {
|
||||
for dlon in -lon_span..=lon_span {
|
||||
// Longitude wraps at the antimeridian: -181 is 179.
|
||||
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
|
||||
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
|
||||
continue;
|
||||
};
|
||||
for &index in indices {
|
||||
let city = &table.cities[index];
|
||||
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
|
||||
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, city));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, city)| city)
|
||||
}
|
||||
|
||||
/// The town with this GeoNames id.
|
||||
pub fn city(id: u32) -> Option<&'static City> {
|
||||
let cities = &table().cities;
|
||||
// The table is sorted by id.
|
||||
cities
|
||||
.binary_search_by_key(&id, |city| city.id)
|
||||
.ok()
|
||||
.map(|index| &cities[index])
|
||||
}
|
||||
|
||||
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
|
||||
pub fn country_name(code: &str) -> Option<&'static str> {
|
||||
COUNTRIES.lines().find_map(|line| {
|
||||
let (c, name) = line.split_once('\t')?;
|
||||
(c == code).then_some(name)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_position_in_a_town_resolves_to_that_town() {
|
||||
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
|
||||
assert_eq!(berlin.name, "Berlin");
|
||||
assert_eq!(berlin.country, "DE");
|
||||
assert_eq!(city(berlin.id), Some(berlin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_sea_has_no_town() {
|
||||
assert_eq!(nearest(0.0, -30.0), None);
|
||||
assert_eq!(nearest(f64::NAN, 0.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_town_across_the_antimeridian_is_still_found() {
|
||||
// Just east of 180°, the nearest towns in Fiji are west of it.
|
||||
let town = nearest(-16.8, 179.99);
|
||||
assert!(town.is_none_or(|t| t.country == "FJ"));
|
||||
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
|
||||
assert_eq!(suva.country, "FJ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_town_has_a_named_country() {
|
||||
for city in &table().cities {
|
||||
assert!(
|
||||
country_name(city.country).is_some(),
|
||||
"{} has no country name for {}",
|
||||
city.name,
|
||||
city.country
|
||||
);
|
||||
}
|
||||
assert_eq!(country_name("DE"), Some("Germany"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distances_are_great_circle_km() {
|
||||
let d = distance_km((52.52, 13.405), (48.137, 11.575));
|
||||
assert!(
|
||||
(d - 504.0).abs() < 5.0,
|
||||
"Berlin to Munich is about 504 km, got {d}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
||||
Ok(items) => CtlResponse::Places { items },
|
||||
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 {
|
||||
|
||||
@@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine as _;
|
||||
use pdfs_core::control::{
|
||||
FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, ThumbnailBuildStatus, is_raw_image_name,
|
||||
is_thumbnail_image_name,
|
||||
FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, PlaceInfo, ThumbnailBuildStatus,
|
||||
is_raw_image_name, is_thumbnail_image_name,
|
||||
};
|
||||
use pdfs_core::db::{self, StoredPhoto};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
@@ -609,6 +609,8 @@ struct PhotoMeta {
|
||||
media_type: Option<String>,
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
/// Where the photo was taken, as its uploader recorded it.
|
||||
location: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
@@ -739,6 +741,40 @@ impl Core {
|
||||
.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(),
|
||||
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.
|
||||
@@ -1759,6 +1795,11 @@ impl Core {
|
||||
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||||
let key = node.uid.to_string();
|
||||
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
|
||||
let location = node
|
||||
.photo
|
||||
.as_ref()
|
||||
.and_then(|p| p.location)
|
||||
.map(|l| (l.latitude, l.longitude));
|
||||
if let Some(photo) = node.photo.as_ref() {
|
||||
if let Some(main) = photo.main_photo_uid.as_ref() {
|
||||
main_of.insert(key.clone(), main.to_string());
|
||||
@@ -1774,6 +1815,7 @@ impl Core {
|
||||
media_type,
|
||||
favorite,
|
||||
content_hash,
|
||||
location,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1795,6 +1837,7 @@ impl Core {
|
||||
media_type: m.media_type,
|
||||
favorite: Some(m.favorite),
|
||||
content_hash: m.content_hash,
|
||||
location: m.location,
|
||||
main_uid,
|
||||
resolved_at: Some(resolved_at),
|
||||
..db::TimelineRow::new(key, it.capture_time)
|
||||
|
||||
@@ -14,6 +14,7 @@ use pages::locations::*;
|
||||
use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
use pages::photos::*;
|
||||
use pages::places::*;
|
||||
use pages::shared::*;
|
||||
use pages::shared_by_me::*;
|
||||
use pages::status::*;
|
||||
@@ -26,7 +27,7 @@ use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
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};
|
||||
|
||||
@@ -55,9 +56,10 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
|
||||
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
|
||||
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
|
||||
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, send,
|
||||
PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
||||
send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
@@ -551,6 +553,13 @@ fn build_window(app: &adw::Application) {
|
||||
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||
view_switch: gallery_widgets.view_switch.clone(),
|
||||
albums_loading: Cell::new(false),
|
||||
places: gallery_widgets.places.clone(),
|
||||
places_stack: gallery_widgets.places_stack.clone(),
|
||||
places_status: gallery_widgets.places_status.clone(),
|
||||
places_btn: gallery_widgets.places_btn.clone(),
|
||||
places_loading: Cell::new(false),
|
||||
place_count: Cell::new(0),
|
||||
place: RefCell::new(None),
|
||||
album: RefCell::new(None),
|
||||
timeline_stale: Cell::new(false),
|
||||
album_list: RefCell::new(Vec::new()),
|
||||
@@ -736,6 +745,7 @@ fn build_window(app: &adw::Application) {
|
||||
.duplicates
|
||||
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
|
||||
wire_albums(&ui);
|
||||
wire_places(&ui);
|
||||
wire_trash(&ui, &trash_widgets);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||
@@ -1044,12 +1054,12 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||
}
|
||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||
// the two — album grid or timeline/album — is actually on screen.
|
||||
// the views — a grid, or the timeline, an album or a place — is on screen.
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
} else {
|
||||
load_gallery(ui, false);
|
||||
match ui.gallery.content.visible_child_name().as_deref() {
|
||||
Some("albums") => load_albums(ui),
|
||||
Some("places") => load_places(ui),
|
||||
_ => load_gallery(ui, false),
|
||||
}
|
||||
}),
|
||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||
@@ -1338,6 +1348,14 @@ 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.",
|
||||
)),
|
||||
);
|
||||
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(
|
||||
"proton-sdk-rs",
|
||||
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
/// position. The timeline's filters do not apply to albums, so they go.
|
||||
fn show_album_grid(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
close_place(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
}
|
||||
@@ -582,7 +583,7 @@ fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
|
||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||
/// same texture cache, same decode pacing as a timeline tile.
|
||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
@@ -662,6 +663,7 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
close_album(&ui_photos);
|
||||
close_place(&ui_photos);
|
||||
ui_photos.gallery.filters.set_visible(true);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
// The timeline comes back as it was left unless an album took over the
|
||||
@@ -674,10 +676,14 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
}
|
||||
});
|
||||
|
||||
// Back returns to the grid the album was opened from, scrolled where it
|
||||
// was, instead of reloading it.
|
||||
// Back returns to the grid the album or place was opened from, scrolled
|
||||
// where it was, instead of reloading it.
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
if ui_back.gallery.place.borrow().is_some() {
|
||||
show_place_grid(&ui_back);
|
||||
return;
|
||||
}
|
||||
show_album_grid(&ui_back);
|
||||
let count = ui_back.gallery.album_list.borrow().len();
|
||||
ui_back.gallery.title.set_subtitle(&ngettext_f(
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
pub(crate) mod places;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
|
||||
@@ -917,6 +917,9 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
|
||||
if let Some(album) = ui.gallery.album.borrow().as_ref() {
|
||||
return Some(album.photo_count);
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -61,6 +61,18 @@ pub(crate) struct GalleryState {
|
||||
/// True while the album listing is in flight, so a re-entry into the Albums
|
||||
/// view can't stack requests.
|
||||
pub(crate) albums_loading: Cell<bool>,
|
||||
/// The Places grid and its own status page, like the album grid's, and the
|
||||
/// switcher's Places toggle.
|
||||
pub(crate) places: gtk4::FlowBox,
|
||||
pub(crate) places_stack: gtk4::Stack,
|
||||
pub(crate) places_status: adw::StatusPage,
|
||||
pub(crate) places_btn: gtk4::ToggleButton,
|
||||
/// True while the place listing is in flight.
|
||||
pub(crate) places_loading: Cell<bool>,
|
||||
/// How many places the grid shows, for the subtitle when back returns there.
|
||||
pub(crate) place_count: Cell<usize>,
|
||||
/// The place currently open, paged by [`load_gallery`] like an album.
|
||||
pub(crate) place: RefCell<Option<PlaceInfo>>,
|
||||
/// The album currently open, or `None` when the timeline is showing. Set by
|
||||
/// [`open_album`]; read by [`load_gallery`], which pages that album instead
|
||||
/// of the timeline while it is set.
|
||||
@@ -190,6 +202,14 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) select_album: gtk4::Button,
|
||||
}
|
||||
|
||||
impl GalleryState {
|
||||
/// Whether the gallery shows an album or a place rather than the timeline.
|
||||
/// Neither is filtered or dated, so the timeline's extras stay away.
|
||||
pub(crate) fn in_collection(&self) -> bool {
|
||||
self.album.borrow().is_some() || self.place.borrow().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||
pub(crate) const PHOTOS_PAGE: usize = 200;
|
||||
|
||||
@@ -385,6 +405,10 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
pub(crate) places: gtk4::FlowBox,
|
||||
pub(crate) places_stack: gtk4::Stack,
|
||||
pub(crate) places_status: adw::StatusPage,
|
||||
pub(crate) places_btn: gtk4::ToggleButton,
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
@@ -678,11 +702,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.label(gettext("Albums"))
|
||||
.build();
|
||||
albums_btn.set_group(Some(&photos_btn));
|
||||
let places_btn = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Places"))
|
||||
.build();
|
||||
places_btn.set_group(Some(&photos_btn));
|
||||
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
view_switch.add_css_class("linked");
|
||||
view_switch.add_css_class("view-switch");
|
||||
view_switch.set_halign(gtk4::Align::Start);
|
||||
for btn in [&photos_btn, &albums_btn] {
|
||||
for btn in [&photos_btn, &albums_btn, &places_btn] {
|
||||
btn.add_css_class("pill");
|
||||
view_switch.append(btn);
|
||||
}
|
||||
@@ -761,12 +789,38 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
albums_page.append(&new_album);
|
||||
albums_page.append(&albums_stack);
|
||||
|
||||
// The place grid: the same cards, one per town the photos were taken in.
|
||||
let places = gtk4::FlowBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.homogeneous(true)
|
||||
.row_spacing(TILE_GAP as u32 * 2)
|
||||
.column_spacing(TILE_GAP as u32 * 2)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(8)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
let places_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&places)
|
||||
.build();
|
||||
let places_status = adw::StatusPage::builder()
|
||||
.icon_name("mark-location-symbolic")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
places_status.add_css_class("compact");
|
||||
let places_stack = gtk4::Stack::new();
|
||||
places_stack.set_vexpand(true);
|
||||
places_stack.add_named(&places_scroll, Some("grid"));
|
||||
places_stack.add_named(&places_status, Some("status"));
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&timeline, Some("timeline"));
|
||||
content.add_named(&status, Some("status"));
|
||||
content.add_named(&albums_page, Some("albums"));
|
||||
content.add_named(&places_stack, Some("places"));
|
||||
|
||||
let import_banner = adw::Banner::builder()
|
||||
.title(gettext("Importing from Google Photos…"))
|
||||
@@ -836,6 +890,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
photos_btn,
|
||||
albums_btn,
|
||||
view_switch,
|
||||
places,
|
||||
places_stack,
|
||||
places_status,
|
||||
places_btn,
|
||||
back,
|
||||
filters,
|
||||
},
|
||||
@@ -2527,9 +2585,9 @@ pub(crate) fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
||||
if loaded == 0 {
|
||||
ui.gallery.title.set_subtitle("");
|
||||
}
|
||||
// An album counts what the server says it holds, not how much of it has been
|
||||
// An album or a place counts what it holds, not how much of it has been
|
||||
// paged in — the subtitle would otherwise climb as the user scrolls.
|
||||
if ui.gallery.album.borrow().is_some() {
|
||||
if ui.gallery.in_collection() {
|
||||
return;
|
||||
}
|
||||
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
||||
@@ -2748,7 +2806,7 @@ fn fill_scrubber(ui: &Rc<Ui>, months: &[PhotoMonth]) {
|
||||
/// Show the scrubber only where a month jump makes sense: the whole timeline,
|
||||
/// not an album, a date window, the favorites or the unfiled photos.
|
||||
pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
|
||||
let whole = ui.gallery.album.borrow().is_none()
|
||||
let whole = !ui.gallery.in_collection()
|
||||
&& ui.gallery.range.get().is_none()
|
||||
&& !ui.gallery.favorites.get()
|
||||
&& !ui.gallery.not_in_album.get();
|
||||
@@ -2888,7 +2946,7 @@ fn sync_memories(ui: &Rc<Ui>) {
|
||||
.list
|
||||
.vadjustment()
|
||||
.is_none_or(|adj| adj.value() < 1.0);
|
||||
let whole = ui.gallery.album.borrow().is_none()
|
||||
let whole = !ui.gallery.in_collection()
|
||||
&& ui.gallery.kind.get().is_none()
|
||||
&& ui.gallery.range.get().is_none()
|
||||
&& !ui.gallery.favorites.get()
|
||||
@@ -3164,9 +3222,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// An open album pages itself instead of the timeline; everything downstream —
|
||||
// the model, the sections, the thumbnails, the lightbox — is the same.
|
||||
// An open album or place pages itself instead of the timeline; everything
|
||||
// downstream — the model, the sections, the thumbnails, the lightbox — is
|
||||
// the same.
|
||||
let album = ui.gallery.album.borrow().as_ref().map(|a| a.uid.clone());
|
||||
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
|
||||
let collection = album.is_some() || place.is_some();
|
||||
if !append {
|
||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||
ui.gallery.model.remove_all();
|
||||
@@ -3181,11 +3242,11 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
||||
// dropdown then would fight the selection the user just made. An album
|
||||
// has no date jump at all.
|
||||
if album.is_none() && ui.gallery.range.get().is_none() {
|
||||
if !collection && ui.gallery.range.get().is_none() {
|
||||
refresh_photo_months(ui);
|
||||
}
|
||||
sync_scrubber(ui);
|
||||
if album.is_none() {
|
||||
if !collection {
|
||||
refresh_memories(ui);
|
||||
}
|
||||
sync_memories(ui);
|
||||
@@ -3201,9 +3262,10 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
ui.gallery.pager.set_spinning(append);
|
||||
|
||||
ui.busy_begin();
|
||||
let request = match album {
|
||||
Some(uid) => Request::AlbumPhotos { uid, offset, limit },
|
||||
None => Request::PhotosTimeline {
|
||||
let request = match (album, place) {
|
||||
(Some(uid), _) => Request::AlbumPhotos { uid, offset, limit },
|
||||
(None, Some(id)) => Request::PlacePhotos { id, offset, limit },
|
||||
(None, None) => Request::PhotosTimeline {
|
||||
offset,
|
||||
limit,
|
||||
kind: ui.gallery.kind.get(),
|
||||
@@ -3305,7 +3367,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
// and an empty filter is not a library that needs photos.
|
||||
ui.gallery
|
||||
.empty_actions
|
||||
.set_visible(ui.gallery.album.borrow().is_none() && !filtered);
|
||||
.set_visible(!ui.gallery.in_collection() && !filtered);
|
||||
return;
|
||||
}
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 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;
|
||||
|
||||
/// 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);
|
||||
match result {
|
||||
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_stack.set_visible_child_name("grid");
|
||||
}
|
||||
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."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -64,11 +64,31 @@ pub(crate) fn init() {
|
||||
tracing::warn!("bindtextdomain failed: {e}");
|
||||
}
|
||||
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
|
||||
// Country names come translated from the iso-codes catalogs, which live in
|
||||
// the system's own locale directory.
|
||||
let _ = gettextrs::bind_textdomain_codeset(ISO_3166_DOMAIN, "UTF-8");
|
||||
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
||||
tracing::warn!("textdomain failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The iso-codes text domain that translates ISO 3166-1 country names.
|
||||
const ISO_3166_DOMAIN: &str = "iso_3166-1";
|
||||
|
||||
/// The name of the country with ISO 3166-1 code `code`, in the user's language.
|
||||
/// The English names are the iso-codes project's own, so that project's
|
||||
/// catalogs translate them; Kosovo has no ISO code of its own and is
|
||||
/// translated here. An unknown code is shown as it is.
|
||||
pub(crate) fn country_name(code: &str) -> String {
|
||||
if code == "XK" {
|
||||
return gettext("Kosovo");
|
||||
}
|
||||
match pdfs_core::places::country_name(code) {
|
||||
Some(name) => gettextrs::dgettext(ISO_3166_DOMAIN, name),
|
||||
None => code.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
|
||||
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
|
||||
/// a configured locale also gets its message locale moved to a real one. Both
|
||||
|
||||
@@ -21,6 +21,7 @@ provides=('pdfs')
|
||||
# Files-on-demand mount + keyring need these at runtime.
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'xdg-utils: "Open folder" action from the tray'
|
||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||
|
||||
@@ -15,6 +15,7 @@ pkgbase = proton-drive-for-linux-bin
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = iso-codes: country names in your language under Places
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
|
||||
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
|
||||
@@ -18,6 +18,7 @@ pkgbase = proton-drive-for-linux-git
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = iso-codes: country names in your language under Places
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
|
||||
@@ -12,6 +12,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
makedepends=('cargo' 'git' 'gettext')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
|
||||
@@ -17,6 +17,7 @@ pkgbase = proton-drive-for-linux
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = iso-codes: country names in your language under Places
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
|
||||
@@ -12,6 +12,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
makedepends=('cargo' 'gettext')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'iso-codes: country names in your language under Places'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
|
||||
@@ -39,6 +39,7 @@ Requires: xdg-utils
|
||||
|
||||
# DE-specific; do not Require a single desktop environment.
|
||||
Recommends: gnome-keyring
|
||||
Recommends: iso-codes
|
||||
Recommends: gnome-shell-extension-appindicator
|
||||
Recommends: kwallet
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ crates/pdfs-gui/src/app/pages/locations.rs
|
||||
crates/pdfs-gui/src/app/pages/login.rs
|
||||
crates/pdfs-gui/src/app/pages/photo_viewer.rs
|
||||
crates/pdfs-gui/src/app/pages/photos.rs
|
||||
crates/pdfs-gui/src/app/pages/places.rs
|
||||
crates/pdfs-gui/src/app/pages/shared.rs
|
||||
crates/pdfs-gui/src/app/pages/shared_by_me.rs
|
||||
crates/pdfs-gui/src/app/pages/status.rs
|
||||
|
||||
+314
-258
File diff suppressed because it is too large.
Load diff
+314
-258
File diff suppressed because it is too large.
Load diff
+314
-258
File diff suppressed because it is too large.
Load diff
Executable
+86
@@ -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])
|
||||
Reference in new issue
Block a user