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
23 changes: 15 additions & 8 deletions src/opensak/db/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,19 +267,26 @@ def ensure_active_initialised(self) -> None:
from opensak.db.database import init_db
init_db(db_path=self._active.path)

@staticmethod
def default_path_for(name: str) -> Path:
"""Stien new_database() giver en database med dette navn."""
from opensak.settings_store import get_db_dir
safe_name = "".join(
c if c.isalnum() or c in "-_ " else "_" for c in name
).strip()
return get_db_dir() / f"{safe_name}.db"

@staticmethod
def is_valid_database_file(path: Path) -> bool:
"""True hvis *path* er en SQLite-fil (magic-header, se _is_valid_sqlite_file)."""
return _is_valid_sqlite_file(Path(path))

def new_database(self, name: str, path: Optional[Path] = None) -> "DatabaseInfo":
"""Opret en ny tom database."""
if self._find_by_name(name):
raise ValueError(tr("db_err_name_exists", name=name))

if path is None:
from opensak.settings_store import get_db_dir
safe_name = "".join(
c if c.isalnum() or c in "-_ " else "_" for c in name
).strip()
path = get_db_dir() / f"{safe_name}.db"

path = Path(path)
path = Path(path) if path is not None else self.default_path_for(name)

# Issue #539 (opfølgning): stien udledes deterministisk af navnet, så
# uden dette tjek kunne "New database" stille genbruge en efterladt/
Expand Down
824 changes: 726 additions & 98 deletions src/opensak/gui/dialogs/gsak_import_dialog.py

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/opensak/gui/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,17 @@ def _on_database_switched(self, db_info) -> None:
self._detail_panel.clear()
self._load_sort_for_active_db()
self._reload_home_combo()
# Den nye database kan have manglende/forældede distancer — fx en
# database som GSAK-backup-importen har udfyldt i baggrunden (kun
# den aktive database genberegnes i _refresh_after_import()), eller
# et andet hjemmepunkt gemt per database. Samme billige tjek som
# ved opstart (issue #579), så et skift normalt ikke koster en fuld
# genberegning.
s = get_settings()
if s.home_lat and s.home_lon:
from opensak.db.database import recalculate_distances, distances_up_to_date
if not distances_up_to_date(s.home_lat, s.home_lon):
recalculate_distances(s.home_lat, s.home_lon)
# Genindlæs kolonner for den nye database (issue #199)
self._cache_table.reload_columns()
# Issue #607 (opfølgning): genindlæs comboen for den nye database —
Expand Down Expand Up @@ -1854,6 +1865,8 @@ def _open_gsak_import_dialog(self) -> None:
from opensak.gui.dialogs.gsak_import_dialog import GsakImportDialog
dlg = GsakImportDialog(self)
dlg.import_completed.connect(self._refresh_after_import)
dlg.databases_changed.connect(self._reload_db_combo)
dlg.filters_imported.connect(self._on_filter_profiles_imported)
dlg.exec()

def _open_gsak_filter_import_dialog(self) -> None:
Expand Down
19 changes: 11 additions & 8 deletions src/opensak/importer/gsak_filter_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import json
import re
import sqlite3
from contextlib import closing
from dataclasses import dataclass, field as dc_field
from datetime import datetime, timedelta, timezone
from pathlib import Path
Expand Down Expand Up @@ -2357,22 +2358,24 @@ def find_gsak_filter_db(path: Path) -> Path:
return path

import tempfile
import zipfile

extract_dir = Path(tempfile.mkdtemp(prefix="gsak_filters_"))
with zipfile.ZipFile(path) as zf:
zf.extractall(extract_dir)
matches = list(extract_dir.rglob("gsak.db3"))
if not matches:
from opensak.importer.gsak_importer import extract_gsak_member, list_gsak_backup

member = list_gsak_backup(path).settings_member
if member is None:
raise GsakFilterSourceError(f"No gsak.db3 file found inside {path.name}")
return matches[0]
# Unpack only gsak.db3 — not the (possibly multi-GB) cache databases.
extract_dir = Path(tempfile.mkdtemp(prefix="gsak_filters_"))
return extract_gsak_member(path, member, extract_dir)


