From 5e043d65c31716ad1144ecdc89855e6de79b413b Mon Sep 17 00:00:00 2001 From: nagisml Date: Wed, 30 Sep 2026 20:58:22 +0200 Subject: [PATCH 1/3] Fix handling of GSAK multi DB backups --- src/opensak/db/manager.py | 23 +- src/opensak/gui/dialogs/gsak_import_dialog.py | 824 +++++++++++++++--- src/opensak/gui/mainwindow.py | 2 + src/opensak/importer/gsak_filter_importer.py | 19 +- src/opensak/importer/gsak_importer.py | 160 +++- src/opensak/lang/cs.py | 19 + src/opensak/lang/da.py | 19 + src/opensak/lang/de.py | 19 + src/opensak/lang/de_CH.py | 19 + src/opensak/lang/en.py | 19 + src/opensak/lang/es.py | 19 + src/opensak/lang/fr.py | 19 + src/opensak/lang/nl.py | 19 + src/opensak/lang/pl.py | 19 + src/opensak/lang/pt.py | 19 + src/opensak/lang/se.py | 19 + tests/unit-tests/test_dialog_height_policy.py | 3 +- tests/unit-tests/test_gsak_backup_import.py | 181 ++++ tests/unit-tests/test_gsak_filter_importer.py | 4 +- tests/unit-tests/test_gsak_import_dialog.py | 551 +++++++++--- 20 files changed, 1746 insertions(+), 230 deletions(-) create mode 100644 tests/unit-tests/test_gsak_backup_import.py diff --git a/src/opensak/db/manager.py b/src/opensak/db/manager.py index 5ee484ed..9da78f8b 100644 --- a/src/opensak/db/manager.py +++ b/src/opensak/db/manager.py @@ -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/ diff --git a/src/opensak/gui/dialogs/gsak_import_dialog.py b/src/opensak/gui/dialogs/gsak_import_dialog.py index 32256e22..3cee69f4 100644 --- a/src/opensak/gui/dialogs/gsak_import_dialog.py +++ b/src/opensak/gui/dialogs/gsak_import_dialog.py @@ -2,45 +2,102 @@ src/opensak/gui/dialogs/gsak_import_dialog.py — GSAK direct database import dialog. Session 4 of #469: the GUI wrapper around ``import_gsak_db()``. Mirrors -``import_dialog.py``'s worker/threading pattern (single background QThread, -progress signal, log widget) but is single-file rather than multi-file, and -adds a one-time confirmation step (#472) when the source database contains -personal notes with embedded local images that can't be carried over. +``import_dialog.py``'s worker/threading pattern (background QThread, +progress signal, log widget) and adds a one-time confirmation step (#472) +when the source database contains personal notes with embedded local images +that can't be carried over. + +A GSAK backup .zip holds one folder per GSAK database (``/sqlite.db3``) +plus GSAK's settings database ``gsak.db3``. Every database found is listed +and the user picks which ones to import; each goes into the OpenSAK database +named after its GSAK folder (editable), which is created when it doesn't +exist yet. An existing OpenSAK database is only touched after the user +confirmed it — overwritten (emptied first) or merged into. When the backup +also contains ``gsak.db3`` the saved filters can be migrated afterwards via +the regular GSAK filter import dialog. A single ``.db3`` file (or a zip with +just one database) is simply a list with one entry. + +Only the selected databases are unpacked, into one temp folder that is +removed once the import (and the optional filter migration) is done. """ from __future__ import annotations + +import shutil +import tempfile from pathlib import Path +from typing import Optional -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog, QProgressBar, - QTextEdit, QComboBox, QMessageBox, + QTextEdit, QComboBox, QMessageBox, QCheckBox, + QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, ) +from opensak.gui.dialogs.widgets import clamp_dialog_height_to_screen from opensak.gui.settings import get_settings from opensak.lang import tr from opensak.gui.theme import hint_style +# Table columns +COL_GSAK, COL_SIZE, COL_TARGET, COL_STATUS = range(4) + +# What happens to the target OpenSAK database of a job +MODE_NEW = "new" # created by this import +MODE_MERGE = "merge" # existing — upsert into it (the pre-backup behaviour) +MODE_REPLACE = "replace" # existing — emptied first, then imported + +# What a proposed OpenSAK database name resolves to (see _target_state) +TARGET_NEW = "new" # nothing there yet — will be created +TARGET_EXISTING = "existing" # a database in OpenSAK's list +TARGET_ORPHAN = "orphan" # a valid database file that isn't in the list +TARGET_BLOCKED = "blocked" # some other file sits on the path the name maps to + + +class GsakImportJob: + """One GSAK database → one OpenSAK database.""" + + def __init__(self, gsak_name: str, target_name: str, mode: str, + member: Optional[str] = None, db3_path: Optional[Path] = None, + target_path: Optional[Path] = None, size: int = 0, + register: bool = False): + self.gsak_name = gsak_name + self.target_name = target_name + self.mode = mode + self.member = member # zip member still to unpack + self.db3_path = db3_path # file to import (set once unpacked) + self.target_path = target_path + self.size = size + self.replace = False # set on the first job of a MODE_REPLACE target + self.register = register # target is an orphan file: add it to the list first + self.unpacked = False # db3_path is a temp copy to delete afterwards + class GsakImportWorker(QThread): """Imports a single GSAK database in a background thread.""" result_ready = Signal(object) # GsakImportResult error = Signal(str) # error message progress = Signal(int, int) # (done, total) + cleared = Signal(int) # caches removed before an overwrite # Completion is reported via QThread.finished (see ImportWorker in # import_dialog.py for the rationale — never emit a custom "done" signal # from inside run() itself). - def __init__(self, db3_path: Path, target_db_path: Path | None = None): + def __init__(self, db3_path: Path, target_db_path: Path | None = None, + replace: bool = False): super().__init__() self.db3_path = db3_path self.target_db_path = target_db_path # None → use currently active DB + self.replace = replace # empty the target DB first def run(self) -> None: from opensak.db.database import get_session, init_db from opensak.db.manager import get_db_manager - from opensak.importer.gsak_importer import import_gsak_db + from opensak.importer.gsak_importer import ( + clear_opensak_cache_data, import_gsak_db, + ) manager = get_db_manager() original_path = manager.active_path @@ -53,6 +110,8 @@ def run(self) -> None: try: with get_session() as session: + if self.replace: + self.cleared.emit(clear_opensak_cache_data(session)) result = import_gsak_db( self.db3_path, session, progress_cb=lambda done, total: self.progress.emit(done, total), @@ -66,38 +125,81 @@ def run(self) -> None: init_db(db_path=original_path) +class GsakExtractWorker(QThread): + """Unpacks the selected members of a GSAK backup .zip in the background. + + ``items`` is a list of ``(key, member, dest_dir)``; ``extracted`` maps each + key to the unpacked file once all of them are done. + """ + extracting = Signal(str) # zip member being unpacked + progress = Signal(int, int) # (KiB done, KiB total) over all members + extracted = Signal(object) # {key: Path} + error = Signal(str) + + def __init__(self, zip_path: Path, items: list[tuple[str, str, Path]], total_bytes: int): + super().__init__() + self.zip_path = zip_path + self.items = items + self.total_bytes = max(total_bytes, 1) + + def run(self) -> None: + from opensak.importer.gsak_importer import extract_gsak_member + + total_kib = max(self.total_bytes // 1024, 1) + done_before = 0 + paths: dict[str, Path] = {} + try: + for key, member, dest_dir in self.items: + self.extracting.emit(member) + base = done_before + + def _cb(written: int, base=base) -> None: + self.progress.emit(min((base + written) // 1024, total_kib), total_kib) + + path = extract_gsak_member(self.zip_path, member, dest_dir, progress_cb=_cb) + done_before += path.stat().st_size + paths[key] = path + self.extracted.emit(paths) + except Exception: + import traceback + self.error.emit(traceback.format_exc()) + + +def _format_size(size: int) -> str: + if size >= 1024 ** 3: + return f"{size / 1024 ** 3:.1f} GB" + if size >= 1024 ** 2: + return f"{size / 1024 ** 2:.1f} MB" + return f"{max(size, 0) / 1024:.0f} KB" + + class GsakImportDialog(QDialog): - """Dialog for importing a single GSAK database (.zip backup or .db3 file).""" + """Dialog for importing GSAK databases (.zip backup or a single .db3 file).""" - import_completed = Signal() # emitted when the import created/updated at least one cache + import_completed = Signal() # the import created/updated at least one cache + databases_changed = Signal() # at least one OpenSAK database was created + filters_imported = Signal() # the follow-up filter import wrote profiles def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle(tr("gsak_import_dialog_title")) - self.setMinimumWidth(540) - self.setMinimumHeight(360) - self._worker: GsakImportWorker | None = None + self.setMinimumWidth(680) + self.setMinimumHeight(520) + clamp_dialog_height_to_screen(self, parent) + self._worker: Optional[QThread] = None self._selected_path: Path | None = None - self._db_combo: QComboBox | None = None + self._contents = None # GsakBackupContents + self._jobs: list[GsakImportJob] = [] + self._job_index = 0 + self._temp_dir: Optional[Path] = None + self._extracted: Optional[dict] = None # what the extract worker unpacked + self._wants_filters = False # migrate gsak.db3's filters at the end + self._stage_filters = False # the running extraction is gsak.db3 + self._images_confirmed = False # #472 warning answered with Continue + self._changed = False # any cache created/updated this run self._setup_ui() - self._populate_db_combo() - def _populate_db_combo(self) -> None: - """Fill the database combo with all known databases; pre-select the active one.""" - if self._db_combo is None: - return - from opensak.db.manager import get_db_manager - manager = get_db_manager() - active_path = manager.active_path - self._db_combo.clear() - active_index = 0 - for i, db in enumerate(manager.databases): - label = db.name - if db.path == active_path: - label += f" {tr('import_target_db_active')}" - active_index = i - self._db_combo.addItem(label, userData=db.path) - self._db_combo.setCurrentIndex(active_index) + # ── UI ─────────────────────────────────────────────────────────────────── def _setup_ui(self) -> None: layout = QVBoxLayout(self) @@ -115,16 +217,37 @@ def _setup_ui(self) -> None: file_row.addWidget(self._browse_btn) layout.addLayout(file_row) - # ── Database selector ───────────────────────────────────────────────── - db_row = QHBoxLayout() - db_lbl = QLabel(tr("import_target_db_label")) - db_row.addWidget(db_lbl) - self._db_combo = QComboBox() - self._db_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - self._db_combo.setMinimumWidth(180) - db_row.addWidget(self._db_combo) - db_row.addStretch() - layout.addLayout(db_row) + # ── Database list ──────────────────────────────────────────────────── + list_header = QHBoxLayout() + self._found_label = QLabel("") + list_header.addWidget(self._found_label, stretch=1) + self._all_btn = QPushButton(tr("gsak_filter_import_select_all")) + self._all_btn.clicked.connect(lambda: self._check_all(True)) + list_header.addWidget(self._all_btn) + self._none_btn = QPushButton(tr("gsak_filter_import_select_none")) + self._none_btn.clicked.connect(lambda: self._check_all(False)) + list_header.addWidget(self._none_btn) + layout.addLayout(list_header) + + self._table = QTableWidget(0, 4) + self._table.setHorizontalHeaderLabels([ + tr("gsak_import_col_gsak_db"), tr("file_locations_col_size"), + tr("gsak_import_col_target"), tr("gsak_import_col_status"), + ]) + self._table.verticalHeader().setVisible(False) + self._table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + header = self._table.horizontalHeader() + header.setSectionResizeMode(COL_GSAK, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(COL_SIZE, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(COL_TARGET, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(COL_STATUS, QHeaderView.ResizeMode.ResizeToContents) + self._table.itemChanged.connect(lambda _item: self._update_import_button()) + layout.addWidget(self._table, stretch=1) + + self._filters_cb = QCheckBox(tr("gsak_import_filters_checkbox")) + self._filters_cb.setVisible(False) + self._filters_cb.toggled.connect(lambda _on: self._update_import_button()) + layout.addWidget(self._filters_cb) # Import + Close row btn_row = QHBoxLayout() @@ -151,16 +274,93 @@ def _setup_ui(self) -> None: self._log = QTextEdit() self._log.setReadOnly(True) self._log.setPlaceholderText(tr("import_log_placeholder")) - layout.addWidget(self._log) + layout.addWidget(self._log, stretch=1) + + self._set_list_enabled(False) # ── File selection ─────────────────────────────────────────────────────── def set_path(self, path: Path) -> None: - """Select a file (used by drag & drop from MainWindow).""" + """Select a file (used by drag & drop from MainWindow) and list the + GSAK databases it holds.""" + from opensak.importer.gsak_importer import list_gsak_backup + self._selected_path = path self._file_label.setText(path.name) self._file_label.setToolTip(str(path)) - self._import_btn.setEnabled(True) + self._table.setRowCount(0) + self._contents = None + self._filters_cb.setVisible(False) + self._filters_cb.setChecked(False) + self._found_label.setText("") + + try: + contents = list_gsak_backup(path) + except Exception as exc: # not a zip, unreadable, … + self._set_list_enabled(False) + self._update_import_button() + QMessageBox.critical( + self, tr("gsak_import_dialog_title"), + f"{tr('gsak_import_no_db3_found', name=path.name)}\n\n{exc}", + ) + return + + if not contents.databases and not contents.has_settings_db: + self._set_list_enabled(False) + self._update_import_button() + QMessageBox.critical( + self, tr("gsak_import_dialog_title"), + tr("gsak_import_no_db3_found", name=path.name), + ) + return + + self._contents = contents + self._fill_table(contents.databases) + self._found_label.setText( + tr("gsak_import_databases_found", count=len(contents.databases)) + ) + if contents.has_settings_db: + self._filters_cb.setVisible(True) + self._filters_cb.setChecked(True) + self._set_list_enabled(bool(contents.databases)) + self._update_import_button() + + def _fill_table(self, databases) -> None: + from opensak.db.manager import get_db_manager + + known = [db.name for db in get_db_manager().databases] + self._table.blockSignals(True) + self._table.setRowCount(len(databases)) + for row, entry in enumerate(databases): + name_item = QTableWidgetItem(entry.name) + name_item.setFlags( + Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable + ) + name_item.setCheckState(Qt.CheckState.Checked) + name_item.setData(Qt.ItemDataRole.UserRole, entry) + self._table.setItem(row, COL_GSAK, name_item) + + size_item = QTableWidgetItem(_format_size(entry.size)) + size_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + size_item.setTextAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + self._table.setItem(row, COL_SIZE, size_item) + + combo = QComboBox() + combo.setEditable(True) + combo.addItems(known) + combo.setCurrentText(self._free_name(entry.name)) + combo.currentTextChanged.connect( + lambda _text, r=row: self._on_target_changed(r) + ) + self._table.setCellWidget(row, COL_TARGET, combo) + + status_item = QTableWidgetItem("") + status_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + self._table.setItem(row, COL_STATUS, status_item) + self._update_status(row) + self._table.blockSignals(False) def _browse(self) -> None: settings = get_settings() @@ -175,65 +375,447 @@ def _browse(self) -> None: settings.last_import_dir = str(path.parent) self.set_path(path) - # ── Import ──────────────────────────────────────────────────────────────── + # ── Selection helpers ──────────────────────────────────────────────────── - def _start_import(self) -> None: - if self._selected_path is None: + def _set_list_enabled(self, enabled: bool) -> None: + self._table.setEnabled(enabled) + self._all_btn.setEnabled(enabled) + self._none_btn.setEnabled(enabled) + + def _check_all(self, checked: bool) -> None: + state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + for row in range(self._table.rowCount()): + self._gsak_item(row).setCheckState(state) + self._update_import_button() + + def _gsak_item(self, row: int) -> QTableWidgetItem: + item = self._table.item(row, COL_GSAK) + assert item is not None # every row gets one in _fill_table() + return item + + def _target_name(self, row: int) -> str: + combo = self._table.cellWidget(row, COL_TARGET) + return combo.currentText().strip() if isinstance(combo, QComboBox) else "" + + @staticmethod + def _find_existing(name: str): + """The known OpenSAK database called *name* (case-insensitive — the + file name is derived from it, and Windows file names ignore case).""" + from opensak.db.manager import get_db_manager + + wanted = name.lower() + for db in get_db_manager().databases: + if db.name.lower() == wanted: + return db + return None + + @classmethod + def _target_state(cls, name: str): + """Resolve an OpenSAK database name: ``(TARGET_*, DatabaseInfo | Path)``. + + A name that isn't in the database list can still collide with a file + on disk (new_database() derives the path from the name and refuses + to reuse a file) — e.g. a stale ``Default.db`` left behind. A valid + database file is offered as an existing target; anything else blocks + the name. + """ + from opensak.db.manager import get_db_manager + + existing = cls._find_existing(name) + if existing is not None: + return TARGET_EXISTING, existing + manager = get_db_manager() + path = manager.default_path_for(name) + for db in manager.databases: + if db.path == path: + return TARGET_EXISTING, db + if path.exists(): + if manager.is_valid_database_file(path): + return TARGET_ORPHAN, path + return TARGET_BLOCKED, path + return TARGET_NEW, path + + @classmethod + def _free_name(cls, name: str) -> str: + """*name*, or ``name-2``, ``name-3``, … when a stray file blocks it.""" + candidate, n = name, 2 + while cls._target_state(candidate)[0] == TARGET_BLOCKED: + candidate = f"{name}-{n}" + n += 1 + return candidate + + def _update_status(self, row: int) -> None: + item = self._table.item(row, COL_STATUS) + if item is None: + return + name = self._target_name(row) + if not name: + item.setText("") + item.setToolTip("") return + state, where = self._target_state(name) + if state == TARGET_BLOCKED: + item.setText(tr("gsak_import_status_blocked")) + elif state in (TARGET_EXISTING, TARGET_ORPHAN): + item.setText(tr("file_locations_col_exists")) + else: + item.setText(tr("gsak_import_status_new")) + item.setToolTip(str(getattr(where, "path", where))) - from opensak.importer.gsak_importer import ( - find_gsak_db3_in_zip, - scan_gsak_notes_for_embedded_images, + def _on_target_changed(self, row: int) -> None: + self._update_status(row) + self._update_import_button() + + def _checked_rows(self) -> list[int]: + return [ + row for row in range(self._table.rowCount()) + if self._gsak_item(row).checkState() == Qt.CheckState.Checked + ] + + def _update_import_button(self) -> None: + running = self._worker is not None and self._worker.isRunning() + wants_filters = not self._filters_cb.isHidden() and self._filters_cb.isChecked() + self._import_btn.setEnabled( + not running and self._contents is not None + and (bool(self._checked_rows()) or wants_filters) ) - source_path = self._selected_path - if source_path.suffix.lower() == ".zip": - self._append_log(tr("gsak_import_extracting", name=source_path.name)) - try: - db3_path = find_gsak_db3_in_zip(source_path) - except ValueError: - QMessageBox.critical( - self, tr("gsak_import_dialog_title"), - tr("gsak_import_no_db3_found", name=source_path.name), + def _set_busy(self, busy: bool) -> None: + self._browse_btn.setEnabled(not busy) + self._import_btn.setEnabled(not busy) + self._filters_cb.setEnabled(not busy) + self._set_list_enabled(not busy and self._table.rowCount() > 0) + self._progress.setVisible(busy) + if busy: + self._progress.setRange(0, 0) + + # ── Import ──────────────────────────────────────────────────────────────── + + def _collect_jobs(self) -> Optional[list[GsakImportJob]]: + """Build one job per ticked row; None when a row can't be imported + (no target name, or a stray file blocks it).""" + jobs: list[GsakImportJob] = [] + for row in self._checked_rows(): + entry = self._gsak_item(row).data(Qt.ItemDataRole.UserRole) + target = self._target_name(row) + if not target: + QMessageBox.warning( + self, tr("gsak_import_dialog_title"), + tr("gsak_import_empty_target", name=entry.name), + ) + return None + state, where = self._target_state(target) + if state == TARGET_BLOCKED: + QMessageBox.warning( + self, tr("gsak_import_dialog_title"), + tr("gsak_import_blocked_target", name=target, path=str(where)), + ) + return None + job = GsakImportJob( + gsak_name=entry.name, + target_name=where.name if state == TARGET_EXISTING else target, + mode=MODE_NEW if state == TARGET_NEW else MODE_MERGE, + member=entry.member, + db3_path=entry.path, + size=entry.size, + register=state == TARGET_ORPHAN, ) + if state == TARGET_EXISTING: + job.target_path = where.path + elif state == TARGET_ORPHAN: + job.target_path = where + jobs.append(job) + return jobs + + def _confirm_existing(self, jobs: list[GsakImportJob]) -> Optional[list[GsakImportJob]]: + """Ask once what to do with targets that already exist. + + Returns the jobs to run (existing ones marked replace/merge or + dropped), or None when the user cancelled. + """ + existing = sorted({j.target_name for j in jobs if j.mode != MODE_NEW}, key=str.lower) + if not existing: + return jobs + + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle(tr("gsak_import_existing_title")) + box.setText(tr( + "gsak_import_existing_body", + names="\n".join(f" • {name}" for name in existing), + )) + overwrite_btn = box.addButton(tr("gsak_import_existing_overwrite"), + QMessageBox.ButtonRole.DestructiveRole) + merge_btn = box.addButton(tr("gsak_import_existing_merge"), + QMessageBox.ButtonRole.AcceptRole) + skip_btn = box.addButton(tr("gsak_import_existing_skip"), + QMessageBox.ButtonRole.ActionRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(skip_btn) + box.exec() + clicked = box.clickedButton() + + if clicked is overwrite_btn: + cleared: set[str] = set() + for job in jobs: + if job.mode != MODE_NEW: + job.mode = MODE_REPLACE + # Several GSAK databases may go into the same target: + # only the first one empties it. + job.replace = job.target_name not in cleared + cleared.add(job.target_name) + return jobs + if clicked is merge_btn: + return jobs + if clicked is skip_btn: + return [j for j in jobs if j.mode == MODE_NEW] + return None + + # ── Run ────────────────────────────────────────────────────────────────── + # + # One database at a time: unpack it (zip only) → #472 pre-scan → create or + # register the target → import → delete the unpacked copy. So the temp + # folder never holds more than one database (a full backup is several + # GB), nothing is created for a database that is skipped, and one failing + # database is logged and skipped instead of aborting the rest. The saved + # filters (gsak.db3) come last. + + def _start_import(self) -> None: + if self._selected_path is None or self._contents is None: return - # ── Issue #472: warn once about embedded local images in notes ──────── - scan = scan_gsak_notes_for_embedded_images(db3_path) - if scan["affected_notes"]: - box = QMessageBox(self) - box.setWindowTitle(tr("gsak_prescan_title")) - box.setText(tr( - "gsak_prescan_body", - notes=scan["affected_notes"], - images=scan["total_images"], - )) - continue_btn = box.addButton(tr("gsak_prescan_continue"), QMessageBox.ButtonRole.AcceptRole) - box.addButton(QMessageBox.StandardButton.Cancel) - box.setDefaultButton(continue_btn) - box.exec() - if box.clickedButton() is not continue_btn: - return + jobs = self._collect_jobs() + if jobs is None: + return + jobs = self._confirm_existing(jobs) + if jobs is None: + return + wants_filters = not self._filters_cb.isHidden() and self._filters_cb.isChecked() + if not jobs and not wants_filters: + return - self._import_btn.setEnabled(False) - self._browse_btn.setEnabled(False) - self._progress.setVisible(True) - self._progress.setRange(0, 0) + self._jobs = jobs + self._job_index = 0 + self._changed = False + self._images_confirmed = False + self._wants_filters = wants_filters self._log.clear() - self._append_log(tr("gsak_import_running", name=source_path.name)) + self._set_busy(True) + if self._contents.is_zip: + self._temp_dir = Path(tempfile.mkdtemp(prefix="gsak_import_")) + self._run_next_job() + + def _current_job(self) -> Optional[GsakImportJob]: + if 0 <= self._job_index < len(self._jobs): + return self._jobs[self._job_index] + return None + + def _run_next_job(self) -> None: + job = self._current_job() + if job is None: + self._start_filter_stage() + return + if job.db3_path is None: + assert job.member is not None and self._temp_dir is not None + self._start_extract(job.member, self._temp_dir / f"{self._job_index:02d}", job.size) + return + self._import_current_job() + + # ── Unpacking ──────────────────────────────────────────────────────────── + + def _start_extract(self, member: str, dest_dir: Path, size: int) -> None: + assert self._selected_path is not None + self._append_log(tr("gsak_import_extracting", name=member)) + worker = GsakExtractWorker(self._selected_path, [("file", member, dest_dir)], size) + worker.extracting.connect( + lambda name: self._progress.setFormat(f"{name} %p%") + ) + worker.progress.connect(self._on_progress) + worker.extracted.connect(self._on_extracted) + worker.error.connect(self._on_error) + # The next step starts from QThread.finished, never from a signal + # emitted inside run(): the thread must have ended completely before + # its worker object is let go of. + worker.finished.connect(self._on_extract_done) + self._extracted = None + self._worker = worker + worker.start() + + def _retire_worker(self) -> None: + """Let go of the finished worker — only once its thread has really + ended (destroying a QThread that is still winding down hangs Qt).""" + worker = self._worker + self._worker = None + if worker is None: + return + try: + worker.wait() + worker.deleteLater() + except RuntimeError: + pass + + def _on_extracted(self, paths: dict) -> None: + self._extracted = paths + + def _on_extract_done(self) -> None: + self._retire_worker() + self._progress.setFormat("%p%") + path = (self._extracted or {}).get("file") + self._extracted = None + + if self._stage_filters: + self._stage_filters = False + if path is not None: + self._run_filter_import(path) + self._finish() + return + + job = self._current_job() + if job is None: # the dialog is closing + self._finish() + return + if path is None: # unpacking failed (already logged) + self._skip_job(job) + return + job.db3_path = path + job.unpacked = True + self._import_current_job() + + # ── Importing ──────────────────────────────────────────────────────────── + + def _import_current_job(self) -> None: + job = self._current_job() + assert job is not None and job.db3_path is not None + + verdict = self._confirm_embedded_images(job.db3_path) + if verdict == "cancel": + self._append_log(tr("gsak_import_cancelled")) + self._discard_unpacked(job) + self._jobs = [] + self._finish() + return + if verdict == "skip" or not self._prepare_target(job): + self._skip_job(job) + return - target_db_path = ( - self._db_combo.currentData() - if self._db_combo is not None and self._db_combo.count() > 0 - else None + self._progress.setRange(0, 0) + self._append_log(tr("gsak_import_running", + name=f"{job.gsak_name} → {job.target_name}")) + worker = GsakImportWorker(job.db3_path, target_db_path=job.target_path, + replace=job.replace) + worker.progress.connect(self._on_progress) + worker.cleared.connect( + lambda count, name=job.target_name: + self._append_log(tr("gsak_import_cleared", count=count, name=name)) ) - self._worker = GsakImportWorker(db3_path, target_db_path=target_db_path) - self._worker.progress.connect(self._on_progress) - self._worker.result_ready.connect(self._on_result) - self._worker.error.connect(self._on_error) - self._worker.finished.connect(self._on_done) - self._worker.finished.connect(self._worker.deleteLater) - self._worker.start() + worker.result_ready.connect(self._on_result) + worker.error.connect(self._on_error) + worker.finished.connect(self._on_job_done) + self._worker = worker + worker.start() + + def _confirm_embedded_images(self, db3_path: Path) -> str: + """Issue #472: warn about embedded local images in notes. + + Returns "continue", "skip" (this database) or "cancel" (the whole + run). Once the user chose to continue, later databases aren't asked + about again. + """ + if self._images_confirmed: + return "continue" + from opensak.importer.gsak_importer import scan_gsak_notes_for_embedded_images + + scan = scan_gsak_notes_for_embedded_images(db3_path) + if not scan["affected_notes"]: + return "continue" + + box = QMessageBox(self) + box.setWindowTitle(tr("gsak_prescan_title")) + box.setText(tr("gsak_prescan_body", + notes=scan["affected_notes"], images=scan["total_images"])) + continue_btn = box.addButton(tr("gsak_prescan_continue"), QMessageBox.ButtonRole.AcceptRole) + skip_btn = None + if len(self._jobs) > 1: + skip_btn = box.addButton(tr("gsak_import_existing_skip"), + QMessageBox.ButtonRole.ActionRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(continue_btn) + box.exec() + clicked = box.clickedButton() + if clicked is continue_btn: + self._images_confirmed = True + return "continue" + if skip_btn is not None and clicked is skip_btn: + return "skip" + return "cancel" + + def _prepare_target(self, job: GsakImportJob) -> bool: + """Create (or add to the list) the job's OpenSAK database — in the + GUI thread, the database manager and settings aren't thread-safe. + Failures are logged; the caller skips the job.""" + from opensak.db.manager import get_db_manager + + manager = get_db_manager() + try: + if job.mode == MODE_NEW: + # An earlier job of this run may already have created it. + info = self._find_existing(job.target_name) + if info is None: + info = manager.new_database(job.target_name) + self._append_log(tr("gsak_import_created", name=info.name)) + self.databases_changed.emit() + job.target_path = info.path + elif job.register: + assert job.target_path is not None + info = manager.open_database(job.target_path) + job.target_name = info.name + job.register = False + self.databases_changed.emit() + except Exception as exc: + self._append_log(tr("gsak_import_create_failed", + name=job.target_name, error=str(exc))) + return False + return True + + def _skip_job(self, job: GsakImportJob) -> None: + self._append_log(tr("gsak_import_skipped_db", name=job.gsak_name)) + self._discard_unpacked(job) + self._job_index += 1 + self._run_next_job() + + def _discard_unpacked(self, job: GsakImportJob) -> None: + """Delete a job's unpacked copy as soon as it's no longer needed.""" + if not job.unpacked or job.db3_path is None: + return + import gc + gc.collect() # drop stray sqlite handles so Windows lets go of the file + shutil.rmtree(job.db3_path.parent, ignore_errors=True) + job.db3_path = None + job.unpacked = False + + def _on_job_done(self) -> None: + self._retire_worker() + job = self._current_job() + if job is not None: + self._discard_unpacked(job) + self._job_index += 1 + self._run_next_job() + + # ── Saved filters ──────────────────────────────────────────────────────── + + def _start_filter_stage(self) -> None: + contents = self._contents + if not self._wants_filters or contents is None: + self._finish() + return + self._wants_filters = False + if contents.settings_member is not None and self._temp_dir is not None: + self._stage_filters = True + self._start_extract(contents.settings_member, self._temp_dir / "settings", 0) + return + if contents.settings_path is not None: + self._run_filter_import(contents.settings_path) + self._finish() def _on_progress(self, done: int, total: int) -> None: if total > 0: @@ -242,9 +824,15 @@ def _on_progress(self, done: int, total: int) -> None: else: self._progress.setRange(0, 0) + def _current_job_label(self) -> str: + if 0 <= self._job_index < len(self._jobs): + job = self._jobs[self._job_index] + return f"{job.gsak_name} → {job.target_name}" + return self._selected_path.name if self._selected_path else "" + def _on_result(self, result) -> None: lines = [ - tr("import_complete", name=self._selected_path.name if self._selected_path else ""), + tr("import_complete", name=self._current_job_label()), f" {tr('import_new_caches'):<28} {result.created}", f" {tr('import_updated'):<28} {result.updated}", f" {tr('import_waypoints'):<28} {result.waypoints}", @@ -268,27 +856,67 @@ def _on_result(self, result) -> None: self._append_log("\n".join(lines)) if result.created > 0 or result.updated > 0: - self.import_completed.emit() + self._changed = True def _on_error(self, msg: str) -> None: self._append_log(f"{tr('import_failed')}\n{msg}") - def _on_done(self) -> None: + def _finish(self) -> None: + """The run is over (done or aborted): clean up and re-enable the dialog.""" + self._retire_worker() self._progress.setVisible(False) + self._progress.setFormat("%p%") + if self._changed: + # Once per run, not per database — the main window recalculates + # distances and reloads the list on every emit. + self._changed = False + self.import_completed.emit() + self._cleanup_temp() self._append_log(tr("gsak_import_done")) - self._browse_btn.setEnabled(True) + self._set_busy(False) self._import_btn.setText(tr("import_again")) - self._import_btn.setEnabled(True) + self._update_import_button() - def closeEvent(self, event) -> None: + def _run_filter_import(self, db3_path: Path) -> None: + """Hand gsak.db3 to the regular GSAK filter import dialog.""" + from opensak.gui.dialogs.gsak_filter_import_dialog import GsakFilterImportDialog + + dlg = GsakFilterImportDialog(self) + dlg.import_completed.connect(self.filters_imported) + dlg.set_path(db3_path) + dlg.exec() + + def _cleanup_temp(self) -> None: + if self._temp_dir is None: + return + import gc + gc.collect() # drop stray sqlite handles so Windows lets go of the files + shutil.rmtree(self._temp_dir, ignore_errors=True) + self._temp_dir = None + + def _abort_pending(self) -> None: + """Closing mid-run: let the running worker finish, start nothing new.""" + self._jobs = [] + self._wants_filters = False + self._stage_filters = False + self._extracted = None try: if self._worker and self._worker.isRunning(): self._worker.wait() except RuntimeError: pass - self._worker = None + + def closeEvent(self, event) -> None: + self._abort_pending() + self._cleanup_temp() super().closeEvent(event) + def done(self, result: int) -> None: + # accept()/reject() (Close button, Esc) bypass closeEvent. + self._abort_pending() + self._cleanup_temp() + super().done(result) + # ── Log helpers ─────────────────────────────────────────────────────────── def _append_log(self, text: str) -> None: diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 2b8dc1bb..78405a3b 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -1854,6 +1854,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: diff --git a/src/opensak/importer/gsak_filter_importer.py b/src/opensak/importer/gsak_filter_importer.py index 12e7a916..48381677 100644 --- a/src/opensak/importer/gsak_filter_importer.py +++ b/src/opensak/importer/gsak_filter_importer.py @@ -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 @@ -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'" diff --git a/src/opensak/importer/gsak_importer.py b/src/opensak/importer/gsak_importer.py index 68fff999..56058fba 100644 --- a/src/opensak/importer/gsak_importer.py +++ b/src/opensak/importer/gsak_importer.py @@ -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 ``/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 ``/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 (``/data//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( diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index fb29e5f7..d0fe75b9 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -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", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index 7318a95e..4a65569f 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -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", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index c7c284cf..57030dfc 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -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", diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index 11a28ddd..0177317d 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -226,6 +226,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", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 337c49a4..45a0ab1b 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -225,6 +225,25 @@ "gsak_import_warnings_header": "Warnings ({count}):", "gsak_import_no_db3_found": "No sqlite.db3 file found inside {name}", "gsak_import_done": "✓ Import finished.", + "gsak_import_databases_found": "GSAK databases found: {count}", + "gsak_import_col_gsak_db": "GSAK database", + "gsak_import_col_target": "Import into OpenSAK database", + "gsak_import_col_status": "Status", + "gsak_import_status_new": "New database", + "gsak_import_filters_checkbox": "Also import the saved GSAK filters (gsak.db3)", + "gsak_import_existing_title": "Existing databases", + "gsak_import_existing_body": "These OpenSAK databases already exist:\n\n{names}\n\nOverwrite: remove all caches in them, then import.\nMerge: add new caches and update existing ones.\nSkip: leave them untouched and import only into new databases.", + "gsak_import_existing_overwrite": "Overwrite", + "gsak_import_existing_merge": "Merge", + "gsak_import_existing_skip": "Skip", + "gsak_import_empty_target": "Please enter an OpenSAK database name for {name}.", + "gsak_import_create_failed": "Could not create database {name}:\n{error}", + "gsak_import_cleared": "Overwrite: removed {count} existing caches from {name}.", + "gsak_import_status_blocked": "Name taken by another file", + "gsak_import_blocked_target": "The OpenSAK database name '{name}' can't be used: the file\n{path}\nexists but is not an OpenSAK database. Please choose another name.", + "gsak_import_created": "Created OpenSAK database {name}.", + "gsak_import_skipped_db": "{name} skipped.", + "gsak_import_cancelled": "Import cancelled — the remaining databases were not imported.", "gsak_prescan_title": "Some personal notes contain images", "gsak_prescan_body": "{notes} of your personal notes contain {images} image(s) that GSAK downloaded to your old computer. These can't be copied automatically, so OpenSAK will show a placeholder like [image: filename.jpg] in their place. The note text itself will still be imported normally.\n\nContinue with import?", "gsak_prescan_continue": "Continue Import", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index 98c527ef..2236ed7c 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -227,6 +227,25 @@ "gsak_import_warnings_header": "Advertencias ({count}):", "gsak_import_no_db3_found": "No se encontró ningún archivo sqlite.db3 dentro de {name}", "gsak_import_done": "✓ Importación finalizada.", + "gsak_import_databases_found": "Bases de datos GSAK encontradas: {count}", + "gsak_import_col_gsak_db": "Base de datos GSAK", + "gsak_import_col_target": "Importar a la base de datos de OpenSAK", + "gsak_import_col_status": "Estado", + "gsak_import_status_new": "Nueva base de datos", + "gsak_import_filters_checkbox": "Importar también los filtros guardados de GSAK (gsak.db3)", + "gsak_import_existing_title": "Bases de datos existentes", + "gsak_import_existing_body": "Estas bases de datos de OpenSAK ya existen:\n\n{names}\n\nSobrescribir: eliminar todos sus cachés y luego importar.\nCombinar: añadir cachés nuevos y actualizar los existentes.\nOmitir: dejarlas intactas e importar solo en bases de datos nuevas.", + "gsak_import_existing_overwrite": "Sobrescribir", + "gsak_import_existing_merge": "Combinar", + "gsak_import_existing_skip": "Omitir", + "gsak_import_empty_target": "Introduzca un nombre de base de datos de OpenSAK para {name}.", + "gsak_import_create_failed": "No se pudo crear la base de datos {name}:\n{error}", + "gsak_import_cleared": "Sobrescribir: se eliminaron {count} cachés existentes de {name}.", + "gsak_import_status_blocked": "Nombre ocupado por otro archivo", + "gsak_import_blocked_target": "No se puede usar el nombre de base de datos de OpenSAK '{name}': el archivo\n{path}\nexiste pero no es una base de datos de OpenSAK. Elija otro nombre.", + "gsak_import_created": "Base de datos de OpenSAK {name} creada.", + "gsak_import_skipped_db": "{name} omitida.", + "gsak_import_cancelled": "Importación cancelada: las bases de datos restantes no se importaron.", "gsak_prescan_title": "Algunas notas personales contienen imágenes", "gsak_prescan_body": "{notes} de tus notas personales contienen {images} imagen(es) que GSAK descargó en tu antiguo ordenador. No se pueden copiar automáticamente, así que OpenSAK mostrará en su lugar un marcador de posición como [image: filename.jpg]. El texto de la nota sí se importará con normalidad.\n\n¿Continuar con la importación?", "gsak_prescan_continue": "Continuar importación", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 4ad0c061..d1f17e64 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -225,6 +225,25 @@ "gsak_import_warnings_header": "Avertissements ({count}) :", "gsak_import_no_db3_found": "Aucun fichier sqlite.db3 trouvé dans {name}", "gsak_import_done": "✓ Importation terminée.", + "gsak_import_databases_found": "Bases de données GSAK trouvées : {count}", + "gsak_import_col_gsak_db": "Base de données GSAK", + "gsak_import_col_target": "Importer dans la base OpenSAK", + "gsak_import_col_status": "État", + "gsak_import_status_new": "Nouvelle base", + "gsak_import_filters_checkbox": "Importer aussi les filtres GSAK enregistrés (gsak.db3)", + "gsak_import_existing_title": "Bases existantes", + "gsak_import_existing_body": "Ces bases OpenSAK existent déjà :\n\n{names}\n\nÉcraser : supprimer toutes leurs caches, puis importer.\nFusionner : ajouter les nouvelles caches et mettre à jour les existantes.\nIgnorer : les laisser intactes et n'importer que dans les nouvelles bases.", + "gsak_import_existing_overwrite": "Écraser", + "gsak_import_existing_merge": "Fusionner", + "gsak_import_existing_skip": "Ignorer", + "gsak_import_empty_target": "Veuillez saisir un nom de base OpenSAK pour {name}.", + "gsak_import_create_failed": "Impossible de créer la base {name} :\n{error}", + "gsak_import_cleared": "Écraser : {count} caches existantes supprimées de {name}.", + "gsak_import_status_blocked": "Nom pris par un autre fichier", + "gsak_import_blocked_target": "Le nom de base OpenSAK « {name} » ne peut pas être utilisé : le fichier\n{path}\nexiste mais n'est pas une base OpenSAK. Veuillez choisir un autre nom.", + "gsak_import_created": "Base OpenSAK {name} créée.", + "gsak_import_skipped_db": "{name} ignorée.", + "gsak_import_cancelled": "Importation annulée — les bases restantes n'ont pas été importées.", "gsak_prescan_title": "Certaines notes personnelles contiennent des images", "gsak_prescan_body": "{notes} de vos notes personnelles contiennent {images} image(s) que GSAK avait téléchargée(s) sur votre ancien ordinateur. Celles-ci ne peuvent pas être copiées automatiquement ; OpenSAK affichera à la place un substitut du type [image : filename.jpg]. Le texte de la note sera quant à lui importé normalement.\n\nContinuer l'importation ?", "gsak_prescan_continue": "Continuer l'importation", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index 8529e98d..16fdf077 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -228,6 +228,25 @@ "gsak_import_warnings_header": "Waarschuwingen ({count}):", "gsak_import_no_db3_found": "Geen sqlite.db3-bestand gevonden in {name}", "gsak_import_done": "✓ Importeren voltooid.", + "gsak_import_databases_found": "Gevonden GSAK-databases: {count}", + "gsak_import_col_gsak_db": "GSAK-database", + "gsak_import_col_target": "Importeren in OpenSAK-database", + "gsak_import_col_status": "Status", + "gsak_import_status_new": "Nieuwe database", + "gsak_import_filters_checkbox": "Ook de opgeslagen GSAK-filters importeren (gsak.db3)", + "gsak_import_existing_title": "Bestaande databases", + "gsak_import_existing_body": "Deze OpenSAK-databases bestaan al:\n\n{names}\n\nOverschrijven: alle caches erin verwijderen en daarna importeren.\nSamenvoegen: nieuwe caches toevoegen en bestaande bijwerken.\nOverslaan: ongewijzigd laten en alleen in nieuwe databases importeren.", + "gsak_import_existing_overwrite": "Overschrijven", + "gsak_import_existing_merge": "Samenvoegen", + "gsak_import_existing_skip": "Overslaan", + "gsak_import_empty_target": "Voer een OpenSAK-databasenaam in voor {name}.", + "gsak_import_create_failed": "Database {name} kon niet worden aangemaakt:\n{error}", + "gsak_import_cleared": "Overschrijven: {count} bestaande caches verwijderd uit {name}.", + "gsak_import_status_blocked": "Naam bezet door ander bestand", + "gsak_import_blocked_target": "De OpenSAK-databasenaam '{name}' kan niet worden gebruikt: het bestand\n{path}\nbestaat, maar is geen OpenSAK-database. Kies een andere naam.", + "gsak_import_created": "OpenSAK-database {name} aangemaakt.", + "gsak_import_skipped_db": "{name} overgeslagen.", + "gsak_import_cancelled": "Importeren geannuleerd — de overige databases zijn niet geïmporteerd.", "gsak_prescan_title": "Sommige persoonlijke notities bevatten afbeeldingen", "gsak_prescan_body": "{notes} van je persoonlijke notities bevatten {images} afbeelding(en) die GSAK naar je oude computer had gedownload. Deze kunnen niet automatisch worden gekopieerd, dus OpenSAK toont in plaats daarvan een placeholder zoals [image: filename.jpg]. De tekst van de notitie zelf wordt gewoon normaal geïmporteerd.\n\nDoorgaan met importeren?", "gsak_prescan_continue": "Doorgaan met importeren", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 4ab6bcd1..13c49efb 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -227,6 +227,25 @@ "gsak_import_warnings_header": "Ostrzeżenia ({count}):", "gsak_import_no_db3_found": "Nie znaleziono pliku sqlite.db3 w {name}", "gsak_import_done": "✓ Import zakończony.", + "gsak_import_databases_found": "Znalezione bazy GSAK: {count}", + "gsak_import_col_gsak_db": "Baza GSAK", + "gsak_import_col_target": "Importuj do bazy OpenSAK", + "gsak_import_col_status": "Status", + "gsak_import_status_new": "Nowa baza", + "gsak_import_filters_checkbox": "Importuj także zapisane filtry GSAK (gsak.db3)", + "gsak_import_existing_title": "Istniejące bazy", + "gsak_import_existing_body": "Te bazy OpenSAK już istnieją:\n\n{names}\n\nNadpisz: usuń z nich wszystkie skrytki, a następnie importuj.\nScal: dodaj nowe skrytki i zaktualizuj istniejące.\nPomiń: pozostaw bez zmian i importuj tylko do nowych baz.", + "gsak_import_existing_overwrite": "Nadpisz", + "gsak_import_existing_merge": "Scal", + "gsak_import_existing_skip": "Pomiń", + "gsak_import_empty_target": "Podaj nazwę bazy OpenSAK dla {name}.", + "gsak_import_create_failed": "Nie można utworzyć bazy {name}:\n{error}", + "gsak_import_cleared": "Nadpisanie: usunięto {count} istniejących skrytek z {name}.", + "gsak_import_status_blocked": "Nazwa zajęta przez inny plik", + "gsak_import_blocked_target": "Nie można użyć nazwy bazy OpenSAK '{name}': plik\n{path}\nistnieje, ale nie jest bazą OpenSAK. Wybierz inną nazwę.", + "gsak_import_created": "Utworzono bazę OpenSAK {name}.", + "gsak_import_skipped_db": "Pominięto {name}.", + "gsak_import_cancelled": "Import anulowany — pozostałe bazy nie zostały zaimportowane.", "gsak_prescan_title": "Niektóre notatki osobiste zawierają obrazy", "gsak_prescan_body": "{notes} Twoich notatek osobistych zawiera {images} obraz(y/ów), które GSAK pobrał na Twój stary komputer. Nie można ich skopiować automatycznie, więc OpenSAK pokaże w ich miejsce zastępnik, np. [image: filename.jpg]. Sam tekst notatki zostanie zaimportowany normalnie.\n\nKontynuować import?", "gsak_prescan_continue": "Kontynuuj import", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index 508743b0..37c026e6 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -225,6 +225,25 @@ "gsak_import_warnings_header": "Avisos ({count}):", "gsak_import_no_db3_found": "Nenhum ficheiro sqlite.db3 encontrado em {name}", "gsak_import_done": "✓ Importação concluída.", + "gsak_import_databases_found": "Bases de dados GSAK encontradas: {count}", + "gsak_import_col_gsak_db": "Base de dados GSAK", + "gsak_import_col_target": "Importar para a base de dados OpenSAK", + "gsak_import_col_status": "Estado", + "gsak_import_status_new": "Nova base de dados", + "gsak_import_filters_checkbox": "Importar também os filtros guardados do GSAK (gsak.db3)", + "gsak_import_existing_title": "Bases de dados existentes", + "gsak_import_existing_body": "Estas bases de dados OpenSAK já existem:\n\n{names}\n\nSubstituir: remover todas as caches e depois importar.\nFundir: adicionar caches novas e atualizar as existentes.\nIgnorar: deixá-las intactas e importar apenas para bases de dados novas.", + "gsak_import_existing_overwrite": "Substituir", + "gsak_import_existing_merge": "Fundir", + "gsak_import_existing_skip": "Ignorar", + "gsak_import_empty_target": "Introduza um nome de base de dados OpenSAK para {name}.", + "gsak_import_create_failed": "Não foi possível criar a base de dados {name}:\n{error}", + "gsak_import_cleared": "Substituir: removidas {count} caches existentes de {name}.", + "gsak_import_status_blocked": "Nome ocupado por outro ficheiro", + "gsak_import_blocked_target": "O nome de base de dados OpenSAK '{name}' não pode ser usado: o ficheiro\n{path}\nexiste mas não é uma base de dados OpenSAK. Escolha outro nome.", + "gsak_import_created": "Base de dados OpenSAK {name} criada.", + "gsak_import_skipped_db": "{name} ignorada.", + "gsak_import_cancelled": "Importação cancelada — as restantes bases de dados não foram importadas.", "gsak_prescan_title": "Algumas notas pessoais contêm imagens", "gsak_prescan_body": "{notes} das suas notas pessoais contêm {images} imagem(ns) que o GSAK descarregou para o seu computador antigo. Estas não podem ser copiadas automaticamente, pelo que o OpenSAK irá mostrar um marcador como [image: filename.jpg] no seu lugar. O texto da nota em si será importado normalmente.\n\nContinuar com a importação?", "gsak_prescan_continue": "Continuar importação", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index f8dae10f..eed6ff3b 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -225,6 +225,25 @@ "gsak_import_warnings_header": "Varningar ({count}):", "gsak_import_no_db3_found": "Ingen sqlite.db3-fil hittades i {name}", "gsak_import_done": "✓ Import klar.", + "gsak_import_databases_found": "Hittade GSAK-databaser: {count}", + "gsak_import_col_gsak_db": "GSAK-databas", + "gsak_import_col_target": "Importera till OpenSAK-databas", + "gsak_import_col_status": "Status", + "gsak_import_status_new": "Ny databas", + "gsak_import_filters_checkbox": "Importera även de sparade GSAK-filtren (gsak.db3)", + "gsak_import_existing_title": "Befintliga databaser", + "gsak_import_existing_body": "Dessa OpenSAK-databaser finns redan:\n\n{names}\n\nSkriv över: ta bort alla cacher i dem och importera sedan.\nSammanfoga: lägg till nya cacher och uppdatera befintliga.\nHoppa över: lämna dem orörda och importera bara till nya databaser.", + "gsak_import_existing_overwrite": "Skriv över", + "gsak_import_existing_merge": "Sammanfoga", + "gsak_import_existing_skip": "Hoppa över", + "gsak_import_empty_target": "Ange ett OpenSAK-databasnamn för {name}.", + "gsak_import_create_failed": "Kunde inte skapa databasen {name}:\n{error}", + "gsak_import_cleared": "Skriv över: tog bort {count} befintliga cacher från {name}.", + "gsak_import_status_blocked": "Namnet används av en annan fil", + "gsak_import_blocked_target": "OpenSAK-databasnamnet '{name}' kan inte användas: filen\n{path}\nfinns men är ingen OpenSAK-databas. Välj ett annat namn.", + "gsak_import_created": "OpenSAK-databasen {name} skapades.", + "gsak_import_skipped_db": "{name} hoppades över.", + "gsak_import_cancelled": "Importen avbröts — de återstående databaserna importerades inte.", "gsak_prescan_title": "Vissa personliga anteckningar innehåller bilder", "gsak_prescan_body": "{notes} av dina personliga anteckningar innehåller {images} bild(er) som GSAK laddade ner till din gamla dator. Dessa kan inte kopieras automatiskt, så OpenSAK visar istället en platshållare som [image: filename.jpg]. Själva anteckningstexten importeras fortfarande normalt.\n\nFortsätta med importen?", "gsak_prescan_continue": "Fortsätt import", diff --git a/tests/unit-tests/test_dialog_height_policy.py b/tests/unit-tests/test_dialog_height_policy.py index a33cb027..022a4485 100644 --- a/tests/unit-tests/test_dialog_height_policy.py +++ b/tests/unit-tests/test_dialog_height_policy.py @@ -60,7 +60,6 @@ "distance_bearing_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", "file_export_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", "found_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", - "gsak_import_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", "kml_export_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", "mark_found_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", "midpoint_dialog.py": "#811 follow-up — not yet triaged, tracked in #816", @@ -163,7 +162,7 @@ def test_known_unprotected_list_matches_current_gap_count(self): catches a *new* dialog silently added straight into the allowlist instead of being protected from the start). """ - assert len(KNOWN_UNPROTECTED) == 14, ( + assert len(KNOWN_UNPROTECTED) == 13, ( f"KNOWN_UNPROTECTED has {len(KNOWN_UNPROTECTED)} entries, " "expected 14. If you just fixed one, remove its entry " "(caught above too) and update this count. If you just added " diff --git a/tests/unit-tests/test_gsak_backup_import.py b/tests/unit-tests/test_gsak_backup_import.py new file mode 100644 index 00000000..49a07d35 --- /dev/null +++ b/tests/unit-tests/test_gsak_backup_import.py @@ -0,0 +1,181 @@ +# tests/unit-tests/test_gsak_backup_import.py — multi-database GSAK backups. +# +# A GSAK backup .zip holds one ``/sqlite.db3`` per GSAK database plus +# ``gsak.db3`` (settings / saved filters). These tests cover listing such a +# backup without unpacking it, unpacking a single member, the backwards- +# compatible single-database helpers, and emptying an OpenSAK database before +# an "overwrite" import. + +import importlib.util +import zipfile +from pathlib import Path + +import pytest + +from opensak.db.models import Cache, Log, UserNote, Waypoint +from opensak.importer.gsak_filter_importer import find_gsak_filter_db +from opensak.importer.gsak_importer import ( + clear_opensak_cache_data, + extract_gsak_member, + find_gsak_db3_in_zip, + import_gsak_db, + list_gsak_backup, +) + +# Reuse the synthetic GSAK-schema builder of the main importer tests (the +# importlib import mode doesn't let test modules import each other by name). +_spec = importlib.util.spec_from_file_location( + "_gsak_importer_tests", Path(__file__).with_name("test_gsak_importer.py") +) +assert _spec is not None and _spec.loader is not None +_helpers = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_helpers) +_make_gsak_db = _helpers._make_gsak_db + + +@pytest.fixture(autouse=True) +def _temp_in_tmp_path(tmp_path, monkeypatch): + """The helpers unpack into tempfile.mkdtemp() folders the caller owns — + keep them inside tmp_path rather than littering the real %TEMP%.""" + import tempfile + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + +def _backup(tmp_path: Path, members: dict[str, bytes]) -> Path: + archive = tmp_path / "GSAKAuto1.zip" + with zipfile.ZipFile(archive, "w") as zf: + for name, data in members.items(): + zf.writestr(name, data) + return archive + + +# ── list_gsak_backup ───────────────────────────────────────────────────────── + +class TestListBackup: + def test_every_database_is_listed_by_folder_name(self, tmp_path): + archive = _backup(tmp_path, { + "AllCH/sqlite.db3": b"a" * 10, + "AdventureLabs/sqlite.db3": b"b" * 20, + "Holidays/sqlite.db3": b"c", + "Holidays/Attachments/readme.txt": b"x", + "gsak.db3": b"settings", + "Macros/ImportAdventureLabs.db3": b"macro", + }) + contents = list_gsak_backup(archive) + assert [db.name for db in contents.databases] == ["AdventureLabs", "AllCH", "Holidays"] + assert [db.size for db in contents.databases] == [20, 10, 1] + assert contents.databases[1].member == "AllCH/sqlite.db3" + assert contents.is_zip + assert contents.settings_member == "gsak.db3" + assert contents.has_settings_db + + def test_zip_with_single_database(self, tmp_path): + contents = list_gsak_backup(_backup(tmp_path, {"Sommerhus/sqlite.db3": b"x"})) + assert [db.name for db in contents.databases] == ["Sommerhus"] + assert not contents.has_settings_db + + def test_database_at_zip_root_is_named_after_the_zip(self, tmp_path): + contents = list_gsak_backup(_backup(tmp_path, {"sqlite.db3": b"x"})) + assert [db.name for db in contents.databases] == ["GSAKAuto1"] + + def test_shallowest_gsak_db3_wins(self, tmp_path): + contents = list_gsak_backup(_backup(tmp_path, { + "Macros/old/gsak.db3": b"nested", "gsak.db3": b"root", + })) + assert contents.settings_member == "gsak.db3" + + def test_zip_without_gsak_content(self, tmp_path): + contents = list_gsak_backup(_backup(tmp_path, {"readme.txt": b"x"})) + assert contents.databases == [] + assert not contents.has_settings_db + + def test_single_file_is_named_after_its_folder(self, tmp_path): + db = tmp_path / "gsak" / "data" / "AllCH" / "sqlite.db3" + db.parent.mkdir(parents=True) + db.write_bytes(b"x" * 5) + contents = list_gsak_backup(db) + assert not contents.is_zip + assert [(d.name, d.size, d.path) for d in contents.databases] == [("AllCH", 5, db)] + assert not contents.has_settings_db + + def test_single_file_picks_up_gsak_db3_of_its_install(self, tmp_path): + db = tmp_path / "gsak" / "data" / "AllCH" / "sqlite.db3" + db.parent.mkdir(parents=True) + db.write_bytes(b"x") + settings = tmp_path / "gsak" / "gsak.db3" + settings.write_bytes(b"s") + assert list_gsak_backup(db).settings_path == settings + + def test_renamed_single_file_uses_its_stem(self, tmp_path): + db = tmp_path / "Export.db3" + db.write_bytes(b"x") + assert list_gsak_backup(db).databases[0].name == "Export" + + +# ── Unpacking ──────────────────────────────────────────────────────────────── + +class TestExtract: + def test_extracts_only_the_member(self, tmp_path): + archive = _backup(tmp_path, {"A/sqlite.db3": b"aaa", "B/sqlite.db3": b"bbb"}) + seen = [] + out = extract_gsak_member(archive, "B/sqlite.db3", tmp_path / "out", + progress_cb=seen.append) + assert out == tmp_path / "out" / "sqlite.db3" + assert out.read_bytes() == b"bbb" + assert list((tmp_path / "out").iterdir()) == [out] + assert seen == [3] + + def test_member_path_cannot_escape_dest_dir(self, tmp_path): + archive = _backup(tmp_path, {"../../evil/sqlite.db3": b"x"}) + out = extract_gsak_member(archive, "../../evil/sqlite.db3", tmp_path / "out") + assert out.parent == tmp_path / "out" + + def test_find_gsak_db3_in_zip_still_returns_first_database(self, tmp_path): + archive = _backup(tmp_path, { + "Zulu/sqlite.db3": b"z", "Alpha/sqlite.db3": b"a", "gsak.db3": b"s", + }) + out = find_gsak_db3_in_zip(archive) + assert out.name == "sqlite.db3" and out.read_bytes() == b"a" + # only that one database was unpacked + assert [p.name for p in out.parent.iterdir()] == ["sqlite.db3"] + + def test_find_gsak_db3_in_zip_passes_plain_files_through(self, tmp_path): + db = tmp_path / "sqlite.db3" + assert find_gsak_db3_in_zip(db) == db + + def test_find_gsak_db3_in_zip_without_database_raises(self, tmp_path): + with pytest.raises(ValueError): + find_gsak_db3_in_zip(_backup(tmp_path, {"gsak.db3": b"s"})) + + def test_find_gsak_filter_db_unpacks_only_gsak_db3(self, tmp_path): + archive = _backup(tmp_path, {"A/sqlite.db3": b"a", "gsak.db3": b"s"}) + out = find_gsak_filter_db(archive) + assert out.read_bytes() == b"s" + assert [p.name for p in out.parent.iterdir()] == ["gsak.db3"] + + +# ── Overwrite ──────────────────────────────────────────────────────────────── + +def test_clear_opensak_cache_data_empties_the_database(db_session, tmp_path): + gsak = _make_gsak_db( + tmp_path / "sqlite.db3", + memos=[{"Code": "GC1TEST", "UserNote": "note"}], + waypoints=[{"cParent": "GC1TEST", "cCode": "PK1TEST", "cPrefix": "PK", + "cName": "Parking", "cType": "Parking Area", + "cLat": "47.1", "cLon": "8.1", "cByuser": 0, "cDate": "", "cFlag": 0}], + logs=[{"lParent": "GC1TEST", "lLogId": 1, "lType": "Found it", "lBy": "me", + "lDate": "2020-01-01", "lTime": "", "lLat": "", "lLon": "", + "lEncoded": 0, "lownerid": 1, "lHasHtml": 0, "lIsowner": 0}], + ) + import_gsak_db(gsak, db_session) + assert db_session.query(Cache).count() == 1 + assert db_session.query(Waypoint).count() == 1 + assert db_session.query(Log).count() == 1 + + assert clear_opensak_cache_data(db_session) == 1 + for model in (Cache, Waypoint, Log, UserNote): + assert db_session.query(model).count() == 0 + + # …and the import afterwards creates, rather than updates + result = import_gsak_db(gsak, db_session) + assert result.created == 1 and result.updated == 0 diff --git a/tests/unit-tests/test_gsak_filter_importer.py b/tests/unit-tests/test_gsak_filter_importer.py index 2fd3a199..dc465c57 100644 --- a/tests/unit-tests/test_gsak_filter_importer.py +++ b/tests/unit-tests/test_gsak_filter_importer.py @@ -613,8 +613,10 @@ def test_a_cache_database_is_rejected_with_a_clear_error(self, tmp_path): with pytest.raises(GsakFilterSourceError): load_gsak_filters(path) - def test_zip_backup_is_unpacked(self, tmp_path): + def test_zip_backup_is_unpacked(self, tmp_path, monkeypatch): + import tempfile import zipfile + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) # not the real %TEMP% db = _make_gsak_db(tmp_path / "gsak.db3", {"A": _blob({})}) archive = tmp_path / "backup.zip" with zipfile.ZipFile(archive, "w") as zf: diff --git a/tests/unit-tests/test_gsak_import_dialog.py b/tests/unit-tests/test_gsak_import_dialog.py index 56615e02..0bfa685e 100644 --- a/tests/unit-tests/test_gsak_import_dialog.py +++ b/tests/unit-tests/test_gsak_import_dialog.py @@ -1,6 +1,9 @@ -# tests/unit-tests/test_gsak_import_dialog.py — GSAK import dialog worker + UI (#469 session 4). +# tests/unit-tests/test_gsak_import_dialog.py — GSAK import dialog worker + UI (#469 session 4, multi-database backups). import contextlib +import importlib.util +import sqlite3 +import zipfile from pathlib import Path from types import SimpleNamespace @@ -9,6 +12,8 @@ pytest.importorskip("pytestqt") +from PySide6.QtCore import Qt + from opensak.gui.dialogs import gsak_import_dialog as gdlg from opensak.gui.dialogs.gsak_import_dialog import GsakImportWorker, GsakImportDialog @@ -95,15 +100,87 @@ def test_run_no_switch_when_target_is_active(self, monkeypatch): w.run() assert inits == [] + def test_run_replace_clears_target_first(self, monkeypatch): + self._patch_common(monkeypatch) + calls = [] + monkeypatch.setattr("opensak.importer.gsak_importer.clear_opensak_cache_data", + lambda session: calls.append("clear") or 7) + monkeypatch.setattr("opensak.importer.gsak_importer.import_gsak_db", + lambda path, session, progress_cb=None: calls.append("import") or _result()) + w = GsakImportWorker(Path("/gsak.db3"), replace=True) + cleared = [] + w.cleared.connect(cleared.append) + w.run() + assert calls == ["clear", "import"] + assert cleared == [7] + + def test_run_without_replace_does_not_clear(self, monkeypatch): + self._patch_common(monkeypatch) + calls = [] + monkeypatch.setattr("opensak.importer.gsak_importer.clear_opensak_cache_data", + lambda session: calls.append("clear") or 0) + monkeypatch.setattr("opensak.importer.gsak_importer.import_gsak_db", + lambda path, session, progress_cb=None: _result()) + GsakImportWorker(Path("/gsak.db3")).run() + assert calls == [] + # ── GsakImportDialog ────────────────────────────────────────────────────────── +# +# These run against the real (test-isolated, see conftest) database manager +# and real synthetic GSAK databases, so the whole path — listing the backup, +# unpacking, creating/overwriting OpenSAK databases, cleanup — is exercised. + +_spec = importlib.util.spec_from_file_location( + "_gsak_importer_tests", Path(__file__).with_name("test_gsak_importer.py") +) +assert _spec is not None and _spec.loader is not None +_helpers = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_helpers) + + +def _gsak_db(path: Path, code: str = "GC1TEST") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + return _helpers._make_gsak_db( + path, caches=[{"Code": code}], + memos=[{"Code": code, "Url": f"https://coord.info/{code}"}], + ) + + +def _codes(db_path: Path) -> list[str]: + conn = sqlite3.connect(db_path) + try: + return sorted(r[0] for r in conn.execute("SELECT gc_code FROM caches")) + finally: + conn.close() + + +def _backup(tmp_path: Path, dbs: dict[str, str], with_settings: bool = True) -> Path: + """Zip with one /sqlite.db3 per entry (the value is its cache code).""" + archive = tmp_path / "GSAKAuto1.zip" + with zipfile.ZipFile(archive, "w") as zf: + for name, code in dbs.items(): + zf.write(_gsak_db(tmp_path / "src" / name / "sqlite.db3", code), f"{name}/sqlite.db3") + if with_settings: + zf.writestr("gsak.db3", b"settings") + return archive + + +def _seed(manager, db_info, tmp_path: Path, code: str) -> None: + """Put one cache into an existing OpenSAK database, then restore the active one.""" + from opensak.db.database import get_session, init_db + from opensak.importer.gsak_importer import import_gsak_db + init_db(db_path=db_info.path) + with get_session() as s: + import_gsak_db(_gsak_db(tmp_path / "seed" / "sqlite.db3", code), s) + manager.ensure_active_initialised() + @pytest.fixture -def manager(monkeypatch): - a = SimpleNamespace(name="Active", path=Path("/active.db")) - b = SimpleNamespace(name="Other", path=Path("/other.db")) - mgr = SimpleNamespace(active_path=Path("/active.db"), databases=[a, b]) - monkeypatch.setattr("opensak.db.manager.get_db_manager", lambda: mgr) +def manager(): + from opensak.db.manager import get_db_manager + mgr = get_db_manager() + mgr.ensure_active_initialised() return mgr @@ -114,23 +191,130 @@ def dlg(qtbot, manager): return d -class TestGsakImportDialog: - def test_db_combo_populated_active_selected(self, dlg): - assert dlg._db_combo.count() == 2 - assert dlg._db_combo.currentIndex() == 0 +class FakeBox: + """Stand-in for QMessageBox that 'clicks' the button labelled ``choice``.""" + choice = None + instances: list = [] + Icon = gdlg.QMessageBox.Icon + ButtonRole = gdlg.QMessageBox.ButtonRole + StandardButton = gdlg.QMessageBox.StandardButton + critical = staticmethod(lambda *a, **k: None) + warning = staticmethod(lambda *a, **k: None) + + def __init__(self, parent=None): + self.buttons = {} + self.text = "" + FakeBox.instances.append(self) + + def setIcon(self, _i): pass + def setWindowTitle(self, _t): pass + def setText(self, t): self.text = t + def setDefaultButton(self, _b): pass + def exec(self): pass - def test_set_path_enables_import(self, dlg): - dlg.set_path(Path("/x/Sommerhus.zip")) - assert dlg._selected_path == Path("/x/Sommerhus.zip") - assert dlg._import_btn.isEnabled() is True - assert "Sommerhus.zip" in dlg._file_label.text() + def addButton(self, label, role=None): + btn = SimpleNamespace(label=label) + self.buttons[label if isinstance(label, str) else "cancel"] = btn + return btn + + def clickedButton(self): + return self.buttons.get(FakeBox.choice) + + +@pytest.fixture +def fake_box(monkeypatch): + FakeBox.instances = [] + FakeBox.choice = None + monkeypatch.setattr(gdlg, "QMessageBox", FakeBox) + return FakeBox + + +@pytest.fixture +def sync_workers(monkeypatch): + """Run the dialog's workers on the calling thread. + + Real QThreads are unreliable inside the full suite (a thread's + ``finished`` was seen arriving a full minute late after ~2000 other + tests), so — like the worker tests above, which call run() directly — + start() just runs the work and then reports completion. + """ + def _start(self): + self.run() + self.finished.emit() + + monkeypatch.setattr(gdlg.GsakImportWorker, "start", _start) + monkeypatch.setattr(gdlg.GsakExtractWorker, "start", _start) + + +def _run(dlg, qtbot): + dlg._start_import() + assert gdlg.tr("gsak_import_done") in dlg._log.toPlainText(), dlg._log.toPlainText() + + +def _target(dlg, row): + return dlg._table.cellWidget(row, gdlg.COL_TARGET) + + +def _db_by_name(manager, name): + return {db.name: db for db in manager.databases}[name] + + +class TestGsakImportDialogListing: + def test_zip_lists_every_database_named_after_its_folder(self, dlg, tmp_path): + dlg.set_path(_backup(tmp_path, {"AllCH": "GC1", "AdventureLabs": "GC2"})) + assert dlg._table.rowCount() == 2 + names = [dlg._table.item(r, gdlg.COL_GSAK).text() for r in range(2)] + assert names == ["AdventureLabs", "AllCH"] + assert [_target(dlg, r).currentText() for r in range(2)] == names + assert all(dlg._table.item(r, gdlg.COL_GSAK).checkState() == Qt.CheckState.Checked + for r in range(2)) + assert dlg._table.item(0, gdlg.COL_STATUS).text() == gdlg.tr("gsak_import_status_new") + assert not dlg._filters_cb.isHidden() and dlg._filters_cb.isChecked() + assert dlg._import_btn.isEnabled() + + def test_filters_checkbox_hidden_without_gsak_db3(self, dlg, tmp_path): + dlg.set_path(_backup(tmp_path, {"AllCH": "GC1"}, with_settings=False)) + assert dlg._filters_cb.isHidden() + + def test_single_db3_file_is_one_row(self, dlg, tmp_path): + dlg.set_path(_gsak_db(tmp_path / "data" / "Sommerhus" / "sqlite.db3")) + assert dlg._table.rowCount() == 1 + assert _target(dlg, 0).currentText() == "Sommerhus" + assert dlg._import_btn.isEnabled() + + def test_existing_target_is_flagged(self, dlg, manager, tmp_path): + manager.new_database("AllCH") + dlg.set_path(_backup(tmp_path, {"allch": "GC1"})) # case-insensitive match + assert dlg._table.item(0, gdlg.COL_STATUS).text() == gdlg.tr("file_locations_col_exists") + _target(dlg, 0).setCurrentText("Something else") + assert dlg._table.item(0, gdlg.COL_STATUS).text() == gdlg.tr("gsak_import_status_new") + + def test_zip_without_gsak_content_shows_error(self, dlg, tmp_path, monkeypatch): + archive = tmp_path / "x.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("readme.txt", "nothing") + shown = [] + monkeypatch.setattr(gdlg.QMessageBox, "critical", lambda *a, **k: shown.append(a)) + dlg.set_path(archive) + assert shown + assert dlg._import_btn.isEnabled() is False - def test_browse_sets_path(self, dlg, monkeypatch): + def test_nothing_ticked_disables_import_unless_filters_wanted(self, dlg, tmp_path): + dlg.set_path(_backup(tmp_path, {"A": "GC1"})) + dlg._check_all(False) + assert dlg._import_btn.isEnabled() # filters still ticked + dlg._filters_cb.setChecked(False) + assert dlg._import_btn.isEnabled() is False + dlg._check_all(True) + assert dlg._import_btn.isEnabled() + + def test_browse_sets_path(self, dlg, monkeypatch, tmp_path): + archive = _backup(tmp_path, {"A": "GC1"}) monkeypatch.setattr(gdlg.QFileDialog, "getOpenFileName", - lambda *a, **k: ("/d/GSAK_Backup.zip", "f")) + lambda *a, **k: (str(archive), "f")) dlg._browse() - assert dlg._selected_path == Path("/d/GSAK_Backup.zip") - assert dlg._import_btn.isEnabled() is True + assert dlg._selected_path == archive + assert dlg._table.rowCount() == 1 def test_browse_cancel_leaves_path_unset(self, dlg, monkeypatch): monkeypatch.setattr(gdlg.QFileDialog, "getOpenFileName", lambda *a, **k: ("", "")) @@ -139,110 +323,265 @@ def test_browse_cancel_leaves_path_unset(self, dlg, monkeypatch): assert dlg._import_btn.isEnabled() is False def test_start_import_no_path_noop(self, dlg): - dlg._start_import() # nothing selected -> early return + dlg._start_import() assert dlg._progress.isVisible() is False - def test_start_import_no_db3_found_shows_error(self, dlg, monkeypatch): - dlg.set_path(Path("/x/not_a_gsak.zip")) + +class TestGsakImportDialogConfirmExisting: + def _jobs(self): + return [ + gdlg.GsakImportJob("A", "Target", gdlg.MODE_MERGE), + gdlg.GsakImportJob("B", "Target", gdlg.MODE_MERGE), + gdlg.GsakImportJob("C", "Fresh", gdlg.MODE_NEW), + ] + + def test_no_existing_targets_asks_nothing(self, dlg, fake_box): + jobs = [gdlg.GsakImportJob("C", "Fresh", gdlg.MODE_NEW)] + assert dlg._confirm_existing(jobs) == jobs + assert fake_box.instances == [] + + def test_overwrite_clears_each_target_once(self, dlg, fake_box, monkeypatch): + monkeypatch.setattr(gdlg, "tr", lambda key, **kw: f"{key} {kw}" if kw else key) + fake_box.choice = gdlg.tr("gsak_import_existing_overwrite") + jobs = dlg._confirm_existing(self._jobs()) + assert [(j.mode, j.replace) for j in jobs] == [ + (gdlg.MODE_REPLACE, True), (gdlg.MODE_REPLACE, False), (gdlg.MODE_NEW, False), + ] + assert "Target" in fake_box.instances[0].text + + def test_merge_keeps_jobs(self, dlg, fake_box): + fake_box.choice = gdlg.tr("gsak_import_existing_merge") + jobs = dlg._confirm_existing(self._jobs()) + assert [j.mode for j in jobs] == [gdlg.MODE_MERGE, gdlg.MODE_MERGE, gdlg.MODE_NEW] + assert not any(j.replace for j in jobs) + + def test_skip_drops_existing(self, dlg, fake_box): + fake_box.choice = gdlg.tr("gsak_import_existing_skip") + assert [j.gsak_name for j in dlg._confirm_existing(self._jobs())] == ["C"] + + def test_cancel_aborts(self, dlg, fake_box): + fake_box.choice = "cancel" + assert dlg._confirm_existing(self._jobs()) is None + + +@pytest.mark.usefixtures("sync_workers") +class TestGsakImportDialogRun: + def test_backup_creates_one_database_per_folder_and_cleans_up( + self, dlg, manager, tmp_path, qtbot, monkeypatch): + monkeypatch.setattr(gdlg, "tr", lambda key, **kw: f"{key} {kw}" if kw else key) + filter_calls = [] + monkeypatch.setattr(dlg, "_run_filter_import", + lambda p: filter_calls.append((p, p.exists(), p.read_bytes()))) + signals = {"completed": 0, "dbs": 0} + dlg.import_completed.connect(lambda: signals.__setitem__("completed", signals["completed"] + 1)) + dlg.databases_changed.connect(lambda: signals.__setitem__("dbs", signals["dbs"] + 1)) + + dlg.set_path(_backup(tmp_path, {"AllCH": "GC1AAA", "Holidays": "GC2BBB"})) + _run(dlg, qtbot) + + assert _codes(_db_by_name(manager, "AllCH").path) == ["GC1AAA"] + assert _codes(_db_by_name(manager, "Holidays").path) == ["GC2BBB"] + assert _codes(manager.active_path) == [] # active DB untouched + assert signals == {"completed": 1, "dbs": 2} # one per created database + # gsak.db3 was unpacked and handed to the filter import … + assert len(filter_calls) == 1 + path, existed, data = filter_calls[0] + assert existed and data == b"settings" + # … and the whole temp folder is gone afterwards + assert dlg._temp_dir is None + assert not path.exists() and not path.parent.parent.exists() + assert "'AllCH → AllCH'" in dlg._log.toPlainText() + + def test_only_ticked_databases_are_imported(self, dlg, manager, tmp_path, qtbot): + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA", "B": "GC2BBB"}, with_settings=False)) + dlg._table.item(1, gdlg.COL_GSAK).setCheckState(Qt.CheckState.Unchecked) + _run(dlg, qtbot) + names = {db.name for db in manager.databases} + assert "A" in names and "B" not in names + + def test_overwrite_replaces_existing_database( + self, dlg, manager, tmp_path, qtbot, fake_box): + existing = manager.new_database("A") + _seed(manager, existing, tmp_path, "GC0OLD") + fake_box.choice = gdlg.tr("gsak_import_existing_overwrite") + dlg.set_path(_backup(tmp_path, {"A": "GC1NEW"}, with_settings=False)) + _run(dlg, qtbot) + assert _codes(existing.path) == ["GC1NEW"] + + def test_merge_keeps_existing_caches(self, dlg, manager, tmp_path, qtbot, fake_box): + existing = manager.new_database("A") + _seed(manager, existing, tmp_path, "GC0OLD") + fake_box.choice = gdlg.tr("gsak_import_existing_merge") + dlg.set_path(_backup(tmp_path, {"A": "GC1NEW"}, with_settings=False)) + _run(dlg, qtbot) + assert _codes(existing.path) == ["GC0OLD", "GC1NEW"] + + def test_skip_existing_leaves_it_untouched(self, dlg, manager, tmp_path, qtbot, fake_box): + existing = manager.new_database("A") + fake_box.choice = gdlg.tr("gsak_import_existing_skip") + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA", "B": "GC2BBB"}, with_settings=False)) + _run(dlg, qtbot) + assert _codes(existing.path) == [] + assert _codes(_db_by_name(manager, "B").path) == ["GC2BBB"] + + def test_single_db3_file_imports_without_unpacking(self, dlg, manager, tmp_path, qtbot): + db = _gsak_db(tmp_path / "data" / "Sommerhus" / "sqlite.db3", "GC9SOM") + dlg.set_path(db) + _run(dlg, qtbot) + assert _codes(_db_by_name(manager, "Sommerhus").path) == ["GC9SOM"] + assert db.exists() # the user's own file is never removed + + def test_single_db3_into_the_active_database(self, dlg, manager, tmp_path, qtbot, fake_box): + # the pre-backup workflow: pick an existing DB (here the active one) and merge + fake_box.choice = gdlg.tr("gsak_import_existing_merge") + dlg.set_path(_gsak_db(tmp_path / "data" / "X" / "sqlite.db3", "GC7ACT")) + _target(dlg, 0).setCurrentText(manager.active.name) + _run(dlg, qtbot) + assert _codes(manager.active_path) == ["GC7ACT"] + + def test_prescan_cancel_aborts_and_cleans_up( + self, dlg, manager, tmp_path, qtbot, monkeypatch, fake_box): monkeypatch.setattr( - "opensak.importer.gsak_importer.find_gsak_db3_in_zip", - lambda p: (_ for _ in ()).throw(ValueError("no db3")), + "opensak.importer.gsak_importer.scan_gsak_notes_for_embedded_images", + lambda p: {"affected_notes": 2, "total_images": 3}, ) + fake_box.choice = "cancel" + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA"})) + _run(dlg, qtbot) + assert "A" not in {db.name for db in manager.databases} + assert dlg._temp_dir is None + + def test_empty_target_name_is_rejected(self, dlg, tmp_path, monkeypatch, fake_box): shown = [] - monkeypatch.setattr(gdlg.QMessageBox, "critical", lambda *a, **k: shown.append(a)) + monkeypatch.setattr(FakeBox, "warning", staticmethod(lambda *a, **k: shown.append(a))) + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA"})) + _target(dlg, 0).setCurrentText(" ") dlg._start_import() assert shown assert dlg._worker is None - def test_start_import_no_affected_notes_launches_worker_directly(self, dlg, monkeypatch): - dlg.set_path(Path("/x/gsak.db3")) - monkeypatch.setattr("opensak.importer.gsak_importer.find_gsak_db3_in_zip", lambda p: p) + # ── Regressions from a real 23-database backup ─────────────────────────── + + def test_stray_file_on_the_derived_path_gets_a_free_name( + self, dlg, manager, tmp_path, qtbot): + # A leftover, non-database "Default.db" (not in the list) used to make + # new_database("Default") fail and abort the whole run. + # (the isolated test manager owns Default.db itself, hence "Stray") + stray = manager.default_path_for("Stray") + stray.parent.mkdir(parents=True, exist_ok=True) + stray.write_bytes(b"not sqlite") + dlg.set_path(_backup(tmp_path, {"Stray": "GC1DEF"}, with_settings=False)) + assert _target(dlg, 0).currentText() == "Stray-2" + _run(dlg, qtbot) + assert _codes(_db_by_name(manager, "Stray-2").path) == ["GC1DEF"] + assert stray.read_bytes() == b"not sqlite" # never touched + + def test_blocked_name_typed_by_the_user_is_rejected( + self, dlg, manager, tmp_path, monkeypatch, fake_box): + stray = manager.default_path_for("Junk") + stray.parent.mkdir(parents=True, exist_ok=True) + stray.write_bytes(b"x") + shown = [] + monkeypatch.setattr(FakeBox, "warning", staticmethod(lambda *a, **k: shown.append(a))) + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA"}, with_settings=False)) + _target(dlg, 0).setCurrentText("Junk") + assert dlg._table.item(0, gdlg.COL_STATUS).text() == gdlg.tr("gsak_import_status_blocked") + dlg._start_import() + assert shown and dlg._worker is None + + def test_orphan_database_file_is_offered_and_registered( + self, dlg, manager, tmp_path, qtbot, fake_box): + # A valid OpenSAK database file that isn't in the list (e.g. removed + # from the list earlier) counts as existing and needs confirmation. + info = manager.new_database("Orphan") + _seed(manager, info, tmp_path, "GC0OLD") + manager.remove_from_list(info) + dlg.set_path(_backup(tmp_path, {"Orphan": "GC1NEW"}, with_settings=False)) + assert dlg._table.item(0, gdlg.COL_STATUS).text() == gdlg.tr("file_locations_col_exists") + fake_box.choice = gdlg.tr("gsak_import_existing_merge") + _run(dlg, qtbot) + assert len(fake_box.instances) == 1 + assert _codes(_db_by_name(manager, "Orphan").path) == ["GC0OLD", "GC1NEW"] + + def test_failing_database_is_skipped_and_the_rest_continues( + self, dlg, manager, tmp_path, qtbot, monkeypatch): + real_new = manager.new_database + + def flaky(name, path=None): + if name == "B": + raise ValueError("disk says no") + return real_new(name, path) + + monkeypatch.setattr(manager, "new_database", flaky) + monkeypatch.setattr(gdlg, "tr", lambda key, **kw: f"{key} {kw}" if kw else key) + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA", "B": "GC2BBB", "C": "GC3CCC"}, + with_settings=False)) + _run(dlg, qtbot) + names = {db.name for db in manager.databases} + assert {"A", "C"} <= names and "B" not in names + assert _codes(_db_by_name(manager, "C").path) == ["GC3CCC"] + log = dlg._log.toPlainText() + assert "gsak_import_create_failed" in log and "disk says no" in log + assert "gsak_import_skipped_db {'name': 'B'}" in log + + def test_temp_folder_never_holds_more_than_one_database( + self, dlg, manager, tmp_path, qtbot, monkeypatch): + seen = [] + + def scan(path): + seen.append(sorted(p.name for p in dlg._temp_dir.rglob("sqlite.db3")).count("sqlite.db3")) + return {"affected_notes": 0, "total_images": 0} + + monkeypatch.setattr( + "opensak.importer.gsak_importer.scan_gsak_notes_for_embedded_images", scan) + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA", "B": "GC2BBB", "C": "GC3CCC"}, + with_settings=False)) + _run(dlg, qtbot) + assert seen == [1, 1, 1] + + def test_prescan_skip_skips_only_that_database( + self, dlg, manager, tmp_path, qtbot, monkeypatch, fake_box): monkeypatch.setattr( "opensak.importer.gsak_importer.scan_gsak_notes_for_embedded_images", - lambda p: {"affected_notes": 0, "total_images": 0}, + lambda p: {"affected_notes": 1, "total_images": 1}, ) - - started = [] - - class FakeWorker: - def __init__(self, db3_path, target_db_path=None): - self.progress = MagicMock() - self.result_ready = MagicMock() - self.error = MagicMock() - self.finished = MagicMock() - self.deleteLater = MagicMock() - self.isRunning = MagicMock(return_value=False) - self.wait = MagicMock() - - def start(self): - started.append(True) - - monkeypatch.setattr(gdlg, "GsakImportWorker", FakeWorker) - dlg._start_import() - assert started == [True] - assert dlg._import_btn.isEnabled() is False - assert dlg._worker is not None - - def test_start_import_with_affected_notes_shows_prescan_dialog(self, dlg, monkeypatch): - dlg.set_path(Path("/x/gsak.db3")) - monkeypatch.setattr("opensak.importer.gsak_importer.find_gsak_db3_in_zip", lambda p: p) + fake_box.choice = gdlg.tr("gsak_import_existing_skip") + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA", "B": "GC2BBB"}, with_settings=False)) + _run(dlg, qtbot) + names = {db.name for db in manager.databases} + assert "A" not in names and "B" not in names # skipped → nothing created + assert len(fake_box.instances) == 2 + + def test_prescan_continue_is_asked_only_once( + self, dlg, manager, tmp_path, qtbot, monkeypatch, fake_box): monkeypatch.setattr( "opensak.importer.gsak_importer.scan_gsak_notes_for_embedded_images", - lambda p: {"affected_notes": 3, "total_images": 5}, + lambda p: {"affected_notes": 1, "total_images": 1}, ) + fake_box.choice = gdlg.tr("gsak_prescan_continue") + dlg.set_path(_backup(tmp_path, {"A": "GC1AAA", "B": "GC2BBB"}, with_settings=False)) + _run(dlg, qtbot) + assert len(fake_box.instances) == 1 + assert _codes(_db_by_name(manager, "B").path) == ["GC2BBB"] - class FakeBox: - instances = [] - - def __init__(self, parent=None): - self.buttons = [] - FakeBox.instances.append(self) - - def setWindowTitle(self, t): self.title = t - def setText(self, t): self.text = t - def addButton(self, *a, **k): - btn = MagicMock() - self.buttons.append(btn) - return btn - def setDefaultButton(self, b): self.default = b - def exec(self): pass - def clickedButton(self): return self.buttons[-1] # simulate Cancel clicked - monkeypatch.setattr(gdlg, "QMessageBox", FakeBox) - FakeBox.ButtonRole = MagicMock() - FakeBox.StandardButton = MagicMock() - - started = [] - monkeypatch.setattr(gdlg, "GsakImportWorker", - lambda *a, **k: SimpleNamespace(start=lambda: started.append(True))) - - dlg._start_import() - assert len(FakeBox.instances) == 1 # the pre-scan dialog was shown - assert started == [] # cancelled -> worker never started - - def test_on_result_appends_summary_and_emits_completed(self, dlg, qtbot): +class TestGsakImportDialogMisc: + def test_on_result_appends_summary_and_marks_changed(self, dlg): dlg._selected_path = Path("/x/gsak.db3") - completed = [] - dlg.import_completed.connect(lambda: completed.append(True)) dlg._on_result(_result(created=48, waypoints=18, attributes=341, logs=1378, notes=2, note_images_replaced=1, corrected=1)) - text = dlg._log.toPlainText() - assert "48" in text - assert completed == [True] + assert "48" in dlg._log.toPlainText() + assert dlg._changed is True - def test_on_result_no_changes_does_not_emit_completed(self, dlg): - dlg._selected_path = Path("/x/gsak.db3") - completed = [] - dlg.import_completed.connect(lambda: completed.append(True)) + def test_on_result_no_changes(self, dlg): dlg._on_result(_result(created=0, updated=0)) - assert completed == [] + assert dlg._changed is False def test_on_error_appends_log(self, dlg): dlg._on_error("boom traceback") assert "boom traceback" in dlg._log.toPlainText() def test_on_progress_determinate(self, dlg): - dlg._progress.setVisible(True) dlg._on_progress(5, 10) assert dlg._progress.maximum() == 10 assert dlg._progress.value() == 5 @@ -251,18 +590,16 @@ def test_on_progress_indeterminate(self, dlg): dlg._on_progress(0, -1) assert dlg._progress.maximum() == 0 - def test_on_done_resets_ui(self, dlg): - dlg._import_btn.setEnabled(False) - dlg._browse_btn.setEnabled(False) - dlg._progress.setVisible(True) - dlg._on_done() - assert dlg._progress.isVisible() is False - assert dlg._import_btn.isEnabled() is True - assert dlg._browse_btn.isEnabled() is True - - def test_close_event_waits_for_running_worker(self, dlg): + def test_close_waits_for_worker_and_removes_temp(self, dlg, tmp_path): worker = MagicMock() worker.isRunning.return_value = True dlg._worker = worker - dlg.close() - worker.wait.assert_called_once() + temp = tmp_path / "gsak_import_x" + (temp / "00").mkdir(parents=True) + (temp / "00" / "sqlite.db3").write_bytes(b"x") + dlg._temp_dir = temp + dlg._jobs = [gdlg.GsakImportJob("A", "A", gdlg.MODE_NEW)] + dlg.reject() + worker.wait.assert_called() + assert not temp.exists() + assert dlg._jobs == [] From 8b7fa55c8822d7191d56f3261c64f656fbf34e6e Mon Sep 17 00:00:00 2001 From: nagisml Date: Wed, 30 Sep 2026 21:56:39 +0200 Subject: [PATCH 2/3] fixing test --- tests/e2e-tests/test_e2e_filter.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/e2e-tests/test_e2e_filter.py b/tests/e2e-tests/test_e2e_filter.py index 22818f81..a7754006 100644 --- a/tests/e2e-tests/test_e2e_filter.py +++ b/tests/e2e-tests/test_e2e_filter.py @@ -21,9 +21,9 @@ def test_quick_filter_found_returns_zero(seeded_window, qtbot): assert window._cache_table.row_count() == TOTAL window._quick_filter.setCurrentIndex(2) # index 2 = Found - qtbot.wait(50) - assert window._cache_table.row_count() == 0 + # The refresh runs on a background RefreshWorker — poll, don't sleep. + qtbot.waitUntil(lambda: window._cache_table.row_count() == 0, timeout=1_000) def test_quick_filter_not_found_returns_all(seeded_window, qtbot): @@ -31,13 +31,10 @@ def test_quick_filter_not_found_returns_all(seeded_window, qtbot): window = seeded_window window._quick_filter.setCurrentIndex(2) # Found → 0 rows - qtbot.wait(50) - assert window._cache_table.row_count() == 0 + qtbot.waitUntil(lambda: window._cache_table.row_count() == 0, timeout=1_000) window._quick_filter.setCurrentIndex(1) # Not Found → all rows - qtbot.wait(50) - - assert window._cache_table.row_count() == TOTAL + qtbot.waitUntil(lambda: window._cache_table.row_count() == TOTAL, timeout=1_000) def test_quick_filter_reset_to_all_restores_count(seeded_window, qtbot): @@ -45,11 +42,9 @@ def test_quick_filter_reset_to_all_restores_count(seeded_window, qtbot): window = seeded_window window._quick_filter.setCurrentIndex(2) # Found → 0 - qtbot.wait(50) + qtbot.waitUntil(lambda: window._cache_table.row_count() == 0, timeout=1_000) window._quick_filter.setCurrentIndex(0) # All - qtbot.wait(50) - - assert window._cache_table.row_count() == TOTAL + qtbot.waitUntil(lambda: window._cache_table.row_count() == TOTAL, timeout=1_000) # ── Name search ──────────────────────────────────────────────────────────────── From 92f9578b0c9389b623e75323b407d05dbc256d88 Mon Sep 17 00:00:00 2001 From: nagisml Date: Wed, 30 Sep 2026 22:09:59 +0200 Subject: [PATCH 3/3] Fixing distances NULL after import --- src/opensak/gui/mainwindow.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 78405a3b..bad935c9 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -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 —