Skip to content
Merged
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
151 changes: 151 additions & 0 deletions src/opensak/export/file_export_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
src/opensak/export/file_export_settings.py — named, saveable settings for
the GPX/LOC/GGZ file export dialog.

Stored as JSON files in <app data>/export_settings/, one file per named
setting — the same model as FilterProfile (filters/engine.py), so the
settings are independent of the active database. The settings used for the
most recent export are kept in a reserved file and offered as
"… last used" in the dialog.

Forward compatibility: FileExportSettings.from_dict() falls back to the
default for every missing or invalid key, so settings saved by an older
version keep loading after new export options are added. New options only
need a field with a default, plus a line in to_dict()/from_dict().
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

EXPORT_FORMATS = ("gpx", "loc", "ggz")

# Reserved file for the settings used by the most recent export. It is left
# out of list_profiles(), and profile_path() steers a user-chosen name away
# from it.
_LAST_USED_STEM = "__last_used__"


@dataclass
class FileExportSettings:
"""All options of the file export dialog."""

fmt: str = "gpx" # "gpx" | "loc" | "ggz"
output_path: str = "" # last chosen output file ("" = not set)
use_corrected_coords: bool = True # False = always export original coords
max_records: int = 0 # max caches to export (0 = all)

def to_dict(self) -> dict:
return {
"fmt": self.fmt,
"output_path": self.output_path,
"use_corrected_coords": self.use_corrected_coords,
"max_records": self.max_records,
}

@classmethod
def from_dict(cls, data: dict) -> "FileExportSettings":
defaults = cls()
fmt = data.get("fmt", defaults.fmt)
if fmt not in EXPORT_FORMATS:
fmt = defaults.fmt
output_path = data.get("output_path", defaults.output_path)
if not isinstance(output_path, str):
output_path = defaults.output_path
use_corrected = data.get("use_corrected_coords", defaults.use_corrected_coords)
if not isinstance(use_corrected, bool):
use_corrected = defaults.use_corrected_coords
max_records = data.get("max_records", defaults.max_records)
# bool is an int subclass — reject it explicitly.
if (not isinstance(max_records, int) or isinstance(max_records, bool)
or max_records < 0):
max_records = defaults.max_records
return cls(
fmt=fmt,
output_path=output_path,
use_corrected_coords=use_corrected,
max_records=max_records,
)


class FileExportProfile:
"""A named FileExportSettings, saved as JSON."""

def __init__(self, name: str, settings: FileExportSettings):
self.name = name
self.settings = settings

@staticmethod
def default_dir() -> Path:
from opensak.config import get_app_data_dir
return get_app_data_dir() / "export_settings"

@classmethod
def profile_path(cls, name: str, profiles_dir: Optional[Path] = None) -> Path:
"""Return the JSON path a profile called *name* is stored at."""
if profiles_dir is None:
profiles_dir = cls.default_dir()
safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in name)
if safe_name == _LAST_USED_STEM:
safe_name += "_" # never clobber the reserved "last used" file
return profiles_dir / f"{safe_name}.json"

def save(self, profiles_dir: Optional[Path] = None) -> Path:
"""Save this profile to disk as JSON. Returns the saved file path."""
path = self.profile_path(self.name, profiles_dir)
self._write(path)
return path

