diff --git a/src/opensak/export/file_export_settings.py b/src/opensak/export/file_export_settings.py new file mode 100644 index 00000000..5c8b2026 --- /dev/null +++ b/src/opensak/export/file_export_settings.py @@ -0,0 +1,151 @@ +""" +src/opensak/export/file_export_settings.py — named, saveable settings for +the GPX/LOC/GGZ file export dialog. + +Stored as JSON files in /export_settings/, one file per named +setting — the same model as FilterProfile (filters/engine.py), so the +settings are independent of the active database. The settings used for the +most recent export are kept in a reserved file and offered as +"… last used" in the dialog. + +Forward compatibility: FileExportSettings.from_dict() falls back to the +default for every missing or invalid key, so settings saved by an older +version keep loading after new export options are added. New options only +need a field with a default, plus a line in to_dict()/from_dict(). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +EXPORT_FORMATS = ("gpx", "loc", "ggz") + +# Reserved file for the settings used by the most recent export. It is left +# out of list_profiles(), and profile_path() steers a user-chosen name away +# from it. +_LAST_USED_STEM = "__last_used__" + + +@dataclass +class FileExportSettings: + """All options of the file export dialog.""" + + fmt: str = "gpx" # "gpx" | "loc" | "ggz" + output_path: str = "" # last chosen output file ("" = not set) + use_corrected_coords: bool = True # False = always export original coords + max_records: int = 0 # max caches to export (0 = all) + + def to_dict(self) -> dict: + return { + "fmt": self.fmt, + "output_path": self.output_path, + "use_corrected_coords": self.use_corrected_coords, + "max_records": self.max_records, + } + + @classmethod + def from_dict(cls, data: dict) -> "FileExportSettings": + defaults = cls() + fmt = data.get("fmt", defaults.fmt) + if fmt not in EXPORT_FORMATS: + fmt = defaults.fmt + output_path = data.get("output_path", defaults.output_path) + if not isinstance(output_path, str): + output_path = defaults.output_path + use_corrected = data.get("use_corrected_coords", defaults.use_corrected_coords) + if not isinstance(use_corrected, bool): + use_corrected = defaults.use_corrected_coords + max_records = data.get("max_records", defaults.max_records) + # bool is an int subclass — reject it explicitly. + if (not isinstance(max_records, int) or isinstance(max_records, bool) + or max_records < 0): + max_records = defaults.max_records + return cls( + fmt=fmt, + output_path=output_path, + use_corrected_coords=use_corrected, + max_records=max_records, + ) + + +class FileExportProfile: + """A named FileExportSettings, saved as JSON.""" + + def __init__(self, name: str, settings: FileExportSettings): + self.name = name + self.settings = settings + + @staticmethod + def default_dir() -> Path: + from opensak.config import get_app_data_dir + return get_app_data_dir() / "export_settings" + + @classmethod + def profile_path(cls, name: str, profiles_dir: Optional[Path] = None) -> Path: + """Return the JSON path a profile called *name* is stored at.""" + if profiles_dir is None: + profiles_dir = cls.default_dir() + safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in name) + if safe_name == _LAST_USED_STEM: + safe_name += "_" # never clobber the reserved "last used" file + return profiles_dir / f"{safe_name}.json" + + def save(self, profiles_dir: Optional[Path] = None) -> Path: + """Save this profile to disk as JSON. Returns the saved file path.""" + path = self.profile_path(self.name, profiles_dir) + self._write(path) + return path + + def _write(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = {"name": self.name, "settings": self.settings.to_dict()} + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + @classmethod + def load(cls, path: Path) -> "FileExportProfile": + """Load a profile from a JSON file.""" + data = json.loads(path.read_text(encoding="utf-8")) + return cls( + name=data["name"], + settings=FileExportSettings.from_dict(data.get("settings", {})), + ) + + @classmethod + def list_profiles(cls, profiles_dir: Optional[Path] = None) -> list[Path]: + """Return the paths of all user-saved profiles (not "last used").""" + if profiles_dir is None: + profiles_dir = cls.default_dir() + if not profiles_dir.exists(): + return [] + return sorted( + p for p in profiles_dir.glob("*.json") if p.stem != _LAST_USED_STEM + ) + + # ── "Last used" ─────────────────────────────────────────────────────────── + + @classmethod + def last_used_path(cls, profiles_dir: Optional[Path] = None) -> Path: + if profiles_dir is None: + profiles_dir = cls.default_dir() + return profiles_dir / f"{_LAST_USED_STEM}.json" + + @classmethod + def load_last_used(cls, profiles_dir: Optional[Path] = None) -> FileExportSettings: + """Return the settings of the most recent export, or the defaults.""" + path = cls.last_used_path(profiles_dir) + try: + return cls.load(path).settings + except Exception: + return FileExportSettings() + + @classmethod + def save_last_used( + cls, settings: FileExportSettings, profiles_dir: Optional[Path] = None + ) -> None: + cls(_LAST_USED_STEM, settings)._write(cls.last_used_path(profiles_dir)) + + def __repr__(self) -> str: + return f"" diff --git a/src/opensak/gps/garmin.py b/src/opensak/gps/garmin.py index 8e6bd211..72607f8d 100644 --- a/src/opensak/gps/garmin.py +++ b/src/opensak/gps/garmin.py @@ -548,15 +548,16 @@ def debug_scan() -> str: # ── GPX generator ───────────────────────────────────────────────────────────── -def _effective_coords(cache) -> tuple[float, float]: +def _effective_coords(cache, use_corrected: bool = True) -> tuple[float, float]: """ Returner de koordinater der skal bruges til GPX export. Hvis cachen har korrigerede koordinater (user_note.is_corrected), bruges disse. Ellers bruges de originale koordinater. + use_corrected=False returnerer altid de originale koordinater. """ note = getattr(cache, "user_note", None) - if note and getattr(note, "is_corrected", False): + if use_corrected and note and getattr(note, "is_corrected", False): lat = note.corrected_lat lon = note.corrected_lon if lat is not None and lon is not None: @@ -564,7 +565,10 @@ def _effective_coords(cache) -> tuple[float, float]: return cache.latitude, cache.longitude -def generate_gpx(caches: list, filename: str = "opensak_export", progress_cb=None) -> str: +def generate_gpx( + caches: list, filename: str = "opensak_export", progress_cb=None, + use_corrected: bool = True, +) -> str: """ Generer GPX indhold fra en liste af Cache objekter. Returnerer GPX som en streng klar til at skrive til fil. @@ -583,6 +587,7 @@ def generate_gpx(caches: list, filename: str = "opensak_export", progress_cb=Non i en cmt (comment) feltom muligt. progress_cb(done, total): valgfrit kald per cache, så GUI kan vise fremgang. + use_corrected=False eksporterer altid de originale koordinater. """ from xml.etree.ElementTree import Element, SubElement import xml.etree.ElementTree as ET @@ -614,7 +619,7 @@ def generate_gpx(caches: list, filename: str = "opensak_export", progress_cb=Non continue # Brug korrigerede koordinater hvis de findes - export_lat, export_lon = _effective_coords(cache) + export_lat, export_lon = _effective_coords(cache, use_corrected) has_corrected = (export_lat != cache.latitude or export_lon != cache.longitude) wpt = SubElement(gpx, "wpt") @@ -939,14 +944,14 @@ def _indent(elem, level: int = 0) -> None: # ── LOC generator ───────────────────────────────────────────────────────────── -def generate_loc(caches: list, progress_cb=None) -> str: +def generate_loc(caches: list, progress_cb=None, use_corrected: bool = True) -> str: """ Generate LOC 1.0 XML content from a list of Cache objects. Returns the LOC content as a string ready to write to file. LOC is a simple waypoint format supported by many GPS apps and devices. It includes GC code, name, coordinates, difficulty, terrain and container. - Corrected coordinates are used when available. + Corrected coordinates are used when available, unless use_corrected=False. progress_cb(done, total): optional per-cache callback for GUI progress. """ @@ -964,7 +969,7 @@ def generate_loc(caches: list, progress_cb=None) -> str: if cache.latitude is None or cache.longitude is None: continue - export_lat, export_lon = _effective_coords(cache) + export_lat, export_lon = _effective_coords(cache, use_corrected) wp = SubElement(root, "waypoint") @@ -1025,7 +1030,10 @@ def _fix_cdata(m: re.Match) -> str: # ── GGZ generator ───────────────────────────────────────────────────────────── -def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=None) -> bytes: +def generate_ggz( + caches: list, filename: str = "opensak_export", progress_cb=None, + use_corrected: bool = True, +) -> bytes: """ Generate a GGZ file (ZIP archive) from a list of Cache objects. Returns the GGZ content as bytes ready to write to file. @@ -1036,7 +1044,7 @@ def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=Non The format allows Garmin devices to load more than the usual 10,000 cache limit by using the GGZ container instead of plain GPX files. - Corrected coordinates are used when available. + Corrected coordinates are used when available, unless use_corrected=False. progress_cb(done, total): optional per-cache callback for GUI progress; reported over the index-building pass (the slow part of GGZ). @@ -1048,7 +1056,9 @@ def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=Non from datetime import datetime, timezone gpx_filename = f"{filename}.gpx" - gpx_content = generate_gpx(caches, filename).encode("utf-8") + gpx_content = generate_gpx( + caches, filename, use_corrected=use_corrected + ).encode("utf-8") # ── CRC32 of the GPX content (hex, uppercase, 8 chars) ──────────────────── import binascii @@ -1112,7 +1122,7 @@ def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=Non if cache.latitude is None or cache.longitude is None: continue - export_lat, export_lon = _effective_coords(cache) + export_lat, export_lon = _effective_coords(cache, use_corrected) gc_code = cache.gc_code or "" file_pos, file_len = offsets_by_gc_code.get(gc_code, (0, 0)) diff --git a/src/opensak/gui/dialogs/file_export_dialog.py b/src/opensak/gui/dialogs/file_export_dialog.py index 4c92862f..cf37de05 100644 --- a/src/opensak/gui/dialogs/file_export_dialog.py +++ b/src/opensak/gui/dialogs/file_export_dialog.py @@ -3,6 +3,10 @@ Simple dialog that lets the user choose a file format and destination path, then writes the selected format using the generators in opensak.gps.garmin. + +The dialog options can be saved under a name and loaded again (see +opensak.export.file_export_settings); the options of the most recent export +are restored when the dialog opens. """ from __future__ import annotations @@ -14,12 +18,16 @@ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog, QRadioButton, QButtonGroup, QGroupBox, QProgressBar, - QTextEdit, + QTextEdit, QComboBox, QInputDialog, QSizePolicy, + QCheckBox, QSpinBox, QFormLayout, ) from opensak.lang import tr from opensak.gui.icon import OpenSAKMessageBox as QMessageBox from opensak.gui.dialogs import make_progress_cb +from opensak.export.file_export_settings import ( + FileExportProfile, FileExportSettings, +) # ── Background worker ───────────────────────────────────────────────────────── @@ -29,11 +37,13 @@ class _ExportWorker(QThread): error = Signal(str) # error message progress = Signal(int, int) # (done, total) - def __init__(self, caches: list, output_path: Path, fmt: str): + def __init__(self, caches: list, output_path: Path, fmt: str, + use_corrected: bool = True): super().__init__() - self._caches = caches - self._output_path = output_path - self._fmt = fmt # "gpx" | "loc" | "ggz" + self._caches = caches + self._output_path = output_path + self._fmt = fmt # "gpx" | "loc" | "ggz" + self._use_corrected = use_corrected def run(self) -> None: try: @@ -45,13 +55,16 @@ def run(self) -> None: cb = make_progress_cb(self.progress.emit) if self._fmt == "gpx": - content = generate_gpx(caches, self._output_path.stem, progress_cb=cb) + content = generate_gpx(caches, self._output_path.stem, progress_cb=cb, + use_corrected=self._use_corrected) self._output_path.write_text(content, encoding="utf-8") elif self._fmt == "loc": - content = generate_loc(caches, progress_cb=cb) + content = generate_loc(caches, progress_cb=cb, + use_corrected=self._use_corrected) self._output_path.write_text(content, encoding="utf-8") elif self._fmt == "ggz": - data = generate_ggz(caches, self._output_path.stem, progress_cb=cb) + data = generate_ggz(caches, self._output_path.stem, progress_cb=cb, + use_corrected=self._use_corrected) self._output_path.write_bytes(data) count = len([c for c in caches if c.latitude is not None]) @@ -76,7 +89,9 @@ def __init__(self, caches: list, parent=None): self.setMinimumWidth(480) self._caches = caches self._worker: _ExportWorker | None = None + self._output_path = "" self._setup_ui() + self._apply_settings(FileExportProfile.load_last_used()) # ── UI ──────────────────────────────────────────────────────────────────── @@ -109,6 +124,44 @@ def _setup_ui(self) -> None: fmt_layout.addWidget(self._btn_ggz) layout.addWidget(fmt_group) + # Export options + opt_group = QGroupBox(tr("gps_opt_group")) + opt_layout = QFormLayout(opt_group) + self._chk_corrected = QCheckBox(tr("file_export_use_corrected")) + self._chk_corrected.setChecked(True) + opt_layout.addRow(self._chk_corrected) + self._spin_max = QSpinBox() + self._spin_max.setRange(0, 1_000_000) + self._spin_max.setSpecialValueText(tr("file_export_max_records_all")) + self._spin_max.setToolTip(tr("file_export_max_records_tip")) + opt_layout.addRow(tr("file_export_max_records"), self._spin_max) + layout.addWidget(opt_group) + + # Saved settings + settings_group = QGroupBox(tr("file_export_settings_label")) + settings_row = QHBoxLayout(settings_group) + self._settings_combo = QComboBox() + self._settings_combo.setMinimumWidth(200) + self._settings_combo.blockSignals(True) + self._load_profiles_into_combo() + self._settings_combo.blockSignals(False) + self._settings_combo.currentIndexChanged.connect(self._on_profile_selected) + settings_row.addWidget(self._settings_combo, 1) + + save_btn = QPushButton(tr("save")) + save_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + save_btn.setAutoDefault(False) + save_btn.clicked.connect(self._save_profile) + settings_row.addWidget(save_btn) + + self._del_btn = QPushButton(tr("delete")) + self._del_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self._del_btn.setAutoDefault(False) + self._del_btn.setEnabled(False) + self._del_btn.clicked.connect(self._delete_profile) + settings_row.addWidget(self._del_btn) + layout.addWidget(settings_group) + # Progress / log area self._log = QTextEdit() self._log.setReadOnly(True) @@ -135,6 +188,107 @@ def _setup_ui(self) -> None: btn_row.addWidget(btn_close) layout.addLayout(btn_row) + # ── Settings ────────────────────────────────────────────────────────────── + + def _collect_settings(self) -> FileExportSettings: + return FileExportSettings( + fmt=self._current_fmt(), + output_path=self._output_path, + use_corrected_coords=self._chk_corrected.isChecked(), + max_records=self._spin_max.value(), + ) + + def _apply_settings(self, settings: FileExportSettings) -> None: + { + "gpx": self._btn_gpx, + "loc": self._btn_loc, + "ggz": self._btn_ggz, + }.get(settings.fmt, self._btn_gpx).setChecked(True) + self._output_path = settings.output_path + self._chk_corrected.setChecked(settings.use_corrected_coords) + self._spin_max.setValue(settings.max_records) + + def _load_profiles_into_combo(self) -> None: + self._settings_combo.clear() + self._settings_combo.addItem(tr("file_export_settings_last_used"), None) + for path in FileExportProfile.list_profiles(): + try: + self._settings_combo.addItem(FileExportProfile.load(path).name, path) + except Exception: + pass + + def _select_profile(self, name: str) -> None: + for i in range(self._settings_combo.count()): + if (self._settings_combo.itemData(i) is not None + and self._settings_combo.itemText(i) == name): + self._settings_combo.setCurrentIndex(i) + return + + def _on_profile_selected(self, index: int) -> None: + path = self._settings_combo.currentData() + self._del_btn.setEnabled(path is not None) + try: + if path is None: + self._apply_settings(FileExportProfile.load_last_used()) + else: + self._apply_settings(FileExportProfile.load(path).settings) + except Exception as e: + QMessageBox.warning( + self, tr("error"), tr("file_export_settings_load_error", error=e) + ) + + def _save_profile(self) -> None: + current = ( + self._settings_combo.currentText() + if self._settings_combo.currentData() is not None + else "" + ) + name, ok = QInputDialog.getText( + self, tr("file_export_settings_save_title"), + tr("file_export_settings_name_label"), text=current, + ) + if not ok or not name.strip(): + return + name = name.strip() + if FileExportProfile.profile_path(name).exists(): + reply = QMessageBox.question( + self, tr("file_export_settings_save_title"), + tr("file_export_settings_overwrite_msg", name=name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + FileExportProfile(name, self._collect_settings()).save() + self._settings_combo.blockSignals(True) + self._load_profiles_into_combo() + self._select_profile(name) + self._settings_combo.blockSignals(False) + self._del_btn.setEnabled(self._settings_combo.currentData() is not None) + + def _delete_profile(self) -> None: + path = self._settings_combo.currentData() + if path is None: + return + name = self._settings_combo.currentText() + reply = QMessageBox.question( + self, tr("delete"), + tr("file_export_settings_delete_msg", name=name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + try: + Path(path).unlink() + except OSError: + pass + # Keep the options currently shown — only the stored copy is gone. + self._settings_combo.blockSignals(True) + self._load_profiles_into_combo() + self._settings_combo.blockSignals(False) + self._del_btn.setEnabled(False) + # ── Logic ───────────────────────────────────────────────────────────────── def _current_fmt(self) -> str: @@ -154,10 +308,14 @@ def _do_export(self) -> None: "ggz": "GGZ Files (*.ggz)", } + default_path = f"opensak_export.{ext}" + if self._output_path: + default_path = str(Path(self._output_path).with_suffix(f".{ext}")) + path_str, _ = QFileDialog.getSaveFileName( self, tr("file_export_save_dialog_title"), - f"opensak_export.{ext}", + default_path, filters[fmt], ) if not path_str: @@ -167,13 +325,28 @@ def _do_export(self) -> None: if output_path.suffix.lower() != f".{ext}": output_path = output_path.with_suffix(f".{ext}") + self._output_path = str(output_path) + try: + FileExportProfile.save_last_used(self._collect_settings()) + except OSError: + pass # failing to remember the settings must not block the export + self._log.clear() self._log.setVisible(True) self._reset_progress() self._progress.setVisible(True) self._btn_export.setEnabled(False) - self._worker = _ExportWorker(self._caches, output_path, fmt) + # Only caches with coordinates are exported, so the limit counts those. + caches = [c for c in self._caches if c.latitude is not None] + max_records = self._spin_max.value() + if max_records: + caches = caches[:max_records] + + self._worker = _ExportWorker( + caches, output_path, fmt, + use_corrected=self._chk_corrected.isChecked(), + ) self._worker.finished.connect(self._on_success) self._worker.error.connect(self._on_error) self._worker.progress.connect(self._on_progress) diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index fb29e5f7..e7b8ca95 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -1271,6 +1271,17 @@ "file_export_save_dialog_title": "Uložit exportní soubor", "file_export_done_msg": "{count} keší exportováno do {path}", "file_export_error_title": "Export selhal", + "file_export_settings_label": "Nastavení exportu", + "file_export_settings_last_used": "… naposledy použité", + "file_export_settings_save_title": "Uložit nastavení exportu", + "file_export_settings_name_label": "Název nastavení:", + "file_export_settings_overwrite_msg": "Nastavení exportu '{name}' již existuje. Přepsat?", + "file_export_settings_delete_msg": "Smazat nastavení exportu '{name}'?", + "file_export_settings_load_error": "Nastavení exportu nelze načíst: {error}", + "file_export_use_corrected": "Použít opravené souřadnice (pokud jsou k dispozici)", + "file_export_max_records": "Max. počet exportovaných keší:", + "file_export_max_records_all": "Vše", + "file_export_max_records_tip": "Exportovat pouze prvních N keší aktuálního seznamu (0 = vše)", "action_kml_export": "Exportovat do Google Maps (KML)...", "kml_no_caches_title": "Žádné keše", "kml_no_caches_msg": "V aktuálním filtru nejsou žádné keše.\nUpravte filtr a zkuste to znovu.", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index 7318a95e..eb831968 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -1275,6 +1275,17 @@ "file_export_save_dialog_title": "Gem eksportfil", "file_export_done_msg": "{count} caches eksporteret til {path}", "file_export_error_title": "Eksport fejlede", + "file_export_settings_label": "Eksportindstillinger", + "file_export_settings_last_used": "… senest brugt", + "file_export_settings_save_title": "Gem eksportindstillinger", + "file_export_settings_name_label": "Navn på indstillinger:", + "file_export_settings_overwrite_msg": "Eksportindstillingerne '{name}' findes allerede. Overskriv dem?", + "file_export_settings_delete_msg": "Slet eksportindstillingerne '{name}'?", + "file_export_settings_load_error": "Kunne ikke indlæse eksportindstillinger: {error}", + "file_export_use_corrected": "Brug korrigerede koordinater (hvis tilgængelige)", + "file_export_max_records": "Maks. antal caches at eksportere:", + "file_export_max_records_all": "Alle", + "file_export_max_records_tip": "Eksportér kun de første N caches i den aktuelle liste (0 = alle)", "action_kml_export": "Eksportér til Google Maps (KML)...", "kml_no_caches_title": "Ingen caches", "kml_no_caches_msg": "Der er ingen caches i det aktuelle filter.\nJuster filteret og prøv igen.", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index c7c284cf..c6dbbe59 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -1276,6 +1276,17 @@ "file_export_save_dialog_title": "Exportdatei speichern", "file_export_done_msg": "{count} Caches exportiert nach {path}", "file_export_error_title": "Export fehlgeschlagen", + "file_export_settings_label": "Export-Einstellungen", + "file_export_settings_last_used": "… zuletzt verwendet", + "file_export_settings_save_title": "Export-Einstellungen speichern", + "file_export_settings_name_label": "Name der Einstellungen:", + "file_export_settings_overwrite_msg": "Die Export-Einstellungen '{name}' existieren bereits. Überschreiben?", + "file_export_settings_delete_msg": "Export-Einstellungen '{name}' löschen?", + "file_export_settings_load_error": "Export-Einstellungen konnten nicht geladen werden: {error}", + "file_export_use_corrected": "Korrigierte Koordinaten verwenden (falls vorhanden)", + "file_export_max_records": "Max. zu exportierende Caches:", + "file_export_max_records_all": "Alle", + "file_export_max_records_tip": "Nur die ersten N Caches der aktuellen Liste exportieren (0 = alle)", "action_kml_export": "Nach Google Maps exportieren (KML)...", "kml_no_caches_title": "Keine Caches", "kml_no_caches_msg": "Im aktuellen Filter sind keine Caches vorhanden.\nFilter anpassen und erneut versuchen.", diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index 11a28ddd..7e642dd3 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -1277,6 +1277,17 @@ "file_export_save_dialog_title": "Exportdatei speichern", "file_export_done_msg": "{count} Caches exportiert nach {path}", "file_export_error_title": "Export fehlgeschlagen", + "file_export_settings_label": "Export-Einstellungen", + "file_export_settings_last_used": "… zuletzt verwendet", + "file_export_settings_save_title": "Export-Einstellungen speichern", + "file_export_settings_name_label": "Name der Einstellungen:", + "file_export_settings_overwrite_msg": "Die Export-Einstellungen '{name}' existieren bereits. Überschreiben?", + "file_export_settings_delete_msg": "Export-Einstellungen '{name}' löschen?", + "file_export_settings_load_error": "Export-Einstellungen konnten nicht geladen werden: {error}", + "file_export_use_corrected": "Korrigierte Koordinaten verwenden (falls vorhanden)", + "file_export_max_records": "Max. zu exportierende Caches:", + "file_export_max_records_all": "Alle", + "file_export_max_records_tip": "Nur die ersten N Caches der aktuellen Liste exportieren (0 = alle)", "action_kml_export": "Nach Google Maps exportieren (KML)...", "kml_no_caches_title": "Keine Caches", "kml_no_caches_msg": "Im aktuellen Filter sind keine Caches vorhanden.\nFilter anpassen und erneut versuchen.", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 337c49a4..c0626b03 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -1274,6 +1274,17 @@ "file_export_save_dialog_title": "Save export file", "file_export_done_msg": "{count} caches exported to {path}", "file_export_error_title": "Export failed", + "file_export_settings_label": "Export settings", + "file_export_settings_last_used": "… last used", + "file_export_settings_save_title": "Save export settings", + "file_export_settings_name_label": "Settings name:", + "file_export_settings_overwrite_msg": "Export settings '{name}' already exist. Overwrite them?", + "file_export_settings_delete_msg": "Delete export settings '{name}'?", + "file_export_settings_load_error": "Could not load export settings: {error}", + "file_export_use_corrected": "Use corrected coordinates (when available)", + "file_export_max_records": "Max. caches to export:", + "file_export_max_records_all": "All", + "file_export_max_records_tip": "Export only the first N caches of the current list (0 = all)", "action_kml_export": "Export to Google Maps (KML)...", "kml_no_caches_title": "No caches", "kml_no_caches_msg": "There are no caches matching the current filter.\nAdjust the filter and try again.", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index 98c527ef..59ebae3b 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -1276,6 +1276,17 @@ "file_export_save_dialog_title": "Guardar archivo de exportación", "file_export_done_msg": "{count} cachés exportadas a {path}", "file_export_error_title": "Error de exportación", + "file_export_settings_label": "Ajustes de exportación", + "file_export_settings_last_used": "… usados por última vez", + "file_export_settings_save_title": "Guardar ajustes de exportación", + "file_export_settings_name_label": "Nombre de los ajustes:", + "file_export_settings_overwrite_msg": "Los ajustes de exportación '{name}' ya existen. ¿Sobrescribirlos?", + "file_export_settings_delete_msg": "¿Eliminar los ajustes de exportación '{name}'?", + "file_export_settings_load_error": "No se pudieron cargar los ajustes de exportación: {error}", + "file_export_use_corrected": "Usar coordenadas corregidas (si existen)", + "file_export_max_records": "Máx. de cachés a exportar:", + "file_export_max_records_all": "Todos", + "file_export_max_records_tip": "Exportar solo los primeros N cachés de la lista actual (0 = todos)", "action_kml_export": "Exportar a Google Maps (KML)...", "kml_no_caches_title": "Sin cachés", "kml_no_caches_msg": "No hay cachés que coincidan con el filtro actual.\nAjusta el filtro e inténtalo de nuevo.", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 4ad0c061..6c4941ab 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -1276,6 +1276,17 @@ "file_export_save_dialog_title": "Enregistrer le fichier d'export", "file_export_done_msg": "{count} caches exportées vers {path}", "file_export_error_title": "Échec de l'export", + "file_export_settings_label": "Paramètres d'export", + "file_export_settings_last_used": "… dernière utilisation", + "file_export_settings_save_title": "Enregistrer les paramètres d'export", + "file_export_settings_name_label": "Nom des paramètres :", + "file_export_settings_overwrite_msg": "Les paramètres d'export '{name}' existent déjà. Les remplacer ?", + "file_export_settings_delete_msg": "Supprimer les paramètres d'export '{name}' ?", + "file_export_settings_load_error": "Impossible de charger les paramètres d'export : {error}", + "file_export_use_corrected": "Utiliser les coordonnées corrigées (si disponibles)", + "file_export_max_records": "Nombre max. de caches à exporter :", + "file_export_max_records_all": "Toutes", + "file_export_max_records_tip": "Exporter uniquement les N premières caches de la liste actuelle (0 = toutes)", "action_kml_export": "Exporter vers Google Maps (KML)...", "kml_no_caches_title": "Aucune cache", "kml_no_caches_msg": "Aucune cache ne correspond au filtre actuel.\nAjustez le filtre et réessayez.", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index 8529e98d..3346b699 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -1273,6 +1273,17 @@ "file_export_save_dialog_title": "Exportbestand opslaan", "file_export_done_msg": "{count} caches geëxporteerd naar {path}", "file_export_error_title": "Export mislukt", + "file_export_settings_label": "Exportinstellingen", + "file_export_settings_last_used": "… laatst gebruikt", + "file_export_settings_save_title": "Exportinstellingen opslaan", + "file_export_settings_name_label": "Naam van de instellingen:", + "file_export_settings_overwrite_msg": "Exportinstellingen '{name}' bestaan al. Overschrijven?", + "file_export_settings_delete_msg": "Exportinstellingen '{name}' verwijderen?", + "file_export_settings_load_error": "Kon exportinstellingen niet laden: {error}", + "file_export_use_corrected": "Gecorrigeerde coördinaten gebruiken (indien beschikbaar)", + "file_export_max_records": "Max. aantal te exporteren caches:", + "file_export_max_records_all": "Alle", + "file_export_max_records_tip": "Alleen de eerste N caches van de huidige lijst exporteren (0 = alle)", "action_kml_export": "Exporteren naar Google Maps (KML)...", "kml_no_caches_title": "Geen caches", "kml_no_caches_msg": "Er zijn geen caches in het huidige filter.\nPas het filter aan en probeer opnieuw.", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 4ab6bcd1..6605f3ef 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -1276,6 +1276,17 @@ "file_export_save_dialog_title": "Zapisz plik eksportu", "file_export_done_msg": "Wyeksportowano {count} skrytek do {path}", "file_export_error_title": "Eksport nieudany", + "file_export_settings_label": "Ustawienia eksportu", + "file_export_settings_last_used": "… ostatnio używane", + "file_export_settings_save_title": "Zapisz ustawienia eksportu", + "file_export_settings_name_label": "Nazwa ustawień:", + "file_export_settings_overwrite_msg": "Ustawienia eksportu '{name}' już istnieją. Zastąpić je?", + "file_export_settings_delete_msg": "Usunąć ustawienia eksportu '{name}'?", + "file_export_settings_load_error": "Nie można wczytać ustawień eksportu: {error}", + "file_export_use_corrected": "Użyj poprawionych współrzędnych (jeśli dostępne)", + "file_export_max_records": "Maks. liczba eksportowanych skrzynek:", + "file_export_max_records_all": "Wszystkie", + "file_export_max_records_tip": "Eksportuj tylko pierwsze N skrzynek z bieżącej listy (0 = wszystkie)", "action_kml_export": "Eksportuj do Google Maps (KML)...", "kml_no_caches_title": "Brak skrytek", "kml_no_caches_msg": "Brak skrytek pasujących do bieżącego filtra.\nZmień filtr i spróbuj ponownie.", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index 508743b0..f66b6739 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -1276,6 +1276,17 @@ "file_export_save_dialog_title": "Guardar ficheiro de exportação", "file_export_done_msg": "{count} caches exportadas para {path}", "file_export_error_title": "Falha na exportação", + "file_export_settings_label": "Definições de exportação", + "file_export_settings_last_used": "… última utilizada", + "file_export_settings_save_title": "Guardar definições de exportação", + "file_export_settings_name_label": "Nome das definições:", + "file_export_settings_overwrite_msg": "As definições de exportação '{name}' já existem. Substituir?", + "file_export_settings_delete_msg": "Eliminar as definições de exportação '{name}'?", + "file_export_settings_load_error": "Não foi possível carregar as definições de exportação: {error}", + "file_export_use_corrected": "Usar coordenadas corrigidas (se disponíveis)", + "file_export_max_records": "Máx. de caches a exportar:", + "file_export_max_records_all": "Todas", + "file_export_max_records_tip": "Exportar apenas as primeiras N caches da lista atual (0 = todas)", "action_kml_export": "Exportar para Google Maps (KML)...", "kml_no_caches_title": "Sem caches", "kml_no_caches_msg": "Não há caches no filtro atual.\nAjuste o filtro e tente novamente.", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index f8dae10f..298c2ccc 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -1275,6 +1275,17 @@ "file_export_save_dialog_title": "Spara exportfil", "file_export_done_msg": "{count} cacher exporterade till {path}", "file_export_error_title": "Exporten misslyckades", + "file_export_settings_label": "Exportinställningar", + "file_export_settings_last_used": "… senast använda", + "file_export_settings_save_title": "Spara exportinställningar", + "file_export_settings_name_label": "Namn på inställningar:", + "file_export_settings_overwrite_msg": "Exportinställningarna '{name}' finns redan. Skriva över dem?", + "file_export_settings_delete_msg": "Ta bort exportinställningarna '{name}'?", + "file_export_settings_load_error": "Kunde inte läsa in exportinställningar: {error}", + "file_export_use_corrected": "Använd korrigerade koordinater (om tillgängliga)", + "file_export_max_records": "Max antal cacher att exportera:", + "file_export_max_records_all": "Alla", + "file_export_max_records_tip": "Exportera endast de första N cacherna i aktuell lista (0 = alla)", "action_kml_export": "Exportera till Google Maps (KML)...", "kml_no_caches_title": "Inga cacher", "kml_no_caches_msg": "Det finns inga cacher i det aktuella filtret.\nJustera filtret och försök igen.", diff --git a/tests/e2e-tests/test_e2e_filter.py b/tests/e2e-tests/test_e2e_filter.py index 22818f81..b851003c 100644 --- a/tests/e2e-tests/test_e2e_filter.py +++ b/tests/e2e-tests/test_e2e_filter.py @@ -6,6 +6,8 @@ import pytest +from tests.data import wait_for_refresh + pytest.importorskip("pytestqt") @@ -21,7 +23,7 @@ def test_quick_filter_found_returns_zero(seeded_window, qtbot): assert window._cache_table.row_count() == TOTAL window._quick_filter.setCurrentIndex(2) # index 2 = Found - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 0 @@ -31,11 +33,11 @@ def test_quick_filter_not_found_returns_all(seeded_window, qtbot): window = seeded_window window._quick_filter.setCurrentIndex(2) # Found → 0 rows - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 0 window._quick_filter.setCurrentIndex(1) # Not Found → all rows - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == TOTAL @@ -45,9 +47,9 @@ def test_quick_filter_reset_to_all_restores_count(seeded_window, qtbot): window = seeded_window window._quick_filter.setCurrentIndex(2) # Found → 0 - qtbot.wait(50) + wait_for_refresh(window) window._quick_filter.setCurrentIndex(0) # All - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == TOTAL @@ -161,7 +163,7 @@ def test_dropdown_shows_active_for_quick_filter(seeded_window, qtbot): window = seeded_window window._quick_filter.setCurrentIndex(2) # Found → not the "All" default - qtbot.wait(50) + wait_for_refresh(window) assert window._filter_profile_combo.currentText() == tr("toolbar_filter_combo_active") @@ -190,7 +192,7 @@ def test_dropdown_shows_active_for_unsaved_advanced_filter(seeded_window, qtbot) fs = FilterSet(mode="AND") fs.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs, SortSpec("name", ascending=True), "") - qtbot.wait(50) + wait_for_refresh(window) assert window._filter_profile_combo.currentText() == tr("toolbar_filter_combo_active") @@ -212,7 +214,7 @@ def test_dropdown_shows_profile_name_for_saved_filter(seeded_window, qtbot, tmp_ window = seeded_window window._on_filter_applied(fs, SortSpec("name", ascending=True), "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._filter_profile_combo.currentText() == "Traditional only" @@ -225,11 +227,11 @@ def test_dropdown_shows_none_after_clear_filter(seeded_window, qtbot): fs = FilterSet(mode="AND") fs.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs, SortSpec("name", ascending=True), "") - qtbot.wait(50) + wait_for_refresh(window) assert window._filter_profile_combo.currentText() == tr("toolbar_filter_combo_active") window._clear_filter() - qtbot.wait(50) + wait_for_refresh(window) assert window._filter_profile_combo.currentText() == tr("toolbar_filter_combo_none") @@ -253,7 +255,7 @@ def test_filter_applied_signal_updates_table(seeded_window, qtbot): sort = SortSpec("name", ascending=True) window._on_filter_applied(fs, sort, "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 assert window._filter_lbl.text() != "" # label shows active filter name @@ -268,11 +270,11 @@ def test_clear_filter_removes_advanced_filter(seeded_window, qtbot): fs = FilterSet(mode="AND") fs.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs, SortSpec("name", ascending=True), "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 window._clear_filter() - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == TOTAL assert window._filter_lbl.text() == "" @@ -291,12 +293,12 @@ def test_profile_deleted_clears_active_filter(seeded_window, qtbot): fs = FilterSet(mode="AND") fs.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs, SortSpec("name", ascending=True), "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 assert window._active_filter_name == "Traditional only" window._on_profile_deleted("Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == TOTAL assert window._active_filter_name == "" @@ -312,11 +314,11 @@ def test_profile_deleted_of_inactive_profile_leaves_active_filter(seeded_window, fs = FilterSet(mode="AND") fs.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs, SortSpec("name", ascending=True), "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 window._on_profile_deleted("Some other profile") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 assert window._active_filter_name == "Traditional only" @@ -334,10 +336,10 @@ def test_profile_deleted_preserves_quick_search_text(seeded_window, qtbot): fs = FilterSet(mode="AND") fs.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs, SortSpec("name", ascending=True), "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) window._on_profile_deleted("Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._search_gc.text() == "GC" assert window._active_filter_name == "" @@ -360,7 +362,7 @@ def test_where_clause_filter_reduces_row_count(seeded_window, qtbot): fs = FilterSet() fs.add(WhereClauseFilter("difficulty >= 4.0")) window._on_filter_applied(fs, SortSpec("name", ascending=True), "High D") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 @@ -380,7 +382,7 @@ def test_where_clause_invalid_sql_hides_all_rows(seeded_window, qtbot, monkeypat fs = FilterSet() fs.add(WhereClauseFilter("NOT VALID SQL @@@")) window._on_filter_applied(fs, SortSpec("name"), "Bad SQL") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == TOTAL # unchanged, not emptied assert window._active_filter_name != "Bad SQL" @@ -395,11 +397,11 @@ def test_where_clause_clear_restores_full_count(seeded_window, qtbot): fs = FilterSet() fs.add(WhereClauseFilter("difficulty >= 4.0")) window._on_filter_applied(fs, SortSpec("name"), "High D") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 window._clear_filter() - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == TOTAL @@ -508,7 +510,7 @@ def test_text_search_description_narrows_rows(seeded_window, qtbot): fs.add(TextSearchFilter("puzzle", search_description=True, search_logs=False, search_notes=False, search_hint=False)) window._on_filter_applied(fs, SortSpec("name", ascending=True), "Puzzle") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 @@ -524,7 +526,7 @@ def test_text_search_hint_narrows_rows(seeded_window, qtbot): fs.add(TextSearchFilter("rock", search_description=False, search_logs=False, search_notes=False, search_hint=True)) window._on_filter_applied(fs, SortSpec("name", ascending=True), "Rock hint") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 @@ -549,7 +551,7 @@ def test_text_search_no_match_returns_zero(seeded_window, qtbot, monkeypatch): fs = FilterSet() fs.add(TextSearchFilter("zzznomatch")) window._on_filter_applied(fs, SortSpec("name"), "No match") - qtbot.wait(50) + wait_for_refresh(window) assert warned # the "no results" warning was shown assert window._cache_table.row_count() == TOTAL # previous view untouched @@ -571,14 +573,14 @@ def test_filter_zero_results_leaves_existing_filter_active(seeded_window, qtbot, fs1 = FilterSet(mode="AND") fs1.add(CacheTypeFilter(["Traditional Cache"])) window._on_filter_applied(fs1, SortSpec("name", ascending=True), "Traditional only") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 assert window._active_filter_name == "Traditional only" fs2 = FilterSet() fs2.add(TextSearchFilter("zzznomatch")) window._on_filter_applied(fs2, SortSpec("name"), "No match") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 # unchanged — still "Traditional only" assert window._active_filter_name == "Traditional only" @@ -596,6 +598,6 @@ def test_where_exists_logs_narrows_rows(seeded_window, qtbot): "EXISTS (SELECT 1 FROM logs WHERE logs.cache_id = caches.id AND logs.text LIKE '%TFTC%')" )) window._on_filter_applied(fs, SortSpec("name"), "TFTC logs") - qtbot.wait(50) + wait_for_refresh(window) assert window._cache_table.row_count() == 2 diff --git a/tests/unit-tests/test_export_dialogs.py b/tests/unit-tests/test_export_dialogs.py index 5d2fe6f6..ae8fb03c 100644 --- a/tests/unit-tests/test_export_dialogs.py +++ b/tests/unit-tests/test_export_dialogs.py @@ -236,7 +236,7 @@ def test_do_export_launches_worker_with_suffix(self, qtbot, monkeypatch): captured = {} class FakeWorker: - def __init__(self, caches, output_path, fmt): + def __init__(self, caches, output_path, fmt, use_corrected=True): captured["path"] = output_path self.finished = MagicMock() self.error = MagicMock() diff --git a/tests/unit-tests/test_file_export_settings.py b/tests/unit-tests/test_file_export_settings.py new file mode 100644 index 00000000..b0220592 --- /dev/null +++ b/tests/unit-tests/test_file_export_settings.py @@ -0,0 +1,215 @@ +# tests/unit-tests/test_file_export_settings.py — saved settings for the +# GPX/LOC/GGZ file export dialog. + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from opensak.export.file_export_settings import ( + FileExportProfile, FileExportSettings, +) + + +# ── Settings / profile storage ──────────────────────────────────────────────── + +class TestFileExportSettings: + def test_roundtrip(self): + s = FileExportSettings(fmt="ggz", output_path="/x/y.ggz", + use_corrected_coords=False, max_records=250) + assert FileExportSettings.from_dict(s.to_dict()) == s + + def test_missing_keys_fall_back_to_defaults(self): + assert FileExportSettings.from_dict({}) == FileExportSettings() + + def test_invalid_values_fall_back_to_defaults(self): + s = FileExportSettings.from_dict({"fmt": "kml", "output_path": 42}) + assert s == FileExportSettings() + + @pytest.mark.parametrize("value", ["yes", 1, None]) + def test_invalid_use_corrected_falls_back(self, value): + s = FileExportSettings.from_dict({"use_corrected_coords": value}) + assert s.use_corrected_coords is True + + @pytest.mark.parametrize("value", [-1, "10", 2.5, True, None]) + def test_invalid_max_records_falls_back(self, value): + assert FileExportSettings.from_dict({"max_records": value}).max_records == 0 + + def test_unknown_keys_are_ignored(self): + s = FileExportSettings.from_dict({"fmt": "loc", "from_the_future": True}) + assert s.fmt == "loc" + + +class TestFileExportProfile: + def test_save_load_list(self, tmp_path): + FileExportProfile("Garmin", FileExportSettings(fmt="ggz")).save(tmp_path) + FileExportProfile("Phone", FileExportSettings(fmt="gpx")).save(tmp_path) + paths = FileExportProfile.list_profiles(tmp_path) + profiles = [FileExportProfile.load(p) for p in paths] + assert [(p.name, p.settings.fmt) for p in profiles] == [ + ("Garmin", "ggz"), ("Phone", "gpx"), + ] + + def test_list_empty_when_dir_missing(self, tmp_path): + assert FileExportProfile.list_profiles(tmp_path / "nope") == [] + + def test_last_used_roundtrip_and_hidden_from_list(self, tmp_path): + s = FileExportSettings(fmt="loc", output_path="/a/b.loc") + FileExportProfile.save_last_used(s, tmp_path) + assert FileExportProfile.load_last_used(tmp_path) == s + assert FileExportProfile.list_profiles(tmp_path) == [] + + def test_last_used_defaults_when_missing_or_corrupt(self, tmp_path): + assert FileExportProfile.load_last_used(tmp_path) == FileExportSettings() + FileExportProfile.last_used_path(tmp_path).write_text("{", encoding="utf-8") + assert FileExportProfile.load_last_used(tmp_path) == FileExportSettings() + + def test_user_name_cannot_clobber_last_used(self, tmp_path): + reserved = FileExportProfile.last_used_path(tmp_path) + assert FileExportProfile.profile_path("__last_used__", tmp_path) != reserved + + def test_default_dir_is_in_app_data(self): + from opensak.config import get_app_data_dir + assert FileExportProfile.default_dir() == get_app_data_dir() / "export_settings" + + def test_file_format(self, tmp_path): + path = FileExportProfile("A/B", FileExportSettings(fmt="loc")).save(tmp_path) + assert path.name == "A_B.json" + data = json.loads(path.read_text(encoding="utf-8")) + assert data == {"name": "A/B", "settings": { + "fmt": "loc", "output_path": "", + "use_corrected_coords": True, "max_records": 0, + }} + + +# ── Dialog integration ──────────────────────────────────────────────────────── + +pytestqt = pytest.importorskip("pytestqt") + + +@pytest.fixture +def fed(): + from opensak.gui.dialogs import file_export_dialog + return file_export_dialog + + +def _cache(): + return SimpleNamespace(latitude=55.0, longitude=12.0) + + +def _combo_names(dlg): + c = dlg._settings_combo + return [c.itemText(i) for i in range(1, c.count())] + + +class TestFileExportDialogSettings: + def test_opens_with_last_used(self, qtbot, fed): + FileExportProfile.save_last_used(FileExportSettings(fmt="ggz")) + dlg = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg) + assert dlg._current_fmt() == "ggz" + assert dlg._settings_combo.currentData() is None + assert dlg._del_btn.isEnabled() is False + + def test_save_then_select_profile_applies_it(self, qtbot, fed, monkeypatch): + dlg = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg) + dlg._btn_loc.setChecked(True) + monkeypatch.setattr(fed.QInputDialog, "getText", lambda *a, **k: ("Loc", True)) + dlg._save_profile() + assert _combo_names(dlg) == ["Loc"] + assert dlg._settings_combo.currentText() == "Loc" + assert dlg._del_btn.isEnabled() is True + + dlg._settings_combo.setCurrentIndex(0) # back to "last used" (defaults) + assert dlg._current_fmt() == "gpx" + dlg._settings_combo.setCurrentIndex(1) + assert dlg._current_fmt() == "loc" + + def test_overwrite_declined_keeps_existing(self, qtbot, fed, monkeypatch): + FileExportProfile("P", FileExportSettings(fmt="ggz")).save() + dlg = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg) + dlg._btn_loc.setChecked(True) + monkeypatch.setattr(fed.QInputDialog, "getText", lambda *a, **k: ("P", True)) + monkeypatch.setattr(fed.QMessageBox, "question", + lambda *a, **k: fed.QMessageBox.StandardButton.No) + dlg._save_profile() + path = FileExportProfile.profile_path("P") + assert FileExportProfile.load(path).settings.fmt == "ggz" + + def test_new_options_saved_and_applied(self, qtbot, fed, monkeypatch): + dlg = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg) + dlg._chk_corrected.setChecked(False) + dlg._spin_max.setValue(42) + monkeypatch.setattr(fed.QInputDialog, "getText", lambda *a, **k: ("Opt", True)) + dlg._save_profile() + saved = FileExportProfile.load(FileExportProfile.profile_path("Opt")).settings + assert saved.use_corrected_coords is False + assert saved.max_records == 42 + + dlg._settings_combo.setCurrentIndex(0) # "last used" (defaults) + assert dlg._chk_corrected.isChecked() is True + assert dlg._spin_max.value() == 0 + dlg._settings_combo.setCurrentIndex(1) + assert dlg._chk_corrected.isChecked() is False + assert dlg._spin_max.value() == 42 + + def test_export_applies_max_records_and_coord_choice(self, qtbot, fed, monkeypatch, tmp_path): + caches = [_cache() for _ in range(5)] + caches.insert(0, SimpleNamespace(latitude=None, longitude=None)) + dlg = fed.FileExportDialog(caches) + qtbot.addWidget(dlg) + dlg._chk_corrected.setChecked(False) + dlg._spin_max.setValue(3) + monkeypatch.setattr(fed.QFileDialog, "getSaveFileName", + lambda *a: (str(tmp_path / "x.gpx"), "")) + calls = [] + monkeypatch.setattr(fed, "_ExportWorker", + lambda *a, **k: calls.append((a, k)) or MagicMock()) + dlg._do_export() + (args, kwargs), = calls + assert args[0] == caches[1:4] + assert kwargs["use_corrected"] is False + last = FileExportProfile.load_last_used() + assert (last.use_corrected_coords, last.max_records) == (False, 3) + + def test_delete_profile(self, qtbot, fed, monkeypatch): + FileExportProfile("P", FileExportSettings(fmt="ggz")).save() + dlg = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg) + dlg._settings_combo.setCurrentIndex(1) + monkeypatch.setattr(fed.QMessageBox, "question", + lambda *a, **k: fed.QMessageBox.StandardButton.Yes) + dlg._delete_profile() + assert _combo_names(dlg) == [] + assert not FileExportProfile.profile_path("P").exists() + assert dlg._current_fmt() == "ggz" # what is shown stays + + def test_export_records_last_used_and_prefills_path(self, qtbot, fed, monkeypatch, tmp_path): + dlg = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg) + dlg._btn_loc.setChecked(True) + target = tmp_path / "out" / "mine" + seen_defaults = [] + + def fake_save(parent, title, default, flt): + seen_defaults.append(default) + return str(target), flt + + monkeypatch.setattr(fed.QFileDialog, "getSaveFileName", fake_save) + worker = MagicMock() + monkeypatch.setattr(fed, "_ExportWorker", lambda *a, **k: worker) + dlg._do_export() + + last = FileExportProfile.load_last_used() + assert last.fmt == "loc" + assert last.output_path == str(target.with_suffix(".loc")) + + dlg2 = fed.FileExportDialog([_cache()]) + qtbot.addWidget(dlg2) + dlg2._btn_gpx.setChecked(True) + dlg2._do_export() + assert seen_defaults[-1] == str(target.with_suffix(".gpx")) diff --git a/tests/unit-tests/test_garmin.py b/tests/unit-tests/test_garmin.py index 03a4a768..e20c8aa3 100644 --- a/tests/unit-tests/test_garmin.py +++ b/tests/unit-tests/test_garmin.py @@ -281,6 +281,11 @@ def test_corrected_note_with_none_lon_falls_back(self): c = _cache(latitude=55.0, longitude=12.0, user_note=note) assert _effective_coords(c) == (55.0, 12.0) + def test_use_corrected_false_returns_original(self): + note = _note(is_corrected=True, corrected_lat=56.0, corrected_lon=13.0) + c = _cache(latitude=55.0, longitude=12.0, user_note=note) + assert _effective_coords(c, use_corrected=False) == (55.0, 12.0) + # ── generate_gpx ────────────────────────────────────────────────────────────── @@ -637,6 +642,15 @@ def test_corrected_coords_store_original_in_comment(self): assert "Original" in result assert "55.000000" in result + def test_use_corrected_false_exports_original_coords(self): + note = _note(is_corrected=True, corrected_lat=60.0, corrected_lon=20.0) + c = _cache(latitude=55.0, longitude=12.0, user_note=note) + result = generate_gpx([c], use_corrected=False) + assert 'lat="55.000000"' in result + assert 'lon="12.000000"' in result + assert "60.000000" not in result + assert "Corrected coordinates used" not in result + def test_user_note_emitted_as_gsak_element(self): n = _note(is_corrected=False, note="My personal note") result = generate_gpx([_cache(user_note=n)]) @@ -1098,6 +1112,13 @@ def test_corrected_coords_used(self): assert 'lat="60.000000"' in out assert 'lon="20.000000"' in out + def test_use_corrected_false_exports_original_coords(self): + c = _cache(latitude=55.0, longitude=12.0, + user_note=_note(is_corrected=True, corrected_lat=60.0, corrected_lon=20.0)) + out = generate_loc([c], use_corrected=False) + assert 'lat="55.000000"' in out + assert "60.000000" not in out + def test_skips_cache_without_coords(self): c = _cache(gc_code="GCNULL") c.latitude = None @@ -1145,6 +1166,14 @@ def test_no_container_omits_size(self): "index/com/garmin/geocaches/v0/index.xml"].decode("utf-8") assert "" not in index + def test_use_corrected_false_exports_original_coords(self): + c = _cache(latitude=55.0, longitude=12.0, + user_note=_note(is_corrected=True, corrected_lat=60.0, corrected_lon=20.0)) + entries = _ggz_entries(generate_ggz([c], use_corrected=False)) + for name, blob in entries.items(): + if name.endswith((".gpx", ".xml")): + assert "60.000000" not in blob.decode("utf-8"), name + def test_found_flag_in_index(self): c = _cache(gc_code="GCFOUND") c.found = True