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:
nvrl committed 2026-09-28 02:12:59 +02:00
1 parent 2757977fd6
commit 753056418c
40 files changed
+35708 -2360

No files matched your search

+56
View File
@@ -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]
+34 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 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;
";
+1 -1
View File
@@ -59,7 +59,7 @@ pub use ops::{
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
+83 -3
View File
@@ -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.
///
+64
View File
@@ -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]
+1
View File
@@ -14,6 +14,7 @@ pub mod localindex;
pub mod menu;
pub mod mounts;
pub mod opener;
pub mod places;
pub mod profile;
pub mod sdkcache;
pub mod search;
+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}"
);
}
}