Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions macros/examples/corrected_coords.csv
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions macros/examples/corrected_coords_from_csv.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
-- corrected_coords_from_csv.lua — set corrected coordinates from a CSV file
--
-- Reads corrected_coords.csv (next to this macro) and stores the solved
-- coordinates of each listed cache as its corrected coordinates.
--
-- CSV layout (header line required, separator , ; or tab is detected):
--
-- code;coords;note
-- GC1;N47 22.123 E008 32.456;Mystery solved via checksum
--
-- "coords" accepts every format OpenSAK understands (DMM, DMS, decimal
-- degrees). Instead of one "coords" column the file may also have separate
-- "lat" and "lon" columns in decimal degrees. A row with no coordinates
-- removes the corrected coordinates of that cache.
--
-- Open this file via Macros → Run macro… → Open… so the relative CSV path
-- is resolved against this folder.

local CSV_FILE = "corrected_coords.csv"

local function has(v) return v ~= nil and v ~= "" end

-- Returns (found, action) or raises an error for bad coordinates.
local function apply(row)
if has(row.coords) then
return opensak.set_corrected(row.code, row.coords), "set"
elseif has(row.lat) and has(row.lon) then
return opensak.set_corrected(row.code, row.lat, row.lon), "set"
else
return opensak.clear_corrected(row.code), "cleared"
end
end

local rows = opensak.read_csv(CSV_FILE)
print(("Read %d row(s) from %s"):format(#rows, CSV_FILE))

local changed = {} -- GC codes that were updated, for the filter below
local set, cleared, missing, failed = 0, 0, 0, 0

for i, row in ipairs(rows) do
if not has(row.code) then
print(("Row %d: no GC code — skipped"):format(i))
failed = failed + 1
else
-- pcall so one bad line does not stop the whole run
local ok, found, action = pcall(apply, row)
if not ok then
print(("%s: %s"):format(row.code, found)) -- found = error message
failed = failed + 1
elseif not found then
print(("%s: not in the database — skipped"):format(row.code))
missing = missing + 1
elseif action == "cleared" then
print(("%s: corrected coordinates removed"):format(row.code))
cleared = cleared + 1
else
local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon)
print(("%s: corrected → %s %s"):format(row.code, coords, row.note or ""))
set = set + 1
changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'"
end
end
end

print(("Done: %d set, %d cleared, %d not found, %d failed"):format(
set, cleared, missing, failed))

-- Show the caches that just got corrected coordinates
if #changed > 0 then
opensak.filter{
where = "gc_code IN (" .. table.concat(changed, ", ") .. ")",
label = "Corrected via CSV",
}
end
10 changes: 9 additions & 1 deletion src/opensak/gui/dialogs/macro_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
-- OpenSAK macro (Lua) — proof of concept
-- opensak.filter{...}, opensak.filter_profile(name), opensak.clear_filter(),
-- opensak.count(), opensak.profiles(), print(...)
-- opensak.set_corrected(code, lat, lon | "N47 22.123 E008 32.456"),
-- opensak.clear_corrected(code), opensak.read_csv(path [, sep])

local n = opensak.filter{
type = {"Traditional", "Multi-cache"},
Expand Down Expand Up @@ -60,6 +62,7 @@ def __init__(self, host: MacroHost, parent=None):
)
self._runtime = MacroRuntime(host, output=self._append_output)
self._chunk_name = "macro"
self._base_dir: Path | None = None
self._setup_ui()

def _setup_ui(self) -> None:
Expand Down Expand Up @@ -103,6 +106,7 @@ def _open_file(self) -> None:
return
self._editor.setPlainText(Path(path).read_text(encoding="utf-8"))
self._chunk_name = Path(path).name
self._base_dir = Path(path).parent

def _append_output(self, text: str) -> None:
self._output.appendPlainText(text)
Expand All @@ -111,7 +115,11 @@ def _run(self) -> None:
self._output.clear()
self._btn_run.setEnabled(False)
try:
self._runtime.run(self._editor.toPlainText(), chunk_name=self._chunk_name)
self._runtime.run(
self._editor.toPlainText(),
chunk_name=self._chunk_name,
base_dir=self._base_dir,
)
self._append_output(tr("macro_done"))
except MacroError as exc:
self._append_output(tr("macro_error", msg=str(exc)))
Expand Down
10 changes: 10 additions & 0 deletions src/opensak/gui/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3090,6 +3090,16 @@ def cache_count(self) -> int:
with get_session() as session:
return len(apply_filters_auto(session, self._build_active_filterset()))

def set_corrected_coords(self, gc_code, lat, lon) -> bool:
"""MacroHost: set (or clear, with lat/lon = None) corrected coordinates
and refresh the table row, map pin and detail panel like the other
entry points do."""
from opensak.db.corrected_coords import set_corrected_coords
if not set_corrected_coords(gc_code, lat, lon):
return False
self._on_corrected_coords_changed(gc_code)
return True

def _open_found_updater(self) -> None:
if self._trip_planner_active():
self._warn_trip_planner_active()
Expand Down
153 changes: 151 additions & 2 deletions src/opensak/macro/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@
opensak.clear_filter() -- show all caches again
opensak.count() -- caches matching the active filter
opensak.profiles() -- list of saved filter profile names
opensak.set_corrected(code, lat, lon)
-- set corrected coordinates (decimal
-- degrees); returns false if the cache
-- is not in the database
opensak.set_corrected(code, "N47 22.123 E008 32.456")
-- same, from a coordinate string in any
-- format OpenSAK understands
opensak.clear_corrected(code) -- remove corrected coordinates; returns
-- false if the cache is not in the database
opensak.read_csv(path [, sep]) -- read a CSV file (UTF-8) into an array of
-- rows keyed by the header line; the
-- separator (, ; or tab) is detected
-- unless given. A relative path is
-- resolved against the macro file's folder
print(...) -- write to the macro output pane

Keys understood by opensak.filter{} (all combined with AND):
Expand All @@ -34,6 +48,10 @@
local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false }
print("Easy unfound traditionals: " .. n)

