From 1b93fc66cbedae4f55039c90f4d9612cfbe8537e Mon Sep 17 00:00:00 2001 From: nagisml Date: Mon, 14 Sep 2026 18:03:20 +0200 Subject: [PATCH 1/9] First version of Date conditions in filters --- src/opensak/filters/engine.py | 255 ++++++++++++- src/opensak/gui/dialogs/filter_dialog.py | 345 +++++++++++------- src/opensak/lang/cs.py | 20 + src/opensak/lang/da.py | 20 + src/opensak/lang/de.py | 20 + src/opensak/lang/en.py | 20 + src/opensak/lang/es.py | 20 + src/opensak/lang/fr.py | 20 + src/opensak/lang/nl.py | 20 + src/opensak/lang/pl.py | 20 + src/opensak/lang/pt.py | 20 + src/opensak/lang/se.py | 20 + tests/unit-tests/test_filter_dialog.py | 160 ++++++-- .../unit-tests/test_filter_sql_parity_633.py | 87 ++++- tests/unit-tests/test_filters.py | 167 ++++++++- 15 files changed, 1037 insertions(+), 177 deletions(-) diff --git a/src/opensak/filters/engine.py b/src/opensak/filters/engine.py index 3b9e321e..9eea11e8 100644 --- a/src/opensak/filters/engine.py +++ b/src/opensak/filters/engine.py @@ -24,7 +24,7 @@ import re from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime +from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Optional @@ -1310,6 +1310,258 @@ def from_dict(cls, data: dict) -> "HiddenDateFilter": ) +# ── GSAK-style date filter ──────────────────────────────────────────────────── + +# Filterable date fields: filter key -> Cache attribute. The keys are the +# cache table's column IDs (changed_date/creation_date display +# last_updated/imported_at there as well). +DATE_FILTER_FIELDS: dict[str, str] = { + "last_found_date": "last_found_date", + "hidden_date": "hidden_date", + "found_date": "found_date", + "dnf_date": "dnf_date", + "creation_date": "imported_at", + "last_gpx_update": "last_gpx_update", + "last_log_date": "last_log_date", + "changed_date": "last_updated", +} +DATE_OPS = ("on_or_before", "on_or_after", "equal", "between", + "during", "not_during", "compare") +DATE_UNITS = ("days", "weeks", "months", "years") +DATE_COMPARE_OPS = ("equal", "older", "older_or_equal", "newer", + "newer_or_equal", "within", "outside") + +# filter_type of the older from/to-only date filters -> the field they cover. +LEGACY_DATE_FILTER_FIELDS: dict[str, str] = { + "hidden_date_range": "hidden_date", + "found_by_me_date": "found_date", + "dnf_date": "dnf_date", + "last_log_date": "last_log_date", +} + + +def _to_date(value) -> Optional[date]: + """Calendar date of a datetime (tz dropped, like the other date filters).""" + if value is None: + return None + if isinstance(value, datetime): + return value.replace(tzinfo=None).date() + return value + + +def _parse_iso_date(value: Optional[str]) -> Optional[date]: + """Parse a saved date; accepts both 'YYYY-MM-DD' and full ISO datetimes.""" + return datetime.fromisoformat(value).date() if value else None + + +def _day_start(d: date) -> datetime: + return datetime(d.year, d.month, d.day) + + +def _months_back(d: date, months: int) -> date: + """*d* moved back *months* calendar months, clamped to the month's last day.""" + import calendar + year, month0 = divmod(d.year * 12 + d.month - 1 - months, 12) + month = month0 + 1 + return date(year, month, min(d.day, calendar.monthrange(year, month)[1])) + + +def _shift_back(d: date, amount: int, unit: str) -> date: + """*d* moved back *amount* days/weeks/months/years (date.min on underflow).""" + try: + if unit == "weeks": + return d - timedelta(weeks=amount) + if unit == "months": + return _months_back(d, amount) + if unit == "years": + return _months_back(d, amount * 12) + return d - timedelta(days=amount) + except (ValueError, OverflowError): + return date.min + + +def _today() -> date: + """Reference day for the relative "during the last N …" operators.""" + return date.today() + + +def _compare_diff(op: str, diff, days: int, absolute=abs): + """Evaluate a DateFilter compare op on *diff* = this date - other date (in + days). Works on ints and on SQL expressions (pass absolute=func.abs).""" + if op == "equal": + return diff == 0 + if op == "older": + return diff < 0 + if op == "older_or_equal": + return diff <= 0 + if op == "newer": + return diff > 0 + if op == "newer_or_equal": + return diff >= 0 + if op == "within": + return absolute(diff) <= days + return absolute(diff) > days # outside + + +class DateFilter(BaseFilter): + """GSAK-style filter on one of the cache's date fields (DATE_FILTER_FIELDS). + + Operators — all compare calendar dates, ignoring the time of day: + on_or_before / on_or_after / equal relative to *date1* + between *date1*..*date2* inclusive (in either order) + during within the last *amount* *unit*s, up to and including today + not_during the complement of "during": also matches caches without a + date, so "last found not during the last 2 years" keeps + never-found caches + compare against *other_field* of the same cache using *compare_op*; + "within"/"outside" take *compare_days* + Apart from not_during, a cache without a date never matches. + + Supersedes the from/to-only HiddenDateFilter/FoundByMeDateFilter/ + DnfDateFilter/LastLogDateFilter, which stay registered so filter profiles + saved before this still load; from_legacy() converts them. + """ + filter_type = "date" + + def __init__( + self, + field: str, + op: str, + date1: Optional[date] = None, + date2: Optional[date] = None, + amount: int = 1, + unit: str = "days", + other_field: str = "hidden_date", + compare_op: str = "equal", + compare_days: int = 0, + ): + if field not in DATE_FILTER_FIELDS: + raise ValueError(f"Unknown date field {field!r}") + if op not in DATE_OPS: + raise ValueError(f"Unknown date operator {op!r}") + if unit not in DATE_UNITS: + raise ValueError(f"Unknown date unit {unit!r}") + if other_field not in DATE_FILTER_FIELDS: + raise ValueError(f"Unknown date field {other_field!r}") + if compare_op not in DATE_COMPARE_OPS: + raise ValueError(f"Unknown date compare operator {compare_op!r}") + self.field = field + self.op = op + self.date1 = _to_date(date1) + self.date2 = _to_date(date2) + if op in ("on_or_before", "on_or_after", "equal", "between") and self.date1 is None: + raise ValueError(f"Date operator {op!r} needs date1") + if op == "between" and self.date2 is None: + raise ValueError("Date operator 'between' needs date2") + self.amount = max(0, int(amount)) + self.unit = unit + self.other_field = other_field + self.compare_op = compare_op + self.compare_days = max(0, int(compare_days)) + + def _range(self) -> tuple[Optional[date], Optional[date]]: + """Inclusive (lo, hi) bounds for every op except compare.""" + if self.op == "on_or_before": + return None, self.date1 + if self.op == "on_or_after": + return self.date1, None + if self.op == "equal": + return self.date1, self.date1 + if self.op == "between": + return min(self.date1, self.date2), max(self.date1, self.date2) + today = _today() # during / not_during + return _shift_back(today, self.amount, self.unit), today + + def apply_to_query(self, query): + # Mirrors matches() exactly. Range bounds compare the raw column + # against day boundaries (index-friendly); compare uses SQLite's + # date()/julianday() so both sides are reduced to calendar dates. + from sqlalchemy import and_, func, not_, or_ + col = getattr(Cache, DATE_FILTER_FIELDS[self.field]) + if self.op == "compare": + other = getattr(Cache, DATE_FILTER_FIELDS[self.other_field]) + diff = func.julianday(func.date(col)) - func.julianday(func.date(other)) + return query.filter( + col.is_not(None), other.is_not(None), + _compare_diff(self.compare_op, diff, self.compare_days, func.abs), + ) + lo, hi = self._range() + conditions = [col.is_not(None)] + if lo is not None: + conditions.append(col >= _day_start(lo)) + if hi is not None and hi < date.max: + conditions.append(col < _day_start(hi + timedelta(days=1))) + inside = and_(*conditions) + if self.op == "not_during": + return query.filter(or_(col.is_(None), not_(inside))) + return query.filter(inside) + + def matches(self, cache: Cache) -> bool: + value = _to_date(getattr(cache, DATE_FILTER_FIELDS[self.field], None)) + if self.op == "compare": + other = _to_date(getattr(cache, DATE_FILTER_FIELDS[self.other_field], None)) + if value is None or other is None: + return False + return _compare_diff(self.compare_op, (value - other).days, self.compare_days) + lo, hi = self._range() + inside = ( + value is not None + and (lo is None or value >= lo) + and (hi is None or value <= hi) + ) + return not inside if self.op == "not_during" else inside + + def to_dict(self) -> dict: + return { + "filter_type": self.filter_type, + "field": self.field, + "op": self.op, + "date1": self.date1.isoformat() if self.date1 else None, + "date2": self.date2.isoformat() if self.date2 else None, + "amount": self.amount, + "unit": self.unit, + "other_field": self.other_field, + "compare_op": self.compare_op, + "compare_days": self.compare_days, + } + + @classmethod + def from_dict(cls, data: dict) -> "DateFilter": + return cls( + field=data["field"], + op=data["op"], + date1=_parse_iso_date(data.get("date1")), + date2=_parse_iso_date(data.get("date2")), + amount=data.get("amount", 1), + unit=data.get("unit", "days"), + other_field=data.get("other_field", "hidden_date"), + compare_op=data.get("compare_op", "equal"), + compare_days=data.get("compare_days", 0), + ) + + @classmethod + def from_legacy(cls, legacy: BaseFilter) -> Optional["DateFilter"]: + """Equivalent DateFilter for an older from/to range filter + (LEGACY_DATE_FILTER_FIELDS), or None if it has no date bounds. + + Not an exact match for FoundByMeDateFilter/DnfDateFilter, which also + let found/DNF caches without a date through — DateFilter never + matches a missing date.""" + field = LEGACY_DATE_FILTER_FIELDS.get(legacy.filter_type) + from_date = getattr(legacy, "from_date", None) + to_date = getattr(legacy, "to_date", None) + if field is None or not (from_date or to_date): + return None + if from_date and to_date: + return cls(field, "between", date1=from_date, date2=to_date) + if from_date: + return cls(field, "on_or_after", date1=from_date) + return cls(field, "on_or_before", date1=to_date) + + def __repr__(self) -> str: + return f"" + + class TextSearchFilter(BaseFilter): """Keep caches whose text fields contain *text* (case-insensitive). @@ -1442,6 +1694,7 @@ def from_dict(cls, data: dict) -> "TextSearchFilter": "dnf_date": DnfDateFilter, "last_log_date": LastLogDateFilter, "hidden_date_range": HiddenDateFilter, + "date": DateFilter, "text_search": TextSearchFilter, } diff --git a/src/opensak/gui/dialogs/filter_dialog.py b/src/opensak/gui/dialogs/filter_dialog.py index dd521816..d0197355 100644 --- a/src/opensak/gui/dialogs/filter_dialog.py +++ b/src/opensak/gui/dialogs/filter_dialog.py @@ -13,14 +13,14 @@ """ from __future__ import annotations -from datetime import datetime +from datetime import date from typing import Optional from PySide6.QtCore import Qt, QSize, Signal from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit, QCheckBox, QPushButton, - QComboBox, QDoubleSpinBox, QTabWidget, QWidget, + QComboBox, QDoubleSpinBox, QSpinBox, QTabWidget, QWidget, QGroupBox, QScrollArea, QGridLayout, QDialogButtonBox, QMessageBox, QInputDialog, QDateEdit, QSizePolicy, QFrame, QPlainTextEdit, @@ -45,7 +45,7 @@ PremiumFilter, NonPremiumFilter, WhereClauseFilter, UserFlagFilter, LockedFilter, DnfFilter, FtfFilter, FavoritePointsFilter, - FoundByMeDateFilter, DnfDateFilter, LastLogDateFilter, HiddenDateFilter, + DateFilter, LEGACY_DATE_FILTER_FIELDS, TextSearchFilter, FilterProfile, ) @@ -283,6 +283,188 @@ def _on_op_changed(self) -> None: self.edit.setPlaceholderText(placeholder) +# ── Hjælper widget: GSAK-lignende datofilter ────────────────────────────────── + +# Dropdown-rækkefølge som i GSAK. Nøglerne står som literals, så +# test_no_unused_keys kan finde dem. "any" = intet filter. +_DATE_OP_LABELS: tuple[tuple[str, str], ...] = ( + ("any", "filter_date_op_any"), + ("on_or_before", "filter_date_op_on_or_before"), + ("on_or_after", "filter_date_op_on_or_after"), + ("equal", "filter_date_op_equal"), + ("between", "filter_date_op_between"), + ("during", "filter_date_op_during"), + ("not_during", "filter_date_op_not_during"), + ("compare", "filter_date_op_compare"), +) +_DATE_UNIT_LABELS: tuple[tuple[str, str], ...] = ( + ("days", "filter_date_unit_days"), + ("weeks", "filter_date_unit_weeks"), + ("months", "filter_date_unit_months"), + ("years", "filter_date_unit_years"), +) +_DATE_COMPARE_LABELS: tuple[tuple[str, str], ...] = ( + ("equal", "filter_date_cmp_equal"), + ("older", "filter_date_cmp_older"), + ("older_or_equal", "filter_date_cmp_older_or_equal"), + ("newer", "filter_date_cmp_newer"), + ("newer_or_equal", "filter_date_cmp_newer_or_equal"), + ("within", "filter_date_cmp_within"), + ("outside", "filter_date_cmp_outside"), +) +# Datofelterne i GSAK's rækkefølge, med deres label. +_DATE_FIELD_LABELS: tuple[tuple[str, str], ...] = ( + ("last_found_date", "col_last_found_date"), + ("hidden_date", "filter_hidden_date_group"), + ("found_date", "filter_found_date_group"), + ("dnf_date", "col_dnf_date"), + ("creation_date", "col_creation_date"), + ("last_gpx_update", "col_last_gpx_update"), + ("last_log_date", "filter_log_date_group"), + ("changed_date", "col_changed_date"), +) +_DATE_OPS_WITH_DATE1 = ("on_or_before", "on_or_after", "equal", "between") +_DATE_OPS_RELATIVE = ("during", "not_during") + + +def _qdate_to_date(qdate: QDate) -> date: + return date(qdate.year(), qdate.month(), qdate.day()) + + +class DateFilterRow(QWidget): + """Operator dropdown + inputs for one date field (GSAK's Dates tab). + + Depending on the operator it shows one or two date pickers, "Last + [N] [days/weeks/months/years]", or a comparison with another date field + (plus a day count for "within"/"outside"). The label turns bold while the + row is active. + """ + + def __init__(self, field: str, label: str, parent=None): + super().__init__(parent) + self.field = field + self.label = QLabel(label) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.op_combo = QComboBox() + for op, key in _DATE_OP_LABELS: + self.op_combo.addItem(tr(key), op) + layout.addWidget(self.op_combo) + + self.date1 = self._make_date_edit() + self.date2 = self._make_date_edit() + layout.addWidget(self.date1) + layout.addWidget(self.date2) + + self._relative = QWidget() + rel_layout = QHBoxLayout(self._relative) + rel_layout.setContentsMargins(0, 0, 0, 0) + rel_layout.addWidget(QLabel(tr("filter_date_last"))) + self.amount = QSpinBox() + self.amount.setRange(0, 9999) + self.amount.setValue(1) + rel_layout.addWidget(self.amount) + self.unit_combo = QComboBox() + for unit, key in _DATE_UNIT_LABELS: + self.unit_combo.addItem(tr(key), unit) + rel_layout.addWidget(self.unit_combo) + layout.addWidget(self._relative) + + self._compare = QWidget() + cmp_layout = QHBoxLayout(self._compare) + cmp_layout.setContentsMargins(0, 0, 0, 0) + self.other_combo = QComboBox() + for other, key in _DATE_FIELD_LABELS: + if other != field: + self.other_combo.addItem(tr(key), other) + cmp_layout.addWidget(self.other_combo) + self.compare_combo = QComboBox() + for op, key in _DATE_COMPARE_LABELS: + self.compare_combo.addItem(tr(key), op) + cmp_layout.addWidget(self.compare_combo) + self.compare_days = QSpinBox() + self.compare_days.setRange(0, 99999) + cmp_layout.addWidget(self.compare_days) + self._days_label = QLabel(tr("filter_date_unit_days")) + cmp_layout.addWidget(self._days_label) + layout.addWidget(self._compare) + layout.addStretch() + + self.op_combo.currentIndexChanged.connect(self._update_inputs) + self.compare_combo.currentIndexChanged.connect(self._update_inputs) + self._update_inputs() + + @staticmethod + def _make_date_edit() -> QDateEdit: + edit = QDateEdit() + edit.setCalendarPopup(True) + edit.setDate(QDate.currentDate()) + return edit + + def op(self) -> str: + return self.op_combo.currentData() + + @staticmethod + def _select(combo: QComboBox, value) -> None: + index = combo.findData(value) + if index >= 0: + combo.setCurrentIndex(index) + + def reset(self) -> None: + self.op_combo.setCurrentIndex(0) + self.date1.setDate(QDate.currentDate()) + self.date2.setDate(QDate.currentDate()) + self.amount.setValue(1) + self.unit_combo.setCurrentIndex(0) + self.other_combo.setCurrentIndex(0) + self.compare_combo.setCurrentIndex(0) + self.compare_days.setValue(0) + + def build(self) -> Optional[DateFilter]: + """Filter for the current input, or None when the row is "Any".""" + op = self.op() + if op == "any": + return None + # Only the dates the operator uses — keeps saved profiles free of + # stale picker values. + return DateFilter( + self.field, op, + date1=_qdate_to_date(self.date1.date()) if op in _DATE_OPS_WITH_DATE1 else None, + date2=_qdate_to_date(self.date2.date()) if op == "between" else None, + amount=self.amount.value(), + unit=self.unit_combo.currentData(), + other_field=self.other_combo.currentData(), + compare_op=self.compare_combo.currentData(), + compare_days=self.compare_days.value(), + ) + + def load(self, f: DateFilter) -> None: + self._select(self.op_combo, f.op) + for edit, value in ((self.date1, f.date1), (self.date2, f.date2)): + if value is not None: + edit.setDate(QDate(value.year, value.month, value.day)) + self.amount.setValue(f.amount) + self._select(self.unit_combo, f.unit) + self._select(self.other_combo, f.other_field) + self._select(self.compare_combo, f.compare_op) + self.compare_days.setValue(f.compare_days) + + def _update_inputs(self) -> None: + op = self.op() + self.date1.setVisible(op in _DATE_OPS_WITH_DATE1) + self.date2.setVisible(op == "between") + self._relative.setVisible(op in _DATE_OPS_RELATIVE) + self._compare.setVisible(op == "compare") + needs_days = self.compare_combo.currentData() in ("within", "outside") + self.compare_days.setVisible(needs_days) + self._days_label.setVisible(needs_days) + font = self.label.font() + font.setBold(op != "any") + self.label.setFont(font) + + # ── Filter dialog ───────────────────────────────────────────────────────────── class FilterDialog(QDialog): @@ -620,59 +802,17 @@ def _build_general_tab(self) -> QWidget: return outer def _build_dates_tab(self) -> QWidget: - """Datoer filter fane.""" + """Datoer filter fane — én GSAK-lignende operator-række pr. datofelt.""" widget = QWidget() layout = QFormLayout(widget) layout.setSpacing(10) layout.setContentsMargins(10, 10, 10, 10) - def _make_date_group(title: str): - """Hjælper: lav en from/to dato-gruppe og returner (group, from_en, from_dt, to_en, to_dt).""" - group = QGroupBox(title) - grp_layout = QFormLayout(group) - from_en = QCheckBox(tr("filter_from")) - from_dt = QDateEdit() - from_dt.setCalendarPopup(True) - from_dt.setDate(QDate(2000, 1, 1)) - from_dt.setEnabled(False) - from_en.toggled.connect(from_dt.setEnabled) - row1 = QHBoxLayout() - row1.addWidget(from_en) - row1.addWidget(from_dt) - row1.addStretch() - grp_layout.addRow(row1) - to_en = QCheckBox(tr("filter_to")) - to_dt = QDateEdit() - to_dt.setCalendarPopup(True) - to_dt.setDate(QDate.currentDate()) - to_dt.setEnabled(False) - to_en.toggled.connect(to_dt.setEnabled) - row2 = QHBoxLayout() - row2.addWidget(to_en) - row2.addWidget(to_dt) - row2.addStretch() - grp_layout.addRow(row2) - return group, from_en, from_dt, to_en, to_dt - - # Udlagt dato - g, self._hidden_from_enabled, self._hidden_from, self._hidden_to_enabled, self._hidden_to = \ - _make_date_group(tr("filter_hidden_date_group")) - layout.addRow(g) - - # Fundet af mig dato - g, self._found_from_enabled, self._found_from, self._found_to_enabled, self._found_to = \ - _make_date_group(tr("filter_found_date_group")) - layout.addRow(g) - - # DNF dato - g, self._dnf_date_from_enabled, self._dnf_date_from, self._dnf_date_to_enabled, self._dnf_date_to = \ - _make_date_group(tr("col_dnf_date")) - layout.addRow(g) - - # Seneste log dato - g, self._log_from_enabled, self._log_from, self._log_to_enabled, self._log_to = \ - _make_date_group(tr("filter_log_date_group")) - layout.addRow(g) + self._date_rows: dict[str, DateFilterRow] = {} + for field, key in _DATE_FIELD_LABELS: + row = DateFilterRow(field, tr(key)) + self._date_rows[field] = row + layout.addRow(row.label, row) return widget @@ -1128,14 +1268,8 @@ def _reset_general(self) -> None: self._cc_no.setChecked(True) def _reset_dates(self) -> None: - self._hidden_from_enabled.setChecked(False) - self._hidden_to_enabled.setChecked(False) - self._found_from_enabled.setChecked(False) - self._found_to_enabled.setChecked(False) - self._dnf_date_from_enabled.setChecked(False) - self._dnf_date_to_enabled.setChecked(False) - self._log_from_enabled.setChecked(False) - self._log_to_enabled.setChecked(False) + for row in self._date_rows.values(): + row.reset() def _reset_misc(self) -> None: for row, _cls in self._geo_text_rows(): @@ -1302,44 +1436,11 @@ def _build_filterset(self) -> FilterSet: fs.add(NoCorrectedFilter()) # Begge valgt (eller ingen) = vis alt = intet filter - # Datoer — hjælper til at konvertere QDate til datetime - # #844: hour/minute var hardkodet til 23/59 uanset end_of_day, så - # from_date reelt blev sat til 23:59:00 i stedet for 00:00:00 — - # samme dato i from/to gav dermed et 59-sekunders vindue og ingen - # match; en flerdagesrange "virkede" kun fordi from-grænsen i - # praksis rykkede en dag tilbage. - def _qdate_to_dt(qdate, end_of_day=False) -> datetime: - if end_of_day: - return datetime(qdate.year(), qdate.month(), qdate.day(), 23, 59, 59) - return datetime(qdate.year(), qdate.month(), qdate.day(), 0, 0, 0) - - # Udlagt dato - if self._hidden_from_enabled.isChecked() or self._hidden_to_enabled.isChecked(): - fs.add(HiddenDateFilter( - from_date=_qdate_to_dt(self._hidden_from.date()) if self._hidden_from_enabled.isChecked() else None, - to_date=_qdate_to_dt(self._hidden_to.date(), end_of_day=True) if self._hidden_to_enabled.isChecked() else None, - )) - - # Fundet af mig dato - if self._found_from_enabled.isChecked() or self._found_to_enabled.isChecked(): - fs.add(FoundByMeDateFilter( - from_date=_qdate_to_dt(self._found_from.date()) if self._found_from_enabled.isChecked() else None, - to_date=_qdate_to_dt(self._found_to.date(), end_of_day=True) if self._found_to_enabled.isChecked() else None, - )) - - # DNF dato - if self._dnf_date_from_enabled.isChecked() or self._dnf_date_to_enabled.isChecked(): - fs.add(DnfDateFilter( - from_date=_qdate_to_dt(self._dnf_date_from.date()) if self._dnf_date_from_enabled.isChecked() else None, - to_date=_qdate_to_dt(self._dnf_date_to.date(), end_of_day=True) if self._dnf_date_to_enabled.isChecked() else None, - )) - - # Seneste log dato - if self._log_from_enabled.isChecked() or self._log_to_enabled.isChecked(): - fs.add(LastLogDateFilter( - from_date=_qdate_to_dt(self._log_from.date()) if self._log_from_enabled.isChecked() else None, - to_date=_qdate_to_dt(self._log_to.date(), end_of_day=True) if self._log_to_enabled.isChecked() else None, - )) + # Datoer — én DateFilter pr. datofelt med en valgt operator + for row in self._date_rows.values(): + date_filter = row.build() + if date_filter is not None: + fs.add(date_filter) # Øvrigt — Land / Stat / Kommune for row, cls in self._geo_text_rows(): @@ -1637,45 +1738,17 @@ def _load_filterset(self, fs: FilterSet) -> None: self._fav_enabled.setChecked(True) self._fav_min.setValue(getattr(f, "min_pts", 0)) self._fav_max.setValue(getattr(f, "max_pts", 9999)) - elif ftype == "found_by_me_date": - if getattr(f, "from_date", None): - self._found_from_enabled.setChecked(True) - d = f.from_date - self._found_from.setDate(QDate(d.year, d.month, d.day)) - if getattr(f, "to_date", None): - self._found_to_enabled.setChecked(True) - d = f.to_date - self._found_to.setDate(QDate(d.year, d.month, d.day)) - elif ftype == "dnf_date": - if getattr(f, "from_date", None): - self._dnf_date_from_enabled.setChecked(True) - d = f.from_date - self._dnf_date_from.setDate(QDate(d.year, d.month, d.day)) - if getattr(f, "to_date", None): - self._dnf_date_to_enabled.setChecked(True) - d = f.to_date - self._dnf_date_to.setDate(QDate(d.year, d.month, d.day)) - elif ftype == "last_log_date": - if getattr(f, "from_date", None): - self._log_from_enabled.setChecked(True) - d = f.from_date - self._log_from.setDate(QDate(d.year, d.month, d.day)) - if getattr(f, "to_date", None): - self._log_to_enabled.setChecked(True) - d = f.to_date - self._log_to.setDate(QDate(d.year, d.month, d.day)) - elif ftype == "hidden_date_range": - # #857: this branch was missing, so the Hidden date - # checkboxes/fields silently reset on reopen even though the - # filter was still active on the cache list. - if getattr(f, "from_date", None): - self._hidden_from_enabled.setChecked(True) - d = f.from_date - self._hidden_from.setDate(QDate(d.year, d.month, d.day)) - if getattr(f, "to_date", None): - self._hidden_to_enabled.setChecked(True) - d = f.to_date - self._hidden_to.setDate(QDate(d.year, d.month, d.day)) + elif ftype == "date": + row = self._date_rows.get(f.field) + if row is not None: + row.load(f) + elif ftype in LEGACY_DATE_FILTER_FIELDS: + # Profiles saved before the GSAK-style date filter hold + # from/to range filters — show them as the equivalent + # operator (Between / On or after / On or before). + converted = DateFilter.from_legacy(f) + if converted is not None: + self._date_rows[converted.field].load(converted) # Andre/ukendte filtre ignoreres stille # ── Apply ───────────────────────────────────────────────────────────────── diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 6ae2a4d7..66535964 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -669,6 +669,26 @@ "filter_no_corrected": "Bez opravených souřadnic", "filter_hidden_date_group": "Datum umístění", "filter_log_date_group": "Datum posledního logu", + "filter_date_op_any": "Libovolné", + "filter_date_op_on_or_before": "V den nebo před", + "filter_date_op_on_or_after": "V den nebo po", + "filter_date_op_equal": "Rovno", + "filter_date_op_between": "Mezi (včetně)", + "filter_date_op_during": "Během", + "filter_date_op_not_during": "Ne během", + "filter_date_op_compare": "Porovnáno s", + "filter_date_last": "Posledních", + "filter_date_unit_days": "dní", + "filter_date_unit_weeks": "týdnů", + "filter_date_unit_months": "měsíců", + "filter_date_unit_years": "let", + "filter_date_cmp_equal": "rovno", + "filter_date_cmp_older": "starší", + "filter_date_cmp_older_or_equal":"rovno nebo starší", + "filter_date_cmp_newer": "novější", + "filter_date_cmp_newer_or_equal":"rovno nebo novější", + "filter_date_cmp_within": "v rozmezí", + "filter_date_cmp_outside": "mimo rozmezí", "filter_caches_with": "Keše, které mají:", "filter_all_selected": "VŠECHNY vybrané atributy", "filter_attr_col_name": "Atribut", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index b9469aca..4f6731f5 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -669,6 +669,26 @@ "filter_no_corrected": "Ingen rettede koordinater", "filter_hidden_date_group": "Udlagt dato", "filter_log_date_group": "Seneste log dato", + "filter_date_op_any": "Vilkårlig", + "filter_date_op_on_or_before": "På eller før", + "filter_date_op_on_or_after": "På eller efter", + "filter_date_op_equal": "Lig med", + "filter_date_op_between": "Mellem (inklusive)", + "filter_date_op_during": "Inden for", + "filter_date_op_not_during": "Ikke inden for", + "filter_date_op_compare": "Sammenlignet med", + "filter_date_last": "Seneste", + "filter_date_unit_days": "dage", + "filter_date_unit_weeks": "uger", + "filter_date_unit_months": "måneder", + "filter_date_unit_years": "år", + "filter_date_cmp_equal": "lig med", + "filter_date_cmp_older": "ældre", + "filter_date_cmp_older_or_equal":"lig med eller ældre", + "filter_date_cmp_newer": "nyere", + "filter_date_cmp_newer_or_equal":"lig med eller nyere", + "filter_date_cmp_within": "inden for", + "filter_date_cmp_outside": "uden for", "filter_caches_with": "Cacher der har:", "filter_all_selected": "ALLE valgte attributter", "filter_attr_col_name": "Attribut", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 2dcb1388..9a4acbee 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -669,6 +669,26 @@ "filter_no_corrected": "Keine korrigierten Koordinaten", "filter_hidden_date_group": "Versteckdatum", "filter_log_date_group": "Letztes Logdatum", + "filter_date_op_any": "Beliebig", + "filter_date_op_on_or_before": "Am oder vor", + "filter_date_op_on_or_after": "Am oder nach", + "filter_date_op_equal": "Gleich", + "filter_date_op_between": "Zwischen (inklusive)", + "filter_date_op_during": "Während", + "filter_date_op_not_during": "Nicht während", + "filter_date_op_compare": "Verglichen mit", + "filter_date_last": "Letzte", + "filter_date_unit_days": "Tage", + "filter_date_unit_weeks": "Wochen", + "filter_date_unit_months": "Monate", + "filter_date_unit_years": "Jahre", + "filter_date_cmp_equal": "gleich", + "filter_date_cmp_older": "älter", + "filter_date_cmp_older_or_equal":"gleich oder älter", + "filter_date_cmp_newer": "neuer", + "filter_date_cmp_newer_or_equal":"gleich oder neuer", + "filter_date_cmp_within": "innerhalb", + "filter_date_cmp_outside": "außerhalb", "filter_caches_with": "Caches mit:", "filter_all_selected": "ALLE gewählten Attribute", "filter_attr_col_name": "Attribut", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 628f4428..2470db80 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -668,6 +668,26 @@ "filter_no_corrected": "No corrected coordinates", "filter_hidden_date_group": "Hidden date", "filter_log_date_group": "Latest log date", + "filter_date_op_any": "Any", + "filter_date_op_on_or_before": "On or before", + "filter_date_op_on_or_after": "On or after", + "filter_date_op_equal": "Equal", + "filter_date_op_between": "Between (inclusive)", + "filter_date_op_during": "During", + "filter_date_op_not_during": "Not during", + "filter_date_op_compare": "Compared with", + "filter_date_last": "Last", + "filter_date_unit_days": "days", + "filter_date_unit_weeks": "weeks", + "filter_date_unit_months": "months", + "filter_date_unit_years": "years", + "filter_date_cmp_equal": "equal", + "filter_date_cmp_older": "older", + "filter_date_cmp_older_or_equal":"equal or older", + "filter_date_cmp_newer": "newer", + "filter_date_cmp_newer_or_equal":"equal or newer", + "filter_date_cmp_within": "within", + "filter_date_cmp_outside": "outside", "filter_caches_with": "Caches that have:", "filter_all_selected": "ALL selected attributes", "filter_attr_col_name": "Attribute", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index 58e2b5f9..f1a8a777 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -670,6 +670,26 @@ "filter_no_corrected": "Sin coordenadas corregidas", "filter_hidden_date_group": "Fecha de ocultación", "filter_log_date_group": "Fecha del último registro", + "filter_date_op_any": "Cualquiera", + "filter_date_op_on_or_before": "En o antes de", + "filter_date_op_on_or_after": "En o después de", + "filter_date_op_equal": "Igual a", + "filter_date_op_between": "Entre (inclusive)", + "filter_date_op_during": "Durante", + "filter_date_op_not_during": "No durante", + "filter_date_op_compare": "Comparado con", + "filter_date_last": "Últimos", + "filter_date_unit_days": "días", + "filter_date_unit_weeks": "semanas", + "filter_date_unit_months": "meses", + "filter_date_unit_years": "años", + "filter_date_cmp_equal": "igual", + "filter_date_cmp_older": "más antigua", + "filter_date_cmp_older_or_equal":"igual o más antigua", + "filter_date_cmp_newer": "más reciente", + "filter_date_cmp_newer_or_equal":"igual o más reciente", + "filter_date_cmp_within": "dentro de", + "filter_date_cmp_outside": "fuera de", "filter_caches_with": "Cachés que tienen:", "filter_all_selected": "TODOS los atributos seleccionados", "filter_attr_col_name": "Atributo", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 0056a297..832bfff7 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -670,6 +670,26 @@ "filter_no_corrected": "Pas de coordonnées corrigées", "filter_hidden_date_group": "Date de la cache", "filter_log_date_group": "Date du dernier log", + "filter_date_op_any": "Indifférent", + "filter_date_op_on_or_before": "Le ou avant", + "filter_date_op_on_or_after": "Le ou après", + "filter_date_op_equal": "Égal à", + "filter_date_op_between": "Entre (inclus)", + "filter_date_op_during": "Pendant", + "filter_date_op_not_during": "Pas pendant", + "filter_date_op_compare": "Comparé à", + "filter_date_last": "Derniers", + "filter_date_unit_days": "jours", + "filter_date_unit_weeks": "semaines", + "filter_date_unit_months": "mois", + "filter_date_unit_years": "années", + "filter_date_cmp_equal": "égal", + "filter_date_cmp_older": "plus ancien", + "filter_date_cmp_older_or_equal":"égal ou plus ancien", + "filter_date_cmp_newer": "plus récent", + "filter_date_cmp_newer_or_equal":"égal ou plus récent", + "filter_date_cmp_within": "à moins de", + "filter_date_cmp_outside": "à plus de", "filter_caches_with": "Caches qui ont:", "filter_all_selected": "TOUS les attributs sélectionnés", "filter_attr_col_name": "Attribut", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index fe1beed6..c5c1178f 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -672,6 +672,26 @@ "filter_no_corrected": "Geen gecorrigeerde coördinaten", "filter_hidden_date_group": "Verborgen datum", "filter_log_date_group": "Datum laatste log", + "filter_date_op_any": "Willekeurig", + "filter_date_op_on_or_before": "Op of voor", + "filter_date_op_on_or_after": "Op of na", + "filter_date_op_equal": "Gelijk aan", + "filter_date_op_between": "Tussen (inclusief)", + "filter_date_op_during": "Tijdens", + "filter_date_op_not_during": "Niet tijdens", + "filter_date_op_compare": "Vergeleken met", + "filter_date_last": "Laatste", + "filter_date_unit_days": "dagen", + "filter_date_unit_weeks": "weken", + "filter_date_unit_months": "maanden", + "filter_date_unit_years": "jaren", + "filter_date_cmp_equal": "gelijk", + "filter_date_cmp_older": "ouder", + "filter_date_cmp_older_or_equal":"gelijk of ouder", + "filter_date_cmp_newer": "nieuwer", + "filter_date_cmp_newer_or_equal":"gelijk of nieuwer", + "filter_date_cmp_within": "binnen", + "filter_date_cmp_outside": "buiten", "filter_caches_with": "Caches met:", "filter_all_selected": "ALLE geselecteerde attributen", "filter_attr_col_name": "Attribuut", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 1425e279..36594edf 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -670,6 +670,26 @@ "filter_no_corrected": "Brak poprawionych współrzędnych", "filter_hidden_date_group": "Data ukrycia", "filter_log_date_group": "Data ostatniego logu", + "filter_date_op_any": "Dowolna", + "filter_date_op_on_or_before": "W dniu lub przed", + "filter_date_op_on_or_after": "W dniu lub po", + "filter_date_op_equal": "Równa", + "filter_date_op_between": "Pomiędzy (włącznie)", + "filter_date_op_during": "W ciągu", + "filter_date_op_not_during": "Nie w ciągu", + "filter_date_op_compare": "W porównaniu z", + "filter_date_last": "Ostatnie", + "filter_date_unit_days": "dni", + "filter_date_unit_weeks": "tygodnie", + "filter_date_unit_months": "miesiące", + "filter_date_unit_years": "lata", + "filter_date_cmp_equal": "równa", + "filter_date_cmp_older": "starsza", + "filter_date_cmp_older_or_equal":"równa lub starsza", + "filter_date_cmp_newer": "nowsza", + "filter_date_cmp_newer_or_equal":"równa lub nowsza", + "filter_date_cmp_within": "w zakresie", + "filter_date_cmp_outside": "poza zakresem", "filter_caches_with": "Skrytki, które mają:", "filter_all_selected": "WSZYSTKIE wybrane atrybuty", "filter_attr_col_name": "Atrybut", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index b8cb64f9..f6c53598 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -669,6 +669,26 @@ "filter_no_corrected": "Sem coordenadas corrigidas", "filter_hidden_date_group": "Data de colocação", "filter_log_date_group": "Data do último log", + "filter_date_op_any": "Qualquer", + "filter_date_op_on_or_before": "Em ou antes de", + "filter_date_op_on_or_after": "Em ou depois de", + "filter_date_op_equal": "Igual a", + "filter_date_op_between": "Entre (inclusive)", + "filter_date_op_during": "Durante", + "filter_date_op_not_during": "Não durante", + "filter_date_op_compare": "Comparado com", + "filter_date_last": "Últimos", + "filter_date_unit_days": "dias", + "filter_date_unit_weeks": "semanas", + "filter_date_unit_months": "meses", + "filter_date_unit_years": "anos", + "filter_date_cmp_equal": "igual", + "filter_date_cmp_older": "mais antiga", + "filter_date_cmp_older_or_equal":"igual ou mais antiga", + "filter_date_cmp_newer": "mais recente", + "filter_date_cmp_newer_or_equal":"igual ou mais recente", + "filter_date_cmp_within": "dentro de", + "filter_date_cmp_outside": "fora de", "filter_caches_with": "Caches que têm:", "filter_all_selected": "TODOS os atributos selecionados", "filter_attr_col_name": "Atributo", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 814e1e6b..91677d98 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -669,6 +669,26 @@ "filter_no_corrected": "Inga korrigerade koordinater", "filter_hidden_date_group": "Gömd datum", "filter_log_date_group": "Senaste logg datum", + "filter_date_op_any": "Valfritt", + "filter_date_op_on_or_before": "På eller före", + "filter_date_op_on_or_after": "På eller efter", + "filter_date_op_equal": "Lika med", + "filter_date_op_between": "Mellan (inklusive)", + "filter_date_op_during": "Under", + "filter_date_op_not_during": "Inte under", + "filter_date_op_compare": "Jämfört med", + "filter_date_last": "Senaste", + "filter_date_unit_days": "dagar", + "filter_date_unit_weeks": "veckor", + "filter_date_unit_months": "månader", + "filter_date_unit_years": "år", + "filter_date_cmp_equal": "lika", + "filter_date_cmp_older": "äldre", + "filter_date_cmp_older_or_equal":"lika eller äldre", + "filter_date_cmp_newer": "nyare", + "filter_date_cmp_newer_or_equal":"lika eller nyare", + "filter_date_cmp_within": "inom", + "filter_date_cmp_outside": "utanför", "filter_caches_with": "Cacher som har:", "filter_all_selected": "ALLA valda attribut", "filter_attr_col_name": "Attribut", diff --git a/tests/unit-tests/test_filter_dialog.py b/tests/unit-tests/test_filter_dialog.py index 993d0bd8..6ce95614 100644 --- a/tests/unit-tests/test_filter_dialog.py +++ b/tests/unit-tests/test_filter_dialog.py @@ -1,6 +1,6 @@ # tests/unit-tests/test_filter_dialog.py — complete filter dialog (build/load/profiles). -from datetime import datetime +from datetime import date, datetime from types import SimpleNamespace import pytest @@ -21,11 +21,16 @@ CountryFilter, StateFilter, CountyFilter, UserFlagFilter, LockedFilter, DnfFilter, FtfFilter, FavoritePointsFilter, AttributeFilter, WhereClauseFilter, FoundByMeDateFilter, DnfDateFilter, LastLogDateFilter, HiddenDateFilter, + DateFilter, DATE_FILTER_FIELDS, TextSearchFilter, FilterProfile, ) +def _date_filters(fs) -> dict: + return {f.field: f for f in fs._filters if isinstance(f, DateFilter)} + + @pytest.fixture(autouse=True) def isolate(monkeypatch): # No real profiles on disk; deterministic home for DistanceFilter. @@ -265,36 +270,90 @@ def test_reset_misc_clears_locked(self, dlg): assert dlg._locked_yes.isChecked() is True assert dlg._locked_no.isChecked() is True + def test_date_rows_default_to_any(self, dlg): + assert set(dlg._date_rows) == set(DATE_FILTER_FIELDS) + assert all(row.op() == "any" for row in dlg._date_rows.values()) + assert _date_filters(dlg._build_filterset()) == {} + def test_date_filters(self, dlg): - dlg._hidden_from_enabled.setChecked(True) - dlg._found_from_enabled.setChecked(True) - dlg._dnf_date_from_enabled.setChecked(True) - dlg._log_from_enabled.setChecked(True) - types = _types(dlg._build_filterset()) - assert "found_by_me_date" in types - assert "dnf_date" in types - assert "last_log_date" in types - assert "hidden_date_range" in types - - def test_single_day_date_range_covers_whole_day(self, dlg): - # #844: from_date skal være 00:00:00 og to_date 23:59:59, så en - # og samme dato i from/to-felterne giver et helt-dags vindue — - # ikke et 59-sekunders vindue (23:59:00-23:59:59), som gav - # "ingen cache matcher" ved single-day-filtrering. - same_date = QDate(2026, 9, 2) - dlg._found_from_enabled.setChecked(True) - dlg._found_to_enabled.setChecked(True) - dlg._found_from.setDate(same_date) - dlg._found_to.setDate(same_date) - fs = dlg._build_filterset() - found_filter = next(f for f in fs._filters if getattr(f, "filter_type", None) == "found_by_me_date") - assert found_filter.from_date == datetime(2026, 9, 2, 0, 0, 0) - assert found_filter.to_date == datetime(2026, 9, 2, 23, 59, 59) + for row in dlg._date_rows.values(): + row._select(row.op_combo, "on_or_after") + assert set(_date_filters(dlg._build_filterset())) == set(DATE_FILTER_FIELDS) + + def test_single_day_equal_covers_whole_day(self, dlg): + # #844: a single date must match the whole day, whatever the time. + row = dlg._date_rows["found_date"] + row._select(row.op_combo, "equal") + row.date1.setDate(QDate(2026, 9, 2)) + f = _date_filters(dlg._build_filterset())["found_date"] + assert (f.op, f.date1) == ("equal", date(2026, 9, 2)) class _Cache: found = True found_date = datetime(2026, 9, 2, 14, 30, 0) - assert found_filter.matches(_Cache()) is True + assert f.matches(_Cache()) is True + + def test_between_relative_and_compare_rows(self, dlg): + hidden = dlg._date_rows["hidden_date"] + hidden._select(hidden.op_combo, "between") + hidden.date1.setDate(QDate(2020, 1, 1)) + hidden.date2.setDate(QDate(2020, 12, 31)) + last_found = dlg._date_rows["last_found_date"] + last_found._select(last_found.op_combo, "not_during") + last_found.amount.setValue(2) + last_found._select(last_found.unit_combo, "years") + log = dlg._date_rows["last_log_date"] + log._select(log.op_combo, "compare") + log._select(log.other_combo, "hidden_date") + log._select(log.compare_combo, "within") + log.compare_days.setValue(7) + + by_field = _date_filters(dlg._build_filterset()) + assert set(by_field) == {"hidden_date", "last_found_date", "last_log_date"} + h = by_field["hidden_date"] + assert (h.op, h.date1, h.date2) == ("between", date(2020, 1, 1), date(2020, 12, 31)) + lf = by_field["last_found_date"] + assert (lf.op, lf.amount, lf.unit) == ("not_during", 2, "years") + lg = by_field["last_log_date"] + assert (lg.op, lg.other_field, lg.compare_op, lg.compare_days) == \ + ("compare", "hidden_date", "within", 7) + + def test_date_row_inputs_follow_operator(self, dlg): + row = dlg._date_rows["dnf_date"] + + def shown(): + return tuple(not w.isHidden() for w in (row.date1, row.date2, row._relative, row._compare)) + + assert shown() == (False, False, False, False) + assert not row.label.font().bold() + for op, expected in [ + ("on_or_before", (True, False, False, False)), + ("equal", (True, False, False, False)), + ("between", (True, True, False, False)), + ("during", (False, False, True, False)), + ("not_during", (False, False, True, False)), + ("compare", (False, False, False, True)), + ]: + row._select(row.op_combo, op) + assert shown() == expected, op + assert row.label.font().bold() + assert row.compare_days.isHidden() + row._select(row.compare_combo, "outside") + assert not row.compare_days.isHidden() + + def test_compare_offers_every_other_field(self, dlg): + row = dlg._date_rows["hidden_date"] + others = [row.other_combo.itemData(i) for i in range(row.other_combo.count())] + assert set(others) == set(DATE_FILTER_FIELDS) - {"hidden_date"} + + def test_reset_dates(self, dlg): + row = dlg._date_rows["changed_date"] + row._select(row.op_combo, "during") + row.amount.setValue(5) + dlg._reset_dates() + assert row.op() == "any" + assert row.amount.value() == 1 + assert not row.label.font().bold() def test_attributes_and_mode(self, dlg): attr_id = next(iter(dlg._attr_boxes)) @@ -383,31 +442,56 @@ def test_loads_types_and_container(self, dlg): assert dlg._type_checks[CACHE_TYPES[0]].isChecked() assert not dlg._type_checks[CACHE_TYPES[1]].isChecked() - def test_loads_date_filters(self, dlg): + def test_loads_date_filters_round_trip(self, dlg): + filters = [ + DateFilter("creation_date", "compare", other_field="last_gpx_update", + compare_op="older_or_equal"), + DateFilter("changed_date", "between", date1=date(2020, 1, 1), date2=date(2021, 6, 30)), + DateFilter("dnf_date", "during", amount=3, unit="months"), + DateFilter("last_found_date", "compare", other_field="found_date", + compare_op="outside", compare_days=30), + ] + fs = FilterSet(mode="AND") + for f in filters: + fs.add(f) + dlg._load_filterset(fs) + rebuilt = {k: f.to_dict() for k, f in _date_filters(dlg._build_filterset()).items()} + assert rebuilt == {f.field: f.to_dict() for f in filters} + + def test_loads_legacy_date_filters(self, dlg): + # Profiles saved before the GSAK-style date filter hold from/to + # range filters; they show as the equivalent operator. fs = FilterSet(mode="AND") fs.add(FoundByMeDateFilter(from_date=datetime(2020, 1, 1), to_date=datetime(2021, 1, 1))) fs.add(DnfDateFilter(from_date=datetime(2020, 2, 2), to_date=None)) fs.add(LastLogDateFilter(from_date=None, to_date=datetime(2022, 3, 3))) dlg._load_filterset(fs) - assert dlg._found_from_enabled.isChecked() - assert dlg._dnf_date_from_enabled.isChecked() - assert dlg._log_to_enabled.isChecked() + found = dlg._date_rows["found_date"] + assert found.op() == "between" + assert (found.date1.date(), found.date2.date()) == (QDate(2020, 1, 1), QDate(2021, 1, 1)) + dnf = dlg._date_rows["dnf_date"] + assert (dnf.op(), dnf.date1.date()) == ("on_or_after", QDate(2020, 2, 2)) + log = dlg._date_rows["last_log_date"] + assert (log.op(), log.date1.date()) == ("on_or_before", QDate(2022, 3, 3)) + assert set(_date_filters(dlg._build_filterset())) == {"found_date", "dnf_date", "last_log_date"} def test_loads_hidden_date_filter(self, dlg): # #857: reopening the Filter dialog after setting a Hidden date - # range didn't restore the checkboxes/dates, even though the list - # was correctly filtered — no branch in _load_filterset() handled - # "hidden_date_range". Found/DNF/last-log date ranges round-tripped - # fine, only Hidden date was affected. + # range didn't restore it, even though the list was correctly + # filtered. A legacy hidden_date_range must still restore. fs = FilterSet(mode="AND") fs.add(HiddenDateFilter(from_date=datetime(2020, 5, 1), to_date=datetime(2020, 6, 15))) dlg._load_filterset(fs) - assert dlg._hidden_from_enabled.isChecked() - assert dlg._hidden_from.date() == QDate(2020, 5, 1) - assert dlg._hidden_to_enabled.isChecked() - assert dlg._hidden_to.date() == QDate(2020, 6, 15) + row = dlg._date_rows["hidden_date"] + assert row.op() == "between" + assert row.date1.date() == QDate(2020, 5, 1) + assert row.date2.date() == QDate(2020, 6, 15) + + def test_legacy_range_without_dates_leaves_row_any(self, dlg): + dlg._load_filterset(FilterSet().add(FoundByMeDateFilter())) + assert dlg._date_rows["found_date"].op() == "any" def test_hidden_date_filter_round_trips_via_to_dict(self): # #857 (root cause, part 2): the old inline HiddenDateFilter's diff --git a/tests/unit-tests/test_filter_sql_parity_633.py b/tests/unit-tests/test_filter_sql_parity_633.py index 8fd20aa0..87aaf370 100644 --- a/tests/unit-tests/test_filter_sql_parity_633.py +++ b/tests/unit-tests/test_filter_sql_parity_633.py @@ -10,13 +10,15 @@ data, since none of these columns are nullable=False at the DB level. """ -from datetime import datetime +from datetime import date, datetime import pytest from opensak.db.database import get_session from opensak.db.models import Cache, UserNote +from opensak.filters import engine from opensak.filters.engine import ( + DATE_COMPARE_OPS, DATE_FILTER_FIELDS, DateFilter, DnfDateFilter, DnfFilter, FavoritePointsFilter, FilterSet, FoundByMeDateFilter, FtfFilter, HasCorrectedFilter, HiddenDateFilter, LastLogDateFilter, LockedFilter, NoCorrectedFilter, UserFlagFilter, apply_filters, @@ -76,6 +78,15 @@ def seed_633_data(tmp_db): Cache(gc_code="GC6330012", name="NoHiddenDate", cache_type="Traditional Cache", latitude=56.1, longitude=13.1, hidden_date=None), + # Several dates on one cache, with times of day, for DateFilter's + # calendar-day comparisons: last found on the hidden day but earlier + # in the day, last log 3 calendar days (2.6 x 24h) after hiding. + Cache(gc_code="GC6330013", name="ManyDates", cache_type="Traditional Cache", + latitude=56.2, longitude=13.2, + hidden_date=datetime(2026, 5, 1, 18, 30), + last_found_date=datetime(2026, 5, 1, 8, 0), + last_log_date=datetime(2026, 5, 4, 9, 0), + last_updated=datetime(2026, 4, 1, 23, 59, 59)), ] with get_session() as s: for c in caches: @@ -226,6 +237,80 @@ def test_range_excludes_out_of_range(self): assert "GC6330012" not in codes # NULL, always excluded +class TestDateFilter: + # GSAK-style DateFilter: SQL pushdown (day-boundary ranges, and + # date()/julianday() for compare) must agree with Python matches(). + + @pytest.fixture(autouse=True) + def fixed_today(self, monkeypatch): + monkeypatch.setattr(engine, "_today", lambda: date(2026, 5, 10)) + + @pytest.mark.parametrize("field", list(DATE_FILTER_FIELDS)) + @pytest.mark.parametrize("op, kwargs", [ + ("on_or_before", {"date1": date(2026, 4, 1)}), + ("on_or_after", {"date1": date(2026, 4, 1)}), + ("equal", {"date1": date(2026, 5, 1)}), + ("between", {"date1": date(2026, 5, 15), "date2": date(2026, 3, 15)}), + ("during", {"amount": 10, "unit": "days"}), + ("not_during", {"amount": 10, "unit": "days"}), + ("during", {"amount": 2, "unit": "months"}), + ("not_during", {"amount": 1, "unit": "years"}), + ]) + def test_range_ops(self, field, op, kwargs): + assert_parity(FilterSet().add(DateFilter(field, op, **kwargs))) + + @pytest.mark.parametrize("compare_op", DATE_COMPARE_OPS) + @pytest.mark.parametrize("days", [2, 3]) + def test_compare_ops(self, compare_op, days): + assert_parity(FilterSet().add(DateFilter( + "last_log_date", "compare", other_field="hidden_date", + compare_op=compare_op, compare_days=days, + ))) + + def test_equal_ignores_time_of_day(self): + codes = assert_parity(FilterSet().add(DateFilter("hidden_date", "equal", date1=date(2026, 5, 1)))) + assert codes == {"GC6330013"} + + def test_on_or_before_includes_end_of_day(self): + codes = assert_parity(FilterSet().add(DateFilter("changed_date", "on_or_before", date1=date(2026, 4, 1)))) + assert "GC6330013" in codes # last_updated 23:59:59 on that day + + def test_compare_equal_by_calendar_day(self): + codes = assert_parity(FilterSet().add(DateFilter( + "last_found_date", "compare", other_field="hidden_date", compare_op="equal", + ))) + assert codes == {"GC6330013"} + + def test_compare_within_counts_calendar_days(self): + # 1 May 18:30 -> 4 May 09:00 is 2.6 x 24h but 3 calendar days. + within_3 = assert_parity(FilterSet().add(DateFilter( + "last_log_date", "compare", other_field="hidden_date", + compare_op="within", compare_days=3, + ))) + within_2 = assert_parity(FilterSet().add(DateFilter( + "last_log_date", "compare", other_field="hidden_date", + compare_op="within", compare_days=2, + ))) + assert "GC6330013" in within_3 + assert "GC6330013" not in within_2 + + def test_not_during_includes_missing_date(self): + codes = assert_parity(FilterSet().add(DateFilter("last_log_date", "not_during", amount=10))) + assert "GC6330010" in codes # NULL last_log_date + assert "GC6330009" not in codes # 1 May — within 10 days of 10 May + assert "GC6330013" not in codes # 4 May + + def test_lightweight_path(self): + from opensak.filters.engine import apply_filters_lightweight + fs = FilterSet() + fs.add(DateFilter("hidden_date", "during", amount=1, unit="months")) + fs.add(DateFilter("last_log_date", "compare", other_field="hidden_date", + compare_op="newer")) + with get_session() as s: + codes = {c.gc_code for c in apply_filters_lightweight(s, fs)} + assert codes == assert_parity(fs) == {"GC6330013"} + + class TestComposition: def test_and_with_or_subtree(self): inner = FilterSet(mode="OR") diff --git a/tests/unit-tests/test_filters.py b/tests/unit-tests/test_filters.py index 2207137d..a563cd19 100644 --- a/tests/unit-tests/test_filters.py +++ b/tests/unit-tests/test_filters.py @@ -3,11 +3,12 @@ import json import pytest from pathlib import Path -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone from types import SimpleNamespace from opensak.db.database import get_session from opensak.db.models import Cache, Attribute, Trackable +from opensak.filters import engine from opensak.filters.engine import ( FilterSet, SortSpec, FilterProfile, apply_filters, annotate_distances, # All filter classes @@ -19,6 +20,7 @@ WhereClauseFilter, HasCorrectedFilter, NoCorrectedFilter, UserFlagFilter, LockedFilter, DnfFilter, FtfFilter, FavoritePointsFilter, FoundByMeDateFilter, DnfDateFilter, LastLogDateFilter, + HiddenDateFilter, DateFilter, # Helpers _haversine_km, _iter_filters, FILTER_REGISTRY, SORT_FIELDS, ) @@ -956,6 +958,169 @@ def test_last_log_date(self): assert restored.to_date == self.TO +def _day(d: date, hour: int = 0) -> datetime: + return datetime(d.year, d.month, d.day, hour) + + +class TestDateFilter: + """GSAK-style DateFilter — every operator works on calendar dates.""" + D = date(2026, 9, 14) + + def test_on_or_before_after_equal_ignore_time_of_day(self): + before = DateFilter("hidden_date", "on_or_before", date1=self.D) + after = DateFilter("hidden_date", "on_or_after", date1=self.D) + equal = DateFilter("hidden_date", "equal", date1=self.D) + same_day_evening = _cache(hidden_date=_day(self.D, 23)) + next_day = _cache(hidden_date=_day(self.D + timedelta(days=1))) + day_before = _cache(hidden_date=_day(self.D - timedelta(days=1), 23)) + assert before.matches(same_day_evening) and before.matches(day_before) + assert not before.matches(next_day) + assert after.matches(same_day_evening) and after.matches(next_day) + assert not after.matches(day_before) + assert equal.matches(same_day_evening) + assert not equal.matches(next_day) and not equal.matches(day_before) + + @pytest.mark.parametrize("op", ["on_or_before", "on_or_after", "equal", "between", "during"]) + def test_missing_date_never_matches(self, op): + f = DateFilter("found_date", op, date1=self.D, date2=self.D) + assert f.matches(_cache(found=True, found_date=None)) is False + + def test_between_is_inclusive_in_either_order(self): + f = DateFilter("found_date", "between", date1=date(2026, 9, 20), date2=date(2026, 9, 10)) + assert f.matches(_cache(found_date=datetime(2026, 9, 10))) + assert f.matches(_cache(found_date=datetime(2026, 9, 20, 23, 59))) + assert not f.matches(_cache(found_date=datetime(2026, 9, 21))) + assert not f.matches(_cache(found_date=datetime(2026, 9, 9, 23, 59))) + + def test_during_and_not_during(self, monkeypatch): + monkeypatch.setattr(engine, "_today", lambda: self.D) + during = DateFilter("last_found_date", "during", amount=2, unit="weeks") + not_during = DateFilter("last_found_date", "not_during", amount=2, unit="weeks") + cutoff = _cache(last_found_date=datetime(2026, 8, 31, 8)) # exactly 2 weeks back + older = _cache(last_found_date=datetime(2026, 8, 30, 23)) + never = _cache(last_found_date=None) + future = _cache(last_found_date=datetime(2026, 9, 15)) + assert during.matches(cutoff) + assert not during.matches(older) + assert not during.matches(never) + assert not during.matches(future) + # not_during is the exact complement — including caches without a date + for c in (cutoff, older, never, future): + assert not_during.matches(c) is (not during.matches(c)) + + @pytest.mark.parametrize("unit, amount, cutoff", [ + ("days", 10, date(2026, 9, 4)), + ("weeks", 1, date(2026, 9, 7)), + ("months", 1, date(2026, 8, 14)), + ("years", 2, date(2024, 9, 14)), + ("days", 0, date(2026, 9, 14)), + ]) + def test_relative_units(self, monkeypatch, unit, amount, cutoff): + monkeypatch.setattr(engine, "_today", lambda: self.D) + f = DateFilter("hidden_date", "during", amount=amount, unit=unit) + assert f.matches(_cache(hidden_date=_day(cutoff))) + assert not f.matches(_cache(hidden_date=_day(cutoff - timedelta(days=1), 23))) + + def test_shift_back_clamps(self): + assert engine._shift_back(date(2026, 3, 31), 1, "months") == date(2026, 2, 28) + assert engine._shift_back(date(2024, 2, 29), 1, "years") == date(2023, 2, 28) + assert engine._shift_back(date(2026, 1, 15), 13, "months") == date(2024, 12, 15) + assert engine._shift_back(date(2026, 1, 1), 9999, "years") == date.min + + # found_date is 5 calendar days before last_log_date (times deliberately + # "wrong way round" so a datetime diff would be 4.1 days, not 5). + @pytest.mark.parametrize("compare_op, days, expected", [ + ("equal", 0, False), + ("older", 0, True), + ("older_or_equal", 0, True), + ("newer", 0, False), + ("newer_or_equal", 0, False), + ("within", 5, True), + ("within", 4, False), + ("outside", 4, True), + ("outside", 5, False), + ]) + def test_compare(self, compare_op, days, expected): + f = DateFilter("found_date", "compare", other_field="last_log_date", + compare_op=compare_op, compare_days=days) + c = _cache(found_date=datetime(2026, 9, 1, 22), last_log_date=datetime(2026, 9, 6, 1)) + assert f.matches(c) is expected + + def test_compare_equal_is_same_calendar_day(self): + f = DateFilter("last_found_date", "compare", other_field="found_date", compare_op="equal") + assert f.matches(_cache(last_found_date=datetime(2026, 9, 1, 8), + found_date=datetime(2026, 9, 1, 20))) + + def test_compare_needs_both_dates(self): + f = DateFilter("found_date", "compare", other_field="last_log_date", compare_op="outside") + assert not f.matches(_cache(found_date=datetime(2026, 9, 1), last_log_date=None)) + assert not f.matches(_cache(found_date=None, last_log_date=datetime(2026, 9, 1))) + + def test_round_trip_through_json(self): + fs = FilterSet() + fs.add(DateFilter("changed_date", "between", date1=date(2020, 1, 1), date2=date(2020, 12, 31))) + fs.add(DateFilter("creation_date", "compare", other_field="hidden_date", + compare_op="within", compare_days=3)) + fs.add(DateFilter("dnf_date", "not_during", amount=6, unit="months")) + restored = FilterSet.from_dict(json.loads(json.dumps(fs.to_dict()))) + assert [f.to_dict() for f in restored._filters] == [f.to_dict() for f in fs._filters] + assert FILTER_REGISTRY["date"] is DateFilter + + @pytest.mark.parametrize("kwargs", [ + dict(field="nope", op="equal", date1=date(2026, 1, 1)), + dict(field="hidden_date", op="sometimes", date1=date(2026, 1, 1)), + dict(field="hidden_date", op="equal"), + dict(field="hidden_date", op="between", date1=date(2026, 1, 1)), + dict(field="hidden_date", op="during", unit="decades"), + dict(field="hidden_date", op="compare", other_field="nope"), + dict(field="hidden_date", op="compare", compare_op="roughly"), + ]) + def test_invalid_values_rejected(self, kwargs): + with pytest.raises(ValueError): + DateFilter(**kwargs) + + def test_legacy_profile_json_still_loads(self): + # Filter profiles saved before DateFilter existed. + data = {"mode": "AND", "filters": [ + {"filter_type": "hidden_date_range", + "from_date": "2020-05-01T00:00:00", "to_date": "2020-06-15T23:59:59"}, + {"filter_type": "found_by_me_date", "from_date": "2021-01-01T00:00:00", "to_date": None}, + {"filter_type": "dnf_date", "from_date": None, "to_date": "2022-01-01T23:59:59"}, + {"filter_type": "last_log_date", "from_date": "2023-01-01T00:00:00", "to_date": None}, + ]} + fs = FilterSet.from_dict(data) + assert [type(f) for f in fs._filters] == [ + HiddenDateFilter, FoundByMeDateFilter, DnfDateFilter, LastLogDateFilter, + ] + assert fs._filters[0].to_date == datetime(2020, 6, 15, 23, 59, 59) + + @pytest.mark.parametrize("legacy, expected", [ + (HiddenDateFilter(datetime(2020, 5, 1), datetime(2020, 6, 15, 23, 59, 59)), + ("hidden_date", "between", date(2020, 5, 1), date(2020, 6, 15))), + (FoundByMeDateFilter(from_date=datetime(2021, 1, 1)), + ("found_date", "on_or_after", date(2021, 1, 1), None)), + (LastLogDateFilter(to_date=datetime(2022, 3, 3, 23, 59, 59)), + ("last_log_date", "on_or_before", date(2022, 3, 3), None)), + (DnfDateFilter(from_date=datetime(2020, 2, 2), to_date=datetime(2020, 3, 3)), + ("dnf_date", "between", date(2020, 2, 2), date(2020, 3, 3))), + (DnfDateFilter(), None), + ]) + def test_from_legacy(self, legacy, expected): + converted = DateFilter.from_legacy(legacy) + if expected is None: + assert converted is None + else: + assert (converted.field, converted.op, converted.date1, converted.date2) == expected + + def test_from_legacy_matches_like_the_legacy_range(self): + legacy = HiddenDateFilter(datetime(2020, 5, 1), datetime(2020, 6, 15, 23, 59, 59)) + converted = DateFilter.from_legacy(legacy) + for d in (datetime(2020, 4, 30, 23), datetime(2020, 5, 1), datetime(2020, 6, 15, 23), + datetime(2020, 6, 16), None): + c = _cache(hidden_date=d) + assert converted.matches(c) is legacy.matches(c) + + class TestFilterSetEdges: def test_invalid_mode_raises(self): with pytest.raises(ValueError): From 39a9e85d332479b59e95cd9e2352d86e63ada24a Mon Sep 17 00:00:00 2001 From: nagisml Date: Mon, 14 Sep 2026 20:41:33 +0200 Subject: [PATCH 2/9] First version of Line/Point/Polygon filtering --- docs/filters.md | 31 +- src/opensak/filters/__init__.py | 2 +- src/opensak/filters/engine.py | 157 +++++++- src/opensak/filters/line_polygon.py | 354 ++++++++++++++++++ src/opensak/gui/dialogs/filter_dialog.py | 231 +++++++++++- src/opensak/lang/cs.py | 22 ++ src/opensak/lang/da.py | 22 ++ src/opensak/lang/de.py | 22 ++ src/opensak/lang/en.py | 22 ++ src/opensak/lang/es.py | 22 ++ src/opensak/lang/fr.py | 22 ++ src/opensak/lang/nl.py | 22 ++ src/opensak/lang/pl.py | 22 ++ src/opensak/lang/pt.py | 22 ++ src/opensak/lang/se.py | 22 ++ tests/unit-tests/test_filter_dialog.py | 4 +- .../test_filter_dialog_line_polygon.py | 210 +++++++++++ tests/unit-tests/test_line_polygon_filter.py | 343 +++++++++++++++++ 18 files changed, 1541 insertions(+), 11 deletions(-) create mode 100644 src/opensak/filters/line_polygon.py create mode 100644 tests/unit-tests/test_filter_dialog_line_polygon.py create mode 100644 tests/unit-tests/test_line_polygon_filter.py diff --git a/docs/filters.md b/docs/filters.md index 58803df5..a984184f 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -22,13 +22,14 @@ Filters can also be **nested**: an outer AND group can contain an inner OR group ## Filter tabs -The filter dialog is split across six tabs: +The filter dialog is split across seven tabs: | Tab | What's on it | |---|---| | **General** | Cache type, container, D/T, found status, availability, distance, premium, trackables, corrected coordinates | | **Dates** | Hidden date, found by me date, DNF date, last log date | | **Other** | Country / State / County, user flag, DNF, FTF, favourite points, locked | +| **Line/Polygon** | Caches along a route, inside an area, or near a list of points | | **Attributes** | ~70 standard Groundspeak attributes | | **Text Search** | Full-text search across description, logs, notes, and (optionally) hint | | **Where** | Raw SQL WHERE clause for advanced filtering | @@ -245,6 +246,34 @@ Filter by the date of the most recent log entry for the cache. --- +## Line / polygon filter + +The **Line/Polygon** tab works like GSAK's filter of the same name. Enter one point per line in the text box: + +```text +53.18346, 8.71113 +N 53 23.613, E 008 00.941 +W,GC12345 +``` + +- Any coordinate format OpenSAK understands works (decimal degrees, DMM, DMS), with or without a comma between latitude and longitude. +- `W,` takes the coordinates of a cache (its corrected coordinates when set) or a waypoint in the current database. +- Text after `#` is ignored, so you can annotate the list. +- **Add flagged (user flag)** appends a `W,` line for every cache with the user flag set. +- **Read points from file** loads a GPX file (track points, else route points, else waypoints), a KML file, or a text file in the format above — replacing or appending to the list. + +Choose the filter type: + +| Type | Includes caches… | Needs | +|---|---|---| +| Line | within the distance of the line through the points (a route or track) | 2+ points and a distance | +| Polygon | inside the area the points outline (closed automatically); a distance above 0 also includes caches that close to the outline | 3+ points | +| Points | within the distance of any single point | 1+ point and a distance | + +Check **Exclude** to invert the filter and keep only the caches that do *not* match. The filter uses a cache's corrected coordinates when set. Distances are measured along the Earth's surface; polygon edges are straight lines in latitude/longitude. Shapes that cross the ±180° meridian are not supported. + +--- + ## Text search filter The **Text Search** tab searches free-text fields for a word or phrase, rather than an exact match like the *Name* or *GC code* filters. diff --git a/src/opensak/filters/__init__.py b/src/opensak/filters/__init__.py index 0d46b4b8..1358f106 100644 --- a/src/opensak/filters/__init__.py +++ b/src/opensak/filters/__init__.py @@ -5,7 +5,7 @@ CacheTypeFilter, ContainerFilter, DifficultyFilter, TerrainFilter, FoundFilter, NotFoundFilter, AvailableFilter, ArchivedFilter, CountryFilter, StateFilter, CountyFilter, NameFilter, GcCodeFilter, PlacedByFilter, - DistanceFilter, AttributeFilter, HasTrackableFilter, + DistanceFilter, LinePolygonFilter, AttributeFilter, HasTrackableFilter, PremiumFilter, NonPremiumFilter, FilterSet, SortSpec, SORT_FIELDS, FILTER_REGISTRY, FilterProfile, apply_filters, diff --git a/src/opensak/filters/engine.py b/src/opensak/filters/engine.py index 9eea11e8..e2ccc7b9 100644 --- a/src/opensak/filters/engine.py +++ b/src/opensak/filters/engine.py @@ -30,7 +30,8 @@ from sqlalchemy.orm import Session -from opensak.db.models import Cache, UserNote +from opensak.db.models import Cache, UserNote, Waypoint +from opensak.filters.line_polygon import LP_MIN_POINTS, LP_MODES, LineShape # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -790,6 +791,107 @@ def from_dict(cls, data: dict) -> "DistanceFilter": ) +class LinePolygonFilter(BaseFilter): + """GSAK's line/polygon filter: keep caches along a line, inside a polygon + or near a set of points (see line_polygon.LineShape) — or, with + *exclude*, only the caches that don't match. + + Tests each cache's effective_coords() (corrected coordinates when set, + as on the map). *points* is a frozen snapshot taken when the filter was + built, like DistanceFilter's centre; *text* is the dialog's point list + as entered ("W," lines and comments included), kept only to + re-populate the dialog. + """ + filter_type = "line_polygon" + + # apply_to_query() below only pushes a bounding-box pre-narrowing — + # matches() makes the exact decision. See BaseFilter.sql_exact. + sql_exact = False + + def __init__( + self, + points: list[tuple[float, float]], + mode: str = "line", + distance_km: float = 0.0, + exclude: bool = False, + text: str = "", + ): + if mode not in LP_MODES: + raise ValueError(f"mode must be one of {LP_MODES}, got {mode!r}") + self.points = [(float(lat), float(lon)) for lat, lon in points] + self.mode = mode + self.distance_km = max(0.0, float(distance_km)) + self.exclude = bool(exclude) + self.text = text + self._shape = LineShape(self.points, mode, self.distance_km) + + def apply_to_query(self, query): + """Pre-narrow to the shape's bounding box (grown by the distance). + + Checked against the raw coordinates OR the corrected ones, since + matches() uses whichever applies — a puzzle whose final lies on the + line but whose posted coordinates don't must still come through. + Nothing is pushed for *exclude* (the complement of a box narrows + nothing) or when the shape has no box (poles / antimeridian). + """ + bbox = self._shape.bbox + if self.exclude or bbox is None: + return None + from sqlalchemy import and_, exists, or_ + lat_lo, lat_hi, lon_lo, lon_hi = bbox + # .correlate(Cache) — see HasCorrectedFilter.apply_to_query(). + corrected_in_box = ( + exists() + .where( + UserNote.cache_id == Cache.id, + UserNote.is_corrected == True, # noqa: E712 + UserNote.corrected_lat.between(lat_lo, lat_hi), + UserNote.corrected_lon.between(lon_lo, lon_hi), + ) + .correlate(Cache) + ) + return query.filter(or_( + and_( + Cache.latitude.between(lat_lo, lat_hi), + Cache.longitude.between(lon_lo, lon_hi), + ), + corrected_in_box, + )) + + def matches(self, cache: Cache) -> bool: + lat, lon = effective_coords(cache) + if lat is None or lon is None: + return False + return self._shape.contains(lat, lon) != self.exclude + + def to_dict(self) -> dict: + # "shape_type", not "mode": FilterSet.from_dict() reads any dict + # with a "mode" key as a nested FilterSet. + return { + "filter_type": self.filter_type, + "shape_type": self.mode, + "points": [[lat, lon] for lat, lon in self.points], + "distance_km": self.distance_km, + "exclude": self.exclude, + "text": self.text, + } + + @classmethod + def from_dict(cls, data: dict) -> "LinePolygonFilter": + return cls( + points=data.get("points", []), + mode=data.get("shape_type", "line"), + distance_km=data.get("distance_km", 0.0), + exclude=data.get("exclude", False), + text=data.get("text", ""), + ) + + def __repr__(self) -> str: + exclude = " exclude" if self.exclude else "" + return (f"") + + class AttributeFilter(BaseFilter): """ Keep caches that have a specific attribute set to *is_on*. @@ -1678,6 +1780,7 @@ def from_dict(cls, data: dict) -> "TextSearchFilter": "placed_by": PlacedByFilter, "owner_name": OwnerFilter, "distance": DistanceFilter, + "line_polygon": LinePolygonFilter, "attribute": AttributeFilter, "has_trackable": HasTrackableFilter, "has_corrected": HasCorrectedFilter, @@ -2629,6 +2732,58 @@ def effective_coords(cache) -> tuple[Optional[float], Optional[float]]: return cache.latitude, cache.longitude +def lookup_code_coords(session: Session, code: str) -> Optional[tuple[float, float]]: + """Coordinates for a GSAK-style "W," point of the line/polygon filter. + + A cache code gives that cache's effective_coords() (corrected when set); + otherwise a waypoint is looked up by its own code — wp_code (GSAK + imports), else prefix + the parent cache's code without "GC" (PK12345 + for a GC12345 parking waypoint, as GPX files name them). None for an + unknown code or a waypoint without coordinates. + """ + code = code.strip().upper() + if not code: + return None + cache = session.query(Cache).filter(Cache.gc_code == code).first() + if cache is not None: + lat, lon = effective_coords(cache) + return (lat, lon) if lat is not None and lon is not None else None + from sqlalchemy import func + has_coords = (Waypoint.latitude.is_not(None), Waypoint.longitude.is_not(None)) + wp = ( + session.query(Waypoint) + .filter(func.upper(Waypoint.wp_code) == code, *has_coords) + .first() + ) + if wp is None and len(code) > 2: + wp = ( + session.query(Waypoint) + .join(Cache, Waypoint.cache_id == Cache.id) + .filter( + func.upper(Waypoint.prefix) == code[:2], + Cache.gc_code == "GC" + code[2:], + *has_coords, + ) + .first() + ) + if wp is None or wp.latitude is None or wp.longitude is None: + return None + return wp.latitude, wp.longitude + + +def user_flagged_codes(session: Session) -> list[str]: + """GC codes of every cache with the user flag set, in user sort order + (then by code) — for the line/polygon filter's "Add flagged" button.""" + from sqlalchemy import func + rows = ( + session.query(Cache.gc_code) + .filter(Cache.user_flag == True) # noqa: E712 + .order_by(func.coalesce(Cache.user_sort, 999999), Cache.gc_code) + .all() + ) + return [row[0] for row in rows] + + def get_nearby_caches( session: Session, lat: float, diff --git a/src/opensak/filters/line_polygon.py b/src/opensak/filters/line_polygon.py new file mode 100644 index 00000000..77bd9343 --- /dev/null +++ b/src/opensak/filters/line_polygon.py @@ -0,0 +1,354 @@ +""" +src/opensak/filters/line_polygon.py — Geometry and point-list parsing for the +line/polygon filter (GSAK's "Line/Polygon" filter tab). + +Pure helpers (no Qt, no database) shared by LinePolygonFilter in engine.py and +the filter dialog: + + parse_points_text() the dialog's point list → [(lat, lon), …] + read_points_file() GPX / KML / plain-text file → [(lat, lon), …] + LineShape "is this coordinate on/near the shape?" queries + +Distances are great-circle distances on a sphere (same radius as +engine._haversine_km). Polygon containment treats the edges as straight +lines in the latitude/longitude plane, like GSAK — neither works across the +antimeridian or around the poles. +""" + +from __future__ import annotations + +import math +import re +from pathlib import Path +from typing import Callable, Optional + +from opensak.coords import parse_coords + +Point = tuple[float, float] + +EARTH_RADIUS_KM = 6371.0 + +LP_MODES: tuple[str, ...] = ("line", "polygon", "points") + +# Fewest points each mode needs to describe a shape. +LP_MIN_POINTS: dict[str, int] = {"line": 2, "polygon": 3, "points": 1} + +# "W,GC12345" — take the coordinates of a cache/waypoint from the database. +_CODE_LINE_RE = re.compile(r"^[Ww]\s*,\s*(\S+)$") + +# Long segments are indexed as sub-arcs of at most this length, so a +# latitude/longitude box around each piece stays a safe superset of the arc +# (a great circle bulges poleward between its end points). _BOX_PAD_KM covers +# the remaining bulge of such a piece, plus rounding. +_MAX_PIECE_KM = 50.0 +_BOX_PAD_KM = 1.0 +# Conservative km per degree of latitude (the real value is 110.57–111.69). +_KM_PER_DEG = 110.0 +# Upper bound on grid cells per axis for the proximity index. +_GRID_CELLS = 128 + + +# ── Parsing ────────────────────────────────────────────────────────────────── + +def parse_point(text: str) -> Optional[Point]: + """One coordinate in any format parse_coords() accepts — also with a + comma between latitude and longitude ("N 53 23.613, E 008 00.941").""" + point = parse_coords(text) + if point is None and "," in text: + point = parse_coords(text.replace(",", " ")) + return point + + +def parse_points_text( + text: str, + resolve_code: Optional[Callable[[str], Optional[Point]]] = None, +) -> tuple[list[Point], list[str]]: + """Parse the dialog's point list — one point per line. + + "W," lines are looked up with *resolve_code* (upper-cased code → + (lat, lon) or None). Blank lines and everything after "#" are ignored. + Returns (points, bad_lines): bad_lines holds every line that could not + be read (a code *resolve_code* doesn't know included), stripped. + """ + points: list[Point] = [] + bad: list[str] = [] + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + m = _CODE_LINE_RE.match(line) + if m: + point = resolve_code(m.group(1).upper()) if resolve_code else None + else: + point = parse_point(line) + if point is None: + bad.append(line) + else: + points.append(point) + return points, bad + + +def _local_name(tag: str) -> str: + """XML tag without its namespace ("{ns}trkpt" → "trkpt").""" + return tag.rsplit("}", 1)[-1] + + +def read_points_file( + path: Path, + resolve_code: Optional[Callable[[str], Optional[Point]]] = None, +) -> list[Point]: + """Points from a file, in file order. + + .gpx track points; if there are none, route points; else waypoints + .kml every element ("lon,lat[,alt]" tuples) + other plain text, one point per line as in the dialog (unreadable lines + are skipped) + + Raises OSError for an unreadable file, xml.etree.ElementTree.ParseError + for malformed XML and ValueError for malformed numbers. + """ + suffix = path.suffix.lower() + if suffix not in (".gpx", ".kml"): + text = path.read_text(encoding="utf-8", errors="replace") + return parse_points_text(text, resolve_code)[0] + + import xml.etree.ElementTree as ET + root = ET.parse(path).getroot() + if suffix == ".gpx": + for kind in ("trkpt", "rtept", "wpt"): + points = [ + (float(el.get("lat", "")), float(el.get("lon", ""))) + for el in root.iter() + if _local_name(el.tag) == kind + ] + if points: + return points + return [] + points = [] + for el in root.iter(): + if _local_name(el.tag) != "coordinates": + continue + for tuple_text in (el.text or "").split(): + parts = tuple_text.split(",") + points.append((float(parts[1]), float(parts[0]))) + return points + + +# ── Spherical geometry ─────────────────────────────────────────────────────── + +def _central_angle(phi1: float, lam1: float, phi2: float, lam2: float) -> float: + """Haversine central angle (radians) between two points in radians.""" + a = (math.sin((phi2 - phi1) / 2) ** 2 + + math.cos(phi1) * math.cos(phi2) * math.sin((lam2 - lam1) / 2) ** 2) + a = min(1.0, max(0.0, a)) + return 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def _bearing(phi1: float, lam1: float, phi2: float, lam2: float) -> float: + """Initial great-circle bearing (radians) from point 1 to point 2.""" + dlam = lam2 - lam1 + return math.atan2( + math.sin(dlam) * math.cos(phi2), + math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlam), + ) + + +def _intermediate(a: Point, b: Point, fraction: float) -> Point: + """The point *fraction* of the way along the great circle from a to b.""" + phi1, lam1 = math.radians(a[0]), math.radians(a[1]) + phi2, lam2 = math.radians(b[0]), math.radians(b[1]) + delta = _central_angle(phi1, lam1, phi2, lam2) + if delta == 0.0: + return a + wa = math.sin((1 - fraction) * delta) / math.sin(delta) + wb = math.sin(fraction * delta) / math.sin(delta) + x = wa * math.cos(phi1) * math.cos(lam1) + wb * math.cos(phi2) * math.cos(lam2) + y = wa * math.cos(phi1) * math.sin(lam1) + wb * math.cos(phi2) * math.sin(lam2) + z = wa * math.sin(phi1) + wb * math.sin(phi2) + return ( + math.degrees(math.atan2(z, math.hypot(x, y))), + math.degrees(math.atan2(y, x)), + ) + + +class _Segment: + """Great-circle arc a→b (a == b is a single point), with the per-arc + values distance_km() needs precomputed.""" + __slots__ = ("a", "b", "_phi1", "_lam1", "_phi2", "_lam2", "_length", "_bearing") + + def __init__(self, a: Point, b: Point): + self.a, self.b = a, b + self._phi1, self._lam1 = math.radians(a[0]), math.radians(a[1]) + self._phi2, self._lam2 = math.radians(b[0]), math.radians(b[1]) + self._length = _central_angle(self._phi1, self._lam1, self._phi2, self._lam2) + self._bearing = _bearing(self._phi1, self._lam1, self._phi2, self._lam2) + + def distance_km(self, lat: float, lon: float) -> float: + """Shortest distance from (lat, lon) to any point of the arc.""" + phi, lam = math.radians(lat), math.radians(lon) + d13 = _central_angle(self._phi1, self._lam1, phi, lam) + if self._length == 0.0 or d13 == 0.0: + return d13 * EARTH_RADIUS_KM + rel = _bearing(self._phi1, self._lam1, phi, lam) - self._bearing + if math.cos(rel) <= 0.0: + return d13 * EARTH_RADIUS_KM # behind the start point + cross = math.asin(max(-1.0, min(1.0, math.sin(d13) * math.sin(rel)))) + cos_cross = math.cos(cross) + if cos_cross == 0.0: + return d13 * EARTH_RADIUS_KM + along = math.acos(max(-1.0, min(1.0, math.cos(d13) / cos_cross))) + if along >= self._length: + return _central_angle(self._phi2, self._lam2, phi, lam) * EARTH_RADIUS_KM + return abs(cross) * EARTH_RADIUS_KM + + +def point_segment_km(lat: float, lon: float, a: Point, b: Point) -> float: + """Shortest great-circle distance (km) from (lat, lon) to the arc a→b.""" + return _Segment(a, b).distance_km(lat, lon) + + +def _pieces(a: Point, b: Point) -> list[_Segment]: + """Arc a→b split into sub-arcs of at most _MAX_PIECE_KM.""" + whole = _Segment(a, b) + count = max(1, math.ceil(whole._length * EARTH_RADIUS_KM / _MAX_PIECE_KM)) + if count == 1: + return [whole] + stops = [a] + [_intermediate(a, b, i / count) for i in range(1, count)] + [b] + return [_Segment(p, q) for p, q in zip(stops, stops[1:])] + + +# ── Shape ──────────────────────────────────────────────────────────────────── + +BBox = tuple[float, float, float, float] # lat_lo, lat_hi, lon_lo, lon_hi + + +class LineShape: + """A line, polygon or point set plus a distance, answering contains(). + + line within *distance_km* of the polyline through the points + polygon inside the polygon (closed automatically), or within + *distance_km* of its outline + points within *distance_km* of any single point + + Built once per filter; contains() is called once per cache, so both + queries go through small spatial indexes instead of looping over every + segment/edge. + """ + + def __init__(self, points: list[Point], mode: str, distance_km: float): + if mode not in LP_MODES: + raise ValueError(f"mode must be one of {LP_MODES}, got {mode!r}") + if len(points) < LP_MIN_POINTS[mode]: + raise ValueError(f"{mode} needs at least {LP_MIN_POINTS[mode]} points") + self.mode = mode + self.distance_km = max(0.0, float(distance_km)) + + if mode == "points": + pairs = [(p, p) for p in points] + elif mode == "line": + pairs = list(zip(points, points[1:])) + else: + pairs = list(zip(points, points[1:] + points[:1])) + self._segments = [piece for a, b in pairs for piece in _pieces(a, b)] + + self._edges: list[tuple[float, float, float, float]] = [] + if mode == "polygon": + self._edges = [(a[0], a[1], b[0], b[1]) for a, b in pairs] + self._build_band_index(points) + + # Bounding box of everything contains() can accept, or None when it + # would reach a pole or wrap the antimeridian (no shortcuts then). + self.bbox: Optional[BBox] = None + self._grid: Optional[dict[tuple[int, int], list[_Segment]]] = None + reach = self.distance_km + _BOX_PAD_KM + self._dlat = reach / _KM_PER_DEG + lats = [p[0] for seg in self._segments for p in (seg.a, seg.b)] + lons = [p[1] for seg in self._segments for p in (seg.a, seg.b)] + lat_lo, lat_hi = min(lats) - self._dlat, max(lats) + self._dlat + if max(abs(lat_lo), abs(lat_hi)) >= 89.0: + return + self._dlon = reach / (_KM_PER_DEG * math.cos(math.radians(max(abs(lat_lo), abs(lat_hi))))) + lon_lo, lon_hi = min(lons) - self._dlon, max(lons) + self._dlon + if lon_lo < -180.0 or lon_hi > 180.0: + return + self.bbox = (lat_lo, lat_hi, lon_lo, lon_hi) + if self.distance_km > 0: + self._build_grid() + + # ── indexes ────────────────────────────────────────────────────────────── + + def _build_band_index(self, points: list[Point]) -> None: + """Bucket polygon edges by latitude band: a horizontal ray at some + latitude can only cross edges whose latitude range includes it.""" + self._poly_lat_lo = min(p[0] for p in points) + self._poly_lat_hi = max(p[0] for p in points) + span = self._poly_lat_hi - self._poly_lat_lo + self._band_count = max(1, min(len(self._edges), 512)) + self._band_size = span / self._band_count if span > 0 else 1.0 + self._bands: list[list[tuple[float, float, float, float]]] = [ + [] for _ in range(self._band_count) + ] + for edge in self._edges: + lo = self._band_of(min(edge[0], edge[2])) + hi = self._band_of(max(edge[0], edge[2])) + for band in range(lo, hi + 1): + self._bands[band].append(edge) + + def _band_of(self, lat: float) -> int: + band = int((lat - self._poly_lat_lo) / self._band_size) + return min(self._band_count - 1, max(0, band)) + + def _build_grid(self) -> None: + """Map grid cells to the segments whose distance-grown box touches + them, so a query only measures against nearby segments.""" + assert self.bbox is not None + lat_lo, lat_hi, lon_lo, lon_hi = self.bbox + self._cell = max((lat_hi - lat_lo) / _GRID_CELLS, + (lon_hi - lon_lo) / _GRID_CELLS, 1e-4) + grid: dict[tuple[int, int], list[_Segment]] = {} + for seg in self._segments: + r0, c0 = self._cell_of(min(seg.a[0], seg.b[0]) - self._dlat, + min(seg.a[1], seg.b[1]) - self._dlon) + r1, c1 = self._cell_of(max(seg.a[0], seg.b[0]) + self._dlat, + max(seg.a[1], seg.b[1]) + self._dlon) + for r in range(r0, r1 + 1): + for c in range(c0, c1 + 1): + grid.setdefault((r, c), []).append(seg) + self._grid = grid + + def _cell_of(self, lat: float, lon: float) -> tuple[int, int]: + assert self.bbox is not None + return (int((lat - self.bbox[0]) // self._cell), + int((lon - self.bbox[2]) // self._cell)) + + # ── queries ────────────────────────────────────────────────────────────── + + def contains(self, lat: float, lon: float) -> bool: + """True if (lat, lon) is inside/near the shape (see class docstring).""" + if self.bbox is not None: + lat_lo, lat_hi, lon_lo, lon_hi = self.bbox + if not (lat_lo <= lat <= lat_hi and lon_lo <= lon <= lon_hi): + return False + if self.mode == "polygon" and self._inside_polygon(lat, lon): + return True + if self.distance_km <= 0: + return False + return self._near(lat, lon) + + def _inside_polygon(self, lat: float, lon: float) -> bool: + if not (self._poly_lat_lo <= lat <= self._poly_lat_hi): + return False + inside = False + for lat1, lon1, lat2, lon2 in self._bands[self._band_of(lat)]: + if (lat1 > lat) != (lat2 > lat): + cross_lon = lon1 + (lat - lat1) * (lon2 - lon1) / (lat2 - lat1) + if lon < cross_lon: + inside = not inside + return inside + + def _near(self, lat: float, lon: float) -> bool: + if self._grid is not None: + candidates = self._grid.get(self._cell_of(lat, lon), ()) + else: + candidates = self._segments + return any(seg.distance_km(lat, lon) <= self.distance_km for seg in candidates) diff --git a/src/opensak/gui/dialogs/filter_dialog.py b/src/opensak/gui/dialogs/filter_dialog.py index d0197355..a48f2bb3 100644 --- a/src/opensak/gui/dialogs/filter_dialog.py +++ b/src/opensak/gui/dialogs/filter_dialog.py @@ -1,28 +1,30 @@ """ src/opensak/gui/dialogs/filter_dialog.py — Komplet filter dialog. -Seks faner: +Syv faner: 1. Generelt — navn, type, D/T, afstand, fundet, tilgængelighed osv. 2. Datoer — udlagt dato, fundet dato, DNF dato, seneste log dato 3. Øvrigt — land/stat/kommune, user flag, DNF, favorit points -4. Attributter — alle Groundspeak attributter -5. Tekstsøgning — søg i beskrivelse, logs, noter og hint -6. Where — rå SQL WHERE-betingelse +4. Linje/Polygon — caches langs en linje, i et polygon eller nær punkter +5. Attributter — alle Groundspeak attributter +6. Tekstsøgning — søg i beskrivelse, logs, noter og hint +7. Where — rå SQL WHERE-betingelse Understøtter gem/indlæs filterprofiler. """ from __future__ import annotations from datetime import date +from pathlib import Path from typing import Optional from PySide6.QtCore import Qt, QSize, Signal from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, - QLabel, QLineEdit, QCheckBox, QPushButton, + QLabel, QLineEdit, QCheckBox, QPushButton, QRadioButton, QComboBox, QDoubleSpinBox, QSpinBox, QTabWidget, QWidget, QGroupBox, QScrollArea, QGridLayout, - QDialogButtonBox, QMessageBox, QInputDialog, + QDialogButtonBox, QMessageBox, QInputDialog, QFileDialog, QDateEdit, QSizePolicy, QFrame, QPlainTextEdit, QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView, ) @@ -40,6 +42,7 @@ CountryFilter, StateFilter, CountyFilter, NameFilter, GcCodeFilter, PlacedByFilter, OwnerFilter, DistanceFilter, + LinePolygonFilter, lookup_code_coords, user_flagged_codes, TextMatchFilter, TEXT_OPS_VALUELESS, AttributeFilter, HasTrackableFilter, HasCorrectedFilter, NoCorrectedFilter, PremiumFilter, NonPremiumFilter, @@ -49,6 +52,7 @@ TextSearchFilter, FilterProfile, ) +from opensak.filters.line_polygon import LP_MIN_POINTS, parse_points_text, read_points_file # ── Groundspeak attribut definitioner ───────────────────────────────────────── @@ -327,6 +331,22 @@ def _on_op_changed(self) -> None: _DATE_OPS_RELATIVE = ("during", "not_during") +# ── Linje/polygon-fanen ─────────────────────────────────────────────────────── + +# (filtertype, oversættelsesnøgle) i GSAK's rækkefølge. Nøglerne står som +# literals, så test_no_unused_keys kan finde dem. +_LP_MODE_LABELS: tuple[tuple[str, str], ...] = ( + ("line", "filter_lp_type_line"), + ("polygon", "filter_lp_type_polygon"), + ("points", "filter_lp_type_points"), +) +_LP_DEFAULT_DISTANCE = 1.0 # i brugerens enhed (km / mi) + + +def _format_lp_point(point: tuple[float, float]) -> str: + return f"{point[0]:.6f}, {point[1]:.6f}" + + def _qdate_to_date(qdate: QDate) -> date: return date(qdate.year(), qdate.month(), qdate.day()) @@ -557,12 +577,14 @@ def _setup_ui(self) -> None: self._general_tab = self._build_general_tab() self._dates_tab = self._build_dates_tab() self._misc_tab = self._build_misc_tab() + self._line_polygon_tab = self._build_line_polygon_tab() self._attributes_tab = self._build_attributes_tab() self._text_search_tab = self._build_text_search_tab() self._where_tab = self._build_where_tab() self._tabs.addTab(self._general_tab, tr("settings_tab_general")) self._tabs.addTab(self._dates_tab, tr("filter_tab_dates")) self._tabs.addTab(self._misc_tab, tr("filter_tab_misc")) + self._tabs.addTab(self._line_polygon_tab, tr("filter_tab_line_polygon")) self._tabs.addTab(self._attributes_tab, tr("filter_tab_attributes")) self._tabs.addTab(self._text_search_tab, tr("filter_tab_text_search")) self._tabs.addTab(self._where_tab, tr("filter_tab_where")) @@ -923,6 +945,78 @@ def _build_misc_tab(self) -> QWidget: outer_layout.addWidget(scroll) return outer + def _build_line_polygon_tab(self) -> QWidget: + """Linje/Polygon fane — GSAK's linje-/polygonfilter: caches langs en + rute, inden for et område eller nær en række punkter.""" + from opensak.gui.settings import get_settings as _gs + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setSpacing(12) + layout.setContentsMargins(10, 10, 10, 10) + + # Venstre: punktliste, markerede caches, punkter fra fil + left = QVBoxLayout() + left.addWidget(QLabel(tr("filter_lp_points_label"))) + self._lp_text = QPlainTextEdit() + self._lp_text.setPlaceholderText(tr("filter_lp_points_placeholder")) + self._lp_text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + left.addWidget(self._lp_text, 1) + + flagged_btn = QPushButton(tr("filter_lp_add_flagged_btn")) + flagged_btn.setAutoDefault(False) + flagged_btn.clicked.connect(self._add_flagged_points) + left.addWidget(flagged_btn, alignment=Qt.AlignmentFlag.AlignLeft) + + file_group = QGroupBox(tr("filter_lp_file_group")) + file_layout = QVBoxLayout(file_group) + file_btn = QPushButton(tr("filter_lp_choose_file_btn")) + file_btn.setAutoDefault(False) + file_btn.clicked.connect(self._load_points_file) + file_layout.addWidget(file_btn, alignment=Qt.AlignmentFlag.AlignLeft) + file_mode_row = QHBoxLayout() + self._lp_replace = QRadioButton(tr("filter_lp_replace")) + self._lp_replace.setChecked(True) + self._lp_append = QRadioButton(tr("filter_lp_append")) + file_mode_row.addWidget(self._lp_replace) + file_mode_row.addWidget(self._lp_append) + file_mode_row.addStretch() + file_layout.addLayout(file_mode_row) + left.addWidget(file_group) + layout.addLayout(left, 1) + + # Højre: forklaring, filtertype, afstand, udeluk + right = QVBoxLayout() + desc_label = QLabel(tr("filter_lp_description")) + desc_label.setWordWrap(True) + right.addWidget(desc_label) + + type_group = QGroupBox(tr("filter_lp_type_group")) + type_layout = QHBoxLayout(type_group) + self._lp_mode_buttons: dict[str, QRadioButton] = {} + for mode, key in _LP_MODE_LABELS: + button = QRadioButton(tr(key)) + type_layout.addWidget(button) + self._lp_mode_buttons[mode] = button + self._lp_mode_buttons["line"].setChecked(True) + right.addWidget(type_group) + + dist_row = QHBoxLayout() + dist_row.addWidget(QLabel(tr("filter_lp_distance_label"))) + self._lp_distance = QDoubleSpinBox() + self._lp_distance.setRange(0.0, 99999.0) + self._lp_distance.setDecimals(3) + self._lp_distance.setValue(_LP_DEFAULT_DISTANCE) + self._lp_distance.setSuffix(" mi" if _gs().use_miles else " km") + dist_row.addWidget(self._lp_distance) + dist_row.addStretch() + right.addLayout(dist_row) + + self._lp_exclude = QCheckBox(tr("filter_lp_exclude")) + right.addWidget(self._lp_exclude) + right.addStretch() + layout.addLayout(right, 1) + return widget + def _build_attributes_tab(self) -> QWidget: """Attributter filter fane med scrollbar.""" outer = QWidget() @@ -1223,6 +1317,104 @@ def _validate_text_filters(self) -> bool: # ── Slots ───────────────────────────────────────────────────────────────── + # ── Linje/Polygon ──────────────────────────────────────────────────────── + + def _lp_mode(self) -> str: + for mode, button in self._lp_mode_buttons.items(): + if button.isChecked(): + return mode + return "line" + + def _lp_distance_km(self) -> float: + from opensak.gui.settings import get_settings as _gs + value = self._lp_distance.value() + return value * 1.60934 if _gs().use_miles else value + + @staticmethod + def _resolve_point_code(code: str) -> Optional[tuple[float, float]]: + """Coordinates for a "W," line, from the open database.""" + try: + from opensak.db.database import get_session + with get_session() as session: + return lookup_code_coords(session, code) + except Exception: + return None + + def _lp_points(self) -> tuple[list[tuple[float, float]], list[str]]: + return parse_points_text(self._lp_text.toPlainText(), self._resolve_point_code) + + def _build_line_polygon_filter(self) -> Optional[LinePolygonFilter]: + """Filter for the Line/Polygon tab, or None when the tab is unused or + incomplete — _validate_line_polygon() tells the user why.""" + points, bad = self._lp_points() + mode = self._lp_mode() + distance_km = self._lp_distance_km() + if bad or len(points) < LP_MIN_POINTS[mode]: + return None + if mode != "polygon" and distance_km <= 0: + return None + return LinePolygonFilter( + points, mode, distance_km, + exclude=self._lp_exclude.isChecked(), + text=self._lp_text.toPlainText().strip(), + ) + + def _validate_line_polygon(self) -> bool: + """Warn about, and show, a Line/Polygon tab that is filled in but + can't be used: unreadable lines, too few points or no distance.""" + points, bad = self._lp_points() + if not points and not bad: + return True # fanen er ikke i brug + mode = self._lp_mode() + if bad: + message = tr("filter_lp_invalid_lines", lines="\n".join(bad[:10])) + elif len(points) < LP_MIN_POINTS[mode]: + message = tr("filter_lp_too_few_points", count=LP_MIN_POINTS[mode]) + elif mode != "polygon" and self._lp_distance_km() <= 0: + message = tr("filter_lp_distance_required") + else: + return True + self._tabs.setCurrentWidget(self._line_polygon_tab) + QMessageBox.warning(self, tr("warning"), message) + return False + + def _add_lp_lines(self, lines: list[str], replace: bool = False) -> None: + current = "" if replace else self._lp_text.toPlainText().rstrip() + added = "\n".join(lines) + self._lp_text.setPlainText(f"{current}\n{added}" if current else added) + + def _add_flagged_points(self) -> None: + """Tilføj en "W,"-linje for hver cache med user flag.""" + try: + from opensak.db.database import get_session + with get_session() as session: + codes = user_flagged_codes(session) + except Exception: + codes = [] + if not codes: + QMessageBox.information(self, tr("filter_tab_line_polygon"), tr("filter_lp_no_flagged")) + return + self._add_lp_lines([f"W,{code}" for code in codes]) + + def _load_points_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, tr("filter_lp_file_group"), "", tr("filter_lp_file_filter"), + ) + if not path: + return + try: + points = read_points_file(Path(path), self._resolve_point_code) + except Exception as exc: + QMessageBox.warning(self, tr("error"), tr("filter_lp_file_error", error=exc)) + return + if not points: + QMessageBox.warning(self, tr("warning"), tr("filter_lp_file_no_points")) + return + self._add_lp_lines( + [_format_lp_point(p) for p in points], + replace=self._lp_replace.isChecked(), + ) + def _on_dist_toggled(self, checked: bool) -> None: self._dist_max.setEnabled(checked) self._dist_min.setEnabled(checked) @@ -1299,10 +1491,18 @@ def _reset_text_search(self) -> None: self._text_search_notes.setChecked(True) self._text_search_hint.setChecked(False) + def _reset_line_polygon(self) -> None: + self._lp_text.clear() + self._lp_mode_buttons["line"].setChecked(True) + self._lp_distance.setValue(_LP_DEFAULT_DISTANCE) + self._lp_exclude.setChecked(False) + self._lp_replace.setChecked(True) + def _reset_all(self) -> None: self._reset_general() self._reset_dates() self._reset_misc() + self._reset_line_polygon() self._reset_attributes() self._reset_text_search() if self._where_tab is not None: @@ -1320,6 +1520,8 @@ def _reset_current_tab(self) -> None: self._reset_dates() elif tab is self._misc_tab: self._reset_misc() + elif tab is self._line_polygon_tab: + self._reset_line_polygon() elif tab is self._attributes_tab: self._reset_attributes() elif tab is self._text_search_tab: @@ -1487,6 +1689,11 @@ def _build_filterset(self) -> FilterSet: max_pts=int(self._fav_max.value()), )) + # Linje/Polygon + lp_filter = self._build_line_polygon_filter() + if lp_filter is not None: + fs.add(lp_filter) + # Attributter attr_mode_and = self._attr_mode_all.isChecked() attr_filters = [] @@ -1700,6 +1907,16 @@ def _load_filterset(self, fs: FilterSet) -> None: elif ftype == "no_corrected": self._cc_yes.setChecked(False) self._cc_no.setChecked(True) + elif ftype == "line_polygon": + self._lp_text.setPlainText( + f.text or "\n".join(_format_lp_point(p) for p in f.points) + ) + self._lp_mode_buttons[f.mode].setChecked(True) + from opensak.gui.settings import get_settings as _gs + self._lp_distance.setValue( + f.distance_km * 0.621371 if _gs().use_miles else f.distance_km + ) + self._lp_exclude.setChecked(f.exclude) elif ftype == "attribute": attr_id = getattr(f, "attribute_id", None) is_on = getattr(f, "is_on", True) @@ -1771,6 +1988,8 @@ def _apply(self) -> None: # Et ugyldigt regulært udtryk ville stille matche ingenting — afvis det if not self._validate_text_filters(): return + if not self._validate_line_polygon(): + return fs = self._build_filterset() profile_name = ( diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 66535964..535d9955 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -689,6 +689,28 @@ "filter_date_cmp_newer_or_equal":"rovno nebo novější", "filter_date_cmp_within": "v rozmezí", "filter_date_cmp_outside": "mimo rozmezí", + "filter_tab_line_polygon": "Linie/Polygon", + "filter_lp_points_label": "Body linie/polygonu", + "filter_lp_points_placeholder": "Zeměpisná šířka, délka — jeden bod na řádek, např.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 použije souřadnice keše nebo waypointu z databáze.\nText za # se ignoruje.", + "filter_lp_add_flagged_btn": "Přidat označené (user flag)", + "filter_lp_no_flagged": "Žádná keš nemá nastavený user flag.", + "filter_lp_file_group": "Načíst body ze souboru", + "filter_lp_choose_file_btn": "Vybrat soubor…", + "filter_lp_replace": "Nahradit", + "filter_lp_append": "Připojit", + "filter_lp_file_filter": "Soubory bodů (*.gpx *.kml *.txt *.csv);;Všechny soubory (*)", + "filter_lp_file_error": "Soubor nelze načíst:\n{error}", + "filter_lp_file_no_points": "V souboru nebyly nalezeny žádné body.", + "filter_lp_description": "Filtr linie vybírá keše podle toho, jak blízko leží u linie — řady propojených bodů, například trasy nebo stopy. Zadejte alespoň dva body a vzdálenost od linie, do které se keše zahrnou.\n\nPolygon: body ohraničují oblast (uzavře se automaticky, alespoň tři body) a zahrnou se keše uvnitř. Vzdálenost větší než 0 zahrne navíc keše v této vzdálenosti od okraje.\n\nBody: zahrnou se keše do zadané vzdálenosti od kteréhokoli bodu.\n\nZaškrtněte „Vyloučit“, chcete-li místo toho ponechat jen keše, které nevyhovují. Použijí se opravené souřadnice, pokud jsou nastavené.", + "filter_lp_type_group": "Typ filtru", + "filter_lp_type_line": "Linie", + "filter_lp_type_polygon": "Polygon", + "filter_lp_type_points": "Body", + "filter_lp_distance_label": "Vzdálenost:", + "filter_lp_exclude": "Vyloučit", + "filter_lp_invalid_lines": "Tyto body linie/polygonu nelze načíst:\n{lines}", + "filter_lp_too_few_points": "Zvolený typ filtru vyžaduje alespoň {count} body.", + "filter_lp_distance_required": "Pro filtr linie nebo bodů zadejte vzdálenost větší než 0.", "filter_caches_with": "Keše, které mají:", "filter_all_selected": "VŠECHNY vybrané atributy", "filter_attr_col_name": "Atribut", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index 4f6731f5..ad220de9 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -689,6 +689,28 @@ "filter_date_cmp_newer_or_equal":"lig med eller nyere", "filter_date_cmp_within": "inden for", "filter_date_cmp_outside": "uden for", + "filter_tab_line_polygon": "Linje/Polygon", + "filter_lp_points_label": "Linje-/polygonpunkter", + "filter_lp_points_placeholder": "Breddegrad, længdegrad — ét punkt pr. linje, fx\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 bruger koordinaterne for en cache eller et waypoint i databasen.\nTekst efter # ignoreres.", + "filter_lp_add_flagged_btn": "Tilføj markerede (user flag)", + "filter_lp_no_flagged": "Ingen caches har user flag sat.", + "filter_lp_file_group": "Læs punkter fra fil", + "filter_lp_choose_file_btn": "Vælg fil…", + "filter_lp_replace": "Erstat", + "filter_lp_append": "Tilføj til sidst", + "filter_lp_file_filter": "Punktfiler (*.gpx *.kml *.txt *.csv);;Alle filer (*)", + "filter_lp_file_error": "Filen kunne ikke læses:\n{error}", + "filter_lp_file_no_points": "Der blev ikke fundet nogen punkter i filen.", + "filter_lp_description": "Linjefilteret vælger caches efter, hvor tæt de ligger på en linje — en række forbundne punkter, fx en rute eller et spor. Angiv mindst to punkter og den afstand fra linjen, inden for hvilken caches medtages.\n\nPolygon: punkterne afgrænser et område (lukkes automatisk, mindst tre punkter), og caches inden for det medtages. En afstand større end 0 medtager desuden caches så tæt på kanten.\n\nPunkter: caches inden for afstanden af et af punkterne medtages.\n\nSæt flueben i \"Udeluk\" for i stedet kun at beholde de caches, der ikke passer. Korrigerede koordinater bruges, når de er sat.", + "filter_lp_type_group": "Filtertype", + "filter_lp_type_line": "Linje", + "filter_lp_type_polygon": "Polygon", + "filter_lp_type_points": "Punkter", + "filter_lp_distance_label": "Afstand:", + "filter_lp_exclude": "Udeluk", + "filter_lp_invalid_lines": "Disse linje-/polygonpunkter kunne ikke læses:\n{lines}", + "filter_lp_too_few_points": "Den valgte filtertype kræver mindst {count} punkter.", + "filter_lp_distance_required": "Angiv en afstand større end 0 for et linje- eller punktfilter.", "filter_caches_with": "Cacher der har:", "filter_all_selected": "ALLE valgte attributter", "filter_attr_col_name": "Attribut", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 9a4acbee..8d725c8c 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -689,6 +689,28 @@ "filter_date_cmp_newer_or_equal":"gleich oder neuer", "filter_date_cmp_within": "innerhalb", "filter_date_cmp_outside": "außerhalb", + "filter_tab_line_polygon": "Linie/Polygon", + "filter_lp_points_label": "Linien-/Polygon-Punkte", + "filter_lp_points_placeholder": "Breitengrad, Längengrad — ein Punkt pro Zeile, z. B.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 übernimmt die Koordinaten eines Caches oder Wegpunkts aus der Datenbank.\nText nach # wird ignoriert.", + "filter_lp_add_flagged_btn": "Markierte (User-Flag) hinzufügen", + "filter_lp_no_flagged": "Kein Cache hat das User-Flag gesetzt.", + "filter_lp_file_group": "Punkte aus Datei lesen", + "filter_lp_choose_file_btn": "Datei wählen…", + "filter_lp_replace": "Ersetzen", + "filter_lp_append": "Anfügen", + "filter_lp_file_filter": "Punktdateien (*.gpx *.kml *.txt *.csv);;Alle Dateien (*)", + "filter_lp_file_error": "Die Datei konnte nicht gelesen werden:\n{error}", + "filter_lp_file_no_points": "In der Datei wurden keine Punkte gefunden.", + "filter_lp_description": "Der Linienfilter wählt Caches danach aus, wie nahe sie an einer Linie liegen — einer Folge verbundener Punkte, ähnlich einer Route oder einem Track. Gib mindestens zwei Punkte und die Entfernung zur Linie an, innerhalb der Caches berücksichtigt werden.\n\nPolygon: Die Punkte umranden ein Gebiet (wird automatisch geschlossen, mindestens drei Punkte); Caches darin werden berücksichtigt. Eine Entfernung größer 0 schließt zusätzlich Caches in dieser Entfernung zum Rand ein.\n\nPunkte: Caches innerhalb der Entfernung zu einem der Punkte werden berücksichtigt.\n\nSetze den Haken bei „Ausschließen“, um stattdessen nur die nicht passenden Caches zu behalten. Korrigierte Koordinaten werden verwendet, sofern vorhanden.", + "filter_lp_type_group": "Filter-Typ", + "filter_lp_type_line": "Linie", + "filter_lp_type_polygon": "Polygon", + "filter_lp_type_points": "Punkte", + "filter_lp_distance_label": "Entfernung:", + "filter_lp_exclude": "Ausschließen", + "filter_lp_invalid_lines": "Diese Linien-/Polygon-Punkte konnten nicht gelesen werden:\n{lines}", + "filter_lp_too_few_points": "Der gewählte Filter-Typ benötigt mindestens {count} Punkte.", + "filter_lp_distance_required": "Gib für einen Linien- oder Punktefilter eine Entfernung größer 0 ein.", "filter_caches_with": "Caches mit:", "filter_all_selected": "ALLE gewählten Attribute", "filter_attr_col_name": "Attribut", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 2470db80..d9af8822 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -688,6 +688,28 @@ "filter_date_cmp_newer_or_equal":"equal or newer", "filter_date_cmp_within": "within", "filter_date_cmp_outside": "outside", + "filter_tab_line_polygon": "Line/Polygon", + "filter_lp_points_label": "Line/polygon points", + "filter_lp_points_placeholder": "Latitude, longitude — one point per line, e.g.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 uses the coordinates of a cache or waypoint in the database.\nText after # is ignored.", + "filter_lp_add_flagged_btn": "Add flagged (user flag)", + "filter_lp_no_flagged": "No caches have the user flag set.", + "filter_lp_file_group": "Read points from file", + "filter_lp_choose_file_btn": "Choose file…", + "filter_lp_replace": "Replace", + "filter_lp_append": "Append", + "filter_lp_file_filter": "Point files (*.gpx *.kml *.txt *.csv);;All files (*)", + "filter_lp_file_error": "Could not read the file:\n{error}", + "filter_lp_file_no_points": "No points found in the file.", + "filter_lp_description": "The line filter selects caches by how close they are to a line — a series of connected points, like a route or a track. Enter at least two points and the distance from the line within which caches are included.\n\nPolygon: the points outline an area (closed automatically, at least three points) and caches inside it are included. A distance greater than 0 also includes caches that close to the outline.\n\nPoints: caches within the distance of any of the points are included.\n\nCheck \"Exclude\" to keep only the caches that do not match instead. Corrected coordinates are used when set.", + "filter_lp_type_group": "Filter type", + "filter_lp_type_line": "Line", + "filter_lp_type_polygon": "Polygon", + "filter_lp_type_points": "Points", + "filter_lp_distance_label": "Distance:", + "filter_lp_exclude": "Exclude", + "filter_lp_invalid_lines": "These line/polygon points could not be read:\n{lines}", + "filter_lp_too_few_points": "The selected filter type needs at least {count} points.", + "filter_lp_distance_required": "Enter a distance greater than 0 for a line or points filter.", "filter_caches_with": "Caches that have:", "filter_all_selected": "ALL selected attributes", "filter_attr_col_name": "Attribute", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index f1a8a777..8c72acdb 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -690,6 +690,28 @@ "filter_date_cmp_newer_or_equal":"igual o más reciente", "filter_date_cmp_within": "dentro de", "filter_date_cmp_outside": "fuera de", + "filter_tab_line_polygon": "Línea/Polígono", + "filter_lp_points_label": "Puntos de línea/polígono", + "filter_lp_points_placeholder": "Latitud, longitud — un punto por línea, p. ej.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 usa las coordenadas de una caché o waypoint de la base de datos.\nEl texto después de # se ignora.", + "filter_lp_add_flagged_btn": "Añadir marcadas (user flag)", + "filter_lp_no_flagged": "Ninguna caché tiene activado el user flag.", + "filter_lp_file_group": "Leer puntos de un archivo", + "filter_lp_choose_file_btn": "Elegir archivo…", + "filter_lp_replace": "Reemplazar", + "filter_lp_append": "Añadir al final", + "filter_lp_file_filter": "Archivos de puntos (*.gpx *.kml *.txt *.csv);;Todos los archivos (*)", + "filter_lp_file_error": "No se pudo leer el archivo:\n{error}", + "filter_lp_file_no_points": "No se encontraron puntos en el archivo.", + "filter_lp_description": "El filtro de línea selecciona cachés según su cercanía a una línea — una serie de puntos conectados, como una ruta o un track. Introduce al menos dos puntos y la distancia a la línea dentro de la cual se incluyen las cachés.\n\nPolígono: los puntos delimitan un área (se cierra automáticamente, al menos tres puntos) y se incluyen las cachés dentro de ella. Una distancia mayor que 0 incluye además las cachés así de cerca del contorno.\n\nPuntos: se incluyen las cachés dentro de la distancia de cualquiera de los puntos.\n\nMarca \"Excluir\" para quedarte en su lugar solo con las cachés que no coinciden. Se usan las coordenadas corregidas cuando existen.", + "filter_lp_type_group": "Tipo de filtro", + "filter_lp_type_line": "Línea", + "filter_lp_type_polygon": "Polígono", + "filter_lp_type_points": "Puntos", + "filter_lp_distance_label": "Distancia:", + "filter_lp_exclude": "Excluir", + "filter_lp_invalid_lines": "No se pudieron leer estos puntos de línea/polígono:\n{lines}", + "filter_lp_too_few_points": "El tipo de filtro elegido necesita al menos {count} puntos.", + "filter_lp_distance_required": "Introduce una distancia mayor que 0 para un filtro de línea o de puntos.", "filter_caches_with": "Cachés que tienen:", "filter_all_selected": "TODOS los atributos seleccionados", "filter_attr_col_name": "Atributo", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 832bfff7..a5559b76 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -690,6 +690,28 @@ "filter_date_cmp_newer_or_equal":"égal ou plus récent", "filter_date_cmp_within": "à moins de", "filter_date_cmp_outside": "à plus de", + "filter_tab_line_polygon": "Ligne/Polygone", + "filter_lp_points_label": "Points de la ligne/du polygone", + "filter_lp_points_placeholder": "Latitude, longitude — un point par ligne, p. ex.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 utilise les coordonnées d'une cache ou d'un waypoint de la base de données.\nLe texte après # est ignoré.", + "filter_lp_add_flagged_btn": "Ajouter les marquées (user flag)", + "filter_lp_no_flagged": "Aucune cache n'a le user flag activé.", + "filter_lp_file_group": "Lire les points depuis un fichier", + "filter_lp_choose_file_btn": "Choisir un fichier…", + "filter_lp_replace": "Remplacer", + "filter_lp_append": "Ajouter à la suite", + "filter_lp_file_filter": "Fichiers de points (*.gpx *.kml *.txt *.csv);;Tous les fichiers (*)", + "filter_lp_file_error": "Impossible de lire le fichier :\n{error}", + "filter_lp_file_no_points": "Aucun point trouvé dans le fichier.", + "filter_lp_description": "Le filtre ligne sélectionne les caches selon leur proximité d'une ligne — une suite de points reliés, comme un itinéraire ou une trace. Saisissez au moins deux points et la distance à la ligne en deçà de laquelle les caches sont retenues.\n\nPolygone : les points délimitent une zone (fermée automatiquement, au moins trois points) et les caches à l'intérieur sont retenues. Une distance supérieure à 0 retient aussi les caches aussi proches du contour.\n\nPoints : les caches situées à moins de cette distance de l'un des points sont retenues.\n\nCochez « Exclure » pour ne garder à la place que les caches qui ne correspondent pas. Les coordonnées corrigées sont utilisées lorsqu'elles existent.", + "filter_lp_type_group": "Type de filtre", + "filter_lp_type_line": "Ligne", + "filter_lp_type_polygon": "Polygone", + "filter_lp_type_points": "Points", + "filter_lp_distance_label": "Distance :", + "filter_lp_exclude": "Exclure", + "filter_lp_invalid_lines": "Ces points de ligne/polygone n'ont pas pu être lus :\n{lines}", + "filter_lp_too_few_points": "Le type de filtre choisi nécessite au moins {count} points.", + "filter_lp_distance_required": "Saisissez une distance supérieure à 0 pour un filtre ligne ou points.", "filter_caches_with": "Caches qui ont:", "filter_all_selected": "TOUS les attributs sélectionnés", "filter_attr_col_name": "Attribut", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index c5c1178f..88162ba3 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -692,6 +692,28 @@ "filter_date_cmp_newer_or_equal":"gelijk of nieuwer", "filter_date_cmp_within": "binnen", "filter_date_cmp_outside": "buiten", + "filter_tab_line_polygon": "Lijn/Polygoon", + "filter_lp_points_label": "Lijn-/polygoonpunten", + "filter_lp_points_placeholder": "Breedtegraad, lengtegraad — één punt per regel, bijv.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 gebruikt de coördinaten van een cache of waypoint in de database.\nTekst na # wordt genegeerd.", + "filter_lp_add_flagged_btn": "Gemarkeerde (user flag) toevoegen", + "filter_lp_no_flagged": "Geen enkele cache heeft de user flag ingesteld.", + "filter_lp_file_group": "Punten uit bestand lezen", + "filter_lp_choose_file_btn": "Bestand kiezen…", + "filter_lp_replace": "Vervangen", + "filter_lp_append": "Achteraan toevoegen", + "filter_lp_file_filter": "Puntbestanden (*.gpx *.kml *.txt *.csv);;Alle bestanden (*)", + "filter_lp_file_error": "Het bestand kon niet worden gelezen:\n{error}", + "filter_lp_file_no_points": "Geen punten gevonden in het bestand.", + "filter_lp_description": "Het lijnfilter selecteert caches op basis van hoe dicht ze bij een lijn liggen — een reeks verbonden punten, zoals een route of track. Voer minstens twee punten in en de afstand tot de lijn waarbinnen caches worden meegenomen.\n\nPolygoon: de punten omsluiten een gebied (automatisch gesloten, minstens drie punten) en caches daarbinnen worden meegenomen. Een afstand groter dan 0 neemt ook caches mee die zo dicht bij de rand liggen.\n\nPunten: caches binnen de afstand van een van de punten worden meegenomen.\n\nVink \"Uitsluiten\" aan om in plaats daarvan alleen de caches te houden die niet overeenkomen. Gecorrigeerde coördinaten worden gebruikt als ze zijn ingesteld.", + "filter_lp_type_group": "Filtertype", + "filter_lp_type_line": "Lijn", + "filter_lp_type_polygon": "Polygoon", + "filter_lp_type_points": "Punten", + "filter_lp_distance_label": "Afstand:", + "filter_lp_exclude": "Uitsluiten", + "filter_lp_invalid_lines": "Deze lijn-/polygoonpunten konden niet worden gelezen:\n{lines}", + "filter_lp_too_few_points": "Het gekozen filtertype heeft minstens {count} punten nodig.", + "filter_lp_distance_required": "Voer een afstand groter dan 0 in voor een lijn- of puntenfilter.", "filter_caches_with": "Caches met:", "filter_all_selected": "ALLE geselecteerde attributen", "filter_attr_col_name": "Attribuut", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 36594edf..4990df9e 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -690,6 +690,28 @@ "filter_date_cmp_newer_or_equal":"równa lub nowsza", "filter_date_cmp_within": "w zakresie", "filter_date_cmp_outside": "poza zakresem", + "filter_tab_line_polygon": "Linia/Wielokąt", + "filter_lp_points_label": "Punkty linii/wielokąta", + "filter_lp_points_placeholder": "Szerokość, długość geograficzna — jeden punkt w wierszu, np.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 używa współrzędnych skrytki lub waypointu z bazy danych.\nTekst po # jest ignorowany.", + "filter_lp_add_flagged_btn": "Dodaj oznaczone (user flag)", + "filter_lp_no_flagged": "Żadna skrytka nie ma ustawionej flagi użytkownika.", + "filter_lp_file_group": "Wczytaj punkty z pliku", + "filter_lp_choose_file_btn": "Wybierz plik…", + "filter_lp_replace": "Zastąp", + "filter_lp_append": "Dołącz", + "filter_lp_file_filter": "Pliki punktów (*.gpx *.kml *.txt *.csv);;Wszystkie pliki (*)", + "filter_lp_file_error": "Nie można odczytać pliku:\n{error}", + "filter_lp_file_no_points": "W pliku nie znaleziono punktów.", + "filter_lp_description": "Filtr linii wybiera skrytki według odległości od linii — ciągu połączonych punktów, np. trasy lub śladu. Podaj co najmniej dwa punkty oraz odległość od linii, w której skrytki są uwzględniane.\n\nWielokąt: punkty wyznaczają obszar (zamykany automatycznie, co najmniej trzy punkty), a skrytki wewnątrz niego są uwzględniane. Odległość większa niż 0 obejmuje dodatkowo skrytki w tej odległości od krawędzi.\n\nPunkty: uwzględniane są skrytki w podanej odległości od dowolnego z punktów.\n\nZaznacz „Wyklucz”, aby zamiast tego zachować tylko skrytki, które nie pasują. Używane są poprawione współrzędne, jeśli są ustawione.", + "filter_lp_type_group": "Typ filtra", + "filter_lp_type_line": "Linia", + "filter_lp_type_polygon": "Wielokąt", + "filter_lp_type_points": "Punkty", + "filter_lp_distance_label": "Odległość:", + "filter_lp_exclude": "Wyklucz", + "filter_lp_invalid_lines": "Nie można odczytać tych punktów linii/wielokąta:\n{lines}", + "filter_lp_too_few_points": "Wybrany typ filtra wymaga co najmniej {count} punktów.", + "filter_lp_distance_required": "Podaj odległość większą niż 0 dla filtra linii lub punktów.", "filter_caches_with": "Skrytki, które mają:", "filter_all_selected": "WSZYSTKIE wybrane atrybuty", "filter_attr_col_name": "Atrybut", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index f6c53598..bfdd6571 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -689,6 +689,28 @@ "filter_date_cmp_newer_or_equal":"igual ou mais recente", "filter_date_cmp_within": "dentro de", "filter_date_cmp_outside": "fora de", + "filter_tab_line_polygon": "Linha/Polígono", + "filter_lp_points_label": "Pontos da linha/polígono", + "filter_lp_points_placeholder": "Latitude, longitude — um ponto por linha, p. ex.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 usa as coordenadas de uma cache ou waypoint da base de dados.\nO texto após # é ignorado.", + "filter_lp_add_flagged_btn": "Adicionar marcadas (user flag)", + "filter_lp_no_flagged": "Nenhuma cache tem o user flag ativado.", + "filter_lp_file_group": "Ler pontos de um ficheiro", + "filter_lp_choose_file_btn": "Escolher ficheiro…", + "filter_lp_replace": "Substituir", + "filter_lp_append": "Acrescentar", + "filter_lp_file_filter": "Ficheiros de pontos (*.gpx *.kml *.txt *.csv);;Todos os ficheiros (*)", + "filter_lp_file_error": "Não foi possível ler o ficheiro:\n{error}", + "filter_lp_file_no_points": "Não foram encontrados pontos no ficheiro.", + "filter_lp_description": "O filtro de linha seleciona caches pela proximidade a uma linha — uma série de pontos ligados, como uma rota ou um trilho. Introduza pelo menos dois pontos e a distância à linha dentro da qual as caches são incluídas.\n\nPolígono: os pontos delimitam uma área (fechada automaticamente, pelo menos três pontos) e as caches dentro dela são incluídas. Uma distância maior que 0 inclui também as caches a essa distância do contorno.\n\nPontos: são incluídas as caches dentro da distância de qualquer um dos pontos.\n\nMarque \"Excluir\" para manter, em vez disso, apenas as caches que não correspondem. São usadas as coordenadas corrigidas quando existem.", + "filter_lp_type_group": "Tipo de filtro", + "filter_lp_type_line": "Linha", + "filter_lp_type_polygon": "Polígono", + "filter_lp_type_points": "Pontos", + "filter_lp_distance_label": "Distância:", + "filter_lp_exclude": "Excluir", + "filter_lp_invalid_lines": "Não foi possível ler estes pontos de linha/polígono:\n{lines}", + "filter_lp_too_few_points": "O tipo de filtro escolhido precisa de pelo menos {count} pontos.", + "filter_lp_distance_required": "Introduza uma distância maior que 0 para um filtro de linha ou de pontos.", "filter_caches_with": "Caches que têm:", "filter_all_selected": "TODOS os atributos selecionados", "filter_attr_col_name": "Atributo", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 91677d98..7457bc30 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -689,6 +689,28 @@ "filter_date_cmp_newer_or_equal":"lika eller nyare", "filter_date_cmp_within": "inom", "filter_date_cmp_outside": "utanför", + "filter_tab_line_polygon": "Linje/Polygon", + "filter_lp_points_label": "Linje-/polygonpunkter", + "filter_lp_points_placeholder": "Latitud, longitud — en punkt per rad, t.ex.\n53.18346, 8.71113\nN 53 23.613, E 008 00.941\n\nW,GC12345 använder koordinaterna för en cache eller ett waypoint i databasen.\nText efter # ignoreras.", + "filter_lp_add_flagged_btn": "Lägg till markerade (user flag)", + "filter_lp_no_flagged": "Inga cacher har user flag satt.", + "filter_lp_file_group": "Läs punkter från fil", + "filter_lp_choose_file_btn": "Välj fil…", + "filter_lp_replace": "Ersätt", + "filter_lp_append": "Lägg till sist", + "filter_lp_file_filter": "Punktfiler (*.gpx *.kml *.txt *.csv);;Alla filer (*)", + "filter_lp_file_error": "Filen kunde inte läsas:\n{error}", + "filter_lp_file_no_points": "Inga punkter hittades i filen.", + "filter_lp_description": "Linjefiltret väljer cacher efter hur nära de ligger en linje — en serie sammanbundna punkter, som en rutt eller ett spår. Ange minst två punkter och avståndet från linjen inom vilket cacher tas med.\n\nPolygon: punkterna avgränsar ett område (stängs automatiskt, minst tre punkter) och cacher inuti det tas med. Ett avstånd större än 0 tar dessutom med cacher så nära kanten.\n\nPunkter: cacher inom avståndet från någon av punkterna tas med.\n\nMarkera \"Uteslut\" för att i stället bara behålla de cacher som inte matchar. Korrigerade koordinater används när de finns.", + "filter_lp_type_group": "Filtertyp", + "filter_lp_type_line": "Linje", + "filter_lp_type_polygon": "Polygon", + "filter_lp_type_points": "Punkter", + "filter_lp_distance_label": "Avstånd:", + "filter_lp_exclude": "Uteslut", + "filter_lp_invalid_lines": "Dessa linje-/polygonpunkter kunde inte läsas:\n{lines}", + "filter_lp_too_few_points": "Den valda filtertypen kräver minst {count} punkter.", + "filter_lp_distance_required": "Ange ett avstånd större än 0 för ett linje- eller punktfilter.", "filter_caches_with": "Cacher som har:", "filter_all_selected": "ALLA valda attribut", "filter_attr_col_name": "Attribut", diff --git a/tests/unit-tests/test_filter_dialog.py b/tests/unit-tests/test_filter_dialog.py index 6ce95614..348680c6 100644 --- a/tests/unit-tests/test_filter_dialog.py +++ b/tests/unit-tests/test_filter_dialog.py @@ -119,8 +119,8 @@ def test_dtspinbox(self, qtbot): # ── construction ──────────────────────────────────────────────────────────────── class TestConstruction: - def test_six_tabs(self, dlg): - assert dlg._tabs.count() == 6 + def test_seven_tabs(self, dlg): + assert dlg._tabs.count() == 7 def test_init_with_filterset(self, qtbot): fs = FilterSet(mode="AND") diff --git a/tests/unit-tests/test_filter_dialog_line_polygon.py b/tests/unit-tests/test_filter_dialog_line_polygon.py new file mode 100644 index 00000000..13a82baf --- /dev/null +++ b/tests/unit-tests/test_filter_dialog_line_polygon.py @@ -0,0 +1,210 @@ +# tests/unit-tests/test_filter_dialog_line_polygon.py — the filter dialog's +# Line/Polygon tab (GSAK-style line/polygon filter). + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("pytestqt") + +from opensak.gui.dialogs import filter_dialog as fd +from opensak.gui.dialogs.filter_dialog import FilterDialog +from opensak.filters.engine import FilterSet, LinePolygonFilter + +# "W," lines resolve from here instead of a database. +CODES = {"GC12345": (55.5, 10.5)} + +_GPX = ( + '' + '' + '' +) + + +@pytest.fixture +def settings(): + from opensak.utils.types import CoordFormat, DateFormat + return SimpleNamespace(home_lat=55.0, home_lon=12.0, use_miles=False, + date_format=DateFormat.YMD, coord_format=CoordFormat.DD, + home_points=[], theme="light") + + +@pytest.fixture(autouse=True) +def isolate(monkeypatch, settings): + monkeypatch.setattr(fd.FilterProfile, "list_profiles", staticmethod(lambda: [])) + monkeypatch.setattr("opensak.gui.settings.get_settings", lambda: settings) + monkeypatch.setattr(fd.FilterDialog, "_resolve_point_code", + staticmethod(lambda code: CODES.get(code))) + + +@pytest.fixture +def dlg(qtbot): + d = FilterDialog() + qtbot.addWidget(d) + return d + + +def _lp_filters(fs): + return [f for f in fs._filters if isinstance(f, LinePolygonFilter)] + + +def _choose_file(monkeypatch, path): + monkeypatch.setattr(fd, "QFileDialog", SimpleNamespace( + getOpenFileName=lambda *a, **k: (str(path) if path else "", ""), + )) + + +class TestBuild: + def test_tab_present(self, dlg): + assert dlg._tabs.indexOf(dlg._line_polygon_tab) >= 0 + + def test_unused_tab_adds_no_filter(self, dlg): + assert _lp_filters(dlg._build_filterset()) == [] + dlg._lp_text.setPlainText("# only a comment\n\n") + assert _lp_filters(dlg._build_filterset()) == [] + assert dlg._validate_line_polygon() is True + + def test_line_filter(self, dlg): + dlg._lp_text.setPlainText("55.0, 10.0\nN 55 00.000, E 011 00.000\nW,GC12345") + dlg._lp_distance.setValue(2.5) + [f] = _lp_filters(dlg._build_filterset()) + assert f.mode == "line" + assert f.points == [(55.0, 10.0), (55.0, 11.0), (55.5, 10.5)] + assert f.distance_km == 2.5 + assert f.exclude is False + + def test_polygon_without_distance(self, dlg): + dlg._lp_text.setPlainText("55.0, 10.0\n55.0, 11.0\n56.0, 10.5") + dlg._lp_mode_buttons["polygon"].setChecked(True) + dlg._lp_distance.setValue(0.0) + dlg._lp_exclude.setChecked(True) + [f] = _lp_filters(dlg._build_filterset()) + assert (f.mode, f.distance_km, f.exclude) == ("polygon", 0.0, True) + + def test_miles_are_converted(self, dlg, settings): + settings.use_miles = True + dlg._lp_text.setPlainText("55.0, 10.0\n55.0, 11.0") + dlg._lp_distance.setValue(1.0) + [f] = _lp_filters(dlg._build_filterset()) + assert f.distance_km == pytest.approx(1.60934) + + +class TestApply: + def test_valid_input_applies(self, dlg): + received = [] + dlg.filter_applied.connect(lambda fs, sort, name: received.append(fs)) + dlg._lp_text.setPlainText("55.0, 10.0\n55.0, 11.0") + dlg._apply() + assert len(_lp_filters(received[0])) == 1 + + @pytest.mark.parametrize("text, mode, distance, fragment", [ + ("55.0, 10.0\nnonsense", "line", 1.0, "nonsense"), + ("W,GCNOPE\n55.0, 10.0", "line", 1.0, "W,GCNOPE"), + ("55.0, 10.0", "line", 1.0, None), # too few points + ("55.0, 10.0\n55.0, 11.0", "polygon", 1.0, None), # too few points + ("55.0, 10.0\n55.0, 11.0", "points", 0.0, None), # distance required + ]) + def test_invalid_input_blocks_apply(self, dlg, monkeypatch, text, mode, distance, fragment): + warning = MagicMock() + monkeypatch.setattr(fd.QMessageBox, "warning", warning) + monkeypatch.setattr(fd, "tr", lambda key, **kwargs: f"{key} {kwargs}") + applied = MagicMock() + dlg.filter_applied.connect(applied) + dlg._lp_text.setPlainText(text) + dlg._lp_mode_buttons[mode].setChecked(True) + dlg._lp_distance.setValue(distance) + dlg._apply() + warning.assert_called_once() + applied.assert_not_called() + assert dlg._tabs.currentWidget() is dlg._line_polygon_tab + if fragment: + assert fragment in warning.call_args.args[2] + assert _lp_filters(dlg._build_filterset()) == [] + + +class TestLoadAndReset: + def test_load_restores_tab(self, dlg): + text = "# route\n55.0, 10.0\nW,GC12345" + f = LinePolygonFilter([(55.0, 10.0), (55.5, 10.5)], "points", 3.0, + exclude=True, text=text) + dlg._load_filterset(FilterSet().add(f)) + assert dlg._lp_text.toPlainText() == text + assert dlg._lp_mode_buttons["points"].isChecked() + assert dlg._lp_distance.value() == 3.0 + assert dlg._lp_exclude.isChecked() + [rebuilt] = _lp_filters(dlg._build_filterset()) + assert rebuilt.to_dict() == f.to_dict() + + def test_load_without_text_lists_points(self, dlg): + f = LinePolygonFilter([(55.0, 10.0), (55.0, 11.0)], "line", 1.0) + dlg._load_filterset(FilterSet().add(f)) + assert dlg._lp_text.toPlainText() == "55.000000, 10.000000\n55.000000, 11.000000" + + def test_reset_current_tab(self, dlg): + dlg._lp_text.setPlainText("55.0, 10.0") + dlg._lp_mode_buttons["polygon"].setChecked(True) + dlg._lp_distance.setValue(7.0) + dlg._lp_exclude.setChecked(True) + dlg._tabs.setCurrentWidget(dlg._line_polygon_tab) + dlg._reset_current_tab() + assert dlg._lp_text.toPlainText() == "" + assert dlg._lp_mode_buttons["line"].isChecked() + assert dlg._lp_distance.value() == 1.0 + assert not dlg._lp_exclude.isChecked() + + +class TestAddPoints: + @pytest.fixture + def fake_db(self, monkeypatch): + @contextmanager + def fake_session(): + yield None + monkeypatch.setattr("opensak.db.database.get_session", fake_session) + + def test_add_flagged(self, dlg, monkeypatch, fake_db): + monkeypatch.setattr(fd, "user_flagged_codes", lambda session: ["GC1", "GC2"]) + dlg._lp_text.setPlainText("55.0, 10.0\n") + dlg._add_flagged_points() + assert dlg._lp_text.toPlainText() == "55.0, 10.0\nW,GC1\nW,GC2" + + def test_add_flagged_none(self, dlg, monkeypatch, fake_db): + monkeypatch.setattr(fd, "user_flagged_codes", lambda session: []) + information = MagicMock() + monkeypatch.setattr(fd.QMessageBox, "information", information) + dlg._add_flagged_points() + information.assert_called_once() + assert dlg._lp_text.toPlainText() == "" + + def test_points_file_replace_and_append(self, dlg, monkeypatch, tmp_path): + path = tmp_path / "route.gpx" + path.write_text(_GPX, encoding="utf-8") + _choose_file(monkeypatch, path) + dlg._lp_text.setPlainText("55.0, 10.0") + dlg._load_points_file() # Replace is the default + assert dlg._lp_text.toPlainText() == "55.100000, 10.100000\n55.200000, 10.200000" + dlg._lp_append.setChecked(True) + dlg._load_points_file() + assert dlg._lp_text.toPlainText().splitlines() == [ + "55.100000, 10.100000", "55.200000, 10.200000", + "55.100000, 10.100000", "55.200000, 10.200000", + ] + + @pytest.mark.parametrize("content", ["", '']) + def test_points_file_unreadable_or_empty(self, dlg, monkeypatch, tmp_path, content): + path = tmp_path / "bad.gpx" + path.write_text(content, encoding="utf-8") + _choose_file(monkeypatch, path) + warning = MagicMock() + monkeypatch.setattr(fd.QMessageBox, "warning", warning) + dlg._lp_text.setPlainText("55.0, 10.0") + dlg._load_points_file() + warning.assert_called_once() + assert dlg._lp_text.toPlainText() == "55.0, 10.0" + + def test_cancelled_file_dialog_changes_nothing(self, dlg, monkeypatch): + _choose_file(monkeypatch, None) + dlg._lp_text.setPlainText("55.0, 10.0") + dlg._load_points_file() + assert dlg._lp_text.toPlainText() == "55.0, 10.0" diff --git a/tests/unit-tests/test_line_polygon_filter.py b/tests/unit-tests/test_line_polygon_filter.py new file mode 100644 index 00000000..dcaf7ea5 --- /dev/null +++ b/tests/unit-tests/test_line_polygon_filter.py @@ -0,0 +1,343 @@ +# tests/unit-tests/test_line_polygon_filter.py — line/polygon filter (GSAK's +# "Line/Polygon" tab): parsing, geometry, engine filter and SQL pushdown. + +import json +import math +import random +import xml.etree.ElementTree as ET +from types import SimpleNamespace + +import pytest + +from opensak.db.models import Cache, UserNote, Waypoint +from opensak.filters import line_polygon +from opensak.filters.engine import ( + FILTER_REGISTRY, FilterSet, LinePolygonFilter, + apply_filters, apply_filters_lightweight, lookup_code_coords, user_flagged_codes, +) +from opensak.filters.line_polygon import ( + LineShape, parse_point, parse_points_text, point_segment_km, read_points_file, +) + +KM_PER_DEG = 6371.0 * math.pi / 180 # one degree of latitude on the sphere + + +def _cache(lat, lon, corrected=None): + note = None + if corrected is not None: + note = SimpleNamespace(is_corrected=True, corrected_lat=corrected[0], + corrected_lon=corrected[1]) + return SimpleNamespace(latitude=lat, longitude=lon, user_note=note) + + +def _brute_force(points, mode, distance_km, lat, lon): + """Reference LineShape.contains() without any index.""" + if mode == "points": + pairs = [(p, p) for p in points] + elif mode == "line": + pairs = list(zip(points, points[1:])) + else: + pairs = list(zip(points, points[1:] + points[:1])) + if mode == "polygon": + inside = False + for (lat1, lon1), (lat2, lon2) in pairs: + if (lat1 > lat) != (lat2 > lat) and \ + lon < lon1 + (lat - lat1) * (lon2 - lon1) / (lat2 - lat1): + inside = not inside + if inside: + return True + return distance_km > 0 and min( + point_segment_km(lat, lon, a, b) for a, b in pairs + ) <= distance_km + + +# ── parsing ─────────────────────────────────────────────────────────────────── + +class TestParsing: + def test_decimal_degrees(self): + assert parse_point("53.18346, 8.71113") == (53.18346, 8.71113) + + def test_dmm_with_comma(self): + lat, lon = parse_point("N 53 23.613, E 008 00.941") + assert lat == pytest.approx(53 + 23.613 / 60) + assert lon == pytest.approx(8 + 0.941 / 60) + + def test_unparseable(self): + # Decimal commas are rejected rather than misread. + assert parse_point("53,18346 8,71113") is None + assert parse_point("hello") is None + + def test_comments_blank_lines_and_bad_lines(self): + text = "# header\n\n55.0, 12.0 # start\n N 55 30.000 E 012 30.000\nnonsense\n" + points, bad = parse_points_text(text) + assert points == [(55.0, 12.0), (55.5, 12.5)] + assert bad == ["nonsense"] + + def test_code_lines_use_resolver(self): + seen = [] + + def resolve(code): + seen.append(code) + return (56.0, 10.0) if code == "GC12345" else None + + points, bad = parse_points_text("W,gc12345\nw, GCNOPE", resolve) + assert points == [(56.0, 10.0)] + assert bad == ["w, GCNOPE"] + assert seen == ["GC12345", "GCNOPE"] + + def test_code_line_without_resolver_is_bad(self): + assert parse_points_text("W,GC12345") == ([], ["W,GC12345"]) + + +# ── files ───────────────────────────────────────────────────────────────────── + +_GPX = """ + + + {body} +""" + + +class TestReadPointsFile: + def test_gpx_prefers_track(self, tmp_path): + path = tmp_path / "t.gpx" + path.write_text(_GPX.format(body=( + '' + '' + )), encoding="utf-8") + assert read_points_file(path) == [(5.0, 6.0), (7.0, 8.0)] + + def test_gpx_route_then_waypoints(self, tmp_path): + path = tmp_path / "r.gpx" + path.write_text(_GPX.format(body=''), encoding="utf-8") + assert read_points_file(path) == [(3.0, 4.0)] + path.write_text(_GPX.format(body=""), encoding="utf-8") + assert read_points_file(path) == [(1.0, 2.0)] + + def test_kml(self, tmp_path): + path = tmp_path / "a.kml" + path.write_text( + '' + '8.7,53.1,0 8.8,53.2' + '', + encoding="utf-8", + ) + assert read_points_file(path) == [(53.1, 8.7), (53.2, 8.8)] + + def test_text_file_skips_bad_lines(self, tmp_path): + path = tmp_path / "pts.txt" + path.write_text("# route\n55.0, 12.0\nbad\nN 55 30.000, E 012 30.000\n", encoding="utf-8") + assert read_points_file(path) == [(55.0, 12.0), (55.5, 12.5)] + + def test_malformed_xml_raises(self, tmp_path): + path = tmp_path / "x.gpx" + path.write_text("", encoding="utf-8") + with pytest.raises(ET.ParseError): + read_points_file(path) + + +# ── geometry ────────────────────────────────────────────────────────────────── + +class TestPointSegmentDistance: + def test_perpendicular(self): + # Cross-track distance to the equator is the latitude itself. + d = point_segment_km(0.1, 0.5, (0.0, 0.0), (0.0, 1.0)) + assert d == pytest.approx(0.1 * KM_PER_DEG, rel=1e-6) + + def test_on_segment(self): + assert point_segment_km(0.0, 0.5, (0.0, 0.0), (0.0, 1.0)) == pytest.approx(0.0, abs=1e-6) + + def test_beyond_end_and_behind_start(self): + assert point_segment_km(0.0, 2.0, (0.0, 0.0), (0.0, 1.0)) == pytest.approx(KM_PER_DEG, rel=1e-6) + assert point_segment_km(0.0, -1.0, (0.0, 0.0), (0.0, 1.0)) == pytest.approx(KM_PER_DEG, rel=1e-6) + + def test_degenerate_segment_is_a_point(self): + assert point_segment_km(1.0, 0.0, (0.0, 0.0), (0.0, 0.0)) == pytest.approx(KM_PER_DEG, rel=1e-6) + + +_SQUARE = [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)] + + +class TestLineShape: + def test_line(self): + shape = LineShape([(0.0, 0.0), (0.0, 1.0), (1.0, 1.0)], "line", 5.0) + assert shape.contains(0.03, 0.5) # ~3.3 km from the first leg + assert not shape.contains(0.1, 0.5) # ~11 km + assert shape.contains(0.5, 1.04) # ~4.4 km from the second leg + assert not shape.contains(0.5, 0.5) # between the legs, far from both + + def test_polygon_inside_outside(self): + shape = LineShape(_SQUARE, "polygon", 0.0) + assert shape.contains(0.5, 0.5) + assert not shape.contains(1.01, 0.5) + assert not shape.contains(0.5, -0.01) + + def test_polygon_distance_includes_band_around_outline(self): + shape = LineShape(_SQUARE, "polygon", 5.0) + assert shape.contains(0.5, 0.5) + assert shape.contains(1.03, 0.5) # 3.3 km outside the top edge + assert not shape.contains(1.1, 0.5) + + def test_concave_polygon(self): + # L-shape — the notch at the top right is outside. + l_shape = [(0.0, 0.0), (0.0, 2.0), (1.0, 2.0), (1.0, 1.0), (2.0, 1.0), (2.0, 0.0)] + shape = LineShape(l_shape, "polygon", 0.0) + assert shape.contains(0.5, 1.5) + assert shape.contains(1.5, 0.5) + assert not shape.contains(1.5, 1.5) + + def test_points(self): + shape = LineShape([(0.0, 0.0), (0.0, 1.0)], "points", 5.0) + assert shape.contains(0.03, 0.0) + assert shape.contains(0.0, 1.03) + assert not shape.contains(0.0, 0.5) # between the points, ~55 km from both + + def test_long_segment_follows_great_circle(self): + # A ~1000 km east-west leg at 50°N bulges ~20 km north of the 50° + # parallel midway; the bounding box / grid must not cut that off. + a, b = (50.0, 0.0), (50.0, 14.0) + mid_lat, mid_lon = line_polygon._intermediate(a, b, 0.5) + assert mid_lat > 50.15 + shape = LineShape([a, b], "line", 0.5) + assert shape.contains(mid_lat, mid_lon) + assert not shape.contains(50.0, 7.0) + + def test_no_bbox_across_antimeridian_still_matches(self): + shape = LineShape([(0.0, 179.9), (0.0, 179.99)], "line", 20.0) + assert shape.bbox is None + assert shape.contains(0.0, -179.95) # ~6.7 km past the end, across ±180° + + @pytest.mark.parametrize("mode", ["line", "polygon", "points"]) + def test_index_agrees_with_brute_force(self, mode): + rng = random.Random(42) + points = [(55 + rng.uniform(-1, 1), 10 + rng.uniform(-1, 1)) for _ in range(40)] + shape = LineShape(points, mode, 8.0) + for _ in range(3000): + lat, lon = 55 + rng.uniform(-1.3, 1.3), 10 + rng.uniform(-1.3, 1.3) + assert shape.contains(lat, lon) == _brute_force(points, mode, 8.0, lat, lon), (lat, lon) + + @pytest.mark.parametrize("mode, count", [("line", 1), ("polygon", 2), ("points", 0)]) + def test_too_few_points(self, mode, count): + with pytest.raises(ValueError): + LineShape([(0.0, float(i)) for i in range(count)], mode, 1.0) + + def test_unknown_mode(self): + with pytest.raises(ValueError): + LineShape(_SQUARE, "circle", 1.0) + + +# ── LinePolygonFilter ───────────────────────────────────────────────────────── + +_LINE = [(55.0, 10.0), (55.0, 11.0)] + + +class TestLinePolygonFilter: + def test_matches_and_exclude(self): + near, far = _cache(55.01, 10.5), _cache(55.2, 10.5) + f = LinePolygonFilter(_LINE, "line", 2.0) + assert f.matches(near) and not f.matches(far) + g = LinePolygonFilter(_LINE, "line", 2.0, exclude=True) + assert not g.matches(near) and g.matches(far) + + def test_uses_corrected_coordinates(self): + f = LinePolygonFilter(_LINE, "line", 2.0) + assert f.matches(_cache(56.0, 10.5, corrected=(55.01, 10.5))) + assert not f.matches(_cache(55.01, 10.5, corrected=(56.0, 10.5))) + + def test_roundtrip(self): + f = LinePolygonFilter( + [(55.0, 10.0), (55.0, 11.0), (56.0, 10.5)], "polygon", 0.5, + exclude=True, text="W,GC1\n55.0, 11.0", + ) + data = json.loads(json.dumps(f.to_dict())) + restored = FILTER_REGISTRY[data["filter_type"]].from_dict(data) + assert type(restored) is LinePolygonFilter + assert restored.to_dict() == f.to_dict() + + def test_filterset_roundtrip(self): + fs = FilterSet().add(LinePolygonFilter(_LINE, "line", 1.5)) + restored = FilterSet.from_dict(json.loads(json.dumps(fs.to_dict()))) + assert [f.to_dict() for f in restored._filters] == [f.to_dict() for f in fs._filters] + + def test_invalid(self): + with pytest.raises(ValueError): + LinePolygonFilter(_LINE, "circle", 1.0) + with pytest.raises(ValueError): + LinePolygonFilter(_LINE, "polygon", 1.0) # needs three points + + +# ── database: SQL pushdown, code lookup, flagged caches ─────────────────────── + +@pytest.fixture +def seeded(db_session): + db_session.add_all([ + Cache(gc_code="GCNEAR", name="near", cache_type="Traditional Cache", + latitude=55.01, longitude=10.5), + Cache(gc_code="GCFAR", name="far", cache_type="Traditional Cache", + latitude=55.5, longitude=10.5), + # Posted far away, solved final on the line. + Cache(gc_code="GCSOLVED", name="solved", cache_type="Unknown Cache", + latitude=57.0, longitude=10.5, + user_note=UserNote(is_corrected=True, corrected_lat=55.005, corrected_lon=10.2)), + # Posted on the line, solved final far away. + Cache(gc_code="GCMOVED", name="moved", cache_type="Unknown Cache", + latitude=55.0, longitude=10.8, + user_note=UserNote(is_corrected=True, corrected_lat=58.0, corrected_lon=10.8)), + ]) + db_session.commit() + return db_session + + +@pytest.mark.parametrize("apply", [apply_filters, apply_filters_lightweight]) +def test_query_uses_effective_coordinates(seeded, apply): + fs = FilterSet().add(LinePolygonFilter(_LINE, "line", 2.0)) + assert {c.gc_code for c in apply(seeded, fs)} == {"GCNEAR", "GCSOLVED"} + + +@pytest.mark.parametrize("apply", [apply_filters, apply_filters_lightweight]) +def test_query_exclude(seeded, apply): + fs = FilterSet().add(LinePolygonFilter(_LINE, "line", 2.0, exclude=True)) + assert {c.gc_code for c in apply(seeded, fs)} == {"GCFAR", "GCMOVED"} + + +def test_bbox_pushdown_is_a_superset(seeded): + # Raw OR corrected coordinates inside the box pass the SQL pre-filter; + # matches() then drops GCMOVED, whose final is far away. + f = LinePolygonFilter(_LINE, "line", 2.0) + query = f.apply_to_query(seeded.query(Cache)) + assert {c.gc_code for c in query.all()} == {"GCNEAR", "GCSOLVED", "GCMOVED"} + assert f.sql_exact is False + assert LinePolygonFilter(_LINE, "line", 2.0, exclude=True).apply_to_query(seeded.query(Cache)) is None + + +def test_lookup_code_coords(seeded): + cache = seeded.query(Cache).filter_by(gc_code="GCNEAR").one() + seeded.add_all([ + Waypoint(cache_id=cache.id, prefix="PK", wp_type="Parking Area", + latitude=55.02, longitude=10.51), + Waypoint(cache_id=cache.id, prefix="S1", wp_type="Stage", wp_code="S1XYZ", + latitude=55.03, longitude=10.52), + Waypoint(cache_id=cache.id, prefix="FN", wp_type="Final"), + ]) + seeded.commit() + assert lookup_code_coords(seeded, "gcnear") == (55.01, 10.5) + assert lookup_code_coords(seeded, "GCSOLVED") == (55.005, 10.2) # corrected + assert lookup_code_coords(seeded, "s1xyz") == (55.03, 10.52) + assert lookup_code_coords(seeded, "PKNEAR") == (55.02, 10.51) + assert lookup_code_coords(seeded, "FNNEAR") is None # no coordinates + assert lookup_code_coords(seeded, "GCNOPE") is None + assert lookup_code_coords(seeded, " ") is None + + +def test_user_flagged_codes(db_session): + def make(code, **kwargs): + return Cache(gc_code=code, name=code, cache_type="Traditional Cache", + latitude=55.0, longitude=10.0, **kwargs) + db_session.add_all([ + make("GCB", user_flag=True), + make("GCA", user_flag=True), + make("GCC", user_flag=True, user_sort=1), + make("GCD", user_flag=False), + ]) + db_session.commit() + assert user_flagged_codes(db_session) == ["GCC", "GCA", "GCB"] From c9f3d4d71a6ff3948cd0b5c0421856906aea27a4 Mon Sep 17 00:00:00 2001 From: nagisml Date: Mon, 14 Sep 2026 21:14:19 +0200 Subject: [PATCH 3/9] OR condition for attributes --- src/opensak/gui/dialogs/filter_dialog.py | 16 +++++++---- src/opensak/lang/cs.py | 1 + src/opensak/lang/da.py | 1 + src/opensak/lang/de.py | 1 + src/opensak/lang/en.py | 1 + src/opensak/lang/es.py | 1 + src/opensak/lang/fr.py | 1 + src/opensak/lang/nl.py | 1 + src/opensak/lang/pl.py | 1 + src/opensak/lang/pt.py | 1 + src/opensak/lang/se.py | 1 + tests/unit-tests/test_filter_dialog.py | 35 ++++++++++++++++++++++-- 12 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/opensak/gui/dialogs/filter_dialog.py b/src/opensak/gui/dialogs/filter_dialog.py index a48f2bb3..24f0e2a2 100644 --- a/src/opensak/gui/dialogs/filter_dialog.py +++ b/src/opensak/gui/dialogs/filter_dialog.py @@ -1023,12 +1023,14 @@ def _build_attributes_tab(self) -> QWidget: outer_layout = QVBoxLayout(outer) outer_layout.setContentsMargins(0, 0, 0, 0) - # Mode + # Mode — ALLE valgte attributter skal passe (AND) eller blot ÉN af dem (OR) mode_row = QHBoxLayout() mode_row.addWidget(QLabel(tr("filter_caches_with"))) - self._attr_mode_all = QCheckBox(tr("filter_all_selected")) + self._attr_mode_all = QRadioButton(tr("filter_all_selected")) + self._attr_mode_any = QRadioButton(tr("filter_any_selected")) self._attr_mode_all.setChecked(True) mode_row.addWidget(self._attr_mode_all) + mode_row.addWidget(self._attr_mode_any) mode_row.addStretch() outer_layout.addLayout(mode_row) @@ -1479,6 +1481,7 @@ def _reset_misc(self) -> None: self._fav_max.setValue(9999) def _reset_attributes(self) -> None: + self._attr_mode_all.setChecked(True) for ja_cb, nej_cb, ingen_cb in self._attr_boxes.values(): ja_cb.setChecked(False) nej_cb.setChecked(False) @@ -1827,9 +1830,12 @@ def _load_filterset(self, fs: FilterSet) -> None: else: flat_filters.append(f) - # OR-mode = "any selected"; the UI only has the "all selected" checkbox, - # so unchecking it expresses ANY (avoids a crash on the missing widget). - self._attr_mode_all.setChecked(not attr_mode_or_detected) + # OR-mode = "ONE of the selected attributes". The two mode radios are + # exclusive, so check the matching one (setChecked(False) is a no-op). + if attr_mode_or_detected: + self._attr_mode_any.setChecked(True) + else: + self._attr_mode_all.setChecked(True) text_rows = { cls.filter_type: row diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 535d9955..8a0db288 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -713,6 +713,7 @@ "filter_lp_distance_required": "Pro filtr linie nebo bodů zadejte vzdálenost větší než 0.", "filter_caches_with": "Keše, které mají:", "filter_all_selected": "VŠECHNY vybrané atributy", + "filter_any_selected": "JEDEN z vybraných atributů", "filter_attr_col_name": "Atribut", "filter_none_short": "Libovolné", "filter_save_title": "Uložit filtr", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index ad220de9..432c6f9a 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -713,6 +713,7 @@ "filter_lp_distance_required": "Angiv en afstand større end 0 for et linje- eller punktfilter.", "filter_caches_with": "Cacher der har:", "filter_all_selected": "ALLE valgte attributter", + "filter_any_selected": "ÉN af de valgte attributter", "filter_attr_col_name": "Attribut", "filter_none_short": "Ingen", "filter_save_title": "Gem filter", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 8d725c8c..33b7ca9c 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -713,6 +713,7 @@ "filter_lp_distance_required": "Gib für einen Linien- oder Punktefilter eine Entfernung größer 0 ein.", "filter_caches_with": "Caches mit:", "filter_all_selected": "ALLE gewählten Attribute", + "filter_any_selected": "EINES der gewählten Attribute", "filter_attr_col_name": "Attribut", "filter_none_short": "Keine", "filter_save_title": "Filter speichern", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index d9af8822..d0a488f6 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -712,6 +712,7 @@ "filter_lp_distance_required": "Enter a distance greater than 0 for a line or points filter.", "filter_caches_with": "Caches that have:", "filter_all_selected": "ALL selected attributes", + "filter_any_selected": "ONE of the selected attributes", "filter_attr_col_name": "Attribute", "filter_none_short": "Any", "filter_save_title": "Save filter", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index 8c72acdb..b93800da 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -714,6 +714,7 @@ "filter_lp_distance_required": "Introduce una distancia mayor que 0 para un filtro de línea o de puntos.", "filter_caches_with": "Cachés que tienen:", "filter_all_selected": "TODOS los atributos seleccionados", + "filter_any_selected": "UNO de los atributos seleccionados", "filter_attr_col_name": "Atributo", "filter_none_short": "Cualquiera", "filter_save_title": "Guardar filtro", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index a5559b76..93ebf5b3 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -714,6 +714,7 @@ "filter_lp_distance_required": "Saisissez une distance supérieure à 0 pour un filtre ligne ou points.", "filter_caches_with": "Caches qui ont:", "filter_all_selected": "TOUS les attributs sélectionnés", + "filter_any_selected": "UN des attributs sélectionnés", "filter_attr_col_name": "Attribut", "filter_none_short": "Tous", "filter_save_title": "Enregistrer le filtre", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index 88162ba3..2bacb939 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -716,6 +716,7 @@ "filter_lp_distance_required": "Voer een afstand groter dan 0 in voor een lijn- of puntenfilter.", "filter_caches_with": "Caches met:", "filter_all_selected": "ALLE geselecteerde attributen", + "filter_any_selected": "ÉÉN van de geselecteerde attributen", "filter_attr_col_name": "Attribuut", "filter_none_short": "Alle", "filter_save_title": "Filter opslaan", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 4990df9e..4b4afcce 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -714,6 +714,7 @@ "filter_lp_distance_required": "Podaj odległość większą niż 0 dla filtra linii lub punktów.", "filter_caches_with": "Skrytki, które mają:", "filter_all_selected": "WSZYSTKIE wybrane atrybuty", + "filter_any_selected": "JEDEN z wybranych atrybutów", "filter_attr_col_name": "Atrybut", "filter_none_short": "Dowolny", "filter_save_title": "Zapisz filtr", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index bfdd6571..fc44a3b3 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -713,6 +713,7 @@ "filter_lp_distance_required": "Introduza uma distância maior que 0 para um filtro de linha ou de pontos.", "filter_caches_with": "Caches que têm:", "filter_all_selected": "TODOS os atributos selecionados", + "filter_any_selected": "UM dos atributos selecionados", "filter_attr_col_name": "Atributo", "filter_none_short": "Qualquer", "filter_save_title": "Guardar filtro", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 7457bc30..e498f94f 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -713,6 +713,7 @@ "filter_lp_distance_required": "Ange ett avstånd större än 0 för ett linje- eller punktfilter.", "filter_caches_with": "Cacher som har:", "filter_all_selected": "ALLA valda attribut", + "filter_any_selected": "ETT av de valda attributen", "filter_attr_col_name": "Attribut", "filter_none_short": "Någon", "filter_save_title": "Spara filter", diff --git a/tests/unit-tests/test_filter_dialog.py b/tests/unit-tests/test_filter_dialog.py index 348680c6..ebe84b30 100644 --- a/tests/unit-tests/test_filter_dialog.py +++ b/tests/unit-tests/test_filter_dialog.py @@ -363,7 +363,8 @@ def test_attributes_and_mode(self, dlg): assert any(getattr(f, "filter_type", None) == "attribute" for f in fs._filters) def test_attributes_or_mode(self, dlg): - dlg._attr_mode_all.setChecked(False) # ANY/OR mode + dlg._attr_mode_any.setChecked(True) # ANY/OR mode + assert dlg._attr_mode_all.isChecked() is False # radios are exclusive ids = list(dlg._attr_boxes)[:2] for aid in ids: dlg._attr_boxes[aid][0].setChecked(True) @@ -371,6 +372,25 @@ def test_attributes_or_mode(self, dlg): # nested OR FilterSet present assert any(isinstance(f, FilterSet) and f.mode == "OR" for f in fs._filters) + def test_attributes_or_mode_matches_cache_with_only_one(self, dlg): + # ONE-of mode: a cache carrying just one of the selected attributes + # passes; in ALL mode the same cache is rejected. + a1, a2 = list(dlg._attr_boxes)[:2] + dlg._attr_boxes[a1][0].setChecked(True) + dlg._attr_boxes[a2][0].setChecked(True) + cache = SimpleNamespace(attributes=[ + SimpleNamespace(attribute_id=a1, is_on=True), + ]) + assert dlg._build_filterset().matches(cache) is False + dlg._attr_mode_any.setChecked(True) + assert dlg._build_filterset().matches(cache) is True + + def test_reset_attributes_restores_all_mode(self, dlg): + dlg._attr_mode_any.setChecked(True) + dlg._reset_attributes() + assert dlg._attr_mode_all.isChecked() is True + assert dlg._attr_mode_any.isChecked() is False + def test_where_clause(self, dlg): dlg._where_sql_general.setPlainText("found = 0") assert "where_clause" in _types(dlg._build_filterset()) @@ -526,10 +546,21 @@ def test_loads_attribute_or_group_sets_any_mode(self, dlg): inner.add(AttributeFilter(attr_id, True)) fs = FilterSet(mode="AND") fs.add(inner) - dlg._load_filterset(fs) # exercises the fixed _attr_mode_all toggle + dlg._load_filterset(fs) + assert dlg._attr_mode_any.isChecked() is True assert dlg._attr_mode_all.isChecked() is False assert dlg._attr_boxes[attr_id][0].isChecked() is True + def test_loads_plain_attribute_sets_all_mode(self, dlg): + # A previously loaded ONE-of profile must not leak into the next load. + dlg._attr_mode_any.setChecked(True) + attr_id = next(iter(dlg._attr_boxes)) + fs = FilterSet(mode="AND") + fs.add(AttributeFilter(attr_id, True)) + dlg._load_filterset(fs) + assert dlg._attr_mode_all.isChecked() is True + assert dlg._attr_mode_any.isChecked() is False + def test_loads_text_search_filter(self, dlg): fs = FilterSet(mode="AND") fs.add(TextSearchFilter("waterfall", search_description=True, From 51ec5729376afa366a7576c4cb9c37f8b1b0a637 Mon Sep 17 00:00:00 2001 From: AgreeDK <17005023+AgreeDK@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:04:17 +0200 Subject: [PATCH 4/9] Release v1.19.0 --- CHANGELOG.md | 97 +++++++++++++++++++++++++++++++++++++++++ site/user-guide.html | 10 ++--- src/opensak/__init__.py | 2 +- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e379f91..c619b219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,103 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.19.0] — 2026-09-15 + +> First stable release of the 1.19.0 cycle. Replaces the `1.19.0-beta.1` +> … `1.19.0-beta.8` builds — see git history / the entries below for the +> detailed beta-by-beta log if needed. Headline of this cycle: MTP support +> for newer Garmin devices (Linux + Windows), Linux AppImage +> self-administration, Pocket Query e-mail retrieval, Polish and Spanish +> UI languages, and the first wave of GSAK filter-parity work (12 text +> filter operators). + +### Added + +- **MTP support for newer Garmin devices, Linux and Windows (#453, #822, + #826)** — Newer Garmin models (2020+) dropped USB mass-storage in + favour of MTP (Media Transfer Protocol), which the mount-point-based + device detection couldn't see, so "Send to GPS" silently found no + device for these units. On Linux, device detection now also scans + GVFS/MTP mounts (`/run/user/*/gvfs/mtp:...`); on Windows, a new + `opensak.gps.mtp` module talks to the device through the same Shell + "Folder" automation API File Explorer itself uses (via `pywin32`, a + new Windows-only dependency), wrapped in an `MTPDevice`/`MTPPath` + adapter that mirrors `pathlib.Path`'s interface — so the existing + GPX/GGZ export code needed no changes at all. With this, #453 is + resolved on both Linux and Windows; macOS remains unconfirmed. Thanks + to Brian Anderson (@blazerat) for the investigation and fix. +- **Linux AppImage: self-administration, no terminal required (#824, + #835, #836, #837)** — Replaces the originally planned external + `uninstall.sh` + AppImageUpdate approach with self-integration, + self-update, and in-app uninstall implemented directly in OpenSAK. + Linux-only; a no-op everywhere else. On first launch, OpenSAK offers + to install itself into the application menu; an "Upgrade now" button + downloads and atomically replaces the running AppImage; and a new + "Uninstall OpenSAK" button removes the desktop integration with a + choice between removing the program only or the program and all data. +- **Pocket Query e-mail retrieval (#443)** — OpenSAK can now check a + configured IMAP mailbox for Pocket Query zip attachments and import + them automatically. Settings → PQ Email configures the mailbox + (host/port/SSL/credentials), with the password stored in the OS + keyring, never in plaintext. File → "Check for PQ Email…" runs a + manual, on-demand check; an opt-in checkbox deletes the e-mail after a + successful import. An "only check new (unread) messages" option uses + IMAP's native `\Seen` flag to avoid re-importing already-read PQ + e-mails. Gmail and Outlook.com/Live.com aren't supported yet (both + need OAuth2, tracked as #697/#698); scheduled/background checking is + tracked separately as #445. Thanks to Jimbo-DK for real-world PQ-club + mailbox testing and feedback. +- **Polish and Spanish UI languages** — OpenSAK now ships with `pl` and + `es` translations, bringing the total to 10 supported languages. Both + are a machine-translated first pass; community review and corrections + are welcome before promotion to stable status. +- **Text filter operators (#557, #850)** — Name, GC code, Placed by, + Owner, Country, State and County filters now offer 12 operators + instead of a single substring match: `contains`/`not contains`, + `equals`/`not equals`, `starts with`/`ends with`, `in list`/`not in + list`, `empty`/`not empty`, and `regex`/`not regex`. Matching is + pushed down to SQL where possible; anything SQLite can't express + falls back to an in-Python check, so accented/non-Latin text still + matches correctly. Existing saved filter profiles keep working + unchanged. First part of the GSAK filter-parity work tracked in #821. + Thanks to @nagisml for the contribution. +- **Cache type icons in filter dialog (#855, #856)** — The General tab's + cache type checkboxes now show the same type icon used in the cache + table instead of plain text labels. Thanks to @nagisml. + +### Fixed + +- **GPX import failing on invalid XML character references (#845, + #846)** — A cache description containing a character reference to a + code point XML 1.0 forbids (e.g. from text pasted out of Word) made + lxml reject the entire file and import zero caches. Illegal character + references and raw control characters are now stripped while + streaming the file, before parsing, for GPX, PQ ZIP, and .loc imports + alike. Thanks to @nagisml for the report and fix. +- **Filter dialog: Reset didn't clear the Owner name field (fixes + #848)** — Resetting the General tab (or "Reset all") cleared Name, GC + code and Placed by but left a typed Owner name in place. Thanks to + @nagisml for the report and fix (#849). +- **Filter dialog: single-day date range failed to match caches (fixes + #844)** — The start-of-range time was hardcoded to 23:59 regardless of + whether it was the "from" or "to" bound, so filtering on a single day + produced a 59-second window instead of the full day. +- **Filter dialog: Hidden date range not restored on reopen (fixes + #857)** — Reopening the Filter dialog after setting a Hidden date + range showed the Hidden date checkboxes unchecked and the date fields + empty, even though the cache list was still correctly filtered. + `HiddenDateFilter` is now a proper filter class alongside its + siblings, with working save/reload and dialog restore. Thanks to + ianwork for the report. + +### Changed + +- **Filter dialog: "Save filter" pre-fills the current profile name + (#852)** — When a saved profile is selected, the save dialog now + suggests its name instead of an empty field. Thanks to @nagisml. + +--- + ## [1.19.0-beta.8] — 2026-09-14 ### Added diff --git a/site/user-guide.html b/site/user-guide.html index 4a1e0499..8274de96 100644 --- a/site/user-guide.html +++ b/site/user-guide.html @@ -3,7 +3,7 @@ -OpenSAK User Guide — v1.19.0-beta.8 +OpenSAK User Guide — v1.19.0