def _write(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data = {"name": self.name, "settings": self.settings.to_dict()}
path.write_text(json.dumps(data, indent=2), encoding="utf-8")

@classmethod
def load(cls, path: Path) -> "FileExportProfile":
"""Load a profile from a JSON file."""
data = json.loads(path.read_text(encoding="utf-8"))
return cls(
name=data["name"],
settings=FileExportSettings.from_dict(data.get("settings", {})),
)

@classmethod
def list_profiles(cls, profiles_dir: Optional[Path] = None) -> list[Path]:
"""Return the paths of all user-saved profiles (not "last used")."""
if profiles_dir is None:
profiles_dir = cls.default_dir()
if not profiles_dir.exists():
return []
return sorted(
p for p in profiles_dir.glob("*.json") if p.stem != _LAST_USED_STEM
)

# ── "Last used" ───────────────────────────────────────────────────────────

@classmethod
def last_used_path(cls, profiles_dir: Optional[Path] = None) -> Path:
if profiles_dir is None:
profiles_dir = cls.default_dir()
return profiles_dir / f"{_LAST_USED_STEM}.json"

@classmethod
def load_last_used(cls, profiles_dir: Optional[Path] = None) -> FileExportSettings:
"""Return the settings of the most recent export, or the defaults."""
path = cls.last_used_path(profiles_dir)
try:
return cls.load(path).settings
except Exception:
return FileExportSettings()

@classmethod
def save_last_used(
cls, settings: FileExportSettings, profiles_dir: Optional[Path] = None
) -> None:
cls(_LAST_USED_STEM, settings)._write(cls.last_used_path(profiles_dir))

def __repr__(self) -> str:
return f"<FileExportProfile {self.name!r}>"
32 changes: 21 additions & 11 deletions src/opensak/gps/garmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,23 +548,27 @@ def debug_scan() -> str:

# ── GPX generator ─────────────────────────────────────────────────────────────

def _effective_coords(cache) -> tuple[float, float]:
def _effective_coords(cache, use_corrected: bool = True) -> tuple[float, float]:
"""
Returner de koordinater der skal bruges til GPX export.

Hvis cachen har korrigerede koordinater (user_note.is_corrected),
bruges disse. Ellers bruges de originale koordinater.
use_corrected=False returnerer altid de originale koordinater.
"""
note = getattr(cache, "user_note", None)
if note and getattr(note, "is_corrected", False):
if use_corrected and note and getattr(note, "is_corrected", False):
lat = note.corrected_lat
lon = note.corrected_lon
if lat is not None and lon is not None:
return lat, lon
return cache.latitude, cache.longitude


def generate_gpx(caches: list, filename: str = "opensak_export", progress_cb=None) -> str:
def generate_gpx(
caches: list, filename: str = "opensak_export", progress_cb=None,
use_corrected: bool = True,
) -> str:
"""
Generer GPX indhold fra en liste af Cache objekter.
Returnerer GPX som en streng klar til at skrive til fil.
Expand All @@ -583,6 +587,7 @@ def generate_gpx(caches: list, filename: str = "opensak_export", progress_cb=Non
i en cmt (comment) feltom muligt.

progress_cb(done, total): valgfrit kald per cache, så GUI kan vise fremgang.
use_corrected=False eksporterer altid de originale koordinater.
"""
from xml.etree.ElementTree import Element, SubElement
import xml.etree.ElementTree as ET
Expand Down Expand Up @@ -614,7 +619,7 @@ def generate_gpx(caches: list, filename: str = "opensak_export", progress_cb=Non
continue

# Brug korrigerede koordinater hvis de findes
export_lat, export_lon = _effective_coords(cache)
export_lat, export_lon = _effective_coords(cache, use_corrected)
has_corrected = (export_lat != cache.latitude or export_lon != cache.longitude)

wpt = SubElement(gpx, "wpt")
Expand Down Expand Up @@ -939,14 +944,14 @@ def _indent(elem, level: int = 0) -> None:

# ── LOC generator ─────────────────────────────────────────────────────────────

def generate_loc(caches: list, progress_cb=None) -> str:
def generate_loc(caches: list, progress_cb=None, use_corrected: bool = True) -> str:
"""
Generate LOC 1.0 XML content from a list of Cache objects.
Returns the LOC content as a string ready to write to file.

LOC is a simple waypoint format supported by many GPS apps and devices.
It includes GC code, name, coordinates, difficulty, terrain and container.
Corrected coordinates are used when available.
Corrected coordinates are used when available, unless use_corrected=False.

progress_cb(done, total): optional per-cache callback for GUI progress.
"""
Expand All @@ -964,7 +969,7 @@ def generate_loc(caches: list, progress_cb=None) -> str:
if cache.latitude is None or cache.longitude is None:
continue

export_lat, export_lon = _effective_coords(cache)
export_lat, export_lon = _effective_coords(cache, use_corrected)

wp = SubElement(root, "waypoint")

Expand Down Expand Up @@ -1025,7 +1030,10 @@ def _fix_cdata(m: re.Match) -> str:

# ── GGZ generator ─────────────────────────────────────────────────────────────

def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=None) -> bytes:
def generate_ggz(
caches: list, filename: str = "opensak_export", progress_cb=None,
use_corrected: bool = True,
) -> bytes:
"""
Generate a GGZ file (ZIP archive) from a list of Cache objects.
Returns the GGZ content as bytes ready to write to file.
Expand All @@ -1036,7 +1044,7 @@ def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=Non

The format allows Garmin devices to load more than the usual 10,000
cache limit by using the GGZ container instead of plain GPX files.
Corrected coordinates are used when available.
Corrected coordinates are used when available, unless use_corrected=False.

progress_cb(done, total): optional per-cache callback for GUI progress;
reported over the index-building pass (the slow part of GGZ).
Expand All @@ -1048,7 +1056,9 @@ def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=Non
from datetime import datetime, timezone

gpx_filename = f"{filename}.gpx"
gpx_content = generate_gpx(caches, filename).encode("utf-8")
gpx_content = generate_gpx(
caches, filename, use_corrected=use_corrected
).encode("utf-8")

# ── CRC32 of the GPX content (hex, uppercase, 8 chars) ────────────────────
import binascii
Expand Down Expand Up @@ -1112,7 +1122,7 @@ def generate_ggz(caches: list, filename: str = "opensak_export", progress_cb=Non
if cache.latitude is None or cache.longitude is None:
continue

export_lat, export_lon = _effective_coords(cache)
export_lat, export_lon = _effective_coords(cache, use_corrected)
gc_code = cache.gc_code or ""

file_pos, file_len = offsets_by_gc_code.get(gc_code, (0, 0))
Expand Down
Loading
Loading