From 1f0108be7052a802f2cfe4c914b266f15b6930e5 Mon Sep 17 00:00:00 2001 From: nagisml Date: Wed, 30 Sep 2026 22:42:33 +0200 Subject: [PATCH 01/12] POC of Corrected Coords LUA --- src/opensak/gui/dialogs/macro_dialog.py | 10 +- src/opensak/gui/mainwindow.py | 10 ++ src/opensak/macro/runtime.py | 153 +++++++++++++++++++++++- tests/unit-tests/test_macro_runtime.py | 93 ++++++++++++++ 4 files changed, 263 insertions(+), 3 deletions(-) diff --git a/src/opensak/gui/dialogs/macro_dialog.py b/src/opensak/gui/dialogs/macro_dialog.py index 4498f817..f4b8ac81 100644 --- a/src/opensak/gui/dialogs/macro_dialog.py +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -25,6 +25,8 @@ -- OpenSAK macro (Lua) — proof of concept -- opensak.filter{...}, opensak.filter_profile(name), opensak.clear_filter(), -- opensak.count(), opensak.profiles(), print(...) +-- opensak.set_corrected(code, lat, lon | "N47 22.123 E008 32.456"), +-- opensak.clear_corrected(code), opensak.read_csv(path [, sep]) local n = opensak.filter{ type = {"Traditional", "Multi-cache"}, @@ -60,6 +62,7 @@ def __init__(self, host: MacroHost, parent=None): ) self._runtime = MacroRuntime(host, output=self._append_output) self._chunk_name = "macro" + self._base_dir: Path | None = None self._setup_ui() def _setup_ui(self) -> None: @@ -103,6 +106,7 @@ def _open_file(self) -> None: return self._editor.setPlainText(Path(path).read_text(encoding="utf-8")) self._chunk_name = Path(path).name + self._base_dir = Path(path).parent def _append_output(self, text: str) -> None: self._output.appendPlainText(text) @@ -111,7 +115,11 @@ def _run(self) -> None: self._output.clear() self._btn_run.setEnabled(False) try: - self._runtime.run(self._editor.toPlainText(), chunk_name=self._chunk_name) + self._runtime.run( + self._editor.toPlainText(), + chunk_name=self._chunk_name, + base_dir=self._base_dir, + ) self._append_output(tr("macro_done")) except MacroError as exc: self._append_output(tr("macro_error", msg=str(exc))) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 7a7d3aff..5664b14f 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -3193,6 +3193,16 @@ def cache_count(self) -> int: with get_session() as session: return len(apply_filters_auto(session, self._build_active_filterset())) + def set_corrected_coords(self, gc_code, lat, lon) -> bool: + """MacroHost: set (or clear, with lat/lon = None) corrected coordinates + and refresh the table row, map pin and detail panel like the other + entry points do.""" + from opensak.db.corrected_coords import set_corrected_coords + if not set_corrected_coords(gc_code, lat, lon): + return False + self._on_corrected_coords_changed(gc_code) + return True + def _open_found_updater(self) -> None: if self._trip_planner_active(): self._warn_trip_planner_active() diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 4592644e..6d914c50 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -15,6 +15,20 @@ opensak.clear_filter() -- show all caches again opensak.count() -- caches matching the active filter opensak.profiles() -- list of saved filter profile names + opensak.set_corrected(code, lat, lon) + -- set corrected coordinates (decimal + -- degrees); returns false if the cache + -- is not in the database + opensak.set_corrected(code, "N47 22.123 E008 32.456") + -- same, from a coordinate string in any + -- format OpenSAK understands + opensak.clear_corrected(code) -- remove corrected coordinates; returns + -- false if the cache is not in the database + opensak.read_csv(path [, sep]) -- read a CSV file (UTF-8) into an array of + -- rows keyed by the header line; the + -- separator (, ; or tab) is detected + -- unless given. A relative path is + -- resolved against the macro file's folder print(...) -- write to the macro output pane Keys understood by opensak.filter{} (all combined with AND): @@ -34,6 +48,10 @@ local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } print("Easy unfound traditionals: " .. n) + for _, row in ipairs(opensak.read_csv("solved.csv")) do + opensak.set_corrected(row.code, row.coords) + end + Known limitation (#938 step 4): the instruction limit only counts Lua VM instructions, not work inside C functions. Lua pattern matching backtracks @@ -50,6 +68,8 @@ from __future__ import annotations +import csv +import io from pathlib import Path from typing import Any, Callable, Optional, Protocol @@ -71,6 +91,7 @@ TerrainFilter, WhereClauseFilter, ) +from opensak.coords import parse_coords from opensak.utils.constants import CACHE_TYPES # A runaway `while true do end` would freeze the GUI thread, so the script is @@ -78,6 +99,9 @@ DEFAULT_INSTRUCTION_LIMIT = 50_000_000 # Upper bound for the Lua heap, so e.g. string.rep("x", 1e10) cannot exhaust RAM. DEFAULT_MEMORY_LIMIT = 256 * 1024 * 1024 +# opensak.read_csv() refuses larger files — it is meant for small lists +# (solved puzzles, corrections), not for bulk imports. +MAX_CSV_BYTES = 10 * 1024 * 1024 _TEXT_FILTERS = { "name": NameFilter, @@ -189,6 +213,15 @@ def cache_count(self) -> int: not the UI. """ + def set_corrected_coords( + self, gc_code: str, lat: Optional[float], lon: Optional[float] + ) -> bool: + """Set (or clear, with lat/lon = None) corrected coordinates. + + Returns False if the cache is not in the database. The host should + refresh whatever shows the cache (table row, map pin, detail panel). + """ + # ── Lua table → FilterSet ──────────────────────────────────────────────────── @@ -260,6 +293,90 @@ def build_filterset(spec: dict) -> tuple[FilterSet, str]: return fs, str(spec.get("label") or "Macro") +# ── Corrected coordinates / CSV ────────────────────────────────────────────── + + +def _gc_code(code: Any, func: str) -> str: + if not isinstance(code, str) or not code.strip(): + raise MacroError(f"{func} expects a GC code as first argument, got {code!r}") + return code.strip().upper() + + +def _number(value: Any) -> Optional[float]: + """A Lua number, or a string holding one (CSV cells are strings).""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def resolve_coords(lat: Any, lon: Any = None) -> tuple[float, float]: + """(lat, lon) from two numbers or from one coordinate string. + + Raises MacroError if the values cannot be parsed or are out of range. + """ + if lon is None: + if not isinstance(lat, str): + raise MacroError( + 'expected lat, lon or a coordinate string such as "N47 22.123 E008 32.456"' + ) + parsed = parse_coords(lat) + if parsed is None: + raise MacroError(f"cannot parse coordinates {lat!r}") + return parsed + la, lo = _number(lat), _number(lon) + if la is None or lo is None: + raise MacroError(f"lat/lon must be numbers, got {lat!r}, {lon!r}") + if not (-90.0 <= la <= 90.0 and -180.0 <= lo <= 180.0): + raise MacroError(f"coordinates out of range: {la}, {lo}") + return la, lo + + +def read_csv_rows(path: Path, sep: Optional[str] = None) -> list[dict[str, str]]: + """Parse a UTF-8 CSV file (BOM allowed) into a list of header-keyed dicts. + + Header names and cells are stripped; blank lines are skipped. Without + *sep* the separator is sniffed among "," ";" and tab. + """ + try: + if path.stat().st_size > MAX_CSV_BYTES: + raise MacroError( + f"{path.name} is larger than {MAX_CSV_BYTES // (1024 * 1024)} MB" + ) + text = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + raise MacroError(f"file not found: {path}") from None + except (OSError, UnicodeDecodeError) as exc: + raise MacroError(f"cannot read {path}: {exc}") from None + + if sep is None: + try: + first_line = text.split("\n", 1)[0] + sep = csv.Sniffer().sniff(first_line, delimiters=",;\t").delimiter + except csv.Error: + sep = "," + elif len(sep) != 1: + raise MacroError(f"separator must be a single character, got {sep!r}") + + reader = csv.reader(io.StringIO(text), delimiter=sep) + header = [h.strip() for h in next(reader, [])] + rows = [] + for cells in reader: + if not any(c.strip() for c in cells): + continue + rows.append({ + h: (cells[i].strip() if i < len(cells) else "") + for i, h in enumerate(header) if h + }) + return rows + + # ── Runtime ────────────────────────────────────────────────────────────────── @@ -283,6 +400,7 @@ def __init__( self._profiles_dir = profiles_dir self._instruction_limit = instruction_limit self._memory_limit = memory_limit + self._base_dir: Optional[Path] = None # -- API functions exposed to Lua ----------------------------------------- @@ -319,10 +437,38 @@ def _profile_names(self) -> list[str]: continue return names + def _set_corrected(self, code=None, lat=None, lon=None) -> bool: + gc_code = _gc_code(code, "opensak.set_corrected") + la, lo = resolve_coords(lat, lon) + return bool(self._host.set_corrected_coords(gc_code, la, lo)) + + def _clear_corrected(self, code=None) -> bool: + gc_code = _gc_code(code, "opensak.clear_corrected") + return bool(self._host.set_corrected_coords(gc_code, None, None)) + + def _read_csv(self, lua, path=None, sep=None): + if not isinstance(path, str) or not path.strip(): + raise MacroError("opensak.read_csv expects a file path") + if sep is not None and not isinstance(sep, str): + raise MacroError("opensak.read_csv: separator must be a string") + file = Path(path).expanduser() + if not file.is_absolute(): + file = (self._base_dir or Path.cwd()) / file + rows = read_csv_rows(file, sep) + return lua.table_from([lua.table_from(r) for r in rows]) + # -- Running --------------------------------------------------------------- - def run(self, source: str, chunk_name: str = "macro") -> None: - """Execute *source*. Raises MacroError on any failure.""" + def run( + self, source: str, chunk_name: str = "macro", base_dir: Optional[Path] = None + ) -> None: + """Execute *source*. Raises MacroError on any failure. + + *base_dir* (usually the macro file's folder) is where relative paths + given to opensak.read_csv() are looked up; the working directory + otherwise. + """ + self._base_dir = base_dir try: from lupa.lua54 import LuaError, LuaMemoryError, LuaRuntime except ImportError as exc: @@ -350,6 +496,9 @@ def run(self, source: str, chunk_name: str = "macro") -> None: "clear_filter": self._wrap(self._host.clear_filter), "count": self._wrap(self._host.cache_count), "profiles": self._wrap(lambda: lua.table_from(self._profile_names())), + "set_corrected": self._wrap(self._set_corrected), + "clear_corrected": self._wrap(self._clear_corrected), + "read_csv": self._wrap(lambda *a: self._read_csv(lua, *a)), } ) diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 972a53e5..ca3bcb73 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -5,8 +5,11 @@ database, so "a Lua script selects caches" is covered end to end. """ +from pathlib import Path + import pytest +from opensak.db.corrected_coords import set_corrected_coords from opensak.db.database import get_session from opensak.db.models import Cache from opensak.filters.engine import ( @@ -32,6 +35,11 @@ def clear_filter(self): def cache_count(self): return 99 + def set_corrected_coords(self, gc_code, lat, lon): + self.corrected = getattr(self, "corrected", []) + self.corrected.append((gc_code, lat, lon)) + return gc_code != "GCNONE" + class DbHost(FakeHost): """Applies the filter against the test DB and remembers the selection.""" @@ -47,6 +55,9 @@ def apply_filter(self, filterset, label): self.selected = codes return len(codes) + def set_corrected_coords(self, gc_code, lat, lon): + return set_corrected_coords(gc_code, lat, lon) + def _run(source, host=None, **kwargs): out: list[str] = [] @@ -207,3 +218,85 @@ def test_empty_result_keeps_previous_selection(): assert(opensak.filter{ name = "does-not-exist" } == 0) """, host=host) assert host.selected == {"GCMAC3"} + + +# ── Corrected coordinates / CSV ────────────────────────────────────────────── + +EXAMPLES = Path(__file__).resolve().parents[2] / "macros" / "examples" + + +def test_set_corrected_accepts_numbers_strings_and_coord_text(): + host, out = _run(""" + print(opensak.set_corrected("gc123", 47.5, 8.25)) + print(opensak.set_corrected("GC124", "47.5", "8.25")) + print(opensak.set_corrected("GC125", "N47 30.000 E008 15.000")) + print(opensak.clear_corrected("GC126")) + print(opensak.set_corrected("GCNONE", 1, 2)) + """) + assert out == ["true", "true", "true", "true", "false"] + assert host.corrected[:3] == [("GC123", 47.5, 8.25)] + [("GC124", 47.5, 8.25)] + [ + ("GC125", pytest.approx(47.5), pytest.approx(8.25))] + assert host.corrected[3] == ("GC126", None, None) + + +@pytest.mark.parametrize("call,msg", [ + ('opensak.set_corrected("GC1", 91, 0)', "out of range"), + ('opensak.set_corrected("GC1", "somewhere")', "cannot parse coordinates"), + ('opensak.set_corrected("GC1", "x", 8)', "must be numbers"), + ('opensak.set_corrected(nil, 1, 2)', "expects a GC code"), + ('opensak.set_corrected("GC1", 47)', "coordinate string"), +]) +def test_set_corrected_rejects_bad_input(call, msg): + host = FakeHost() + with pytest.raises(MacroError, match=msg): + _run(call, host=host) + assert not getattr(host, "corrected", []) + + +def test_read_csv_sniffs_separator_and_resolves_relative_path(tmp_path): + (tmp_path / "a.csv").write_text( + "code ; lat;lon\nGC1;47.1;8.2\n\nGC2;46;7\n", encoding="utf-8") + (tmp_path / "b.csv").write_text("code|x\nGC3|y\n", encoding="utf-8") + out: list[str] = [] + MacroRuntime(FakeHost(), output=out.append).run(""" + local rows = opensak.read_csv("a.csv") + print(#rows, rows[1].code, rows[1].lat, rows[2].lon) + print(opensak.read_csv("b.csv", "|")[1].x) + """, base_dir=tmp_path) + assert out == ["2\tGC1\t47.1\t7", "y"] + + +def test_read_csv_missing_file(tmp_path): + with pytest.raises(MacroError, match="file not found"): + MacroRuntime(FakeHost(), output=lambda _: None).run( + 'opensak.read_csv("nope.csv")', base_dir=tmp_path) + + +def test_example_macro_sets_corrected_coords_from_csv(): + codes = ["GC1", "GC2", "GC3", "GC4", "GC5", "GC6"] + with get_session() as s: + for code in codes: + s.add(Cache(gc_code=code, name=code, cache_type="Unknown Cache", + latitude=47.0, longitude=8.0)) + set_corrected_coords("GC4", 1.0, 1.0) # overwritten by the CSV + + host, out = DbHost(), [] + MacroRuntime(host, output=out.append).run( + (EXAMPLES / "corrected_coords_from_csv.lua").read_text(encoding="utf-8"), + base_dir=EXAMPLES, + ) + + assert out[0] == "Read 6 row(s) from corrected_coords.csv" + assert out[-1] == "Done: 6 set, 0 cleared, 0 not found, 0 failed" + assert host.selected == set(codes) + with get_session() as s: + got = {c.gc_code: (c.user_note.corrected_lat, c.user_note.corrected_lon, + c.user_note.is_corrected) + for c in s.query(Cache).filter(Cache.gc_code.in_(codes))} + assert got["GC1"] == (pytest.approx(47 + 21.689 / 60), pytest.approx(6 + 18.718 / 60), True) + assert got["GC2"][:2] == (pytest.approx(47 + 8.905 / 60), pytest.approx(9 + 42.534 / 60)) + assert got["GC3"] == (pytest.approx(47.514093), pytest.approx(7.470118), True) + assert got["GC4"][:2] == (pytest.approx(46 + 40.099 / 60), pytest.approx(6 + 33.842 / 60)) + assert got["GC5"][:2] == (pytest.approx(47 + 25 / 60 + 0.37 / 3600), + pytest.approx(8 + 5 / 60 + 31.97 / 3600)) + assert got["GC6"][:2] == (pytest.approx(46.66695), pytest.approx(8.32197)) From d77bada257c6a6ee8faf3b3b9a55d4054a780ba4 Mon Sep 17 00:00:00 2001 From: nagisml Date: Thu, 1 Oct 2026 06:54:39 +0200 Subject: [PATCH 02/12] CC Example csv & lua script --- macros/examples/corrected_coords.csv | 7 ++ macros/examples/corrected_coords_from_csv.lua | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 macros/examples/corrected_coords.csv create mode 100644 macros/examples/corrected_coords_from_csv.lua diff --git a/macros/examples/corrected_coords.csv b/macros/examples/corrected_coords.csv new file mode 100644 index 00000000..c82b569b --- /dev/null +++ b/macros/examples/corrected_coords.csv @@ -0,0 +1,7 @@ +code;coords;note +GC1;N47 21.689 E006 18.718;Mystery solved via checksum +GC2;N 47° 08.905' E 009° 42.534';Final behind the bridge +GC3;47.514093, 7.470118;Decimal degrees work too +GC4;N46 40.099 E006 33.842;Bonus from the multi stages +GC5;N47° 25' 00.37" E008° 05' 31.97";DMS format +GC6;N 46.66695 E 8.32197;Decimal degrees with hemisphere letters diff --git a/macros/examples/corrected_coords_from_csv.lua b/macros/examples/corrected_coords_from_csv.lua new file mode 100644 index 00000000..6c209c4b --- /dev/null +++ b/macros/examples/corrected_coords_from_csv.lua @@ -0,0 +1,74 @@ +-- corrected_coords_from_csv.lua — set corrected coordinates from a CSV file +-- +-- Reads corrected_coords.csv (next to this macro) and stores the solved +-- coordinates of each listed cache as its corrected coordinates. +-- +-- CSV layout (header line required, separator , ; or tab is detected): +-- +-- code;coords;note +-- GC1;N47 22.123 E008 32.456;Mystery solved via checksum +-- +-- "coords" accepts every format OpenSAK understands (DMM, DMS, decimal +-- degrees). Instead of one "coords" column the file may also have separate +-- "lat" and "lon" columns in decimal degrees. A row with no coordinates +-- removes the corrected coordinates of that cache. +-- +-- Open this file via Macros → Run macro… → Open… so the relative CSV path +-- is resolved against this folder. + +local CSV_FILE = "corrected_coords.csv" + +local function has(v) return v ~= nil and v ~= "" end + +-- Returns (found, action) or raises an error for bad coordinates. +local function apply(row) + if has(row.coords) then + return opensak.set_corrected(row.code, row.coords), "set" + elseif has(row.lat) and has(row.lon) then + return opensak.set_corrected(row.code, row.lat, row.lon), "set" + else + return opensak.clear_corrected(row.code), "cleared" + end +end + +local rows = opensak.read_csv(CSV_FILE) +print(("Read %d row(s) from %s"):format(#rows, CSV_FILE)) + +local changed = {} -- GC codes that were updated, for the filter below +local set, cleared, missing, failed = 0, 0, 0, 0 + +for i, row in ipairs(rows) do + if not has(row.code) then + print(("Row %d: no GC code — skipped"):format(i)) + failed = failed + 1 + else + -- pcall so one bad line does not stop the whole run + local ok, found, action = pcall(apply, row) + if not ok then + print(("%s: %s"):format(row.code, found)) -- found = error message + failed = failed + 1 + elseif not found then + print(("%s: not in the database — skipped"):format(row.code)) + missing = missing + 1 + elseif action == "cleared" then + print(("%s: corrected coordinates removed"):format(row.code)) + cleared = cleared + 1 + else + local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon) + print(("%s: corrected → %s %s"):format(row.code, coords, row.note or "")) + set = set + 1 + changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'" + end + end +end + +print(("Done: %d set, %d cleared, %d not found, %d failed"):format( + set, cleared, missing, failed)) + +-- Show the caches that just got corrected coordinates +if #changed > 0 then + opensak.filter{ + where = "gc_code IN (" .. table.concat(changed, ", ") .. ")", + label = "Corrected via CSV", + } +end From 76b094ab9e0184733bf557d1666729af096f0d60 Mon Sep 17 00:00:00 2001 From: nagisml Date: Thu, 1 Oct 2026 21:20:39 +0200 Subject: [PATCH 03/12] Secure CC update to not delete CC on empty or half filled CSV rows --- macros/examples/corrected_coords_from_csv.lua | 32 +++++++++++---- tests/unit-tests/test_macro_runtime.py | 41 ++++++++++++++++++- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/macros/examples/corrected_coords_from_csv.lua b/macros/examples/corrected_coords_from_csv.lua index 6c209c4b..d3762cb8 100644 --- a/macros/examples/corrected_coords_from_csv.lua +++ b/macros/examples/corrected_coords_from_csv.lua @@ -10,8 +10,14 @@ -- -- "coords" accepts every format OpenSAK understands (DMM, DMS, decimal -- degrees). Instead of one "coords" column the file may also have separate --- "lat" and "lon" columns in decimal degrees. A row with no coordinates --- removes the corrected coordinates of that cache. +-- "lat" and "lon" columns in decimal degrees. +-- +-- Empty never deletes anything — a blank cell is far more often a typo or a +-- cache not solved yet than a wish to throw away a solution: +-- +-- * no coordinates in the row → skipped (listed in the output) +-- * only lat or only lon → error for that row; the rest carries on +-- * coords cell reads "clear" → corrected coordinates are removed -- -- Open this file via Macros → Run macro… → Open… so the relative CSV path -- is resolved against this folder. @@ -20,22 +26,29 @@ local CSV_FILE = "corrected_coords.csv" local function has(v) return v ~= nil and v ~= "" end --- Returns (found, action) or raises an error for bad coordinates. +-- Returns (found, action) or raises an error for bad or half-filled +-- coordinates. found is nil for a skipped row. local function apply(row) if has(row.coords) then + if row.coords:lower() == "clear" then + return opensak.clear_corrected(row.code), "cleared" + end return opensak.set_corrected(row.code, row.coords), "set" elseif has(row.lat) and has(row.lon) then return opensak.set_corrected(row.code, row.lat, row.lon), "set" - else - return opensak.clear_corrected(row.code), "cleared" + elseif has(row.lat) then + error("lon missing", 0) + elseif has(row.lon) then + error("lat missing", 0) end + return nil, "skipped" end local rows = opensak.read_csv(CSV_FILE) print(("Read %d row(s) from %s"):format(#rows, CSV_FILE)) local changed = {} -- GC codes that were updated, for the filter below -local set, cleared, missing, failed = 0, 0, 0, 0 +local set, cleared, skipped, missing, failed = 0, 0, 0, 0, 0 for i, row in ipairs(rows) do if not has(row.code) then @@ -47,6 +60,9 @@ for i, row in ipairs(rows) do if not ok then print(("%s: %s"):format(row.code, found)) -- found = error message failed = failed + 1 + elseif action == "skipped" then + print(("%s: no coordinates — skipped"):format(row.code)) + skipped = skipped + 1 elseif not found then print(("%s: not in the database — skipped"):format(row.code)) missing = missing + 1 @@ -62,8 +78,8 @@ for i, row in ipairs(rows) do end end -print(("Done: %d set, %d cleared, %d not found, %d failed"):format( - set, cleared, missing, failed)) +print(("Done: %d set, %d cleared, %d skipped, %d not found, %d failed"):format( + set, cleared, skipped, missing, failed)) -- Show the caches that just got corrected coordinates if #changed > 0 then diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index ca3bcb73..8c972cc5 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -287,7 +287,7 @@ def test_example_macro_sets_corrected_coords_from_csv(): ) assert out[0] == "Read 6 row(s) from corrected_coords.csv" - assert out[-1] == "Done: 6 set, 0 cleared, 0 not found, 0 failed" + assert out[-1] == "Done: 6 set, 0 cleared, 0 skipped, 0 not found, 0 failed" assert host.selected == set(codes) with get_session() as s: got = {c.gc_code: (c.user_note.corrected_lat, c.user_note.corrected_lon, @@ -300,3 +300,42 @@ def test_example_macro_sets_corrected_coords_from_csv(): assert got["GC5"][:2] == (pytest.approx(47 + 25 / 60 + 0.37 / 3600), pytest.approx(8 + 5 / 60 + 31.97 / 3600)) assert got["GC6"][:2] == (pytest.approx(46.66695), pytest.approx(8.32197)) + + +def test_example_macro_never_clears_on_empty_or_half_filled_rows(tmp_path): + codes = ["GCE1", "GCE2", "GCE3", "GCE4", "GCE5"] + with get_session() as s: + for code in codes: + s.add(Cache(gc_code=code, name=code, cache_type="Unknown Cache", + latitude=47.0, longitude=8.0)) + for code in codes: + set_corrected_coords(code, 1.0, 2.0) # already solved + + (tmp_path / "corrected_coords.csv").write_text( + "code;coords;lat;lon\n" + "GCE1;;;\n" # nothing → skipped + "GCE2;;47.5;\n" # lon missing → error + "GCE3;;;8.5\n" # lat missing → error + "GCE4;Clear;;\n" # explicit clear + "GCE5;;47.5;8.5\n", # set + encoding="utf-8", + ) + out = [] + MacroRuntime(DbHost(), output=out.append).run( + (EXAMPLES / "corrected_coords_from_csv.lua").read_text(encoding="utf-8"), + base_dir=tmp_path, + ) + + assert "GCE1: no coordinates — skipped" in out + assert "GCE2: lon missing" in out + assert "GCE3: lat missing" in out + assert "GCE4: corrected coordinates removed" in out + assert out[-1] == "Done: 1 set, 1 cleared, 1 skipped, 0 not found, 2 failed" + with get_session() as s: + got = {c.gc_code: (c.user_note.corrected_lat, c.user_note.corrected_lon) + for c in s.query(Cache).filter(Cache.gc_code.in_(codes))} + assert got["GCE1"] == (1.0, 2.0) + assert got["GCE2"] == (1.0, 2.0) + assert got["GCE3"] == (1.0, 2.0) + assert got["GCE4"] == (None, None) + assert got["GCE5"] == (pytest.approx(47.5), pytest.approx(8.5)) From b9c52d0adf64194be96f1f6e26b87b580ab9021c Mon Sep 17 00:00:00 2001 From: nagisml Date: Thu, 1 Oct 2026 21:26:48 +0200 Subject: [PATCH 04/12] Batch view refresh after Lua macros change corrected coords Write each change to the DB right away, but refresh the view once at the end through a new MacroHost.end_macro() hook (also called when the macro fails). Up to 50 caches are refreshed row by row; more trigger one full reload of the cache list. --- src/opensak/gui/mainwindow.py | 36 +++++++++++++++++++++++--- src/opensak/macro/runtime.py | 10 ++++++- tests/unit-tests/test_macro_runtime.py | 34 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 5664b14f..40599a8f 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -197,6 +197,11 @@ def update_counts( self._owned_lbl.setText(str(owned)) +# Up to this many caches changed by one macro run are refreshed row by row; +# more trigger a single full reload of the cache list (see end_macro()). +_MACRO_ROW_REFRESH_LIMIT = 50 + + class MainWindow(QMainWindow): def __init__(self): super().__init__() @@ -225,6 +230,9 @@ def __init__(self): # RefreshWorker's docstring and _on_refresh_result() below. self._refresh_generation: int = 0 self._active_refresh_workers: list[RefreshWorker] = [] + # GC codes whose corrected coordinates a running Lua macro changed; + # refreshed in one go by end_macro() instead of once per call. + self._macro_changed_codes: set[GcCode] = set() # Issue #558: the toolbar Where box's expression currently in # effect — only set once validated on Enter, so a half-typed # expression never leaks into refreshes triggered elsewhere. @@ -3194,15 +3202,35 @@ def cache_count(self) -> int: return len(apply_filters_auto(session, self._build_active_filterset())) def set_corrected_coords(self, gc_code, lat, lon) -> bool: - """MacroHost: set (or clear, with lat/lon = None) corrected coordinates - and refresh the table row, map pin and detail panel like the other - entry points do.""" + """MacroHost: set (or clear, with lat/lon = None) corrected coordinates. + + The view is not refreshed here but once in end_macro(): a macro + importing a large CSV would otherwise reload the table row, map pin + and detail panel for every single row.""" from opensak.db.corrected_coords import set_corrected_coords if not set_corrected_coords(gc_code, lat, lon): return False - self._on_corrected_coords_changed(gc_code) + self._macro_changed_codes.add(gc_code) return True + def end_macro(self) -> None: + """MacroHost: refresh what the macro's corrected-coordinate changes + affect. A handful of caches get the same per-cache refresh as the + other entry points; beyond that, one full reload of table and map is + cheaper than updating each row (refresh_cache_row() scans the whole + model and _load_full_cache() loads logs etc. per call).""" + changed, self._macro_changed_codes = self._macro_changed_codes, set() + if len(changed) <= _MACRO_ROW_REFRESH_LIMIT: + for gc_code in sorted(changed): + self._on_corrected_coords_changed(gc_code) + return + self._refresh_cache_list() + current = getattr(self._detail_panel, "_current_gc_code", None) + if current in changed: + full = self._load_full_cache(current) + if full: + self._detail_panel.show_cache(full) + def _open_found_updater(self) -> None: if self._trip_planner_active(): self._warn_trip_planner_active() diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 6d914c50..65dfddc4 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -219,9 +219,15 @@ def set_corrected_coords( """Set (or clear, with lat/lon = None) corrected coordinates. Returns False if the cache is not in the database. The host should - refresh whatever shows the cache (table row, map pin, detail panel). + refresh whatever shows the cache (table row, map pin, detail panel), + but may defer that to end_macro() so a macro changing thousands of + caches does not refresh the view thousands of times. """ + def end_macro(self) -> None: + """Called once after every run, also when the macro failed — apply + any refreshes deferred while it was running.""" + # ── Lua table → FilterSet ──────────────────────────────────────────────────── @@ -517,6 +523,8 @@ def run( # A Python exception raised inside a callback (e.g. the # attribute_filter) propagates as itself, not as a LuaError. raise MacroError(f"{type(exc).__name__}: {exc}") from exc + finally: + self._host.end_macro() @staticmethod def _deny_attribute(obj, attr_name, is_setting): diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 8c972cc5..02ffcd7e 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -40,6 +40,9 @@ def set_corrected_coords(self, gc_code, lat, lon): self.corrected.append((gc_code, lat, lon)) return gc_code != "GCNONE" + def end_macro(self): + self.ended = getattr(self, "ended", 0) + 1 + class DbHost(FakeHost): """Applies the filter against the test DB and remembers the selection.""" @@ -272,6 +275,37 @@ def test_read_csv_missing_file(tmp_path): 'opensak.read_csv("nope.csv")', base_dir=tmp_path) +def test_end_macro_called_once_after_run_even_on_error(): + host, _ = _run('opensak.set_corrected("GC1", 47, 8) opensak.clear_corrected("GC2")') + assert host.ended == 1 + host = FakeHost() + with pytest.raises(MacroError): + MacroRuntime(host, output=lambda _: None).run('opensak.set_corrected("GC1", 47, 8) error("boom")') + assert host.ended == 1 + + +@pytest.mark.parametrize("n, row_refreshes, full_reloads", [(3, 3, 0), (51, 0, 1)]) +def test_mainwindow_batches_macro_refresh(n, row_refreshes, full_reloads): + from types import SimpleNamespace + from opensak.gui import mainwindow as mw + + calls = {"row": [], "full": 0, "detail": []} + win = SimpleNamespace( + _macro_changed_codes={f"GC{i}" for i in range(n)}, + _on_corrected_coords_changed=calls["row"].append, + _refresh_cache_list=lambda: calls.__setitem__("full", calls["full"] + 1), + _detail_panel=SimpleNamespace(_current_gc_code="GC1", + show_cache=calls["detail"].append), + _load_full_cache=lambda code: code, + ) + mw.MainWindow.end_macro(win) + + assert len(calls["row"]) == row_refreshes + assert calls["full"] == full_reloads + assert calls["detail"] == (["GC1"] if full_reloads else []) + assert win._macro_changed_codes == set() + + def test_example_macro_sets_corrected_coords_from_csv(): codes = ["GC1", "GC2", "GC3", "GC4", "GC5", "GC6"] with get_session() as s: From e7ac37876387f5f55cfea1ef12ac5b91fb597837 Mon Sep 17 00:00:00 2001 From: nagisml Date: Thu, 1 Oct 2026 21:33:25 +0200 Subject: [PATCH 05/12] confirm before save --- macros/examples/corrected_coords_from_csv.lua | 103 ++++++++++++------ src/opensak/gui/dialogs/macro_dialog.py | 3 +- src/opensak/gui/mainwindow.py | 12 ++ src/opensak/macro/runtime.py | 10 ++ tests/unit-tests/test_macro_runtime.py | 38 +++++++ 5 files changed, 131 insertions(+), 35 deletions(-) diff --git a/macros/examples/corrected_coords_from_csv.lua b/macros/examples/corrected_coords_from_csv.lua index d3762cb8..17411f99 100644 --- a/macros/examples/corrected_coords_from_csv.lua +++ b/macros/examples/corrected_coords_from_csv.lua @@ -19,6 +19,9 @@ -- * only lat or only lon → error for that row; the rest carries on -- * coords cell reads "clear" → corrected coordinates are removed -- +-- Nothing is written until you confirm the summary ("12 will be set, +-- 2 cleared — continue?"). +-- -- Open this file via Macros → Run macro… → Open… so the relative CSV path -- is resolved against this folder. @@ -26,55 +29,87 @@ local CSV_FILE = "corrected_coords.csv" local function has(v) return v ~= nil and v ~= "" end --- Returns (found, action) or raises an error for bad or half-filled --- coordinates. found is nil for a skipped row. -local function apply(row) - if has(row.coords) then - if row.coords:lower() == "clear" then - return opensak.clear_corrected(row.code), "cleared" - end - return opensak.set_corrected(row.code, row.coords), "set" +-- What a row asks for: "set", "clear" or "skip". Raises an error for a +-- row without GC code or with half-filled coordinates. +local function plan(row) + if not has(row.code) then + error("no GC code", 0) + elseif has(row.coords) then + return row.coords:lower() == "clear" and "clear" or "set" elseif has(row.lat) and has(row.lon) then - return opensak.set_corrected(row.code, row.lat, row.lon), "set" + return "set" elseif has(row.lat) then error("lon missing", 0) elseif has(row.lon) then error("lat missing", 0) end - return nil, "skipped" + return "skip" +end + +local function apply(row, action) + if action == "clear" then + return opensak.clear_corrected(row.code) + elseif has(row.coords) then + return opensak.set_corrected(row.code, row.coords) + end + return opensak.set_corrected(row.code, row.lat, row.lon) end local rows = opensak.read_csv(CSV_FILE) print(("Read %d row(s) from %s"):format(#rows, CSV_FILE)) -local changed = {} -- GC codes that were updated, for the filter below -local set, cleared, skipped, missing, failed = 0, 0, 0, 0, 0 +-- Pass 1: check every row, write nothing +local todo = {} -- {row, action} for the rows to apply +local to_set, to_clear, skipped, failed = 0, 0, 0, 0 for i, row in ipairs(rows) do - if not has(row.code) then - print(("Row %d: no GC code — skipped"):format(i)) + local ok, action = pcall(plan, row) + if not ok then + print(("%s: %s"):format(has(row.code) and row.code or ("Row " .. i), action)) + failed = failed + 1 + elseif action == "skip" then + print(("%s: no coordinates — skipped"):format(row.code)) + skipped = skipped + 1 + else + todo[#todo + 1] = { row = row, action = action } + if action == "set" then to_set = to_set + 1 else to_clear = to_clear + 1 end + end +end + +if #todo == 0 then + print(("Nothing to do: %d skipped, %d failed"):format(skipped, failed)) + return +end + +local question = ("%d will be set, %d cleared (%d skipped, %d invalid).\nContinue?") + :format(to_set, to_clear, skipped, failed) +if not opensak.confirm(question) then + print("Cancelled — nothing changed") + return +end + +-- Pass 2: write +local changed = {} -- GC codes that were updated, for the filter below +local set, cleared, missing = 0, 0, 0 + +for _, t in ipairs(todo) do + local row = t.row + -- pcall so one bad coordinate does not stop the whole run + local ok, found = pcall(apply, row, t.action) + if not ok then + print(("%s: %s"):format(row.code, found)) -- found = error message failed = failed + 1 + elseif not found then + print(("%s: not in the database — skipped"):format(row.code)) + missing = missing + 1 + elseif t.action == "clear" then + print(("%s: corrected coordinates removed"):format(row.code)) + cleared = cleared + 1 else - -- pcall so one bad line does not stop the whole run - local ok, found, action = pcall(apply, row) - if not ok then - print(("%s: %s"):format(row.code, found)) -- found = error message - failed = failed + 1 - elseif action == "skipped" then - print(("%s: no coordinates — skipped"):format(row.code)) - skipped = skipped + 1 - elseif not found then - print(("%s: not in the database — skipped"):format(row.code)) - missing = missing + 1 - elseif action == "cleared" then - print(("%s: corrected coordinates removed"):format(row.code)) - cleared = cleared + 1 - else - local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon) - print(("%s: corrected → %s %s"):format(row.code, coords, row.note or "")) - set = set + 1 - changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'" - end + local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon) + print(("%s: corrected → %s %s"):format(row.code, coords, row.note or "")) + set = set + 1 + changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'" end end diff --git a/src/opensak/gui/dialogs/macro_dialog.py b/src/opensak/gui/dialogs/macro_dialog.py index f4b8ac81..5f4f257b 100644 --- a/src/opensak/gui/dialogs/macro_dialog.py +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -26,7 +26,8 @@ -- opensak.filter{...}, opensak.filter_profile(name), opensak.clear_filter(), -- opensak.count(), opensak.profiles(), print(...) -- opensak.set_corrected(code, lat, lon | "N47 22.123 E008 32.456"), --- opensak.clear_corrected(code), opensak.read_csv(path [, sep]) +-- opensak.clear_corrected(code), opensak.read_csv(path [, sep]), +-- opensak.confirm(message) local n = opensak.filter{ type = {"Traditional", "Multi-cache"}, diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 40599a8f..7cc071a9 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -3213,6 +3213,18 @@ def set_corrected_coords(self, gc_code, lat, lon) -> bool: self._macro_changed_codes.add(gc_code) return True + def confirm(self, message: str) -> bool: + """MacroHost: Yes/No question, on top of the macro dialog.""" + parent = getattr(self, "_macro_dialog", None) or self + reply = QMessageBox.question( + parent, + tr("macro_title"), + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return reply == QMessageBox.StandardButton.Yes + def end_macro(self) -> None: """MacroHost: refresh what the macro's corrected-coordinate changes affect. A handful of caches get the same per-cache refresh as the diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 65dfddc4..c8eca5d6 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -29,6 +29,7 @@ -- separator (, ; or tab) is detected -- unless given. A relative path is -- resolved against the macro file's folder + opensak.confirm(message) -- ask the user Yes/No; returns true on Yes print(...) -- write to the macro output pane Keys understood by opensak.filter{} (all combined with AND): @@ -224,6 +225,9 @@ def set_corrected_coords( caches does not refresh the view thousands of times. """ + def confirm(self, message: str) -> bool: + """Ask the user a Yes/No question; True on Yes.""" + def end_macro(self) -> None: """Called once after every run, also when the macro failed — apply any refreshes deferred while it was running.""" @@ -452,6 +456,11 @@ def _clear_corrected(self, code=None) -> bool: gc_code = _gc_code(code, "opensak.clear_corrected") return bool(self._host.set_corrected_coords(gc_code, None, None)) + def _confirm(self, message=None) -> bool: + if not isinstance(message, str) or not message.strip(): + raise MacroError("opensak.confirm expects a message") + return bool(self._host.confirm(message)) + def _read_csv(self, lua, path=None, sep=None): if not isinstance(path, str) or not path.strip(): raise MacroError("opensak.read_csv expects a file path") @@ -505,6 +514,7 @@ def run( "set_corrected": self._wrap(self._set_corrected), "clear_corrected": self._wrap(self._clear_corrected), "read_csv": self._wrap(lambda *a: self._read_csv(lua, *a)), + "confirm": self._wrap(self._confirm), } ) diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 02ffcd7e..94cbbe51 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -40,6 +40,13 @@ def set_corrected_coords(self, gc_code, lat, lon): self.corrected.append((gc_code, lat, lon)) return gc_code != "GCNONE" + answer = True + + def confirm(self, message): + self.asked = getattr(self, "asked", []) + self.asked.append(message) + return self.answer + def end_macro(self): self.ended = getattr(self, "ended", 0) + 1 @@ -275,6 +282,17 @@ def test_read_csv_missing_file(tmp_path): 'opensak.read_csv("nope.csv")', base_dir=tmp_path) +def test_confirm_returns_host_answer(): + host, out = _run('print(opensak.confirm("Go?"))') + assert host.asked == ["Go?"] and out == ["true"] + host = FakeHost() + host.answer = False + _, out = _run('print(opensak.confirm("Go?"))', host=host) + assert out == ["false"] + with pytest.raises(MacroError, match="expects a message"): + _run("opensak.confirm()") + + def test_end_macro_called_once_after_run_even_on_error(): host, _ = _run('opensak.set_corrected("GC1", 47, 8) opensak.clear_corrected("GC2")') assert host.ended == 1 @@ -336,6 +354,26 @@ def test_example_macro_sets_corrected_coords_from_csv(): assert got["GC6"][:2] == (pytest.approx(46.66695), pytest.approx(8.32197)) +def test_example_macro_writes_nothing_when_summary_is_declined(tmp_path): + with get_session() as s: + s.add(Cache(gc_code="GCD1", name="GCD1", cache_type="Unknown Cache", + latitude=47.0, longitude=8.0)) + (tmp_path / "corrected_coords.csv").write_text( + "code;coords\nGCD1;47.5, 8.5\nGCD2;clear\nGCD3;\n", encoding="utf-8") + host, out = DbHost(), [] + host.answer = False + MacroRuntime(host, output=out.append).run( + (EXAMPLES / "corrected_coords_from_csv.lua").read_text(encoding="utf-8"), + base_dir=tmp_path, + ) + + assert host.asked == ["1 will be set, 1 cleared (1 skipped, 0 invalid).\nContinue?"] + assert out[-1] == "Cancelled — nothing changed" + with get_session() as s: + cache = s.query(Cache).filter_by(gc_code="GCD1").one() + assert cache.user_note is None or not cache.user_note.is_corrected + + def test_example_macro_never_clears_on_empty_or_half_filled_rows(tmp_path): codes = ["GCE1", "GCE2", "GCE3", "GCE4", "GCE5"] with get_session() as s: From 0f9881e332e19ba24a24f20f1802492b2615fca0 Mon Sep 17 00:00:00 2001 From: nagisml Date: Thu, 1 Oct 2026 21:41:56 +0200 Subject: [PATCH 06/12] Move csv/lua tests into tests --- tests/fixtures/macros/csv_import.lua | 109 +++++++++++++++++++++++++ tests/fixtures/macros/declined.csv | 4 + tests/fixtures/macros/formats.csv | 7 ++ tests/fixtures/macros/partial_rows.csv | 6 ++ tests/unit-tests/test_macro_runtime.py | 97 +++++++++++----------- 5 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 tests/fixtures/macros/csv_import.lua create mode 100644 tests/fixtures/macros/declined.csv create mode 100644 tests/fixtures/macros/formats.csv create mode 100644 tests/fixtures/macros/partial_rows.csv diff --git a/tests/fixtures/macros/csv_import.lua b/tests/fixtures/macros/csv_import.lua new file mode 100644 index 00000000..f2436c3b --- /dev/null +++ b/tests/fixtures/macros/csv_import.lua @@ -0,0 +1,109 @@ +-- csv_import.lua — test fixture for tests/unit-tests/test_macro_runtime.py +-- +-- A CSV-import macro in the style of macros/examples/corrected_coords_from_csv.lua, +-- kept separate so the example can change without breaking the tests (and +-- the tests pin the behaviour regardless of what the example looks like). +-- Each test copies one of the CSV fixtures next to it as corrected_coords.csv. +-- +-- Rules: coords (any format) or lat+lon → set; coords = "clear" → clear; +-- no coordinates → skipped; only lat or only lon → error for that row. +-- Nothing is written until opensak.confirm() returns true. + +local CSV_FILE = "corrected_coords.csv" + +local function has(v) return v ~= nil and v ~= "" end + +-- What a row asks for: "set", "clear" or "skip". Raises an error for a +-- row without GC code or with half-filled coordinates. +local function plan(row) + if not has(row.code) then + error("no GC code", 0) + elseif has(row.coords) then + return row.coords:lower() == "clear" and "clear" or "set" + elseif has(row.lat) and has(row.lon) then + return "set" + elseif has(row.lat) then + error("lon missing", 0) + elseif has(row.lon) then + error("lat missing", 0) + end + return "skip" +end + +local function apply(row, action) + if action == "clear" then + return opensak.clear_corrected(row.code) + elseif has(row.coords) then + return opensak.set_corrected(row.code, row.coords) + end + return opensak.set_corrected(row.code, row.lat, row.lon) +end + +local rows = opensak.read_csv(CSV_FILE) +print(("Read %d row(s) from %s"):format(#rows, CSV_FILE)) + +-- Pass 1: check every row, write nothing +local todo = {} -- {row, action} for the rows to apply +local to_set, to_clear, skipped, failed = 0, 0, 0, 0 + +for i, row in ipairs(rows) do + local ok, action = pcall(plan, row) + if not ok then + print(("%s: %s"):format(has(row.code) and row.code or ("Row " .. i), action)) + failed = failed + 1 + elseif action == "skip" then + print(("%s: no coordinates — skipped"):format(row.code)) + skipped = skipped + 1 + else + todo[#todo + 1] = { row = row, action = action } + if action == "set" then to_set = to_set + 1 else to_clear = to_clear + 1 end + end +end + +if #todo == 0 then + print(("Nothing to do: %d skipped, %d failed"):format(skipped, failed)) + return +end + +local question = ("%d will be set, %d cleared (%d skipped, %d invalid).\nContinue?") + :format(to_set, to_clear, skipped, failed) +if not opensak.confirm(question) then + print("Cancelled — nothing changed") + return +end + +-- Pass 2: write +local changed = {} -- GC codes that were updated, for the filter below +local set, cleared, missing = 0, 0, 0 + +for _, t in ipairs(todo) do + local row = t.row + -- pcall so one bad coordinate does not stop the whole run + local ok, found = pcall(apply, row, t.action) + if not ok then + print(("%s: %s"):format(row.code, found)) -- found = error message + failed = failed + 1 + elseif not found then + print(("%s: not in the database — skipped"):format(row.code)) + missing = missing + 1 + elseif t.action == "clear" then + print(("%s: corrected coordinates removed"):format(row.code)) + cleared = cleared + 1 + else + local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon) + print(("%s: corrected → %s %s"):format(row.code, coords, row.note or "")) + set = set + 1 + changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'" + end +end + +print(("Done: %d set, %d cleared, %d skipped, %d not found, %d failed"):format( + set, cleared, skipped, missing, failed)) + +-- Show the caches that just got corrected coordinates +if #changed > 0 then + opensak.filter{ + where = "gc_code IN (" .. table.concat(changed, ", ") .. ")", + label = "Corrected via CSV", + } +end diff --git a/tests/fixtures/macros/declined.csv b/tests/fixtures/macros/declined.csv new file mode 100644 index 00000000..ad682a70 --- /dev/null +++ b/tests/fixtures/macros/declined.csv @@ -0,0 +1,4 @@ +code;coords +GCD1;47.5, 8.5 +GCD2;clear +GCD3; diff --git a/tests/fixtures/macros/formats.csv b/tests/fixtures/macros/formats.csv new file mode 100644 index 00000000..a8f688d7 --- /dev/null +++ b/tests/fixtures/macros/formats.csv @@ -0,0 +1,7 @@ +code;coords;note +GCF1;N47 21.689 E006 18.718;DMM +GCF2;N 47° 08.905' E 009° 42.534';DMM with symbols +GCF3;47.514093, 7.470118;Decimal degrees +GCF4;N46 40.099 E006 33.842;Overwrites existing +GCF5;N47° 25' 00.37" E008° 05' 31.97";DMS +GCF6;N 46.66695 E 8.32197;Decimal with hemisphere letters diff --git a/tests/fixtures/macros/partial_rows.csv b/tests/fixtures/macros/partial_rows.csv new file mode 100644 index 00000000..c3ad05f7 --- /dev/null +++ b/tests/fixtures/macros/partial_rows.csv @@ -0,0 +1,6 @@ +code;coords;lat;lon +GCE1;;; +GCE2;;47.5; +GCE3;;;8.5 +GCE4;Clear;; +GCE5;;47.5;8.5 diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 94cbbe51..a42f709d 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -233,6 +233,23 @@ def test_empty_result_keeps_previous_selection(): # ── Corrected coordinates / CSV ────────────────────────────────────────────── EXAMPLES = Path(__file__).resolve().parents[2] / "macros" / "examples" +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "macros" + + +def _run_csv_import(csv_fixture, tmp_path, host=None): + """Run the csv_import.lua fixture against a copy of *csv_fixture*.""" + (tmp_path / "corrected_coords.csv").write_bytes((FIXTURES / csv_fixture).read_bytes()) + host, out = host or DbHost(), [] + MacroRuntime(host, output=out.append).run( + (FIXTURES / "csv_import.lua").read_text(encoding="utf-8"), base_dir=tmp_path) + return host, out + + +def _add_caches(codes): + with get_session() as s: + for code in codes: + s.add(Cache(gc_code=code, name=code, cache_type="Unknown Cache", + latitude=47.0, longitude=8.0)) def test_set_corrected_accepts_numbers_strings_and_coord_text(): @@ -324,48 +341,44 @@ def test_mainwindow_batches_macro_refresh(n, row_refreshes, full_reloads): assert win._macro_changed_codes == set() -def test_example_macro_sets_corrected_coords_from_csv(): - codes = ["GC1", "GC2", "GC3", "GC4", "GC5", "GC6"] - with get_session() as s: - for code in codes: - s.add(Cache(gc_code=code, name=code, cache_type="Unknown Cache", - latitude=47.0, longitude=8.0)) - set_corrected_coords("GC4", 1.0, 1.0) # overwritten by the CSV +@pytest.mark.parametrize("script", sorted(EXAMPLES.glob("*.lua")), ids=lambda p: p.name) +def test_example_macros_compile(script): + """Compile only: catches syntax errors in the shipped examples. Calls to + a renamed or removed opensak.* function are only found when run.""" + from lupa.lua54 import LuaRuntime + LuaRuntime().compile(script.read_text(encoding="utf-8")) - host, out = DbHost(), [] - MacroRuntime(host, output=out.append).run( - (EXAMPLES / "corrected_coords_from_csv.lua").read_text(encoding="utf-8"), - base_dir=EXAMPLES, - ) + +def test_csv_import_sets_corrected_coords_in_every_format(tmp_path): + codes = ["GCF1", "GCF2", "GCF3", "GCF4", "GCF5", "GCF6"] + _add_caches(codes) + set_corrected_coords("GCF4", 1.0, 1.0) # overwritten by the CSV + + host, out = _run_csv_import("formats.csv", tmp_path) assert out[0] == "Read 6 row(s) from corrected_coords.csv" + assert host.asked == ["6 will be set, 0 cleared (0 skipped, 0 invalid).\nContinue?"] assert out[-1] == "Done: 6 set, 0 cleared, 0 skipped, 0 not found, 0 failed" assert host.selected == set(codes) with get_session() as s: got = {c.gc_code: (c.user_note.corrected_lat, c.user_note.corrected_lon, c.user_note.is_corrected) for c in s.query(Cache).filter(Cache.gc_code.in_(codes))} - assert got["GC1"] == (pytest.approx(47 + 21.689 / 60), pytest.approx(6 + 18.718 / 60), True) - assert got["GC2"][:2] == (pytest.approx(47 + 8.905 / 60), pytest.approx(9 + 42.534 / 60)) - assert got["GC3"] == (pytest.approx(47.514093), pytest.approx(7.470118), True) - assert got["GC4"][:2] == (pytest.approx(46 + 40.099 / 60), pytest.approx(6 + 33.842 / 60)) - assert got["GC5"][:2] == (pytest.approx(47 + 25 / 60 + 0.37 / 3600), - pytest.approx(8 + 5 / 60 + 31.97 / 3600)) - assert got["GC6"][:2] == (pytest.approx(46.66695), pytest.approx(8.32197)) + assert got["GCF1"] == (pytest.approx(47 + 21.689 / 60), pytest.approx(6 + 18.718 / 60), True) + assert got["GCF2"][:2] == (pytest.approx(47 + 8.905 / 60), pytest.approx(9 + 42.534 / 60)) + assert got["GCF3"] == (pytest.approx(47.514093), pytest.approx(7.470118), True) + assert got["GCF4"][:2] == (pytest.approx(46 + 40.099 / 60), pytest.approx(6 + 33.842 / 60)) + assert got["GCF5"][:2] == (pytest.approx(47 + 25 / 60 + 0.37 / 3600), + pytest.approx(8 + 5 / 60 + 31.97 / 3600)) + assert got["GCF6"][:2] == (pytest.approx(46.66695), pytest.approx(8.32197)) -def test_example_macro_writes_nothing_when_summary_is_declined(tmp_path): - with get_session() as s: - s.add(Cache(gc_code="GCD1", name="GCD1", cache_type="Unknown Cache", - latitude=47.0, longitude=8.0)) - (tmp_path / "corrected_coords.csv").write_text( - "code;coords\nGCD1;47.5, 8.5\nGCD2;clear\nGCD3;\n", encoding="utf-8") - host, out = DbHost(), [] +def test_csv_import_writes_nothing_when_summary_is_declined(tmp_path): + _add_caches(["GCD1"]) + host = DbHost() host.answer = False - MacroRuntime(host, output=out.append).run( - (EXAMPLES / "corrected_coords_from_csv.lua").read_text(encoding="utf-8"), - base_dir=tmp_path, - ) + + host, out = _run_csv_import("declined.csv", tmp_path, host) assert host.asked == ["1 will be set, 1 cleared (1 skipped, 0 invalid).\nContinue?"] assert out[-1] == "Cancelled — nothing changed" @@ -374,29 +387,15 @@ def test_example_macro_writes_nothing_when_summary_is_declined(tmp_path): assert cache.user_note is None or not cache.user_note.is_corrected -def test_example_macro_never_clears_on_empty_or_half_filled_rows(tmp_path): +def test_csv_import_never_clears_on_empty_or_half_filled_rows(tmp_path): codes = ["GCE1", "GCE2", "GCE3", "GCE4", "GCE5"] - with get_session() as s: - for code in codes: - s.add(Cache(gc_code=code, name=code, cache_type="Unknown Cache", - latitude=47.0, longitude=8.0)) + _add_caches(codes) for code in codes: set_corrected_coords(code, 1.0, 2.0) # already solved - (tmp_path / "corrected_coords.csv").write_text( - "code;coords;lat;lon\n" - "GCE1;;;\n" # nothing → skipped - "GCE2;;47.5;\n" # lon missing → error - "GCE3;;;8.5\n" # lat missing → error - "GCE4;Clear;;\n" # explicit clear - "GCE5;;47.5;8.5\n", # set - encoding="utf-8", - ) - out = [] - MacroRuntime(DbHost(), output=out.append).run( - (EXAMPLES / "corrected_coords_from_csv.lua").read_text(encoding="utf-8"), - base_dir=tmp_path, - ) + # partial_rows.csv: GCE1 nothing, GCE2 lat only, GCE3 lon only, + # GCE4 "Clear", GCE5 lat + lon + _, out = _run_csv_import("partial_rows.csv", tmp_path) assert "GCE1: no coordinates — skipped" in out assert "GCE2: lon missing" in out From 836b1ea232d8b20a3138bdfc970d77b0fb178e43 Mon Sep 17 00:00:00 2001 From: nagisml Date: Sat, 3 Oct 2026 21:59:20 +0200 Subject: [PATCH 07/12] First folder permission settings --- src/opensak/config.py | 12 + src/opensak/gui/dialogs/macro_dialog.py | 4 +- src/opensak/gui/dialogs/settings_dialog.py | 166 +++++++++++++ src/opensak/lang/cs.py | 11 + src/opensak/lang/da.py | 11 + src/opensak/lang/de.py | 11 + src/opensak/lang/de_CH.py | 11 + src/opensak/lang/en.py | 11 + src/opensak/lang/es.py | 11 + src/opensak/lang/fr.py | 11 + src/opensak/lang/nl.py | 11 + src/opensak/lang/pl.py | 11 + src/opensak/lang/pt.py | 11 + src/opensak/lang/se.py | 11 + src/opensak/macro/__init__.py | 6 +- src/opensak/macro/permissions.py | 126 ++++++++++ src/opensak/macro/runtime.py | 14 +- tests/unit-tests/test_macro_permissions.py | 257 +++++++++++++++++++++ tests/unit-tests/test_settings_dialog.py | 6 +- 19 files changed, 707 insertions(+), 5 deletions(-) create mode 100644 src/opensak/macro/permissions.py create mode 100644 tests/unit-tests/test_macro_permissions.py diff --git a/src/opensak/config.py b/src/opensak/config.py index 041ca6ab..c2c33ced 100644 --- a/src/opensak/config.py +++ b/src/opensak/config.py @@ -68,6 +68,18 @@ def get_icons_dir() -> Path: return d +def get_macros_dir() -> Path: + """ + Return (and create if needed) the user's Lua macros directory. + + Lives under /macros so macros survive app updates. Macros + may read files here by default (see opensak.macro.permissions). + """ + d = get_app_data_dir() / "macros" + d.mkdir(parents=True, exist_ok=True) + return d + + def get_log_path() -> Path: """Return the path to the application log file.""" return get_app_data_dir() / "opensak.log" diff --git a/src/opensak/gui/dialogs/macro_dialog.py b/src/opensak/gui/dialogs/macro_dialog.py index 5f4f257b..257b279c 100644 --- a/src/opensak/gui/dialogs/macro_dialog.py +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -100,8 +100,10 @@ def _setup_ui(self) -> None: layout.addLayout(buttons) def _open_file(self) -> None: + from opensak.config import get_macros_dir + path, _ = QFileDialog.getOpenFileName( - self, tr("macro_open_title"), "", "Lua (*.lua);;* (*)" + self, tr("macro_open_title"), str(get_macros_dir()), "Lua (*.lua);;* (*)" ) if not path: return diff --git a/src/opensak/gui/dialogs/settings_dialog.py b/src/opensak/gui/dialogs/settings_dialog.py index 1619ab1c..b164554e 100644 --- a/src/opensak/gui/dialogs/settings_dialog.py +++ b/src/opensak/gui/dialogs/settings_dialog.py @@ -121,6 +121,13 @@ def _setup_ui(self) -> None: self._tabs.addTab(self._build_gc_tab(), tr("settings_tab_geocaching")) self._tabs.addTab(self._build_pq_email_tab(), tr("settings_tab_pq_email")) self._tabs.addTab(self._build_advanced_tab(), tr("settings_tab_advanced")) + # Lua macros are beta-only (same flag as the Macros menu), and so is + # the list of folders they may access. + from opensak.utils import flags + self._perm_table: QTableWidget | None = None + if flags.lua_macros: + self._tabs.addTab(self._build_folder_permissions_tab(), + tr("settings_tab_folder_permissions")) layout.addWidget(self._tabs) @@ -826,6 +833,161 @@ def _on_macos_uninstall_clicked(self) -> None: from PySide6.QtWidgets import QApplication QApplication.quit() + # ── Fane: Mappe-rettigheder (Lua-makroer) ──────────────────────────────── + + _PERM_COL_FOLDER, _PERM_COL_READ, _PERM_COL_WRITE = range(3) + + def _build_folder_permissions_tab(self) -> QWidget: + from opensak.macro.permissions import load_permissions + + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + intro = QLabel(tr("settings_folder_perm_intro")) + intro.setWordWrap(True) + intro.setStyleSheet(hint_style()) + layout.addWidget(intro) + + table = QTableWidget(0, 3) + table.setHorizontalHeaderLabels([ + tr("settings_folder_perm_col_folder"), + tr("settings_folder_perm_col_read"), + tr("settings_folder_perm_col_write"), + ]) + header = table.horizontalHeader() + header.setSectionResizeMode(self._PERM_COL_FOLDER, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(self._PERM_COL_READ, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(self._PERM_COL_WRITE, QHeaderView.ResizeMode.ResizeToContents) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.verticalHeader().setVisible(False) + table.setShowGrid(False) + table.setAlternatingRowColors(True) + table.verticalHeader().setDefaultSectionSize(24) + self._perm_table = table + + for perm in load_permissions(): + self._append_perm_row(perm.path, perm.read, perm.write) + table.itemChanged.connect(self._on_perm_item_changed) + table.itemSelectionChanged.connect(self._update_perm_buttons) + layout.addWidget(table) + + btn_row = QHBoxLayout() + btn_add = QPushButton(tr("settings_folder_perm_add")) + btn_add.clicked.connect(self._on_add_perm_folder) + btn_row.addWidget(btn_add) + self._btn_perm_remove = QPushButton(tr("settings_folder_perm_remove")) + self._btn_perm_remove.setEnabled(False) + self._btn_perm_remove.clicked.connect(self._on_remove_perm_folder) + btn_row.addWidget(self._btn_perm_remove) + btn_row.addStretch() + layout.addLayout(btn_row) + + scroll = QScrollArea() + scroll.setWidget(tab) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + return scroll + + def _append_perm_row(self, path: str, read: bool, write: bool) -> int: + table = self._perm_table + assert table is not None + row = table.rowCount() + table.blockSignals(True) + table.insertRow(row) + folder_item = QTableWidgetItem(path) + folder_item.setToolTip(path) + table.setItem(row, self._PERM_COL_FOLDER, folder_item) + for col, checked in ((self._PERM_COL_READ, read), (self._PERM_COL_WRITE, write)): + item = QTableWidgetItem() + item.setFlags( + Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + ) + item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked) + table.setItem(row, col, item) + table.blockSignals(False) + return row + + def _perm_row_checked(self, row: int, col: int) -> bool: + item = self._perm_table.item(row, col) + return item is not None and item.checkState() == Qt.CheckState.Checked + + def _collect_permissions(self) -> list: + from opensak.macro.permissions import FolderPermission + + table = self._perm_table + return [ + FolderPermission( + path=table.item(row, self._PERM_COL_FOLDER).text(), + read=self._perm_row_checked(row, self._PERM_COL_READ), + write=self._perm_row_checked(row, self._PERM_COL_WRITE), + ) + for row in range(table.rowCount()) + ] + + def _update_perm_buttons(self) -> None: + self._btn_perm_remove.setEnabled(bool(self._perm_table.selectedItems())) + + def _on_perm_item_changed(self, item: QTableWidgetItem) -> None: + """Both boxes cleared → offer to drop the folder from the list. + + Answering No keeps it without rights, which blocks the folder even + when a parent folder in the list is permitted. + """ + if item.column() not in (self._PERM_COL_READ, self._PERM_COL_WRITE): + return + row = item.row() + if self._perm_row_checked(row, self._PERM_COL_READ) or \ + self._perm_row_checked(row, self._PERM_COL_WRITE): + return + path = self._perm_table.item(row, self._PERM_COL_FOLDER).text() + answer = QMessageBox.question( + self, + tr("settings_folder_perm_no_rights_title"), + tr("settings_folder_perm_no_rights_msg", path=path), + ) + if answer == QMessageBox.StandardButton.Yes: + self._perm_table.removeRow(row) + + def _on_add_perm_folder(self) -> None: + from PySide6.QtWidgets import QFileDialog + from opensak.config import get_macros_dir + + chosen = QFileDialog.getExistingDirectory( + self, tr("settings_folder_perm_add_title"), str(get_macros_dir()), + QFileDialog.Option.ShowDirsOnly, + ) + if chosen: + self._add_perm_folder(chosen) + + def _add_perm_folder(self, chosen: str) -> None: + """Add *chosen* with read permission. It is stored resolved, so the + list shows the folder the access check really compares against.""" + from opensak.macro.permissions import resolve_path + + folder = resolve_path(chosen) + table = self._perm_table + for row in range(table.rowCount()): + if resolve_path(table.item(row, self._PERM_COL_FOLDER).text()) == folder: + table.selectRow(row) + QMessageBox.information( + self, + tr("settings_tab_folder_permissions"), + tr("settings_folder_perm_duplicate"), + ) + return + table.selectRow(self._append_perm_row(str(folder), read=True, write=False)) + + def _on_remove_perm_folder(self) -> None: + rows = {index.row() for index in self._perm_table.selectedIndexes()} + for row in sorted(rows, reverse=True): + self._perm_table.removeRow(row) + # ── Fane 2: Geocaching.com ──────────────────────────────────────────────── def _build_gc_tab(self) -> QWidget: @@ -1500,6 +1662,10 @@ def _save(self) -> None: s.sync() + if self._perm_table is not None: + from opensak.macro.permissions import save_permissions + save_permissions(self._collect_permissions()) + # Database-mappe — kun gem og advar hvis brugeren faktisk har ændret den from opensak.settings_store import get_db_dir, get_store new_db_dir = self._db_dir_row.path diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index f68c4242..082d8a84 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -1264,6 +1264,17 @@ "settings_tab_map": "Mapa", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Pokročilé", + "settings_tab_folder_permissions": "Oprávnění složek", + "settings_folder_perm_intro": "Makra Lua smí číst nebo zapisovat soubory pouze ve složkách uvedených zde. Cesty se kontrolují po vyřešení \"..\" a symbolických odkazů, takže makro nemůže opustit uvedenou složku. Konkrétnější složka má přednost před nadřazenou složkou.", + "settings_folder_perm_col_folder": "Složka", + "settings_folder_perm_col_read": "Čtení", + "settings_folder_perm_col_write": "Zápis", + "settings_folder_perm_add": "Přidat složku…", + "settings_folder_perm_remove": "Odebrat", + "settings_folder_perm_add_title": "Vyberte složku pro makra", + "settings_folder_perm_duplicate": "Tato složka už je v seznamu.", + "settings_folder_perm_no_rights_title": "Žádná oprávnění", + "settings_folder_perm_no_rights_msg": "{path}\n\nnemá oprávnění ke čtení ani k zápisu. Odebrat ji ze seznamu?", "gc_not_logged_in": "Nepřihlášen", "gc_status_offline": "Offline", "gc_status_online": "Připojeno", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index 3c1a8161..c28f7967 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -1268,6 +1268,17 @@ "settings_tab_map": "Kort", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Avanceret", + "settings_tab_folder_permissions": "Mappetilladelser", + "settings_folder_perm_intro": "Lua-makroer må kun læse eller skrive filer i de mapper, der er listet her. Stier kontrolleres efter at \"..\" og symbolske links er opløst, så en makro ikke kan nå uden for en listet mappe. En mere specifik mappe har forrang for sin overmappe.", + "settings_folder_perm_col_folder": "Mappe", + "settings_folder_perm_col_read": "Læse", + "settings_folder_perm_col_write": "Skrive", + "settings_folder_perm_add": "Tilføj mappe…", + "settings_folder_perm_remove": "Fjern", + "settings_folder_perm_add_title": "Vælg en mappe til makroer", + "settings_folder_perm_duplicate": "Denne mappe er allerede på listen.", + "settings_folder_perm_no_rights_title": "Ingen tilladelser", + "settings_folder_perm_no_rights_msg": "{path}\n\nhar hverken læse- eller skrivetilladelse. Fjern den fra listen?", "gc_not_logged_in": "Ikke logget ind", "gc_status_offline": "Offline", "gc_status_online": "Forbundet", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 703651a9..9fc06ee1 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -1269,6 +1269,17 @@ "settings_tab_map": "Karte", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Erweitert", + "settings_tab_folder_permissions": "Ordnerberechtigungen", + "settings_folder_perm_intro": "Lua-Makros dürfen nur Dateien in den hier aufgeführten Ordnern lesen oder schreiben. Pfade werden geprüft, nachdem \"..\" und symbolische Links aufgelöst wurden, sodass ein Makro einen aufgeführten Ordner nicht verlassen kann. Ein spezifischerer Ordner hat Vorrang vor seinem übergeordneten Ordner.", + "settings_folder_perm_col_folder": "Ordner", + "settings_folder_perm_col_read": "Lesen", + "settings_folder_perm_col_write": "Schreiben", + "settings_folder_perm_add": "Ordner hinzufügen…", + "settings_folder_perm_remove": "Entfernen", + "settings_folder_perm_add_title": "Ordner für Makros auswählen", + "settings_folder_perm_duplicate": "Dieser Ordner ist bereits in der Liste.", + "settings_folder_perm_no_rights_title": "Keine Berechtigungen", + "settings_folder_perm_no_rights_msg": "{path}\n\nhat weder Lese- noch Schreibberechtigung. Aus der Liste entfernen?", "gc_not_logged_in": "Nicht eingeloggt", "gc_status_offline": "Offline", "gc_status_online": "Verbunden", diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index f9fc3d00..ab0d9752 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -1270,6 +1270,17 @@ "settings_tab_map": "Karte", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Erweitert", + "settings_tab_folder_permissions": "Ordnerberechtigungen", + "settings_folder_perm_intro": "Lua-Makros dürfen nur Dateien in den hier aufgeführten Ordnern lesen oder schreiben. Pfade werden geprüft, nachdem \"..\" und symbolische Links aufgelöst wurden, sodass ein Makro einen aufgeführten Ordner nicht verlassen kann. Ein spezifischerer Ordner hat Vorrang vor seinem übergeordneten Ordner.", + "settings_folder_perm_col_folder": "Ordner", + "settings_folder_perm_col_read": "Lesen", + "settings_folder_perm_col_write": "Schreiben", + "settings_folder_perm_add": "Ordner hinzufügen…", + "settings_folder_perm_remove": "Entfernen", + "settings_folder_perm_add_title": "Ordner für Makros auswählen", + "settings_folder_perm_duplicate": "Dieser Ordner ist bereits in der Liste.", + "settings_folder_perm_no_rights_title": "Keine Berechtigungen", + "settings_folder_perm_no_rights_msg": "{path}\n\nhat weder Lese- noch Schreibberechtigung. Aus der Liste entfernen?", "gc_not_logged_in": "Nicht eingeloggt", "gc_status_offline": "Offline", "gc_status_online": "Verbunden", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 80d1ea64..faeeba02 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -1267,6 +1267,17 @@ "settings_tab_map": "Map", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Advanced", + "settings_tab_folder_permissions": "Folder permissions", + "settings_folder_perm_intro": "Lua macros may only read or write files inside the folders listed here. Paths are checked after resolving \"..\" and symbolic links, so a macro cannot reach outside a listed folder. A more specific folder overrides its parent folder.", + "settings_folder_perm_col_folder": "Folder", + "settings_folder_perm_col_read": "Read", + "settings_folder_perm_col_write": "Write", + "settings_folder_perm_add": "Add folder…", + "settings_folder_perm_remove": "Remove", + "settings_folder_perm_add_title": "Select a folder for macros", + "settings_folder_perm_duplicate": "This folder is already in the list.", + "settings_folder_perm_no_rights_title": "No permissions", + "settings_folder_perm_no_rights_msg": "{path}\n\nhas neither read nor write permission. Remove it from the list?", "gc_not_logged_in": "Not logged in", "gc_status_offline": "Offline", "gc_status_online": "Connected", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index 01fd9bc5..e47ca5db 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -1269,6 +1269,17 @@ "settings_tab_map": "Mapa", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Avanzado", + "settings_tab_folder_permissions": "Permisos de carpetas", + "settings_folder_perm_intro": "Las macros Lua solo pueden leer o escribir archivos dentro de las carpetas listadas aquí. Las rutas se comprueban después de resolver \"..\" y los enlaces simbólicos, por lo que una macro no puede salir de una carpeta listada. Una carpeta más específica prevalece sobre su carpeta superior.", + "settings_folder_perm_col_folder": "Carpeta", + "settings_folder_perm_col_read": "Lectura", + "settings_folder_perm_col_write": "Escritura", + "settings_folder_perm_add": "Añadir carpeta…", + "settings_folder_perm_remove": "Quitar", + "settings_folder_perm_add_title": "Seleccione una carpeta para macros", + "settings_folder_perm_duplicate": "Esta carpeta ya está en la lista.", + "settings_folder_perm_no_rights_title": "Sin permisos", + "settings_folder_perm_no_rights_msg": "{path}\n\nno tiene permiso de lectura ni de escritura. ¿Quitarla de la lista?", "gc_not_logged_in": "No has iniciado sesión", "gc_status_offline": "Sin conexión", "gc_status_online": "Conectado", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index cb254e7e..a379dfcd 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -1269,6 +1269,17 @@ "settings_tab_map": "Carte", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Avancé", + "settings_tab_folder_permissions": "Autorisations des dossiers", + "settings_folder_perm_intro": "Les macros Lua ne peuvent lire ou écrire des fichiers que dans les dossiers listés ici. Les chemins sont vérifiés après résolution de \"..\" et des liens symboliques : une macro ne peut donc pas sortir d'un dossier listé. Un dossier plus spécifique l'emporte sur son dossier parent.", + "settings_folder_perm_col_folder": "Dossier", + "settings_folder_perm_col_read": "Lecture", + "settings_folder_perm_col_write": "Écriture", + "settings_folder_perm_add": "Ajouter un dossier…", + "settings_folder_perm_remove": "Supprimer", + "settings_folder_perm_add_title": "Choisir un dossier pour les macros", + "settings_folder_perm_duplicate": "Ce dossier est déjà dans la liste.", + "settings_folder_perm_no_rights_title": "Aucune autorisation", + "settings_folder_perm_no_rights_msg": "{path}\n\nn'a ni autorisation de lecture ni d'écriture. Le retirer de la liste ?", "gc_not_logged_in": "Non connecté", "gc_status_offline": "Hors ligne", "gc_status_online": "Connecté", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index c1794b89..76fa6f80 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -1265,6 +1265,17 @@ "settings_tab_map": "Kaart", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Geavanceerd", + "settings_tab_folder_permissions": "Mapmachtigingen", + "settings_folder_perm_intro": "Lua-macro's mogen alleen bestanden lezen of schrijven in de mappen die hier staan. Paden worden gecontroleerd nadat \"..\" en symbolische koppelingen zijn opgelost, zodat een macro een vermelde map niet kan verlaten. Een specifiekere map gaat voor op de bovenliggende map.", + "settings_folder_perm_col_folder": "Map", + "settings_folder_perm_col_read": "Lezen", + "settings_folder_perm_col_write": "Schrijven", + "settings_folder_perm_add": "Map toevoegen…", + "settings_folder_perm_remove": "Verwijderen", + "settings_folder_perm_add_title": "Kies een map voor macro's", + "settings_folder_perm_duplicate": "Deze map staat al in de lijst.", + "settings_folder_perm_no_rights_title": "Geen machtigingen", + "settings_folder_perm_no_rights_msg": "{path}\n\nheeft geen lees- of schrijfmachtiging. Uit de lijst verwijderen?", "gc_not_logged_in": "Niet ingelogd", "gc_status_offline": "Offline", "gc_status_online": "Verbonden", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index e955d555..9cbfd5ad 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -1269,6 +1269,17 @@ "settings_tab_map": "Mapa", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Zaawansowane", + "settings_tab_folder_permissions": "Uprawnienia folderów", + "settings_folder_perm_intro": "Makra Lua mogą odczytywać lub zapisywać pliki tylko w folderach wymienionych tutaj. Ścieżki są sprawdzane po rozwinięciu \"..\" i dowiązań symbolicznych, więc makro nie może wyjść poza wymieniony folder. Bardziej szczegółowy folder ma pierwszeństwo przed folderem nadrzędnym.", + "settings_folder_perm_col_folder": "Folder", + "settings_folder_perm_col_read": "Odczyt", + "settings_folder_perm_col_write": "Zapis", + "settings_folder_perm_add": "Dodaj folder…", + "settings_folder_perm_remove": "Usuń", + "settings_folder_perm_add_title": "Wybierz folder dla makr", + "settings_folder_perm_duplicate": "Ten folder jest już na liście.", + "settings_folder_perm_no_rights_title": "Brak uprawnień", + "settings_folder_perm_no_rights_msg": "{path}\n\nnie ma uprawnień do odczytu ani zapisu. Usunąć go z listy?", "gc_not_logged_in": "Niezalogowany", "gc_status_offline": "Offline", "gc_status_online": "Połączono", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index b2e507f6..f33ecf91 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -1269,6 +1269,17 @@ "settings_tab_map": "Mapa", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Avançado", + "settings_tab_folder_permissions": "Permissões de pastas", + "settings_folder_perm_intro": "As macros Lua só podem ler ou escrever ficheiros dentro das pastas listadas aqui. Os caminhos são verificados depois de resolver \"..\" e ligações simbólicas, pelo que uma macro não consegue sair de uma pasta listada. Uma pasta mais específica prevalece sobre a pasta-mãe.", + "settings_folder_perm_col_folder": "Pasta", + "settings_folder_perm_col_read": "Leitura", + "settings_folder_perm_col_write": "Escrita", + "settings_folder_perm_add": "Adicionar pasta…", + "settings_folder_perm_remove": "Remover", + "settings_folder_perm_add_title": "Escolha uma pasta para macros", + "settings_folder_perm_duplicate": "Esta pasta já está na lista.", + "settings_folder_perm_no_rights_title": "Sem permissões", + "settings_folder_perm_no_rights_msg": "{path}\n\nnão tem permissão de leitura nem de escrita. Removê-la da lista?", "gc_not_logged_in": "Sessão não iniciada", "gc_status_offline": "Offline", "gc_status_online": "Ligado", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 128c2327..a226cda7 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -1268,6 +1268,17 @@ "settings_tab_map": "Karta", "settings_tab_geocaching": "Geocaching.com", "settings_tab_advanced": "Avancerat", + "settings_tab_folder_permissions": "Mappbehörigheter", + "settings_folder_perm_intro": "Lua-makron får bara läsa eller skriva filer i mapparna som listas här. Sökvägar kontrolleras efter att \"..\" och symboliska länkar har lösts upp, så ett makro kan inte ta sig ut ur en listad mapp. En mer specifik mapp går före sin överordnade mapp.", + "settings_folder_perm_col_folder": "Mapp", + "settings_folder_perm_col_read": "Läsa", + "settings_folder_perm_col_write": "Skriva", + "settings_folder_perm_add": "Lägg till mapp…", + "settings_folder_perm_remove": "Ta bort", + "settings_folder_perm_add_title": "Välj en mapp för makron", + "settings_folder_perm_duplicate": "Den här mappen finns redan i listan.", + "settings_folder_perm_no_rights_title": "Inga behörigheter", + "settings_folder_perm_no_rights_msg": "{path}\n\nhar varken läs- eller skrivbehörighet. Ta bort den från listan?", "gc_not_logged_in": "Inte inloggad", "gc_status_offline": "Offline", "gc_status_online": "Ansluten", diff --git a/src/opensak/macro/__init__.py b/src/opensak/macro/__init__.py index 4aed9d79..d7847cc0 100644 --- a/src/opensak/macro/__init__.py +++ b/src/opensak/macro/__init__.py @@ -6,6 +6,10 @@ the available functions. """ +from opensak.macro.permissions import FolderPermission, check_access from opensak.macro.runtime import MacroError, MacroHost, MacroRuntime, build_filterset -__all__ = ["MacroError", "MacroHost", "MacroRuntime", "build_filterset"] +__all__ = [ + "FolderPermission", "MacroError", "MacroHost", "MacroRuntime", + "build_filterset", "check_access", +] diff --git a/src/opensak/macro/permissions.py b/src/opensak/macro/permissions.py new file mode 100644 index 00000000..8c361dd5 --- /dev/null +++ b/src/opensak/macro/permissions.py @@ -0,0 +1,126 @@ +""" +src/opensak/macro/permissions.py — folders Lua macros may read or write. + +The list lives in opensak.json under "macros.folder_permissions" as +[{"path": ..., "read": bool, "write": bool}, ...] and is edited in +Settings → Folder permissions. Until the user saves a list of their own, +the defaults apply: the system temp folder (read/write) and OpenSAK's +macros folder (read only). + +Every path a macro hands to a file function goes through check_access(). +Both the requested path and the listed folders are run through +Path.resolve() before comparing, so "../" segments and symlinks (or +Windows junctions) cannot lead out of a permitted folder. When folders are +nested, the most specific entry decides — a sub-folder listed without +rights therefore blocks it even if a parent folder is permitted. +""" + +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +STORE_KEY = "macros.folder_permissions" + + +class FolderAccessDenied(PermissionError): + """A macro tried to read or write outside its permitted folders.""" + + +@dataclass +class FolderPermission: + path: str + read: bool = False + write: bool = False + + def to_dict(self) -> dict: + return {"path": self.path, "read": self.read, "write": self.write} + + @classmethod + def from_dict(cls, data: dict) -> "FolderPermission": + return cls( + path=str(data.get("path") or ""), + read=bool(data.get("read")), + write=bool(data.get("write")), + ) + + +def resolve_path(path: str | Path) -> Path: + """Expand ~ and environment variables, make absolute and resolve + "..", "." and symlinks.""" + return Path(os.path.expandvars(os.path.expanduser(str(path)))).resolve() + + +def default_permissions() -> list[FolderPermission]: + from opensak.config import get_macros_dir + + return [ + FolderPermission(str(resolve_path(tempfile.gettempdir())), read=True, write=True), + FolderPermission(str(resolve_path(get_macros_dir())), read=True, write=False), + ] + + +def load_permissions() -> list[FolderPermission]: + """The saved list, or the defaults if the user never saved one.""" + from opensak.settings_store import get_store + + raw = get_store().get(STORE_KEY) + if not isinstance(raw, list): + return default_permissions() + return [ + FolderPermission.from_dict(entry) + for entry in raw + if isinstance(entry, dict) and entry.get("path") + ] + + +def save_permissions(permissions: Iterable[FolderPermission]) -> None: + from opensak.settings_store import get_store + + get_store().set(STORE_KEY, [p.to_dict() for p in permissions]) + + +def _matching_entry( + target: Path, permissions: Iterable[FolderPermission] +) -> Optional[FolderPermission]: + """The most specific listed folder that contains *target*.""" + best: Optional[FolderPermission] = None + best_depth = -1 + for perm in permissions: + try: + folder = resolve_path(perm.path) + except (OSError, RuntimeError): + continue + if target.is_relative_to(folder) and len(folder.parts) > best_depth: + best, best_depth = perm, len(folder.parts) + return best + + +def check_access( + path: str | Path, + write: bool = False, + permissions: Optional[Iterable[FolderPermission]] = None, +) -> Path: + """Return the resolved *path* if a permitted folder grants the access. + + Raises FolderAccessDenied otherwise. *permissions* defaults to the + saved list (load_permissions()). + """ + if permissions is None: + permissions = load_permissions() + try: + target = resolve_path(path) + except (OSError, RuntimeError) as exc: # RuntimeError: symlink loop + raise FolderAccessDenied(f"cannot resolve {path}: {exc}") from None + entry = _matching_entry(target, permissions) + allowed = entry is not None and (entry.write if write else entry.read) + if not allowed: + kind = "write" if write else "read" + raise FolderAccessDenied( + f"macros may not {kind} {target} — permitted folders are set in " + "Settings → Folder permissions" + ) + return target diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index c8eca5d6..ac74c6ee 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -28,7 +28,9 @@ -- rows keyed by the header line; the -- separator (, ; or tab) is detected -- unless given. A relative path is - -- resolved against the macro file's folder + -- resolved against the macro file's folder. + -- The file must lie in a folder with read + -- permission (Settings → Folder permissions) opensak.confirm(message) -- ask the user Yes/No; returns true on Yes print(...) -- write to the macro output pane @@ -93,6 +95,7 @@ WhereClauseFilter, ) from opensak.coords import parse_coords +from opensak.macro.permissions import FolderAccessDenied, FolderPermission, check_access from opensak.utils.constants import CACHE_TYPES # A runaway `while true do end` would freeze the GUI thread, so the script is @@ -404,12 +407,17 @@ def __init__( profiles_dir: Optional[Path] = None, instruction_limit: int = DEFAULT_INSTRUCTION_LIMIT, memory_limit: int = DEFAULT_MEMORY_LIMIT, + folder_permissions: Optional[list[FolderPermission]] = None, ): + """*folder_permissions* limits which folders file functions may + touch; None means the list saved in Settings, read at every run so + changes apply without reopening the macro window.""" self._host = host self._output = output or print self._profiles_dir = profiles_dir self._instruction_limit = instruction_limit self._memory_limit = memory_limit + self._folder_permissions = folder_permissions self._base_dir: Optional[Path] = None # -- API functions exposed to Lua ----------------------------------------- @@ -469,6 +477,10 @@ def _read_csv(self, lua, path=None, sep=None): file = Path(path).expanduser() if not file.is_absolute(): file = (self._base_dir or Path.cwd()) / file + try: + file = check_access(file, write=False, permissions=self._folder_permissions) + except FolderAccessDenied as exc: + raise MacroError(str(exc)) from None rows = read_csv_rows(file, sep) return lua.table_from([lua.table_from(r) for r in rows]) diff --git a/tests/unit-tests/test_macro_permissions.py b/tests/unit-tests/test_macro_permissions.py new file mode 100644 index 00000000..bd4e7125 --- /dev/null +++ b/tests/unit-tests/test_macro_permissions.py @@ -0,0 +1,257 @@ +"""tests/unit-tests/test_macro_permissions.py — folders Lua macros may access. + +Covers the Qt-free check (resolve-based, so ".." and symlinks cannot escape), +its use by opensak.read_csv(), and the Settings → Folder permissions tab. +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from opensak.macro import MacroError, MacroRuntime +from opensak.macro.permissions import ( + STORE_KEY, + FolderAccessDenied, + FolderPermission, + check_access, + default_permissions, + load_permissions, + resolve_path, + save_permissions, +) +from opensak.settings_store import get_store + + +@pytest.fixture(autouse=True) +def macros_dir(tmp_path, monkeypatch): + d = tmp_path / "install" / "macros" + d.mkdir(parents=True) + monkeypatch.setattr("opensak.config.get_macros_dir", lambda: d) + return d + + +def _rw(path, read=True, write=True): + return FolderPermission(str(path), read=read, write=write) + + +# ── Defaults / storage ─────────────────────────────────────────────────────── + +def test_defaults_are_temp_read_write_and_macros_read_only(macros_dir): + temp, macros = default_permissions() + assert Path(temp.path) == resolve_path(tempfile.gettempdir()) + assert (temp.read, temp.write) == (True, True) + assert Path(macros.path) == resolve_path(macros_dir) + assert (macros.read, macros.write) == (True, False) + + +def test_load_returns_defaults_until_a_list_is_saved(): + assert load_permissions() == default_permissions() + save_permissions([]) + assert load_permissions() == [] + save_permissions([_rw("/x", write=False)]) + assert get_store().get(STORE_KEY) == [{"path": "/x", "read": True, "write": False}] + assert load_permissions() == [_rw("/x", write=False)] + + +def test_load_skips_malformed_entries(): + get_store().set(STORE_KEY, ["junk", {"read": True}, {"path": "/ok", "read": 1}]) + assert load_permissions() == [FolderPermission("/ok", read=True, write=False)] + + +# ── check_access ───────────────────────────────────────────────────────────── + +def test_read_and_write_are_checked_separately(tmp_path): + perms = [_rw(tmp_path / "ro", write=False), _rw(tmp_path / "wo", read=False)] + assert check_access(tmp_path / "ro" / "a.csv", permissions=perms) == \ + resolve_path(tmp_path / "ro" / "a.csv") + with pytest.raises(FolderAccessDenied): + check_access(tmp_path / "ro" / "a.csv", write=True, permissions=perms) + check_access(tmp_path / "wo" / "a.csv", write=True, permissions=perms) + with pytest.raises(FolderAccessDenied): + check_access(tmp_path / "wo" / "a.csv", permissions=perms) + + +def test_path_outside_every_folder_is_denied(tmp_path): + with pytest.raises(FolderAccessDenied, match="Folder permissions"): + check_access(tmp_path / "other" / "a.csv", permissions=[_rw(tmp_path / "ok")]) + + +def test_dotdot_cannot_escape(tmp_path): + (tmp_path / "ok").mkdir() + (tmp_path / "secret.csv").write_text("x", encoding="utf-8") + with pytest.raises(FolderAccessDenied): + check_access(tmp_path / "ok" / ".." / "secret.csv", permissions=[_rw(tmp_path / "ok")]) + + +def test_sibling_with_common_prefix_is_not_inside(tmp_path): + with pytest.raises(FolderAccessDenied): + check_access(tmp_path / "ok2" / "a.csv", permissions=[_rw(tmp_path / "ok")]) + + +def test_symlink_cannot_escape(tmp_path): + ok, outside = tmp_path / "ok", tmp_path / "outside" + ok.mkdir() + outside.mkdir() + (outside / "secret.csv").write_text("x", encoding="utf-8") + try: + (ok / "link").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("cannot create symlinks here") + with pytest.raises(FolderAccessDenied): + check_access(ok / "link" / "secret.csv", permissions=[_rw(ok)]) + + +@pytest.mark.skipif(os.name != "nt", reason="junctions are Windows-only") +def test_junction_cannot_escape(tmp_path): + # Unlike symlinks, junctions need no admin rights / developer mode. + import _winapi + ok, outside = tmp_path / "ok", tmp_path / "outside" + ok.mkdir() + outside.mkdir() + _winapi.CreateJunction(str(outside), str(ok / "link")) + with pytest.raises(FolderAccessDenied): + check_access(ok / "link" / "secret.csv", permissions=[_rw(ok)]) + + +def test_most_specific_folder_wins(tmp_path): + perms = [_rw(tmp_path), _rw(tmp_path / "locked", read=False, write=False)] + check_access(tmp_path / "a.csv", permissions=perms) + with pytest.raises(FolderAccessDenied): + check_access(tmp_path / "locked" / "a.csv", permissions=perms) + + +def test_listed_folder_with_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("OPENSAK_TEST_DIR", str(tmp_path)) + check_access(tmp_path / "a.csv", permissions=[_rw(os.path.join("$OPENSAK_TEST_DIR"))]) + + +# ── opensak.read_csv() ─────────────────────────────────────────────────────── + +def _read(tmp_path, perms, name="a.csv"): + out = [] + MacroRuntime(MagicMock(),output=out.append, folder_permissions=perms).run( + f'print(#opensak.read_csv("{name}"))', base_dir=tmp_path) + return out + + +def test_read_csv_in_permitted_folder(tmp_path): + (tmp_path / "a.csv").write_text("code\nGC1\n", encoding="utf-8") + assert _read(tmp_path, [_rw(tmp_path, write=False)]) == ["1"] + + +def test_read_csv_outside_permitted_folders_fails(tmp_path): + (tmp_path / "a.csv").write_text("code\nGC1\n", encoding="utf-8") + with pytest.raises(MacroError, match="may not read"): + _read(tmp_path, [_rw(tmp_path / "elsewhere")]) + + +def test_read_csv_relative_dotdot_fails(tmp_path): + (tmp_path / "ok").mkdir() + (tmp_path / "a.csv").write_text("code\nGC1\n", encoding="utf-8") + with pytest.raises(MacroError, match="may not read"): + _read(tmp_path / "ok", [_rw(tmp_path / "ok")], name="../a.csv") + + +def test_read_csv_uses_saved_list_by_default(tmp_path): + (tmp_path / "a.csv").write_text("code\nGC1\n", encoding="utf-8") + save_permissions([]) + with pytest.raises(MacroError, match="may not read"): + MacroRuntime(MagicMock(), output=lambda _: None).run( + 'opensak.read_csv("a.csv")', base_dir=tmp_path) + save_permissions([_rw(tmp_path, write=False)]) + MacroRuntime(MagicMock(),output=lambda _: None).run( + 'opensak.read_csv("a.csv")', base_dir=tmp_path) + + +# ── Settings → Folder permissions ──────────────────────────────────────────── + +pytest.importorskip("pytestqt") + + +@pytest.fixture +def dlg(qtbot, monkeypatch): + from opensak.gui.dialogs import settings_dialog as sd + from opensak.gui.settings import AppSettings + from opensak.utils import flags + + s = AppSettings() + monkeypatch.setattr(sd, "get_settings", lambda: s) + monkeypatch.setattr("opensak.gui.settings.get_settings", lambda: s) + monkeypatch.setattr("opensak.api.geocaching.is_logged_in", lambda: False) + monkeypatch.setattr(flags, "lua_macros", True) + d = sd.SettingsDialog() + qtbot.addWidget(d) + return d + + +def _rows(d): + return [(p.path, p.read, p.write) for p in d._collect_permissions()] + + +def test_tab_lists_defaults(dlg): + from opensak.lang import tr + titles = [dlg._tabs.tabText(i) for i in range(dlg._tabs.count())] + assert tr("settings_tab_folder_permissions") in titles + assert [tuple(r[1:]) for r in _rows(dlg)] == [(True, True), (True, False)] + + +def test_tab_hidden_without_lua_macros_flag(qtbot, monkeypatch): + from opensak.gui.dialogs import settings_dialog as sd + from opensak.utils import flags + monkeypatch.setattr("opensak.api.geocaching.is_logged_in", lambda: False) + monkeypatch.setattr(flags, "lua_macros", False) + d = sd.SettingsDialog() + qtbot.addWidget(d) + assert d._perm_table is None + + +def test_toggle_write_and_save(dlg): + from PySide6.QtCore import Qt + dlg._perm_table.item(1, dlg._PERM_COL_WRITE).setCheckState(Qt.CheckState.Checked) + dlg._save() + assert [(p.read, p.write) for p in load_permissions()] == [(True, True), (True, True)] + + +@pytest.mark.parametrize("answer, remaining", [("Yes", 1), ("No", 2)]) +def test_clearing_both_boxes_asks_to_remove(dlg, monkeypatch, answer, remaining): + from PySide6.QtCore import Qt + from opensak.gui.dialogs import settings_dialog as sd + ask = MagicMock(return_value=getattr(sd.QMessageBox.StandardButton, answer)) + monkeypatch.setattr(sd.QMessageBox, "question", ask) + table = dlg._perm_table + table.item(1, dlg._PERM_COL_READ).setCheckState(Qt.CheckState.Unchecked) + ask.assert_called_once() + assert table.rowCount() == remaining + + +def test_clearing_one_box_does_not_ask(dlg, monkeypatch): + from PySide6.QtCore import Qt + from opensak.gui.dialogs import settings_dialog as sd + ask = MagicMock() + monkeypatch.setattr(sd.QMessageBox, "question", ask) + dlg._perm_table.item(0, dlg._PERM_COL_WRITE).setCheckState(Qt.CheckState.Unchecked) + ask.assert_not_called() + + +def test_add_stores_resolved_path_and_rejects_duplicates(dlg, tmp_path, monkeypatch): + from opensak.gui.dialogs import settings_dialog as sd + (tmp_path / "data").mkdir() + dlg._add_perm_folder(str(tmp_path / "data" / ".." / "data")) + assert _rows(dlg)[-1] == (str(resolve_path(tmp_path / "data")), True, False) + + info = MagicMock() + monkeypatch.setattr(sd.QMessageBox, "information", info) + dlg._add_perm_folder(str(tmp_path / "data")) + info.assert_called_once() + assert len(_rows(dlg)) == 3 + + +def test_remove_selected_and_save_empty_list(dlg): + for _ in range(2): + dlg._perm_table.selectRow(0) + dlg._on_remove_perm_folder() + dlg._save() + assert load_permissions() == [] diff --git a/tests/unit-tests/test_settings_dialog.py b/tests/unit-tests/test_settings_dialog.py index 43fcc1f7..0d5c86c3 100644 --- a/tests/unit-tests/test_settings_dialog.py +++ b/tests/unit-tests/test_settings_dialog.py @@ -150,8 +150,10 @@ def _raise(*a, **kw): class TestConstruction: def test_builds_five_tabs(self, dlg): - # General, Map (#638), Geocaching.com, PQ Email (#443), Advanced. - assert dlg._tabs.count() == 5 + # General, Map (#638), Geocaching.com, PQ Email (#443), Advanced, + # plus Folder permissions while Lua macros are enabled. + from opensak.utils import flags + assert dlg._tabs.count() == 5 + flags.lua_macros def test_load_reflects_settings(self, qtbot, settings): settings.gc_username = "preset" From 0b679c1fcc0a1dd1fe8201488496ff52c59f4f3a Mon Sep 17 00:00:00 2001 From: nagisml Date: Sat, 3 Oct 2026 22:05:44 +0200 Subject: [PATCH 08/12] fix mypy errors --- src/opensak/gui/dialogs/settings_dialog.py | 34 +++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/opensak/gui/dialogs/settings_dialog.py b/src/opensak/gui/dialogs/settings_dialog.py index b164554e..891294fd 100644 --- a/src/opensak/gui/dialogs/settings_dialog.py +++ b/src/opensak/gui/dialogs/settings_dialog.py @@ -892,9 +892,17 @@ def _build_folder_permissions_tab(self) -> QWidget: scroll.setFrameShape(QScrollArea.Shape.NoFrame) return scroll + def _perms(self) -> QTableWidget: + """The folder table; only called once the tab has been built.""" + assert self._perm_table is not None + return self._perm_table + + def _perm_path(self, row: int) -> str: + item = self._perms().item(row, self._PERM_COL_FOLDER) + return item.text() if item is not None else "" + def _append_perm_row(self, path: str, read: bool, write: bool) -> int: - table = self._perm_table - assert table is not None + table = self._perms() row = table.rowCount() table.blockSignals(True) table.insertRow(row) @@ -914,24 +922,23 @@ def _append_perm_row(self, path: str, read: bool, write: bool) -> int: return row def _perm_row_checked(self, row: int, col: int) -> bool: - item = self._perm_table.item(row, col) + item = self._perms().item(row, col) return item is not None and item.checkState() == Qt.CheckState.Checked def _collect_permissions(self) -> list: from opensak.macro.permissions import FolderPermission - table = self._perm_table return [ FolderPermission( - path=table.item(row, self._PERM_COL_FOLDER).text(), + path=self._perm_path(row), read=self._perm_row_checked(row, self._PERM_COL_READ), write=self._perm_row_checked(row, self._PERM_COL_WRITE), ) - for row in range(table.rowCount()) + for row in range(self._perms().rowCount()) ] def _update_perm_buttons(self) -> None: - self._btn_perm_remove.setEnabled(bool(self._perm_table.selectedItems())) + self._btn_perm_remove.setEnabled(bool(self._perms().selectedItems())) def _on_perm_item_changed(self, item: QTableWidgetItem) -> None: """Both boxes cleared → offer to drop the folder from the list. @@ -945,14 +952,14 @@ def _on_perm_item_changed(self, item: QTableWidgetItem) -> None: if self._perm_row_checked(row, self._PERM_COL_READ) or \ self._perm_row_checked(row, self._PERM_COL_WRITE): return - path = self._perm_table.item(row, self._PERM_COL_FOLDER).text() + path = self._perm_path(row) answer = QMessageBox.question( self, tr("settings_folder_perm_no_rights_title"), tr("settings_folder_perm_no_rights_msg", path=path), ) if answer == QMessageBox.StandardButton.Yes: - self._perm_table.removeRow(row) + self._perms().removeRow(row) def _on_add_perm_folder(self) -> None: from PySide6.QtWidgets import QFileDialog @@ -971,9 +978,9 @@ def _add_perm_folder(self, chosen: str) -> None: from opensak.macro.permissions import resolve_path folder = resolve_path(chosen) - table = self._perm_table + table = self._perms() for row in range(table.rowCount()): - if resolve_path(table.item(row, self._PERM_COL_FOLDER).text()) == folder: + if resolve_path(self._perm_path(row)) == folder: table.selectRow(row) QMessageBox.information( self, @@ -984,9 +991,10 @@ def _add_perm_folder(self, chosen: str) -> None: table.selectRow(self._append_perm_row(str(folder), read=True, write=False)) def _on_remove_perm_folder(self) -> None: - rows = {index.row() for index in self._perm_table.selectedIndexes()} + table = self._perms() + rows = {index.row() for index in table.selectedIndexes()} for row in sorted(rows, reverse=True): - self._perm_table.removeRow(row) + table.removeRow(row) # ── Fane 2: Geocaching.com ──────────────────────────────────────────────── From fdab09b33f34923f854f75b561d0011027093c08 Mon Sep 17 00:00:00 2001 From: nagisml Date: Sun, 4 Oct 2026 20:38:55 +0200 Subject: [PATCH 09/12] added 2 folder functions temp_dir() and macros_dir() --- src/opensak/macro/permissions.py | 16 +++++++++++++--- src/opensak/macro/runtime.py | 13 ++++++++++++- tests/unit-tests/test_macro_runtime.py | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/opensak/macro/permissions.py b/src/opensak/macro/permissions.py index 8c361dd5..0a2eeff3 100644 --- a/src/opensak/macro/permissions.py +++ b/src/opensak/macro/permissions.py @@ -54,12 +54,22 @@ def resolve_path(path: str | Path) -> Path: return Path(os.path.expandvars(os.path.expanduser(str(path)))).resolve() -def default_permissions() -> list[FolderPermission]: +def temp_dir() -> Path: + """The system temp folder, resolved (on macOS e.g. /private/var/folders/…/T).""" + return resolve_path(tempfile.gettempdir()) + + +def macros_dir() -> Path: + """OpenSAK's macros folder, resolved.""" from opensak.config import get_macros_dir + return resolve_path(get_macros_dir()) + + +def default_permissions() -> list[FolderPermission]: return [ - FolderPermission(str(resolve_path(tempfile.gettempdir())), read=True, write=True), - FolderPermission(str(resolve_path(get_macros_dir())), read=True, write=False), + FolderPermission(str(temp_dir()), read=True, write=True), + FolderPermission(str(macros_dir()), read=True, write=False), ] diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index ac74c6ee..ebc6bf3b 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -32,6 +32,9 @@ -- The file must lie in a folder with read -- permission (Settings → Folder permissions) opensak.confirm(message) -- ask the user Yes/No; returns true on Yes + opensak.temp_dir() -- the system temp folder (read/write by + -- default), e.g. opensak.temp_dir() .. "/x.csv" + opensak.macros_dir() -- OpenSAK's macros folder (read by default) print(...) -- write to the macro output pane Keys understood by opensak.filter{} (all combined with AND): @@ -95,7 +98,13 @@ WhereClauseFilter, ) from opensak.coords import parse_coords -from opensak.macro.permissions import FolderAccessDenied, FolderPermission, check_access +from opensak.macro.permissions import ( + FolderAccessDenied, + FolderPermission, + check_access, + macros_dir, + temp_dir, +) from opensak.utils.constants import CACHE_TYPES # A runaway `while true do end` would freeze the GUI thread, so the script is @@ -527,6 +536,8 @@ def run( "clear_corrected": self._wrap(self._clear_corrected), "read_csv": self._wrap(lambda *a: self._read_csv(lua, *a)), "confirm": self._wrap(self._confirm), + "temp_dir": self._wrap(lambda: str(temp_dir())), + "macros_dir": self._wrap(lambda: str(macros_dir())), } ) diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index a42f709d..dd41bb0f 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -299,6 +299,29 @@ def test_read_csv_missing_file(tmp_path): 'opensak.read_csv("nope.csv")', base_dir=tmp_path) +def test_temp_and_macros_dir(): + from opensak.macro.permissions import macros_dir, temp_dir + + _, out = _run("print(opensak.temp_dir()); print(opensak.macros_dir())") + assert out == [str(temp_dir()), str(macros_dir())] + + +def test_read_csv_from_temp_dir(): + import tempfile + + with tempfile.NamedTemporaryFile( + "w", suffix=".csv", delete=False, encoding="utf-8" + ) as f: + f.write("code\nGC1\n") + try: + _, out = _run( + f'print(opensak.read_csv(opensak.temp_dir() .. "/{Path(f.name).name}")[1].code)' + ) + assert out == ["GC1"] + finally: + Path(f.name).unlink() + + def test_confirm_returns_host_answer(): host, out = _run('print(opensak.confirm("Go?"))') assert host.asked == ["Go?"] and out == ["true"] From e8401cb685f86df85376af4df029c53be2c0ac7d Mon Sep 17 00:00:00 2001 From: nagisml Date: Sun, 4 Oct 2026 20:48:06 +0200 Subject: [PATCH 10/12] Generate Lua macro API docs from a single registry - runtime.py: the opensak table is now built from an API registry (name, signatures, description, example, since); filter keys are documented in FILTER_KEY_DOCS - Add opensak.api_version() (API_VERSION = 1) - scripts/generate_macro_api_docs.py writes docs/macros/api.md, which also links to the example macros in macros/examples/ - Tests fail if an exposed function or filter key is undocumented, an example does not compile, or api.md is out of date --- docs/macros/api.md | 259 ++++++++++++++++++++++++ scripts/generate_macro_api_docs.py | 27 +++ src/opensak/macro/api_docs.py | 110 ++++++++++ src/opensak/macro/runtime.py | 249 +++++++++++++++++------ tests/unit-tests/test_macro_api_docs.py | 67 ++++++ 5 files changed, 647 insertions(+), 65 deletions(-) create mode 100644 docs/macros/api.md create mode 100644 scripts/generate_macro_api_docs.py create mode 100644 src/opensak/macro/api_docs.py create mode 100644 tests/unit-tests/test_macro_api_docs.py diff --git a/docs/macros/api.md b/docs/macros/api.md new file mode 100644 index 00000000..3e9bf1d8 --- /dev/null +++ b/docs/macros/api.md @@ -0,0 +1,259 @@ + + +# OpenSAK Lua macro API + +API version: **1** (`opensak.api_version()`). + +Macros are Lua 5.4 scripts run in a sandbox. They talk to OpenSAK through the global `opensak` table. File access is limited to the folders listed in Settings → Folder permissions. + +See [Example macros](#example-macros) for complete scripts. + +## Functions + +| Function | Since | +|---|---| +| [`opensak.api_version`](#opensakapiversion) | 1 | +| [`opensak.filter`](#opensakfilter) | 1 | +| [`opensak.filter_profile`](#opensakfilterprofile) | 1 | +| [`opensak.clear_filter`](#opensakclearfilter) | 1 | +| [`opensak.count`](#opensakcount) | 1 | +| [`opensak.profiles`](#opensakprofiles) | 1 | +| [`opensak.set_corrected`](#opensaksetcorrected) | 1 | +| [`opensak.clear_corrected`](#opensakclearcorrected) | 1 | +| [`opensak.read_csv`](#opensakreadcsv) | 1 | +| [`opensak.confirm`](#opensakconfirm) | 1 | +| [`opensak.temp_dir`](#opensaktempdir) | 1 | +| [`opensak.macros_dir`](#opensakmacrosdir) | 1 | + +### opensak.api_version + +```lua +opensak.api_version() +``` + +The API version of this OpenSAK build. Each function below lists the version it was added in. + +Since API version 1. + +Example: + +```lua +if opensak.api_version() < 1 then + error("this macro needs a newer OpenSAK") +end +``` + +### opensak.filter + +```lua +opensak.filter{ key = value, ... } +``` + +Build a filter from the given keys (see Filter keys; all combined with AND) and apply it. Returns the number of matching caches. When nothing matches, 0 is returned and the view is left unchanged. + +Since API version 1. + +Example: + +```lua +local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } +print("Easy unfound traditionals: " .. n) +``` + +### opensak.filter_profile + +```lua +opensak.filter_profile("Name") +``` + +Apply a saved filter profile. Returns the number of matching caches. + +Since API version 1. + +Example: + +```lua +local n = opensak.filter_profile("Unfound nearby") +``` + +### opensak.clear_filter + +```lua +opensak.clear_filter() +``` + +Remove the active filter, so all caches are shown again. + +Since API version 1. + +Example: + +```lua +opensak.clear_filter() +``` + +### opensak.count + +```lua +opensak.count() +``` + +The number of caches matching the active filter. + +Since API version 1. + +Example: + +```lua +print(opensak.count() .. " caches shown") +``` + +### opensak.profiles + +```lua +opensak.profiles() +``` + +An array with the names of all saved filter profiles. + +Since API version 1. + +Example: + +```lua +for _, name in ipairs(opensak.profiles()) do + print(name) +end +``` + +### opensak.set_corrected + +```lua +opensak.set_corrected(code, lat, lon) +opensak.set_corrected(code, "N47 22.123 E008 32.456") +``` + +Set corrected coordinates, either as decimal degrees or as one coordinate string in any format OpenSAK understands. Returns false if the cache is not in the database. + +Since API version 1. + +Example: + +```lua +opensak.set_corrected("GC12345", 47.36872, 8.54093) +opensak.set_corrected("GC12345", "N47 22.123 E008 32.456") +``` + +### opensak.clear_corrected + +```lua +opensak.clear_corrected(code) +``` + +Remove the corrected coordinates of a cache. Returns false if the cache is not in the database. + +Since API version 1. + +Example: + +```lua +opensak.clear_corrected("GC12345") +``` + +### opensak.read_csv + +```lua +opensak.read_csv(path [, sep]) +``` + +Read a CSV file (UTF-8) into an array of rows keyed by the header line. The separator (, ; or tab) is detected unless given. A relative path is resolved against the macro file's folder. The file must lie in a folder with read permission (Settings → Folder permissions) and may be at most 10 MB. + +Since API version 1. + +Example: + +```lua +for _, row in ipairs(opensak.read_csv("solved.csv")) do + opensak.set_corrected(row.code, row.coords) +end +``` + +### opensak.confirm + +```lua +opensak.confirm(message) +``` + +Ask the user a Yes/No question. Returns true on Yes. + +Since API version 1. + +Example: + +```lua +if not opensak.confirm("Update 12 caches?") then return end +``` + +### opensak.temp_dir + +```lua +opensak.temp_dir() +``` + +The system temp folder (read and write permission by default), without a trailing separator. "/" works as separator on every platform. + +Since API version 1. + +Example: + +```lua +local rows = opensak.read_csv(opensak.temp_dir() .. "/solved.csv") +``` + +### opensak.macros_dir + +```lua +opensak.macros_dir() +``` + +OpenSAK's macros folder (read permission by default), without a trailing separator. + +Since API version 1. + +Example: + +```lua +local rows = opensak.read_csv(opensak.macros_dir() .. "/data/solved.csv") +``` + +## Filter keys + +Keys understood by `opensak.filter{}`, all combined with AND. + +| Key | Value | Meaning | +|---|---|---| +| `type` | `"Traditional" \| {"Traditional", "Multi-cache", ...}` | Cache type(s); the " Cache" suffix may be left out. | +| `container` | `"Small" \| {"Micro", "Small", ...}` | Container size(s). | +| `difficulty` | `2 \| {1, 2.5}` | Exact value or {min, max}. | +| `terrain` | `2 \| {1, 2.5}` | Exact value or {min, max}. | +| `found` | `true \| false` | Only found or only unfound caches. | +| `available` | `true` | Only available caches (not disabled or archived). | +| `name`, `code`, `owner`, `country`, `state`, `county` | `"text"` | "Contains" match on that field. | +| `where` | `"SQL WHERE clause"` | Raw clause against the caches table. | +| `label` | `"text"` | Shown in the toolbar (optional, default "Macro"). | + +## Example macros + +Ready-to-use scripts to copy and adapt are in [`macros/examples/`](../../macros/examples/). Each one starts with a comment explaining what it does and which files it expects. + +- [`corrected_coords_from_csv.lua`](../../macros/examples/corrected_coords_from_csv.lua) — set corrected coordinates from a CSV file + +## Globals + +### print + +```lua +print(...) +``` + +Write the arguments, separated by tabs, to the macro output pane. diff --git a/scripts/generate_macro_api_docs.py b/scripts/generate_macro_api_docs.py new file mode 100644 index 00000000..c2c65f9a --- /dev/null +++ b/scripts/generate_macro_api_docs.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +scripts/generate_macro_api_docs.py — regenerate docs/macros/api.md from the +Lua API registry in src/opensak/macro/runtime.py. + +Usage: + python scripts/generate_macro_api_docs.py +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from opensak.macro.api_docs import DOC_PATH, render_api_markdown + + +def main() -> None: + target = ROOT / DOC_PATH + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(render_api_markdown(), encoding="utf-8", newline="\n") + print(f"Wrote {target}") + + +if __name__ == "__main__": + main() diff --git a/src/opensak/macro/api_docs.py b/src/opensak/macro/api_docs.py new file mode 100644 index 00000000..97e1218c --- /dev/null +++ b/src/opensak/macro/api_docs.py @@ -0,0 +1,110 @@ +""" +src/opensak/macro/api_docs.py — render the Lua API reference (Markdown). + +docs/macros/api.md is generated from the registry in runtime.py by +scripts/generate_macro_api_docs.py; never edit the Markdown by hand. +""" + +from __future__ import annotations + +from pathlib import Path + +from opensak.macro.runtime import API, API_VERSION, FILTER_KEY_DOCS + +DOC_PATH = Path("docs/macros/api.md") +EXAMPLES_DIR = Path("macros/examples") +# EXAMPLES_DIR as a link relative to DOC_PATH +_EXAMPLES_LINK = "../../macros/examples" + + +def _code(text: str) -> list[str]: + return ["```lua", *text.splitlines(), "```"] + + +def _example_summary(path: Path) -> str: + """The text after the dash in the first line, e.g. + "-- name.lua — set corrected coordinates" → "set corrected coordinates".""" + first = path.read_text(encoding="utf-8").splitlines()[0] + if first.startswith("--"): + for dash in ("—", " - "): + if dash in first: + return first.split(dash, 1)[1].strip() + return "" + + +def _examples_section() -> list[str]: + lines = [ + "", + "## Example macros", + "", + f"Ready-to-use scripts to copy and adapt are in [`{EXAMPLES_DIR.as_posix()}/`]" + f"({_EXAMPLES_LINK}/). Each one starts with a comment explaining what it " + "does and which files it expects.", + "", + ] + for path in sorted(EXAMPLES_DIR.glob("*.lua")): + summary = _example_summary(path) + entry = f"- [`{path.name}`]({_EXAMPLES_LINK}/{path.name})" + lines.append(f"{entry} — {summary}" if summary else entry) + return lines + + +def render_api_markdown() -> str: + lines = [ + "", + "", + "# OpenSAK Lua macro API", + "", + f"API version: **{API_VERSION}** (`opensak.api_version()`).", + "", + "Macros are Lua 5.4 scripts run in a sandbox. They talk to OpenSAK " + "through the global `opensak` table. File access is limited to the " + "folders listed in Settings → Folder permissions.", + "", + "See [Example macros](#example-macros) for complete scripts.", + "", + "## Functions", + "", + "| Function | Since |", + "|---|---|", + ] + for func in API: + lines.append(f"| [`opensak.{func.name}`](#opensak{func.name.replace('_', '')}) " + f"| {func.since} |") + + for func in API: + lines += ["", f"### opensak.{func.name}", ""] + lines += _code("\n".join(func.signatures)) + lines += ["", func.description, "", f"Since API version {func.since}.", + "", "Example:", ""] + lines += _code(func.example) + + lines += [ + "", + "## Filter keys", + "", + "Keys understood by `opensak.filter{}`, all combined with AND.", + "", + "| Key | Value | Meaning |", + "|---|---|---|", + ] + for doc in FILTER_KEY_DOCS: + keys = ", ".join(f"`{k}`" for k in doc.keys) + value = doc.value.replace("|", "\\|") + lines.append(f"| {keys} | `{value}` | {doc.description} |") + + lines += _examples_section() + + lines += [ + "", + "## Globals", + "", + "### print", + "", + *_code("print(...)"), + "", + "Write the arguments, separated by tabs, to the macro output pane.", + "", + ] + return "\n".join(lines) diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index ebc6bf3b..54b1ecac 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -6,58 +6,10 @@ touches the main window goes through a MacroHost, so the runtime can be unit-tested with a fake host. -Lua API (POC): - - opensak.filter{ ... } -- build a filter and apply it; returns - -- the number of matching caches - -- (0 = nothing matched, view unchanged) - opensak.filter_profile("Name") -- apply a saved filter profile; returns count - opensak.clear_filter() -- show all caches again - opensak.count() -- caches matching the active filter - opensak.profiles() -- list of saved filter profile names - opensak.set_corrected(code, lat, lon) - -- set corrected coordinates (decimal - -- degrees); returns false if the cache - -- is not in the database - opensak.set_corrected(code, "N47 22.123 E008 32.456") - -- same, from a coordinate string in any - -- format OpenSAK understands - opensak.clear_corrected(code) -- remove corrected coordinates; returns - -- false if the cache is not in the database - opensak.read_csv(path [, sep]) -- read a CSV file (UTF-8) into an array of - -- rows keyed by the header line; the - -- separator (, ; or tab) is detected - -- unless given. A relative path is - -- resolved against the macro file's folder. - -- The file must lie in a folder with read - -- permission (Settings → Folder permissions) - opensak.confirm(message) -- ask the user Yes/No; returns true on Yes - opensak.temp_dir() -- the system temp folder (read/write by - -- default), e.g. opensak.temp_dir() .. "/x.csv" - opensak.macros_dir() -- OpenSAK's macros folder (read by default) - print(...) -- write to the macro output pane - -Keys understood by opensak.filter{} (all combined with AND): - - type = "Traditional" | {"Traditional", "Multi-cache", ...} - container = "Small" | {"Micro", "Small", ...} - difficulty = 2 | {1, 2.5} -- exact value or {min, max} - terrain = 2 | {1, 2.5} - found = true | false - available = true -- only available (not disabled/archived) - name, code, owner, country, state, county = "text" -- "contains" match - where = "SQL WHERE clause" -- raw clause against the caches table - label = "Shown in the toolbar" (optional) - -Example: - - local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } - print("Easy unfound traditionals: " .. n) - - for _, row in ipairs(opensak.read_csv("solved.csv")) do - opensak.set_corrected(row.code, row.coords) - end - +The Lua API is defined once, in API and FILTER_KEY_DOCS below: run() builds +the `opensak` table from API, and docs/macros/api.md is generated from both +(python scripts/generate_macro_api_docs.py). test_macro_api_docs fails when +a function is exposed without documentation or the generated file is stale. Known limitation (#938 step 4): the instruction limit only counts Lua VM instructions, not work inside C functions. Lua pattern matching backtracks @@ -76,6 +28,7 @@ import csv import io +from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Optional, Protocol @@ -138,6 +91,34 @@ | set(_TEXT_FILTERS) ) + +@dataclass(frozen=True) +class FilterKeyDoc: + """Documentation of one or more opensak.filter{} keys.""" + + keys: tuple[str, ...] + value: str + description: str + + +# Every key in FILTER_KEYS must be documented here (checked by a test). +FILTER_KEY_DOCS: tuple[FilterKeyDoc, ...] = ( + FilterKeyDoc(("type",), '"Traditional" | {"Traditional", "Multi-cache", ...}', + 'Cache type(s); the " Cache" suffix may be left out.'), + FilterKeyDoc(("container",), '"Small" | {"Micro", "Small", ...}', + "Container size(s)."), + FilterKeyDoc(("difficulty",), "2 | {1, 2.5}", "Exact value or {min, max}."), + FilterKeyDoc(("terrain",), "2 | {1, 2.5}", "Exact value or {min, max}."), + FilterKeyDoc(("found",), "true | false", "Only found or only unfound caches."), + FilterKeyDoc(("available",), "true", + "Only available caches (not disabled or archived)."), + FilterKeyDoc(tuple(_TEXT_FILTERS), '"text"', '"Contains" match on that field.'), + FilterKeyDoc(("where",), '"SQL WHERE clause"', + "Raw clause against the caches table."), + FilterKeyDoc(("label",), '"text"', + 'Shown in the toolbar (optional, default "Macro").'), +) + # Instruction budget + removal of globals that give file/process access, load # other code, or bridge back into Python. # @@ -526,19 +507,7 @@ def run( g = lua.globals() g.print = self._lua_print g.opensak = lua.table_from( - { - "filter": self._wrap(self._filter), - "filter_profile": self._wrap(self._filter_profile), - "clear_filter": self._wrap(self._host.clear_filter), - "count": self._wrap(self._host.cache_count), - "profiles": self._wrap(lambda: lua.table_from(self._profile_names())), - "set_corrected": self._wrap(self._set_corrected), - "clear_corrected": self._wrap(self._clear_corrected), - "read_csv": self._wrap(lambda *a: self._read_csv(lua, *a)), - "confirm": self._wrap(self._confirm), - "temp_dir": self._wrap(lambda: str(temp_dir())), - "macros_dir": self._wrap(lambda: str(macros_dir())), - } + {func.name: self._wrap(func.bind(self, lua)) for func in API} ) try: @@ -580,6 +549,156 @@ def _lua_print(self, *args) -> None: self._output("\t".join(_lua_tostring(a) for a in args)) +# ── Lua API registry ───────────────────────────────────────────────────────── +# +# Single source of truth for the `opensak` table. To add a function: add an +# ApiFunction here (bump API_VERSION and use it as `since` if the release +# already shipped the current version), then regenerate the docs with +# python scripts/generate_macro_api_docs.py + +# Raised whenever functions are added or changed in a released build, so +# macros can check opensak.api_version() before using newer functions. +API_VERSION = 1 + + +@dataclass(frozen=True) +class ApiFunction: + """One function of the `opensak` table, with its documentation.""" + + name: str + signatures: tuple[str, ...] + description: str + example: str + since: int + # (runtime, lua) → the Python callable exposed to Lua + bind: Callable[[MacroRuntime, Any], Callable] + + +API: tuple[ApiFunction, ...] = ( + ApiFunction( + name="api_version", + signatures=("opensak.api_version()",), + description="The API version of this OpenSAK build. Each function below " + "lists the version it was added in.", + example='if opensak.api_version() < 1 then\n' + ' error("this macro needs a newer OpenSAK")\nend', + since=1, + bind=lambda rt, lua: lambda: API_VERSION, + ), + ApiFunction( + name="filter", + signatures=("opensak.filter{ key = value, ... }",), + description="Build a filter from the given keys (see Filter keys; all " + "combined with AND) and apply it. Returns the number of " + "matching caches. When nothing matches, 0 is returned and " + "the view is left unchanged.", + example='local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false }\n' + 'print("Easy unfound traditionals: " .. n)', + since=1, + bind=lambda rt, lua: rt._filter, + ), + ApiFunction( + name="filter_profile", + signatures=('opensak.filter_profile("Name")',), + description="Apply a saved filter profile. Returns the number of " + "matching caches.", + example='local n = opensak.filter_profile("Unfound nearby")', + since=1, + bind=lambda rt, lua: rt._filter_profile, + ), + ApiFunction( + name="clear_filter", + signatures=("opensak.clear_filter()",), + description="Remove the active filter, so all caches are shown again.", + example="opensak.clear_filter()", + since=1, + bind=lambda rt, lua: rt._host.clear_filter, + ), + ApiFunction( + name="count", + signatures=("opensak.count()",), + description="The number of caches matching the active filter.", + example='print(opensak.count() .. " caches shown")', + since=1, + bind=lambda rt, lua: rt._host.cache_count, + ), + ApiFunction( + name="profiles", + signatures=("opensak.profiles()",), + description="An array with the names of all saved filter profiles.", + example="for _, name in ipairs(opensak.profiles()) do\n" + " print(name)\nend", + since=1, + bind=lambda rt, lua: lambda: lua.table_from(rt._profile_names()), + ), + ApiFunction( + name="set_corrected", + signatures=( + "opensak.set_corrected(code, lat, lon)", + 'opensak.set_corrected(code, "N47 22.123 E008 32.456")', + ), + description="Set corrected coordinates, either as decimal degrees or " + "as one coordinate string in any format OpenSAK " + "understands. Returns false if the cache is not in the " + "database.", + example='opensak.set_corrected("GC12345", 47.36872, 8.54093)\n' + 'opensak.set_corrected("GC12345", "N47 22.123 E008 32.456")', + since=1, + bind=lambda rt, lua: rt._set_corrected, + ), + ApiFunction( + name="clear_corrected", + signatures=("opensak.clear_corrected(code)",), + description="Remove the corrected coordinates of a cache. Returns " + "false if the cache is not in the database.", + example='opensak.clear_corrected("GC12345")', + since=1, + bind=lambda rt, lua: rt._clear_corrected, + ), + ApiFunction( + name="read_csv", + signatures=("opensak.read_csv(path [, sep])",), + description="Read a CSV file (UTF-8) into an array of rows keyed by the " + "header line. The separator (, ; or tab) is detected " + "unless given. A relative path is resolved against the " + "macro file's folder. The file must lie in a folder with " + "read permission (Settings → Folder permissions) and may " + f"be at most {MAX_CSV_BYTES // (1024 * 1024)} MB.", + example='for _, row in ipairs(opensak.read_csv("solved.csv")) do\n' + " opensak.set_corrected(row.code, row.coords)\nend", + since=1, + bind=lambda rt, lua: lambda *a: rt._read_csv(lua, *a), + ), + ApiFunction( + name="confirm", + signatures=("opensak.confirm(message)",), + description="Ask the user a Yes/No question. Returns true on Yes.", + example='if not opensak.confirm("Update 12 caches?") then return end', + since=1, + bind=lambda rt, lua: rt._confirm, + ), + ApiFunction( + name="temp_dir", + signatures=("opensak.temp_dir()",), + description="The system temp folder (read and write permission by " + "default), without a trailing separator. \"/\" works as " + "separator on every platform.", + example='local rows = opensak.read_csv(opensak.temp_dir() .. "/solved.csv")', + since=1, + bind=lambda rt, lua: lambda: str(temp_dir()), + ), + ApiFunction( + name="macros_dir", + signatures=("opensak.macros_dir()",), + description="OpenSAK's macros folder (read permission by default), " + "without a trailing separator.", + example='local rows = opensak.read_csv(opensak.macros_dir() .. "/data/solved.csv")', + since=1, + bind=lambda rt, lua: lambda: str(macros_dir()), + ), +) + + def _lua_tostring(value: Any) -> str: if value is None: return "nil" diff --git a/tests/unit-tests/test_macro_api_docs.py b/tests/unit-tests/test_macro_api_docs.py new file mode 100644 index 00000000..a8179fdd --- /dev/null +++ b/tests/unit-tests/test_macro_api_docs.py @@ -0,0 +1,67 @@ +"""The Lua API registry, the `opensak` table and docs/macros/api.md must agree.""" + +from pathlib import Path + +import pytest + +from opensak.macro.api_docs import DOC_PATH, render_api_markdown +from opensak.macro.runtime import ( + API, + API_VERSION, + FILTER_KEY_DOCS, + FILTER_KEYS, + MacroRuntime, +) + +REGENERATE = "run: python scripts/generate_macro_api_docs.py" + + +class _Host: + """Just enough of a MacroHost to build the `opensak` table.""" + + def clear_filter(self): + pass + + def cache_count(self): + return 0 + + def end_macro(self): + pass + + +def test_every_exposed_function_is_documented(): + out: list[str] = [] + MacroRuntime(_Host(), output=out.append).run( # type: ignore[arg-type] + "for name in pairs(opensak) do print(name) end") + assert sorted(out) == sorted(f.name for f in API) + + +def test_api_names_are_unique(): + names = [f.name for f in API] + assert len(names) == len(set(names)) + + +@pytest.mark.parametrize("func", API, ids=[f.name for f in API]) +def test_api_entry_is_complete(func): + assert func.signatures and all(s.startswith(f"opensak.{func.name}") for s in func.signatures) + assert func.description.strip() and func.example.strip() + assert 1 <= func.since <= API_VERSION + + +@pytest.mark.parametrize("func", API, ids=[f.name for f in API]) +def test_api_example_compiles(func): + from lupa.lua54 import LuaRuntime + + LuaRuntime().compile(func.example) + + +def test_every_filter_key_is_documented(): + documented = [k for doc in FILTER_KEY_DOCS for k in doc.keys] + assert sorted(documented) == sorted(FILTER_KEYS) + + +def test_generated_doc_is_up_to_date(): + path = Path(DOC_PATH) + assert path.exists(), f"{DOC_PATH} is missing — {REGENERATE}" + assert path.read_text(encoding="utf-8") == render_api_markdown(), \ + f"{DOC_PATH} is out of date — {REGENERATE}" From 76acbde3820d0da7e1bf1fef376e1aa23ff828c6 Mon Sep 17 00:00:00 2001 From: nagisml Date: Sun, 4 Oct 2026 20:55:21 +0200 Subject: [PATCH 11/12] Generate a Lua Language Server stub from the macro API registry - ApiFunction now carries typed params, return type and overloads; signatures in api.md are built from them - FilterKeyDoc gets a Lua type, emitted as the opensak.FilterSpec class - scripts/generate_macro_api_docs.py also writes macros/types/opensak.lua (---@meta) for autocompletion and inline docs in VS Code - api.md lists parameters and return values and explains the editor setup - Tests check the stub is current and compiles, and that every param and return value is typed and described --- docs/macros/api.md | 85 +++++++++++-- macros/types/opensak.lua | 160 ++++++++++++++++++++++++ scripts/generate_macro_api_docs.py | 21 ++-- src/opensak/macro/api_docs.py | 118 +++++++++++++++-- src/opensak/macro/runtime.py | 143 ++++++++++++++------- tests/unit-tests/test_macro_api_docs.py | 34 +++-- 6 files changed, 478 insertions(+), 83 deletions(-) create mode 100644 macros/types/opensak.lua diff --git a/docs/macros/api.md b/docs/macros/api.md index 3e9bf1d8..21765bf3 100644 --- a/docs/macros/api.md +++ b/docs/macros/api.md @@ -6,7 +6,7 @@ API version: **1** (`opensak.api_version()`). Macros are Lua 5.4 scripts run in a sandbox. They talk to OpenSAK through the global `opensak` table. File access is limited to the folders listed in Settings → Folder permissions. -See [Example macros](#example-macros) for complete scripts. +See [Example macros](#example-macros) for complete scripts and [Editor support](#editor-support-vs-code) for autocompletion in VS Code. ## Functions @@ -31,7 +31,9 @@ See [Example macros](#example-macros) for complete scripts. opensak.api_version() ``` -The API version of this OpenSAK build. Each function below lists the version it was added in. +The API version of this OpenSAK build. Each function lists the version it was added in. + +Returns `integer` — The API version. Since API version 1. @@ -46,10 +48,16 @@ end ### opensak.filter ```lua -opensak.filter{ key = value, ... } +opensak.filter(spec) ``` -Build a filter from the given keys (see Filter keys; all combined with AND) and apply it. Returns the number of matching caches. When nothing matches, 0 is returned and the view is left unchanged. +Build a filter from the given keys (see Filter keys; all combined with AND) and apply it. Usually called with table syntax: `opensak.filter{ ... }`. When nothing matches, the view is left unchanged. + +Parameters: + +- `spec` (`opensak.FilterSpec`) — The filter keys. + +Returns `integer` — Number of matching caches (0 = view unchanged). Since API version 1. @@ -63,10 +71,16 @@ print("Easy unfound traditionals: " .. n) ### opensak.filter_profile ```lua -opensak.filter_profile("Name") +opensak.filter_profile(name) ``` -Apply a saved filter profile. Returns the number of matching caches. +Apply a saved filter profile. + +Parameters: + +- `name` (`string`) — Name of the saved profile. + +Returns `integer` — Number of matching caches. Since API version 1. @@ -100,6 +114,8 @@ opensak.count() The number of caches matching the active filter. +Returns `integer` — Number of caches shown. + Since API version 1. Example: @@ -114,7 +130,9 @@ print(opensak.count() .. " caches shown") opensak.profiles() ``` -An array with the names of all saved filter profiles. +The names of all saved filter profiles. + +Returns `string[]` — Profile names. Since API version 1. @@ -130,10 +148,19 @@ end ```lua opensak.set_corrected(code, lat, lon) -opensak.set_corrected(code, "N47 22.123 E008 32.456") +opensak.set_corrected(code, coords) ``` -Set corrected coordinates, either as decimal degrees or as one coordinate string in any format OpenSAK understands. Returns false if the cache is not in the database. +Set corrected coordinates, either as decimal degrees or as one coordinate string in any format OpenSAK understands (DMM, DMS, decimal degrees). + +Parameters: + +- `code` (`string`) — GC code, e.g. "GC12345". +- `lat` (`number|string`) — Latitude in decimal degrees. +- `lon` (`number|string`) — Longitude in decimal degrees. +- `coords` (`string`) — Coordinates, e.g. "N47 22.123 E008 32.456". + +Returns `boolean` — false if the cache is not in the database. Since API version 1. @@ -150,7 +177,13 @@ opensak.set_corrected("GC12345", "N47 22.123 E008 32.456") opensak.clear_corrected(code) ``` -Remove the corrected coordinates of a cache. Returns false if the cache is not in the database. +Remove the corrected coordinates of a cache. + +Parameters: + +- `code` (`string`) — GC code, e.g. "GC12345". + +Returns `boolean` — false if the cache is not in the database. Since API version 1. @@ -166,7 +199,14 @@ opensak.clear_corrected("GC12345") opensak.read_csv(path [, sep]) ``` -Read a CSV file (UTF-8) into an array of rows keyed by the header line. The separator (, ; or tab) is detected unless given. A relative path is resolved against the macro file's folder. The file must lie in a folder with read permission (Settings → Folder permissions) and may be at most 10 MB. +Read a CSV file (UTF-8) into an array of rows keyed by the header line. A relative path is resolved against the macro file's folder. The file must lie in a folder with read permission (Settings → Folder permissions) and may be at most 10 MB. + +Parameters: + +- `path` (`string`) — The CSV file. +- `sep` (`string`, optional) — Separator character; detected among , ; and tab if omitted. + +Returns `table[]` — One table per data row, keyed by header. Since API version 1. @@ -184,7 +224,13 @@ end opensak.confirm(message) ``` -Ask the user a Yes/No question. Returns true on Yes. +Ask the user a Yes/No question. + +Parameters: + +- `message` (`string`) — The question. + +Returns `boolean` — true on Yes. Since API version 1. @@ -202,6 +248,8 @@ opensak.temp_dir() The system temp folder (read and write permission by default), without a trailing separator. "/" works as separator on every platform. +Returns `string` — Folder path. + Since API version 1. Example: @@ -218,6 +266,8 @@ opensak.macros_dir() OpenSAK's macros folder (read permission by default), without a trailing separator. +Returns `string` — Folder path. + Since API version 1. Example: @@ -248,6 +298,17 @@ Ready-to-use scripts to copy and adapt are in [`macros/examples/`](../../macros/ - [`corrected_coords_from_csv.lua`](../../macros/examples/corrected_coords_from_csv.lua) — set corrected coordinates from a CSV file +## Editor support (VS Code) + +[`macros/types/opensak.lua`](../../macros/types/opensak.lua) describes this API for the [Lua Language Server](https://luals.github.io/) (VS Code extension "Lua" by sumneko): autocompletion, parameter hints and these docs while you type. Macros inside the OpenSAK repository pick it up automatically. For macros in another folder, put a `.luarc.json` next to them that points at the folder holding the stub: + +```json +{ + "runtime.version": "Lua 5.4", + "workspace.library": ["C:/path/to/OpenSAK/macros/types"] +} +``` + ## Globals ### print diff --git a/macros/types/opensak.lua b/macros/types/opensak.lua new file mode 100644 index 00000000..100e63fd --- /dev/null +++ b/macros/types/opensak.lua @@ -0,0 +1,160 @@ +---@meta +-- Generated from src/opensak/macro/runtime.py by scripts/generate_macro_api_docs.py — do not edit by hand. +-- OpenSAK Lua macro API, version 1. Reference: docs/macros/api.md + +---Keys understood by `opensak.filter{}`, all combined with AND. +---@class opensak.FilterSpec +---@field type? string|string[] Cache type(s); the " Cache" suffix may be left out. +---@field container? string|string[] Container size(s). +---@field difficulty? number|number[] Exact value or {min, max}. +---@field terrain? number|number[] Exact value or {min, max}. +---@field found? boolean Only found or only unfound caches. +---@field available? boolean Only available caches (not disabled or archived). +---@field name? string "Contains" match on that field. +---@field code? string "Contains" match on that field. +---@field owner? string "Contains" match on that field. +---@field country? string "Contains" match on that field. +---@field state? string "Contains" match on that field. +---@field county? string "Contains" match on that field. +---@field where? string Raw clause against the caches table. +---@field label? string Shown in the toolbar (optional, default "Macro"). + +---The OpenSAK API, available as a global in every macro. +opensak = {} + +---The API version of this OpenSAK build. Each function lists the version it was added in. +--- +---Since API version 1. +--- +---```lua +---if opensak.api_version() < 1 then +--- error("this macro needs a newer OpenSAK") +---end +---``` +---@return integer # The API version. +function opensak.api_version() end + +---Build a filter from the given keys (see Filter keys; all combined with AND) and apply it. Usually called with table syntax: `opensak.filter{ ... }`. When nothing matches, the view is left unchanged. +--- +---Since API version 1. +--- +---```lua +---local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } +---print("Easy unfound traditionals: " .. n) +---``` +---@param spec opensak.FilterSpec The filter keys. +---@return integer # Number of matching caches (0 = view unchanged). +function opensak.filter(spec) end + +---Apply a saved filter profile. +--- +---Since API version 1. +--- +---```lua +---local n = opensak.filter_profile("Unfound nearby") +---``` +---@param name string Name of the saved profile. +---@return integer # Number of matching caches. +function opensak.filter_profile(name) end + +---Remove the active filter, so all caches are shown again. +--- +---Since API version 1. +--- +---```lua +---opensak.clear_filter() +---``` +function opensak.clear_filter() end + +---The number of caches matching the active filter. +--- +---Since API version 1. +--- +---```lua +---print(opensak.count() .. " caches shown") +---``` +---@return integer # Number of caches shown. +function opensak.count() end + +---The names of all saved filter profiles. +--- +---Since API version 1. +--- +---```lua +---for _, name in ipairs(opensak.profiles()) do +--- print(name) +---end +---``` +---@return string[] # Profile names. +function opensak.profiles() end + +---Set corrected coordinates, either as decimal degrees or as one coordinate string in any format OpenSAK understands (DMM, DMS, decimal degrees). +--- +---Since API version 1. +--- +---```lua +---opensak.set_corrected("GC12345", 47.36872, 8.54093) +---opensak.set_corrected("GC12345", "N47 22.123 E008 32.456") +---``` +---@param code string GC code, e.g. "GC12345". +---@param lat number|string Latitude in decimal degrees. +---@param lon number|string Longitude in decimal degrees. +---@return boolean # false if the cache is not in the database. +---@overload fun(code: string, coords: string): boolean +function opensak.set_corrected(code, lat, lon) end + +---Remove the corrected coordinates of a cache. +--- +---Since API version 1. +--- +---```lua +---opensak.clear_corrected("GC12345") +---``` +---@param code string GC code, e.g. "GC12345". +---@return boolean # false if the cache is not in the database. +function opensak.clear_corrected(code) end + +---Read a CSV file (UTF-8) into an array of rows keyed by the header line. A relative path is resolved against the macro file's folder. The file must lie in a folder with read permission (Settings → Folder permissions) and may be at most 10 MB. +--- +---Since API version 1. +--- +---```lua +---for _, row in ipairs(opensak.read_csv("solved.csv")) do +--- opensak.set_corrected(row.code, row.coords) +---end +---``` +---@param path string The CSV file. +---@param sep? string Separator character; detected among , ; and tab if omitted. +---@return table[] # One table per data row, keyed by header. +function opensak.read_csv(path, sep) end + +---Ask the user a Yes/No question. +--- +---Since API version 1. +--- +---```lua +---if not opensak.confirm("Update 12 caches?") then return end +---``` +---@param message string The question. +---@return boolean # true on Yes. +function opensak.confirm(message) end + +---The system temp folder (read and write permission by default), without a trailing separator. "/" works as separator on every platform. +--- +---Since API version 1. +--- +---```lua +---local rows = opensak.read_csv(opensak.temp_dir() .. "/solved.csv") +---``` +---@return string # Folder path. +function opensak.temp_dir() end + +---OpenSAK's macros folder (read permission by default), without a trailing separator. +--- +---Since API version 1. +--- +---```lua +---local rows = opensak.read_csv(opensak.macros_dir() .. "/data/solved.csv") +---``` +---@return string # Folder path. +function opensak.macros_dir() end diff --git a/scripts/generate_macro_api_docs.py b/scripts/generate_macro_api_docs.py index c2c65f9a..bd812eee 100644 --- a/scripts/generate_macro_api_docs.py +++ b/scripts/generate_macro_api_docs.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ -scripts/generate_macro_api_docs.py — regenerate docs/macros/api.md from the -Lua API registry in src/opensak/macro/runtime.py. +scripts/generate_macro_api_docs.py — regenerate docs/macros/api.md and the +Lua Language Server stub macros/types/opensak.lua from the Lua API registry +in src/opensak/macro/runtime.py. Usage: python scripts/generate_macro_api_docs.py @@ -13,14 +14,20 @@ ROOT = Path(__file__).parent.parent sys.path.insert(0, str(ROOT / "src")) -from opensak.macro.api_docs import DOC_PATH, render_api_markdown +from opensak.macro.api_docs import ( + DOC_PATH, + STUB_PATH, + render_api_markdown, + render_lua_stub, +) def main() -> None: - target = ROOT / DOC_PATH - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(render_api_markdown(), encoding="utf-8", newline="\n") - print(f"Wrote {target}") + for path, text in ((DOC_PATH, render_api_markdown()), (STUB_PATH, render_lua_stub())): + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8", newline="\n") + print(f"Wrote {target}") if __name__ == "__main__": diff --git a/src/opensak/macro/api_docs.py b/src/opensak/macro/api_docs.py index 97e1218c..67c0f3cd 100644 --- a/src/opensak/macro/api_docs.py +++ b/src/opensak/macro/api_docs.py @@ -1,24 +1,37 @@ """ -src/opensak/macro/api_docs.py — render the Lua API reference (Markdown). +src/opensak/macro/api_docs.py — render the Lua API reference. -docs/macros/api.md is generated from the registry in runtime.py by -scripts/generate_macro_api_docs.py; never edit the Markdown by hand. +Two files are generated from the registry in runtime.py by +scripts/generate_macro_api_docs.py; never edit them by hand: + + * docs/macros/api.md — the reference for people (Markdown) + * macros/types/opensak.lua — a ---@meta stub for the Lua Language Server, + giving autocompletion and inline docs in + VS Code while writing macros """ from __future__ import annotations from pathlib import Path -from opensak.macro.runtime import API, API_VERSION, FILTER_KEY_DOCS +from opensak.macro.runtime import API, API_VERSION, FILTER_KEY_DOCS, Param DOC_PATH = Path("docs/macros/api.md") +STUB_PATH = Path("macros/types/opensak.lua") EXAMPLES_DIR = Path("macros/examples") -# EXAMPLES_DIR as a link relative to DOC_PATH +# Links relative to DOC_PATH _EXAMPLES_LINK = "../../macros/examples" +_STUB_LINK = "../../macros/types/opensak.lua" + +_GENERATED = ("Generated from src/opensak/macro/runtime.py by " + "scripts/generate_macro_api_docs.py — do not edit by hand.") + + +def _code(text: str, lang: str = "lua") -> list[str]: + return [f"```{lang}", *text.splitlines(), "```"] -def _code(text: str) -> list[str]: - return ["```lua", *text.splitlines(), "```"] +# ── Markdown ───────────────────────────────────────────────────────────────── def _example_summary(path: Path) -> str: @@ -49,10 +62,33 @@ def _examples_section() -> list[str]: return lines +def _editor_section() -> list[str]: + return [ + "", + "## Editor support (VS Code)", + "", + f"[`{STUB_PATH.as_posix()}`]({_STUB_LINK}) describes this API for the " + "[Lua Language Server](https://luals.github.io/) (VS Code extension " + '"Lua" by sumneko): autocompletion, parameter hints and these docs ' + "while you type. Macros inside the OpenSAK repository pick it up " + "automatically. For macros in another folder, put a `.luarc.json` " + "next to them that points at the folder holding the stub:", + "", + *_code('{\n' + ' "runtime.version": "Lua 5.4",\n' + ' "workspace.library": ["C:/path/to/OpenSAK/macros/types"]\n' + '}', "json"), + ] + + +def _param_line(p: Param) -> str: + optional = ", optional" if p.optional else "" + return f"- `{p.name}` (`{p.type}`{optional}) — {p.description}" + + def render_api_markdown() -> str: lines = [ - "", + f"", "", "# OpenSAK Lua macro API", "", @@ -62,7 +98,8 @@ def render_api_markdown() -> str: "through the global `opensak` table. File access is limited to the " "folders listed in Settings → Folder permissions.", "", - "See [Example macros](#example-macros) for complete scripts.", + "See [Example macros](#example-macros) for complete scripts and " + "[Editor support](#editor-support-vs-code) for autocompletion in VS Code.", "", "## Functions", "", @@ -76,8 +113,17 @@ def render_api_markdown() -> str: for func in API: lines += ["", f"### opensak.{func.name}", ""] lines += _code("\n".join(func.signatures)) - lines += ["", func.description, "", f"Since API version {func.since}.", - "", "Example:", ""] + lines += ["", func.description, ""] + params: dict[str, Param] = {} + for p in (*func.params, *(q for form in func.overloads for q in form)): + params.setdefault(p.name, p) + if params: + lines += ["Parameters:", ""] + lines += [_param_line(p) for p in params.values()] + lines.append("") + if func.returns: + lines += [f"Returns `{func.returns[0]}` — {func.returns[1]}", ""] + lines += [f"Since API version {func.since}.", "", "Example:", ""] lines += _code(func.example) lines += [ @@ -95,6 +141,7 @@ def render_api_markdown() -> str: lines.append(f"| {keys} | `{value}` | {doc.description} |") lines += _examples_section() + lines += _editor_section() lines += [ "", @@ -108,3 +155,50 @@ def render_api_markdown() -> str: "", ] return "\n".join(lines) + + +# ── Lua Language Server stub ───────────────────────────────────────────────── + + +def _comment(text: str) -> list[str]: + return [f"---{line}".rstrip() for line in text.splitlines()] + + +def _overload(params: tuple[Param, ...], returns: str | None) -> str: + args = ", ".join(f"{p.name}{'?' if p.optional else ''}: {p.type}" for p in params) + return f"fun({args})" + (f": {returns}" if returns else "") + + +def render_lua_stub() -> str: + lines = [ + "---@meta", + f"-- {_GENERATED}", + f"-- OpenSAK Lua macro API, version {API_VERSION}. Reference: {DOC_PATH.as_posix()}", + "", + "---Keys understood by `opensak.filter{}`, all combined with AND.", + "---@class opensak.FilterSpec", + ] + for doc in FILTER_KEY_DOCS: + for key in doc.keys: + lines.append(f"---@field {key}? {doc.type} {doc.description}") + + lines += [ + "", + "---The OpenSAK API, available as a global in every macro.", + "opensak = {}", + ] + for func in API: + lines.append("") + lines += _comment(func.description) + lines += ["---", f"---Since API version {func.since}.", "---"] + lines += _comment(f"```lua\n{func.example}\n```") + for p in func.params: + lines.append(f"---@param {p.name}{'?' if p.optional else ''} {p.type} {p.description}") + if func.returns: + lines.append(f"---@return {func.returns[0]} # {func.returns[1]}") + for form in func.overloads: + lines.append(f"---@overload {_overload(form, func.returns and func.returns[0])}") + args = ", ".join(p.name for p in func.params) + lines.append(f"function opensak.{func.name}({args}) end") + lines.append("") + return "\n".join(lines) diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 54b1ecac..7e137918 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -7,9 +7,10 @@ unit-tested with a fake host. The Lua API is defined once, in API and FILTER_KEY_DOCS below: run() builds -the `opensak` table from API, and docs/macros/api.md is generated from both +the `opensak` table from API, and docs/macros/api.md plus the Lua Language +Server stub macros/types/opensak.lua are generated from both (python scripts/generate_macro_api_docs.py). test_macro_api_docs fails when -a function is exposed without documentation or the generated file is stale. +a function is exposed without documentation or a generated file is stale. Known limitation (#938 step 4): the instruction limit only counts Lua VM instructions, not work inside C functions. Lua pattern matching backtracks @@ -97,25 +98,32 @@ class FilterKeyDoc: """Documentation of one or more opensak.filter{} keys.""" keys: tuple[str, ...] + # Lua Language Server type of the value + type: str value: str description: str # Every key in FILTER_KEYS must be documented here (checked by a test). FILTER_KEY_DOCS: tuple[FilterKeyDoc, ...] = ( - FilterKeyDoc(("type",), '"Traditional" | {"Traditional", "Multi-cache", ...}', + FilterKeyDoc(("type",), "string|string[]", + '"Traditional" | {"Traditional", "Multi-cache", ...}', 'Cache type(s); the " Cache" suffix may be left out.'), - FilterKeyDoc(("container",), '"Small" | {"Micro", "Small", ...}', - "Container size(s)."), - FilterKeyDoc(("difficulty",), "2 | {1, 2.5}", "Exact value or {min, max}."), - FilterKeyDoc(("terrain",), "2 | {1, 2.5}", "Exact value or {min, max}."), - FilterKeyDoc(("found",), "true | false", "Only found or only unfound caches."), - FilterKeyDoc(("available",), "true", + FilterKeyDoc(("container",), "string|string[]", + '"Small" | {"Micro", "Small", ...}', "Container size(s)."), + FilterKeyDoc(("difficulty",), "number|number[]", "2 | {1, 2.5}", + "Exact value or {min, max}."), + FilterKeyDoc(("terrain",), "number|number[]", "2 | {1, 2.5}", + "Exact value or {min, max}."), + FilterKeyDoc(("found",), "boolean", "true | false", + "Only found or only unfound caches."), + FilterKeyDoc(("available",), "boolean", "true", "Only available caches (not disabled or archived)."), - FilterKeyDoc(tuple(_TEXT_FILTERS), '"text"', '"Contains" match on that field.'), - FilterKeyDoc(("where",), '"SQL WHERE clause"', + FilterKeyDoc(tuple(_TEXT_FILTERS), "string", '"text"', + '"Contains" match on that field.'), + FilterKeyDoc(("where",), "string", '"SQL WHERE clause"', "Raw clause against the caches table."), - FilterKeyDoc(("label",), '"text"', + FilterKeyDoc(("label",), "string", '"text"', 'Shown in the toolbar (optional, default "Macro").'), ) @@ -553,7 +561,8 @@ def _lua_print(self, *args) -> None: # # Single source of truth for the `opensak` table. To add a function: add an # ApiFunction here (bump API_VERSION and use it as `since` if the release -# already shipped the current version), then regenerate the docs with +# already shipped the current version), then regenerate the docs and the +# Lua Language Server stub with # python scripts/generate_macro_api_docs.py # Raised whenever functions are added or changed in a released build, so @@ -561,54 +570,87 @@ def _lua_print(self, *args) -> None: API_VERSION = 1 +@dataclass(frozen=True) +class Param: + """One parameter of an API function.""" + + name: str + # Lua Language Server type, e.g. "string", "number|string", "string[]" + type: str + description: str + optional: bool = False + + @dataclass(frozen=True) class ApiFunction: - """One function of the `opensak` table, with its documentation.""" + """One function of the `opensak` table, with its documentation. + + The signatures in docs/macros/api.md and the Lua Language Server stub + are generated from *params*, *returns* and *overloads*. + """ name: str - signatures: tuple[str, ...] description: str example: str since: int # (runtime, lua) → the Python callable exposed to Lua bind: Callable[[MacroRuntime, Any], Callable] - + params: tuple[Param, ...] = () + # (Lua Language Server type, description), or None if nothing is returned + returns: Optional[tuple[str, str]] = None + # Further accepted parameter lists (same return value) + overloads: tuple[tuple[Param, ...], ...] = () + + @property + def signatures(self) -> list[str]: + """E.g. ["opensak.read_csv(path [, sep])"].""" + result = [] + for params in (self.params, *self.overloads): + text = "" + for i, p in enumerate(params): + sep = ", " if i else "" + text += f" [{sep}{p.name}]" if p.optional else f"{sep}{p.name}" + result.append(f"opensak.{self.name}({text.strip()})") + return result + + +_CODE = Param("code", "string", 'GC code, e.g. "GC12345".') API: tuple[ApiFunction, ...] = ( ApiFunction( name="api_version", - signatures=("opensak.api_version()",), - description="The API version of this OpenSAK build. Each function below " + description="The API version of this OpenSAK build. Each function " "lists the version it was added in.", example='if opensak.api_version() < 1 then\n' ' error("this macro needs a newer OpenSAK")\nend', since=1, bind=lambda rt, lua: lambda: API_VERSION, + returns=("integer", "The API version."), ), ApiFunction( name="filter", - signatures=("opensak.filter{ key = value, ... }",), description="Build a filter from the given keys (see Filter keys; all " - "combined with AND) and apply it. Returns the number of " - "matching caches. When nothing matches, 0 is returned and " - "the view is left unchanged.", + "combined with AND) and apply it. Usually called with " + "table syntax: `opensak.filter{ ... }`. When nothing " + "matches, the view is left unchanged.", example='local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false }\n' 'print("Easy unfound traditionals: " .. n)', since=1, bind=lambda rt, lua: rt._filter, + params=(Param("spec", "opensak.FilterSpec", "The filter keys."),), + returns=("integer", "Number of matching caches (0 = view unchanged)."), ), ApiFunction( name="filter_profile", - signatures=('opensak.filter_profile("Name")',), - description="Apply a saved filter profile. Returns the number of " - "matching caches.", + description="Apply a saved filter profile.", example='local n = opensak.filter_profile("Unfound nearby")', since=1, bind=lambda rt, lua: rt._filter_profile, + params=(Param("name", "string", "Name of the saved profile."),), + returns=("integer", "Number of matching caches."), ), ApiFunction( name="clear_filter", - signatures=("opensak.clear_filter()",), description="Remove the active filter, so all caches are shown again.", example="opensak.clear_filter()", since=1, @@ -616,51 +658,54 @@ class ApiFunction: ), ApiFunction( name="count", - signatures=("opensak.count()",), description="The number of caches matching the active filter.", example='print(opensak.count() .. " caches shown")', since=1, bind=lambda rt, lua: rt._host.cache_count, + returns=("integer", "Number of caches shown."), ), ApiFunction( name="profiles", - signatures=("opensak.profiles()",), - description="An array with the names of all saved filter profiles.", + description="The names of all saved filter profiles.", example="for _, name in ipairs(opensak.profiles()) do\n" " print(name)\nend", since=1, bind=lambda rt, lua: lambda: lua.table_from(rt._profile_names()), + returns=("string[]", "Profile names."), ), ApiFunction( name="set_corrected", - signatures=( - "opensak.set_corrected(code, lat, lon)", - 'opensak.set_corrected(code, "N47 22.123 E008 32.456")', - ), description="Set corrected coordinates, either as decimal degrees or " "as one coordinate string in any format OpenSAK " - "understands. Returns false if the cache is not in the " - "database.", + "understands (DMM, DMS, decimal degrees).", example='opensak.set_corrected("GC12345", 47.36872, 8.54093)\n' 'opensak.set_corrected("GC12345", "N47 22.123 E008 32.456")', since=1, bind=lambda rt, lua: rt._set_corrected, + params=( + _CODE, + Param("lat", "number|string", "Latitude in decimal degrees."), + Param("lon", "number|string", "Longitude in decimal degrees."), + ), + overloads=(( + _CODE, + Param("coords", "string", 'Coordinates, e.g. "N47 22.123 E008 32.456".'), + ),), + returns=("boolean", "false if the cache is not in the database."), ), ApiFunction( name="clear_corrected", - signatures=("opensak.clear_corrected(code)",), - description="Remove the corrected coordinates of a cache. Returns " - "false if the cache is not in the database.", + description="Remove the corrected coordinates of a cache.", example='opensak.clear_corrected("GC12345")', since=1, bind=lambda rt, lua: rt._clear_corrected, + params=(_CODE,), + returns=("boolean", "false if the cache is not in the database."), ), ApiFunction( name="read_csv", - signatures=("opensak.read_csv(path [, sep])",), description="Read a CSV file (UTF-8) into an array of rows keyed by the " - "header line. The separator (, ; or tab) is detected " - "unless given. A relative path is resolved against the " + "header line. A relative path is resolved against the " "macro file's folder. The file must lie in a folder with " "read permission (Settings → Folder permissions) and may " f"be at most {MAX_CSV_BYTES // (1024 * 1024)} MB.", @@ -668,33 +713,41 @@ class ApiFunction: " opensak.set_corrected(row.code, row.coords)\nend", since=1, bind=lambda rt, lua: lambda *a: rt._read_csv(lua, *a), + params=( + Param("path", "string", "The CSV file."), + Param("sep", "string", + "Separator character; detected among , ; and tab if omitted.", + optional=True), + ), + returns=("table[]", "One table per data row, keyed by header."), ), ApiFunction( name="confirm", - signatures=("opensak.confirm(message)",), - description="Ask the user a Yes/No question. Returns true on Yes.", + description="Ask the user a Yes/No question.", example='if not opensak.confirm("Update 12 caches?") then return end', since=1, bind=lambda rt, lua: rt._confirm, + params=(Param("message", "string", "The question."),), + returns=("boolean", "true on Yes."), ), ApiFunction( name="temp_dir", - signatures=("opensak.temp_dir()",), description="The system temp folder (read and write permission by " "default), without a trailing separator. \"/\" works as " "separator on every platform.", example='local rows = opensak.read_csv(opensak.temp_dir() .. "/solved.csv")', since=1, bind=lambda rt, lua: lambda: str(temp_dir()), + returns=("string", "Folder path."), ), ApiFunction( name="macros_dir", - signatures=("opensak.macros_dir()",), description="OpenSAK's macros folder (read permission by default), " "without a trailing separator.", example='local rows = opensak.read_csv(opensak.macros_dir() .. "/data/solved.csv")', since=1, bind=lambda rt, lua: lambda: str(macros_dir()), + returns=("string", "Folder path."), ), ) diff --git a/tests/unit-tests/test_macro_api_docs.py b/tests/unit-tests/test_macro_api_docs.py index a8179fdd..438c727b 100644 --- a/tests/unit-tests/test_macro_api_docs.py +++ b/tests/unit-tests/test_macro_api_docs.py @@ -1,10 +1,16 @@ -"""The Lua API registry, the `opensak` table and docs/macros/api.md must agree.""" +"""The Lua API registry, the `opensak` table and the generated files +(docs/macros/api.md, macros/types/opensak.lua) must agree.""" from pathlib import Path import pytest -from opensak.macro.api_docs import DOC_PATH, render_api_markdown +from opensak.macro.api_docs import ( + DOC_PATH, + STUB_PATH, + render_api_markdown, + render_lua_stub, +) from opensak.macro.runtime import ( API, API_VERSION, @@ -46,6 +52,10 @@ def test_api_entry_is_complete(func): assert func.signatures and all(s.startswith(f"opensak.{func.name}") for s in func.signatures) assert func.description.strip() and func.example.strip() assert 1 <= func.since <= API_VERSION + for p in (*func.params, *(q for form in func.overloads for q in form)): + assert p.name and p.type and p.description.strip(), p + if func.returns: + assert all(part.strip() for part in func.returns) @pytest.mark.parametrize("func", API, ids=[f.name for f in API]) @@ -60,8 +70,18 @@ def test_every_filter_key_is_documented(): assert sorted(documented) == sorted(FILTER_KEYS) -def test_generated_doc_is_up_to_date(): - path = Path(DOC_PATH) - assert path.exists(), f"{DOC_PATH} is missing — {REGENERATE}" - assert path.read_text(encoding="utf-8") == render_api_markdown(), \ - f"{DOC_PATH} is out of date — {REGENERATE}" +@pytest.mark.parametrize( + "path, render", + [(DOC_PATH, render_api_markdown), (STUB_PATH, render_lua_stub)], + ids=["api.md", "lua-stub"], +) +def test_generated_file_is_up_to_date(path, render): + assert Path(path).exists(), f"{path} is missing — {REGENERATE}" + assert Path(path).read_text(encoding="utf-8") == render(), \ + f"{path} is out of date — {REGENERATE}" + + +def test_lua_stub_compiles(): + from lupa.lua54 import LuaRuntime + + LuaRuntime().compile(render_lua_stub()) From 1c4ed3f5ac29160c1a5e997e638e6b14a2afcab2 Mon Sep 17 00:00:00 2001 From: nagisml Date: Mon, 5 Oct 2026 12:38:27 +0200 Subject: [PATCH 12/12] =?UTF-8?q?Package=20example=20macros=20and=20add=20?= =?UTF-8?q?Macros=20=E2=86=92=20Open=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opensak.spec: bundle macros/examples so release builds include them - New opensak.macro.examples: copies the chosen example and its CSV into /examples (existing files kept) and opens it in the macro window - read_csv: relative paths fall back to macros_dir() instead of cwd - Translations for the new menu entry and copy error; tests added --- macros/examples/corrected_coords_from_csv.lua | 4 +- opensak.spec | 3 + src/opensak/gui/dialogs/macro_dialog.py | 15 +++-- src/opensak/gui/mainwindow.py | 21 +++++++ src/opensak/lang/cs.py | 2 + src/opensak/lang/da.py | 2 + src/opensak/lang/de.py | 2 + src/opensak/lang/de_CH.py | 2 + src/opensak/lang/en.py | 2 + src/opensak/lang/es.py | 2 + src/opensak/lang/fr.py | 2 + src/opensak/lang/nl.py | 2 + src/opensak/lang/pl.py | 2 + src/opensak/lang/pt.py | 2 + src/opensak/lang/se.py | 2 + src/opensak/macro/examples.py | 55 +++++++++++++++++++ src/opensak/macro/runtime.py | 4 +- tests/unit-tests/test_macro_runtime.py | 26 +++++++++ 18 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 src/opensak/macro/examples.py diff --git a/macros/examples/corrected_coords_from_csv.lua b/macros/examples/corrected_coords_from_csv.lua index 17411f99..42d71525 100644 --- a/macros/examples/corrected_coords_from_csv.lua +++ b/macros/examples/corrected_coords_from_csv.lua @@ -22,8 +22,8 @@ -- Nothing is written until you confirm the summary ("12 will be set, -- 2 cleared — continue?"). -- --- Open this file via Macros → Run macro… → Open… so the relative CSV path --- is resolved against this folder. +-- Open it via Macros → Open example: that copies this macro and the sample +-- CSV into your macros folder, where the relative CSV path is resolved. local CSV_FILE = "corrected_coords.csv" diff --git a/opensak.spec b/opensak.spec index df9c77a7..89e657c5 100644 --- a/opensak.spec +++ b/opensak.spec @@ -83,6 +83,9 @@ a = Analysis( ("src/opensak/assets/icons/cache_types", "opensak/assets/icons/cache_types"), ("src/opensak/assets/icons/cache_found", "opensak/assets/icons/cache_found"), ("src/opensak/assets/icon_guide.html", "opensak/assets/icon_guide.html"), + # Example Lua macros (+ their CSV), copied into the user's macros + # folder by Macros → Open example (opensak.macro.examples). + ("macros/examples", "macros/examples"), ] + certifi_datas + boundary_datas + qt_translation_datas, hiddenimports=[ "PySide6.QtWebEngineWidgets", diff --git a/src/opensak/gui/dialogs/macro_dialog.py b/src/opensak/gui/dialogs/macro_dialog.py index 257b279c..52516f47 100644 --- a/src/opensak/gui/dialogs/macro_dialog.py +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -105,11 +105,16 @@ def _open_file(self) -> None: path, _ = QFileDialog.getOpenFileName( self, tr("macro_open_title"), str(get_macros_dir()), "Lua (*.lua);;* (*)" ) - if not path: - return - self._editor.setPlainText(Path(path).read_text(encoding="utf-8")) - self._chunk_name = Path(path).name - self._base_dir = Path(path).parent + if path: + self.open_path(Path(path)) + + def open_path(self, path: Path) -> None: + """Load *path* into the editor; relative read_csv() paths then + resolve against its folder.""" + self._editor.setPlainText(path.read_text(encoding="utf-8")) + self._output.clear() + self._chunk_name = path.name + self._base_dir = path.parent def _append_output(self, text: str) -> None: self._output.appendPlainText(text) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 7cc071a9..0ae892c4 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -578,6 +578,14 @@ def _setup_menu(self) -> None: act_run_macro.triggered.connect(self._open_macro_dialog) macros_menu.addAction(act_run_macro) + from opensak.macro.examples import list_examples + examples_menu = macros_menu.addMenu(tr("menu_macro_examples")) + for name in list_examples(): + act = QAction(name, self) + act.triggered.connect(lambda _=False, n=name: self._open_macro_example(n)) + examples_menu.addAction(act) + examples_menu.setEnabled(not examples_menu.isEmpty()) + # ── Hjælp ───────────────────────────────────────────────────────────── help_menu = menubar.addMenu(tr("menu_help")) @@ -3176,6 +3184,19 @@ def _open_macro_dialog(self) -> None: self._macro_dialog.raise_() self._macro_dialog.activateWindow() + def _open_macro_example(self, name: str) -> None: + """Copy a shipped example into the macros folder and open the copy.""" + from opensak.macro.examples import install_example + try: + path = install_example(name) + except OSError as exc: + QMessageBox.warning( + self, tr("macro_title"), tr("macro_example_error", name=name, msg=str(exc)) + ) + return + self._open_macro_dialog() + self._macro_dialog.open_path(path) + def apply_filter(self, filterset, label: str) -> int: """MacroHost: apply *filterset*; like GSAK's MFILTER, an empty result leaves the current view untouched and returns 0.""" diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 082d8a84..e1d28ebe 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -94,6 +94,8 @@ "macro_btn_open": "Otevřít…", "macro_btn_run": "▶ Spustit", "macro_open_title": "Otevřít makro Lua", + "menu_macro_examples": "Otevřít příklad", + "macro_example_error": "Příklad {name} nelze zkopírovat: {msg}", "macro_done": "Makro dokončeno.", "macro_error": "Chyba makra: {msg}", "action_update_location": "Update waypoint locations…", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index c28f7967..f92abb6a 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -94,6 +94,8 @@ "macro_btn_open": "Åbn…", "macro_btn_run": "▶ Kør", "macro_open_title": "Åbn Lua-makro", + "menu_macro_examples": "Åbn eksempel", + "macro_example_error": "Kunne ikke kopiere eksemplet {name}: {msg}", "macro_done": "Makro færdig.", "macro_error": "Makrofejl: {msg}", "action_update_location": "Update waypoint locations…", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 9fc06ee1..5d0abc65 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -94,6 +94,8 @@ "macro_btn_open": "Öffnen…", "macro_btn_run": "▶ Ausführen", "macro_open_title": "Lua-Makro öffnen", + "menu_macro_examples": "Beispiel öffnen", + "macro_example_error": "Beispiel {name} konnte nicht kopiert werden: {msg}", "macro_done": "Makro beendet.", "macro_error": "Makrofehler: {msg}", "action_update_location": "Standortdaten der Wegpunkte aktualisieren…", diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index ab0d9752..27e3a53c 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -95,6 +95,8 @@ "macro_btn_open": "Öffnen…", "macro_btn_run": "▶ Ausführen", "macro_open_title": "Lua-Makro öffnen", + "menu_macro_examples": "Beispiel öffnen", + "macro_example_error": "Beispiel {name} konnte nicht kopiert werden: {msg}", "macro_done": "Makro beendet.", "macro_error": "Makrofehler: {msg}", "action_update_location": "Standortdaten der Wegpunkte aktualisieren…", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index faeeba02..6335a363 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -94,6 +94,8 @@ "macro_btn_open": "Open…", "macro_btn_run": "▶ Run", "macro_open_title": "Open Lua macro", + "menu_macro_examples": "Open example", + "macro_example_error": "Could not copy example {name}: {msg}", "macro_done": "Macro finished.", "macro_error": "Macro error: {msg}", "action_update_location": "🌍 Update waypoint locations…", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index e47ca5db..733a030e 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -96,6 +96,8 @@ "macro_btn_open": "Abrir…", "macro_btn_run": "▶ Ejecutar", "macro_open_title": "Abrir macro Lua", + "menu_macro_examples": "Abrir ejemplo", + "macro_example_error": "No se pudo copiar el ejemplo {name}: {msg}", "macro_done": "Macro finalizada.", "macro_error": "Error de macro: {msg}", "action_update_location": "🌍 Actualizar ubicaciones de waypoints…", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index a379dfcd..a5058acc 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -94,6 +94,8 @@ "macro_btn_open": "Ouvrir…", "macro_btn_run": "▶ Exécuter", "macro_open_title": "Ouvrir une macro Lua", + "menu_macro_examples": "Ouvrir un exemple", + "macro_example_error": "Impossible de copier l'exemple {name} : {msg}", "macro_done": "Macro terminée.", "macro_error": "Erreur de macro : {msg}", "action_update_location": "🌍 Mettre à jour les données de localisation des waypoints…", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index 76fa6f80..9e79b2af 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -97,6 +97,8 @@ "macro_btn_open": "Openen…", "macro_btn_run": "▶ Uitvoeren", "macro_open_title": "Lua-macro openen", + "menu_macro_examples": "Voorbeeld openen", + "macro_example_error": "Voorbeeld {name} kon niet worden gekopieerd: {msg}", "macro_done": "Macro voltooid.", "macro_error": "Macrofout: {msg}", "action_update_location": "🌍 Waypointlocaties bijwerken…", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 9cbfd5ad..9fb690b8 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -96,6 +96,8 @@ "macro_btn_open": "Otwórz…", "macro_btn_run": "▶ Uruchom", "macro_open_title": "Otwórz makro Lua", + "menu_macro_examples": "Otwórz przykład", + "macro_example_error": "Nie można skopiować przykładu {name}: {msg}", "macro_done": "Makro zakończone.", "macro_error": "Błąd makra: {msg}", "action_update_location": "🌍 Zaktualizuj lokalizacje waypointów…", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index f33ecf91..febca216 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -94,6 +94,8 @@ "macro_btn_open": "Abrir…", "macro_btn_run": "▶ Executar", "macro_open_title": "Abrir macro Lua", + "menu_macro_examples": "Abrir exemplo", + "macro_example_error": "Não foi possível copiar o exemplo {name}: {msg}", "macro_done": "Macro concluída.", "macro_error": "Erro na macro: {msg}", "action_update_location": "Update waypoint locations…", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index a226cda7..cbeb6cf9 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -94,6 +94,8 @@ "macro_btn_open": "Öppna…", "macro_btn_run": "▶ Kör", "macro_open_title": "Öppna Lua-makro", + "menu_macro_examples": "Öppna exempel", + "macro_example_error": "Kunde inte kopiera exemplet {name}: {msg}", "macro_done": "Makrot är klart.", "macro_error": "Makrofel: {msg}", "action_update_location": "Update waypoint locations…", diff --git a/src/opensak/macro/examples.py b/src/opensak/macro/examples.py new file mode 100644 index 00000000..e403910d --- /dev/null +++ b/src/opensak/macro/examples.py @@ -0,0 +1,55 @@ +""" +src/opensak/macro/examples.py — example macros shipped with OpenSAK. + +The examples live in macros/examples (bundled by opensak.spec). They are +never run from there: "Open example" first copies them into the user's +macros folder, which macros may read by default, so relative paths such as +the CSV next to an example resolve without any extra folder permission. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +from opensak.macro.permissions import macros_dir + + +def bundled_examples_dir() -> Path: + """Where the shipped examples are: inside the PyInstaller bundle, or + macros/examples in a source checkout.""" + if getattr(sys, "frozen", False): + return Path(getattr(sys, "_MEIPASS", "")) / "macros" / "examples" + return Path(__file__).resolve().parents[3] / "macros" / "examples" + + +def list_examples() -> list[str]: + """File names of the shipped example macros, sorted.""" + folder = bundled_examples_dir() + if not folder.is_dir(): + return [] + return sorted(p.name for p in folder.glob("*.lua")) + + +def install_example(name: str) -> Path: + """Copy example *name* and the data files the examples use (everything + that is not a .lua) into /examples and return the copied macro. + + Files already there are left alone, so a user's edits survive opening + the same example again. + """ + source_dir = bundled_examples_dir() + source = source_dir / name + if source.suffix != ".lua" or not source.is_file(): + raise FileNotFoundError(f"no example macro named {name!r}") + target_dir = macros_dir() / "examples" + target_dir.mkdir(parents=True, exist_ok=True) + files = [source] + [ + p for p in source_dir.iterdir() if p.is_file() and p.suffix != ".lua" + ] + for file in files: + target = target_dir / file.name + if not target.exists(): + shutil.copy2(file, target) + return target_dir / name diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 7e137918..ef1b9668 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -474,7 +474,7 @@ def _read_csv(self, lua, path=None, sep=None): raise MacroError("opensak.read_csv: separator must be a string") file = Path(path).expanduser() if not file.is_absolute(): - file = (self._base_dir or Path.cwd()) / file + file = (self._base_dir or macros_dir()) / file try: file = check_access(file, write=False, permissions=self._folder_permissions) except FolderAccessDenied as exc: @@ -490,7 +490,7 @@ def run( """Execute *source*. Raises MacroError on any failure. *base_dir* (usually the macro file's folder) is where relative paths - given to opensak.read_csv() are looked up; the working directory + given to opensak.read_csv() are looked up; the macros folder otherwise. """ self._base_dir = base_dir diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index dd41bb0f..c1e35904 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -299,6 +299,32 @@ def test_read_csv_missing_file(tmp_path): 'opensak.read_csv("nope.csv")', base_dir=tmp_path) +def test_read_csv_without_base_dir_resolves_in_macros_folder(tmp_path, monkeypatch): + monkeypatch.setattr("opensak.config.get_macros_dir", lambda: tmp_path) + (tmp_path / "solved.csv").write_text("code\nGC9\n", encoding="utf-8") + _, out = _run('print(opensak.read_csv("solved.csv")[1].code)') + assert out == ["GC9"] + + +def test_install_example_copies_macro_and_csv_into_macros_folder(tmp_path, monkeypatch): + from opensak.macro.examples import install_example, list_examples + + monkeypatch.setattr("opensak.config.get_macros_dir", lambda: tmp_path) + assert "corrected_coords_from_csv.lua" in list_examples() + path = install_example("corrected_coords_from_csv.lua") + assert path == tmp_path / "examples" / "corrected_coords_from_csv.lua" + assert (tmp_path / "examples" / "corrected_coords.csv").is_file() + assert not (tmp_path / "examples" / "export_filters_to_gpx.lua").exists() + + # A second open keeps the user's edits. + path.write_text("-- edited", encoding="utf-8") + install_example("corrected_coords_from_csv.lua") + assert path.read_text(encoding="utf-8") == "-- edited" + + with pytest.raises(FileNotFoundError): + install_example("nope.lua") + + def test_temp_and_macros_dir(): from opensak.macro.permissions import macros_dir, temp_dir