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:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:05:26 +02:00
1 parent f0e17b06b5
commit dc4ce18593
9 files changed
+489 -12

No files matched your search

+43 -3
View File
@@ -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 ----------