From f660e0eb1901b12b59b7db7f7b70a05ee1464bd7 Mon Sep 17 00:00:00 2001 From: nagisml Date: Sun, 20 Sep 2026 20:55:50 +0200 Subject: [PATCH 1/5] Filter import as GUI task --- docs/filters.md | 53 + .../gui/dialogs/gsak_filter_import_dialog.py | 381 +++ src/opensak/gui/mainwindow.py | 19 + src/opensak/importer/gsak_filter_importer.py | 2490 +++++++++++++++++ src/opensak/lang/cs.py | 34 + src/opensak/lang/da.py | 34 + src/opensak/lang/de.py | 34 + src/opensak/lang/de_CH.py | 34 + src/opensak/lang/en.py | 34 + src/opensak/lang/es.py | 34 + src/opensak/lang/fr.py | 34 + src/opensak/lang/nl.py | 34 + src/opensak/lang/pl.py | 34 + src/opensak/lang/pt.py | 34 + src/opensak/lang/se.py | 34 + .../e2e-tests/test_e2e_gsak_filter_import.py | 140 + .../test_gsak_filter_import_dialog.py | 241 ++ tests/unit-tests/test_gsak_filter_importer.py | 441 +++ 18 files changed, 4139 insertions(+) create mode 100644 src/opensak/gui/dialogs/gsak_filter_import_dialog.py create mode 100644 src/opensak/importer/gsak_filter_importer.py create mode 100644 tests/e2e-tests/test_e2e_gsak_filter_import.py create mode 100644 tests/unit-tests/test_gsak_filter_import_dialog.py create mode 100644 tests/unit-tests/test_gsak_filter_importer.py diff --git a/docs/filters.md b/docs/filters.md index 54722123..b20cdf40 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -464,6 +464,59 @@ Once you have set up a useful combination, save it so you can reload it in one c --- +## Importing GSAK's saved filters + +**File → Import GSAK Filters…** turns the filters you saved in GSAK into OpenSAK filter profiles. It is the companion of *Import from GSAK Database*: that one imports caches out of a cache database (`sqlite.db3`), this one imports filters out of GSAK's settings database (`gsak.db3`, normally `%AppData%\GSAK\gsak.db3`). A GSAK backup `.zip` works too — the `gsak.db3` inside it is found automatically. + +Pick the file, tick the filters you want (all of them are ticked to begin with; the search box narrows the list, and **Select all / Select none** apply to what the search currently shows), and press **Start import**. Existing profiles of the same name are kept and reported as skipped unless you tick **Overwrite filter profiles that already exist**. + +### Where each GSAK condition ends up + +Every condition in a GSAK filter lands in one of three places: + +| | What it means | Counts as migrated | +|---|---|---| +| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators | yes | +| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, elevation, bearing, user data 1–4, compass quadrants, TB/coin names | yes | +| **A comment in the Where tab** | Nothing in OpenSAK can express it | no | + +The third case is why the Where tab of an imported filter often opens with a block of `--` lines. They do nothing; they are there so you can see exactly what GSAK filtered on and rebuild it yourself. A typical one looks like this: + +```sql +-- NOT MIGRATED from the GSAK filter "Ideas_CH" (2 condition(s)). +-- Rebuild these by hand, then delete the comment. +-- User-defined GSAK column "Niggae_Ignore": OpenSAK stores no custom columns +-- (only user_data_1-4) … The GSAK criterion was: Niggae_Ignore;bool;… +-- Cache types "Waymark": GSAK has these cache types but OpenSAK does not … +-- Watch list +(coalesce(watch, 0) = 1) +``` + +The comments always come first and the executable SQL last, so the clause stays valid whatever you delete. Where a whole GSAK `WHERE` clause could not be translated, the translation-so-far is included in the comment, ready to be fixed up and uncommented — it is deliberately never left live, because SQL that fails to run would silently make the filter match nothing. + +The things that cannot be migrated are, in practice: GSAK's user-defined columns (OpenSAK has no custom columns, and the cache import does not carry them over), the Waymark and L&F Celebration cache types (the cache import files them under *Unknown Cache*, where nothing tells them apart), GSAK macro variables inside a saved `WHERE` clause, and columns OpenSAK does not store at all (FavPerc, LabId, the GPX symbol name). + +### The statistics after an import + +The results panel ends with a migration-coverage figure: + +``` +Migration coverage + GSAK conditions 445 + … migrated to filters 286 + … migrated to Where SQL 63 + … left as SQL comments 96 + + Coverage 78.4 % + fully migrated (100 %) 54 + partly migrated 61 + nothing migrated (0 %) 7 +``` + +Coverage is counted over **conditions, not filters** — 100 % means every condition from every imported filter runs, 0 % means all of it sits in comments — so one filter with twenty conditions weighs more than one with two. Filters that came through with something left in comments are listed underneath, lowest coverage first, so you know which ones to open and finish. + +--- + ## Clearing filters Click **View → Clear filter** or use the clear button (shown in red when active) in the toolbar to remove all active filters and show the full cache list. diff --git a/src/opensak/gui/dialogs/gsak_filter_import_dialog.py b/src/opensak/gui/dialogs/gsak_filter_import_dialog.py new file mode 100644 index 00000000..cc1d64e6 --- /dev/null +++ b/src/opensak/gui/dialogs/gsak_filter_import_dialog.py @@ -0,0 +1,381 @@ +""" +src/opensak/gui/dialogs/gsak_filter_import_dialog.py — import saved GSAK +filters as OpenSAK filter profiles. + +The sibling of ``gsak_import_dialog.py``: that one imports the caches out of +a GSAK cache database (``sqlite.db3``), this one imports the saved filters out +of GSAK's settings database (``gsak.db3``). Same worker/threading pattern, +plus a checkable list of the filters found in the file, since a GSAK install +easily holds a hundred of them. + +What each GSAK criterion becomes — a native filter, SQL in the Where tab, or +a ``--`` comment there — is decided by +``opensak.importer.gsak_filter_importer``; this dialog reports the resulting +migration coverage once the import has run. +""" + +from __future__ import annotations +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFileDialog, QProgressBar, + QTextEdit, QCheckBox, QLineEdit, QListWidget, QListWidgetItem, + QMessageBox, +) + +from opensak.gui.dialogs.widgets import clamp_dialog_height_to_screen +from opensak.gui.settings import get_settings +from opensak.lang import tr +from opensak.gui.theme import hint_style + +# How many per-filter lines the results log shows before it stops listing them. +MAX_LISTED_FILTERS = 25 + + +class GsakFilterImportWorker(QThread): + """Imports the selected GSAK filters in a background thread.""" + result_ready = Signal(object) # GsakFilterImportResult + error = Signal(str) # error message + progress = Signal(int, int) # (done, total) + # Completion is reported via QThread.finished — see GsakImportWorker in + # gsak_import_dialog.py for why run() never emits a custom "done" signal. + + def __init__(self, db3_path: Path, names: list[str], overwrite: bool): + super().__init__() + self.db3_path = db3_path + self.names = names + self.overwrite = overwrite + + def run(self) -> None: + from opensak.importer.gsak_filter_importer import import_gsak_filters + + try: + result = import_gsak_filters( + self.db3_path, + names=self.names, + overwrite=self.overwrite, + progress_cb=lambda done, total: self.progress.emit(done, total), + ) + self.result_ready.emit(result) + except Exception: + import traceback + self.error.emit(traceback.format_exc()) + + +class GsakFilterImportDialog(QDialog): + """Dialog for importing GSAK's saved filters (gsak.db3 or a .zip backup).""" + + import_completed = Signal() # at least one filter profile was written + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(tr("gsak_filter_import_dialog_title")) + self.setMinimumWidth(620) + self.setMinimumHeight(560) + # Issue #811: never grow taller than the screen. Both of the parts + # that can get long here — the filter list and the results log — + # scroll on their own, so no extra QScrollArea is needed. + clamp_dialog_height_to_screen(self, parent) + self._worker: Optional[GsakFilterImportWorker] = None + self._selected_path: Optional[Path] = None + self._db3_path: Optional[Path] = None # gsak.db3, unpacked if needed + self._setup_ui() + + # ── UI ─────────────────────────────────────────────────────────────────── + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setSpacing(10) + + layout.addWidget(QLabel(tr("gsak_filter_import_select_file_label"))) + + file_row = QHBoxLayout() + self._file_label = QLabel("") + self._file_label.setStyleSheet(hint_style(font_size=None)) + file_row.addWidget(self._file_label, stretch=1) + self._browse_btn = QPushButton(tr("import_browse")) + self._browse_btn.clicked.connect(self._browse) + file_row.addWidget(self._browse_btn) + layout.addLayout(file_row) + + # ── Filter list ────────────────────────────────────────────────────── + list_header = QHBoxLayout() + self._found_label = QLabel("") + list_header.addWidget(self._found_label, stretch=1) + self._all_btn = QPushButton(tr("gsak_filter_import_select_all")) + self._all_btn.clicked.connect(lambda: self._check_visible(True)) + self._all_btn.setEnabled(False) + list_header.addWidget(self._all_btn) + self._none_btn = QPushButton(tr("gsak_filter_import_select_none")) + self._none_btn.clicked.connect(lambda: self._check_visible(False)) + self._none_btn.setEnabled(False) + list_header.addWidget(self._none_btn) + layout.addLayout(list_header) + + self._search = QLineEdit() + self._search.setPlaceholderText(tr("gsak_filter_import_search_placeholder")) + self._search.textChanged.connect(self._apply_search) + self._search.setEnabled(False) + layout.addWidget(self._search) + + self._list = QListWidget() + self._list.itemChanged.connect(lambda _item: self._update_import_button()) + layout.addWidget(self._list, stretch=1) + + self._overwrite_cb = QCheckBox(tr("gsak_filter_import_overwrite")) + self._overwrite_cb.setToolTip(tr("gsak_filter_import_overwrite_tooltip")) + layout.addWidget(self._overwrite_cb) + + # ── Import + Close row ─────────────────────────────────────────────── + btn_row = QHBoxLayout() + btn_row.addStretch() + self._import_btn = QPushButton(tr("import_start")) + self._import_btn.setEnabled(False) + self._import_btn.clicked.connect(self._start_import) + btn_row.addWidget(self._import_btn) + self._close_btn = QPushButton(tr("close")) + self._close_btn.clicked.connect(self.accept) + btn_row.addWidget(self._close_btn) + layout.addLayout(btn_row) + + self._progress = QProgressBar() + self._progress.setRange(0, 0) + self._progress.setVisible(False) + layout.addWidget(self._progress) + + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setPlaceholderText(tr("import_log_placeholder")) + layout.addWidget(self._log, stretch=1) + + # ── File selection ─────────────────────────────────────────────────────── + + def set_path(self, path: Path) -> None: + """Select a file and list the filters it holds.""" + from opensak.importer.gsak_filter_importer import ( + GsakFilterSourceError, find_gsak_filter_db, load_gsak_filters, + ) + + self._selected_path = path + self._file_label.setText(path.name) + self._file_label.setToolTip(str(path)) + self._list.clear() + self._db3_path = None + + try: + db3_path = find_gsak_filter_db(path) + rows = load_gsak_filters(db3_path) + except GsakFilterSourceError as exc: + self._found_label.setText("") + self._set_list_enabled(False) + self._update_import_button() + QMessageBox.critical( + self, tr("gsak_filter_import_dialog_title"), + tr("gsak_filter_import_no_filters", name=path.name, error=str(exc)), + ) + return + + self._db3_path = db3_path + if not rows: + self._found_label.setText(tr("gsak_filter_import_found", count=0)) + self._set_list_enabled(False) + self._update_import_button() + QMessageBox.information( + self, tr("gsak_filter_import_dialog_title"), + tr("gsak_filter_import_none_found", name=path.name), + ) + return + + for name, _data in rows: + item = QListWidgetItem(name) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(Qt.CheckState.Checked) + self._list.addItem(item) + self._found_label.setText(tr("gsak_filter_import_found", count=len(rows))) + self._set_list_enabled(True) + self._update_import_button() + + def _browse(self) -> None: + settings = get_settings() + path_str, _ = QFileDialog.getOpenFileName( + self, + tr("gsak_filter_import_browse_title"), + settings.last_import_dir, + tr("gsak_filter_import_file_filter"), + ) + if path_str: + path = Path(path_str) + settings.last_import_dir = str(path.parent) + self.set_path(path) + + # ── Selection helpers ──────────────────────────────────────────────────── + + def _set_list_enabled(self, enabled: bool) -> None: + self._search.setEnabled(enabled) + self._all_btn.setEnabled(enabled) + self._none_btn.setEnabled(enabled) + + def _apply_search(self, text: str) -> None: + needle = text.strip().lower() + for i in range(self._list.count()): + item = self._list.item(i) + item.setHidden(bool(needle) and needle not in item.text().lower()) + + def _check_visible(self, checked: bool) -> None: + """Tick/untick every filter the search box currently shows.""" + state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + for i in range(self._list.count()): + item = self._list.item(i) + if not item.isHidden(): + item.setCheckState(state) + self._update_import_button() + + def _selected_names(self) -> list[str]: + return [ + self._list.item(i).text() + for i in range(self._list.count()) + if self._list.item(i).checkState() == Qt.CheckState.Checked + ] + + def _update_import_button(self) -> None: + running = self._worker is not None and self._worker.isRunning() + self._import_btn.setEnabled( + not running and self._db3_path is not None and bool(self._selected_names()) + ) + + # ── Import ─────────────────────────────────────────────────────────────── + + def _start_import(self) -> None: + if self._db3_path is None: + return + names = self._selected_names() + if not names: + return + + self._import_btn.setEnabled(False) + self._browse_btn.setEnabled(False) + self._progress.setVisible(True) + self._progress.setRange(0, len(names)) + self._progress.setValue(0) + self._log.clear() + self._append_log(tr("gsak_filter_import_running", count=len(names))) + + self._worker = GsakFilterImportWorker( + self._db3_path, names, self._overwrite_cb.isChecked() + ) + self._worker.progress.connect(self._on_progress) + self._worker.result_ready.connect(self._on_result) + self._worker.error.connect(self._on_error) + self._worker.finished.connect(self._on_done) + self._worker.start() + + def _on_progress(self, done: int, total: int) -> None: + if total > 0: + self._progress.setRange(0, total) + self._progress.setValue(done) + else: + self._progress.setRange(0, 0) + + def _on_result(self, result) -> None: + self._append_log(format_result(result)) + if result.written > 0: + self.import_completed.emit() + + def _on_error(self, msg: str) -> None: + self._append_log(f"{tr('import_failed')}\n{msg}") + + def _on_done(self) -> None: + self._progress.setVisible(False) + self._append_log(tr("gsak_import_done")) + self._browse_btn.setEnabled(True) + self._import_btn.setText(tr("import_again")) + self._worker = None + self._update_import_button() + + def closeEvent(self, event) -> None: + try: + if self._worker and self._worker.isRunning(): + self._worker.wait() + except RuntimeError: + pass + self._worker = None + super().closeEvent(event) + + # ── Log helpers ────────────────────────────────────────────────────────── + + def _append_log(self, text: str) -> None: + current = self._log.toPlainText() + separator = "\n" + ("─" * 40) + "\n" if current else "" + self._log.setPlainText(current + separator + text) + self._log.verticalScrollBar().setValue( + self._log.verticalScrollBar().maximum() + ) + + +def format_result(result) -> str: + """Render a GsakFilterImportResult as the dialog's results text. + + Kept a module-level function (rather than a method) so the statistics can + be tested without a dialog — the numbers are the point of the feature. + """ + def pct(value: float) -> str: + return f"{value * 100:.1f} %" + + lines = [ + tr("gsak_filter_import_complete"), + f" {tr('gsak_filter_import_written'):<32} {result.written}", + ] + if result.skipped: + lines.append(f" {tr('gsak_filter_import_skipped'):<32} {result.skipped}") + if result.failed: + lines.append(f" {tr('gsak_filter_import_failed'):<32} {result.failed}") + + lines += [ + "", + tr("gsak_filter_import_stats_header"), + f" {tr('gsak_filter_import_conditions'):<32} {result.total_criteria}", + f" {tr('gsak_filter_import_as_filters'):<32} {result.native_criteria}", + f" {tr('gsak_filter_import_as_sql'):<32} {result.sql_criteria}", + f" {tr('gsak_filter_import_as_comments'):<32} {result.commented_criteria}", + "", + f" {tr('gsak_filter_import_coverage'):<32} {pct(result.coverage)}", + f" {tr('gsak_filter_import_full'):<30} {result.fully_migrated}", + f" {tr('gsak_filter_import_partial'):<30} {result.partially_migrated}", + f" {tr('gsak_filter_import_nothing'):<30} {result.not_migrated}", + ] + + incomplete = [ + e for e in result.entries + if e.written and e.conversion and e.conversion.coverage < 1.0 + ] + if incomplete: + incomplete.sort(key=lambda e: (e.conversion.coverage, e.name.lower())) + lines += ["", tr("gsak_filter_import_incomplete_header", count=len(incomplete))] + for entry in incomplete[:MAX_LISTED_FILTERS]: + lines.append( + f" {entry.name[:40]:<42} {pct(entry.conversion.coverage):>7} " + + tr("gsak_filter_import_left_in_comments", + count=entry.conversion.not_migrated) + ) + if len(incomplete) > MAX_LISTED_FILTERS: + lines.append(f" … {len(incomplete) - MAX_LISTED_FILTERS}") + lines.append(tr("gsak_filter_import_where_hint")) + + skipped = [e for e in result.entries if e.skipped_existing] + if skipped: + lines += ["", tr("gsak_filter_import_skipped_header", count=len(skipped))] + for entry in skipped[:MAX_LISTED_FILTERS]: + lines.append(f" {entry.name}") + if len(skipped) > MAX_LISTED_FILTERS: + lines.append(f" … {len(skipped) - MAX_LISTED_FILTERS}") + + failed = [e for e in result.entries if e.error] + if failed: + lines += ["", tr("gsak_filter_import_failed_header", count=len(failed))] + for entry in failed[:10]: + lines.append(f" {entry.name}: {entry.error}") + + return "\n".join(lines) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index fbd784af..893dbb4c 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -365,6 +365,10 @@ def _setup_menu(self) -> None: self._act_gsak_import.triggered.connect(self._open_gsak_import_dialog) file_menu.addAction(self._act_gsak_import) + self._act_gsak_filter_import = QAction(tr("action_gsak_filter_import"), self) + self._act_gsak_filter_import.triggered.connect(self._open_gsak_filter_import_dialog) + file_menu.addAction(self._act_gsak_filter_import) + self._act_pq_email_check = QAction(tr("action_pq_email_check"), self) self._act_pq_email_check.triggered.connect(self._open_pq_email_check_dialog) file_menu.addAction(self._act_pq_email_check) @@ -1846,6 +1850,21 @@ def _open_gsak_import_dialog(self) -> None: dlg.import_completed.connect(self._refresh_after_import) dlg.exec() + def _open_gsak_filter_import_dialog(self) -> None: + """Import GSAK's saved filters as OpenSAK filter profiles. + + Unlike the cache import next to it this touches no cache data, so the + cache list and map are left alone — only the toolbar's profile + dropdown has to pick up the new profiles. + """ + from opensak.gui.dialogs.gsak_filter_import_dialog import GsakFilterImportDialog + dlg = GsakFilterImportDialog(self) + dlg.import_completed.connect(self._on_filter_profiles_imported) + dlg.exec() + + def _on_filter_profiles_imported(self) -> None: + self._populate_filter_profile_combo(select_name=self._active_filter_name or None) + def _open_pq_email_check_dialog(self) -> None: if self._trip_planner_active(): self._warn_trip_planner_active() diff --git a/src/opensak/importer/gsak_filter_importer.py b/src/opensak/importer/gsak_filter_importer.py new file mode 100644 index 00000000..69d36eb5 --- /dev/null +++ b/src/opensak/importer/gsak_filter_importer.py @@ -0,0 +1,2490 @@ +""" +src/opensak/importer/gsak_filter_importer.py — import saved GSAK filters as +OpenSAK filter profiles. + +GSAK stores every saved filter as one row in ``gsak.db3``:: + + TranslateFilters(Type, Description, Data) + Type 'FI' for a filter + Description the filter's name as shown in GSAK + Data the whole filter dialog, serialised (see _parse_blob) + +OpenSAK stores one JSON file per filter profile (see +``opensak.filters.engine.FilterProfile``). This module converts the former +into the latter, and every criterion it touches ends up in exactly one of +three places: + + * a **native filter** — the criterion is expressible in the Set Filter + dialog, so it becomes a real filter object and shows up in the tabs; + * **executable SQL** in the Where tab — OpenSAK stores the data but has no + GUI filter for it (watch list, elevation, user_data_1-4, …); + * an **SQL comment** in the Where tab — nothing in OpenSAK can express the + criterion, so it is written as a ``--`` line that documents what GSAK + filtered on. Nothing about it runs; it is there so the filter can be + finished by hand later. + +The first two count as migrated, the third does not, which is what +``Conversion.coverage`` reports and what the import dialog totals up into its +migration-coverage statistic. + +Confidence: decodings marked "ASSUMED" were inferred from real filter data, +not from GSAK documentation. Each assumption that actually affected a written +profile is listed in that profile's ``_gsak.notes`` block. + +Supersedes the former ``scripts/migrate_gsak_filters.py``. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from dataclasses import dataclass, field as dc_field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Optional + +from opensak.filters.engine import ( + DATE_UNITS, + LOG_SCOPE_CHOICES, + AttributeFilter, + AvailabilityFilter, + CacheTypeFilter, + ContainerFilter, + CountryFilter, + CountyFilter, + DateFilter, + DifficultyFilter, + DistanceFilter, + DnfFilter, + FavoritePointsFilter, + FilterProfile, + FilterSet, + FoundFilter, + FtfFilter, + GcCodeFilter, + HasCorrectedFilter, + HasTrackableFilter, + LinePolygonFilter, + LockedFilter, + LogFilter, + NameFilter, + NoCorrectedFilter, + NonPremiumFilter, + NotFoundFilter, + OwnerFilter, + PlacedByFilter, + PremiumFilter, + StateFilter, + TerrainFilter, + TextSearchFilter, + UserFlagFilter, + WaypointFilter, + WhereClauseFilter, +) + +# ── GSAK → OpenSAK vocabularies ────────────────────────────────────────────── + +# GSAK filter-dialog cache-type labels → OpenSAK cache_type string, routed +# through GSAK's one-letter codes so this stays consistent with +# GSAK_CACHE_TYPE_MAP in opensak/importer/gsak_importer.py (the authoritative +# mapping). None = GSAK has the type but OpenSAK has no equivalent. +GSAK_TYPE_LABEL_TO_OSAK: dict[str, Optional[str]] = { + "Traditional": "Traditional Cache", + "Multi": "Multi-cache", + "Mystery": "Unknown Cache", + "Letterbox": "Letterbox Hybrid", + "Wherigo": "Wherigo Cache", + "Earth": "Earthcache", + "Virtual": "Virtual Cache", + "Webcam": "Webcam Cache", + "Event": "Event Cache", + "CITO": "Cache In Trash Out Event", + "Mega Event": "Mega-Event Cache", + "Giga Event": "Giga-Event Cache", + "L&F Event": "Community Celebration Event", + "Groundspeak HQ": "Geocaching HQ Cache", + "Block Party": "Geocaching HQ Block Party", + "Maze Exhibit": "GPS Adventures Maze", + "Project APE": "Project A.P.E. Cache", + "Lab Cache": "Lab Cache", + "Locationless": "Locationless (Reverse) Cache", + "Benchmark": "Benchmark", # not in CACHE_TYPES, but the GSAK importer + "Other": "Other", # writes both of these verbatim + "Waymark": None, # no OpenSAK equivalent + "L&F Celebration": None, # GSAK code D — deliberately unmapped +} + +# GSAK container checkbox key → OpenSAK CONTAINER_SIZES value. +# Virtual/Unknown are GSAK placeholders for "no physical container" and both +# collapse to "Not chosen" on import (GSAK_CONTAINER_MAP in gsak_importer.py), +# so selecting either in GSAK cannot be reproduced exactly. +GSAK_CONTAINER_KEYS: dict[str, str] = { + "cbxMicro": "Micro", + "cbxSmall": "Small", + "cbxRegular": "Regular", + "cbxLarge": "Large", + "cbxOther": "Other", + "cbxNotChosen": "Not chosen", + "cbxVirtual": "Not chosen", # collapses — see above + "chkUnknown": "Not chosen", # collapses — see above +} +_COLLAPSED_CONTAINERS = {"cbxVirtual", "chkUnknown"} + +# Text-comparison combo (cbxDesc/Geocache-Name, cbxOwnerName/Owner-Name, +# cbxCountry/Land, …). Item order read off the GSAK dialog (German UI): +# Enthält, Enthält nicht, Gleich, Ungleich, Leer, Nicht leer, In Liste, +# RegEx, Nicht(RegExp) — which is exactly the operator list OpenSAK's +# TextMatchFilter offers under the same names, so the index maps straight +# onto a TEXT_OPS entry. +# +# Note there is no "off" entry: index 0 ("contains") is the default, and a +# text criterion counts as active whenever its edit box is non-empty — or, +# for empty/not-empty, on the operator alone. +TEXT_OP: dict[int, str] = { + 0: "contains", + 1: "not_contains", + 2: "equals", + 3: "not_equals", + 4: "empty", + 5: "not_empty", + 6: "in_list", + 7: "regex", + 8: "not_regex", +} +# Operators that need no value in the edit box. +TEXT_OP_VALUELESS = {"empty", "not_empty"} +# Operators SQLite cannot run: OpenSAK registers no REGEXP function on its +# connection, so a where_clause can never carry one. (The GUI's text filters +# do regex in Python, so a native filter still handles these.) +TEXT_OP_NO_SQL = {"regex", "not_regex"} + +# Numeric-comparison combo (cbxFavorite/Favoritenpunkte, cbxLogCount, …): +# Beliebig, Kleiner gleich, Größer gleich, Gleich, Zwischen (Inklusive). +NUM_OP: dict[int, Optional[str]] = { + 0: None, # "Beliebig" = criterion off + 1: "at_most", + 2: "at_least", + 3: "equals", + 4: "between", +} +# The same operators under the names OpenSAK's log/waypoint count rows use. +NUM_OP_TO_COUNT_OP: dict[str, str] = { + "at_most": "at_most", + "at_least": "at_least", + "equals": "equal", + "between": "between", +} + +# Difficulty/Terrain use the same operators minus "Beliebig" — the dialog +# opens on "Kleiner gleich" with the value 5.0, which is how "no D/T +# restriction" is stored. ASSUMED order (the dialog only ever shows the +# collapsed combo); a criterion is therefore only written out when it +# actually narrows the 1.0–5.0 range. +DT_OP: dict[int, str] = { + 0: "at_most", + 1: "at_least", + 2: "equals", + 3: "between", +} + +# Date combos (cbxFound/Letztes Funddatum, cbxDNFDate/DNF-Datum, …): +# Am oder vor, Am oder nach, Gleich, Zwischen (Inklusive), Während, +# Nicht während, Verglichen mit, Beliebig — mapped onto DateFilter's own +# operator names (DATE_OPS), which were modelled on this very list. +DATE_OP: dict[int, Optional[str]] = { + 0: "on_or_before", + 1: "on_or_after", + 2: "equal", + 3: "between", + 4: "during", # rolling window + 5: "not_during", # rolling window + 6: "compare", # two date columns — GSAK records only one of them + 7: None, # "Beliebig" = criterion off +} + +# The "Logs" and "Unterwegspunkte" tabs have their own date combos (cbxLogDate, +# cbxcDate) whose stored value is 6 in every saved filter — including filters +# whose date boxes clearly are not meant to restrict anything ("Noch nie +# gefunden", "Letzte 2 DNF"), and including ones that do use the rest of the +# tab. There is nothing for a log/waypoint date to be "compared with", so +# ASSUMED: these two combos simply lack the "Verglichen mit" entry, which puts +# their "Beliebig" at index 6 instead of 7. +DATE_OP_NO_COMPARE: dict[int, Optional[str]] = { + 0: "on_or_before", + 1: "on_or_after", + 2: "equal", + 3: "between", + 4: "during", + 5: "not_during", + 6: None, # "Beliebig" = criterion off +} + +# "Mein Funddatum" (cbxUserFound) does NOT use the list above: its stored value +# is 0 in every saved filter while every other date combo in the same blob +# holds 7 ("Beliebig"), and filters that store 0 also tick both "Gefunden" and +# "Nicht gefunden" — which would contradict any my-found-date restriction. So 0 +# is this combo's "off" state and the date boxes beside it are stale UI values. +# The remaining indices are unknown and are reported rather than guessed. +DATE_OP_MY_FOUND: dict[int, Optional[str]] = {0: None} + +# GSAK's rolling-window row reads "Während den letzten [n] [unit]": the edit +# box holds n, the combo beside it the unit. ASSUMED (every real filter leaves +# the row empty): that combo lists days / weeks / months / years — OpenSAK's +# DATE_UNITS, in the same order. +DURING_UNITS: tuple[str, ...] = DATE_UNITS + +# GSAK's child-waypoint type dropdown (cbxCtype2) stores an index, not a name. +# ASSUMED from real filters: the list holds the six GPX waypoint types in +# alphabetical order — "Parking", a filter that selects caches having a parking +# waypoint, stores index 1, and the "…ToCorrect" filters, which look for +# mystery caches with no final yet, store index 0 together with "waypoint +# count = 0". +WP_TYPE_BY_INDEX: tuple[str, ...] = ( + "Final Location", + "Parking Area", + "Physical Stage", + "Reference Point", + "Trailhead", + "Virtual Stage", +) + +# GSAK compass-quadrant checkbox → the bearing range it covers, in degrees +# clockwise from north. The eight 45° sectors are centred on their compass +# point, so N spans 337.5°–22.5° and wraps around 0. +_QUADRANTS: dict[str, tuple[float, float]] = { + "cbxN": (337.5, 22.5), + "cbxNe": (22.5, 67.5), "cbxNE": (22.5, 67.5), + "cbxE": (67.5, 112.5), + "cbxSE": (112.5, 157.5), + "cbxS": (157.5, 202.5), + "cbxSW": (202.5, 247.5), + "cbxW": (247.5, 292.5), + "cbxNW": (292.5, 337.5), +} + + +def _dt_value(index: int) -> float: + """GSAK D/T value combo index → rating: index 0 = 1.0 … index 8 = 5.0.""" + return 1.0 + 0.5 * index + + +def _escape_sql(value: str) -> str: + return value.replace("'", "''") + + +def _wrap(text: str, width: int) -> list[str]: + """Break *text* into lines of at most *width* characters, on spaces. + + A word longer than *width* (a URL, a long SQL expression) is left whole + rather than cut, so nothing that might be uncommented later gets broken + in the middle. + """ + words = " ".join(text.split()).split(" ") + lines: list[str] = [] + current = "" + for word in words: + if not current: + current = word + elif len(current) + 1 + len(word) <= width: + current += " " + word + else: + lines.append(current) + current = word + if current: + lines.append(current) + return lines or [""] + + +def _dedupe(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item not in seen: + seen.add(item) + out.append(item) + return out + + +# ── GSAK where-clause → OpenSAK SQL ────────────────────────────────────────── + +# Column renames, applied case-insensitively on word boundaries. OpenSAK's +# WhereClauseFilter runs "SELECT id FROM caches WHERE ()", so scalar +# subqueries against the related tables are legal. +WHERE_COLUMN_MAP: dict[str, str] = { + # ── Caches table ──────────────────────────────────────────────────────── + "code": "gc_code", + "cachetype": "cache_type", + "placedby": "placed_by", + "ownername": "owner_name", + "ownerid": "owner_id", + "placeddate": "hidden_date", + "favpoints": "favorite_points", + "userflag": "user_flag", + "usersort": "user_sort", + "userdata": "user_data_1", + "user2": "user_data_2", + "user3": "user_data_3", + "user4": "user_data_4", + "userdate": "found_date", + "founddate": "found_date", + "foundbymedate": "found_date", + "lastfounddate": "last_found_date", + "dnfdate": "dnf_date", + "ftf": "first_to_find", + "lock": "locked", + "ispremium": "premium_only", + "pmonly": "premium_only", + "cacheid": "gc_cache_id", + "gcnote": "gc_note", + "lastgpxdate": "last_gpx_update", + "changed": "last_updated", + "created": "imported_at", + "lastlogdate": "last_log_date", + "longdescription": "long_description", + "shortdescription": "short_description", + "hints": "encoded_hints", + "tbcount": "trackable_count", + "numlogs": "log_count", + "foundcount": "found_log_count", + # GSAK keeps these on the cache row itself (empty string / 0 when unset); + # OpenSAK keeps them in user_notes and simply has no row — hence coalesce, + # so "= ''" style GSAK tests keep working. + "hascorrected": "coalesce((SELECT is_corrected FROM user_notes WHERE user_notes.cache_id = caches.id), 0)", + "usernote": "coalesce((SELECT note FROM user_notes WHERE user_notes.cache_id = caches.id), '')", + "userlat": "(SELECT corrected_lat FROM user_notes WHERE user_notes.cache_id = caches.id)", + "userlon": "(SELECT corrected_lon FROM user_notes WHERE user_notes.cache_id = caches.id)", + "lastuserdate": "(SELECT updated_at FROM user_notes WHERE user_notes.cache_id = caches.id)", + # Identical names, listed so they are not reported as unknown identifiers. + "name": "name", + "container": "container", + "difficulty": "difficulty", + "terrain": "terrain", + "latitude": "latitude", + "longitude": "longitude", + "latoriginal": "latitude", + "lonoriginal": "longitude", + "elevation": "elevation", + "country": "country", + "state": "state", + "county": "county", + "found": "found", + "dnf": "dnf", + "archived": "archived", + "watch": "watch", + "color": "color", + "guid": "guid", + "bearing": "bearing", + "distance": "distance", # OpenSAK rewrites this to its haversine UDF + "url": "url", +} + +# GSAK sub-table columns → their OpenSAK counterparts. +# +# GSAK links its child tables to the parent cache by GC CODE (Logs.lParent, +# Waypoints.cParent, Attributes.aCode, Corrected.kCode); OpenSAK links them by +# the numeric caches.id. A rename on its own would therefore produce SQL that +# runs but matches nothing, so every rename below is followed by +# _relink_code_joins(), which rewrites GSAK's code-based joins into id-based +# ones. +WHERE_SUBTABLE_MAP: dict[str, str] = { + # LOGS + "lparent": "cache_id", + "llogid": "log_id", + "ltype": "log_type", + "ldate": "log_date", + "lby": "finder", + "lownerid": "finder_id", + "ltext": "text", + "lencoded": "text_encoded", + # WAYPOINTS + "cparent": "cache_id", + "ccode": "wp_code", + "cprefix": "prefix", + "cname": "name", + "ctype": "wp_type", + "cdate": "wp_date", + "clat": "latitude", + "clon": "longitude", + "ccomment": "comment", + "cbyuser": "created_by_user", + "cflag": "wp_flag", + "curl": "url", + # ATTRIBUTES + "acode": "cache_id", + "aid": "attribute_id", + "ainc": "is_on", + # CORRECTED → user_notes (see WHERE_TABLE_MAP) + "kcode": "cache_id", + "kafterlat": "corrected_lat", + "kafterlon": "corrected_lon", +} + +# GSAK table names → OpenSAK table names. Logs/Waypoints/Attributes keep their +# names; only Corrected moves. +WHERE_TABLE_MAP: dict[str, str] = { + "corrected": "user_notes", +} + +# GSAK columns whose OpenSAK counterpart carries the opposite meaning. +WHERE_INVERTED: dict[str, str] = { + "tempdisabled": "(NOT available)", +} + +# Mappings that are close but not exact. They still translate (the clause stays +# "verified"), but the difference is recorded in the profile's _gsak.notes. +WHERE_SOFT_NOTES: dict[str, str] = { + "lastgpxdate": "GSAK's LastGPXDate (last GPX/API refresh) became OpenSAK's last_gpx_update — " + "the timestamp of the last import pass that touched the cache, which is the " + "same idea but is stamped locally, not taken from the source file", + "changed": "GSAK's Changed (the GC.com-side listing change date) became OpenSAK's " + "last_updated, which the GSAK importer fills from exactly that column", + "created": "GSAK's Created (record-creation date) became OpenSAK's imported_at, which is " + "when THIS OpenSAK database first saw the cache — not when GSAK first saw it", + "foundbymedate": "GSAK's FoundByMeDate became OpenSAK's found_date", + "lastuserdate": "GSAK's LastUserDate became a sub-select on OpenSAK's user_notes.updated_at " + "(NULL for caches that have no personal note or corrected coordinates)", + "userdata": "GSAK's UserData became OpenSAK's user_data_1", + "tbcount": "GSAK's tbCount became OpenSAK's trackable_count (filled in on import)", + "numlogs": "GSAK's NumLogs became OpenSAK's cached log_count column", + "foundcount": "GSAK's FoundCount is a 0/1 \"found by me\" flag; it became OpenSAK's " + "found_log_count (how many of the user's own found-type logs the cache has), " + "which is >= 1 for every cache GSAK would have counted", + "hascorrected": "GSAK's HasCorrected became a sub-select on OpenSAK's user_notes.is_corrected", + "usernote": "GSAK's UserNote became a sub-select on OpenSAK's user_notes.note", + "latitude": "GSAK's Latitude/Longitude hold the CORRECTED position for a solved cache; " + "OpenSAK's caches.latitude/longitude always hold the posted position and keep " + "the correction in user_notes.corrected_lat/corrected_lon. Wrap the column in " + "coalesce((SELECT corrected_lat FROM user_notes WHERE user_notes.cache_id = " + "caches.id), latitude) if the clause meant the corrected position", + "kafterlat": "GSAK's Corrected table became OpenSAK's user_notes (kCode → cache_id, " + "kAfterLat/kAfterLon → corrected_lat/corrected_lon)", + "clat": "GSAK's child-waypoint coordinates (cLat/cLon) became OpenSAK's " + "waypoints.latitude/longitude, which are NULL — not 0.0 — when unknown", +} +WHERE_SOFT_NOTES["latoriginal"] = ( + "GSAK's LatOriginal/LonOriginal (the posted position of a solved cache) became OpenSAK's " + "caches.latitude/longitude, which always hold the posted position — the correction lives " + "in user_notes.corrected_lat/corrected_lon" +) +WHERE_SOFT_NOTES["lonoriginal"] = WHERE_SOFT_NOTES["latoriginal"] +WHERE_SOFT_NOTES["longitude"] = WHERE_SOFT_NOTES["latitude"] +WHERE_SOFT_NOTES["kafterlon"] = WHERE_SOFT_NOTES["kafterlat"] +WHERE_SOFT_NOTES["kcode"] = WHERE_SOFT_NOTES["kafterlat"] +WHERE_SOFT_NOTES["clon"] = WHERE_SOFT_NOTES["clat"] + +# GSAK identifiers with no mechanical translation — each one makes the +# translated clause "unverified", which keeps it out of the executable SQL and +# puts it in the Where tab as a comment instead. +WHERE_UNMAPPABLE: dict[str, str] = { + "isowner": "GSAK's isOwner has no OpenSAK column — set your geocaching.com account name " + "in Settings to have it translated to an owner_name test", + "ltime": "GSAK's logs.lTime is a separate time-of-day column; OpenSAK folds date and " + "time into logs.log_date, so compare against that instead", + "kbeforelat": "GSAK's Corrected.kBeforeLat/kBeforeLon (the posted position of a solved " + "cache) became OpenSAK's caches.latitude/longitude — they are not columns of " + "user_notes, so this sub-select needs a join back to caches", + "cachememo": "GSAK's CacheMemo table is split across OpenSAK's caches / user_notes columns", + "cachesall": "GSAK's CachesAll view (all databases at once) has no OpenSAK counterpart", + "custom": "GSAK's Custom/CustomLocal tables (user-defined columns) have no OpenSAK " + "counterpart — only user_data_1-4 exist", + "customlocal": "GSAK's Custom/CustomLocal tables (user-defined columns) have no OpenSAK " + "counterpart — only user_data_1-4 exist", + "travelbugs": "GSAK's CacheMemo.TravelBugs text maps to OpenSAK's trackables table / " + "trackable_count", + "favperc": "GSAK's FavPerc (favourite percentage) is not stored by OpenSAK", + "labid": "GSAK's LabId is not stored by OpenSAK", + "symbol": "GSAK's Symbol (GPX symbol name) is not stored by OpenSAK", +} +WHERE_UNMAPPABLE["kbeforelon"] = WHERE_UNMAPPABLE["kbeforelat"] + +# GSAK Status ('A' active, 'T' temporarily disabled, 'X' archived) → the +# equivalent test on OpenSAK's archived / available booleans. Verified 1:1 +# against the importer (gsak_importer.py: _STATUS_ARCHIVED / _STATUS_AVAILABLE). +_STATUS_SQL: dict[str, str] = { + "A": "(available AND NOT archived)", + "T": "(NOT available AND NOT archived)", + "X": "archived", +} +_STATUS_CMP_RE = re.compile(r"\bstatus\b\s*(=|<>|!=)\s*'([ATX])'", re.IGNORECASE) + +# GSAK macro globals and macro functions (%g_… / $g_… / g_Distance(…)) that can +# appear in a saved where clause and only resolve while a GSAK macro is running. +# GSAK's kBeforeLat/kAfterLon and friends are NOT listed here: inside a where +# clause they are columns of the Corrected table, and are mapped as such. +_MACRO_GLOBAL_RE = re.compile(r"^g_", re.IGNORECASE) + +# GSAK one-letter cache-type codes as used inside where clauses. +GSAK_CODE_TO_OSAK: dict[str, Optional[str]] = { + "T": "Traditional Cache", "M": "Multi-cache", + "U": "Unknown Cache", "B": "Letterbox Hybrid", + "W": "Webcam Cache", "V": "Virtual Cache", + "E": "Event Cache", "C": "Cache In Trash Out Event", + "R": "Earthcache", "I": "Wherigo Cache", + "L": "Locationless (Reverse) Cache", + "O": "Other", "G": "Benchmark", + "Q": "Lab Cache", "A": "Project A.P.E. Cache", + "H": "Geocaching HQ Cache", "J": "Giga-Event Cache", + "P": "Geocaching HQ Block Party", + "X": "GPS Adventures Maze", "Z": "Mega-Event Cache", + "F": "Community Celebration Event", + "D": None, "Y": None, +} + +# OpenSAK columns (and sub-selects) that hold numbers. GSAK's saved SQL often +# quotes boolean/integer literals ("UserFlag = '1'"); against a plain column +# SQLite's INTEGER affinity still coerces that, but against an expression — +# such as the coalesce() sub-selects above — it does not: "SELECT 0 = '0'" +# is false. So quoted numeric literals are unquoted after translation. +_NUMERIC_COLUMNS = { + "found", "archived", "available", "premium_only", "user_flag", "locked", + "dnf", "first_to_find", "trackable_count", "favorite_points", "user_sort", + "difficulty", "terrain", "latitude", "longitude", "elevation", + "is_corrected", "corrected_lat", "corrected_lon", "attribute_id", "is_on", + "watch", "log_count", "found_log_count", "waypoint_count", "find_count", + "bearing", "distance", "created_by_user", "wp_flag", "text_encoded", +} +_NUM_EXPR = r"(?:coalesce\((?:[^()']|\([^()]*\))*\)|[A-Za-z_][A-Za-z_0-9]*)" +_QUOTED_NUM_RE = re.compile( + rf"(?P{_NUM_EXPR})\s*(?P=|<>|!=|>=|<=|>|<)\s*'(?P-?\d+(?:\.\d+)?)'" +) +_QUOTED_NUM_REV_RE = re.compile( + rf"'(?P-?\d+(?:\.\d+)?)'\s*(?P=|<>|!=|>=|<=|>|<)\s*(?P{_NUM_EXPR})" +) + + +def _unquote_numeric_literals(sql: str, notes: list[str]) -> str: + """Drop the quotes around numeric literals compared against numeric columns.""" + + def _fix(m: re.Match, reverse: bool = False) -> str: + expr = m.group("expr") + if not any(col in expr.lower() for col in _NUMERIC_COLUMNS): + return m.group(0) + notes.append( + "where: GSAK compared a numeric column against a quoted literal " + "(e.g. \"= '1'\"); the quotes were removed so SQLite compares numbers" + ) + val, op = m.group("val"), m.group("op") + return f"{val} {op} {expr}" if reverse else f"{expr} {op} {val}" + + sql = _QUOTED_NUM_RE.sub(_fix, sql) + return _QUOTED_NUM_REV_RE.sub(lambda m: _fix(m, reverse=True), sql) + + +_STRING_LITERAL_RE = re.compile(r"'(?:[^']|'')*'") +# Identifiers are matched together with any table qualifier ("c.hascorrected", +# not "c" and "hascorrected" separately): several GSAK columns map to a +# correlated sub-select rather than a plain column name, and pasting one of +# those in after a "c." would produce invalid SQL. +_IDENT_RE = re.compile( + r"\b(?:(?P[A-Za-z_][A-Za-z_0-9]*)\s*\.\s*)?(?P[A-Za-z_][A-Za-z_0-9]*)\b") +_CACHETYPE_IN_RE = re.compile(r"\bcachetype\b\s+(not\s+)?in\s*\(([^)]*)\)", re.IGNORECASE) +# GSAK's Latitude/Longitude hold the CORRECTED position of a solved cache and +# LatOriginal/LonOriginal the posted one; OpenSAK's caches.latitude/longitude +# are the posted position and the correction lives in user_notes. Both GSAK +# columns therefore translate to the same OpenSAK column, so a clause that +# compares the two collapses into a tautology and has to be flagged. +_GSAK_ORIGINAL_POS_RE = re.compile(r"\b(?:latoriginal|lonoriginal)\b", re.IGNORECASE) +_GSAK_CORRECTED_POS_RE = re.compile(r"\b(?:latitude|longitude)\b", re.IGNORECASE) +_CACHETYPE_CMP_RE = re.compile(r"\bcachetype\b\s*(=|<>|!=)\s*'([A-Za-z])'", re.IGNORECASE) + +# SQL keywords, functions and OpenSAK table names that are never column names. +_SQL_WORDS = { + "select", "from", "where", "and", "or", "not", "in", "is", "null", "like", + "between", "exists", "as", "on", "join", "left", "right", "full", "cross", + "natural", "using", "inner", "outer", "group", + "by", "order", "having", "union", "intersect", "except", "with", + "recursive", "values", "all", "distinct", "case", "when", "then", + "else", "end", "asc", "desc", "limit", "offset", "date", "datetime", "now", + "localtime", "utc", "day", "days", "month", "months", "year", "years", + "hour", "minute", "second", "weekday", "start", "of", + "julianday", "unixepoch", "strftime", "count", "sum", "total", "min", + "max", "avg", "length", "instr", "iif", "printf", "format", "typeof", + "group_concat", "glob", "escape", "random", "hex", "ltrim", "rtrim", + "lower", "upper", "substr", "substring", "trim", "replace", "cast", + "int", "integer", "real", "text", "decimal", "numeric", "float", "double", + "boolean", "char", "varchar", "blob", "collate", "nocase", "binary", + "coalesce", "ifnull", "nullif", "abs", "round", "true", + "false", "caches", "logs", "attributes", "user_notes", "waypoints", + "trackables", +} + +# Every OpenSAK column name (plus its table names), so an identifier that is +# ALREADY an OpenSAK column — because a rewrite earlier in the pipeline +# produced it, or because the GSAK clause happened to use the same word — is +# not reported as unrecognised. +_OSAK_COLUMNS: set[str] = ( + set(WHERE_COLUMN_MAP.values()) + | set(WHERE_SUBTABLE_MAP.values()) + | { + "id", "cache_id", "gc_code", "cache_type", "available", "archived", + "short_desc_html", "long_desc_html", "found_date", "dnf_date", + "first_to_find", "user_data_1", "user_data_2", "user_data_3", + "user_data_4", "favorite_points", "gc_note", "guid", "watch", + "gc_cache_id", "find_count", "log_count", "trackable_count", + "found_log_count", "last_log_date", "waypoint_count", + "parent_gc_code", "locked", "location_source", "location_basis", + "location_updated", "location_dataset", "imported_at", "source_file", + "last_found_date", "last_gpx_update", "last_four_logs", "last_updated", + "is_corrected", "corrected_lat", "corrected_lon", "updated_at", "note", + "wp_type", "wp_code", "wp_date", "wp_flag", "prefix", "comment", + "description", "created_by_user", "log_id", "log_type", "log_date", + "finder", "finder_id", "text_encoded", "logged_by_owner", + "tracking_code", "ref", "attribute_id", "is_on", + } +) - {v for v in WHERE_COLUMN_MAP.values() if not v.isidentifier()} + + +def _split_sql_literals(sql: str) -> list[tuple[bool, str]]: + """Split *sql* into (is_string_literal, chunk) parts so identifier + rewriting never touches the inside of a quoted string.""" + parts: list[tuple[bool, str]] = [] + pos = 0 + for m in _STRING_LITERAL_RE.finditer(sql): + if m.start() > pos: + parts.append((False, sql[pos:m.start()])) + parts.append((True, m.group(0))) + pos = m.end() + if pos < len(sql): + parts.append((False, sql[pos:])) + return parts + + +_UNMAPPED_TYPE_NOTE = ( + "where: GSAK cache-type code {codes} has no OpenSAK cache type — the GSAK importer files " + "such caches under 'Unknown Cache', where they are indistinguishable from real mystery " + "caches, so the code was dropped from the type list rather than left as raw GSAK SQL" +) + + +def _expand_cachetype_codes(sql: str, warnings: list[str], notes: list[str]) -> str: + """Rewrite GSAK's single-letter cachetype comparisons to OpenSAK names. + + Codes with no OpenSAK cache type (GSAK's D / Y) are dropped from the list + and recorded as a note rather than a warning: leaving the raw GSAK letter + in place would produce SQL that silently matches nothing, and the importer + collapses those types into "Unknown Cache" anyway, so no translation can + single them out. + """ + + def _names(codes: str) -> Optional[str]: + out: list[str] = [] + dropped: list[str] = [] + for raw in codes.split(","): + code = raw.strip().strip("'").upper() + if not code: + continue + name = GSAK_CODE_TO_OSAK.get(code) + if name is None: + dropped.append(code) + continue + quoted = "'" + name.replace("'", "''") + "'" + if quoted not in out: + out.append(quoted) + if dropped: + notes.append(_UNMAPPED_TYPE_NOTE.format( + codes=", ".join(f"'{c}'" for c in sorted(set(dropped))))) + return ", ".join(out) if out else None + + def _sub_in(m: re.Match) -> str: + names = _names(m.group(2)) + if names is None: + # Every listed code was unmappable: "in (…)" can never be true and + # "not in (…)" is always true. + warnings.append( + "where: a cachetype list held only GSAK codes with no OpenSAK equivalent — " + "the comparison was left unchanged and needs a manual decision" + ) + return m.group(0) + return f"cache_type {'not ' if m.group(1) else ''}in ({names})" + + def _sub_cmp(m: re.Match) -> str: + code, op = m.group(2).upper(), m.group(1) + name = GSAK_CODE_TO_OSAK.get(code) + if name is None: + # No OpenSAK cache type can stand for this code, so the equality can + # never hold (and its negation always does). + notes.append(_UNMAPPED_TYPE_NOTE.format(codes=f"'{code}'")) + return "1 = 0" if op == "=" else "1 = 1" + return f"cache_type {op} '{name}'" + + sql = _CACHETYPE_IN_RE.sub(_sub_in, sql) + return _CACHETYPE_CMP_RE.sub(_sub_cmp, sql) + + +def _expand_status(sql: str, notes: list[str]) -> str: + """Rewrite GSAK's Status = 'A'/'T'/'X' tests onto archived / available.""" + + def _sub(m: re.Match) -> str: + expr = _STATUS_SQL[m.group(2).upper()] + notes.append( + "where: GSAK's Status ('A' active / 'T' temporarily disabled / 'X' archived) was " + "rewritten onto OpenSAK's archived / available booleans, which the GSAK importer " + "derives from exactly that column" + ) + return expr if m.group(1) == "=" else f"NOT {expr}" + + return _STATUS_CMP_RE.sub(_sub, sql) + + +_ALIAS_RE = re.compile( + r"\b(?:from|join)\s+[A-Za-z_][A-Za-z_0-9]*\s+(?:as\s+)?([A-Za-z_][A-Za-z_0-9]*)", + re.IGNORECASE, +) + + +# ── GSAK code-joins → OpenSAK id-joins ─────────────────────────────────────── +# +# Applied after the identifier renames, so these patterns already talk about +# OpenSAK column names (gc_code / cache_id) rather than GSAK's. +_JOIN_CODE_TO_ID_RE = re.compile( + r"\b(?P[A-Za-z_][A-Za-z_0-9]*)\.gc_code\b\s*=\s*" + r"(?P[A-Za-z_][A-Za-z_0-9]*\.)?cache_id\b", re.IGNORECASE) +_JOIN_ID_TO_CODE_RE = re.compile( + r"\b(?P[A-Za-z_][A-Za-z_0-9]*\.)?cache_id\b\s*=\s*" + r"(?P[A-Za-z_][A-Za-z_0-9]*)\.gc_code\b", re.IGNORECASE) +_IN_SUBSELECT_RE = re.compile( + r"(?P\b[A-Za-z_][A-Za-z_0-9]*\.)?\bgc_code\b\s+(?Pnot\s+)?in\s*\(", + re.IGNORECASE) + +_RELINK_NOTE = ( + "where: GSAK links its Logs/Waypoints/Attributes/Corrected rows to the parent cache by GC " + "code (lParent/cParent/aCode/kCode); OpenSAK links them by the numeric caches.id, so the " + "code-based joins and \"code in (select …)\" tests were rewritten onto id" +) + + +def _closing_paren(sql: str, open_idx: int) -> int: + """Index of the ``)`` matching the ``(`` at *open_idx*, or -1. + + Skips over single-quoted string literals so a bracket inside one is not + mistaken for structure. + """ + depth = 0 + i = open_idx + while i < len(sql): + ch = sql[i] + if ch == "'": + i += 1 + while i < len(sql): + if sql[i] == "'": + if i + 1 < len(sql) and sql[i + 1] == "'": + i += 2 + continue + break + i += 1 + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def _selects_cache_id(subselect: str) -> bool: + """True when *subselect* is a SELECT whose result column is a cache_id.""" + body = subselect.strip() + if not re.match(r"(?is)^select\b", body): + return False + # Only the select list matters — a cache_id in the WHERE part is a join + # predicate, not the value the outer "in" is compared against. + m = re.search(r"(?is)\bfrom\b", body) + select_list = body[6:m.start()] if m else body[6:] + return re.search(r"\bcache_id\b", select_list, re.IGNORECASE) is not None + + +def _relink_code_joins(sql: str, notes: list[str]) -> str: + """Turn GSAK's GC-code joins into OpenSAK's numeric-id joins.""" + hit = False + + def _sub_join(m: re.Match) -> str: + nonlocal hit + hit = True + return f"{m.group('a')}.id = {m.group('b') or ''}cache_id" + + def _sub_join_rev(m: re.Match) -> str: + nonlocal hit + hit = True + return f"{m.group('b') or ''}cache_id = {m.group('a')}.id" + + sql = _JOIN_CODE_TO_ID_RE.sub(_sub_join, sql) + sql = _JOIN_ID_TO_CODE_RE.sub(_sub_join_rev, sql) + + # "code [not] in (select lParent …)" → "id [not] in (select cache_id …)". + out: list[str] = [] + pos = 0 + while True: + m = _IN_SUBSELECT_RE.search(sql, pos) + if not m: + break + open_idx = m.end() - 1 + close_idx = _closing_paren(sql, open_idx) + if close_idx < 0 or not _selects_cache_id(sql[open_idx + 1:close_idx]): + out.append(sql[pos:m.end()]) + pos = m.end() + continue + hit = True + out.append(sql[pos:m.start()]) + out.append(f"{m.group('qual') or ''}id {m.group('neg') or ''}in (") + pos = m.end() + out.append(sql[pos:]) + sql = "".join(out) + + if hit: + notes.append(_RELINK_NOTE) + return sql + + +def _table_aliases(sql: str) -> set[str]: + """Collect table aliases (``from caches c``, ``join logs l``) plus every + identifier used as a qualifier (``c.name``) so they are not mistaken for + column names.""" + aliases = {m.group(1).lower() for m in _ALIAS_RE.finditer(sql)} + aliases -= _SQL_WORDS + aliases |= {m.group(1).lower() + for m in re.finditer(r"\b([A-Za-z_][A-Za-z_0-9]*)\s*\.", sql)} + return aliases + + +def translate_where(sql: str, custom_columns: Optional[list[str]] = None, + me: Optional[str] = None + ) -> tuple[str, list[str], list[str], bool]: + """Best-effort translation of a GSAK where clause into OpenSAK SQL. + + *custom_columns* are the user-defined GSAK column names of the same filter + (from its ``*custom*`` section); they are reported by name rather than as + anonymous unknown identifiers. *me* is the user's geocaching.com account + name, used to translate GSAK's ``isOwner``. + + Returns (translated_sql, warnings, notes, verified). ``verified`` is False + when anything was left untranslated; ``notes`` are inexact-but-usable + mappings that do not block verification. + """ + warnings: list[str] = [] + notes: list[str] = [] + sql = _expand_cachetype_codes(sql, warnings, notes) + sql = _expand_status(sql, notes) + aliases = _table_aliases(sql) + custom = {name.lower() for name in (custom_columns or [])} + + out: list[str] = [] + for is_literal, chunk in _split_sql_literals(sql): + if is_literal: + out.append(chunk) + continue + + def _sub_ident(m: re.Match) -> str: + qual, word = m.group("qual"), m.group("word") + prefix = f"{qual}." if qual else "" + + def _keep(mapped: str) -> str: + """Re-attach the table qualifier — unless the mapping is an + expression rather than a bare column name, in which case the + qualifier cannot survive.""" + if mapped.isidentifier(): + return prefix + mapped + if qual: + notes.append( + f"where: '{prefix}{word}' maps to a sub-select on OpenSAK's user_notes, " + f"which cannot carry the '{qual}' table qualifier — the sub-select is " + f"correlated to the outer caches row instead, so check the logic if " + f"'{qual}' was not that outer caches table" + ) + return mapped + + low = word.lower() + if low in _SQL_WORDS or low in aliases: + return prefix + word + if low in custom: + warnings.append( + f"where: '{word}' is a user-defined GSAK column — OpenSAK has no custom " + f"columns (only user_data_1-4)" + ) + return prefix + word + if _MACRO_GLOBAL_RE.match(word): + warnings.append( + f"where: '{word}' is a GSAK macro variable resolved at run time — " + f"substitute a literal value" + ) + return prefix + word + if low == "isowner": + if me: + notes.append( + f"where: GSAK's isOwner was translated to an owner_name test against " + f"your account name ({me})" + ) + return f"lower(coalesce(owner_name, '')) = '{_escape_sql(me.lower())}'" + warnings.append(f"where: {WHERE_UNMAPPABLE['isowner']}") + return prefix + word + if low in WHERE_INVERTED: + warnings.append( + f"where: '{word}' → {WHERE_INVERTED[low]} — inverted meaning, verify the logic" + ) + return WHERE_INVERTED[low] + if low in WHERE_UNMAPPABLE: + warnings.append(f"where: {WHERE_UNMAPPABLE[low]}") + return prefix + word + mapped = (WHERE_COLUMN_MAP.get(low) + or WHERE_SUBTABLE_MAP.get(low) + or WHERE_TABLE_MAP.get(low)) + if mapped is not None: + if low in WHERE_SOFT_NOTES: + notes.append("where: " + WHERE_SOFT_NOTES[low]) + return _keep(mapped) + if low in _OSAK_COLUMNS: + return prefix + word # already an OpenSAK column + # Anything still unrecognised is left alone and reported. + warnings.append(f"where: unrecognised identifier '{word}' left unchanged") + return prefix + word + + out.append(_IDENT_RE.sub(_sub_ident, chunk)) + + if _GSAK_ORIGINAL_POS_RE.search(sql) and _GSAK_CORRECTED_POS_RE.search( + _GSAK_ORIGINAL_POS_RE.sub("", sql)): + warnings.append( + "where: the clause compares GSAK's Latitude/Longitude (the CORRECTED position of a " + "solved cache) against LatOriginal/LonOriginal (the posted one). OpenSAK's " + "caches.latitude/longitude ARE the posted position and keep the correction in " + "user_notes, so both GSAK columns translate to the same OpenSAK column and the " + "comparison collapses into a tautology — rewrite the corrected side as " + "coalesce((SELECT corrected_lat FROM user_notes WHERE user_notes.cache_id = " + "caches.id), latitude)" + ) + + sql = _relink_code_joins("".join(out), notes) + sql = _unquote_numeric_literals(sql, notes) + return sql, _dedupe(warnings), _dedupe(notes), not warnings + + +# ── GSAK blob parsing ──────────────────────────────────────────────────────── + +class GsakFilter: + """One parsed GSAK filter blob.""" + + def __init__(self, name: str, raw: str): + self.name = name + self.raw = raw + self.kv: dict[str, str] = {} + self.custom: list[str] = [] # *custom* section, one line per criterion + self.where: str = "" # *where* section, verbatim GSAK SQL + + def text(self, key: str) -> str: + return self.kv.get(key, "").strip() + + def flag(self, key: str, default: Optional[bool] = None) -> Optional[bool]: + v = self.kv.get(key) + if v is None: + return default + return v.strip().lower() == "true" + + def num(self, key: str) -> Optional[int]: + v = self.text(key) + if not v: + return None + try: + return int(float(v)) + except ValueError: + return None + + +def parse_filter_blob(name: str, data: str) -> GsakFilter: + """Parse a TranslateFilters.Data blob. + + Layout (CRLF separated): + key=value lines, one per dialog control + [ArcFilter=~lat,lon~lat,lon…] polygon / arc selection + [=True|False] one line per cache type + [*custom*] user-defined column criteria follow + [*where*=] raw SQL, runs to the end of the blob + """ + f = GsakFilter(name, data) + body, sep, where = data.partition("*where*=") + if sep: + f.where = where.strip() + + in_custom = False + for line in body.replace("\r\n", "\n").split("\n"): + if line.strip() == "*custom*": + in_custom = True + continue + if not line.strip(): + continue + if in_custom: + f.custom.append(line.strip()) + continue + key, eq, value = line.partition("=") + if eq: + f.kv[key.strip()] = value + return f + + +def delphi_date(serial: str) -> Optional[datetime]: + """Convert a Delphi TDateTime serial (days since 1899-12-30) to a datetime.""" + try: + value = float(serial.strip()) + except (TypeError, ValueError): + return None + if value <= 0: + return None + return datetime(1899, 12, 30) + timedelta(days=value) + + +# ── Coverage-tracked conversion ────────────────────────────────────────────── + +NATIVE = "native" # became a filter the Set Filter dialog shows +SQL = "sql" # became executable SQL in the Where tab +COMMENT = "comment" # became a -- comment in the Where tab; nothing runs + +MIGRATED_STATUSES = (NATIVE, SQL) + + +@dataclass +class Criterion: + """One GSAK criterion and where it ended up.""" + label: str + status: str + detail: str = "" + + +@dataclass +class Options: + """Everything convert() needs beyond the filter itself.""" + center: Optional[tuple[float, float]] = None # (lat, lon) for distances + miles: bool = False # GSAK's distances are miles + me: Optional[str] = None # geocaching.com account name + + +class Conversion: + """The result of converting one GSAK filter, with its coverage.""" + + def __init__(self, name: str): + self.name = name + self.criteria: list[Criterion] = [] + self.filters: list[Any] = [] # native filter objects + self.sql_parts: list[tuple[str, str]] = [] # (label, sql) + # (label, reason, extra body lines) — what where_text() writes out + self.comment_lines: list[tuple[str, str, list[str]]] = [] + self.notes: list[str] = [] # assumptions worth keeping + + @property + def comments(self) -> list[tuple[str, str]]: + """The commented-out criteria as (label, reason) pairs.""" + return [(label, reason) for label, reason, _ in self.comment_lines] + + # ── Recording ──────────────────────────────────────────────────────────── + + def native(self, label: str, *filters: Any) -> None: + """A criterion the Set Filter dialog can show natively.""" + self.filters.extend(filters) + self.criteria.append(Criterion(label, NATIVE)) + + def sql(self, label: str, sql_text: str) -> None: + """A criterion OpenSAK stores but has no GUI filter for.""" + self.sql_parts.append((label, " ".join(sql_text.split()))) + self.criteria.append(Criterion(label, SQL)) + + def comment(self, label: str, reason: str, + lines: Optional[list[str]] = None) -> None: + """A criterion nothing in OpenSAK can express — documented only. + + *lines* are extra body lines (warnings, the SQL as far as it could be + translated) written under the reason, one ``--`` line each. + """ + reason = " ".join(reason.split()) + self.comment_lines.append((label, reason, list(lines or []))) + self.criteria.append(Criterion(label, COMMENT, reason)) + + def note(self, text: str) -> None: + self.notes.append(" ".join(text.split())) + + # ── Coverage ───────────────────────────────────────────────────────────── + + @property + def total(self) -> int: + return len(self.criteria) + + @property + def migrated(self) -> int: + return sum(1 for c in self.criteria if c.status in MIGRATED_STATUSES) + + @property + def native_count(self) -> int: + return sum(1 for c in self.criteria if c.status == NATIVE) + + @property + def not_migrated(self) -> int: + return sum(1 for c in self.criteria if c.status == COMMENT) + + @property + def coverage(self) -> float: + """Share of this filter's criteria that actually run, 0.0–1.0. + + A filter with no criteria at all (GSAK's "show everything") is fully + migrated by definition — there was nothing to lose. + """ + if not self.criteria: + return 1.0 + return self.migrated / self.total + + # ── Output ─────────────────────────────────────────────────────────────── + + def where_text(self) -> str: + """The Where tab's content: the comments first, then the SQL. + + Order matters. OpenSAK runs the clause as + ``SELECT id FROM caches WHERE ()`` and WhereClauseFilter strips + the text, so a trailing ``--`` comment would swallow the closing + bracket. Every comment therefore goes above the SQL it belongs to, and + the last line is always executable. + """ + lines: list[str] = [] + if self.comment_lines: + # The name comes from GSAK's database, so it is folded onto one + # line before it goes into a -- comment. + lines.append( + f'-- NOT MIGRATED from the GSAK filter "{" ".join(self.name.split())}" ' + f'({len(self.comment_lines)} condition(s)).' + ) + lines.append("-- Rebuild these by hand, then delete the comment.") + for label, reason, extra in self.comment_lines: + for i, chunk in enumerate(_wrap(f"{label}: {reason}", 96)): + lines.append(f"-- {chunk}" if i == 0 else f"-- {chunk}") + for extra_line in extra: + for chunk in _wrap(extra_line, 96): + lines.append(f"-- {chunk}") + if self.sql_parts: + for i, (label, sql_text) in enumerate(self.sql_parts): + for chunk in _wrap(label, 96): + lines.append(f"-- {chunk}") + lines.append(f"{'AND ' if i else ''}({sql_text})") + elif self.comment_lines: + # Comments only — the clause still has to be valid SQL, so it ends + # on a condition that changes nothing. + lines.append("-- (nothing above runs — this tab only documents what was lost)") + lines.append("1 = 1") + return "\n".join(lines) + + def build_filterset(self) -> FilterSet: + fs = FilterSet(mode="AND") + for f in self.filters: + fs.add(f) + where = self.where_text() + if where: + fs.add(WhereClauseFilter(where)) + return fs + + def gsak_block(self, source_db: Path) -> dict[str, Any]: + """Provenance for the profile's ``_gsak`` key. OpenSAK ignores unknown + top-level keys on load, so this travels with the file harmlessly.""" + block: dict[str, Any] = { + "note": ("Imported from GSAK. OpenSAK ignores this block; it records where each " + "GSAK criterion ended up."), + "source_database": str(source_db), + "source_filter": self.name, + "imported_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "coverage_percent": round(self.coverage * 100, 1), + "criteria": [ + {"label": c.label, "status": c.status} + for c in self.criteria + ], + } + if self.notes: + block["notes"] = _dedupe(self.notes) + return block + + +# ── Small shared helpers ───────────────────────────────────────────────────── + +def _tri_flag(gf: GsakFilter, yes_key: str, no_key: str) -> Optional[bool]: + """GSAK yes/no checkbox pairs: both ticked = no restriction (None), + only yes = True, only no = False.""" + yes, no = gf.flag(yes_key), gf.flag(no_key) + if yes is None or no is None: + return None + if yes and not no: + return True + if no and not yes: + return False + return None + + +def _sql_text_test(column: str, op: str, value: str) -> Optional[str]: + """A WHERE fragment for one text comparison, or None when SQLite cannot + run it (the regex operators — OpenSAK registers no REGEXP function).""" + col = f"lower(coalesce({column}, ''))" + needle = _escape_sql(value.lower()) + return { + "contains": f"{col} like '%{needle}%'", + "not_contains": f"{col} not like '%{needle}%'", + "equals": f"{col} = '{needle}'", + "not_equals": f"{col} <> '{needle}'", + "starts_with": f"{col} like '{needle}%'", + "ends_with": f"{col} like '%{needle}'", + "empty": f"{col} = ''", + "not_empty": f"{col} <> ''", + "in_list": f"{col} in (" + + ", ".join(f"'{_escape_sql(v.strip().lower())}'" + for v in value.split(";") if v.strip()) + + ")", + }.get(op) + + +def _wildcard_op(pattern: str) -> tuple[str, str]: + """GSAK's wildcard finder box (``*`` / ``?``) → an OpenSAK text operator. + + The common shapes become the plain operators; anything else becomes a + regular expression, which OpenSAK's text filters evaluate in Python. + """ + core = pattern.strip() + if "?" not in core and core.count("*") == 2 and core.startswith("*") and core.endswith("*"): + return core[1:-1], "contains" + if "?" not in core and core.count("*") == 1: + if core.endswith("*"): + return core[:-1], "starts_with" + if core.startswith("*"): + return core[1:], "ends_with" + if "*" not in core and "?" not in core: + return core, "equals" + escaped = re.escape(core).replace(r"\*", ".*").replace(r"\?", ".") + return f"^{escaped}$", "regex" + + +@dataclass +class DateSpec: + """A decoded GSAK date criterion, in OpenSAK's own vocabulary.""" + op: str + date1: Optional[datetime] = None + date2: Optional[datetime] = None + amount: int = 1 + unit: str = "days" + + def ordered(self) -> "DateSpec": + """"Between" with the dates the wrong way round still means a range.""" + if self.op == "between" and self.date1 and self.date2 and self.date2 < self.date1: + return DateSpec("between", self.date2, self.date1, self.amount, self.unit) + return self + + +def _read_during(gf: GsakFilter, edt_key: str, cbx_key: str) -> Optional[tuple[int, str]]: + """GSAK's "Während den letzten [n] [unit]" row → (amount, DATE_UNITS name). + + ASSUMED unit list — see DURING_UNITS. None when the amount box is empty, + which leaves nothing to reconstruct the window from. + """ + raw = gf.text(edt_key) + m = re.match(r"^\s*(\d+)", raw or "") + if not m: + return None + index = gf.num(cbx_key) or 0 + unit = DURING_UNITS[index] if 0 <= index < len(DURING_UNITS) else "days" + return int(m.group(1)), unit + + +def _read_date(gf: GsakFilter, c: Conversion, label: str, op_key: str, + from_key: str, to_key: str, during_edt: str, during_cbx: str, + ops: dict[int, Optional[str]]) -> Optional[DateSpec]: + """Decode one GSAK date criterion, or record why it could not be.""" + raw_op = gf.num(op_key) + if raw_op is None: + return None + if raw_op not in ops: + start, end = delphi_date(gf.text(from_key)), delphi_date(gf.text(to_key)) + c.comment(label, ( + f"GSAK date operator index {raw_op} is not decoded for this field " + f"(the dates beside it were " + f"{start.date().isoformat() if start else 'empty'} … " + f"{end.date().isoformat() if end else 'empty'})" + )) + return None + op = ops[raw_op] + if op is None: + return None # "Beliebig" — criterion off + if op == "compare": + c.comment(label, ( + "GSAK compared this date against another date column " + "(\"Verglichen mit\") and the saved filter does not record which one, " + "so there is nothing to translate. OpenSAK's date row has the same " + "\"Compared with\" operator — pick the other field there by hand" + )) + return None + if op in ("during", "not_during"): + during = _read_during(gf, during_edt, during_cbx) + if during is None: + c.comment(label, ( + f"GSAK used a rolling window (\"{op}\") but stores no window size, " + f"so the number of days/weeks/months could not be recovered" + )) + return None + amount, unit = during + c.note(f"{label}: the rolling window's unit combo was read as " + f"{' / '.join(DURING_UNITS)} (assumed order)") + return DateSpec(op, amount=amount, unit=unit) + + start, end = delphi_date(gf.text(from_key)), delphi_date(gf.text(to_key)) + if op == "between": + if start is None or end is None: + return None # operator set but no usable dates + return DateSpec("between", start, end).ordered() + if start is None: + return None + return DateSpec(op, start) + + +def _date_sql(column: str, spec: DateSpec) -> str: + """WHERE fragment for a date criterion on a column with no GUI filter. + + Compared with date() on both sides so a stored timestamp's time-of-day + never excludes a day the GSAK filter would have included. + """ + col = f"date({column})" + + def _d(value: Optional[datetime]) -> str: + return f"'{value.date().isoformat()}'" if value else "null" + + if spec.op in ("during", "not_during"): + unit = spec.unit.rstrip("s") + window = f"{col} >= date('now', 'localtime', '-{spec.amount} {unit}')" + return window if spec.op == "during" else f"({column} IS NULL OR NOT ({window}))" + if spec.op == "on_or_before": + return f"{col} <= {_d(spec.date1)}" + if spec.op == "on_or_after": + return f"{col} >= {_d(spec.date1)}" + if spec.op == "equal": + return f"{col} = {_d(spec.date1)}" + return f"{col} between {_d(spec.date1)} and {_d(spec.date2)}" + + +def _num_bounds(gf: GsakFilter, c: Conversion, label: str, op_key: str, + v1_key: str, v2_key: Optional[str] + ) -> Optional[tuple[str, int, int]]: + """Decode a GSAK numeric criterion into (op, value1, value2). + + *op* is one of NUM_OP's names; None means the criterion is off or could + not be read (in which case the reason has been recorded). + """ + raw_op = gf.num(op_key) or 0 + op = NUM_OP.get(raw_op) + if op is None: + if raw_op != 0: + c.comment(label, f"unknown GSAK comparison index {raw_op}") + return None + v1 = gf.num(v1_key) + if v1 is None: + return None + v2 = gf.num(v2_key) if v2_key else None + if op == "between": + if v2 is None: + c.comment(label, "GSAK's \"Zwischen\" without a second value") + return None + return "between", min(v1, v2), max(v1, v2) + return op, v1, v1 + + +def _num_sql(column: str, op: str, v1: int, v2: int) -> str: + if op == "at_most": + return f"{column} <= {v1}" + if op == "at_least": + return f"{column} >= {v1}" + if op == "equals": + return f"{column} = {v1}" + return f"{column} between {v1} and {v2}" + + +# ── Conversion — one function per GSAK tab ─────────────────────────────────── + +def convert(gf: GsakFilter, opts: Optional[Options] = None) -> Conversion: + """Translate one parsed GSAK filter into OpenSAK filters + coverage.""" + opts = opts or Options() + c = Conversion(gf.name) + + _convert_cache_types(gf, c) + _convert_containers(gf, c) + _convert_dt(gf, c) + _convert_found(gf, c) + _convert_availability(gf, c) + _convert_text_fields(gf, c) + _convert_favorites(gf, c) + _convert_distance(gf, c, opts) + _convert_flags(gf, c) + _convert_text_search(gf, c) + _convert_dates(gf, c) + _convert_attributes(gf, c) + _convert_area(gf, c, opts) + _convert_custom(gf, c) + _convert_log_tab(gf, c) + _convert_waypoint_tab(gf, c) + _convert_misc(gf, c) + _convert_where(gf, c, opts) + return c + + +def _convert_cache_types(gf: GsakFilter, c: Conversion) -> None: + present = [k for k in GSAK_TYPE_LABEL_TO_OSAK if k in gf.kv] + if not present: + return + selected = [k for k in present if gf.flag(k)] + if len(selected) == len(present): + return # everything ticked = no restriction + osak: list[str] = [] + dropped: list[str] = [] + for label in selected: + mapped = GSAK_TYPE_LABEL_TO_OSAK[label] + if mapped is None: + dropped.append(label) + elif mapped not in osak: + osak.append(mapped) + if osak: + c.native("Cache types", CacheTypeFilter(osak)) + if dropped: + c.comment("Cache types " + ", ".join(f"\"{d}\"" for d in dropped), ( + "GSAK has these cache types but OpenSAK does not — the GSAK importer files such " + "caches under \"Unknown Cache\", where nothing tells them apart from real mystery " + "caches" + )) + elif not osak and selected: + c.comment("Cache types", "none of the selected GSAK types exist in OpenSAK") + + +def _convert_containers(gf: GsakFilter, c: Conversion) -> None: + present = [k for k in GSAK_CONTAINER_KEYS if k in gf.kv] + if not present: + return + selected = [k for k in present if gf.flag(k)] + if len(selected) == len(present): + return + sizes: list[str] = [] + for key in selected: + size = GSAK_CONTAINER_KEYS[key] + if size not in sizes: + sizes.append(size) + if key in _COLLAPSED_CONTAINERS: + c.note(f"Container: GSAK's \"{key}\" has no OpenSAK equivalent and was mapped to " + f"\"Not chosen\", the same way the GSAK cache importer maps it") + if sizes: + c.native("Container sizes", ContainerFilter(sizes)) + + +def _convert_dt(gf: GsakFilter, c: Conversion) -> None: + for label, op_key, v1_key, v2_key, cls, kwargs_names in ( + ("Difficulty", "cbxDifficulty", "cbxDif", "cbxDif2", + DifficultyFilter, ("min_difficulty", "max_difficulty")), + ("Terrain", "cbxTerrain", "cbxTer", "cbxTer2", + TerrainFilter, ("min_terrain", "max_terrain")), + ): + raw_op = gf.num(op_key) + i1 = gf.num(v1_key) + if raw_op is None or i1 is None: + continue + op = DT_OP.get(raw_op) + if op is None: + c.comment(label, f"unknown GSAK comparison index {raw_op}") + continue + v1 = _dt_value(i1) + i2 = gf.num(v2_key) + v2 = _dt_value(i2) if i2 is not None else v1 + if op == "at_most": + lo, hi = 1.0, v1 + elif op == "at_least": + lo, hi = v1, 5.0 + elif op == "equals": + lo, hi = v1, v1 + else: # between (inclusive) + lo, hi = min(v1, v2), max(v1, v2) + if (lo, hi) == (1.0, 5.0): + continue # the dialog's reset state ("Kleiner gleich 5.0") = no restriction + c.native(label, cls(**dict(zip(kwargs_names, (lo, hi))))) + c.note(f"{label}: GSAK's operator index {raw_op} was read as \"{op}\" → range " + f"{lo}-{hi} (assumed order: 0 = at most, 1 = at least, 2 = equal, 3 = between)") + + +def _convert_found(gf: GsakFilter, c: Conversion) -> None: + found, notfound = gf.flag("chkFound"), gf.flag("chkNotFound") + if found is None or notfound is None: + return + if found and not notfound: + c.native("Found by me", FoundFilter()) + elif notfound and not found: + c.native("Not found by me", NotFoundFilter()) + elif not found and not notfound: + c.sql("Found by me", "1 = 0") + c.note("Found: GSAK had BOTH \"found\" and \"not found\" unticked, which matches " + "nothing at all. That became the constant 1 = 0 — delete it if the GSAK " + "filter was simply misconfigured") + + +def _convert_availability(gf: GsakFilter, c: Conversion) -> None: + avail = gf.flag("chkAvailable") + if avail is None: + return + c.native("Availability", AvailabilityFilter( + show_avail=bool(avail), + show_unavail=bool(gf.flag("chkTempUnavailable", False)), + show_archived=bool(gf.flag("chkArchivedOnly", False)), + )) + + +def _text_criterion(gf: GsakFilter, c: Conversion, op_key: str, val_key: str, + label: str, cls: Optional[type] = None, + column: Optional[str] = None) -> None: + """One GSAK text criterion → a native text filter, or SQL on *column*. + + *cls* is the native TextMatchFilter subclass when OpenSAK's GUI has a row + for the field (which supports every GSAK operator, regex included); + otherwise *column* names the OpenSAK column a where_clause can test. + """ + raw_op = gf.num(op_key) or 0 + op = TEXT_OP.get(raw_op) + value = gf.text(val_key) + if op is None: + if value: + c.comment(label, f"unknown GSAK comparison index {raw_op} for the value " + f"\"{value}\"") + return + if not value and op not in TEXT_OP_VALUELESS: + return # empty edit box = criterion not set + + if cls is not None: + c.native(label, cls(value, op)) + return + + assert column is not None + if op in TEXT_OP_NO_SQL: + c.comment(label, ( + f"GSAK matched \"{value}\" with a regular expression. OpenSAK has no GUI filter " + f"for this field, and its SQLite connection registers no REGEXP operator, so " + f"neither route can run it" + )) + return + sql = _sql_text_test(column, op, value) + if sql is None: + c.comment(label, f"GSAK comparison \"{op}\" could not be expressed in SQL") + return + c.sql(label, sql) + + +def _convert_text_fields(gf: GsakFilter, c: Conversion) -> None: + # Fields the Set Filter dialog has a row for — every GSAK operator, the + # two regex ones included, is available there. + for op_key, val_key, label, cls in ( + ("cbxDesc", "edtDesc", "Geocache name", NameFilter), + ("cbxCode", "edtCode", "GC code", GcCodeFilter), + ("cbxOwnerName", "edtOwnerName", "Owner", OwnerFilter), + ("cbxPlacedBy", "edtPlacedBy", "Placed by", PlacedByFilter), + ("cbxCountry", "edtCountry", "Country", CountryFilter), + ("cbxState", "edtState", "State", StateFilter), + ("cbxCounty", "edtCounty", "County", CountyFilter), + ): + _text_criterion(gf, c, op_key, val_key, label, cls=cls) + + # GSAK's four free-text user fields map 1:1 onto OpenSAK's user_data_1-4 + # columns, but OpenSAK has no filter row for them — hence SQL. + for n, (op_key, val_key) in enumerate( + (("cbxUserData", "edtUserData"), ("cbxUser2", "EdtUser2"), + ("cbxUser3", "edtUser3"), ("cbxUser4", "edtUser4")), start=1 + ): + _text_criterion(gf, c, op_key, val_key, f"User data {n}", + column=f"user_data_{n}") + + +def _convert_favorites(gf: GsakFilter, c: Conversion) -> None: + bounds = _num_bounds(gf, c, "Favourite points", "cbxFavorite", + "edtFavorite", "edtFavorite2") + if bounds is None: + return + op, v1, v2 = bounds + if op == "at_most": + lo, hi = 0, v1 + elif op == "at_least": + lo, hi = v1, 9999 + elif op == "equals": + lo, hi = v1, v1 + else: + lo, hi = v1, v2 + c.native("Favourite points", FavoritePointsFilter(min_pts=lo, max_pts=hi)) + + +def _convert_distance(gf: GsakFilter, c: Conversion, opts: Options) -> None: + bounds_op = NUM_OP.get(gf.num("cbxDistance") or 0) + if bounds_op is None: + return + v1, v2 = gf.text("edtDistance"), gf.text("edtDistance2") + if not v1: + return + unit = "miles" if opts.miles else "km" + factor = 1.609344 if opts.miles else 1.0 + try: + d1 = float(v1.replace(",", ".")) * factor + d2 = float(v2.replace(",", ".")) * factor if v2 else None + except ValueError: + c.comment("Distance", f"could not read the radius \"{v1}\"/\"{v2}\"") + return + + if bounds_op == "at_most": + lo_km, hi_km = 0.0, d1 + elif bounds_op == "at_least": + lo_km, hi_km = d1, None + elif bounds_op == "equals": + lo_km, hi_km = d1, d1 + else: + if d2 is None: + c.comment("Distance", "GSAK's \"Zwischen\" without a second value") + return + lo_km, hi_km = min(d1, d2), max(d1, d2) + + if hi_km is None: + # Open-ended ("at least X") — the GUI's distance row always has an + # upper bound, so this goes to SQL on the `distance` pseudo-column, + # which OpenSAK rewrites to a haversine call against the ACTIVE centre + # point (closer to GSAK, which never froze a centre either). + c.sql("Distance", f"distance >= {round(lo_km / factor, 3)}") + c.note(f"Distance: \"at least {v1} {unit}\" has no upper bound, so it became SQL on " + f"the `distance` column, which OpenSAK measures from the active centre point " + f"in your own unit — rescale the number if your unit is not {unit}") + return + + if opts.center is None: + c.sql("Distance", f"distance between {round(lo_km / factor, 3)} " + f"and {round(hi_km / factor, 3)}") + c.note(f"Distance: no centre point was available, so the radius became SQL on the " + f"`distance` column, which OpenSAK measures from the active centre point in " + f"your own unit — rescale the numbers if your unit is not {unit}") + return + + lat, lon = opts.center + c.native("Distance", DistanceFilter( + lat=round(lat, 6), lon=round(lon, 6), + max_km=round(hi_km, 3), min_km=round(lo_km, 3), + center_state={"kind": "home"}, + )) + c.note(f"Distance: GSAK stores only the radius ({v1} {unit}) and measures it from " + f"whatever centre point is active at the time. OpenSAK's distance filter freezes " + f"a coordinate instead, so your home point ({lat}, {lon}) was taken as the centre") + + +def _convert_flags(gf: GsakFilter, c: Conversion) -> None: + corrected = _tri_flag(gf, "chkCorrectYes", "chkCorrectNo") + if corrected is True: + c.native("Corrected coordinates", HasCorrectedFilter()) + elif corrected is False: + c.native("Corrected coordinates", NoCorrectedFilter()) + + dnf = _tri_flag(gf, "chkDNFYes", "chkDNFNo") + if dnf is not None: + c.native("DNF", DnfFilter(has_dnf=dnf)) + + ftf = _tri_flag(gf, "chkFtfyes", "chkFtfNo") + if ftf is not None: + c.native("FTF", FtfFilter(has_ftf=ftf)) + + premium = _tri_flag(gf, "chkPoYes", "chkPoNo") + if premium is True: + c.native("Premium only", PremiumFilter()) + elif premium is False: + c.native("Premium only", NonPremiumFilter()) + + locked = _tri_flag(gf, "chkLockYes", "chkLockNo") + if locked is not None: + c.native("Locked", LockedFilter(locked=locked)) + + flagged = _tri_flag(gf, "chkUserFlag1", "chkUserFlag2") + if flagged is not None: + c.native("User flag", UserFlagFilter(flagged=flagged)) + + trackables = _tri_flag(gf, "cbxBugs", "chkBugNo") + if trackables is True: + c.native("Has trackables", HasTrackableFilter()) + elif trackables is False: + c.sql("Has trackables", "coalesce(trackable_count, 0) = 0") + c.note("Trackables: GSAK filtered to caches WITHOUT trackables; OpenSAK's GUI only " + "offers \"has trackables\", so the negative became SQL on trackable_count") + + note = _tri_flag(gf, "chkNoteYes", "chkNoteNo") + if note is not None: + op = "<>" if note else "=" + c.sql("User note", f"coalesce((SELECT note FROM user_notes " + f"WHERE user_notes.cache_id = caches.id), '') {op} ''") + + # OpenSAK does store the watch list (caches.watch, filled from GSAK's own + # Watch column by gsak_importer.py) but has no GUI filter for it. + watch = _tri_flag(gf, "chkWatchYes", "chkWatchNo") + if watch is not None: + c.sql("Watch list", f"coalesce(watch, 0) = {1 if watch else 0}") + + +def _convert_text_search(gf: GsakFilter, c: Conversion) -> None: + text = gf.text("edtFull") + if not text: + return + # "Wo suchen": Überall (RbtFullAll) searches everything, in which case the + # individual Logs / Notizen / Beschreibung boxes are ignored by GSAK — they + # are only read for "Nur ausgewählte" (rbtFullSelect). + search_all = bool(gf.flag("RbtFullAll", False)) + c.native("Text search", TextSearchFilter( + text=text, + search_description=search_all or bool(gf.flag("chkFullDes", False)), + search_logs=search_all or bool(gf.flag("chkFullLogs", False)), + search_notes=search_all or bool(gf.flag("chkFullNotes", False)), + search_hint=False, + )) + if search_all: + c.note("Text search: GSAK searched \"everywhere\", which became description + logs + " + "notes (OpenSAK's hint search stays off)") + if gf.flag("chkRegEx", False): + c.comment("Text search (regular expression)", ( + f"GSAK searched for \"{text}\" as a regular expression. OpenSAK's full-text " + f"search is a plain substring match, so it was migrated as one — widen or " + f"narrow it by hand if the expression mattered" + )) + if gf.flag("chkFullHighlight", False): + c.note("Text search: GSAK's \"highlight matches\" option has no OpenSAK equivalent") + + +# GSAK date criterion → (op key, from key, to key, during keys, label, the +# OpenSAK DateFilter field or None, the SQL column to fall back on, operator +# table, extra note). +_DATE_FIELDS: tuple[tuple[str, str, str, str, str, str, Optional[str], str, + dict[int, Optional[str]], str], ...] = ( + ("cbxUserFound", "edtDateMeF", "edtDateMeT", "edtFbmDuring", "cbxFbmDuring", + "My found date", "found_date", "found_date", DATE_OP_MY_FOUND, ""), + ("cbxDNFDate", "edtDNFDateF", "edtDNFDateT", "edtDNFDateDuring", "cbxDNFDateDuring", + "DNF date", "dnf_date", "dnf_date", DATE_OP, ""), + ("cbxLastLog", "edtLastLogF", "edtLastLogT", "edtLastLogDuring", "cbxLastLogDuring", + "Last log date", "last_log_date", "last_log_date", DATE_OP, ""), + ("cbxFound", "edtDateF", "edtDateF2", "edtFoundDuring", "cbxFoundDuring", + "Last found date", "last_found_date", "last_found_date", DATE_OP, ""), + ("cbxPlaced", "edtDateP", "edtDateP2", "edtPlacedDuring", "cbxPlacedDuring", + "Hidden date", "hidden_date", "hidden_date", DATE_OP, ""), + ("cbxCreate", "edtCreateF", "edtCreateT", "edtCreatedDuring", "cbxCreatedDuring", + "Record created date", "creation_date", "imported_at", DATE_OP, + "Record created date: OpenSAK's creation date is when THIS database first saw the " + "cache, not when GSAK first saw it"), + ("cbxLastUpdate", "edtLastUpdateF", "edtLastUpdateT", "edtLastUpdateDuring", + "cbxLastUpdateDuring", "Last GPX update", "last_gpx_update", "last_gpx_update", DATE_OP, + "Last GPX update: OpenSAK stamps this locally on every import pass, where GSAK's " + "LastGPXDate came from the source file"), + ("cbxLastUser", "edtLastUserF", "edtLastUserT", "edtLastUserDuring", "cbxLastUserDuring", + "Last user update", None, + "(SELECT updated_at FROM user_notes WHERE user_notes.cache_id = caches.id)", DATE_OP, + "Last user update: a cache with no personal note and no corrected coordinates has no " + "user_notes row at all, so it can never match"), + ("cbxChange", "edtChangeF", "edtChangeT", "edtChangeDuring", "cbxChangeDuring", + "Last changed date", "changed_date", "last_updated", DATE_OP, ""), +) + + +def _convert_dates(gf: GsakFilter, c: Conversion) -> None: + for (op_key, from_key, to_key, during_edt, during_cbx, + label, osak_field, column, ops, extra) in _DATE_FIELDS: + spec = _read_date(gf, c, label, op_key, from_key, to_key, + during_edt, during_cbx, ops) + if spec is None: + continue + if extra: + c.note(extra) + if osak_field is not None: + c.native(label, DateFilter( + field=osak_field, op=spec.op, + date1=spec.date1, date2=spec.date2, + amount=spec.amount, unit=spec.unit, + )) + else: + c.sql(label, _date_sql(column, spec)) + + +def _convert_attributes(gf: GsakFilter, c: Conversion) -> None: + natives: list[AttributeFilter] = [] + absent: list[int] = [] + for key, value in gf.kv.items(): + m = re.fullmatch(r"chkAtt(\d+)_([012])", key) + if not m or value.strip().lower() != "true": + continue + attr_id, state = int(m.group(1)), m.group(2) + if state == "0": + natives.append(AttributeFilter(attr_id, is_on=True)) + elif state == "1": + natives.append(AttributeFilter(attr_id, is_on=False)) + else: + absent.append(attr_id) # GSAK's "Keines" — not present at all + if natives: + c.note("Attributes: GSAK's three checkbox columns are Ja / Nein / Keines → " + "\"is set\", \"is not set\", and \"not present at all\"") + if gf.flag("rbtAttAny", False) and len(natives) > 1: + group = FilterSet(mode="OR") + for f in natives: + group.add(f) + c.native("Attributes (any of)", group) + else: + c.native("Attributes", *natives) + for attr_id in absent: + c.sql(f"Attribute {attr_id} not present", + f"NOT EXISTS (SELECT 1 FROM attributes a WHERE a.cache_id = caches.id " + f"AND a.attribute_id = {attr_id})") + + +def _convert_area(gf: GsakFilter, c: Conversion, opts: Options) -> None: + """GSAK's polygon / arc / point area selection → the Line/Polygon filter.""" + arc = gf.text("ArcFilter") + if not arc: + return + if gf.flag("rbtPoly", False): + mode, kind = "polygon", "polygon" + elif gf.flag("rbtArc", False): + mode, kind = "line", "arc" + elif gf.flag("rbtPoint", False): + mode, kind = "points", "point" + else: + mode, kind = "polygon", "area" + + points: list[tuple[float, float]] = [] + for point in arc.split("~"): + point = point.strip() + if not point: + continue + try: + lat_s, lon_s = point.split(",") + points.append((float(lat_s), float(lon_s))) + except ValueError: + continue + if not points: + c.comment(f"Area selection ({kind})", + f"no usable coordinates in the GSAK definition ({arc})") + return + # GSAK repeats the first vertex to close a polygon; OpenSAK closes it itself. + if mode == "polygon" and len(points) > 1 and points[0] == points[-1]: + points.pop() + + try: + radius = float((gf.text("edtArcDistance") or "0").replace(",", ".")) + except ValueError: + radius = 0.0 + distance_km = radius * (1.609344 if opts.miles else 1.0) + + label = f"Area selection ({kind})" + try: + c.native(label, LinePolygonFilter( + points=points, mode=mode, distance_km=distance_km, + exclude=bool(gf.flag("chkArcExclude", False)), + )) + except ValueError as exc: + c.comment(label, f"{exc} — GSAK's definition was {arc}") + + +def _convert_custom(gf: GsakFilter, c: Conversion) -> None: + """User-defined GSAK columns (the Custom / CustomLocal tables). + + OpenSAK has no custom-column storage at all — the GSAK cache importer + deliberately skips the Custom table — so there is not even a column for + SQL to point at. The raw GSAK criterion is kept so it can be rebuilt on + one of user_data_1-4 once the data is moved there. + """ + for line in gf.custom: + parts = line.split(";") + name = parts[0] if parts else line + c.comment(f"User-defined GSAK column \"{name}\"", ( + f"OpenSAK stores no custom columns (only user_data_1-4), and the GSAK cache " + f"import does not carry the Custom table over, so there is no column to test. " + f"The GSAK criterion was: {line}" + )) + + +def _convert_log_tab(gf: GsakFilter, c: Conversion) -> None: + """GSAK's Logs tab → OpenSAK's native log filter, which was modelled on it.""" + if "chkLogFound" not in gf.kv and "cbxLogCount" not in gf.kv: + return + + described: list[str] = [] + + # Which log types qualify ("Lt=True", one key per ticked type). The + # names are the ones OpenSAK stores in logs.log_type — the GSAK cache + # importer copies Logs.lType through verbatim. + types = sorted(k[2:] for k, v in gf.kv.items() + if k.startswith("Lt") and v.strip().lower() == "true") + if types: + described.append("types " + ", ".join(types)) + + # Which logs are searched at all ("Zu durchsuchende Logs"). + categories: Optional[list[str]] = None + if "chkLogSearchFound" in gf.kv: + chosen = [ + name for key, name in (("chkLogSearchFound", "found"), + ("chkLogSearchNotFound", "not_found"), + ("chkLogSearchNote", "other")) + if gf.flag(key, False) + ] + if chosen and len(chosen) < 3: + categories = chosen + described.append("only " + "/".join(chosen) + " logs") + elif not chosen: + # All three unticked. Every real filter has at least one ticked, so + # what GSAK means by this is untested; searching every log (the + # OpenSAK default) is the reading that cannot lose caches. + c.note("Logs: GSAK had none of the three log groups (found / not found / notes) " + "ticked, which was read as \"search every log\"") + + # How many of the newest logs are searched. + last_n = 0 + scope_index = gf.num("cbxLogsToSearch") or 0 + if scope_index: + if scope_index < len(LOG_SCOPE_CHOICES): + last_n = LOG_SCOPE_CHOICES[scope_index] + described.append(f"in the last {last_n} log(s)") + c.note("Logs: GSAK's \"logs to search\" dropdown was read through OpenSAK's own " + "list of the same choices (0/all, 1, 2, …, 10, 15, 20, 30, 40, 50, 100)") + else: + c.comment("Logs: number of logs to search", + f"GSAK's dropdown index {scope_index} is outside the list of choices " + f"OpenSAK offers, so every log is searched instead") + + # Who wrote them. + finder_text, finder_op, finder_by_id = "", "contains", False + finder = gf.text("edtGeoName") + if finder: + if gf.flag("rbtId", False): + finder_text, finder_op, finder_by_id = finder, "equals", True + described.append(f"by GC user id {finder}") + elif gf.flag("rbtRegex", False): + finder_text, finder_op = finder, "regex" + described.append(f"by a finder matching /{finder}/") + elif gf.flag("rbtWild", False): + finder_text, finder_op = _wildcard_op(finder) + described.append(f"by a finder matching \"{finder}\"") + c.note("Logs: GSAK's */? wildcards on the finder box were translated into " + "OpenSAK's text operators (or a regular expression when the pattern " + "needed one)") + else: + finder_text, finder_op = finder, "equals" + described.append(f"by {finder}") + + # When they were written. + spec = _read_date(gf, c, "Log date", "cbxLogDate", "edtLogDateF", "edtLogDateT", + "edtLogDateDuring", "cbxLogDateDuring", DATE_OP_NO_COMPARE) + if spec is not None: + described.append(f"dated {spec.op.replace('_', ' ')}") + c.note("Logs: the log-date combo has no \"compared with\" entry, so its \"any\" sits " + "at index 6 rather than 7") + + # How many have to qualify. + count_op, count1, count2 = "any", 0, 0 + bounds = _num_bounds(gf, c, "Log count", "cbxLogCount", "edtLogFrom", "edtLogTo") + if bounds is not None: + op, v1, v2 = bounds + count_op, count1, count2 = NUM_OP_TO_COUNT_OP[op], v1, v2 + described.insert(0, f"count {count_op.replace('_', ' ')} " + f"{v1}{f'..{v2}' if count_op == 'between' else ''}") + + if gf.num("cbxLogInclude"): + c.comment("Logs: \"logs to include\" selector", + f"GSAK stores index {gf.num('cbxLogInclude')} here and its meaning could " + f"not be established, so it was left out of the log filter") + + log_filter = LogFilter( + date_op=spec.op if spec else None, + date1=spec.date1 if spec else None, + date2=spec.date2 if spec else None, + date_amount=spec.amount if spec else 1, + date_unit=spec.unit if spec else "days", + categories=categories, + last_n=last_n, + types=types, + finder_text=finder_text, + finder_op=finder_op, + finder_by_id=finder_by_id, + count_op=count_op, + count1=count1, + count2=count2, + ) + if log_filter.is_noop(): + return + c.native(f"Logs ({'; '.join(described)})" if described else "Logs", log_filter) + + +def _convert_waypoint_tab(gf: GsakFilter, c: Conversion) -> None: + """GSAK's Unterwegspunkte tab → OpenSAK's native waypoint filter. + + Both tabs work the same way: the criteria describe one child waypoint, and + the count operator says how many such waypoints the cache needs. The + general tab's "has child waypoints: yes/no" tick pair belongs to the same + filter, so it is folded in here. + """ + has_tab = "cbxcCount" in gf.kv or "cbxctype" in gf.kv + child = _tri_flag(gf, "chkChildYes", "chkChildNo") + if not has_tab and child is None: + return + + described: list[str] = [] + texts: dict[str, tuple[str, str]] = {} + + def _text(op_key: str, val_key: str, field: str, label: str) -> None: + raw_op = gf.num(op_key) or 0 + op = TEXT_OP.get(raw_op) + value = gf.text(val_key) + if op is None: + if value: + c.comment(f"Waypoint {label}", + f"unknown GSAK comparison index {raw_op} for the value \"{value}\"") + return + if not value and op not in TEXT_OP_VALUELESS: + return + texts[field] = (value, op) + described.append(f"{label} {op.replace('_', ' ')} \"{value}\"") + + _text("cbxcCode", "edtcCode", "code", "code") + _text("cbxctype", "edtctype", "wp_type", "type") + _text("cbxcDescription", "edtcDescription", "name", "name") + _text("cbxcComments", "edtcComments", "comment", "comment") + + # The tab's waypoint-type dropdown, which stores an index rather than a + # name (see WP_TYPE_BY_INDEX). The free-text type row above wins when both + # are set, since that one is unambiguous. + type_index = gf.num("cbxCtype2") + if type_index is not None and "wp_type" not in texts: + if 0 <= type_index < len(WP_TYPE_BY_INDEX): + wp_type = WP_TYPE_BY_INDEX[type_index] + texts["wp_type"] = (wp_type, "equals") + described.append(f"type {wp_type}") + c.note(f"Waypoints: GSAK stores its waypoint-type dropdown as a number " + f"({type_index}); it was read against the six GPX waypoint types in " + f"alphabetical order, which makes it \"{wp_type}\"") + else: + c.comment("Waypoint type", + f"GSAK's waypoint-type dropdown holds index {type_index}, which is " + f"outside the list of types OpenSAK knows") + + spec = _read_date(gf, c, "Waypoint date", "cbxcDate", "edtcDate1", "edtcDate2", + "edtcDateDuring", "cbxcDateDuring", DATE_OP_NO_COMPARE) + if spec is not None: + described.append(f"dated {spec.op.replace('_', ' ')}") + c.note("Waypoints: the waypoint-date combo has no \"compared with\" entry, so its " + "\"any\" sits at index 6 rather than 7") + + by_user = _tri_flag(gf, "chkcByUserYes", "chkcByUserNo") + if by_user is not None: + described.append("created by me" if by_user else "not created by me") + + count_op, count1, count2 = "any", 0, 0 + bounds = _num_bounds(gf, c, "Waypoint count", "cbxcCount", "edtcCount1", "edtcCount2") + if bounds is not None: + op, v1, v2 = bounds + count_op, count1, count2 = NUM_OP_TO_COUNT_OP[op], v1, v2 + described.insert(0, f"count {count_op.replace('_', ' ')} " + f"{v1}{f'..{v2}' if count_op == 'between' else ''}") + elif child is not None: + # "Has child waypoints: yes / no" from the general tab. + count_op, count1 = ("at_least", 1) if child else ("equal", 0) + described.insert(0, "at least one" if child else "none") + + if gf.flag("chkcSetFlag", False) or gf.flag("chkcClearFlag", False): + c.note("Waypoints: the tab's set/clear-flag boxes are a GSAK side effect, not a " + "filter criterion, so there was nothing to migrate") + + wp_filter = WaypointFilter( + texts=texts, + date_op=spec.op if spec else None, + date1=spec.date1 if spec else None, + date2=spec.date2 if spec else None, + date_amount=spec.amount if spec else 1, + date_unit=spec.unit if spec else "days", + by_user=by_user, + count_op=count_op, + count1=count1, + count2=count2, + ) + if wp_filter.is_noop(): + return + c.native(f"Waypoints ({'; '.join(described)})" if described else "Waypoints", wp_filter) + + +def _convert_misc(gf: GsakFilter, c: Conversion) -> None: + # Numeric criteria OpenSAK stores as a column but has no filter row for. + for label, op_key, v1_key, v2_key, column, extra in ( + ("Elevation", "cbxElevation", "edtElevation", "edtElevation2", "elevation", ""), + ("User sort", "cbxUsort", "edtUsort", "edtUsort2", "user_sort", ""), + # GSAK's FoundCount turned out to be a 0/1 "found by me" flag rather + # than a community find count (verified by the cache importer against a + # real 12,600-cache database), so found_log_count — how many of your + # own found-type logs the cache carries — is the honest counterpart. + ("My found count", "cbxFoundCount", "EdtFoundCount", None, "found_log_count", + "My found count: GSAK's FoundCount is really a 0/1 found-by-me flag, while " + "OpenSAK's found_log_count counts your own found-type logs, so it is >= 1 for " + "exactly the caches GSAK counted as 1"), + # OpenSAK persists a bearing per cache, recomputed whenever the centre + # point changes (db/database.py::recalculate_distances). + ("Bearing", "cbxDegrees", "edtDegrees", None, "bearing", + "Bearing: measured from the ACTIVE centre point, and only as fresh as the last " + "distance recalculation"), + ): + bounds = _num_bounds(gf, c, label, op_key, v1_key, v2_key) + if bounds is None: + continue + c.sql(label, _num_sql(column, *bounds)) + if extra: + c.note(extra) + + _text_criterion(gf, c, "cbxSource", "edtSource", "Source", column="source_file") + _trackable_name_criterion(gf, c) + + if gf.text("edtSymbol"): + c.comment("Symbol name", ( + f"GSAK matched the GPX symbol name (\"{gf.text('edtSymbol')}\"), which OpenSAK " + f"does not store" + )) + + _convert_quadrants(gf, c) + + if gf.flag("chkReverse", False): + c.comment("Reverse filter", ( + "GSAK's \"reverse filter\" inverts the whole result. OpenSAK cannot negate a " + "filter set, so everything above matches the NON-inverted criteria" + )) + + +def _convert_quadrants(gf: GsakFilter, c: Conversion) -> None: + """Compass-quadrant tick boxes → a bearing range in SQL.""" + present = [k for k in _QUADRANTS if k in gf.kv] + if not present: + return + selected = [k for k in present if gf.flag(k)] + if len(selected) == len(present) or not selected: + return # all ticked (or none recorded) = no restriction + tests: list[str] = [] + for key in selected: + lo, hi = _QUADRANTS[key] + tests.append(f"(bearing >= {lo} OR bearing < {hi})" if lo > hi + else f"(bearing >= {lo} AND bearing < {hi})") + c.sql("Compass quadrants (" + ", ".join(k[3:] for k in selected) + ")", + "bearing IS NOT NULL AND (" + " OR ".join(tests) + ")") + c.note("Compass quadrants: assumed to be the eight 45° sectors centred on their compass " + "point (N = 337.5°-22.5°), measured from the ACTIVE centre point") + + +def _trackable_name_criterion(gf: GsakFilter, c: Conversion) -> None: + """GSAK's TB/coin name box → an EXISTS on OpenSAK's trackables table.""" + raw_op = gf.num("cbxTbugName") or 0 + op = TEXT_OP.get(raw_op) + value = gf.text("edtTbugName") + if op is None: + if value: + c.comment("TB/coin name", + f"unknown GSAK comparison index {raw_op} for the value \"{value}\"") + return + if not value and op not in TEXT_OP_VALUELESS: + return + if op in TEXT_OP_NO_SQL: + c.comment("TB/coin name", ( + f"GSAK matched \"{value}\" with a regular expression; OpenSAK keeps trackables in " + f"their own table and its SQLite connection registers no REGEXP operator" + )) + return + test = _sql_text_test("t.name", op, value) + if test is None: + c.comment("TB/coin name", f"GSAK comparison \"{op}\" could not be expressed in SQL") + return + c.sql("TB/coin name", + f"EXISTS (SELECT 1 FROM trackables t WHERE t.cache_id = caches.id AND {test})") + c.note("TB/coin name: GSAK matched the cache's travel-bug list as one text field; " + "OpenSAK keeps trackables in their own table, so the test looks for any " + "trackable whose name matches") + + +def _convert_where(gf: GsakFilter, c: Conversion, opts: Options) -> None: + """GSAK's own where clause. + + A clause that translates cleanly becomes executable SQL. One that does not + becomes a comment holding the translation so far plus every warning — + never live SQL, because OpenSAK treats a where_clause whose SQL fails as + "matches nothing", which would silently empty the whole filter. + """ + if not gf.where: + return + custom_columns = [line.split(";")[0] for line in gf.custom if line.split(";")[0]] + sql, warnings, notes, verified = translate_where(gf.where, custom_columns, opts.me) + for note in notes: + c.note(note) + if verified: + c.sql("GSAK where clause", sql) + return + body = ["Why:"] + body += [f"- {w[len('where: '):] if w.startswith('where: ') else w}" for w in warnings] + body.append("Translated as far as it goes — fix it up and uncomment it:") + body += [f"| {line}" for line in _wrap(sql, 88)] + c.comment("GSAK where clause", + "the SQL could not be translated completely, so it was left inactive " + "(OpenSAK treats a where clause that fails to run as matching nothing, " + "which would silently empty this filter)", + lines=body) + + +# ── Reading gsak.db3 ───────────────────────────────────────────────────────── + +class GsakFilterSourceError(Exception): + """The chosen file is not a GSAK settings database with saved filters.""" + + +def find_gsak_filter_db(path: Path) -> Path: + """Locate the ``gsak.db3`` holding the saved filters. + + Saved filters live in GSAK's settings database (``gsak.db3``), not in a + cache database (``sqlite.db3``): a .zip is searched for the former, a + plain file is taken as given. Raises GsakFilterSourceError when a zip + holds no gsak.db3. + """ + path = Path(path) + if path.suffix.lower() != ".zip": + return path + + import tempfile + import zipfile + + extract_dir = Path(tempfile.mkdtemp(prefix="gsak_filters_")) + with zipfile.ZipFile(path) as zf: + zf.extractall(extract_dir) + matches = list(extract_dir.rglob("gsak.db3")) + if not matches: + raise GsakFilterSourceError(f"No gsak.db3 file found inside {path.name}") + return matches[0] + + +def load_gsak_filters(db_path: Path) -> list[tuple[str, str]]: + """Return [(name, data)] for every saved filter in gsak.db3 (read-only).""" + uri = f"file:{Path(db_path).as_posix()}?mode=ro" + try: + with sqlite3.connect(uri, uri=True) as conn: + conn.text_factory = lambda b: b.decode("utf-8", errors="replace") + rows = conn.execute( + "SELECT Description, Data FROM TranslateFilters WHERE Type = 'FI'" + ).fetchall() + except sqlite3.Error as exc: + raise GsakFilterSourceError(str(exc)) from exc + return sorted( + ((str(name), str(data or "")) for name, data in rows if name), + key=lambda row: row[0].lower(), + ) + + +def read_gsak_center(gsak_ini: Path) -> tuple[Optional[tuple[float, float]], bool]: + """Return ((lat, lon) or None, use_miles) read from gsak.ini. + + The centre point lives in the [LastCenter] section; rbtMiles.Checked says + whether GSAK's distances are miles rather than kilometres. + """ + gsak_ini = Path(gsak_ini) + if not gsak_ini.is_file(): + return None, False + lat = lon = None + miles = False + section = "" + for line in gsak_ini.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].lower() + continue + key, eq, value = line.partition("=") + if not eq: + continue + key, value = key.strip().lower(), value.strip() + if key == "rbtmiles.checked": + miles = value.lower() == "true" + elif section == "lastcenter": + try: + if key == "lat": + lat = float(value) + elif key == "lon": + lon = float(value) + except ValueError: + pass + return ((lat, lon) if lat is not None and lon is not None else None), miles + + +def default_options(db_path: Optional[Path] = None) -> Options: + """Build conversion options from OpenSAK's settings plus GSAK's own ini. + + The centre point for distance filters is OpenSAK's home point (the one its + distance filter measures from); GSAK's unit and the account name behind + ``isOwner`` come from gsak.ini and OpenSAK's settings respectively. + """ + center: Optional[tuple[float, float]] = None + me: Optional[str] = None + try: + from opensak.gui.settings import get_settings + settings = get_settings() + center = (settings.home_lat, settings.home_lon) + me = (settings.gc_username or "").strip() or None + except Exception: + pass + miles = False + if db_path is not None: + _, miles = read_gsak_center(Path(db_path).with_name("gsak.ini")) + return Options(center=center, miles=miles, me=me) + + +# ── Writing profiles ───────────────────────────────────────────────────────── + +def safe_filename(name: str) -> str: + """Mirror FilterProfile.save()'s sanitisation so a later save from inside + OpenSAK lands on the same file.""" + return "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in name) + + +def build_profile_data(c: Conversion, source_db: Path) -> dict[str, Any]: + """The JSON a converted filter is written as.""" + profile = FilterProfile(c.name, c.build_filterset()) + return { + "name": profile.name, + "filterset": profile.filterset.to_dict(), + "sort": profile.sort.to_dict(), + "_gsak": c.gsak_block(source_db), + } + + +@dataclass +class FilterImportEntry: + """What happened to one GSAK filter.""" + name: str + conversion: Optional[Conversion] = None + path: Optional[Path] = None + written: bool = False + skipped_existing: bool = False + error: Optional[str] = None + + @property + def coverage(self) -> float: + return self.conversion.coverage if self.conversion else 0.0 + + +@dataclass +class GsakFilterImportResult: + """Everything the dialog reports after an import.""" + source: Optional[Path] = None + entries: list[FilterImportEntry] = dc_field(default_factory=list) + + # ── Counts ─────────────────────────────────────────────────────────────── + + @property + def written(self) -> int: + return sum(1 for e in self.entries if e.written) + + @property + def skipped(self) -> int: + return sum(1 for e in self.entries if e.skipped_existing) + + @property + def failed(self) -> int: + return sum(1 for e in self.entries if e.error) + + @property + def _converted(self) -> list[Conversion]: + return [e.conversion for e in self.entries if e.written and e.conversion] + + # ── Migration coverage ─────────────────────────────────────────────────── + + @property + def total_criteria(self) -> int: + return sum(c.total for c in self._converted) + + @property + def migrated_criteria(self) -> int: + return sum(c.migrated for c in self._converted) + + @property + def native_criteria(self) -> int: + return sum(c.native_count for c in self._converted) + + @property + def sql_criteria(self) -> int: + return self.migrated_criteria - self.native_criteria + + @property + def commented_criteria(self) -> int: + return self.total_criteria - self.migrated_criteria + + @property + def coverage(self) -> float: + """Share of all imported criteria that actually run, 0.0-1.0. + + Counted over criteria rather than over filters, so one filter with + twenty conditions weighs more than one with two — which is what makes + "100% = everything migrated, 0% = everything sits in comments" true of + the whole import and not just of the average filter. + """ + if not self.total_criteria: + return 1.0 + return self.migrated_criteria / self.total_criteria + + @property + def fully_migrated(self) -> int: + return sum(1 for c in self._converted if c.coverage >= 1.0) + + @property + def partially_migrated(self) -> int: + return sum(1 for c in self._converted if 0.0 < c.coverage < 1.0) + + @property + def not_migrated(self) -> int: + return sum(1 for c in self._converted if c.coverage <= 0.0) + + +def import_gsak_filters( + db_path: Path, + out_dir: Optional[Path] = None, + names: Optional[list[str]] = None, + overwrite: bool = False, + opts: Optional[Options] = None, + progress_cb: Optional[Callable[[int, int], None]] = None, +) -> GsakFilterImportResult: + """Convert saved GSAK filters into OpenSAK filter profiles on disk. + + *names* limits the import to those GSAK filters (all of them when None), + *overwrite* decides what happens to a profile file that already exists, + and *out_dir* defaults to OpenSAK's own filter-profile folder. + """ + db_path = Path(db_path) + if out_dir is None: + from opensak.config import get_app_data_dir + out_dir = get_app_data_dir() / "filters" + out_dir = Path(out_dir) + opts = opts or default_options(db_path) + + rows = load_gsak_filters(db_path) + if names is not None: + wanted = {n for n in names} + rows = [r for r in rows if r[0] in wanted] + + out_dir.mkdir(parents=True, exist_ok=True) + result = GsakFilterImportResult(source=db_path) + used: set[str] = set() + total = len(rows) + + for index, (name, data) in enumerate(rows, start=1): + entry = FilterImportEntry(name=name) + result.entries.append(entry) + try: + conversion = convert(parse_filter_blob(name, data), opts) + entry.conversion = conversion + + stem = safe_filename(name) + if stem in used: + n = 2 + while f"{stem}_{n}" in used: + n += 1 + stem = f"{stem}_{n}" + used.add(stem) + path = out_dir / f"{stem}.json" + entry.path = path + + if path.exists() and not overwrite: + entry.skipped_existing = True + else: + path.write_text( + json.dumps(build_profile_data(conversion, db_path), + indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + entry.written = True + except Exception as exc: # one bad filter must not stop the rest + entry.error = f"{type(exc).__name__}: {exc}" + if progress_cb is not None: + progress_cb(index, total) + + return result diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 293ae777..63348a36 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -1401,4 +1401,38 @@ "file_locations_kind_appimage_binary": "Soubor programu (AppImage)", "file_locations_kind_appimage_desktop_file": "Položka v nabídce aplikací", "file_locations_kind_appimage_icon": "Ikona v nabídce aplikací", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importovat &filtry z GSAK…", + "gsak_filter_import_dialog_title": "Import filtrů z GSAK", + "gsak_filter_import_select_file_label": "Vyberte databázi nastavení GSAK (gsak.db3) nebo zálohu (.zip):", + "gsak_filter_import_browse_title": "Vybrat gsak.db3", + "gsak_filter_import_file_filter": "Databáze nastavení GSAK (gsak.db3 *.db3 *.zip);;Všechny soubory (*)", + "gsak_filter_import_found": "Nalezeno {count} uložených filtrů", + "gsak_filter_import_select_all": "Vybrat vše", + "gsak_filter_import_select_none": "Zrušit výběr", + "gsak_filter_import_search_placeholder": "Hledat filtry…", + "gsak_filter_import_overwrite": "Přepsat existující profily filtrů", + "gsak_filter_import_overwrite_tooltip": "Vypnuto: existující profil se stejným názvem zůstane a bude hlášen jako přeskočený.", + "gsak_filter_import_no_filters": "Ze souboru {name} se nepodařilo načíst uložené filtry GSAK.\n\n{error}\n\nUložené filtry jsou v databázi nastavení gsak.db3 — ne v databázi kešek (sqlite.db3).", + "gsak_filter_import_none_found": "{name} neobsahuje žádné uložené filtry.", + "gsak_filter_import_running": "Importuji {count} filtrů…", + "gsak_filter_import_complete": "Import dokončen.", + "gsak_filter_import_written": "Zapsané profily filtrů", + "gsak_filter_import_skipped": "Přeskočeno (již existují)", + "gsak_filter_import_failed": "Selhalo", + "gsak_filter_import_stats_header": "Pokrytí migrace", + "gsak_filter_import_conditions": "Podmínky GSAK", + "gsak_filter_import_as_filters": "… převedeno na filtry", + "gsak_filter_import_as_sql": "… převedeno na SQL ve Where", + "gsak_filter_import_as_comments": "… ponecháno jako SQL komentář", + "gsak_filter_import_coverage": "Pokrytí", + "gsak_filter_import_full": "plně převedeno (100 %)", + "gsak_filter_import_partial": "částečně převedeno", + "gsak_filter_import_nothing": "nepřevedeno nic (0 %)", + "gsak_filter_import_incomplete_header": "{count} filtrů má podmínky ponechané v komentáři:", + "gsak_filter_import_left_in_comments": "{count} v komentáři", + "gsak_filter_import_where_hint": " Otevřete takový filtr v Nastavit filtr → Where a dokončete je tam.", + "gsak_filter_import_skipped_header": "{count} filtrů přeskočeno — profil s tímto názvem už existuje:", + "gsak_filter_import_failed_header": "{count} filtrů se nepodařilo převést:", } diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index 8f46d787..655f64dd 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -1405,4 +1405,38 @@ "file_locations_kind_appimage_binary": "Programfil (AppImage)", "file_locations_kind_appimage_desktop_file": "Punkt i programmenuen", "file_locations_kind_appimage_icon": "Ikon i programmenuen", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importér GSAK-&filtre…", + "gsak_filter_import_dialog_title": "Importér GSAK-filtre", + "gsak_filter_import_select_file_label": "Vælg GSAK's indstillingsdatabase (gsak.db3) eller en backup (.zip):", + "gsak_filter_import_browse_title": "Vælg gsak.db3", + "gsak_filter_import_file_filter": "GSAK-indstillingsdatabase (gsak.db3 *.db3 *.zip);;Alle filer (*)", + "gsak_filter_import_found": "{count} gemte filtre fundet", + "gsak_filter_import_select_all": "Vælg alle", + "gsak_filter_import_select_none": "Vælg ingen", + "gsak_filter_import_search_placeholder": "Søg i filtre…", + "gsak_filter_import_overwrite": "Overskriv filterprofiler der allerede findes", + "gsak_filter_import_overwrite_tooltip": "Fra: en eksisterende profil med samme navn bevares og rapporteres som sprunget over.", + "gsak_filter_import_no_filters": "Der kunne ikke læses gemte GSAK-filtre fra {name}.\n\n{error}\n\nGemte filtre ligger i GSAK's indstillingsdatabase gsak.db3 — ikke i en cache-database (sqlite.db3).", + "gsak_filter_import_none_found": "{name} indeholder ingen gemte filtre.", + "gsak_filter_import_running": "Importerer {count} filtre…", + "gsak_filter_import_complete": "Import færdig.", + "gsak_filter_import_written": "Skrevne filterprofiler", + "gsak_filter_import_skipped": "Sprunget over (findes i forvejen)", + "gsak_filter_import_failed": "Mislykkedes", + "gsak_filter_import_stats_header": "Migreringsdækning", + "gsak_filter_import_conditions": "GSAK-betingelser", + "gsak_filter_import_as_filters": "… migreret til filtre", + "gsak_filter_import_as_sql": "… migreret til Where-SQL", + "gsak_filter_import_as_comments": "… efterladt som SQL-kommentarer", + "gsak_filter_import_coverage": "Dækning", + "gsak_filter_import_full": "fuldt migreret (100 %)", + "gsak_filter_import_partial": "delvist migreret", + "gsak_filter_import_nothing": "intet migreret (0 %)", + "gsak_filter_import_incomplete_header": "{count} filtre har betingelser tilbage i kommentarer:", + "gsak_filter_import_left_in_comments": "{count} i kommentarer", + "gsak_filter_import_where_hint": " Åbn et sådant filter under Sæt filter → Where for at se og færdiggøre dem.", + "gsak_filter_import_skipped_header": "{count} filtre sprunget over — en profil med det navn findes allerede:", + "gsak_filter_import_failed_header": "{count} filtre kunne ikke konverteres:", } diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 275fca6c..7029d03e 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -1406,4 +1406,38 @@ "file_locations_kind_appimage_binary": "Programmdatei (AppImage)", "file_locations_kind_appimage_desktop_file": "Eintrag im Anwendungsmenü", "file_locations_kind_appimage_icon": "Symbol im Anwendungsmenü", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "GSAK-&Filter importieren…", + "gsak_filter_import_dialog_title": "GSAK-Filter importieren", + "gsak_filter_import_select_file_label": "GSAK-Einstellungsdatenbank (gsak.db3) oder Backup (.zip) auswählen:", + "gsak_filter_import_browse_title": "gsak.db3 auswählen", + "gsak_filter_import_file_filter": "GSAK-Einstellungsdatenbank (gsak.db3 *.db3 *.zip);;Alle Dateien (*)", + "gsak_filter_import_found": "{count} gespeicherte(r) Filter gefunden", + "gsak_filter_import_select_all": "Alle auswählen", + "gsak_filter_import_select_none": "Keine auswählen", + "gsak_filter_import_search_placeholder": "Filter suchen…", + "gsak_filter_import_overwrite": "Bereits vorhandene Filterprofile überschreiben", + "gsak_filter_import_overwrite_tooltip": "Aus: ein vorhandenes Profil gleichen Namens bleibt erhalten und wird als übersprungen gemeldet.", + "gsak_filter_import_no_filters": "Aus {name} konnten keine gespeicherten GSAK-Filter gelesen werden.\n\n{error}\n\nGespeicherte Filter liegen in der GSAK-Einstellungsdatenbank gsak.db3 — nicht in einer Cache-Datenbank (sqlite.db3).", + "gsak_filter_import_none_found": "{name} enthält keine gespeicherten Filter.", + "gsak_filter_import_running": "{count} Filter werden importiert…", + "gsak_filter_import_complete": "Import abgeschlossen.", + "gsak_filter_import_written": "Geschriebene Filterprofile", + "gsak_filter_import_skipped": "Übersprungen (schon vorhanden)", + "gsak_filter_import_failed": "Fehlgeschlagen", + "gsak_filter_import_stats_header": "Migrationsabdeckung", + "gsak_filter_import_conditions": "GSAK-Bedingungen", + "gsak_filter_import_as_filters": "… in Filter migriert", + "gsak_filter_import_as_sql": "… in Where-SQL migriert", + "gsak_filter_import_as_comments": "… als SQL-Kommentar hinterlegt", + "gsak_filter_import_coverage": "Abdeckung", + "gsak_filter_import_full": "vollständig migriert (100 %)", + "gsak_filter_import_partial": "teilweise migriert", + "gsak_filter_import_nothing": "nichts migriert (0 %)", + "gsak_filter_import_incomplete_header": "{count} Filter haben Bedingungen im Kommentar:", + "gsak_filter_import_left_in_comments": "{count} im Kommentar", + "gsak_filter_import_where_hint": " Solche Filter unter Filter setzen → Where öffnen und dort fertigstellen.", + "gsak_filter_import_skipped_header": "{count} Filter übersprungen — ein Profil dieses Namens existiert bereits:", + "gsak_filter_import_failed_header": "{count} Filter konnten nicht umgewandelt werden:", } diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index 206bd854..85c8b81a 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -1407,4 +1407,38 @@ "file_locations_kind_appimage_binary": "Programmdatei (AppImage)", "file_locations_kind_appimage_desktop_file": "Eintrag im Anwendungsmenü", "file_locations_kind_appimage_icon": "Symbol im Anwendungsmenü", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "GSAK-&Filter importieren…", + "gsak_filter_import_dialog_title": "GSAK-Filter importieren", + "gsak_filter_import_select_file_label": "GSAK-Einstellungsdatenbank (gsak.db3) oder Backup (.zip) auswählen:", + "gsak_filter_import_browse_title": "gsak.db3 auswählen", + "gsak_filter_import_file_filter": "GSAK-Einstellungsdatenbank (gsak.db3 *.db3 *.zip);;Alle Dateien (*)", + "gsak_filter_import_found": "{count} gespeicherte(r) Filter gefunden", + "gsak_filter_import_select_all": "Alle auswählen", + "gsak_filter_import_select_none": "Keine auswählen", + "gsak_filter_import_search_placeholder": "Filter suchen…", + "gsak_filter_import_overwrite": "Bereits vorhandene Filterprofile überschreiben", + "gsak_filter_import_overwrite_tooltip": "Aus: ein vorhandenes Profil gleichen Namens bleibt erhalten und wird als übersprungen gemeldet.", + "gsak_filter_import_no_filters": "Aus {name} konnten keine gespeicherten GSAK-Filter gelesen werden.\n\n{error}\n\nGespeicherte Filter liegen in der GSAK-Einstellungsdatenbank gsak.db3 — nicht in einer Cache-Datenbank (sqlite.db3).", + "gsak_filter_import_none_found": "{name} enthält keine gespeicherten Filter.", + "gsak_filter_import_running": "{count} Filter werden importiert…", + "gsak_filter_import_complete": "Import abgeschlossen.", + "gsak_filter_import_written": "Geschriebene Filterprofile", + "gsak_filter_import_skipped": "Übersprungen (schon vorhanden)", + "gsak_filter_import_failed": "Fehlgeschlagen", + "gsak_filter_import_stats_header": "Migrationsabdeckung", + "gsak_filter_import_conditions": "GSAK-Bedingungen", + "gsak_filter_import_as_filters": "… in Filter migriert", + "gsak_filter_import_as_sql": "… in Where-SQL migriert", + "gsak_filter_import_as_comments": "… als SQL-Kommentar hinterlegt", + "gsak_filter_import_coverage": "Abdeckung", + "gsak_filter_import_full": "vollständig migriert (100 %)", + "gsak_filter_import_partial": "teilweise migriert", + "gsak_filter_import_nothing": "nichts migriert (0 %)", + "gsak_filter_import_incomplete_header": "{count} Filter haben Bedingungen im Kommentar:", + "gsak_filter_import_left_in_comments": "{count} im Kommentar", + "gsak_filter_import_where_hint": " Solche Filter unter Filter setzen → Where öffnen und dort fertigstellen.", + "gsak_filter_import_skipped_header": "{count} Filter übersprungen — ein Profil dieses Namens existiert bereits:", + "gsak_filter_import_failed_header": "{count} Filter konnten nicht umgewandelt werden:", } diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 0c49d687..49e918f1 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -1404,4 +1404,38 @@ "file_locations_kind_appimage_binary": "Program file (AppImage)", "file_locations_kind_appimage_desktop_file": "Application menu entry", "file_locations_kind_appimage_icon": "Application menu icon", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Import GSAK &Filters…", + "gsak_filter_import_dialog_title": "Import GSAK Filters", + "gsak_filter_import_select_file_label": "Select GSAK's settings database (gsak.db3) or a backup (.zip):", + "gsak_filter_import_browse_title": "Select gsak.db3", + "gsak_filter_import_file_filter": "GSAK settings database (gsak.db3 *.db3 *.zip);;All files (*)", + "gsak_filter_import_found": "{count} saved filter(s) found", + "gsak_filter_import_select_all": "Select all", + "gsak_filter_import_select_none": "Select none", + "gsak_filter_import_search_placeholder": "Search filters…", + "gsak_filter_import_overwrite": "Overwrite filter profiles that already exist", + "gsak_filter_import_overwrite_tooltip": "Off: an existing profile of the same name is kept and reported as skipped.", + "gsak_filter_import_no_filters": "No saved GSAK filters could be read from {name}.\n\n{error}\n\nSaved filters live in GSAK's settings database gsak.db3 — not in a cache database (sqlite.db3).", + "gsak_filter_import_none_found": "{name} holds no saved filters.", + "gsak_filter_import_running": "Importing {count} filter(s)…", + "gsak_filter_import_complete": "Import finished.", + "gsak_filter_import_written": "Filter profiles written", + "gsak_filter_import_skipped": "Skipped (already exist)", + "gsak_filter_import_failed": "Failed", + "gsak_filter_import_stats_header": "Migration coverage", + "gsak_filter_import_conditions": "GSAK conditions", + "gsak_filter_import_as_filters": "… migrated to filters", + "gsak_filter_import_as_sql": "… migrated to Where SQL", + "gsak_filter_import_as_comments": "… left as SQL comments", + "gsak_filter_import_coverage": "Coverage", + "gsak_filter_import_full": "fully migrated (100 %)", + "gsak_filter_import_partial": "partly migrated", + "gsak_filter_import_nothing": "nothing migrated (0 %)", + "gsak_filter_import_incomplete_header": "{count} filter(s) have conditions left in comments:", + "gsak_filter_import_left_in_comments": "{count} in comments", + "gsak_filter_import_where_hint": " Open such a filter under Set filter → Where to see and finish them.", + "gsak_filter_import_skipped_header": "{count} filter(s) skipped — a profile of that name already exists:", + "gsak_filter_import_failed_header": "{count} filter(s) could not be converted:", } diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index c4064cef..99301f5a 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -1406,4 +1406,38 @@ "file_locations_kind_appimage_binary": "Archivo del programa (AppImage)", "file_locations_kind_appimage_desktop_file": "Entrada del menú de aplicaciones", "file_locations_kind_appimage_icon": "Icono del menú de aplicaciones", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importar &filtros de GSAK…", + "gsak_filter_import_dialog_title": "Importar filtros de GSAK", + "gsak_filter_import_select_file_label": "Elige la base de configuración de GSAK (gsak.db3) o una copia (.zip):", + "gsak_filter_import_browse_title": "Elegir gsak.db3", + "gsak_filter_import_file_filter": "Base de configuración de GSAK (gsak.db3 *.db3 *.zip);;Todos los archivos (*)", + "gsak_filter_import_found": "{count} filtro(s) guardado(s) encontrado(s)", + "gsak_filter_import_select_all": "Seleccionar todo", + "gsak_filter_import_select_none": "No seleccionar nada", + "gsak_filter_import_search_placeholder": "Buscar filtros…", + "gsak_filter_import_overwrite": "Sobrescribir los perfiles de filtro existentes", + "gsak_filter_import_overwrite_tooltip": "Desactivado: un perfil existente con el mismo nombre se conserva y se indica como omitido.", + "gsak_filter_import_no_filters": "No se pudieron leer filtros guardados de GSAK en {name}.\n\n{error}\n\nLos filtros guardados están en la base de configuración gsak.db3, no en una base de cachés (sqlite.db3).", + "gsak_filter_import_none_found": "{name} no contiene filtros guardados.", + "gsak_filter_import_running": "Importando {count} filtro(s)…", + "gsak_filter_import_complete": "Importación terminada.", + "gsak_filter_import_written": "Perfiles de filtro escritos", + "gsak_filter_import_skipped": "Omitidos (ya existen)", + "gsak_filter_import_failed": "Fallidos", + "gsak_filter_import_stats_header": "Cobertura de la migración", + "gsak_filter_import_conditions": "Condiciones de GSAK", + "gsak_filter_import_as_filters": "… migradas a filtros", + "gsak_filter_import_as_sql": "… migradas a SQL de Where", + "gsak_filter_import_as_comments": "… dejadas como comentarios SQL", + "gsak_filter_import_coverage": "Cobertura", + "gsak_filter_import_full": "migrados por completo (100 %)", + "gsak_filter_import_partial": "migrados en parte", + "gsak_filter_import_nothing": "nada migrado (0 %)", + "gsak_filter_import_incomplete_header": "{count} filtro(s) tienen condiciones en comentarios:", + "gsak_filter_import_left_in_comments": "{count} en comentarios", + "gsak_filter_import_where_hint": " Abre ese filtro en Definir filtro → Where para verlas y terminarlas.", + "gsak_filter_import_skipped_header": "{count} filtro(s) omitido(s): ya existe un perfil con ese nombre:", + "gsak_filter_import_failed_header": "No se pudieron convertir {count} filtro(s):", } diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 1a0f3da3..3cc20b99 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -1406,4 +1406,38 @@ "file_locations_kind_appimage_binary": "Fichier du programme (AppImage)", "file_locations_kind_appimage_desktop_file": "Entrée du menu des applications", "file_locations_kind_appimage_icon": "Icône du menu des applications", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importer les &filtres GSAK…", + "gsak_filter_import_dialog_title": "Importer les filtres GSAK", + "gsak_filter_import_select_file_label": "Choisissez la base de configuration GSAK (gsak.db3) ou une sauvegarde (.zip) :", + "gsak_filter_import_browse_title": "Choisir gsak.db3", + "gsak_filter_import_file_filter": "Base de configuration GSAK (gsak.db3 *.db3 *.zip);;Tous les fichiers (*)", + "gsak_filter_import_found": "{count} filtre(s) enregistré(s) trouvé(s)", + "gsak_filter_import_select_all": "Tout sélectionner", + "gsak_filter_import_select_none": "Tout désélectionner", + "gsak_filter_import_search_placeholder": "Rechercher des filtres…", + "gsak_filter_import_overwrite": "Écraser les profils de filtre existants", + "gsak_filter_import_overwrite_tooltip": "Décoché : un profil existant du même nom est conservé et signalé comme ignoré.", + "gsak_filter_import_no_filters": "Impossible de lire des filtres GSAK enregistrés dans {name}.\n\n{error}\n\nLes filtres enregistrés se trouvent dans la base de configuration gsak.db3 — pas dans une base de caches (sqlite.db3).", + "gsak_filter_import_none_found": "{name} ne contient aucun filtre enregistré.", + "gsak_filter_import_running": "Import de {count} filtre(s)…", + "gsak_filter_import_complete": "Import terminé.", + "gsak_filter_import_written": "Profils de filtre écrits", + "gsak_filter_import_skipped": "Ignorés (déjà existants)", + "gsak_filter_import_failed": "Échecs", + "gsak_filter_import_stats_header": "Couverture de la migration", + "gsak_filter_import_conditions": "Conditions GSAK", + "gsak_filter_import_as_filters": "… migrées en filtres", + "gsak_filter_import_as_sql": "… migrées en SQL Where", + "gsak_filter_import_as_comments": "… laissées en commentaires SQL", + "gsak_filter_import_coverage": "Couverture", + "gsak_filter_import_full": "entièrement migrés (100 %)", + "gsak_filter_import_partial": "partiellement migrés", + "gsak_filter_import_nothing": "rien de migré (0 %)", + "gsak_filter_import_incomplete_header": "{count} filtre(s) ont des conditions restées en commentaire :", + "gsak_filter_import_left_in_comments": "{count} en commentaire", + "gsak_filter_import_where_hint": " Ouvrez un tel filtre via Définir le filtre → Where pour les voir et les finir.", + "gsak_filter_import_skipped_header": "{count} filtre(s) ignoré(s) — un profil de ce nom existe déjà :", + "gsak_filter_import_failed_header": "{count} filtre(s) n'ont pas pu être convertis :", } diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index de7d3ae2..942c57ec 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -1403,4 +1403,38 @@ "file_locations_kind_appimage_binary": "Programmabestand (AppImage)", "file_locations_kind_appimage_desktop_file": "Item in het programmamenu", "file_locations_kind_appimage_icon": "Pictogram in het programmamenu", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "GSAK-&filters importeren…", + "gsak_filter_import_dialog_title": "GSAK-filters importeren", + "gsak_filter_import_select_file_label": "Kies GSAK's instellingendatabase (gsak.db3) of een back-up (.zip):", + "gsak_filter_import_browse_title": "gsak.db3 kiezen", + "gsak_filter_import_file_filter": "GSAK-instellingendatabase (gsak.db3 *.db3 *.zip);;Alle bestanden (*)", + "gsak_filter_import_found": "{count} opgeslagen filter(s) gevonden", + "gsak_filter_import_select_all": "Alles selecteren", + "gsak_filter_import_select_none": "Niets selecteren", + "gsak_filter_import_search_placeholder": "Filters zoeken…", + "gsak_filter_import_overwrite": "Bestaande filterprofielen overschrijven", + "gsak_filter_import_overwrite_tooltip": "Uit: een bestaand profiel met dezelfde naam blijft staan en wordt als overgeslagen gemeld.", + "gsak_filter_import_no_filters": "Er konden geen opgeslagen GSAK-filters uit {name} gelezen worden.\n\n{error}\n\nOpgeslagen filters staan in GSAK's instellingendatabase gsak.db3 — niet in een cachedatabase (sqlite.db3).", + "gsak_filter_import_none_found": "{name} bevat geen opgeslagen filters.", + "gsak_filter_import_running": "{count} filter(s) importeren…", + "gsak_filter_import_complete": "Import voltooid.", + "gsak_filter_import_written": "Geschreven filterprofielen", + "gsak_filter_import_skipped": "Overgeslagen (bestaan al)", + "gsak_filter_import_failed": "Mislukt", + "gsak_filter_import_stats_header": "Migratiedekking", + "gsak_filter_import_conditions": "GSAK-voorwaarden", + "gsak_filter_import_as_filters": "… gemigreerd naar filters", + "gsak_filter_import_as_sql": "… gemigreerd naar Where-SQL", + "gsak_filter_import_as_comments": "… als SQL-commentaar achtergelaten", + "gsak_filter_import_coverage": "Dekking", + "gsak_filter_import_full": "volledig gemigreerd (100 %)", + "gsak_filter_import_partial": "deels gemigreerd", + "gsak_filter_import_nothing": "niets gemigreerd (0 %)", + "gsak_filter_import_incomplete_header": "{count} filter(s) hebben voorwaarden in commentaar staan:", + "gsak_filter_import_left_in_comments": "{count} in commentaar", + "gsak_filter_import_where_hint": " Open zo'n filter via Filter instellen → Where om ze te zien en af te maken.", + "gsak_filter_import_skipped_header": "{count} filter(s) overgeslagen — een profiel met die naam bestaat al:", + "gsak_filter_import_failed_header": "{count} filter(s) konden niet omgezet worden:", } diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 1d09834b..937dc0e5 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -1406,4 +1406,38 @@ "file_locations_kind_appimage_binary": "Plik programu (AppImage)", "file_locations_kind_appimage_desktop_file": "Pozycja w menu aplikacji", "file_locations_kind_appimage_icon": "Ikona w menu aplikacji", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importuj &filtry GSAK…", + "gsak_filter_import_dialog_title": "Import filtrów GSAK", + "gsak_filter_import_select_file_label": "Wybierz bazę ustawień GSAK (gsak.db3) lub kopię zapasową (.zip):", + "gsak_filter_import_browse_title": "Wybierz gsak.db3", + "gsak_filter_import_file_filter": "Baza ustawień GSAK (gsak.db3 *.db3 *.zip);;Wszystkie pliki (*)", + "gsak_filter_import_found": "Znaleziono {count} zapisanych filtrów", + "gsak_filter_import_select_all": "Zaznacz wszystkie", + "gsak_filter_import_select_none": "Odznacz wszystkie", + "gsak_filter_import_search_placeholder": "Szukaj filtrów…", + "gsak_filter_import_overwrite": "Nadpisz istniejące profile filtrów", + "gsak_filter_import_overwrite_tooltip": "Wyłączone: istniejący profil o tej samej nazwie zostaje zachowany i zgłoszony jako pominięty.", + "gsak_filter_import_no_filters": "Nie udało się odczytać zapisanych filtrów GSAK z {name}.\n\n{error}\n\nZapisane filtry znajdują się w bazie ustawień gsak.db3 — nie w bazie skrytek (sqlite.db3).", + "gsak_filter_import_none_found": "{name} nie zawiera zapisanych filtrów.", + "gsak_filter_import_running": "Importowanie {count} filtrów…", + "gsak_filter_import_complete": "Import zakończony.", + "gsak_filter_import_written": "Zapisane profile filtrów", + "gsak_filter_import_skipped": "Pominięte (już istnieją)", + "gsak_filter_import_failed": "Niepowodzenia", + "gsak_filter_import_stats_header": "Pokrycie migracji", + "gsak_filter_import_conditions": "Warunki GSAK", + "gsak_filter_import_as_filters": "… przeniesione do filtrów", + "gsak_filter_import_as_sql": "… przeniesione do SQL w Where", + "gsak_filter_import_as_comments": "… pozostawione jako komentarz SQL", + "gsak_filter_import_coverage": "Pokrycie", + "gsak_filter_import_full": "w pełni przeniesione (100 %)", + "gsak_filter_import_partial": "częściowo przeniesione", + "gsak_filter_import_nothing": "nic nie przeniesiono (0 %)", + "gsak_filter_import_incomplete_header": "{count} filtrów ma warunki pozostawione w komentarzu:", + "gsak_filter_import_left_in_comments": "{count} w komentarzu", + "gsak_filter_import_where_hint": " Otwórz taki filtr w Ustaw filtr → Where, aby je zobaczyć i dokończyć.", + "gsak_filter_import_skipped_header": "{count} filtrów pominięto — profil o tej nazwie już istnieje:", + "gsak_filter_import_failed_header": "Nie udało się przekonwertować {count} filtrów:", } diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index 8c274916..5b1ac5b8 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -1406,4 +1406,38 @@ "file_locations_kind_appimage_binary": "Ficheiro do programa (AppImage)", "file_locations_kind_appimage_desktop_file": "Entrada no menu de aplicações", "file_locations_kind_appimage_icon": "Ícone no menu de aplicações", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importar &filtros do GSAK…", + "gsak_filter_import_dialog_title": "Importar filtros do GSAK", + "gsak_filter_import_select_file_label": "Escolha a base de definições do GSAK (gsak.db3) ou uma cópia (.zip):", + "gsak_filter_import_browse_title": "Escolher gsak.db3", + "gsak_filter_import_file_filter": "Base de definições do GSAK (gsak.db3 *.db3 *.zip);;Todos os ficheiros (*)", + "gsak_filter_import_found": "{count} filtro(s) guardado(s) encontrado(s)", + "gsak_filter_import_select_all": "Selecionar tudo", + "gsak_filter_import_select_none": "Não selecionar nada", + "gsak_filter_import_search_placeholder": "Procurar filtros…", + "gsak_filter_import_overwrite": "Substituir perfis de filtro já existentes", + "gsak_filter_import_overwrite_tooltip": "Desligado: um perfil existente com o mesmo nome é mantido e indicado como ignorado.", + "gsak_filter_import_no_filters": "Não foi possível ler filtros guardados do GSAK em {name}.\n\n{error}\n\nOs filtros guardados estão na base de definições gsak.db3 — não numa base de caches (sqlite.db3).", + "gsak_filter_import_none_found": "{name} não contém filtros guardados.", + "gsak_filter_import_running": "A importar {count} filtro(s)…", + "gsak_filter_import_complete": "Importação concluída.", + "gsak_filter_import_written": "Perfis de filtro escritos", + "gsak_filter_import_skipped": "Ignorados (já existem)", + "gsak_filter_import_failed": "Falharam", + "gsak_filter_import_stats_header": "Cobertura da migração", + "gsak_filter_import_conditions": "Condições do GSAK", + "gsak_filter_import_as_filters": "… migradas para filtros", + "gsak_filter_import_as_sql": "… migradas para SQL do Where", + "gsak_filter_import_as_comments": "… deixadas como comentários SQL", + "gsak_filter_import_coverage": "Cobertura", + "gsak_filter_import_full": "totalmente migrados (100 %)", + "gsak_filter_import_partial": "parcialmente migrados", + "gsak_filter_import_nothing": "nada migrado (0 %)", + "gsak_filter_import_incomplete_header": "{count} filtro(s) têm condições em comentário:", + "gsak_filter_import_left_in_comments": "{count} em comentário", + "gsak_filter_import_where_hint": " Abra esse filtro em Definir filtro → Where para as ver e terminar.", + "gsak_filter_import_skipped_header": "{count} filtro(s) ignorado(s) — já existe um perfil com esse nome:", + "gsak_filter_import_failed_header": "{count} filtro(s) não puderam ser convertidos:", } diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 6111e1c6..4fb3fe87 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -1405,4 +1405,38 @@ "file_locations_kind_appimage_binary": "Programfil (AppImage)", "file_locations_kind_appimage_desktop_file": "Post i programmenyn", "file_locations_kind_appimage_icon": "Ikon i programmenyn", + + # ── GSAK filter import ──────────────────────────────────────────────────── + "action_gsak_filter_import": "Importera GSAK-&filter…", + "gsak_filter_import_dialog_title": "Importera GSAK-filter", + "gsak_filter_import_select_file_label": "Välj GSAK:s inställningsdatabas (gsak.db3) eller en säkerhetskopia (.zip):", + "gsak_filter_import_browse_title": "Välj gsak.db3", + "gsak_filter_import_file_filter": "GSAK-inställningsdatabas (gsak.db3 *.db3 *.zip);;Alla filer (*)", + "gsak_filter_import_found": "{count} sparade filter hittades", + "gsak_filter_import_select_all": "Markera alla", + "gsak_filter_import_select_none": "Avmarkera alla", + "gsak_filter_import_search_placeholder": "Sök filter…", + "gsak_filter_import_overwrite": "Skriv över filterprofiler som redan finns", + "gsak_filter_import_overwrite_tooltip": "Av: en befintlig profil med samma namn behålls och rapporteras som överhoppad.", + "gsak_filter_import_no_filters": "Det gick inte att läsa sparade GSAK-filter från {name}.\n\n{error}\n\nSparade filter ligger i GSAK:s inställningsdatabas gsak.db3 — inte i en cachedatabas (sqlite.db3).", + "gsak_filter_import_none_found": "{name} innehåller inga sparade filter.", + "gsak_filter_import_running": "Importerar {count} filter…", + "gsak_filter_import_complete": "Importen klar.", + "gsak_filter_import_written": "Skrivna filterprofiler", + "gsak_filter_import_skipped": "Överhoppade (finns redan)", + "gsak_filter_import_failed": "Misslyckades", + "gsak_filter_import_stats_header": "Migreringstäckning", + "gsak_filter_import_conditions": "GSAK-villkor", + "gsak_filter_import_as_filters": "… migrerade till filter", + "gsak_filter_import_as_sql": "… migrerade till Where-SQL", + "gsak_filter_import_as_comments": "… kvar som SQL-kommentarer", + "gsak_filter_import_coverage": "Täckning", + "gsak_filter_import_full": "helt migrerade (100 %)", + "gsak_filter_import_partial": "delvis migrerade", + "gsak_filter_import_nothing": "inget migrerat (0 %)", + "gsak_filter_import_incomplete_header": "{count} filter har villkor kvar i kommentarer:", + "gsak_filter_import_left_in_comments": "{count} i kommentarer", + "gsak_filter_import_where_hint": " Öppna ett sådant filter under Ange filter → Where för att se och slutföra dem.", + "gsak_filter_import_skipped_header": "{count} filter överhoppade — en profil med det namnet finns redan:", + "gsak_filter_import_failed_header": "{count} filter kunde inte konverteras:", } diff --git a/tests/e2e-tests/test_e2e_gsak_filter_import.py b/tests/e2e-tests/test_e2e_gsak_filter_import.py new file mode 100644 index 00000000..198d60cb --- /dev/null +++ b/tests/e2e-tests/test_e2e_gsak_filter_import.py @@ -0,0 +1,140 @@ +# tests/e2e-tests/test_e2e_gsak_filter_import.py — importing GSAK's saved +# filters from the File menu, all the way to a usable filter profile. + +import sqlite3 +from pathlib import Path + +import pytest + +pytest.importorskip("pytestqt") + +from opensak.filters.engine import FilterProfile + + +def _blob(pairs: dict) -> str: + return "\r\n".join(f"{k}={v}" for k, v in pairs.items()) + + +@pytest.fixture +def gsak_db(tmp_path) -> Path: + """A gsak.db3 with two saved filters: one that migrates whole, one that + leaves a condition behind in the Where tab.""" + path = tmp_path / "gsak.db3" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE TranslateFilters (Type TEXT, Description TEXT, Data TEXT)") + conn.executemany("INSERT INTO TranslateFilters VALUES ('FI', ?, ?)", [ + ("Not found", _blob({"chkFound": "False", "chkNotFound": "True"})), + ("Watched", _blob({"chkFound": "False", "chkNotFound": "True", + "chkWatchYes": "True", "chkWatchNo": "False", + "edtSymbol": "Geocache Found"})), + ]) + conn.commit() + conn.close() + return path + + +def _open_dialog(window, monkeypatch, gsak_db, profiles_dir): + """Open the File-menu dialog with the file already chosen.""" + from opensak.gui.dialogs import gsak_filter_import_dialog as fdlg + + monkeypatch.setattr("opensak.config.get_app_data_dir", lambda: profiles_dir) + opened = {} + monkeypatch.setattr(fdlg.GsakFilterImportDialog, "exec", + lambda self: opened.setdefault("dlg", self)) + monkeypatch.setattr(fdlg.QFileDialog, "getOpenFileName", + lambda *a, **k: (str(gsak_db), "f")) + window._act_gsak_filter_import.trigger() + dlg = opened["dlg"] + dlg._browse() + return dlg + + +class TestGsakFilterImportE2E: + def test_menu_action_sits_next_to_the_database_import(self, empty_window): + from PySide6.QtWidgets import QMenu + + action = empty_window._act_gsak_filter_import + assert action.text() != "action_gsak_filter_import" # translated + menu = next(o for o in action.associatedObjects() if isinstance(o, QMenu)) + actions = menu.actions() + assert actions.index(action) == \ + actions.index(empty_window._act_gsak_import) + 1 + + def test_import_writes_profiles_the_filter_dialog_can_load( + self, empty_window, tmp_path, monkeypatch, gsak_db, qtbot + ): + profiles_dir = tmp_path / "appdata" + dlg = _open_dialog(empty_window, monkeypatch, gsak_db, profiles_dir) + assert dlg._selected_names() == ["Not found", "Watched"] + + with qtbot.waitSignal(dlg.import_completed, timeout=10_000): + dlg._start_import() + qtbot.waitUntil(lambda: dlg._worker is None, timeout=10_000) + + written = sorted(p.name for p in (profiles_dir / "filters").glob("*.json")) + assert written == ["Not found.json", "Watched.json"] + + # Both profiles load back as real filter sets… + profile = FilterProfile.load(profiles_dir / "filters" / "Watched.json") + types = [f.filter_type for f in profile.filterset._filters] + assert types == ["not_found", "where_clause"] + + # …and the Where clause holds the watch-list SQL plus a comment about + # the GPX symbol name, which OpenSAK does not store. + sql = profile.filterset._filters[1].sql + assert "coalesce(watch, 0) = 1" in sql + assert "Symbol name" in sql + assert sql.splitlines()[-1].startswith("(") # ends on runnable SQL + + def test_results_log_reports_the_migration_coverage( + self, empty_window, tmp_path, monkeypatch, gsak_db, qtbot + ): + dlg = _open_dialog(empty_window, monkeypatch, gsak_db, tmp_path / "appdata") + with qtbot.waitSignal(dlg.import_completed, timeout=10_000): + dlg._start_import() + qtbot.waitUntil(lambda: dlg._worker is None, timeout=10_000) + + log = dlg._log.toPlainText() + # 4 conditions in total (not-found ×2, watch list, symbol name), of + # which the symbol name could not be migrated. + assert "75.0 %" in log + assert "Watched" in log # listed as incomplete + assert "Not found" not in log.split("Watched")[-1] # it was complete + + def test_second_import_skips_existing_profiles( + self, empty_window, tmp_path, monkeypatch, gsak_db, qtbot + ): + profiles_dir = tmp_path / "appdata" + dlg = _open_dialog(empty_window, monkeypatch, gsak_db, profiles_dir) + with qtbot.waitSignal(dlg.import_completed, timeout=10_000): + dlg._start_import() + qtbot.waitUntil(lambda: dlg._worker is None, timeout=10_000) + first = {p: p.read_text(encoding="utf-8") + for p in (profiles_dir / "filters").glob("*.json")} + + # Second run: nothing is written, so import_completed never fires — + # only the worker finishing says it is done. + dlg = _open_dialog(empty_window, monkeypatch, gsak_db, profiles_dir) + fired = [] + dlg.import_completed.connect(lambda: fired.append(True)) + dlg._start_import() + qtbot.waitUntil(lambda: dlg._worker is None, timeout=10_000) + + assert fired == [] + assert {p: p.read_text(encoding="utf-8") + for p in (profiles_dir / "filters").glob("*.json")} == first + assert dlg._log.toPlainText().count("Watched") >= 1 # listed as skipped + + def test_imported_profile_appears_in_the_toolbar_dropdown( + self, empty_window, tmp_path, monkeypatch, gsak_db, qtbot + ): + profiles_dir = tmp_path / "appdata" + dlg = _open_dialog(empty_window, monkeypatch, gsak_db, profiles_dir) + dlg.import_completed.connect(empty_window._on_filter_profiles_imported) + with qtbot.waitSignal(dlg.import_completed, timeout=10_000): + dlg._start_import() + qtbot.waitUntil(lambda: dlg._worker is None, timeout=10_000) + + combo = empty_window._filter_profile_combo + names = [combo.itemText(i) for i in range(combo.count())] + assert "Watched" in names diff --git a/tests/unit-tests/test_gsak_filter_import_dialog.py b/tests/unit-tests/test_gsak_filter_import_dialog.py new file mode 100644 index 00000000..90364f18 --- /dev/null +++ b/tests/unit-tests/test_gsak_filter_import_dialog.py @@ -0,0 +1,241 @@ +# tests/unit-tests/test_gsak_filter_import_dialog.py — GSAK filter import +# dialog: filter list, selection, worker and the migration statistics it prints. + +import sqlite3 +from pathlib import Path + +import pytest + +pytest.importorskip("pytestqt") + +from PySide6.QtCore import Qt + +from opensak.gui.dialogs import gsak_filter_import_dialog as fdlg +from opensak.gui.dialogs.gsak_filter_import_dialog import ( + GsakFilterImportDialog, + GsakFilterImportWorker, + format_result, +) +from opensak.importer.gsak_filter_importer import ( + Conversion, + FilterImportEntry, + GsakFilterImportResult, + Options, + import_gsak_filters, +) + + +def _blob(pairs: dict) -> str: + return "\r\n".join(f"{k}={v}" for k, v in pairs.items()) + + +_FOUND = _blob({"chkFound": "True", "chkNotFound": "False"}) + + +@pytest.fixture +def gsak_db(tmp_path) -> Path: + path = tmp_path / "gsak.db3" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE TranslateFilters (Type TEXT, Description TEXT, Data TEXT)") + conn.executemany( + "INSERT INTO TranslateFilters VALUES ('FI', ?, ?)", + [("Alpha", _FOUND), ("Beta", _FOUND), ("Gamma export", _FOUND)], + ) + conn.commit() + conn.close() + return path + + +@pytest.fixture +def dlg(qtbot): + d = GsakFilterImportDialog() + qtbot.addWidget(d) + return d + + +# ── Worker ──────────────────────────────────────────────────────────────────── + +class TestWorker: + def test_run_emits_result(self, gsak_db, tmp_path, monkeypatch): + monkeypatch.setattr("opensak.config.get_app_data_dir", lambda: tmp_path) + w = GsakFilterImportWorker(gsak_db, ["Alpha"], overwrite=False) + got = [] + w.result_ready.connect(got.append) + w.run() + assert len(got) == 1 and got[0].written == 1 + + def test_run_reports_errors_instead_of_raising(self, tmp_path): + w = GsakFilterImportWorker(tmp_path / "missing.db3", ["A"], overwrite=False) + errs = [] + w.error.connect(errs.append) + w.run() + assert errs and "Traceback" in errs[0] + + def test_run_reports_progress(self, gsak_db, tmp_path, monkeypatch): + monkeypatch.setattr("opensak.config.get_app_data_dir", lambda: tmp_path) + w = GsakFilterImportWorker(gsak_db, ["Alpha", "Beta"], overwrite=False) + seen = [] + w.progress.connect(lambda d, t: seen.append((d, t))) + w.run() + assert seen == [(1, 2), (2, 2)] + + +# ── Dialog ──────────────────────────────────────────────────────────────────── + +class TestDialog: + def test_starts_empty_and_disabled(self, dlg): + assert dlg._list.count() == 0 + assert dlg._import_btn.isEnabled() is False + assert dlg._search.isEnabled() is False + + def test_set_path_lists_filters_all_checked(self, dlg, gsak_db): + dlg.set_path(gsak_db) + assert [dlg._list.item(i).text() for i in range(dlg._list.count())] == \ + ["Alpha", "Beta", "Gamma export"] + assert dlg._selected_names() == ["Alpha", "Beta", "Gamma export"] + assert dlg._import_btn.isEnabled() is True + + def test_unticking_everything_disables_import(self, dlg, gsak_db): + dlg.set_path(gsak_db) + dlg._check_visible(False) + assert dlg._selected_names() == [] + assert dlg._import_btn.isEnabled() is False + + def test_search_hides_non_matching_filters(self, dlg, gsak_db): + dlg.set_path(gsak_db) + dlg._search.setText("gamma") + hidden = [dlg._list.item(i).text() + for i in range(dlg._list.count()) if dlg._list.item(i).isHidden()] + assert hidden == ["Alpha", "Beta"] + + def test_select_all_only_touches_visible_rows(self, dlg, gsak_db): + """Otherwise "select none" while searching would silently clear the rest.""" + dlg.set_path(gsak_db) + dlg._check_visible(False) + dlg._search.setText("alpha") + dlg._check_visible(True) + assert dlg._selected_names() == ["Alpha"] + + def test_non_gsak_database_reports_and_stays_disabled(self, dlg, tmp_path, monkeypatch): + path = tmp_path / "sqlite.db3" + sqlite3.connect(path).close() + shown = [] + monkeypatch.setattr(fdlg.QMessageBox, "critical", lambda *a, **k: shown.append(a)) + dlg.set_path(path) + assert shown and dlg._import_btn.isEnabled() is False + + def test_database_without_filters_reports_and_stays_disabled(self, dlg, tmp_path, + monkeypatch): + path = tmp_path / "gsak.db3" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE TranslateFilters (Type TEXT, Description TEXT, Data TEXT)") + conn.commit() + conn.close() + shown = [] + monkeypatch.setattr(fdlg.QMessageBox, "information", lambda *a, **k: shown.append(a)) + dlg.set_path(path) + assert shown and dlg._import_btn.isEnabled() is False + + def test_browse_cancel_changes_nothing(self, dlg, monkeypatch): + monkeypatch.setattr(fdlg.QFileDialog, "getOpenFileName", lambda *a, **k: ("", "")) + dlg._browse() + assert dlg._selected_path is None + + def test_start_import_without_a_file_is_a_noop(self, dlg): + dlg._start_import() + assert dlg._progress.isVisible() is False and dlg._worker is None + + def test_result_with_written_profiles_signals_completion(self, dlg, gsak_db, tmp_path, + monkeypatch): + monkeypatch.setattr("opensak.config.get_app_data_dir", lambda: tmp_path) + fired = [] + dlg.import_completed.connect(lambda: fired.append(True)) + dlg._on_result(import_gsak_filters(gsak_db, out_dir=tmp_path / "f", + opts=Options())) + assert fired == [True] + assert dlg._log.toPlainText() + + def test_result_without_written_profiles_stays_quiet(self, dlg): + fired = [] + dlg.import_completed.connect(lambda: fired.append(True)) + dlg._on_result(GsakFilterImportResult()) + assert fired == [] + + +# ── Statistics ──────────────────────────────────────────────────────────────── + +def _entry(name: str, native: int = 0, sql: int = 0, comments: int = 0, + written: bool = True, skipped: bool = False, + error: str | None = None) -> FilterImportEntry: + c = Conversion(name) + for i in range(native): + c.native(f"n{i}", object()) + for i in range(sql): + c.sql(f"s{i}", "1 = 1") + for i in range(comments): + c.comment(f"c{i}", "nope") + return FilterImportEntry(name=name, conversion=c, written=written, + skipped_existing=skipped, error=error) + + +class TestStatistics: + def test_counts_criteria_not_filters(self): + """A filter with many conditions has to weigh more than a small one.""" + result = GsakFilterImportResult(entries=[ + _entry("big", native=8, comments=2), + _entry("small", comments=1), + ]) + assert result.total_criteria == 11 + assert result.migrated_criteria == 8 + assert result.coverage == pytest.approx(8 / 11) + + def test_sql_counts_as_migrated_comments_do_not(self): + result = GsakFilterImportResult(entries=[_entry("f", native=1, sql=1, comments=2)]) + assert (result.native_criteria, result.sql_criteria) == (1, 1) + assert result.commented_criteria == 2 + assert result.coverage == 0.5 + + def test_everything_migrated_is_100_percent(self): + result = GsakFilterImportResult(entries=[_entry("f", native=3, sql=1)]) + assert result.coverage == 1.0 and result.fully_migrated == 1 + + def test_everything_commented_is_0_percent(self): + result = GsakFilterImportResult(entries=[_entry("f", comments=3)]) + assert result.coverage == 0.0 and result.not_migrated == 1 + + def test_skipped_and_failed_entries_are_left_out_of_coverage(self): + result = GsakFilterImportResult(entries=[ + _entry("written", native=2), + _entry("skipped", comments=5, written=False, skipped=True), + _entry("failed", comments=5, written=False, error="boom"), + ]) + assert (result.written, result.skipped, result.failed) == (1, 1, 1) + assert result.total_criteria == 2 and result.coverage == 1.0 + + def test_empty_import_is_not_a_division_by_zero(self): + assert GsakFilterImportResult().coverage == 1.0 + + def test_format_result_reports_the_headline_numbers(self): + result = GsakFilterImportResult(entries=[ + _entry("Good", native=4), + _entry("Partly", native=1, comments=1), + _entry("Existing", written=False, skipped=True), + ]) + text = format_result(result) + assert "83.3 %" in text # 5 of 6 conditions migrated + assert "Partly" in text # listed as incomplete + assert "Existing" in text # listed as skipped + assert "Good" not in text # fully migrated — nothing to say + + def test_format_result_lists_failures(self): + text = format_result(GsakFilterImportResult(entries=[ + _entry("Broken", written=False, error="RuntimeError: boom"), + ])) + assert "Broken" in text and "boom" in text + + def test_format_result_caps_long_listings(self): + entries = [_entry(f"F{i:03d}", native=1, comments=1) for i in range(60)] + text = format_result(GsakFilterImportResult(entries=entries)) + listed = [ln for ln in text.splitlines() if ln.strip().startswith("F0")] + assert len(listed) == fdlg.MAX_LISTED_FILTERS + assert f"… {60 - fdlg.MAX_LISTED_FILTERS}" in text diff --git a/tests/unit-tests/test_gsak_filter_importer.py b/tests/unit-tests/test_gsak_filter_importer.py new file mode 100644 index 00000000..7ed69f22 --- /dev/null +++ b/tests/unit-tests/test_gsak_filter_importer.py @@ -0,0 +1,441 @@ +# tests/unit-tests/test_gsak_filter_importer.py — GSAK saved-filter import: +# conversion, where-tab comments and migration coverage. + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from opensak.filters.engine import ( + DATE_OPS, + DATE_UNITS, + LOG_CATEGORIES, + LOG_SCOPE_CHOICES, + TEXT_OPS, + FilterSet, + validate_where_sql, +) +from opensak.importer import gsak_filter_importer as gfi +from opensak.importer.gsak_filter_importer import ( + COMMENT, + NATIVE, + SQL, + Conversion, + GsakFilterSourceError, + Options, + convert, + find_gsak_filter_db, + import_gsak_filters, + load_gsak_filters, + parse_filter_blob, +) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _blob(pairs: dict, where: str = "", custom: list | None = None) -> str: + """Serialise a GSAK filter blob the way TranslateFilters.Data stores one.""" + lines = [f"{k}={v}" for k, v in pairs.items()] + if custom: + lines.append("*custom*") + lines += custom + text = "\r\n".join(lines) + if where: + text += "\r\n*where*=" + where + return text + + +def _convert(pairs: dict, where: str = "", custom: list | None = None, + opts: Options | None = None) -> Conversion: + return convert(parse_filter_blob("T", _blob(pairs, where, custom)), + opts or Options()) + + +def _statuses(c: Conversion) -> dict: + return {crit.label: crit.status for crit in c.criteria} + + +def _make_gsak_db(path: Path, filters: dict[str, str]) -> Path: + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE TranslateFilters (Type TEXT, Description TEXT, Data TEXT)") + conn.executemany("INSERT INTO TranslateFilters VALUES ('FI', ?, ?)", + list(filters.items())) + conn.commit() + conn.close() + return path + + +# ── The vocabularies have to stay in step with the filter engine ────────────── + +class TestVocabularies: + """GSAK's dialog lists are decoded straight into OpenSAK's own operator + names. If the engine ever renames one, these catch it here rather than as + a ValueError halfway through someone's import.""" + + def test_every_text_operator_exists(self): + assert set(gfi.TEXT_OP.values()) <= set(TEXT_OPS) + + def test_every_date_operator_exists(self): + for table in (gfi.DATE_OP, gfi.DATE_OP_NO_COMPARE, gfi.DATE_OP_MY_FOUND): + assert {op for op in table.values() if op} <= set(DATE_OPS) + + def test_rolling_window_units_are_the_engines(self): + assert gfi.DURING_UNITS == DATE_UNITS + + def test_date_fields_exist_in_the_engine(self): + from opensak.filters.engine import DATE_FILTER_FIELDS + fields = {row[6] for row in gfi._DATE_FIELDS if row[6]} + assert fields <= set(DATE_FILTER_FIELDS) + + def test_log_scope_and_categories_are_the_engines(self): + assert LOG_SCOPE_CHOICES[0] == 0 # index 0 = "all logs" + assert set(LOG_CATEGORIES) == {"found", "not_found", "other"} + + +# ── Blob parsing ────────────────────────────────────────────────────────────── + +class TestParseBlob: + def test_splits_keys_custom_and_where(self): + gf = parse_filter_blob("F", _blob({"chkFound": "True", "edtDesc": "abc"}, + where="Found = 1", + custom=["MyCol;bool;;0;1"])) + assert gf.flag("chkFound") is True + assert gf.text("edtDesc") == "abc" + assert gf.custom == ["MyCol;bool;;0;1"] + assert gf.where == "Found = 1" + + def test_missing_keys_are_none(self): + gf = parse_filter_blob("F", _blob({})) + assert gf.flag("nope") is None and gf.num("nope") is None + assert gf.text("nope") == "" + + +# ── Native conversions ──────────────────────────────────────────────────────── + +class TestNativeConversions: + def test_found_and_availability(self): + c = _convert({"chkFound": "True", "chkNotFound": "False", + "chkAvailable": "True", "chkTempUnavailable": "False", + "chkArchivedOnly": "False"}) + assert _statuses(c) == {"Found by me": NATIVE, "Availability": NATIVE} + assert [f.filter_type for f in c.filters] == ["found", "availability"] + + def test_text_field_keeps_gsak_operator(self): + # GSAK index 3 is "Ungleich" — OpenSAK's text rows have that operator, + # so nothing has to fall back to SQL. + c = _convert({"cbxOwnerName": "3", "edtOwnerName": "Vyrembi"}) + assert _statuses(c) == {"Owner": NATIVE} + assert (c.filters[0].text, c.filters[0].op) == ("Vyrembi", "not_equals") + + def test_regex_text_field_stays_native(self): + c = _convert({"cbxDesc": "7", "edtDesc": "^AL[0-9]+"}) + assert c.filters[0].op == "regex" + assert c.coverage == 1.0 + + def test_cache_types_partially_unmappable(self): + types = {label: "False" for label in gfi.GSAK_TYPE_LABEL_TO_OSAK} + types["Traditional"] = "True" + types["Waymark"] = "True" # no OpenSAK equivalent + c = _convert(types) + statuses = _statuses(c) + assert statuses["Cache types"] == NATIVE + assert c.filters[0].types == ["Traditional Cache"] + assert any(s == COMMENT for s in statuses.values()) + assert c.coverage == 0.5 + + def test_all_types_ticked_is_no_criterion(self): + c = _convert({label: "True" for label in gfi.GSAK_TYPE_LABEL_TO_OSAK}) + assert c.criteria == [] + + def test_dates_become_native_date_filters(self): + # 44927 = 2023-01-01 as a Delphi TDateTime serial. + c = _convert({"cbxPlaced": "1", "edtDateP": "44927", "edtDateP2": "44927"}) + assert _statuses(c) == {"Hidden date": NATIVE} + f = c.filters[0] + assert (f.field, f.op, f.date1.isoformat()) == \ + ("hidden_date", "on_or_after", "2023-01-01") + + def test_rolling_window_date(self): + c = _convert({"cbxChange": "4", "edtChangeDuring": "30", "cbxChangeDuring": "0"}) + f = c.filters[0] + assert (f.op, f.amount, f.unit) == ("during", 30, "days") + + def test_rolling_window_without_size_is_commented(self): + c = _convert({"cbxChange": "4", "edtChangeDuring": "", "cbxChangeDuring": "0"}) + assert _statuses(c) == {"Last changed date": COMMENT} + + def test_polygon_becomes_line_polygon_filter(self): + c = _convert({"rbtPoly": "True", + "ArcFilter": "~47.0,8.0~48.0,8.0~48.0,9.0~47.0,8.0"}) + f = c.filters[0] + assert f.filter_type == "line_polygon" and f.mode == "polygon" + # GSAK repeats the first vertex to close the ring; OpenSAK closes it itself. + assert f.points == [(47.0, 8.0), (48.0, 8.0), (48.0, 9.0)] + assert c.coverage == 1.0 + + def test_degenerate_polygon_is_commented_not_crashing(self): + c = _convert({"rbtPoly": "True", "ArcFilter": "~47.0,8.0"}) + assert list(_statuses(c).values()) == [COMMENT] + + def test_log_tab_becomes_log_filter(self): + # GSAK's own "last 2 logs are DNF" standard filter. + c = _convert({ + "chkLogFound": "False", "chkLogNotFound": "True", + "chkLogSearchFound": "True", "chkLogSearchNotFound": "True", + "chkLogSearchNote": "False", + "cbxLogsToSearch": "2", "cbxLogCount": "2", "edtLogFrom": "2", + "cbxLogDate": "6", "rbtId": "True", "edtGeoName": "", + "LtDidn't find it": "True", "LtNeeds Archived": "True", + }) + assert list(_statuses(c).values()) == [NATIVE] + f = c.filters[0] + assert f.filter_type == "log" + assert f.types == ["Didn't find it", "Needs Archived"] + assert f.categories == ["found", "not_found"] + assert (f.last_n, f.count_op, f.count1) == (2, "at_least", 2) + + def test_waypoint_tab_becomes_waypoint_filter(self): + c = _convert({"cbxcCount": "0", "cbxCtype2": "1", "cbxctype": "0", + "edtctype": "", "cbxcDate": "6"}) + f = c.filters[0] + assert f.filter_type == "waypoint" + assert f.texts["wp_type"].text == "Parking Area" + + def test_child_waypoint_yes_no_folds_into_waypoint_filter(self): + c = _convert({"chkChildYes": "False", "chkChildNo": "True"}) + f = c.filters[0] + assert (f.filter_type, f.count_op, f.count1) == ("waypoint", "equal", 0) + + +# ── SQL fallbacks ───────────────────────────────────────────────────────────── + +class TestSqlFallbacks: + def test_watch_list_has_no_gui_filter(self): + c = _convert({"chkWatchYes": "True", "chkWatchNo": "False"}) + assert _statuses(c) == {"Watch list": SQL} + assert c.sql_parts == [("Watch list", "coalesce(watch, 0) = 1")] + assert c.coverage == 1.0 # SQL still counts as migrated + + def test_user_data_column(self): + c = _convert({"cbxUserData": "0", "edtUserData": "solved"}) + label, sql = c.sql_parts[0] + assert label == "User data 1" + assert "user_data_1" in sql and "'%solved%'" in sql + + def test_user_data_regex_cannot_run_anywhere(self): + c = _convert({"cbxUserData": "7", "edtUserData": "^x"}) + assert _statuses(c) == {"User data 1": COMMENT} + + def test_elevation_range(self): + c = _convert({"cbxElevation": "4", "edtElevation": "1000", + "edtElevation2": "2000"}) + assert c.sql_parts == [("Elevation", "elevation between 1000 and 2000")] + + +# ── Where clause ────────────────────────────────────────────────────────────── + +class TestGsakWhereClause: + def test_translatable_clause_runs(self): + c = _convert({}, where="UserFlag = '1' and cachetype = 'T'") + assert _statuses(c) == {"GSAK where clause": SQL} + sql = c.sql_parts[0][1] + assert "user_flag = 1" in sql and "cache_type = 'Traditional Cache'" in sql + + def test_untranslatable_clause_is_commented_never_run(self): + c = _convert({}, where="FavPerc > 70") + assert _statuses(c) == {"GSAK where clause": COMMENT} + assert c.sql_parts == [] + text = c.where_text() + assert "FavPerc" in text + # Every line of it is inert except the trailing no-op condition. + body = [ln for ln in text.splitlines() if not ln.startswith("--")] + assert body == ["1 = 1"] + + def test_custom_column_is_reported_by_name(self): + c = _convert({}, where="MyCol = 1", custom=["MyCol;bool;;0;1"]) + labels = [crit.label for crit in c.criteria] + assert any("MyCol" in label for label in labels) + + +# ── The Where tab's text ────────────────────────────────────────────────────── + +class TestWhereText: + def test_comments_precede_sql_so_the_clause_stays_valid(self): + c = Conversion("F") + c.sql("Watch list", "coalesce(watch, 0) = 1") + c.comment("Something", "cannot be expressed") + lines = c.where_text().splitlines() + assert lines[0].startswith("--") + assert not lines[-1].startswith("--") # SQL must have the last word + + def test_several_sql_parts_are_anded(self): + c = Conversion("F") + c.sql("A", "a = 1") + c.sql("B", "b = 2") + assert [ln for ln in c.where_text().splitlines() if not ln.startswith("--")] \ + == ["(a = 1)", "AND (b = 2)"] + + def test_no_criteria_means_no_where_clause(self): + fs = Conversion("F").build_filterset() + assert len(fs) == 0 + + def test_generated_sql_is_valid_against_the_real_schema(self, db_session): + c = Conversion("F") + c.sql("Watch list", "coalesce(watch, 0) = 1") + c.comment("Custom column \"x\"", "OpenSAK has no custom columns") + assert validate_where_sql(db_session, c.where_text()) is None + + def test_comment_only_clause_is_valid_and_matches_everything(self, db_session): + c = Conversion("F") + c.comment("Custom column \"x\"", "OpenSAK has no custom columns") + assert validate_where_sql(db_session, c.where_text()) is None + + +# ── Coverage ────────────────────────────────────────────────────────────────── + +class TestCoverage: + def test_all_native_is_full_coverage(self): + c = Conversion("F") + c.native("A", object()) + assert c.coverage == 1.0 and c.not_migrated == 0 + + def test_all_commented_is_zero_coverage(self): + c = Conversion("F") + c.comment("A", "nope") + c.comment("B", "nope") + assert c.coverage == 0.0 + + def test_mixed_coverage_counts_sql_as_migrated(self): + c = Conversion("F") + c.native("A", object()) + c.sql("B", "b = 1") + c.comment("C", "nope") + assert c.coverage == pytest.approx(2 / 3) + + def test_empty_filter_counts_as_fully_migrated(self): + assert Conversion("F").coverage == 1.0 + + +# ── End to end ──────────────────────────────────────────────────────────────── + +class TestImportGsakFilters: + def test_writes_profiles_and_totals_coverage(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "Found": _blob({"chkFound": "True", "chkNotFound": "False"}), + "Custom": _blob({}, custom=["MyCol;bool;;0;1"]), + }) + out = tmp_path / "filters" + result = import_gsak_filters(db, out_dir=out, opts=Options()) + + assert result.written == 2 and result.failed == 0 + assert {p.name for p in out.glob("*.json")} == {"Found.json", "Custom.json"} + assert (result.native_criteria, result.commented_criteria) == (1, 1) + assert result.coverage == 0.5 + assert (result.fully_migrated, result.not_migrated) == (1, 1) + + def test_written_profiles_load_back_unchanged(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "Mixed": _blob({"chkFound": "True", "chkNotFound": "False", + "chkWatchYes": "True", "chkWatchNo": "False"}), + }) + out = tmp_path / "filters" + import_gsak_filters(db, out_dir=out, opts=Options()) + data = json.loads((out / "Mixed.json").read_text(encoding="utf-8")) + fs = FilterSet.from_dict(data["filterset"]) + assert len(fs) == len(data["filterset"]["filters"]) + assert fs.to_dict() == data["filterset"] + + def test_selected_names_only(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "A": _blob({"chkFound": "True", "chkNotFound": "False"}), + "B": _blob({"chkFound": "True", "chkNotFound": "False"}), + }) + out = tmp_path / "filters" + result = import_gsak_filters(db, out_dir=out, names=["B"], opts=Options()) + assert result.written == 1 + assert [p.name for p in out.glob("*.json")] == ["B.json"] + + def test_existing_profile_is_skipped_unless_overwriting(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "A": _blob({"chkFound": "True", "chkNotFound": "False"}), + }) + out = tmp_path / "filters" + out.mkdir() + (out / "A.json").write_text("keep me", encoding="utf-8") + + result = import_gsak_filters(db, out_dir=out, opts=Options()) + assert (result.written, result.skipped) == (0, 1) + assert (out / "A.json").read_text(encoding="utf-8") == "keep me" + # Skipped filters are left out of the statistics entirely. + assert result.total_criteria == 0 + + result = import_gsak_filters(db, out_dir=out, overwrite=True, opts=Options()) + assert (result.written, result.skipped) == (1, 0) + assert "filterset" in (out / "A.json").read_text(encoding="utf-8") + + def test_names_colliding_on_disk_get_separate_files(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "A/B": _blob({"chkFound": "True", "chkNotFound": "False"}), + "A*B": _blob({"chkFound": "True", "chkNotFound": "False"}), + }) + out = tmp_path / "filters" + import_gsak_filters(db, out_dir=out, opts=Options()) + assert {p.name for p in out.glob("*.json")} == {"A_B.json", "A_B_2.json"} + + def test_one_broken_filter_does_not_stop_the_others(self, tmp_path, monkeypatch): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "Bad": _blob({"chkFound": "True", "chkNotFound": "False"}), + "Good": _blob({"chkFound": "True", "chkNotFound": "False"}), + }) + real_convert = gfi.convert + + def explode(gf, opts): + if gf.name == "Bad": + raise RuntimeError("boom") + return real_convert(gf, opts) + + monkeypatch.setattr(gfi, "convert", explode) + result = import_gsak_filters(db, out_dir=tmp_path / "filters", opts=Options()) + assert (result.written, result.failed) == (1, 1) + assert "boom" in next(e.error for e in result.entries if e.name == "Bad") + + def test_progress_is_reported_per_filter(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", { + "A": _blob({}), "B": _blob({}), + }) + seen = [] + import_gsak_filters(db, out_dir=tmp_path / "filters", + opts=Options(), progress_cb=lambda d, t: seen.append((d, t))) + assert seen == [(1, 2), (2, 2)] + + +# ── Source selection ────────────────────────────────────────────────────────── + +class TestSource: + def test_a_cache_database_is_rejected_with_a_clear_error(self, tmp_path): + path = tmp_path / "sqlite.db3" + sqlite3.connect(path).close() + with pytest.raises(GsakFilterSourceError): + load_gsak_filters(path) + + def test_zip_backup_is_unpacked(self, tmp_path): + import zipfile + db = _make_gsak_db(tmp_path / "gsak.db3", {"A": _blob({})}) + archive = tmp_path / "backup.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.write(db, "GSAK/gsak.db3") + assert find_gsak_filter_db(archive).name == "gsak.db3" + + def test_zip_without_gsak_db3_is_rejected(self, tmp_path): + import zipfile + archive = tmp_path / "backup.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("readme.txt", "nothing here") + with pytest.raises(GsakFilterSourceError): + find_gsak_filter_db(archive) + + def test_filters_are_listed_alphabetically(self, tmp_path): + db = _make_gsak_db(tmp_path / "gsak.db3", + {"zulu": _blob({}), "Alpha": _blob({})}) + assert [name for name, _ in load_gsak_filters(db)] == ["Alpha", "zulu"] From a1b9d4db5e8f13453a0bf72984411d487ce7fb4c Mon Sep 17 00:00:00 2001 From: nagisml Date: Sun, 27 Sep 2026 22:16:37 +0200 Subject: [PATCH 2/5] GSAK filter import: use the new native filter rows Map GSAK criteria that previously fell back to Where-SQL or comments onto the filter rows now available on beta: - User data 1-4 -> UserData1-4Filter (regex operators now run too) - User note yes/no -> UserNoteFilter (not_empty / empty) - Elevation -> ElevationFilter - Compass quadrants -> DirectionFilter - Open-ended/equal distance -> DistanceFilter op at_least / equal - GSAK "reverse filter" -> FilterSet negate (global Invert filter) Co-Authored-By: Claude Opus 5.5 --- docs/filters.md | 4 +- src/opensak/importer/gsak_filter_importer.py | 104 +++++++++--------- .../test_gsak_filter_import_dialog.py | 48 ++++++++ tests/unit-tests/test_gsak_filter_importer.py | 83 +++++++++++++- 4 files changed, 180 insertions(+), 59 deletions(-) diff --git a/docs/filters.md b/docs/filters.md index b20cdf40..79b55f91 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -476,8 +476,8 @@ Every condition in a GSAK filter lands in one of three places: | | What it means | Counts as migrated | |---|---|---| -| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators | yes | -| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, elevation, bearing, user data 1–4, compass quadrants, TB/coin names | yes | +| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, compass quadrants (as *Direction*), and GSAK's *reverse filter* (as *Invert filter*) | yes | +| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, bearing, user sort, TB/coin names | yes | | **A comment in the Where tab** | Nothing in OpenSAK can express it | no | The third case is why the Where tab of an imported filter often opens with a block of `--` lines. They do nothing; they are there so you can see exactly what GSAK filtered on and rebuild it yourself. A typical one looks like this: diff --git a/src/opensak/importer/gsak_filter_importer.py b/src/opensak/importer/gsak_filter_importer.py index 69d36eb5..24b50cdb 100644 --- a/src/opensak/importer/gsak_filter_importer.py +++ b/src/opensak/importer/gsak_filter_importer.py @@ -55,8 +55,10 @@ CountyFilter, DateFilter, DifficultyFilter, + DirectionFilter, DistanceFilter, DnfFilter, + ElevationFilter, FavoritePointsFilter, FilterProfile, FilterSet, @@ -78,7 +80,12 @@ StateFilter, TerrainFilter, TextSearchFilter, + UserData1Filter, + UserData2Filter, + UserData3Filter, + UserData4Filter, UserFlagFilter, + UserNoteFilter, WaypointFilter, WhereClauseFilter, ) @@ -249,18 +256,17 @@ "Virtual Stage", ) -# GSAK compass-quadrant checkbox → the bearing range it covers, in degrees -# clockwise from north. The eight 45° sectors are centred on their compass -# point, so N spans 337.5°–22.5° and wraps around 0. -_QUADRANTS: dict[str, tuple[float, float]] = { - "cbxN": (337.5, 22.5), - "cbxNe": (22.5, 67.5), "cbxNE": (22.5, 67.5), - "cbxE": (67.5, 112.5), - "cbxSE": (112.5, 157.5), - "cbxS": (157.5, 202.5), - "cbxSW": (202.5, 247.5), - "cbxW": (247.5, 292.5), - "cbxNW": (292.5, 337.5), +# GSAK compass-quadrant checkbox → OpenSAK DirectionFilter code. Both use the +# eight 45° sectors centred on their compass point (N = 337.5°–22.5°). +_QUADRANTS: dict[str, str] = { + "cbxN": "N", + "cbxNe": "NE", "cbxNE": "NE", + "cbxE": "E", + "cbxSE": "SE", + "cbxS": "S", + "cbxSW": "SW", + "cbxW": "W", + "cbxNW": "NW", } @@ -1067,6 +1073,7 @@ def __init__(self, name: str): # (label, reason, extra body lines) — what where_text() writes out self.comment_lines: list[tuple[str, str, list[str]]] = [] self.notes: list[str] = [] # assumptions worth keeping + self.negate = False # GSAK's "reverse filter" @property def comments(self) -> list[tuple[str, str]]: @@ -1167,7 +1174,7 @@ def where_text(self) -> str: return "\n".join(lines) def build_filterset(self) -> FilterSet: - fs = FilterSet(mode="AND") + fs = FilterSet(mode="AND", negate=self.negate) for f in self.filters: fs.add(f) where = self.where_text() @@ -1579,13 +1586,14 @@ def _convert_text_fields(gf: GsakFilter, c: Conversion) -> None: _text_criterion(gf, c, op_key, val_key, label, cls=cls) # GSAK's four free-text user fields map 1:1 onto OpenSAK's user_data_1-4 - # columns, but OpenSAK has no filter row for them — hence SQL. - for n, (op_key, val_key) in enumerate( - (("cbxUserData", "edtUserData"), ("cbxUser2", "EdtUser2"), - ("cbxUser3", "edtUser3"), ("cbxUser4", "edtUser4")), start=1 + # columns, each with its own row on the Other tab. + for n, (op_key, val_key, ud_cls) in enumerate( + (("cbxUserData", "edtUserData", UserData1Filter), + ("cbxUser2", "EdtUser2", UserData2Filter), + ("cbxUser3", "edtUser3", UserData3Filter), + ("cbxUser4", "edtUser4", UserData4Filter)), start=1 ): - _text_criterion(gf, c, op_key, val_key, f"User data {n}", - column=f"user_data_{n}") + _text_criterion(gf, c, op_key, val_key, f"User data {n}", cls=ud_cls) def _convert_favorites(gf: GsakFilter, c: Conversion) -> None: @@ -1633,29 +1641,27 @@ def _convert_distance(gf: GsakFilter, c: Conversion, opts: Options) -> None: return lo_km, hi_km = min(d1, d2), max(d1, d2) - if hi_km is None: - # Open-ended ("at least X") — the GUI's distance row always has an - # upper bound, so this goes to SQL on the `distance` pseudo-column, - # which OpenSAK rewrites to a haversine call against the ACTIVE centre - # point (closer to GSAK, which never froze a centre either). - c.sql("Distance", f"distance >= {round(lo_km / factor, 3)}") - c.note(f"Distance: \"at least {v1} {unit}\" has no upper bound, so it became SQL on " - f"the `distance` column, which OpenSAK measures from the active centre point " - f"in your own unit — rescale the number if your unit is not {unit}") - return - if opts.center is None: - c.sql("Distance", f"distance between {round(lo_km / factor, 3)} " - f"and {round(hi_km / factor, 3)}") + c.sql("Distance", f"distance >= {round(lo_km / factor, 3)}" if hi_km is None + else f"distance between {round(lo_km / factor, 3)} " + f"and {round(hi_km / factor, 3)}") c.note(f"Distance: no centre point was available, so the radius became SQL on the " f"`distance` column, which OpenSAK measures from the active centre point in " f"your own unit — rescale the numbers if your unit is not {unit}") return lat, lon = opts.center + if hi_km is None: + op, dist1_km, dist2_km = "at_least", lo_km, 0.0 + elif bounds_op == "equals": + op, dist1_km, dist2_km = "equal", lo_km, 0.0 + elif lo_km > 0: + op, dist1_km, dist2_km = "between", lo_km, hi_km + else: + op, dist1_km, dist2_km = "at_most", hi_km, 0.0 c.native("Distance", DistanceFilter( lat=round(lat, 6), lon=round(lon, 6), - max_km=round(hi_km, 3), min_km=round(lo_km, 3), + op=op, dist1_km=round(dist1_km, 3), dist2_km=round(dist2_km, 3), center_state={"kind": "home"}, )) c.note(f"Distance: GSAK stores only the radius ({v1} {unit}) and measures it from " @@ -1702,9 +1708,7 @@ def _convert_flags(gf: GsakFilter, c: Conversion) -> None: note = _tri_flag(gf, "chkNoteYes", "chkNoteNo") if note is not None: - op = "<>" if note else "=" - c.sql("User note", f"coalesce((SELECT note FROM user_notes " - f"WHERE user_notes.cache_id = caches.id), '') {op} ''") + c.native("User note", UserNoteFilter(op="not_empty" if note else "empty")) # OpenSAK does store the watch list (caches.watch, filled from GSAK's own # Watch column by gsak_importer.py) but has no GUI filter for it. @@ -2097,9 +2101,15 @@ def _text(op_key: str, val_key: str, field: str, label: str) -> None: def _convert_misc(gf: GsakFilter, c: Conversion) -> None: + bounds = _num_bounds(gf, c, "Elevation", "cbxElevation", "edtElevation", "edtElevation2") + if bounds is not None: + op, v1, v2 = bounds + lo, hi = {"at_most": (-500, v1), "at_least": (v1, 9000), + "equals": (v1, v1)}.get(op, (v1, v2)) + c.native("Elevation", ElevationFilter(min_m=lo, max_m=hi)) + # Numeric criteria OpenSAK stores as a column but has no filter row for. for label, op_key, v1_key, v2_key, column, extra in ( - ("Elevation", "cbxElevation", "edtElevation", "edtElevation2", "elevation", ""), ("User sort", "cbxUsort", "edtUsort", "edtUsort2", "user_sort", ""), # GSAK's FoundCount turned out to be a 0/1 "found by me" flag rather # than a community find count (verified by the cache importer against a @@ -2134,29 +2144,21 @@ def _convert_misc(gf: GsakFilter, c: Conversion) -> None: _convert_quadrants(gf, c) if gf.flag("chkReverse", False): - c.comment("Reverse filter", ( - "GSAK's \"reverse filter\" inverts the whole result. OpenSAK cannot negate a " - "filter set, so everything above matches the NON-inverted criteria" - )) + # The filter dialog's global "Invert filter" (FilterSet.negate). + c.negate = True + c.criteria.append(Criterion("Reverse filter", NATIVE)) def _convert_quadrants(gf: GsakFilter, c: Conversion) -> None: - """Compass-quadrant tick boxes → a bearing range in SQL.""" + """Compass-quadrant tick boxes → a DirectionFilter.""" present = [k for k in _QUADRANTS if k in gf.kv] if not present: return selected = [k for k in present if gf.flag(k)] if len(selected) == len(present) or not selected: return # all ticked (or none recorded) = no restriction - tests: list[str] = [] - for key in selected: - lo, hi = _QUADRANTS[key] - tests.append(f"(bearing >= {lo} OR bearing < {hi})" if lo > hi - else f"(bearing >= {lo} AND bearing < {hi})") - c.sql("Compass quadrants (" + ", ".join(k[3:] for k in selected) + ")", - "bearing IS NOT NULL AND (" + " OR ".join(tests) + ")") - c.note("Compass quadrants: assumed to be the eight 45° sectors centred on their compass " - "point (N = 337.5°-22.5°), measured from the ACTIVE centre point") + c.native("Compass quadrants", DirectionFilter([_QUADRANTS[k] for k in selected])) + c.note("Compass quadrants: measured from the ACTIVE centre point, like GSAK") def _trackable_name_criterion(gf: GsakFilter, c: Conversion) -> None: diff --git a/tests/unit-tests/test_gsak_filter_import_dialog.py b/tests/unit-tests/test_gsak_filter_import_dialog.py index 90364f18..d7524fc5 100644 --- a/tests/unit-tests/test_gsak_filter_import_dialog.py +++ b/tests/unit-tests/test_gsak_filter_import_dialog.py @@ -239,3 +239,51 @@ def test_format_result_caps_long_listings(self): listed = [ln for ln in text.splitlines() if ln.strip().startswith("F0")] assert len(listed) == fdlg.MAX_LISTED_FILTERS assert f"… {60 - fdlg.MAX_LISTED_FILTERS}" in text + + +# ── Imported profiles survive the Set Filter dialog ─────────────────────────── + +def test_imported_rows_round_trip_through_filter_dialog(qtbot, monkeypatch): + """Every criterion the importer now maps onto a native row (user data, + user note, elevation, direction, open distance, reverse) must load into + the Set Filter dialog and come back out unchanged.""" + from types import SimpleNamespace + + from opensak.gui.dialogs import filter_dialog as fd + from opensak.importer.gsak_filter_importer import convert, parse_filter_blob + from opensak.utils.types import CoordFormat, DateFormat + + monkeypatch.setattr(fd.FilterProfile, "list_profiles", staticmethod(lambda: [])) + monkeypatch.setattr("opensak.gui.settings.get_settings", + lambda: SimpleNamespace(home_lat=47.0, home_lon=8.0, use_miles=False, + date_format=DateFormat.YMD, + coord_format=CoordFormat.DD, home_points=[], + theme="light")) + quadrants = {k: "False" for k in ("cbxN", "cbxNE", "cbxE", "cbxSE", + "cbxS", "cbxSW", "cbxW", "cbxNW")} + quadrants.update(cbxN="True", cbxE="True") + blob = _blob({ + "cbxUserData": "0", "edtUserData": "solved", + "cbxUser3": "7", "edtUser3": "^GC", + "chkNoteYes": "True", "chkNoteNo": "False", + "cbxElevation": "4", "edtElevation": "500", "edtElevation2": "1500", + "cbxDistance": "2", "edtDistance": "10", + "chkReverse": "True", + **quadrants, + }) + c = convert(parse_filter_blob("T", blob), Options(center=(47.0, 8.0))) + fs = c.build_filterset() + + d = fd.FilterDialog(current_filterset=fs) + qtbot.addWidget(d) + rebuilt = d._build_filterset() + + def by_type(f_set): + return {f["filter_type"]: f for f in f_set.to_dict()["filters"] + if f.get("filter_type") in ("user_data_1", "user_data_3", "user_note", + "elevation", "direction", "distance")} + + assert rebuilt.negate is True + assert by_type(rebuilt) == by_type(fs) + assert set(by_type(fs)) == {"user_data_1", "user_data_3", "user_note", + "elevation", "direction", "distance"} diff --git a/tests/unit-tests/test_gsak_filter_importer.py b/tests/unit-tests/test_gsak_filter_importer.py index 7ed69f22..d4418565 100644 --- a/tests/unit-tests/test_gsak_filter_importer.py +++ b/tests/unit-tests/test_gsak_filter_importer.py @@ -217,20 +217,91 @@ def test_watch_list_has_no_gui_filter(self): assert c.sql_parts == [("Watch list", "coalesce(watch, 0) = 1")] assert c.coverage == 1.0 # SQL still counts as migrated + def test_distance_without_centre_stays_sql(self): + c = _convert({"cbxDistance": "2", "edtDistance": "5"}) + assert c.sql_parts == [("Distance", "distance >= 5.0")] + + +# ── Criteria that used to need SQL and now have a filter row ───────────────── + +class TestNewNativeRows: def test_user_data_column(self): c = _convert({"cbxUserData": "0", "edtUserData": "solved"}) - label, sql = c.sql_parts[0] - assert label == "User data 1" - assert "user_data_1" in sql and "'%solved%'" in sql + assert _statuses(c) == {"User data 1": NATIVE} + f = c.filters[0] + assert (f.filter_type, f.text, f.op) == ("user_data_1", "solved", "contains") + + def test_user_data_4(self): + c = _convert({"cbxUser4": "0", "edtUser4": "x"}) + assert c.filters[0].filter_type == "user_data_4" - def test_user_data_regex_cannot_run_anywhere(self): + def test_user_data_regex_now_runs(self): c = _convert({"cbxUserData": "7", "edtUserData": "^x"}) - assert _statuses(c) == {"User data 1": COMMENT} + assert _statuses(c) == {"User data 1": NATIVE} + assert c.filters[0].op == "regex" def test_elevation_range(self): c = _convert({"cbxElevation": "4", "edtElevation": "1000", "edtElevation2": "2000"}) - assert c.sql_parts == [("Elevation", "elevation between 1000 and 2000")] + assert _statuses(c) == {"Elevation": NATIVE} + f = c.filters[0] + assert (f.filter_type, f.min_m, f.max_m) == ("elevation", 1000, 2000) + + def test_elevation_at_least(self): + c = _convert({"cbxElevation": "2", "edtElevation": "1500"}) + f = c.filters[0] + assert (f.min_m, f.max_m) == (1500, 9000) + + @pytest.mark.parametrize("yes,no,op", [("True", "False", "not_empty"), + ("False", "True", "empty")]) + def test_user_note_yes_no(self, yes, no, op): + c = _convert({"chkNoteYes": yes, "chkNoteNo": no}) + assert _statuses(c) == {"User note": NATIVE} + f = c.filters[0] + assert (f.filter_type, f.op) == ("user_note", op) + + def test_compass_quadrants_become_direction_filter(self): + pairs = {k: "False" for k in ("cbxN", "cbxNE", "cbxE", "cbxSE", + "cbxS", "cbxSW", "cbxW", "cbxNW")} + pairs.update(cbxN="True", cbxNW="True") + c = _convert(pairs) + assert _statuses(c) == {"Compass quadrants": NATIVE} + f = c.filters[0] + assert (f.filter_type, f.directions) == ("direction", ["N", "NW"]) + + def test_all_quadrants_ticked_is_no_restriction(self): + c = _convert({k: "True" for k in ("cbxN", "cbxNE", "cbxE", "cbxSE", + "cbxS", "cbxSW", "cbxW", "cbxNW")}) + assert c.criteria == [] + + def test_reverse_filter_negates_filterset(self): + c = _convert({"chkFound": "False", "chkNotFound": "True", "chkReverse": "True"}) + assert _statuses(c)["Reverse filter"] == NATIVE + fs = c.build_filterset() + assert fs.negate is True + assert FilterSet.from_dict(fs.to_dict()).negate is True + + def test_no_reverse_leaves_filterset_positive(self): + c = _convert({"chkFound": "False", "chkNotFound": "True"}) + assert "negate" not in c.build_filterset().to_dict() + + @pytest.mark.parametrize("op_idx,v1,v2,op,d1,d2", [ + ("1", "5", "", "at_most", 5.0, 0.0), + ("2", "5", "", "at_least", 5.0, 0.0), + ("3", "5", "", "equal", 5.0, 0.0), + ("4", "2", "7", "between", 2.0, 7.0), + ]) + def test_distance_ops(self, op_idx, v1, v2, op, d1, d2): + c = _convert({"cbxDistance": op_idx, "edtDistance": v1, "edtDistance2": v2}, + opts=Options(center=(47.0, 8.0))) + assert _statuses(c) == {"Distance": NATIVE} + f = c.filters[0] + assert (f.filter_type, f.op, f.dist1_km, f.dist2_km) == ("distance", op, d1, d2) + + def test_distance_in_miles_is_converted(self): + c = _convert({"cbxDistance": "2", "edtDistance": "1"}, + opts=Options(center=(47.0, 8.0), miles=True)) + assert c.filters[0].dist1_km == pytest.approx(1.609, abs=1e-3) # ── Where clause ────────────────────────────────────────────────────────────── From 8471fc7247ddda793a70be232acd1814da2f9ef8 Mon Sep 17 00:00:00 2001 From: nagisml Date: Sun, 27 Sep 2026 22:32:49 +0200 Subject: [PATCH 3/5] GSAK filter import: map TB names and "no trackables" to TrackableFilter - TB/coin name -> Trackables tab name row (all text operators, regex included) - "has no trackables" -> TrackableFilter count equal 0 - A TB name combined with "no trackables" can never match; the name is left as a comment so the dialog's single trackable row stays intact Co-Authored-By: Claude Opus 5.5 --- docs/filters.md | 4 +-- src/opensak/importer/gsak_filter_importer.py | 25 ++++++++----------- .../test_gsak_filter_import_dialog.py | 8 +++--- tests/unit-tests/test_gsak_filter_importer.py | 20 +++++++++++++++ 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/filters.md b/docs/filters.md index 79b55f91..55a96fbb 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -476,8 +476,8 @@ Every condition in a GSAK filter lands in one of three places: | | What it means | Counts as migrated | |---|---|---| -| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, compass quadrants (as *Direction*), and GSAK's *reverse filter* (as *Invert filter*) | yes | -| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, bearing, user sort, TB/coin names | yes | +| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, compass quadrants (as *Direction*), TB/coin names and "no trackables" (on the *Trackables* tab), and GSAK's *reverse filter* (as *Invert filter*) | yes | +| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, a bearing range in degrees, user sort | yes | | **A comment in the Where tab** | Nothing in OpenSAK can express it | no | The third case is why the Where tab of an imported filter often opens with a block of `--` lines. They do nothing; they are there so you can see exactly what GSAK filtered on and rebuild it yourself. A typical one looks like this: diff --git a/src/opensak/importer/gsak_filter_importer.py b/src/opensak/importer/gsak_filter_importer.py index 24b50cdb..19fb4260 100644 --- a/src/opensak/importer/gsak_filter_importer.py +++ b/src/opensak/importer/gsak_filter_importer.py @@ -80,6 +80,7 @@ StateFilter, TerrainFilter, TextSearchFilter, + TrackableFilter, UserData1Filter, UserData2Filter, UserData3Filter, @@ -1702,9 +1703,8 @@ def _convert_flags(gf: GsakFilter, c: Conversion) -> None: if trackables is True: c.native("Has trackables", HasTrackableFilter()) elif trackables is False: - c.sql("Has trackables", "coalesce(trackable_count, 0) = 0") - c.note("Trackables: GSAK filtered to caches WITHOUT trackables; OpenSAK's GUI only " - "offers \"has trackables\", so the negative became SQL on trackable_count") + # The Trackables tab's count row: "equal 0" = no trackables at all. + c.native("Has trackables", TrackableFilter(count_op="equal", count1=0)) note = _tri_flag(gf, "chkNoteYes", "chkNoteNo") if note is not None: @@ -2162,7 +2162,7 @@ def _convert_quadrants(gf: GsakFilter, c: Conversion) -> None: def _trackable_name_criterion(gf: GsakFilter, c: Conversion) -> None: - """GSAK's TB/coin name box → an EXISTS on OpenSAK's trackables table.""" + """GSAK's TB/coin name box → the Trackables tab's name row.""" raw_op = gf.num("cbxTbugName") or 0 op = TEXT_OP.get(raw_op) value = gf.text("edtTbugName") @@ -2173,20 +2173,17 @@ def _trackable_name_criterion(gf: GsakFilter, c: Conversion) -> None: return if not value and op not in TEXT_OP_VALUELESS: return - if op in TEXT_OP_NO_SQL: + if _tri_flag(gf, "cbxBugs", "chkBugNo") is False: + # The filter dialog holds one trackable row; "no trackables" already + # took it, and no cache can pass both conditions anyway. c.comment("TB/coin name", ( - f"GSAK matched \"{value}\" with a regular expression; OpenSAK keeps trackables in " - f"their own table and its SQLite connection registers no REGEXP operator" + f"GSAK also required the cache to have NO trackables, so the name test " + f"(\"{value}\") could never match and was dropped" )) return - test = _sql_text_test("t.name", op, value) - if test is None: - c.comment("TB/coin name", f"GSAK comparison \"{op}\" could not be expressed in SQL") - return - c.sql("TB/coin name", - f"EXISTS (SELECT 1 FROM trackables t WHERE t.cache_id = caches.id AND {test})") + c.native("TB/coin name", TrackableFilter(texts={"name": (value, op)})) c.note("TB/coin name: GSAK matched the cache's travel-bug list as one text field; " - "OpenSAK keeps trackables in their own table, so the test looks for any " + "OpenSAK keeps trackables in their own table, so the filter looks for any " "trackable whose name matches") diff --git a/tests/unit-tests/test_gsak_filter_import_dialog.py b/tests/unit-tests/test_gsak_filter_import_dialog.py index d7524fc5..0ce38a71 100644 --- a/tests/unit-tests/test_gsak_filter_import_dialog.py +++ b/tests/unit-tests/test_gsak_filter_import_dialog.py @@ -245,7 +245,7 @@ def test_format_result_caps_long_listings(self): def test_imported_rows_round_trip_through_filter_dialog(qtbot, monkeypatch): """Every criterion the importer now maps onto a native row (user data, - user note, elevation, direction, open distance, reverse) must load into + user note, elevation, direction, open distance, TB name, reverse) must load into the Set Filter dialog and come back out unchanged.""" from types import SimpleNamespace @@ -268,6 +268,7 @@ def test_imported_rows_round_trip_through_filter_dialog(qtbot, monkeypatch): "chkNoteYes": "True", "chkNoteNo": "False", "cbxElevation": "4", "edtElevation": "500", "edtElevation2": "1500", "cbxDistance": "2", "edtDistance": "10", + "cbxTbugName": "0", "edtTbugName": "coin", "chkReverse": "True", **quadrants, }) @@ -281,9 +282,10 @@ def test_imported_rows_round_trip_through_filter_dialog(qtbot, monkeypatch): def by_type(f_set): return {f["filter_type"]: f for f in f_set.to_dict()["filters"] if f.get("filter_type") in ("user_data_1", "user_data_3", "user_note", - "elevation", "direction", "distance")} + "elevation", "direction", "distance", + "trackable")} assert rebuilt.negate is True assert by_type(rebuilt) == by_type(fs) assert set(by_type(fs)) == {"user_data_1", "user_data_3", "user_note", - "elevation", "direction", "distance"} + "elevation", "direction", "distance", "trackable"} diff --git a/tests/unit-tests/test_gsak_filter_importer.py b/tests/unit-tests/test_gsak_filter_importer.py index d4418565..dec7a909 100644 --- a/tests/unit-tests/test_gsak_filter_importer.py +++ b/tests/unit-tests/test_gsak_filter_importer.py @@ -298,6 +298,26 @@ def test_distance_ops(self, op_idx, v1, v2, op, d1, d2): f = c.filters[0] assert (f.filter_type, f.op, f.dist1_km, f.dist2_km) == ("distance", op, d1, d2) + def test_no_trackables_is_trackable_count_zero(self): + c = _convert({"cbxBugs": "False", "chkBugNo": "True"}) + assert _statuses(c) == {"Has trackables": NATIVE} + f = c.filters[0] + assert (f.filter_type, f.count_op, f.count1, f.texts) == ("trackable", "equal", 0, {}) + + @pytest.mark.parametrize("op_idx", ["0", "7"]) # contains, regex + def test_tb_name_is_trackable_name_row(self, op_idx): + c = _convert({"cbxTbugName": op_idx, "edtTbugName": "coin"}) + assert _statuses(c) == {"TB/coin name": NATIVE} + f = c.filters[0] + assert f.filter_type == "trackable" and f.count_op == "any" + assert f.texts["name"].text == "coin" + + def test_tb_name_with_no_trackables_is_dropped(self): + c = _convert({"cbxBugs": "False", "chkBugNo": "True", + "cbxTbugName": "0", "edtTbugName": "coin"}) + assert _statuses(c) == {"Has trackables": NATIVE, "TB/coin name": COMMENT} + assert [f.filter_type for f in c.filters] == ["trackable"] + def test_distance_in_miles_is_converted(self): c = _convert({"cbxDistance": "2", "edtDistance": "1"}, opts=Options(center=(47.0, 8.0), miles=True)) From fd1d3c6910023fbb998266fd0658662d6ea043ad Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 09:28:09 +0200 Subject: [PATCH 4/5] Align with 1.20 and align with newest filter features --- docs/filters.md | 4 +- src/opensak/importer/gsak_filter_importer.py | 68 ++++++++++++------- tests/unit-tests/test_gsak_filter_importer.py | 36 +++++++++- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/docs/filters.md b/docs/filters.md index 55a96fbb..43f6b403 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -476,8 +476,8 @@ Every condition in a GSAK filter lands in one of three places: | | What it means | Counts as migrated | |---|---|---| -| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, compass quadrants (as *Direction*), TB/coin names and "no trackables" (on the *Trackables* tab), and GSAK's *reverse filter* (as *Invert filter*) | yes | -| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, a bearing range in degrees, user sort | yes | +| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, a bearing in degrees and compass quadrants (both as *Direction*), TB/coin names and "no trackables" (on the *Trackables* tab), and GSAK's *reverse filter* (as *Invert filter*) | yes | +| **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, user sort, a bearing in degrees next to compass quadrants (the dialog has only one *Direction* row) | yes | | **A comment in the Where tab** | Nothing in OpenSAK can express it | no | The third case is why the Where tab of an imported filter often opens with a block of `--` lines. They do nothing; they are there so you can see exactly what GSAK filtered on and rebuild it yourself. A typical one looks like this: diff --git a/src/opensak/importer/gsak_filter_importer.py b/src/opensak/importer/gsak_filter_importer.py index 19fb4260..3a24a166 100644 --- a/src/opensak/importer/gsak_filter_importer.py +++ b/src/opensak/importer/gsak_filter_importer.py @@ -176,7 +176,8 @@ 3: "equals", 4: "between", } -# The same operators under the names OpenSAK's log/waypoint count rows use. +# The same operators under the names OpenSAK's condition rows use — the +# log/waypoint counts, favourite points, elevation and bearing. NUM_OP_TO_COUNT_OP: dict[str, str] = { "at_most": "at_most", "at_least": "at_least", @@ -1603,15 +1604,7 @@ def _convert_favorites(gf: GsakFilter, c: Conversion) -> None: if bounds is None: return op, v1, v2 = bounds - if op == "at_most": - lo, hi = 0, v1 - elif op == "at_least": - lo, hi = v1, 9999 - elif op == "equals": - lo, hi = v1, v1 - else: - lo, hi = v1, v2 - c.native("Favourite points", FavoritePointsFilter(min_pts=lo, max_pts=hi)) + c.native("Favourite points", FavoritePointsFilter(NUM_OP_TO_COUNT_OP[op], v1, v2)) def _convert_distance(gf: GsakFilter, c: Conversion, opts: Options) -> None: @@ -2104,9 +2097,7 @@ def _convert_misc(gf: GsakFilter, c: Conversion) -> None: bounds = _num_bounds(gf, c, "Elevation", "cbxElevation", "edtElevation", "edtElevation2") if bounds is not None: op, v1, v2 = bounds - lo, hi = {"at_most": (-500, v1), "at_least": (v1, 9000), - "equals": (v1, v1)}.get(op, (v1, v2)) - c.native("Elevation", ElevationFilter(min_m=lo, max_m=hi)) + c.native("Elevation", ElevationFilter(NUM_OP_TO_COUNT_OP[op], v1, v2)) # Numeric criteria OpenSAK stores as a column but has no filter row for. for label, op_key, v1_key, v2_key, column, extra in ( @@ -2119,11 +2110,6 @@ def _convert_misc(gf: GsakFilter, c: Conversion) -> None: "My found count: GSAK's FoundCount is really a 0/1 found-by-me flag, while " "OpenSAK's found_log_count counts your own found-type logs, so it is >= 1 for " "exactly the caches GSAK counted as 1"), - # OpenSAK persists a bearing per cache, recomputed whenever the centre - # point changes (db/database.py::recalculate_distances). - ("Bearing", "cbxDegrees", "edtDegrees", None, "bearing", - "Bearing: measured from the ACTIVE centre point, and only as fresh as the last " - "distance recalculation"), ): bounds = _num_bounds(gf, c, label, op_key, v1_key, v2_key) if bounds is None: @@ -2141,6 +2127,7 @@ def _convert_misc(gf: GsakFilter, c: Conversion) -> None: f"does not store" )) + _convert_bearing(gf, c) _convert_quadrants(gf, c) if gf.flag("chkReverse", False): @@ -2149,15 +2136,48 @@ def _convert_misc(gf: GsakFilter, c: Conversion) -> None: c.criteria.append(Criterion("Reverse filter", NATIVE)) +def _convert_bearing(gf: GsakFilter, c: Conversion) -> None: + """GSAK's "Degrees" criterion → a DirectionFilter's bearing condition. + + Left without a centre, so it reads the persisted Cache.bearing — measured + from the active home point like GSAK's — and stays pushable to SQL. + """ + bounds = _num_bounds(gf, c, "Bearing", "cbxDegrees", "edtDegrees", None) + if bounds is None: + return + op, v1, v2 = bounds + if _quadrant_selection(gf): + # The filter dialog holds one direction row, which the compass + # quadrants take; the degrees still apply as SQL next to them. + c.sql("Bearing", _num_sql("bearing", op, v1, v2)) + c.note("Bearing: the compass quadrants already take the filter dialog's direction " + "row, so the degree condition was kept as SQL on the bearing column") + return + c.native("Bearing", DirectionFilter(op=NUM_OP_TO_COUNT_OP[op], deg1=v1, deg2=v2)) + note = ("Bearing: measured from the ACTIVE centre point, like GSAK, and only as fresh " + "as the last distance recalculation") + if op == "equals": + note += ("; \"equal\" matches the whole degree (±0.5°), where GSAK compared the " + "exact stored bearing") + c.note(note) + + +def _quadrant_selection(gf: GsakFilter) -> list[str]: + """Direction codes of the ticked compass quadrants — empty when none + were recorded or all are ticked, i.e. when they restrict nothing.""" + present = [k for k in _QUADRANTS if k in gf.kv] + selected = [k for k in present if gf.flag(k)] + if len(selected) == len(present): + return [] + return [_QUADRANTS[k] for k in selected] + + def _convert_quadrants(gf: GsakFilter, c: Conversion) -> None: """Compass-quadrant tick boxes → a DirectionFilter.""" - present = [k for k in _QUADRANTS if k in gf.kv] - if not present: + selected = _quadrant_selection(gf) + if not selected: return - selected = [k for k in present if gf.flag(k)] - if len(selected) == len(present) or not selected: - return # all ticked (or none recorded) = no restriction - c.native("Compass quadrants", DirectionFilter([_QUADRANTS[k] for k in selected])) + c.native("Compass quadrants", DirectionFilter(selected)) c.note("Compass quadrants: measured from the ACTIVE centre point, like GSAK") diff --git a/tests/unit-tests/test_gsak_filter_importer.py b/tests/unit-tests/test_gsak_filter_importer.py index dec7a909..4b71af87 100644 --- a/tests/unit-tests/test_gsak_filter_importer.py +++ b/tests/unit-tests/test_gsak_filter_importer.py @@ -10,6 +10,7 @@ from opensak.filters.engine import ( DATE_OPS, DATE_UNITS, + DirectionFilter, LOG_CATEGORIES, LOG_SCOPE_CHOICES, TEXT_OPS, @@ -245,12 +246,12 @@ def test_elevation_range(self): "edtElevation2": "2000"}) assert _statuses(c) == {"Elevation": NATIVE} f = c.filters[0] - assert (f.filter_type, f.min_m, f.max_m) == ("elevation", 1000, 2000) + assert (f.filter_type, f.op, f.elev1_m, f.elev2_m) == ("elevation", "between", 1000, 2000) def test_elevation_at_least(self): c = _convert({"cbxElevation": "2", "edtElevation": "1500"}) f = c.filters[0] - assert (f.min_m, f.max_m) == (1500, 9000) + assert (f.op, f.elev1_m) == ("at_least", 1500) @pytest.mark.parametrize("yes,no,op", [("True", "False", "not_empty"), ("False", "True", "empty")]) @@ -274,6 +275,37 @@ def test_all_quadrants_ticked_is_no_restriction(self): "cbxS", "cbxSW", "cbxW", "cbxNW")}) assert c.criteria == [] + @pytest.mark.parametrize("op_idx,deg,op", [ + ("1", "90", "at_most"), + ("2", "270", "at_least"), + ("3", "45", "equal"), + ]) + def test_bearing_becomes_direction_filter(self, op_idx, deg, op): + # GSAK stores a single degree value (edtDegrees) — no range. + c = _convert({"cbxDegrees": op_idx, "edtDegrees": deg}) + assert _statuses(c) == {"Bearing": NATIVE} + assert c.sql_parts == [] + f = c.filters[0] + assert (f.filter_type, f.op, f.deg1) == ("direction", op, int(deg)) + # No centre: the persisted Cache.bearing from the home point, in SQL. + assert (f.lat, f.lon) == (None, None) + restored = DirectionFilter.from_dict(f.to_dict()) + assert (restored.op, restored.deg1, restored.lat) == (op, int(deg), None) + + def test_bearing_off_is_no_criterion(self): + c = _convert({"cbxDegrees": "0", "edtDegrees": "90"}) + assert c.criteria == [] + + def test_bearing_next_to_quadrants_stays_sql(self): + pairs = {k: "False" for k in ("cbxN", "cbxNE", "cbxE", "cbxSE", + "cbxS", "cbxSW", "cbxW", "cbxNW")} + pairs.update(cbxN="True", cbxDegrees="1", edtDegrees="30") + c = _convert(pairs) + assert _statuses(c) == {"Bearing": SQL, "Compass quadrants": NATIVE} + assert [f.filter_type for f in c.filters] == ["direction"] + assert c.filters[0].directions == ["N"] + assert c.sql_parts == [("Bearing", "bearing <= 30")] + def test_reverse_filter_negates_filterset(self): c = _convert({"chkFound": "False", "chkNotFound": "True", "chkReverse": "True"}) assert _statuses(c)["Reverse filter"] == NATIVE From 372757663b867fe807ab47cf5c6572e525942c24 Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 09:43:41 +0200 Subject: [PATCH 5/5] included logs and date compares --- docs/filters.md | 7 +- src/opensak/importer/gsak_filter_importer.py | 127 ++++++++++++++++-- tests/unit-tests/test_gsak_filter_importer.py | 71 ++++++++++ 3 files changed, 192 insertions(+), 13 deletions(-) diff --git a/docs/filters.md b/docs/filters.md index 43f6b403..9aa3214b 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -476,7 +476,7 @@ Every condition in a GSAK filter lands in one of three places: | | What it means | Counts as migrated | |---|---|---| -| **A filter** | The condition exists in the tabs above — cache types, D/T, dates, logs, child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, a bearing in degrees and compass quadrants (both as *Direction*), TB/coin names and "no trackables" (on the *Trackables* tab), and GSAK's *reverse filter* (as *Invert filter*) | yes | +| **A filter** | The condition exists in the tabs above — cache types, D/T, dates (including *compared with* another date), logs (including *exclude*), child waypoints, polygons, attributes, text fields with all their operators (user data 1–4 included), personal note, elevation, distance, a bearing in degrees and compass quadrants (both as *Direction*), TB/coin names and "no trackables" (on the *Trackables* tab), and GSAK's *reverse filter* (as *Invert filter*) | yes | | **SQL in the Where tab** | OpenSAK stores the data but has no filter row for it — the watch list, user sort, a bearing in degrees next to compass quadrants (the dialog has only one *Direction* row) | yes | | **A comment in the Where tab** | Nothing in OpenSAK can express it | no | @@ -496,6 +496,11 @@ The comments always come first and the executable SQL last, so the clause stays The things that cannot be migrated are, in practice: GSAK's user-defined columns (OpenSAK has no custom columns, and the cache import does not carry them over), the Waymark and L&F Celebration cache types (the cache import files them under *Unknown Cache*, where nothing tells them apart), GSAK macro variables inside a saved `WHERE` clause, and columns OpenSAK does not store at all (FavPerc, LabId, the GPX symbol name). +Two conditions come across with a small difference, which the import reports list: + +- **Last *n* logs** — GSAK counts the last *n* logs among the ticked log groups only (found / not found / notes); OpenSAK counts them over every log. With all three groups ticked the two agree. +- **Compared with another date** — no saved filter available when this was written used it, so the order of GSAK's comparison and date lists was inferred. Open the date row after importing and check it shows the comparison you meant. + ### The statistics after an import The results panel ends with a migration-coverage figure: diff --git a/src/opensak/importer/gsak_filter_importer.py b/src/opensak/importer/gsak_filter_importer.py index 3a24a166..12e7a916 100644 --- a/src/opensak/importer/gsak_filter_importer.py +++ b/src/opensak/importer/gsak_filter_importer.py @@ -45,6 +45,7 @@ from typing import Any, Callable, Optional from opensak.filters.engine import ( + DATE_COMPARE_OPS, DATE_UNITS, LOG_SCOPE_CHOICES, AttributeFilter, @@ -243,6 +244,46 @@ # DATE_UNITS, in the same order. DURING_UNITS: tuple[str, ...] = DATE_UNITS +# "Verglichen mit" (compare this date against another date of the same cache) +# is stored in two extra combos per date field, cbxComp and +# cbxComp2. ASSUMED (no real filter uses the operator; both combos are +# 0 everywhere): Comp is the comparison, in the order of OpenSAK's +# DATE_COMPARE_OPS — equal, older, older or equal, newer, newer or equal, +# within, outside, which was modelled on GSAK's list — and Comp2 picks the +# other date, in the order GSAK writes the date fields to the saved filter. +# "Within"/"outside" take their day count from the field's rolling-window box. +DATE_COMPARE_OP_BY_INDEX: tuple[str, ...] = DATE_COMPARE_OPS +# GSAK date field in Comp2 order → OpenSAK DateFilter field (None = OpenSAK +# has no date row for it). +DATE_COMPARE_FIELD_BY_INDEX: tuple[tuple[str, Optional[str]], ...] = ( + ("Last found date", "last_found_date"), + ("Hidden date", "hidden_date"), + ("DNF date", "dnf_date"), + ("My found date", "found_date"), + ("Record created date", "creation_date"), + ("Last GPX update", "last_gpx_update"), + ("Last user update", None), + ("Last log date", "last_log_date"), + ("Last changed date", "changed_date"), +) +# Date operator combo → its (comparison, other date) combos. +DATE_COMPARE_KEYS: dict[str, tuple[str, str]] = { + "cbxFound": ("cbxFoundComp", "cbxFoundComp2"), + "cbxPlaced": ("cbxPlacedComp", "cbxPlacedComp2"), + "cbxDNFDate": ("cbxDNFComp", "cbxDNFComp2"), + "cbxUserFound": ("cbxFBMComp", "cbxFBMComp2"), + "cbxCreate": ("cbxCreateComp", "cbxCreateComp2"), + "cbxLastUpdate": ("cbxLastUpdateComp", "cbxLastUpdateComp2"), + "cbxLastUser": ("cbxLastUserComp", "cbxLastUserComp2"), + "cbxLastLog": ("cbxLastLogComp", "cbxLastLogComp2"), + "cbxChange": ("CbxChangeComp", "cbxChangeComp2"), +} + +# GSAK's "Logs to include" combo (cbxLogInclude): whether a cache whose log +# count passes is kept or removed — the last step of GSAK's log selection +# process, which OpenSAK's LogFilter models as *exclude*. +LOG_INCLUDE_EXCLUDE: dict[int, bool] = {0: False, 1: True} + # GSAK's child-waypoint type dropdown (cbxCtype2) stores an index, not a name. # ASSUMED from real filters: the list holds the six GPX waypoint types in # alphabetical order — "Parking", a filter that selects caches having a parking @@ -1268,6 +1309,10 @@ class DateSpec: date2: Optional[datetime] = None amount: int = 1 unit: str = "days" + # op == "compare": the other DateFilter field, DATE_COMPARE_OPS name, days. + other_field: str = "hidden_date" + compare_op: str = "equal" + compare_days: int = 0 def ordered(self) -> "DateSpec": """"Between" with the dates the wrong way round still means a range.""" @@ -1311,13 +1356,7 @@ def _read_date(gf: GsakFilter, c: Conversion, label: str, op_key: str, if op is None: return None # "Beliebig" — criterion off if op == "compare": - c.comment(label, ( - "GSAK compared this date against another date column " - "(\"Verglichen mit\") and the saved filter does not record which one, " - "so there is nothing to translate. OpenSAK's date row has the same " - "\"Compared with\" operator — pick the other field there by hand" - )) - return None + return _read_date_compare(gf, c, label, op_key, during_edt, during_cbx) if op in ("during", "not_during"): during = _read_during(gf, during_edt, during_cbx) if during is None: @@ -1341,6 +1380,50 @@ def _read_date(gf: GsakFilter, c: Conversion, label: str, op_key: str, return DateSpec(op, start) +# Days per rolling-window unit that a compare's "within N" can use exactly. +_COMPARE_DAYS_PER_UNIT = {"days": 1, "weeks": 7} + + +def _read_date_compare(gf: GsakFilter, c: Conversion, label: str, op_key: str, + during_edt: str, during_cbx: str) -> Optional[DateSpec]: + """GSAK's "Verglichen mit" → a compare DateSpec (see DATE_COMPARE_KEYS).""" + keys = DATE_COMPARE_KEYS.get(op_key) + if keys is None: + c.comment(label, "GSAK compared this date against another date, which this " + "field cannot do in OpenSAK") + return None + op_index, field_index = gf.num(keys[0]) or 0, gf.num(keys[1]) or 0 + if not (0 <= op_index < len(DATE_COMPARE_OP_BY_INDEX) + and 0 <= field_index < len(DATE_COMPARE_FIELD_BY_INDEX)): + c.comment(label, ( + f"GSAK compared this date against another date (comparison index {op_index}, " + f"date index {field_index}), which is outside the lists OpenSAK knows — " + f"rebuild it with the date row's \"Compared with\" operator" + )) + return None + compare_op = DATE_COMPARE_OP_BY_INDEX[op_index] + other_label, other_field = DATE_COMPARE_FIELD_BY_INDEX[field_index] + if other_field is None: + c.comment(label, f"GSAK compared this date against the {other_label}, which " + f"OpenSAK's date row cannot compare against") + return None + days = 0 + if compare_op in ("within", "outside"): + during = _read_during(gf, during_edt, during_cbx) + if during is None or during[1] not in _COMPARE_DAYS_PER_UNIT: + c.comment(label, ( + f"GSAK compared this date against the {other_label} ({compare_op} a number " + f"of days), but no day count could be read" + )) + return None + days = during[0] * _COMPARE_DAYS_PER_UNIT[during[1]] + c.note(f"{label}: GSAK's \"compared with\" combos were read as comparison " + f"{op_index} = {compare_op.replace('_', ' ')} and date {field_index} = " + f"{other_label} (assumed order) — check the date row") + return DateSpec("compare", other_field=other_field, compare_op=compare_op, + compare_days=days) + + def _date_sql(column: str, spec: DateSpec) -> str: """WHERE fragment for a date criterion on a column with no GUI filter. @@ -1780,7 +1863,16 @@ def _convert_dates(gf: GsakFilter, c: Conversion) -> None: continue if extra: c.note(extra) - if osak_field is not None: + if spec.op == "compare": + if osak_field is None: + c.comment(label, "GSAK compared this date against another date, but " + "OpenSAK has no date row for it to compare from") + continue + c.native(label, DateFilter( + field=osak_field, op="compare", other_field=spec.other_field, + compare_op=spec.compare_op, compare_days=spec.compare_days, + )) + elif osak_field is not None: c.native(label, DateFilter( field=osak_field, op=spec.op, date1=spec.date1, date2=spec.date2, @@ -1929,6 +2021,11 @@ def _convert_log_tab(gf: GsakFilter, c: Conversion) -> None: described.append(f"in the last {last_n} log(s)") c.note("Logs: GSAK's \"logs to search\" dropdown was read through OpenSAK's own " "list of the same choices (0/all, 1, 2, …, 10, 15, 20, 30, 40, 50, 100)") + if categories is not None: + c.note(f"Logs: GSAK counts the last {last_n} log(s) among the ticked " + f"groups only ({'/'.join(categories)}); OpenSAK counts them over " + f"every log, so a cache whose last {last_n} log(s) include other " + f"groups may match differently") else: c.comment("Logs: number of logs to search", f"GSAK's dropdown index {scope_index} is outside the list of choices " @@ -1971,10 +2068,15 @@ def _convert_log_tab(gf: GsakFilter, c: Conversion) -> None: described.insert(0, f"count {count_op.replace('_', ' ')} " f"{v1}{f'..{v2}' if count_op == 'between' else ''}") - if gf.num("cbxLogInclude"): - c.comment("Logs: \"logs to include\" selector", - f"GSAK stores index {gf.num('cbxLogInclude')} here and its meaning could " - f"not be established, so it was left out of the log filter") + include_index = gf.num("cbxLogInclude") or 0 + exclude = LOG_INCLUDE_EXCLUDE.get(include_index) + if exclude is None: + exclude = False + c.comment("Logs: include/exclude", + f"GSAK's include/exclude combo holds index {include_index}, which is " + f"neither include (0) nor exclude (1), so the caches were included") + elif exclude: + described.insert(0, "exclude") log_filter = LogFilter( date_op=spec.op if spec else None, @@ -1991,6 +2093,7 @@ def _convert_log_tab(gf: GsakFilter, c: Conversion) -> None: count_op=count_op, count1=count1, count2=count2, + exclude=exclude, ) if log_filter.is_noop(): return diff --git a/tests/unit-tests/test_gsak_filter_importer.py b/tests/unit-tests/test_gsak_filter_importer.py index 4b71af87..2fd3a199 100644 --- a/tests/unit-tests/test_gsak_filter_importer.py +++ b/tests/unit-tests/test_gsak_filter_importer.py @@ -196,6 +196,36 @@ def test_log_tab_becomes_log_filter(self): assert f.categories == ["found", "not_found"] assert (f.last_n, f.count_op, f.count1) == (2, "at_least", 2) + def test_log_tab_last_n_within_groups_is_noted(self): + c = _convert({ + "chkLogFound": "False", "chkLogSearchFound": "True", + "chkLogSearchNotFound": "True", "chkLogSearchNote": "False", + "cbxLogsToSearch": "2", "cbxLogDate": "6", "LtDidn't find it": "True", + }) + assert c.filters[0].last_n == 2 + assert any("among the ticked groups only" in n for n in c.notes) + + def test_log_tab_last_n_over_all_groups_needs_no_note(self): + c = _convert({ + "chkLogFound": "False", "chkLogSearchFound": "True", + "chkLogSearchNotFound": "True", "chkLogSearchNote": "True", + "cbxLogsToSearch": "2", "cbxLogDate": "6", "LtDidn't find it": "True", + }) + assert not any("among the ticked groups only" in n for n in c.notes) + + @pytest.mark.parametrize("index,exclude", [("0", False), ("1", True)]) + def test_log_tab_include_exclude(self, index, exclude): + c = _convert({"chkLogFound": "False", "cbxLogDate": "6", + "LtNeeds Maintenance": "True", "cbxLogInclude": index}) + assert list(_statuses(c).values()) == [NATIVE] + assert c.filters[0].exclude is exclude + + def test_log_tab_unknown_include_index_is_commented(self): + c = _convert({"chkLogFound": "False", "cbxLogDate": "6", + "LtNeeds Maintenance": "True", "cbxLogInclude": "2"}) + assert _statuses(c)["Logs: include/exclude"] == COMMENT + assert c.filters[0].exclude is False + def test_waypoint_tab_becomes_waypoint_filter(self): c = _convert({"cbxcCount": "0", "cbxCtype2": "1", "cbxctype": "0", "edtctype": "", "cbxcDate": "6"}) @@ -306,6 +336,47 @@ def test_bearing_next_to_quadrants_stays_sql(self): assert c.filters[0].directions == ["N"] assert c.sql_parts == [("Bearing", "bearing <= 30")] + def test_date_compared_within_days(self): + # Hidden date within 7 days of the last found date. + c = _convert({"cbxPlaced": "6", "cbxPlacedComp": "5", "cbxPlacedComp2": "0", + "edtPlacedDuring": "7", "cbxPlacedDuring": "0"}) + assert _statuses(c) == {"Hidden date": NATIVE} + f = c.filters[0] + assert (f.field, f.op, f.other_field, f.compare_op, f.compare_days) == ( + "hidden_date", "compare", "last_found_date", "within", 7) + assert any("assumed order" in n for n in c.notes) + + def test_date_compared_older_needs_no_days(self): + # Last changed date (note GSAK's capital "CbxChangeComp") older than + # the DNF date. + c = _convert({"cbxChange": "6", "CbxChangeComp": "1", "cbxChangeComp2": "2"}) + f = c.filters[0] + assert (f.field, f.other_field, f.compare_op) == ( + "changed_date", "dnf_date", "older") + + def test_date_compared_weeks_become_days(self): + c = _convert({"cbxFound": "6", "cbxFoundComp": "6", "cbxFoundComp2": "1", + "edtFoundDuring": "2", "cbxFoundDuring": "1"}) + f = c.filters[0] + assert (f.compare_op, f.compare_days) == ("outside", 14) + + def test_date_compared_without_day_count_is_commented(self): + c = _convert({"cbxFound": "6", "cbxFoundComp": "5", "cbxFoundComp2": "1"}) + assert _statuses(c) == {"Last found date": COMMENT} + + def test_date_compared_with_last_user_update_is_commented(self): + c = _convert({"cbxPlaced": "6", "cbxPlacedComp": "0", "cbxPlacedComp2": "6"}) + assert _statuses(c) == {"Hidden date": COMMENT} + + def test_last_user_update_compared_is_commented(self): + c = _convert({"cbxLastUser": "6", "cbxLastUserComp": "0", + "cbxLastUserComp2": "1"}) + assert _statuses(c) == {"Last user update": COMMENT} + + def test_date_compared_unknown_index_is_commented(self): + c = _convert({"cbxPlaced": "6", "cbxPlacedComp": "9", "cbxPlacedComp2": "0"}) + assert _statuses(c) == {"Hidden date": COMMENT} + def test_reverse_filter_negates_filterset(self): c = _convert({"chkFound": "False", "chkNotFound": "True", "chkReverse": "True"}) assert _statuses(c)["Reverse filter"] == NATIVE