German date display + optional street address for precise geocoding
- Date pickers (entry form + edit dialog) now display TT.MM.JJJJ instead of
ISO (DATE_DISPLAY_FORMAT). Storage stays YYYY-MM-DD; parse_date() already
accepted both formats, so existing data and the self-updater are unaffected.
- New optional "Straße" field (street + house number) in the entry form and
edit dialog, backed by a new `street` CSV column. geocode_city() and
GeocoderWorker.enqueue() gained a street parameter: when set, a full-address
query is tried first for a much more precise map point, falling back
automatically to the existing city/PLZ search if it doesn't resolve.
- Tab 2 and the entry queue show a Straße column; Tab 2 search now also
matches on street.
- Fix: pandas turns a blank CSV cell into NaN even for a dtype=str column, so
every existing (blank-street) row would have shown literal "nan" in Tab 2.
DataStore._load now does street.fillna("") after every read.
Verified with a non-GUI test suite (date parsing, query construction, CSV
round-trip incl. the NaN case) and a full GUI build/drive test on Python
3.12/Tk 9. Docs updated (changelog, overview, architecture, data-model,
dev-notes, improvements).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,10 @@ LOG_PATH = DATA_DIR / "app.log"
|
||||
ICON_PATH = BASE_DIR / "icon.png" # mitgeliefert (für Fenster + Startmenü)
|
||||
ICON_FALLBACK_PATH = DATA_DIR / "icon.png" # zur Laufzeit gezeichnet, falls obiges fehlt
|
||||
WINDOW_STATE_PATH = DATA_DIR / "window.json"
|
||||
CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"]
|
||||
CSV_COLUMNS = ["date", "city", "postal_code", "street", "lat", "lon"]
|
||||
|
||||
#: Anzeigeformat des Datums-Feldes (deutsch). Gespeichert wird trotzdem immer ISO.
|
||||
DATE_DISPLAY_FORMAT = "%d.%m.%Y"
|
||||
|
||||
#: Wie viele automatische Backups von orte.csv aufbewahrt werden.
|
||||
MAX_BACKUPS = 20
|
||||
@@ -203,6 +206,9 @@ class DataStore:
|
||||
if col not in self.df.columns:
|
||||
self.df[col] = ""
|
||||
self.df = self.df[CSV_COLUMNS]
|
||||
# Leere Zellen liest pandas als NaN ein (auch bei dtype=str) – "street"
|
||||
# ist optional und daher oft leer, das würde sonst als "nan" angezeigt.
|
||||
self.df["street"] = self.df["street"].fillna("")
|
||||
self._refresh_known()
|
||||
|
||||
def _load_from_newest_backup(self) -> pd.DataFrame:
|
||||
@@ -321,14 +327,24 @@ class GeocodingUnavailable(Exception):
|
||||
"""Alle Geokodier-Anfragen sind an einem Dienst-/Netzwerkfehler gescheitert."""
|
||||
|
||||
|
||||
def geocode_city(city: str, postal_code: str) -> tuple[float, float] | None:
|
||||
"""Sucht Koordinaten für ``city``/``postal_code``.
|
||||
def geocode_city(city: str, postal_code: str, street: str = "") -> tuple[float, float] | None:
|
||||
"""Sucht Koordinaten für ``city``/``postal_code``, optional mit ``street``.
|
||||
|
||||
Ist eine Straße (inkl. Hausnummer) angegeben, wird zuerst die genaue Adresse
|
||||
versucht – das liefert einen deutlich präziseren Punkt als nur Ort/PLZ.
|
||||
Schlägt das fehl (z. B. Tippfehler, unbekannte Hausnummer), fällt die Suche
|
||||
automatisch auf Ort/PLZ zurück, genau wie ohne Straßenangabe.
|
||||
|
||||
Rückgabe ``None`` bedeutet "Ort nicht gefunden". Ein
|
||||
:class:`GeocodingUnavailable` wird geworfen, wenn *jede* Anfrage an einem
|
||||
Dienst- oder Netzwerkfehler gescheitert ist (z. B. keine Internetverbindung).
|
||||
"""
|
||||
queries: list[str] = []
|
||||
if street:
|
||||
if postal_code:
|
||||
queries.append(f"{street}, {postal_code} {city}, Germany")
|
||||
else:
|
||||
queries.append(f"{street}, {city}, Germany")
|
||||
if postal_code:
|
||||
queries.append(f"{postal_code} {city}, Germany")
|
||||
queries += [f"{city}, {region}, Germany" for region in GEOCODE_REGIONS]
|
||||
@@ -365,16 +381,16 @@ class GeocoderWorker(threading.Thread):
|
||||
|
||||
def __init__(self, on_service_error=None) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self._items: list[tuple[str, str, str, object]] = []
|
||||
self._items: list[tuple[str, str, str, str, object]] = []
|
||||
self._lock = threading.Lock()
|
||||
self._event = threading.Event()
|
||||
self._stopped = False
|
||||
self._on_service_error = on_service_error
|
||||
self._consecutive_errors = 0
|
||||
|
||||
def enqueue(self, row_id: str, city: str, plz: str, callback) -> None:
|
||||
def enqueue(self, row_id: str, city: str, plz: str, street: str, callback) -> None:
|
||||
with self._lock:
|
||||
self._items.append((row_id, city, plz, callback))
|
||||
self._items.append((row_id, city, plz, street, callback))
|
||||
self._event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -385,11 +401,11 @@ class GeocoderWorker(threading.Thread):
|
||||
with self._lock:
|
||||
if not self._items:
|
||||
break
|
||||
row_id, city, plz, callback = self._items.pop(0)
|
||||
row_id, city, plz, street, callback = self._items.pop(0)
|
||||
|
||||
coords: tuple[float, float] | None = None
|
||||
try:
|
||||
coords = geocode_city(city, plz)
|
||||
coords = geocode_city(city, plz, street)
|
||||
self._consecutive_errors = 0
|
||||
except GeocodingUnavailable:
|
||||
self._consecutive_errors += 1
|
||||
@@ -577,12 +593,9 @@ class EditDialog(tk.Toplevel):
|
||||
start = parse_date(str(row.get("date", "")))
|
||||
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
|
||||
self._date_entry = ttk.DateEntry(
|
||||
self, dateformat="%Y-%m-%d", width=12, startdate=start_dt
|
||||
self, dateformat=DATE_DISPLAY_FORMAT, width=12, startdate=start_dt
|
||||
)
|
||||
self._date_entry.grid(row=0, column=1, sticky=W, **pad)
|
||||
if start:
|
||||
self._date_entry.entry.delete(0, END)
|
||||
self._date_entry.entry.insert(0, start)
|
||||
|
||||
ttk.Label(self, text="Ort:").grid(row=1, column=0, sticky=W, **pad)
|
||||
self._city_var = tk.StringVar(value=str(row.get("city", "")))
|
||||
@@ -596,14 +609,20 @@ class EditDialog(tk.Toplevel):
|
||||
self._plz_var = tk.StringVar(value=str(row.get("postal_code", "")))
|
||||
ttk.Entry(self, textvariable=self._plz_var, width=10).grid(row=2, column=1, sticky=W, **pad)
|
||||
|
||||
ttk.Label(self, text="Straße:").grid(row=3, column=0, sticky=W, **pad)
|
||||
self._street_var = tk.StringVar(value=str(row.get("street", "")))
|
||||
ttk.Entry(self, textvariable=self._street_var, width=28).grid(
|
||||
row=3, column=1, sticky=W, **pad
|
||||
)
|
||||
|
||||
self._regeocode_var = tk.BooleanVar(value=False)
|
||||
ttk.Checkbutton(
|
||||
self, text="Koordinaten automatisch neu suchen", variable=self._regeocode_var,
|
||||
bootstyle="round-toggle", command=self._on_regeocode_toggle,
|
||||
).grid(row=3, column=0, columnspan=2, sticky=W, **pad)
|
||||
).grid(row=4, column=0, columnspan=2, sticky=W, **pad)
|
||||
|
||||
coord_frame = ttk.LabelFrame(self, text=" Koordinaten (manuell) ", padding=(8, 4))
|
||||
coord_frame.grid(row=4, column=0, columnspan=2, sticky=EW, padx=8, pady=(4, 4))
|
||||
coord_frame.grid(row=5, column=0, columnspan=2, sticky=EW, padx=8, pady=(4, 4))
|
||||
ttk.Label(coord_frame, text="Breitengrad:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=2)
|
||||
self._lat_var = tk.StringVar(value=_coord_str(row.get("lat", "")))
|
||||
self._lat_entry = ttk.Entry(coord_frame, textvariable=self._lat_var, width=14)
|
||||
@@ -617,7 +636,7 @@ class EditDialog(tk.Toplevel):
|
||||
).grid(row=2, column=0, columnspan=2, sticky=W, pady=(2, 0))
|
||||
|
||||
btn_frame = ttk.Frame(self)
|
||||
btn_frame.grid(row=5, column=0, columnspan=2, pady=(8, 6))
|
||||
btn_frame.grid(row=6, column=0, columnspan=2, pady=(8, 6))
|
||||
ttk.Button(btn_frame, text="Speichern", bootstyle="success",
|
||||
command=self._on_save).pack(side=LEFT, padx=6)
|
||||
ttk.Button(btn_frame, text="Abbrechen", bootstyle="secondary-outline",
|
||||
@@ -654,7 +673,7 @@ class EditDialog(tk.Toplevel):
|
||||
if iso is None:
|
||||
messagebox.showwarning(
|
||||
"Ungültiges Datum",
|
||||
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben.",
|
||||
"Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
@@ -686,6 +705,7 @@ class EditDialog(tk.Toplevel):
|
||||
"date": iso,
|
||||
"city": city,
|
||||
"postal_code": self._plz_var.get().strip(),
|
||||
"street": self._street_var.get().strip(),
|
||||
"regeocode": regeocode,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
@@ -1004,7 +1024,7 @@ class App(ttk.Window):
|
||||
plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P")
|
||||
|
||||
ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=4)
|
||||
self._date_entry = ttk.DateEntry(input_frame, dateformat="%Y-%m-%d", width=12)
|
||||
self._date_entry = ttk.DateEntry(input_frame, dateformat=DATE_DISPLAY_FORMAT, width=12)
|
||||
self._date_entry.grid(row=0, column=1, sticky=W, padx=(0, 16), pady=4)
|
||||
|
||||
ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4), pady=4)
|
||||
@@ -1029,25 +1049,36 @@ class App(ttk.Window):
|
||||
)
|
||||
add_btn.grid(row=0, column=6, pady=4)
|
||||
|
||||
for w in (self._date_entry.entry, self.city_cb, plz_entry):
|
||||
ttk.Label(input_frame, text="Straße:").grid(row=1, column=0, sticky=W, padx=(0, 4), pady=4)
|
||||
self._street_var = ttk.StringVar()
|
||||
street_entry = ttk.Entry(input_frame, textvariable=self._street_var, width=40)
|
||||
street_entry.grid(row=1, column=1, columnspan=4, sticky=EW, pady=4)
|
||||
ttk.Label(
|
||||
input_frame, text="optional, für genauere Kartenpunkte", bootstyle="secondary",
|
||||
).grid(row=1, column=5, columnspan=2, sticky=W, padx=(8, 0), pady=4)
|
||||
|
||||
for w in (self._date_entry.entry, self.city_cb, plz_entry, street_entry):
|
||||
w.bind("<Return>", lambda _e: self._on_add_row())
|
||||
|
||||
# Warteschlange
|
||||
queue_frame = ttk.LabelFrame(parent, text=" Warteschlange (noch nicht gespeichert) ", padding=(8, 4))
|
||||
queue_frame.pack(fill=BOTH, expand=True, padx=4, pady=4)
|
||||
|
||||
cols = ("date", "city", "postal_code", "coords", "del")
|
||||
cols = ("date", "city", "postal_code", "street", "coords", "del")
|
||||
self.tree = ttk.Treeview(queue_frame, columns=cols, show="headings", height=9)
|
||||
self.tree.heading("date", text="Datum")
|
||||
self.tree.heading("city", text="Ort")
|
||||
self.tree.heading("postal_code", text="PLZ")
|
||||
self.tree.heading("street", text="Straße")
|
||||
self.tree.heading("coords", text="Koordinaten")
|
||||
self.tree.heading("del", text="")
|
||||
self.tree.column("date", width=100, minwidth=90, stretch=False)
|
||||
self.tree.column("city", width=180, minwidth=120)
|
||||
self.tree.column("city", width=150, minwidth=110)
|
||||
self.tree.column("postal_code", width=65, minwidth=50, stretch=False)
|
||||
self.tree.column("street", width=150, minwidth=100)
|
||||
self.tree.column("coords", width=185, minwidth=130)
|
||||
self.tree.column("del", width=32, minwidth=32, stretch=False, anchor=CENTER)
|
||||
self._queue_del_col = f"#{cols.index('del') + 1}"
|
||||
|
||||
sb = ttk.Scrollbar(queue_frame, orient=VERTICAL, command=self.tree.yview)
|
||||
self.tree.configure(yscrollcommand=sb.set)
|
||||
@@ -1104,7 +1135,8 @@ class App(ttk.Window):
|
||||
hist_frame.pack(fill=BOTH, expand=True)
|
||||
|
||||
self._hist_headings = {
|
||||
"date": "Datum", "city": "Ort", "postal_code": "PLZ", "coords": "Karte",
|
||||
"date": "Datum", "city": "Ort", "postal_code": "PLZ",
|
||||
"street": "Straße", "coords": "Karte",
|
||||
}
|
||||
cols = tuple(self._hist_headings)
|
||||
self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16)
|
||||
@@ -1114,8 +1146,9 @@ class App(ttk.Window):
|
||||
command=lambda k=key: self._sort_history(k),
|
||||
)
|
||||
self.hist_tree.column("date", width=115, minwidth=95, stretch=False)
|
||||
self.hist_tree.column("city", width=260, minwidth=140)
|
||||
self.hist_tree.column("city", width=190, minwidth=120)
|
||||
self.hist_tree.column("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER)
|
||||
self.hist_tree.column("street", width=180, minwidth=110)
|
||||
self.hist_tree.column("coords", width=90, minwidth=70, stretch=False, anchor=CENTER)
|
||||
|
||||
sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview)
|
||||
@@ -1170,7 +1203,8 @@ class App(ttk.Window):
|
||||
mask = (
|
||||
df["date"].astype(str).str.lower().str.contains(query) |
|
||||
df["city"].astype(str).str.lower().str.contains(query) |
|
||||
df["postal_code"].astype(str).str.lower().str.contains(query)
|
||||
df["postal_code"].astype(str).str.lower().str.contains(query) |
|
||||
df["street"].astype(str).str.lower().str.contains(query)
|
||||
)
|
||||
df = df[mask]
|
||||
if self._only_missing_var.get():
|
||||
@@ -1193,6 +1227,7 @@ class App(ttk.Window):
|
||||
row.get("date", ""),
|
||||
row.get("city", ""),
|
||||
row.get("postal_code", ""),
|
||||
row.get("street", ""),
|
||||
"fehlt" if row_missing else "✓",
|
||||
),
|
||||
)
|
||||
@@ -1244,11 +1279,12 @@ class App(ttk.Window):
|
||||
row = self.store.df.loc[df_idx]
|
||||
city = str(row.get("city", "")).strip()
|
||||
plz = str(row.get("postal_code", "")).strip()
|
||||
street = str(row.get("street", "")).strip()
|
||||
if not city:
|
||||
self._set_status("Eintrag hat keinen Ort – Koordinatensuche nicht möglich.")
|
||||
return
|
||||
self.geocoder.enqueue(
|
||||
f"edit_{df_idx}", city, plz,
|
||||
f"edit_{df_idx}", city, plz, street,
|
||||
lambda _rid, coords, idx=df_idx: self.after(0, self._apply_edit_geocode, idx, coords),
|
||||
)
|
||||
self._set_status(f"Koordinaten werden gesucht für {city}…")
|
||||
@@ -1271,6 +1307,7 @@ class App(ttk.Window):
|
||||
"date": dlg.result["date"],
|
||||
"city": dlg.result["city"],
|
||||
"postal_code": dlg.result["postal_code"],
|
||||
"street": dlg.result["street"],
|
||||
}
|
||||
|
||||
if dlg.result["regeocode"]:
|
||||
@@ -1282,6 +1319,7 @@ class App(ttk.Window):
|
||||
f"edit_{df_idx}",
|
||||
dlg.result["city"],
|
||||
dlg.result["postal_code"],
|
||||
dlg.result["street"],
|
||||
lambda _rid, coords, idx=df_idx: self.after(0, self._apply_edit_geocode, idx, coords),
|
||||
)
|
||||
self._set_status(f"Koordinaten werden gesucht für {new_vals['city']}…")
|
||||
@@ -1354,16 +1392,17 @@ class App(ttk.Window):
|
||||
def _on_add_row(self) -> None:
|
||||
city = self._city_var.get().strip()
|
||||
plz = self._plz_var.get().strip()
|
||||
street = self._street_var.get().strip()
|
||||
date_str = parse_date(self._date_entry.entry.get())
|
||||
|
||||
if not city:
|
||||
self._set_status("Bitte einen Ort eingeben.", "warning")
|
||||
return
|
||||
if date_str is None:
|
||||
self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.", "warning")
|
||||
self._set_status("Bitte ein gültiges Datum (TT.MM.JJJJ) eingeben.", "warning")
|
||||
messagebox.showwarning(
|
||||
"Ungültiges Datum",
|
||||
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben "
|
||||
"Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben "
|
||||
"oder den Kalender-Knopf benutzen.",
|
||||
parent=self,
|
||||
)
|
||||
@@ -1372,15 +1411,16 @@ class App(ttk.Window):
|
||||
row_id = str(self._next_id)
|
||||
self._next_id += 1
|
||||
row = {"_id": row_id, "date": date_str, "city": city,
|
||||
"postal_code": plz, "lat": "", "lon": ""}
|
||||
"postal_code": plz, "street": street, "lat": "", "lon": ""}
|
||||
self._queue.append(row)
|
||||
|
||||
self.tree.insert("", END, iid=row_id,
|
||||
values=(date_str, city, plz, "⏳ wird gesucht…", "✕"))
|
||||
self.geocoder.enqueue(row_id, city, plz, self._geocode_done)
|
||||
values=(date_str, city, plz, street, "⏳ wird gesucht…", "✕"))
|
||||
self.geocoder.enqueue(row_id, city, plz, street, self._geocode_done)
|
||||
|
||||
self._city_var.set("")
|
||||
self._plz_var.set("")
|
||||
self._street_var.set("")
|
||||
self.city_cb.focus_set()
|
||||
self._set_status(
|
||||
f"{len(self._queue)} Einträge in der Warteschlange.", sticky=True
|
||||
@@ -1404,13 +1444,13 @@ class App(ttk.Window):
|
||||
|
||||
if self.tree.exists(row_id):
|
||||
vals = list(self.tree.item(row_id, "values"))
|
||||
vals[3] = coord_str
|
||||
vals[4] = coord_str
|
||||
self.tree.item(row_id, values=vals)
|
||||
|
||||
def _on_tree_click(self, event) -> None:
|
||||
if self.tree.identify_region(event.x, event.y) != "cell":
|
||||
return
|
||||
if self.tree.identify_column(event.x) == "#5":
|
||||
if self.tree.identify_column(event.x) == self._queue_del_col:
|
||||
row_id = self.tree.identify_row(event.y)
|
||||
if row_id:
|
||||
self._delete_queue_row(row_id)
|
||||
@@ -1448,10 +1488,10 @@ class App(ttk.Window):
|
||||
if row["_id"] == row_id:
|
||||
if self.tree.exists(row_id):
|
||||
vals = list(self.tree.item(row_id, "values"))
|
||||
vals[3] = "⏳ wird gesucht…"
|
||||
vals[4] = "⏳ wird gesucht…"
|
||||
self.tree.item(row_id, values=vals)
|
||||
self.geocoder.enqueue(row_id, row["city"], row["postal_code"],
|
||||
self._geocode_done)
|
||||
row.get("street", ""), self._geocode_done)
|
||||
break
|
||||
|
||||
def _on_clear_queue(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user