for _, row in ipairs(opensak.read_csv("solved.csv")) do
opensak.set_corrected(row.code, row.coords)
end


Known limitation (#938 step 4): the instruction limit only counts Lua VM
instructions, not work inside C functions. Lua pattern matching backtracks
Expand All @@ -50,6 +68,8 @@

from __future__ import annotations

import csv
import io
from pathlib import Path
from typing import Any, Callable, Optional, Protocol

Expand All @@ -71,13 +91,17 @@
TerrainFilter,
WhereClauseFilter,
)
from opensak.coords import parse_coords
from opensak.utils.constants import CACHE_TYPES

# A runaway `while true do end` would freeze the GUI thread, so the script is
# aborted after this many Lua VM instructions.
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,
Expand Down Expand Up @@ -189,6 +213,15 @@ def cache_count(self) -> int:
not the UI.
"""

def set_corrected_coords(
self, gc_code: str, lat: Optional[float], lon: Optional[float]
) -> bool:
"""Set (or clear, with lat/lon = None) corrected coordinates.

Returns False if the cache is not in the database. The host should
refresh whatever shows the cache (table row, map pin, detail panel).
"""


# ── Lua table → FilterSet ────────────────────────────────────────────────────

Expand Down Expand Up @@ -260,6 +293,90 @@ def build_filterset(spec: dict) -> tuple[FilterSet, str]:
return fs, str(spec.get("label") or "Macro")


# ── Corrected coordinates / CSV ──────────────────────────────────────────────


def _gc_code(code: Any, func: str) -> str:
if not isinstance(code, str) or not code.strip():
raise MacroError(f"{func} expects a GC code as first argument, got {code!r}")
return code.strip().upper()


def _number(value: Any) -> Optional[float]:
"""A Lua number, or a string holding one (CSV cells are strings)."""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None


def resolve_coords(lat: Any, lon: Any = None) -> tuple[float, float]:
"""(lat, lon) from two numbers or from one coordinate string.

