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

+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])