def load_gsak_filters(db_path: Path) -> list[tuple[str, str]]:
"""Return [(name, data)] for every saved filter in gsak.db3 (read-only)."""
uri = f"file:{Path(db_path).as_posix()}?mode=ro"
try:
with sqlite3.connect(uri, uri=True) as conn:
# closing(): sqlite3's own context manager only commits, it leaves the
# file open — which on Windows blocks removing an unpacked gsak.db3.
with closing(sqlite3.connect(uri, uri=True)) as conn:
conn.text_factory = lambda b: b.decode("utf-8", errors="replace")
rows = conn.execute(
"SELECT Description, Data FROM TranslateFilters WHERE Type = 'FI'"
Expand Down
160 changes: 154 additions & 6 deletions src/opensak/importer/gsak_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,16 +386,164 @@ def find_gsak_db3_in_zip(path: Path) -> Path:
return path

import tempfile
import zipfile

entries = list_gsak_backup(path).databases
if not entries:
raise ValueError(f"No sqlite.db3 file found inside {path.name}")
# Only the one database is unpacked — a full GSAK backup easily holds
# 20+ databases and several GB, which extractall() used to copy to %TEMP%.
member = entries[0].member
assert member is not None # entries listed from a zip always have one
extract_dir = Path(tempfile.mkdtemp(prefix="gsak_extract_"))
return extract_gsak_member(path, member, extract_dir)


# ── Multi-database GSAK backups ──────────────────────────────────────────────
#
# A GSAK backup .zip ("File → Backup") holds one folder per GSAK database,
# each with its own ``sqlite.db3`` (e.g. ``AllCH/sqlite.db3``), plus GSAK's
# settings database ``gsak.db3`` (saved filters etc.) at the root. The folder
# name *is* the GSAK database name, so it doubles as the OpenSAK database
# name the import dialog proposes.

GSAK_CACHE_DB_NAME = "sqlite.db3"
GSAK_SETTINGS_DB_NAME = "gsak.db3"


class GsakBackupDatabase:
"""One GSAK cache database found in a backup (or a single .db3 file)."""

def __init__(self, name: str, size: int, member: Optional[str] = None,
path: Optional[Path] = None):
self.name = name # GSAK database name (= folder name)
self.size = size # uncompressed size in bytes
self.member = member # zip member name, when inside a .zip
self.path = path # file on disk, when not inside a .zip

def __repr__(self) -> str:
return f"GsakBackupDatabase({self.name!r}, member={self.member!r}, path={self.path!r})"


class GsakBackupContents:
"""What a GSAK backup .zip or a single .db3 file offers for import."""

def __init__(self, source: Path):
self.source = source
self.databases: list[GsakBackupDatabase] = []
self.settings_member: Optional[str] = None # gsak.db3 inside the zip
self.settings_path: Optional[Path] = None # gsak.db3 on disk

@property
def is_zip(self) -> bool:
return self.source.suffix.lower() == ".zip"

@property
def has_settings_db(self) -> bool:
return self.settings_member is not None or self.settings_path is not None


def _gsak_db_name_for_file(db3_path: Path) -> str:
"""GSAK stores every database as ``<name>/sqlite.db3``: use the folder
name. Any other file name (a renamed copy) is used as-is, minus suffix."""
if db3_path.name.lower() == GSAK_CACHE_DB_NAME and db3_path.parent.name:
return db3_path.parent.name
return db3_path.stem


def list_gsak_backup(path: Path) -> GsakBackupContents:
"""List the GSAK cache databases (and gsak.db3) in *path* without
unpacking anything.

*path* is either a GSAK backup .zip — every ``<folder>/sqlite.db3`` in it
becomes one entry, named after its folder (a ``sqlite.db3`` at the zip
root is named after the zip itself) — or a single database file, which
becomes the only entry. For a single file, a ``gsak.db3`` in the GSAK
install it belongs to (``<gsak>/data/<name>/sqlite.db3``) is picked up
too, so its filters can be offered alongside.
"""
path = Path(path)
contents = GsakBackupContents(path)

if not contents.is_zip:
size = path.stat().st_size if path.exists() else 0
contents.databases.append(
GsakBackupDatabase(_gsak_db_name_for_file(path), size, path=path)
)
for candidate in (path.parent.parent / GSAK_SETTINGS_DB_NAME,
path.parent.parent.parent / GSAK_SETTINGS_DB_NAME):
if candidate != path and candidate.is_file():
contents.settings_path = candidate
break
return contents

import zipfile

with zipfile.ZipFile(path) as zf:
zf.extractall(extract_dir)
infos = zf.infolist()

matches = list(extract_dir.rglob("sqlite.db3"))
if not matches:
raise ValueError(f"No sqlite.db3 file found inside {path.name}")
return matches[0]
settings_depth: Optional[int] = None
for info in infos:
if info.is_dir():
continue
parts = [p for p in info.filename.replace("\\", "/").split("/") if p]
if not parts:
continue
leaf = parts[-1].lower()
if leaf == GSAK_CACHE_DB_NAME:
name = parts[-2] if len(parts) > 1 else path.stem
contents.databases.append(
GsakBackupDatabase(name, info.file_size, member=info.filename)
)
elif leaf == GSAK_SETTINGS_DB_NAME:
# Prefer the shallowest gsak.db3 — macros never ship one, but a
# nested copy should never win over the real root one.
if settings_depth is None or len(parts) < settings_depth:
contents.settings_member = info.filename
settings_depth = len(parts)

contents.databases.sort(key=lambda db: db.name.lower())
return contents


def extract_gsak_member(zip_path: Path, member: str, dest_dir: Path,
progress_cb=None) -> Path:
"""Unpack the single zip *member* into *dest_dir* and return its path.

The file keeps its own base name (``sqlite.db3`` / ``gsak.db3``) and is
written straight into *dest_dir* — never to a path taken from the zip —
so a crafted member name can't escape the directory. ``progress_cb`` is
called with the number of bytes written so far after every chunk.
"""
import zipfile

dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
target = dest_dir / Path(member.replace("\\", "/")).name
written = 0
with zipfile.ZipFile(zip_path) as zf, zf.open(member) as src, open(target, "wb") as dst:
while chunk := src.read(1024 * 1024):
dst.write(chunk)
written += len(chunk)
if progress_cb is not None:
progress_cb(written)
return target


def clear_opensak_cache_data(session: Session) -> int:
"""Delete every cache (and everything hanging off it) from *session*'s
database — used when the user chose to *overwrite* an existing OpenSAK
database with a GSAK import rather than merge into it.

Returns the number of caches removed. Children go first: the foreign
keys don't cascade on delete.
"""
from sqlalchemy import delete, func, select

removed = session.execute(select(func.count()).select_from(Cache)).scalar_one()
for model in (UserNote, Trackable, Attribute, Log, Waypoint, Cache):
session.execute(delete(model))
session.commit()
return int(removed)


def _open_readonly(
Expand Down
19 changes: 19 additions & 0 deletions src/opensak/lang/cs.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@
"gsak_import_warnings_header": "Upozornění ({count}):",
"gsak_import_no_db3_found": "V souboru {name} nebyl nalezen žádný sqlite.db3",
"gsak_import_done": "✓ Import dokončen.",
"gsak_import_databases_found": "Nalezené databáze GSAK: {count}",
"gsak_import_col_gsak_db": "Databáze GSAK",
"gsak_import_col_target": "Importovat do databáze OpenSAK",
"gsak_import_col_status": "Stav",
"gsak_import_status_new": "Nová databáze",
"gsak_import_filters_checkbox": "Importovat také uložené filtry GSAK (gsak.db3)",
"gsak_import_existing_title": "Existující databáze",
"gsak_import_existing_body": "Tyto databáze OpenSAK již existují:\n\n{names}\n\nPřepsat: odstranit z nich všechny keše a poté importovat.\nSloučit: přidat nové keše a aktualizovat stávající.\nPřeskočit: ponechat beze změny a importovat jen do nových databází.",
"gsak_import_existing_overwrite": "Přepsat",
"gsak_import_existing_merge": "Sloučit",
"gsak_import_existing_skip": "Přeskočit",
"gsak_import_empty_target": "Zadejte název databáze OpenSAK pro {name}.",
"gsak_import_create_failed": "Databázi {name} nelze vytvořit:\n{error}",
"gsak_import_cleared": "Přepsání: z {name} odstraněno {count} stávajících keší.",
"gsak_import_status_blocked": "Název obsazen jiným souborem",
"gsak_import_blocked_target": "Název databáze OpenSAK '{name}' nelze použít: soubor\n{path}\nexistuje, ale není databází OpenSAK. Zvolte jiný název.",
"gsak_import_created": "Vytvořena databáze OpenSAK {name}.",
"gsak_import_skipped_db": "{name} přeskočeno.",
"gsak_import_cancelled": "Import zrušen — zbývající databáze nebyly importovány.",
"gsak_prescan_title": "Některé osobní poznámky obsahují obrázky",
"gsak_prescan_body": "{notes} vašich osobních poznámek obsahuje {images} obrázek/obrázky, které GSAK stáhl do vašeho starého počítače. Ty nelze automaticky zkopírovat, takže OpenSAK místo nich zobrazí zástupný text jako [image: filename.jpg]. Samotný text poznámky bude importován normálně.\n\nPokračovat v importu?",
"gsak_prescan_continue": "Pokračovat v importu",
Expand Down
19 changes: 19 additions & 0 deletions src/opensak/lang/da.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@
"gsak_import_warnings_header": "Advarsler ({count}):",
"gsak_import_no_db3_found": "Ingen sqlite.db3-fil fundet i {name}",
"gsak_import_done": "✓ Import færdig.",
"gsak_import_databases_found": "Fundne GSAK-databaser: {count}",
"gsak_import_col_gsak_db": "GSAK-database",
"gsak_import_col_target": "Importér til OpenSAK-database",
"gsak_import_col_status": "Status",
"gsak_import_status_new": "Ny database",
"gsak_import_filters_checkbox": "Importér også de gemte GSAK-filtre (gsak.db3)",
"gsak_import_existing_title": "Eksisterende databaser",
"gsak_import_existing_body": "Disse OpenSAK-databaser findes allerede:\n\n{names}\n\nOverskriv: fjern alle caches i dem, og importér derefter.\nFlet: tilføj nye caches og opdatér eksisterende.\nSpring over: lad dem være og importér kun til nye databaser.",
"gsak_import_existing_overwrite": "Overskriv",
"gsak_import_existing_merge": "Flet",
"gsak_import_existing_skip": "Spring over",
"gsak_import_empty_target": "Angiv et OpenSAK-databasenavn for {name}.",
"gsak_import_create_failed": "Kunne ikke oprette databasen {name}:\n{error}",
"gsak_import_cleared": "Overskriv: fjernede {count} eksisterende caches fra {name}.",
"gsak_import_status_blocked": "Navnet bruges af en anden fil",
"gsak_import_blocked_target": "OpenSAK-databasenavnet '{name}' kan ikke bruges: filen\n{path}\nfindes, men er ikke en OpenSAK-database. Vælg et andet navn.",
"gsak_import_created": "OpenSAK-databasen {name} er oprettet.",
"gsak_import_skipped_db": "{name} sprunget over.",
"gsak_import_cancelled": "Import annulleret — de resterende databaser blev ikke importeret.",
"gsak_prescan_title": "Nogle personlige noter indeholder billeder",
"gsak_prescan_body": "{notes} af dine personlige noter indeholder {images} billede(r), som GSAK hentede ned til din gamle computer. Disse kan ikke kopieres automatisk, så OpenSAK viser i stedet en placeholder som [image: filename.jpg]. Selve note-teksten importeres stadig normalt.\n\nFortsæt med import?",
"gsak_prescan_continue": "Fortsæt import",
Expand Down
19 changes: 19 additions & 0 deletions src/opensak/lang/de.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@
"gsak_import_warnings_header": "Warnungen ({count}):",
"gsak_import_no_db3_found": "Keine sqlite.db3-Datei in {name} gefunden",
"gsak_import_done": "✓ Import abgeschlossen.",
"gsak_import_databases_found": "Gefundene GSAK-Datenbanken: {count}",
"gsak_import_col_gsak_db": "GSAK-Datenbank",
"gsak_import_col_target": "In OpenSAK-Datenbank importieren",
"gsak_import_col_status": "Status",
"gsak_import_status_new": "Neue Datenbank",
"gsak_import_filters_checkbox": "Auch die gespeicherten GSAK-Filter importieren (gsak.db3)",
"gsak_import_existing_title": "Vorhandene Datenbanken",
"gsak_import_existing_body": "Diese OpenSAK-Datenbanken existieren bereits:\n\n{names}\n\nÜberschreiben: alle Caches darin entfernen, dann importieren.\nZusammenführen: neue Caches hinzufügen und vorhandene aktualisieren.\nÜberspringen: unverändert lassen und nur in neue Datenbanken importieren.",
"gsak_import_existing_overwrite": "Überschreiben",
"gsak_import_existing_merge": "Zusammenführen",
"gsak_import_existing_skip": "Überspringen",
"gsak_import_empty_target": "Bitte einen OpenSAK-Datenbanknamen für {name} eingeben.",
"gsak_import_create_failed": "Datenbank {name} konnte nicht erstellt werden:\n{error}",
"gsak_import_cleared": "Überschreiben: {count} vorhandene Caches aus {name} entfernt.",
"gsak_import_status_blocked": "Name durch andere Datei belegt",
"gsak_import_blocked_target": "Der OpenSAK-Datenbankname '{name}' kann nicht verwendet werden: Die Datei\n{path}\nexistiert, ist aber keine OpenSAK-Datenbank. Bitte einen anderen Namen wählen.",
"gsak_import_created": "OpenSAK-Datenbank {name} erstellt.",
"gsak_import_skipped_db": "{name} übersprungen.",
"gsak_import_cancelled": "Import abgebrochen — die übrigen Datenbanken wurden nicht importiert.",
"gsak_prescan_title": "Einige persönliche Notizen enthalten Bilder",
"gsak_prescan_body": "{notes} Ihrer persönlichen Notizen enthalten {images} Bild(er), die GSAK auf Ihren alten Computer heruntergeladen hat. Diese können nicht automatisch kopiert werden, daher zeigt OpenSAK stattdessen einen Platzhalter wie [image: filename.jpg] an. Der eigentliche Notiztext wird weiterhin normal importiert.\n\nMit dem Import fortfahren?",
"gsak_prescan_continue": "Import fortsetzen",
Expand Down
Loading
Loading