Raises MacroError if the values cannot be parsed or are out of range.
"""
if lon is None:
if not isinstance(lat, str):
raise MacroError(
'expected lat, lon or a coordinate string such as "N47 22.123 E008 32.456"'
)
parsed = parse_coords(lat)
if parsed is None:
raise MacroError(f"cannot parse coordinates {lat!r}")
return parsed
la, lo = _number(lat), _number(lon)
if la is None or lo is None:
raise MacroError(f"lat/lon must be numbers, got {lat!r}, {lon!r}")
if not (-90.0 <= la <= 90.0 and -180.0 <= lo <= 180.0):
raise MacroError(f"coordinates out of range: {la}, {lo}")
return la, lo


def read_csv_rows(path: Path, sep: Optional[str] = None) -> list[dict[str, str]]:
"""Parse a UTF-8 CSV file (BOM allowed) into a list of header-keyed dicts.

Header names and cells are stripped; blank lines are skipped. Without
*sep* the separator is sniffed among "," ";" and tab.
"""
try:
if path.stat().st_size > MAX_CSV_BYTES:
raise MacroError(
f"{path.name} is larger than {MAX_CSV_BYTES // (1024 * 1024)} MB"
)
text = path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
raise MacroError(f"file not found: {path}") from None
except (OSError, UnicodeDecodeError) as exc:
raise MacroError(f"cannot read {path}: {exc}") from None

if sep is None:
try:
first_line = text.split("\n", 1)[0]
sep = csv.Sniffer().sniff(first_line, delimiters=",;\t").delimiter
except csv.Error:
sep = ","
elif len(sep) != 1:
raise MacroError(f"separator must be a single character, got {sep!r}")

reader = csv.reader(io.StringIO(text), delimiter=sep)
header = [h.strip() for h in next(reader, [])]
rows = []
for cells in reader:
if not any(c.strip() for c in cells):
continue
rows.append({
h: (cells[i].strip() if i < len(cells) else "")
for i, h in enumerate(header) if h
})
return rows


# ── Runtime ──────────────────────────────────────────────────────────────────


Expand All @@ -283,6 +400,7 @@ def __init__(
self._profiles_dir = profiles_dir
self._instruction_limit = instruction_limit
self._memory_limit = memory_limit
self._base_dir: Optional[Path] = None

# -- API functions exposed to Lua -----------------------------------------

Expand Down Expand Up @@ -319,10 +437,38 @@ def _profile_names(self) -> list[str]:
continue
return names

def _set_corrected(self, code=None, lat=None, lon=None) -> bool:
gc_code = _gc_code(code, "opensak.set_corrected")
la, lo = resolve_coords(lat, lon)
return bool(self._host.set_corrected_coords(gc_code, la, lo))

def _clear_corrected(self, code=None) -> bool:
gc_code = _gc_code(code, "opensak.clear_corrected")
return bool(self._host.set_corrected_coords(gc_code, None, None))

def _read_csv(self, lua, path=None, sep=None):
if not isinstance(path, str) or not path.strip():
raise MacroError("opensak.read_csv expects a file path")
if sep is not None and not isinstance(sep, str):
raise MacroError("opensak.read_csv: separator must be a string")
file = Path(path).expanduser()
if not file.is_absolute():
file = (self._base_dir or Path.cwd()) / file
rows = read_csv_rows(file, sep)
return lua.table_from([lua.table_from(r) for r in rows])

# -- Running ---------------------------------------------------------------

def run(self, source: str, chunk_name: str = "macro") -> None:
"""Execute *source*. Raises MacroError on any failure."""
def run(
self, source: str, chunk_name: str = "macro", base_dir: Optional[Path] = None
) -> None:
"""Execute *source*. Raises MacroError on any failure.

*base_dir* (usually the macro file's folder) is where relative paths
given to opensak.read_csv() are looked up; the working directory
otherwise.
"""
self._base_dir = base_dir
try:
from lupa.lua54 import LuaError, LuaMemoryError, LuaRuntime
except ImportError as exc:
Expand Down Expand Up @@ -350,6 +496,9 @@ def run(self, source: str, chunk_name: str = "macro") -> None:
"clear_filter": self._wrap(self._host.clear_filter),
"count": self._wrap(self._host.cache_count),
"profiles": self._wrap(lambda: lua.table_from(self._profile_names())),
"set_corrected": self._wrap(self._set_corrected),
"clear_corrected": self._wrap(self._clear_corrected),
"read_csv": self._wrap(lambda *a: self._read_csv(lua, *a)),
}
)

Expand Down
Loading
Loading