diff --git a/docs/macros/api.md b/docs/macros/api.md new file mode 100644 index 00000000..21765bf3 --- /dev/null +++ b/docs/macros/api.md @@ -0,0 +1,320 @@ + + +# 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 and [Editor support](#editor-support-vs-code) for autocompletion in VS Code. + +## 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 lists the version it was added in. + +Returns `integer` — The API version. + +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(spec) +``` + +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. + +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. + +Parameters: + +- `name` (`string`) — Name of the saved profile. + +Returns `integer` — 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. + +Returns `integer` — Number of caches shown. + +Since API version 1. + +Example: + +```lua +print(opensak.count() .. " caches shown") +``` + +### opensak.profiles + +```lua +opensak.profiles() +``` + +The names of all saved filter profiles. + +Returns `string[]` — Profile names. + +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, coords) +``` + +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. + +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. + +Parameters: + +- `code` (`string`) — GC code, e.g. "GC12345". + +Returns `boolean` — 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. 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. + +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. + +Parameters: + +- `message` (`string`) — The question. + +Returns `boolean` — 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. + +Returns `string` — Folder path. + +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. + +Returns `string` — Folder path. + +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 + +## 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 + +```lua +print(...) +``` + +Write the arguments, separated by tabs, to the macro output pane. 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..42d71525 --- /dev/null +++ b/macros/examples/corrected_coords_from_csv.lua @@ -0,0 +1,125 @@ +-- 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. +-- +-- 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 +-- +-- Nothing is written until you confirm the summary ("12 will be set, +-- 2 cleared — continue?"). +-- +-- 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" + +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/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/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/scripts/generate_macro_api_docs.py b/scripts/generate_macro_api_docs.py new file mode 100644 index 00000000..bd812eee --- /dev/null +++ b/scripts/generate_macro_api_docs.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +""" +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 +""" + +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, + STUB_PATH, + render_api_markdown, + render_lua_stub, +) + + +def main() -> None: + 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__": + main() 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 4498f817..52516f47 100644 --- a/src/opensak/gui/dialogs/macro_dialog.py +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -25,6 +25,9 @@ -- 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]), +-- opensak.confirm(message) local n = opensak.filter{ type = {"Traditional", "Multi-cache"}, @@ -60,6 +63,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: @@ -96,13 +100,21 @@ 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 - self._editor.setPlainText(Path(path).read_text(encoding="utf-8")) - self._chunk_name = Path(path).name + 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) @@ -111,7 +123,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/dialogs/settings_dialog.py b/src/opensak/gui/dialogs/settings_dialog.py index 1619ab1c..891294fd 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,169 @@ 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 _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._perms() + 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._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 + + return [ + FolderPermission( + 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(self._perms().rowCount()) + ] + + def _update_perm_buttons(self) -> None: + 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. + + 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_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._perms().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._perms() + for row in range(table.rowCount()): + if resolve_path(self._perm_path(row)) == 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: + table = self._perms() + rows = {index.row() for index in table.selectedIndexes()} + for row in sorted(rows, reverse=True): + table.removeRow(row) + # ── Fane 2: Geocaching.com ──────────────────────────────────────────────── def _build_gc_tab(self) -> QWidget: @@ -1500,6 +1670,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/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 7a7d3aff..0ae892c4 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. @@ -570,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")) @@ -3168,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.""" @@ -3193,6 +3222,48 @@ 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. + + 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._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 + 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/lang/cs.py b/src/opensak/lang/cs.py index f68c4242..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…", @@ -1264,6 +1266,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..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…", @@ -1268,6 +1270,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..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…", @@ -1269,6 +1271,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..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…", @@ -1270,6 +1272,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..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…", @@ -1267,6 +1269,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..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…", @@ -1269,6 +1271,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..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…", @@ -1269,6 +1271,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..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…", @@ -1265,6 +1267,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..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…", @@ -1269,6 +1271,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..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…", @@ -1269,6 +1271,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..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…", @@ -1268,6 +1270,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/api_docs.py b/src/opensak/macro/api_docs.py new file mode 100644 index 00000000..67c0f3cd --- /dev/null +++ b/src/opensak/macro/api_docs.py @@ -0,0 +1,204 @@ +""" +src/opensak/macro/api_docs.py — render the Lua API reference. + +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, Param + +DOC_PATH = Path("docs/macros/api.md") +STUB_PATH = Path("macros/types/opensak.lua") +EXAMPLES_DIR = Path("macros/examples") +# 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(), "```"] + + +# ── Markdown ───────────────────────────────────────────────────────────────── + + +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 _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", + "", + 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 and " + "[Editor support](#editor-support-vs-code) for autocompletion in VS Code.", + "", + "## 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, ""] + 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 += [ + "", + "## 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 += _editor_section() + + lines += [ + "", + "## Globals", + "", + "### print", + "", + *_code("print(...)"), + "", + "Write the arguments, separated by tabs, to the macro output pane.", + "", + ] + 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/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/permissions.py b/src/opensak/macro/permissions.py new file mode 100644 index 00000000..0a2eeff3 --- /dev/null +++ b/src/opensak/macro/permissions.py @@ -0,0 +1,136 @@ +""" +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 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(temp_dir()), read=True, write=True), + FolderPermission(str(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 4592644e..ef1b9668 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -6,34 +6,11 @@ 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 - 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) - +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 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 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 @@ -50,6 +27,9 @@ from __future__ import annotations +import csv +import io +from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Optional, Protocol @@ -71,6 +51,14 @@ TerrainFilter, WhereClauseFilter, ) +from opensak.coords import parse_coords +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 @@ -78,6 +66,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, @@ -101,6 +92,41 @@ | set(_TEXT_FILTERS) ) + +@dataclass(frozen=True) +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",), "string|string[]", + '"Traditional" | {"Traditional", "Multi-cache", ...}', + 'Cache type(s); the " Cache" suffix may be left out.'), + 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), "string", '"text"', + '"Contains" match on that field.'), + FilterKeyDoc(("where",), "string", '"SQL WHERE clause"', + "Raw clause against the caches table."), + FilterKeyDoc(("label",), "string", '"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. # @@ -189,6 +215,24 @@ 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), + but may defer that to end_macro() so a macro changing thousands of + 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.""" + # ── Lua table → FilterSet ──────────────────────────────────────────────────── @@ -260,6 +304,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 ────────────────────────────────────────────────────────────────── @@ -277,12 +405,18 @@ 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 ----------------------------------------- @@ -319,10 +453,47 @@ 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 _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") + 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 macros_dir()) / 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]) + # -- 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 macros folder + otherwise. + """ + self._base_dir = base_dir try: from lupa.lua54 import LuaError, LuaMemoryError, LuaRuntime except ImportError as exc: @@ -344,13 +515,7 @@ def run(self, source: str, chunk_name: str = "macro") -> None: 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())), - } + {func.name: self._wrap(func.bind(self, lua)) for func in API} ) try: @@ -368,6 +533,8 @@ def run(self, source: str, chunk_name: str = "macro") -> None: # 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): @@ -390,6 +557,201 @@ 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 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 +# macros can check opensak.api_version() before using newer functions. +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. + + The signatures in docs/macros/api.md and the Lua Language Server stub + are generated from *params*, *returns* and *overloads*. + """ + + name: 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", + 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", + description="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.", + 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", + 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", + 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", + 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", + 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", + description="Set corrected coordinates, either as decimal degrees or " + "as one coordinate string in any format OpenSAK " + "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", + 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", + description="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 " + 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), + 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", + 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", + 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", + 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."), + ), +) + + def _lua_tostring(value: Any) -> str: if value is None: return "nil" 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_api_docs.py b/tests/unit-tests/test_macro_api_docs.py new file mode 100644 index 00000000..438c727b --- /dev/null +++ b/tests/unit-tests/test_macro_api_docs.py @@ -0,0 +1,87 @@ +"""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, + STUB_PATH, + render_api_markdown, + render_lua_stub, +) +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 + 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]) +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) + + +@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()) 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_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 972a53e5..c1e35904 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,21 @@ 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" + + 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 + class DbHost(FakeHost): """Applies the filter against the test DB and remembers the selection.""" @@ -47,6 +65,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 +228,234 @@ 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" +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(): + 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_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 + + _, 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"] + 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 + 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() + + +@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")) + + +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["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_csv_import_writes_nothing_when_summary_is_declined(tmp_path): + _add_caches(["GCD1"]) + host = DbHost() + host.answer = False + + 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" + 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_csv_import_never_clears_on_empty_or_half_filled_rows(tmp_path): + codes = ["GCE1", "GCE2", "GCE3", "GCE4", "GCE5"] + _add_caches(codes) + for code in codes: + set_corrected_coords(code, 1.0, 2.0) # already solved + + # 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 + 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)) 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"