Kanban-Archiv mit automatischem Löschen, Reise-Karte als Bild herunterladen
Kanban: - Erledigte Aufgaben wandern 2 Tage nach "Erledigt" ins Archiv (Button "Archiv (n)" im Board, dort "Zurückholen") und werden nach 14 Tagen automatisch gelöscht (stündlich im Hintergrund und beim Öffnen) - Neue Spalte done_at; schon erledigte Aufgaben zählen ab dem Update Reisen: - "Karte herunterladen": eigene Städte und besuchte Länder als PNG (4000/8000 px, nur besuchte Hauptstädte beschriftet) oder SVG (kleine Punkte, alle Städte beschriftet, beliebig zoombar); Ausschnitt "Meine Orte" oder ganze Welt; Beschriftungen weichen einander aus - Hauptstadt-Kennzeichen (GeoNames PPLC) wird beim Hinzufügen gespeichert und für ältere Städte im Hintergrund bei Open-Meteo nachgetragen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0e17b06b5
commit
dc4ce18593
9 files changed
+489
-12
No files matched your search
+43
-3
@@ -6,7 +6,9 @@ Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames,
|
||||
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
|
||||
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
@@ -31,7 +33,10 @@ CACHE_OK = 30 * 24 * 3600 # Infos ändern sich selten
|
||||
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
|
||||
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
|
||||
|
||||
CAPITALS_SECONDS = 6 * 3600
|
||||
|
||||
_search_cache: dict[str, list] = {}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _city(row) -> dict:
|
||||
@@ -39,6 +44,7 @@ def _city(row) -> dict:
|
||||
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
|
||||
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
|
||||
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
|
||||
"capital": None if row["capital"] is None else bool(row["capital"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -107,18 +113,20 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
timezone = data.get("timezone")
|
||||
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
|
||||
timezone = None
|
||||
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach
|
||||
capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
|
||||
values = (
|
||||
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
|
||||
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||
population, elevation, timezone, now_iso(),
|
||||
population, elevation, timezone, capital, now_iso(),
|
||||
)
|
||||
with transaction() as conn:
|
||||
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
|
||||
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
|
||||
conn.execute(
|
||||
"""INSERT INTO travel_cities
|
||||
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||||
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||||
values,
|
||||
)
|
||||
return _state(conn, user["id"])
|
||||
@@ -180,6 +188,7 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
|
||||
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
|
||||
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
|
||||
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
|
||||
"capital": r.get("feature_code") == "PPLC",
|
||||
}
|
||||
for r in res.json().get("results", [])
|
||||
if r.get("country_code") and r.get("id")
|
||||
@@ -190,6 +199,37 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
|
||||
return results
|
||||
|
||||
|
||||
# ---------- Hauptstadt-Kennzeichen für ältere Einträge nachtragen ----------
|
||||
|
||||
|
||||
async def fill_capitals() -> None:
|
||||
"""Städte ohne Hauptstadt-Angabe bei Open-Meteo nachschlagen (GeoNames-Merkmal PPLC)."""
|
||||
with transaction() as conn:
|
||||
ids = [r[0] for r in conn.execute("SELECT DISTINCT geoname_id FROM travel_cities WHERE capital IS NULL")]
|
||||
if not ids:
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
|
||||
for geoname_id in ids:
|
||||
try:
|
||||
res = await client.get("https://geocoding-api.open-meteo.com/v1/get", params={"id": geoname_id})
|
||||
res.raise_for_status()
|
||||
capital = int(res.json().get("feature_code") == "PPLC")
|
||||
except (httpx.HTTPError, ValueError):
|
||||
continue # beim nächsten Durchlauf erneut versuchen
|
||||
with transaction() as conn:
|
||||
conn.execute("UPDATE travel_cities SET capital = ? WHERE geoname_id = ? AND capital IS NULL", (capital, geoname_id))
|
||||
await asyncio.sleep(0.2) # Open-Meteo schonen
|
||||
|
||||
|
||||
async def capitals_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await fill_capitals()
|
||||
except Exception: # nie die Schleife beenden
|
||||
log.exception("Hauptstädte nachtragen fehlgeschlagen")
|
||||
await asyncio.sleep(CAPITALS_SECONDS)
|
||||
|
||||
|
||||
# ---------- Infos aus Wikipedia / Wikidata ----------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user