Quellcode durchsuchen

Merge pull request #2714 from jmoore-skild/feature/2656-restore-from-github

feat(backup): restore selected categories from a Git backup commit
MartinNYHC vor 1 Monat
Ursprung
Commit
22683c058d
34 geänderte Dateien mit 10282 neuen und 3 gelöschten Zeilen
  1. 0 0
      CHANGELOG.md
  2. 123 1
      backend/app/api/routes/github_backup.py
  3. 1 1
      backend/app/models/github_backup.py
  4. 123 0
      backend/app/schemas/github_backup.py
  5. 79 0
      backend/app/services/git_providers/base.py
  6. 106 0
      backend/app/services/git_providers/gitea.py
  7. 246 0
      backend/app/services/git_providers/github.py
  8. 261 0
      backend/app/services/git_providers/gitlab.py
  9. 50 0
      backend/app/services/github_backup.py
  10. 1957 0
      backend/app/services/github_restore.py
  11. 674 0
      backend/tests/integration/test_github_restore_api.py
  12. 753 0
      backend/tests/unit/test_git_providers_restore.py
  13. 3426 0
      backend/tests/unit/test_github_restore.py
  14. 97 0
      frontend/src/__tests__/components/GitHubBackupSettings.history.test.tsx
  15. 115 0
      frontend/src/__tests__/components/GitHubBackupSettingsPermissions.test.tsx
  16. 696 0
      frontend/src/__tests__/components/GitHubRestoreModal.test.tsx
  17. 95 0
      frontend/src/api/client.ts
  18. 35 0
      frontend/src/components/GitHubBackupSettings.tsx
  19. 542 0
      frontend/src/components/GitHubRestoreModal.tsx
  20. 69 0
      frontend/src/i18n/locales/de.ts
  21. 74 0
      frontend/src/i18n/locales/en.ts
  22. 69 0
      frontend/src/i18n/locales/es.ts
  23. 69 0
      frontend/src/i18n/locales/fr.ts
  24. 69 0
      frontend/src/i18n/locales/it.ts
  25. 69 0
      frontend/src/i18n/locales/ja.ts
  26. 69 0
      frontend/src/i18n/locales/ko.ts
  27. 69 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 69 0
      frontend/src/i18n/locales/ru.ts
  29. 69 0
      frontend/src/i18n/locales/tr.ts
  30. 69 0
      frontend/src/i18n/locales/uk.ts
  31. 69 0
      frontend/src/i18n/locales/zh-CN.ts
  32. 69 0
      frontend/src/i18n/locales/zh-TW.ts
  33. 0 0
      static/assets/index-BPSw6nnF.js
  34. 1 1
      static/index.html

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
CHANGELOG.md


+ 123 - 1
backend/app/api/routes/github_backup.py

@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
+    REF_PATTERN,
     CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
@@ -19,10 +20,16 @@ from backend.app.schemas.github_backup import (
     GitHubBackupLogResponse,
     GitHubBackupStatus,
     GitHubBackupTriggerResponse,
+    GitHubCommitListResponse,
+    GitHubRestorePreview,
+    GitHubRestoreRequest,
+    GitHubRestoreResponse,
     GitHubTestConnectionResponse,
     ProviderType,
+    RestoreCategory,
 )
 from backend.app.services.github_backup import github_backup_service
+from backend.app.services.github_restore import github_restore_service
 
 logger = logging.getLogger(__name__)
 
@@ -44,6 +51,33 @@ _UNKNOWN_VISIBILITY_ERROR = (
     "repo API."
 )
 
+# The permission that owns each category's rows, required on top of
+# github:restore. Backup is its own permission group, so without this a role
+# holding only Backup writes — via a restore — rows it cannot write through the
+# endpoint that owns them.
+#
+# Each entry is the permission that endpoint actually gates its writes on:
+#
+#   * SETTINGS   → PUT /api/v1/settings/ (settings:update)
+#   * SPOOLS     → POST/PATCH /api/v1/inventory/spools (inventory:update). Spool
+#     rows and their usage history both restore under this category.
+#   * ARCHIVES   → archives:update_all, not archives:create. A restore writes
+#     rows owned by other users — that is the whole point of carrying
+#     created_by_id — and update_all is the permission that means "may write an
+#     archive that is not yours". create alone would let an operator with
+#     archives:create_own-shaped access seed history onto someone else.
+#   * KPROFILES  → POST /api/v1/printers/{id}/kprofiles (kprofiles:update),
+#     which is what the restore ultimately calls through set_kprofiles_batch.
+#
+# Cloud profiles are absent because they are not a restorable category
+# (RestoreCategory's docstring).
+_CATEGORY_WRITE_PERMISSION = {
+    RestoreCategory.SETTINGS: Permission.SETTINGS_UPDATE,
+    RestoreCategory.SPOOLS: Permission.INVENTORY_UPDATE,
+    RestoreCategory.ARCHIVES: Permission.ARCHIVES_UPDATE_ALL,
+    RestoreCategory.KPROFILES: Permission.KPROFILES_UPDATE,
+}
+
 
 async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> None:
     """Run a test_connection and refuse if the repo is not confirmed private.
@@ -388,13 +422,101 @@ async def get_status(
         configured=True,
         enabled=config.enabled,
         is_running=github_backup_service.is_running,
-        progress=github_backup_service.progress,
+        restore_running=github_restore_service.is_running,
+        progress=github_backup_service.progress or github_restore_service.progress,
         last_backup_at=config.last_backup_at,
         last_backup_status=config.last_backup_status,
         next_scheduled_run=config.next_scheduled_run,
     )
 
 
+@router.get("/commits", response_model=GitHubCommitListResponse)
+async def list_commits(
+    limit: int = Query(default=20, ge=1, le=100),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """List recent backup commits so the user can pick one to restore from."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    commit_result = await github_restore_service.list_commits(config, limit=limit)
+    return GitHubCommitListResponse(**commit_result)
+
+
+@router.get("/restore/preview", response_model=GitHubRestorePreview)
+async def preview_restore(
+    ref: str = Query(default="HEAD", pattern=REF_PATTERN),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """Report which categories a given backup commit contains."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    preview = await github_restore_service.preview(db, config, ref=ref)
+    return GitHubRestorePreview(**preview)
+
+
+@router.post("/restore", response_model=GitHubRestoreResponse)
+async def restore_backup(
+    request: GitHubRestoreRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """Restore selected categories from one backup commit.
+
+    Note there is no private-repo gate here, unlike the config endpoints: that
+    check exists to stop credentials leaving the instance, and this path only
+    reads. A config can only be saved against a private repo anyway.
+
+    Every category needs the permission that owns the rows it writes, on top of
+    ``github:restore`` — see ``_CATEGORY_WRITE_PERMISSION`` and the check below.
+    """
+    if current_user is not None:
+        # Each category rewrites rows some other endpoint already owns, and
+        # Backup is its own permission group — so a role holding only Backup
+        # could otherwise write, through a restore, what it cannot write through
+        # the endpoint that owns them. This module already makes that argument;
+        # it is why the four protected auth keys are refused outright.
+        #
+        # current_user is None only when auth is disabled: github:restore is in
+        # _APIKEY_DENIED_PERMISSIONS, so an API key never gets past the
+        # dependency to reach this line.
+        missing = sorted(
+            {
+                permission.value
+                for category, permission in _CATEGORY_WRITE_PERMISSION.items()
+                if category in request.categories and not current_user.has_all_permissions(permission.value)
+            }
+        )
+        if missing:
+            raise HTTPException(
+                status_code=403,
+                detail=f"Missing required permissions: {', '.join(missing)}",
+            )
+
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    restore_result = await github_restore_service.run_restore(
+        config.id,
+        ref=request.ref,
+        categories=request.categories,
+        overwrite_existing=request.overwrite_existing,
+    )
+    return GitHubRestoreResponse(**restore_result)
+
+
 @router.get("/logs", response_model=list[GitHubBackupLogResponse])
 async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),

+ 1 - 1
backend/app/models/github_backup.py

@@ -59,7 +59,7 @@ class GitHubBackupLog(Base):
     started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     status: Mapped[str] = mapped_column(String(20))  # running/success/failed/skipped
-    trigger: Mapped[str] = mapped_column(String(20))  # manual/scheduled
+    trigger: Mapped[str] = mapped_column(String(20))  # manual/scheduled/restore
 
     commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
     files_changed: Mapped[int] = mapped_column(Integer, default=0)

+ 123 - 0
backend/app/schemas/github_backup.py

@@ -176,6 +176,7 @@ class GitHubBackupStatus(BaseModel):
     configured: bool = Field(description="Whether backup is configured")
     enabled: bool = Field(description="Whether backup is enabled")
     is_running: bool = Field(description="Whether a backup is currently running")
+    restore_running: bool = Field(default=False, description="Whether a restore is currently running")
     progress: str | None = Field(default=None, description="Current backup progress message")
     last_backup_at: datetime | None
     last_backup_status: str | None
@@ -204,3 +205,125 @@ class GitHubBackupTriggerResponse(BaseModel):
     log_id: int | None = None
     commit_sha: str | None = None
     files_changed: int = 0
+
+
+# --- Restore (issue #2656) --------------------------------------------------
+
+# "HEAD" means "whatever the branch tip is right now"; the service resolves it
+# to a concrete SHA before reading anything so preview and apply can't straddle
+# two different commits. Anything else must look like a git object name.
+REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
+
+
+class RestoreCategory(StrEnum):
+    """Backup categories that can be restored.
+
+    Cloud profiles are deliberately absent: restoring a preset means writing to
+    a Bambu or Orca Cloud account, which is a different operation from every
+    other category here — those land in the local database, or on a printer the
+    instance already owns. Tracked separately from #2656.
+    """
+
+    KPROFILES = "kprofiles"
+    SETTINGS = "settings"
+    SPOOLS = "spools"
+    ARCHIVES = "archives"
+
+
+class GitHubCommitInfo(BaseModel):
+    """One commit in the backup repository."""
+
+    sha: str
+    message: str
+    author: str
+    date: str
+
+
+class GitHubCommitListResponse(BaseModel):
+    """Schema for the commit picker."""
+
+    success: bool
+    message: str
+    branch: str
+    commits: list[GitHubCommitInfo] = Field(default_factory=list)
+
+
+class GitHubRestorePreviewCategory(BaseModel):
+    """What a single category looks like inside one backup commit."""
+
+    category: RestoreCategory
+    available: bool = Field(description="Whether this category is present in the commit")
+    item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
+    detail: str | None = Field(default=None, description="Why unavailable, or extra context, in English")
+    detail_code: str | None = Field(
+        default=None, description="Key under backup.restoreFromGit.details, for the client to translate"
+    )
+    detail_params: dict[str, str | int] = Field(
+        default_factory=dict, description="Interpolation values for detail_code"
+    )
+
+
+class GitHubRestorePreview(BaseModel):
+    """Schema for inspecting a commit before restoring from it."""
+
+    success: bool
+    message: str
+    ref: str = Field(description="The concrete commit SHA that was inspected")
+    commit: GitHubCommitInfo | None = None
+    metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
+    categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
+
+
+class GitHubRestoreRequest(BaseModel):
+    """Schema for triggering a restore."""
+
+    ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
+    categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
+    overwrite_existing: bool = Field(
+        default=False,
+        description="Update rows that already exist locally. When false, only missing rows are inserted.",
+    )
+
+    @model_validator(mode="after")
+    def deduplicate_categories(self) -> "GitHubRestoreRequest":
+        # Same category twice would double-count the result totals.
+        seen: list[RestoreCategory] = []
+        for category in self.categories:
+            if category not in seen:
+                seen.append(category)
+        self.categories = seen
+        return self
+
+
+class GitHubRestoreNote(BaseModel):
+    """One tally note, as a translation code plus the values it interpolates.
+
+    Follows the ``backup.pathCheck`` contract already in use one card down in the
+    same component: the server chooses the code and supplies typed params, and
+    the client renders ``t(`...${code}`, { ...params, defaultValue: message })``.
+    ``message`` is the English original, so a client that does not know a code
+    yet still shows something sensible rather than the raw key.
+    """
+
+    code: str = Field(description="Key under backup.restoreFromGit.notes")
+    params: dict[str, str | int] = Field(default_factory=dict, description="Interpolation values for code")
+    message: str = Field(description="English rendering, used as the client's defaultValue")
+
+
+class GitHubRestoreCategoryResult(BaseModel):
+    """Per-category outcome of a restore."""
+
+    restored: int = 0
+    skipped: int = 0
+    failed: int = 0
+    notes: list[GitHubRestoreNote] = Field(default_factory=list)
+
+
+class GitHubRestoreResponse(BaseModel):
+    """Schema for the restore result."""
+
+    success: bool
+    message: str
+    log_id: int | None = None
+    ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
+    results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)

+ 79 - 0
backend/app/services/git_providers/base.py

@@ -76,3 +76,82 @@ class GitProviderBackend(ABC):
         client: httpx.AsyncClient,
     ) -> dict:
         """Push files to the repository. Returns status/message/commit_sha/files_changed."""
+
+    # --- Read side (restore, issue #2656) ---------------------------------
+    # The backup path only ever writes. Restore needs to walk history, list a
+    # snapshot and read individual blobs back, so these three mirror the
+    # ``{"success": bool, "message": str, ...}`` convention ``test_connection``
+    # already uses rather than raising.
+
+    @abstractmethod
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch``, newest first.
+
+        Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
+        """
+
+    @abstractmethod
+    async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
+        """Read one commit's display metadata by SHA.
+
+        ``list_commits`` only reaches back as far as its limit, so a ref outside
+        that window has no entry to describe it. This is the direct lookup for
+        that case.
+
+        Returns ``{"success", "message", "commit": {"sha", "message", "author",
+        "date"} | None}``.
+        """
+
+    @abstractmethod
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List every blob path present at ``ref``.
+
+        ``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
+        via :meth:`list_commits` first, so the snapshot being previewed and the
+        one being restored are provably the same commit even if a scheduled
+        backup lands in between.
+
+        Returns ``{"success", "message", "paths": [str], "blob_shas":
+        {path: sha}}``. ``blob_shas`` is the path -> blob SHA map the listing
+        already had to build, offered so :meth:`fetch_files` need not fetch the
+        same tree again; providers that read files by path return ``{}``.
+        """
+
+    @abstractmethod
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+        blob_shas: dict[str, str] | None = None,
+    ) -> dict:
+        """Read several files' decoded UTF-8 text at ``ref``.
+
+        Batched rather than one-file-at-a-time so providers that need a tree
+        listing to map path -> blob SHA can do that lookup once for the whole
+        restore instead of per file.
+
+        ``blob_shas`` is the map :meth:`list_tree` returned for the same ref, if
+        the caller has one. Passing it saves a second recursive tree GET; a
+        provider that reads by path ignores it, and one that needs it fetches
+        the tree itself when it is absent.
+
+        Returns ``{"success", "message", "files": {path: text}}``. Paths absent
+        from the commit are simply missing from ``files`` — that is not an error,
+        since which categories a given backup contains varies by config.
+        """

+ 106 - 0
backend/app/services/git_providers/gitea.py

@@ -12,6 +12,11 @@ from backend.app.services.git_providers.github import GitHubBackend
 
 logger = logging.getLogger(__name__)
 
+# Gitea clamps per_page to MAX_RESPONSE_ITEMS, which defaults to 50. Consulted
+# only when a tree response carries no usable total_count: a page at least this
+# long may be a clamped full page and cannot be assumed to be the last one.
+_ASSUMED_MIN_PAGE_SIZE = 50
+
 
 class GiteaBackend(GitHubBackend):
     """Backend for Gitea instances.
@@ -100,6 +105,107 @@ class GiteaBackend(GitHubBackend):
         headers["Accept"] = "application/json"
         return headers
 
+    async def _blob_shas_at(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        ref: str,
+    ) -> tuple[dict[str, str] | None, str]:
+        """Paged override of GitHub's single-GET tree read (#2656).
+
+        Divergence four, alongside the three in the class docstring. GitHub's
+        recursive trees endpoint is not paginated and signals overflow with
+        ``truncated: true``, which the inherited implementation hard-fails on.
+        Gitea and Forgejo *do* page the same endpoint — ``page``/``per_page``,
+        with ``total_count`` alongside the tree — so the inherited version would
+        read only the first page and then report every category beyond it as
+        absent from the commit. A restore that silently skips categories is the
+        exact failure the GitHub version refuses to allow, so this pages instead.
+
+        The cap mirrors GitLab's: reaching it means there are more pages, and
+        that is a failure rather than a partial result. Because the page size is
+        the server's choice rather than ours (see below), the cap is a page count
+        and not a file count.
+        """
+        blobs: dict[str, str] = {}
+        seen = 0
+        page = 1
+        page_size: int | None = None
+        while page <= 50:
+            response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}",
+                headers=headers,
+                params={"recursive": "true", "page": page, "per_page": 1000},
+            )
+            if response.status_code == 404:
+                return None, f"Commit or tree '{ref}' not found in the repository"
+            if response.status_code != 200:
+                return None, (
+                    f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                )
+            try:
+                data = response.json()
+            except ValueError:
+                return None, "Non-JSON response listing tree"
+            if not isinstance(data, dict):
+                return None, "Unexpected shape listing tree"
+
+            entries = data.get("tree")
+            if not isinstance(entries, list):
+                entries = []
+            for item in entries:
+                if not isinstance(item, dict) or item.get("type") != "blob":
+                    continue
+                path, sha = item.get("path"), item.get("sha")
+                if isinstance(path, str) and isinstance(sha, str) and path and sha:
+                    blobs[path] = sha
+
+            # total_count counts every entry, trees included, so compare against
+            # what came back rather than against len(blobs).
+            #
+            # Count what the server actually returned, never the per_page we
+            # asked for: Gitea clamps per_page to MAX_RESPONSE_ITEMS, which
+            # defaults to 50. Deriving the offset from the requested 1000 made
+            # page 2 report 1050 entries seen, which clears any total_count below
+            # that — so the loop stopped and returned the first two pages of a
+            # much larger tree as a success. The restore then read every missing
+            # path as "category not present in this commit" and skipped it
+            # silently, the exact failure this override exists to prevent.
+            total = data.get("total_count")
+            seen += len(entries)
+            if page_size is None:
+                page_size = max(len(entries), _ASSUMED_MIN_PAGE_SIZE)
+
+            if not entries:
+                return blobs, ""
+            if isinstance(total, int):
+                if seen >= total:
+                    return blobs, ""
+            elif len(entries) < page_size:
+                # No usable total_count. This used to return here on the *first*
+                # page, i.e. fail open into a success holding whatever one page
+                # happened to be — 50 entries of an arbitrarily large tree under
+                # the default clamp — and the restore then reported every
+                # category beyond it as absent from the commit. Page until a
+                # short or empty page instead; the page-count ceiling below
+                # still gives the correct hard failure for a tree that really is
+                # too large. A page shorter than the first one (or than Gitea's
+                # default clamp, so a genuinely small tree stays one request)
+                # cannot be followed by another. The residual case is an
+                # instance whose MAX_RESPONSE_ITEMS is set *below* 50 and which
+                # also omits total_count; real Gitea and Forgejo always send it
+                # on this route.
+                return blobs, ""
+            page += 1
+
+        return None, (
+            "Repository tree exceeds the listing limit, so the backup contents cannot be "
+            "enumerated reliably. Rotate the backup repository."
+        )
+
     async def push_files(
         self,
         repo_url: str,

+ 246 - 0
backend/app/services/git_providers/github.py

@@ -115,6 +115,252 @@ class GitHubBackend(GitProviderBackend):
                 "is_private": None,
             }
 
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch`` via the repo commits API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            # GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
+            # do the reverse. Sending both lets GiteaBackend inherit this method
+            # unchanged instead of duplicating it for one query parameter.
+            response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/commits",
+                headers=headers,
+                params={"sha": branch, "per_page": limit, "limit": limit},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        f"Branch '{branch}' not found, or the repository has no commits yet. "
+                        "Run a backup before restoring."
+                    ),
+                    "commits": [],
+                }
+            if response.status_code != 200:
+                msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("list_commits %s/%s: %s", owner, repo, msg)
+                return {"success": False, "message": msg, "commits": []}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
+            if not isinstance(data, list):
+                return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
+
+            return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
+
+        except Exception as e:
+            logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
+
+    @staticmethod
+    def _parse_commit_entries(data: list, limit: int) -> list[dict]:
+        """Normalise GitHub/Gitea commit list entries to our flat shape."""
+        commits = []
+        for entry in data[:limit]:
+            if not isinstance(entry, dict):
+                continue
+            sha = entry.get("sha")
+            if not isinstance(sha, str) or not sha:
+                continue
+            commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
+            author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
+            commits.append(
+                {
+                    "sha": sha,
+                    "message": commit.get("message") or "",
+                    "author": author.get("name") or "",
+                    "date": author.get("date") or "",
+                }
+            )
+        return commits
+
+    async def _blob_shas_at(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        ref: str,
+    ) -> tuple[dict[str, str] | None, str]:
+        """Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
+
+        A commit SHA is a valid tree-ish for the trees API, so this resolves the
+        commit's tree in one request rather than commit -> tree -> list.
+        """
+        response = await client.get(
+            f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
+            headers=headers,
+        )
+        if response.status_code == 404:
+            return None, f"Commit or tree '{ref}' not found in the repository"
+        if response.status_code != 200:
+            return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+        try:
+            data = response.json()
+        except ValueError:
+            return None, "Non-JSON response listing tree"
+        # Same limit the push path guards against: a truncated listing would make
+        # a restore silently skip categories that are actually in the backup.
+        if data.get("truncated"):
+            return None, (
+                "Repository tree exceeds the API listing limit (truncated=true), so the backup "
+                "contents cannot be enumerated reliably. Rotate the backup repository."
+            )
+        blobs: dict[str, str] = {}
+        for item in data.get("tree", []):
+            if not isinstance(item, dict) or item.get("type") != "blob":
+                continue
+            path, sha = item.get("path"), item.get("sha")
+            if isinstance(path, str) and isinstance(sha, str) and path and sha:
+                blobs[path] = sha
+        return blobs, ""
+
+    async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
+        """Read one commit's metadata directly, for refs outside the list window."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            response = await client.get(f"{api_base}/repos/{owner}/{repo}/commits/{ref}", headers=headers)
+            if response.status_code == 404:
+                return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
+            if response.status_code != 200:
+                msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("get_commit %s/%s ref=%s: %s", owner, repo, ref, msg)
+                return {"success": False, "message": msg, "commit": None}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
+            if not isinstance(data, dict):
+                return {"success": False, "message": "Unexpected shape reading commit", "commit": None}
+
+            # Same entry shape as list_commits, so callers can treat the two
+            # interchangeably.
+            parsed = self._parse_commit_entries([data], 1)
+            if not parsed:
+                return {"success": False, "message": "Commit response carried no SHA", "commit": None}
+            return {"success": True, "message": "OK", "commit": parsed[0]}
+
+        except Exception as e:
+            logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
+
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List blob paths present at ``ref`` via the Git Data trees API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
+            if blobs is None:
+                logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error)
+                return {"success": False, "message": error, "paths": [], "blob_shas": {}}
+
+            # The map is handed back so fetch_files does not GET the same
+            # recursive tree a second time for the same ref.
+            return {"success": True, "message": "OK", "paths": sorted(blobs), "blob_shas": blobs}
+
+        except Exception as e:
+            logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
+
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+        blob_shas: dict[str, str] | None = None,
+    ) -> dict:
+        """Read ``paths`` at ``ref`` via the Git Data blobs API.
+
+        The blobs API is used rather than the contents API because contents
+        inlines only files up to 1 MB — an archive-heavy ``print_history.json``
+        can exceed that, and it would come back with an empty body instead of an
+        error.
+        """
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            blobs = blob_shas
+            if blobs is None:
+                blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
+                if blobs is None:
+                    logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
+                    return {"success": False, "message": error, "files": {}}
+
+            files: dict[str, str] = {}
+            for path in paths:
+                sha = blobs.get(path)
+                if sha is None:
+                    continue
+                response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
+                if response.status_code != 200:
+                    msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                    logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
+                    return {"success": False, "message": msg, "files": {}}
+                text, error = self._decode_blob(response, path)
+                if text is None:
+                    logger.warning("fetch_files %s/%s: %s", owner, repo, error)
+                    return {"success": False, "message": error, "files": {}}
+                files[path] = text
+
+            return {"success": True, "message": "OK", "files": files}
+
+        except Exception as e:
+            logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
+
+    def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
+        """Decode a blob API response body to text, or return an error message."""
+        try:
+            data = response.json()
+        except ValueError:
+            return None, f"Non-JSON response reading {path}"
+        if not isinstance(data, dict):
+            return None, f"Unexpected shape reading {path}"
+        content = data.get("content")
+        if not isinstance(content, str):
+            return None, f"Missing content reading {path}"
+        encoding = data.get("encoding", "base64")
+        try:
+            if encoding == "base64":
+                # Both providers wrap base64 payloads at 60 chars; b64decode
+                # tolerates the newlines, but be explicit about it.
+                return base64.b64decode(content).decode("utf-8"), ""
+            if encoding in ("utf-8", "text", "plain"):
+                return content, ""
+        except (ValueError, UnicodeDecodeError) as e:
+            return None, f"Could not decode {path}: {type(e).__name__}"
+        return None, f"Unsupported blob encoding {encoding!r} reading {path}"
+
     async def push_files(
         self,
         repo_url: str,

+ 261 - 0
backend/app/services/git_providers/gitlab.py

@@ -115,6 +115,267 @@ class GitLabBackend(GitProviderBackend):
                 "is_private": None,
             }
 
+    def _encoded_project(self, repo_url: str) -> str:
+        """Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
+        owner, repo = self.parse_repo_url(repo_url)
+        return urllib.parse.quote(f"{owner}/{repo}", safe="")
+
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch`` via /repository/commits."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            response = await client.get(
+                f"{api_base}/projects/{encoded_path}/repository/commits",
+                headers=headers,
+                params={"ref_name": branch, "per_page": limit},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        f"Branch '{branch}' not found, or the repository has no commits yet. "
+                        "Run a backup before restoring."
+                    ),
+                    "commits": [],
+                }
+            if response.status_code != 200:
+                msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("list_commits %s: %s", repo_url, msg)
+                return {"success": False, "message": msg, "commits": []}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
+            if not isinstance(data, list):
+                return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
+
+            commits = []
+            for entry in data[:limit]:
+                if not isinstance(entry, dict):
+                    continue
+                sha = entry.get("id")
+                if not isinstance(sha, str) or not sha:
+                    continue
+                # GitLab flattens author/date onto the commit itself rather than
+                # nesting them under "commit" the way GitHub does.
+                commits.append(
+                    {
+                        "sha": sha,
+                        "message": entry.get("message") or "",
+                        "author": entry.get("author_name") or "",
+                        "date": entry.get("committed_date") or entry.get("created_at") or "",
+                    }
+                )
+
+            return {"success": True, "message": "OK", "commits": commits}
+
+        except Exception as e:
+            logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
+
+    async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
+        """Read one commit's metadata directly, for refs outside the list window."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            response = await client.get(
+                f"{api_base}/projects/{encoded_path}/repository/commits/{urllib.parse.quote(ref, safe='')}",
+                headers=headers,
+            )
+            if response.status_code == 404:
+                return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
+            if response.status_code != 200:
+                msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("get_commit %s ref=%s: %s", repo_url, ref, msg)
+                return {"success": False, "message": msg, "commit": None}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
+            sha = data.get("id") if isinstance(data, dict) else None
+            if not isinstance(sha, str) or not sha:
+                return {"success": False, "message": "Commit response carried no SHA", "commit": None}
+
+            # GitLab flattens author/date onto the commit, as in list_commits.
+            return {
+                "success": True,
+                "message": "OK",
+                "commit": {
+                    "sha": sha,
+                    "message": data.get("message") or "",
+                    "author": data.get("author_name") or "",
+                    "date": data.get("committed_date") or data.get("created_at") or "",
+                },
+            }
+
+        except Exception as e:
+            logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
+
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List blob paths at ``ref`` via /repository/tree, following pagination."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            paths: list[str] = []
+            page = 1
+            complete = False
+            # GitLab's tree endpoint paginates instead of exposing a "truncated"
+            # flag, so walk pages until one comes back short. The page cap stops
+            # a malformed X-Next-Page loop from spinning forever — and reaching
+            # it is a failure, not a result: see the check after the loop.
+            while page <= 50:
+                response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/tree",
+                    headers=headers,
+                    params={"ref": ref, "recursive": "true", "per_page": 100, "page": page},
+                )
+                if response.status_code == 404:
+                    return {
+                        "success": False,
+                        "message": f"Commit or tree '{ref}' not found in the repository",
+                        "paths": [],
+                        "blob_shas": {},
+                    }
+                if response.status_code != 200:
+                    msg = (
+                        f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                    )
+                    logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
+                    return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
+
+                try:
+                    data = response.json()
+                except ValueError:
+                    return {"success": False, "message": "Non-JSON response listing tree", "paths": [], "blob_shas": {}}
+                if not isinstance(data, list):
+                    return {"success": False, "message": "Unexpected shape listing tree", "paths": [], "blob_shas": {}}
+
+                for item in data:
+                    if isinstance(item, dict) and item.get("type") == "blob":
+                        path = item.get("path")
+                        if isinstance(path, str) and path:
+                            paths.append(path)
+
+                if len(data) < 100:
+                    complete = True
+                    break
+                page += 1
+
+            if not complete:
+                # Falling out of the loop means the last page was full and there
+                # are more. Returning success here would hand the restore a
+                # silently partial path list, and it would then report the
+                # categories it could not see as "not present in this commit" —
+                # the same failure GitHub's truncated=true check refuses to allow.
+                msg = (
+                    "Repository tree exceeds the listing limit (more than 5000 files), so the backup "
+                    "contents cannot be enumerated reliably. Rotate the backup repository."
+                )
+                logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
+                return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
+
+            # GitLab reads files by path, so there is no blob-SHA map to share.
+            return {"success": True, "message": "OK", "paths": sorted(paths), "blob_shas": {}}
+
+        except Exception as e:
+            logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
+
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+        blob_shas: dict[str, str] | None = None,
+    ) -> dict:
+        """Read ``paths`` at ``ref`` via /repository/files/{path}.
+
+        ``blob_shas`` is accepted for interface parity and ignored: this backend
+        addresses files by path, so it never needed the tree listing that makes
+        the map worth passing.
+        """
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            files: dict[str, str] = {}
+            for path in paths:
+                encoded_file = urllib.parse.quote(path, safe="")
+                response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
+                    headers=headers,
+                    params={"ref": ref},
+                )
+                # A path absent from this commit is expected — which categories a
+                # backup contains varies by config — so skip rather than fail.
+                if response.status_code == 404:
+                    continue
+                if response.status_code != 200:
+                    msg = (
+                        f"Failed to read {path} (HTTP {response.status_code}): "
+                        f"{self._truncated_response_text(response)}"
+                    )
+                    logger.warning("fetch_files %s: %s", repo_url, msg)
+                    return {"success": False, "message": msg, "files": {}}
+
+                try:
+                    data = response.json()
+                except ValueError:
+                    return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
+                if not isinstance(data, dict):
+                    return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
+
+                content = data.get("content")
+                if not isinstance(content, str):
+                    return {"success": False, "message": f"Missing content reading {path}", "files": {}}
+                encoding = data.get("encoding", "base64")
+                try:
+                    if encoding == "base64":
+                        files[path] = base64.b64decode(content).decode("utf-8")
+                    elif encoding in ("text", "utf-8", "plain"):
+                        files[path] = content
+                    else:
+                        return {
+                            "success": False,
+                            "message": f"Unsupported encoding {encoding!r} reading {path}",
+                            "files": {},
+                        }
+                except (ValueError, UnicodeDecodeError) as e:
+                    return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
+
+            return {"success": True, "message": "OK", "files": files}
+
+        except Exception as e:
+            logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
+
     async def push_files(
         self,
         repo_url: str,

+ 50 - 0
backend/app/services/github_backup.py

@@ -173,9 +173,32 @@ class GitHubBackupService:
         Returns:
             dict with success, message, log_id, commit_sha, files_changed
         """
+        # Everything from here to `self._running_backup = True` must stay
+        # await-free. Both flags are plain bools and both callers are coroutines
+        # on one event loop, so with no suspension point in between the loop
+        # cannot run the restore service's mirror-image region (see
+        # github_restore.run_restore) in the gap — whichever gets here first sets
+        # its flag before the other can read it. Adding an `await` inside this
+        # block reintroduces the check-then-set race and lets a backup and a
+        # restore run at once.
         if self._running_backup:
             return {"success": False, "message": "A backup is already running", "log_id": None}
 
+        # Imported locally to avoid a module-level import cycle — the restore
+        # service imports this module's singleton to take the mirror-image lock.
+        # A restore rewrites the same tables this collector reads and publishes
+        # K-profiles to the same printers, so the two must not interleave.
+        # (A local `import` of an already-loaded module is not a suspension
+        # point, so it does not break the await-free rule above.)
+        from backend.app.services.github_restore import github_restore_service
+
+        if github_restore_service.is_running:
+            return {
+                "success": False,
+                "message": "A restore is currently running. Wait for it to finish before backing up.",
+                "log_id": None,
+            }
+
         self._running_backup = True
         log_id = None
 
@@ -805,6 +828,14 @@ class GitHubBackupService:
         if not archives:
             return
 
+        # The natural key for an owner. created_by_id alone is only meaningful on
+        # the instance that wrote it: restoring onto a rebuilt instance — this
+        # feature's main use case — renumbers the users table, so a live id can
+        # land on a different person. username is unique on users, so the restore
+        # can resolve on it and treat a rename as unknown rather than guess.
+        # One query for the map; archives outnumber users by orders of magnitude.
+        user_names = dict((await db.execute(select(User.id, User.username))).all())
+
         archive_list = []
         for a in archives:
             archive_data = {
@@ -840,6 +871,25 @@ class GitHubBackupService:
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "created_at": str(a.created_at) if a.created_at else None,
+                # Soft-deleted archives are collected too — their row is kept on
+                # purpose so the stats endpoint keeps counting their filament and
+                # energy (see archive_service.soft_delete_archive). Recording
+                # deleted_at is what lets a restore put them back the way they
+                # were instead of resurrecting them as visible archives.
+                "deleted_at": str(a.deleted_at) if a.deleted_at else None,
+                # Who owns the archive, for the same reason deleted_at is here:
+                # it is not decoration, it is what the access check runs on.
+                # _ensure_archive_visible (api/routes/archives.py) fails closed on
+                # a NULL created_by_id and the list paths filter on it, so a
+                # restored row without it is invisible to everyone but an admin —
+                # while the restore reports it restored.
+                "created_by_id": a.created_by_id,
+                # Preferred over the id on restore; the id stays as the fallback
+                # for an owner whose row has since gone. Null when the archive
+                # has no owner, or when it points at a user row that no longer
+                # exists locally — the same "absent is not null" rule the restore
+                # applies, so a backup can't claim an owner it cannot name.
+                "created_by_username": user_names.get(a.created_by_id),
             }
             archive_list.append(archive_data)
 

+ 1957 - 0
backend/app/services/github_restore.py

@@ -0,0 +1,1957 @@
+"""Restore Bambuddy data from a Git provider backup (issue #2656).
+
+The backup side (``github_backup.py``) is push-only: it collects a handful of
+JSON documents and commits them. This module is the read side — it walks the
+backup repository's history, lets a caller inspect what a given commit contains,
+and applies selected categories back into the local database (or, for
+K-profiles, back onto the printers).
+
+Design notes worth knowing before editing:
+
+* **A restore never reuses the backup's primary keys.** ``spool.id`` and
+  ``print_archives.id`` are bare autoincrement columns, so the ids in a backup
+  taken weeks ago very likely belong to unrelated rows today. Rows are matched
+  on natural keys instead, inserted without an explicit id, and an
+  ``old_id -> new_id`` map is threaded through so foreign keys in dependent
+  tables (spool usage history) still line up.
+
+  The printer-side ``cali_idx`` behaves the same way and gets the same
+  treatment. Editing a K-profile in Bambuddy is a delete-then-add on a
+  single-nozzle printer, which re-keys it, and ``extrusion_cali_set`` aimed at a
+  slot that no longer exists is silently dropped — so the live index is read
+  back and matched before writing, never taken from the backup.
+* **Categories are applied archives -> spools -> settings -> kprofiles.**
+  Archives first because spool usage history references ``archive_id``;
+  K-profiles last because they leave the database and talk to hardware.
+* **Cloud profiles are not restorable.** Restoring a preset means writing to a
+  Bambu or Orca Cloud account, which is a different operation from everything
+  else here — every other category lands in the local database or, for
+  K-profiles, on a printer the instance already owns. Tracked separately from
+  #2656. (The collector does write ``cloud_profiles/*.json`` as of #2717; the
+  earlier claim that it did not is no longer true.)
+"""
+
+import asyncio
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass, field as dataclasses_field
+from datetime import datetime, timezone
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.printer import Printer
+from backend.app.models.project import Project
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.user import User
+from backend.app.schemas.github_backup import RestoreCategory
+from backend.app.services.git_providers.factory import get_provider_backend
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+METADATA_PATH = "backup_metadata.json"
+SETTINGS_PATH = "settings/app_settings.json"
+SPOOLS_PATH = "spools/inventory.json"
+SPOOL_USAGE_PATH = "spools/usage_history.json"
+ARCHIVES_PATH = "archives/print_history.json"
+
+# kprofiles/{printer_serial}/{nozzle_diameter}.json
+_KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
+
+# Settings keys the backup collector already refuses to write. Applied again on
+# the read side because a backup taken before that denylist existed can still
+# contain them, and a restore must not resurrect a stale credential.
+_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
+
+# The primary refusal, not a backstop for the set above. The collector filters
+# exactly bambu_cloud_token and auth_secret_key, so every other credential —
+# mqtt_password, ldap_bind_password, ha_token, prometheus_token — is present in
+# a current backup and is skipped only because its key matches a hint here.
+# _COMPANION_CREDENTIALS sits downstream of that: it withholds a toggle when the
+# credential it needs was refused, so shortening this tuple would both write a
+# stale credential and quietly make that rule inert.
+_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
+
+# Settings the MQTT relay reads only when it is (re)configured, so restoring the
+# rows is not enough on its own. Mirrors the set the settings PUT handler
+# watches. mqtt_password is in here for the configure() payload's sake — the
+# credential blocklist means a restore never writes it.
+_MQTT_SETTING_KEYS = {
+    "mqtt_enabled",
+    "mqtt_broker",
+    "mqtt_port",
+    "mqtt_username",
+    "mqtt_password",
+    "mqtt_topic_prefix",
+    "mqtt_use_tls",
+}
+
+# Keys that decide *who can reach the instance* rather than how it behaves. The
+# backup collector writes them like any other Settings row, so a backup taken
+# before auth was turned on carries auth_enabled=false — and a restore reaches
+# the table directly, so honouring them would:
+#
+#   * disable authentication outright. ``set_auth_enabled`` pairs its write with
+#     ``invalidate_auth_enabled_cache()``; we cannot, so the 30 s TTL in
+#     core.auth is the only thing between the write and an open instance. That
+#     cache is built to fail closed — writing the stored value behind its back
+#     is what would make it fail open.
+#   * bypass the lockout refusals ``update_settings`` enforces (a
+#     ``local_login_enabled=false`` with no enabled OIDC provider, or with no
+#     OIDC link on the caller, is a 400 there — #1589).
+#   * cross a permission boundary: a restore would be a way to rewrite auth
+#     config without SETTINGS_UPDATE. (The endpoint gates each category on the
+#     permission owning its rows now, but that is settings:update — still not
+#     the auth UI's own guards, which is what these keys actually need.)
+#
+# Auth is reconfigured through the auth UI, which has the guards. Restoring it
+# from a snapshot has no safe reading.
+_PROTECTED_SETTING_KEYS = {
+    "auth_enabled",
+    "advanced_auth_enabled",
+    "local_login_enabled",
+    "setup_completed",
+}
+
+# The LDAP family, refused for the same reason and by prefix rather than by
+# name, so a key added to the schema later is refused by default.
+#
+# These are not "how the instance behaves" settings — together they name *which
+# directory server decides who you are*. auth.py reads them live from this table
+# on every login (see the ldap_keys list in _get_ldap_settings), so a restore
+# that writes them substitutes the authentication source wholesale:
+# ldap_server_url points at another directory, ldap_auto_provision creates a
+# local account for whoever it vouches for, and ldap_default_group decides what
+# that account gets — Administrators, if the backup says so.
+#
+# The companion rule does NOT cover this, which is the trap. ldap_enabled is
+# paired with ldap_bind_password there, but an *anonymous* bind is a working
+# config, so a backup that simply omits the password skips the refusal at the
+# _COMPANION_EXPOSURE_TOGGLES check and the toggle is written. Omitting a
+# credential is exactly what an attacker authoring this file would do — they own
+# the directory being pointed at, so they need no bind credential from us.
+_PROTECTED_SETTING_PREFIXES = ("ldap_",)
+
+# Nozzle diameters the backup collector iterates. A path outside this set means
+# the backup was written by a newer version, so accept it rather than dropping
+# data, but keep the list for validation messages.
+_KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
+
+
+def _parse_dt(value) -> datetime | None:
+    """Best-effort parse of a datetime the backup wrote via ``str(...)``.
+
+    Normalised to naive UTC, because that is what every ``DateTime`` column
+    here holds: the models write ``datetime.now(timezone.utc)`` into naive
+    columns and both dialects drop the offset on the way in. Carrying an aware
+    value through would store the wrong wall clock, and comparing one against a
+    value read back out of a naive column raises ``TypeError``. The collector
+    only ever writes naive strings, so this is a guard on hand-edited or
+    foreign backups rather than a path Bambuddy takes itself.
+    """
+    if not value or not isinstance(value, str):
+        return None
+    try:
+        parsed = datetime.fromisoformat(value)
+    except ValueError:
+        return None
+    if parsed.tzinfo is not None:
+        parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parsed
+
+
+def _created_at_matches(row, created_at: datetime | None) -> bool:
+    """Does ``row.created_at`` equal a timestamp read out of a backup?
+
+    Compared in Python, not in SQL, and that is the whole point. Every
+    ``created_at`` these callers dedupe on is ``server_default=func.now()``, so
+    SQLite fills it from ``CURRENT_TIMESTAMP``, which has second precision and
+    stores ``'2026-08-02 11:28:41'``. SQLAlchemy binds a Python datetime as
+    ``'2026-08-02 11:28:41.000000'``, and SQLite compares the two as strings —
+    so ``Model.created_at == created_at`` never matches a row the application
+    itself created, not even when handed that row's own value straight back.
+    Every dedupe keyed on it misses, and the restore inserts a duplicate of
+    everything instead of recognising what is already there.
+
+    Reading the candidates back and comparing the parsed datetimes sidesteps
+    the bind format entirely, and is equally correct on PostgreSQL (where the
+    column keeps microseconds and the SQL comparison happened to work).
+    """
+    return created_at is not None and row.created_at == created_at
+
+
+def _is_blocked_setting_key(key: str) -> bool:
+    lowered = key.lower()
+    return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
+
+
+def _is_protected_setting_key(key: str) -> bool:
+    # Lowered for the prefix test for the same reason _is_blocked_setting_key
+    # lowers: the key comes from the backup's JSON, not from our own writer, so
+    # its casing is whatever the file says. An exact-match name stays exact —
+    # those four are ours and are only ever written lowercase.
+    return key in _PROTECTED_SETTING_KEYS or key.lower().startswith(_PROTECTED_SETTING_PREFIXES)
+
+
+# There used to be an ``_is_skipped_setting_key`` here, the union of the two
+# predicates above, shared by the preview and the restore so neither could drift
+# from the other. It is gone because a name is no longer enough to decide: the
+# third refusal below depends on the payload's *other* values and on local
+# database state. ``_plan_settings`` is the shared classifier now, and it covers
+# all three reasons.
+
+
+# Toggles whose *safety* depends on a companion credential that the blocklist
+# above refuses to restore. Writing the toggle alone is not a partial restore,
+# it is a downgrade:
+#
+#   * prometheus_enabled with no token opens /api/v1/metrics. The route is on
+#     PUBLIC_API_ROUTES and its own gate is ``if token:`` (api/routes/metrics.py),
+#     so an empty or absent token means no authentication at all — a full,
+#     unauthenticated dump of the instance to anyone who can reach the port. On
+#     an instance that never enabled Prometheus there is no token row, so
+#     overwrite-off alone is enough to do it.
+#   * the other four switch an integration on with no way to authenticate to it,
+#     which breaks the login path (LDAP) or the connection (MQTT, HA).
+#
+# virtual_printer_enabled is largely vestigial post-migration — core/database.py
+# copies the rows into the virtual_printers table — but it is the same shape, and
+# refusing a vestigial toggle is a harmless no-op.
+#
+# ldap_enabled is deliberately NOT here. It was, paired with
+# ldap_bind_password — but this rule judges availability ("will the integration
+# work?"), and that is the wrong question for an authentication source. An
+# anonymous bind is a working config, so the pair let a backup omit the password
+# and have the toggle written; the whole LDAP family is refused by prefix above
+# instead. _is_protected_setting_key runs first in _plan_settings, so leaving the
+# entry here would be dead code that reads like coverage.
+_COMPANION_CREDENTIALS = {
+    "prometheus_enabled": "prometheus_token",
+    "mqtt_enabled": "mqtt_password",
+    "ha_enabled": "ha_token",
+    "virtual_printer_enabled": "virtual_printer_access_code",
+}
+
+# Companion credentials a reader takes from the environment rather than from a
+# Settings row. ha_token is the only one: get_homeassistant_settings prefers
+# HA_TOKEN over the row, and auto-enables ha_enabled when HA_URL and HA_TOKEN are
+# both set, so an env-configured instance has a usable credential and no row.
+_COMPANION_CREDENTIAL_ENV = {"ha_token": "HA_TOKEN"}
+
+# The pairs above divide into two classes, because "did the *backup* carry a
+# usable credential?" does not mean the same thing for both.
+#
+# For the availability pairs it is the condition that stops the rule
+# over-refusing. An anonymous MQTT broker and an anonymous LDAP bind are working
+# configs, so a backup with an empty credential is describing something that
+# works, and refusing its toggle would be a false positive. Those pairs only
+# matter when the restore would produce a config weaker than *both* the backup
+# and the local instance.
+#
+# For the exposure pair it does not transfer. An empty prometheus_token removes
+# /api/v1/metrics' only gate (the route is on PUBLIC_API_ROUTES and its own
+# check is ``if token:``), so the exposure is a property of the toggle itself,
+# not of a downgrade relative to the backup: a backup taken on an instance that
+# enabled Prometheus *without* a token — the field is optional and defaults to
+# "" — is the more likely source of one, not the less. So an exposure toggle
+# skips this condition and is judged on local state alone.
+_COMPANION_EXPOSURE_TOGGLES = frozenset({"prometheus_enabled"})
+
+
+def _setting_value_is_true(value: object) -> bool:
+    """True if a settings *payload* value would be stored as "on".
+
+    Deliberately as narrow as ``api.routes.settings.setting_is_true``: a restore
+    writes ``str(value)`` verbatim and no reader in the codebase treats "1",
+    "on" or "yes" as on, so restoring one of those cannot switch anything on.
+    Bool-tolerant because a backup's JSON can carry a real boolean.
+    """
+    if isinstance(value, bool):
+        return value
+    if value is None:
+        return False
+    return str(value).strip().lower() == "true"
+
+
+def _is_usable_credential(value: object) -> bool:
+    """True if a credential value is present and not blank.
+
+    A present-but-*blank* ``prometheus_token`` row counts as unusable, because an
+    empty token is exactly the ``if token:`` hole the companion rule exists to
+    stop a restore from opening.
+    """
+    return value is not None and bool(str(value).strip())
+
+
+@dataclass(frozen=True)
+class _SettingsPlan:
+    """Which keys of a settings payload will not be written, and why.
+
+    Built once, before anything is added to the session, and shared by the
+    preview and the restore so the two cannot disagree about what a commit will
+    change. The companion bucket is why this needs a session at all: unlike the
+    two name-based buckets it depends on local database state.
+
+    The three buckets are disjoint — a key is classified once, in order.
+    """
+
+    blocked: tuple[str, ...] = ()
+    protected: tuple[str, ...] = ()
+    companion: tuple[str, ...] = ()
+
+    @property
+    def refused(self) -> frozenset[str]:
+        return frozenset(self.blocked) | frozenset(self.protected) | frozenset(self.companion)
+
+    @property
+    def refused_count(self) -> int:
+        return len(self.blocked) + len(self.protected) + len(self.companion)
+
+
+@dataclass(frozen=True)
+class _Detail:
+    """A preview caveat, as a translation code plus its English rendering.
+
+    Same contract as a note: the client translates ``code`` with ``params`` and
+    falls back to ``message``.
+    """
+
+    code: str
+    message: str
+    params: dict[str, str | int] = dataclasses_field(default_factory=dict)
+
+
+class _CategoryTally:
+    """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
+
+    def __init__(self) -> None:
+        self.restored = 0
+        self.skipped = 0
+        self.failed = 0
+        self.notes: list[dict] = []
+
+    def note(self, code: str, message: str, **params) -> None:
+        """Record a note as a translation code, its params and an English fallback.
+
+        Deduped on ``(code, params)`` rather than on the rendered text, which is
+        the same thing today but keeps two notes that differ only in a printer
+        name from collapsing into one. Bounded for the reason it always was: the
+        UI renders every note, so a large backup must not emit one per row.
+        """
+        if any(existing["code"] == code and existing["params"] == params for existing in self.notes):
+            return
+        if len(self.notes) >= 20:
+            return
+        self.notes.append({"code": code, "params": params, "message": message})
+
+    def as_dict(self) -> dict:
+        return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
+
+
+class GitHubRestoreService:
+    """Reads a backup repository and applies selected categories locally."""
+
+    def __init__(self) -> None:
+        self._running_restore: bool = False
+        self._progress: str | None = None
+        self._http_client: httpx.AsyncClient | None = None
+        # Guards the check-then-set on ``_running_restore``. Without it two
+        # concurrent POSTs can both observe False before either sets it.
+        self._lock = asyncio.Lock()
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._http_client is None or self._http_client.is_closed:
+            self._http_client = httpx.AsyncClient(timeout=60.0)
+        return self._http_client
+
+    @property
+    def is_running(self) -> bool:
+        return self._running_restore
+
+    @property
+    def progress(self) -> str | None:
+        return self._progress
+
+    # --- Repository reads --------------------------------------------------
+
+    async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
+        """List recent commits on the configured branch."""
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+        result = await backend.list_commits(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            branch=config.branch,
+            client=client,
+            limit=limit,
+        )
+        result["branch"] = config.branch
+        return result
+
+    async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str, dict | None]:
+        """Turn ``HEAD`` into a concrete commit SHA.
+
+        Done once up front so a preview and the restore that follows it act on
+        the same commit even if a scheduled backup lands in between.
+
+        The third element is the commit entry, when resolving already fetched
+        one. ``preview`` displays it, and taking it from here means the ``HEAD``
+        case — by far the common one — costs one ``list_commits`` call rather
+        than two.
+        """
+        if ref and ref.upper() != "HEAD":
+            return ref, "", None
+        result = await self.list_commits(config, limit=1)
+        if not result.get("success"):
+            return None, result.get("message") or "Could not read the backup repository", None
+        commits = result.get("commits") or []
+        if not commits:
+            return None, f"Branch '{config.branch}' has no commits to restore from", None
+        return commits[0]["sha"], "", commits[0]
+
+    async def _describe_commit(self, config: GitHubBackupConfig, resolved: str) -> dict | None:
+        """Find the display metadata for one commit SHA.
+
+        Two things used to leave ``commit: null`` in a preview, and the second is
+        the one that bit in practice:
+
+        * the commit is older than the 20 the picker lists, so it is not in the
+          scan at all — that is what ``get_commit`` is for;
+        * ``REF_PATTERN`` accepts a 7-character ref while providers return the
+          full 40, so an exact ``==`` never matched an abbreviated SHA *even when
+          the commit was in the window*. Hence the prefix comparison.
+
+        Best-effort throughout: this is a subject line and a date, so a failure
+        returns None and the preview renders without them rather than failing.
+        """
+        commits = (await self.list_commits(config, limit=20)).get("commits") or []
+        for entry in commits:
+            sha = entry.get("sha") or ""
+            if sha == resolved or sha.startswith(resolved) or resolved.startswith(sha):
+                return entry
+
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+        result = await backend.get_commit(
+            repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
+        )
+        return result.get("commit") if result.get("success") else None
+
+    def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
+        """Return the paths in ``available`` that belong to ``category``."""
+        if category == RestoreCategory.SETTINGS:
+            return [p for p in (SETTINGS_PATH,) if p in available]
+        if category == RestoreCategory.SPOOLS:
+            return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
+        if category == RestoreCategory.ARCHIVES:
+            return [p for p in (ARCHIVES_PATH,) if p in available]
+        if category == RestoreCategory.KPROFILES:
+            return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
+        return []
+
+    @staticmethod
+    def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
+        """Parse each fetched file, collecting paths that failed to parse."""
+        parsed: dict[str, object] = {}
+        bad: list[str] = []
+        for path, text in raw.items():
+            try:
+                parsed[path] = json.loads(text)
+            except (ValueError, TypeError):
+                bad.append(path)
+        return parsed, bad
+
+    @staticmethod
+    async def _plan_settings(db: AsyncSession, values: dict) -> _SettingsPlan:
+        """Classify every key of a settings payload into its refusal bucket.
+
+        Keys with an unusable name land in no bucket: they are the restore's
+        ``failed``, not a refusal, and the preview counts them because the run
+        will still report on them.
+
+        Reads local state, so it must run before anything is added to the
+        session — otherwise "does this instance already have a credential" would
+        see the restore's own writes.
+        """
+        blocked: list[str] = []
+        protected: list[str] = []
+        # Toggle -> credential for the pairs that survived the payload-only
+        # conditions and still need local state to judge.
+        candidates: dict[str, str] = {}
+
+        for key, value in values.items():
+            if not isinstance(key, str) or not key:
+                continue
+            if _is_blocked_setting_key(key):
+                blocked.append(key)
+                continue
+            if _is_protected_setting_key(key):
+                protected.append(key)
+                continue
+
+            credential = _COMPANION_CREDENTIALS.get(key)
+            if credential is None:
+                continue
+            # Turning something *off* is always safe to write.
+            if not _setting_value_is_true(value):
+                continue
+            # Expressed as the predicate rather than assumed, so the map cannot
+            # go quietly inert if _SECRET_KEY_HINTS is ever edited: a credential
+            # the restore is willing to write travels with its toggle.
+            if not _is_blocked_setting_key(credential):
+                continue
+            # The backup itself carried no credential here. For an availability
+            # pair that describes a working config — an anonymous MQTT broker and
+            # an anonymous LDAP bind both are (mqtt_relay.py and ldap_service.py
+            # pass empty credentials straight through) — so refusing the toggle
+            # would be a false positive. For an exposure pair a blank credential
+            # is the hole itself, so the condition is skipped and only local
+            # state decides. See _COMPANION_EXPOSURE_TOGGLES.
+            if key not in _COMPANION_EXPOSURE_TOGGLES and not _is_usable_credential(values.get(credential)):
+                continue
+            candidates[key] = credential
+
+        if not candidates:
+            return _SettingsPlan(blocked=tuple(blocked), protected=tuple(protected))
+
+        # One SELECT covering both halves of every candidate pair.
+        wanted = set(candidates) | set(candidates.values())
+        rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
+        local = {row.key: row.value for row in rows.scalars().all()}
+
+        companion: list[str] = []
+        for toggle, credential in candidates.items():
+            if _is_usable_credential(local.get(credential)):
+                continue
+            env_name = _COMPANION_CREDENTIAL_ENV.get(credential)
+            if env_name and _is_usable_credential(os.environ.get(env_name)):
+                continue
+            # Already on locally with no credential: the exposure pre-dates this
+            # restore, so refusing changes nothing and "left switched off" would
+            # be a lie.
+            if _setting_value_is_true(local.get(toggle)):
+                continue
+            companion.append(toggle)
+
+        return _SettingsPlan(
+            blocked=tuple(blocked),
+            protected=tuple(protected),
+            companion=tuple(companion),
+        )
+
+    async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
+        """Report which categories a commit contains, and how much is in each.
+
+        Takes a session because the settings count depends on local state — see
+        ``_plan_settings``. ``ref`` stays keyword-friendly for callers.
+        """
+        resolved, error, commit_info = await self._resolve_ref(config, ref)
+        if resolved is None:
+            return {"success": False, "message": error, "ref": ref, "categories": []}
+
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+
+        tree = await backend.list_tree(
+            repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
+        )
+        if not tree.get("success"):
+            return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
+        available: list[str] = tree.get("paths") or []
+
+        # One batched read covers metadata plus every category payload.
+        wanted = [METADATA_PATH] if METADATA_PATH in available else []
+        for category in RestoreCategory:
+            wanted.extend(self._category_paths(category, available))
+
+        fetched = await backend.fetch_files(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            ref=resolved,
+            paths=wanted,
+            client=client,
+            # The listing above already built this map; without it the GitHub
+            # family would GET the same recursive tree a second time.
+            blob_shas=tree.get("blob_shas") or None,
+        )
+        if not fetched.get("success"):
+            return {
+                "success": False,
+                "message": fetched.get("message") or "Could not read the commit contents",
+                "ref": resolved,
+            }
+        parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
+
+        metadata = parsed.get(METADATA_PATH)
+        metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
+
+        categories = []
+        for category in RestoreCategory:
+            paths = self._category_paths(category, available)
+            if not paths:
+                categories.append(
+                    self._category_entry(category, False, 0, _Detail("notPresent", "Not present in this backup commit"))
+                )
+                continue
+            unreadable = [p for p in paths if p in bad_paths]
+            if unreadable:
+                joined = ", ".join(unreadable)
+                categories.append(
+                    self._category_entry(
+                        category,
+                        False,
+                        0,
+                        _Detail("unreadableJson", f"Unreadable JSON: {joined}", {"paths": joined}),
+                    )
+                )
+                continue
+            count, detail = await self._count_items(db, category, parsed)
+            categories.append(self._category_entry(category, True, count, detail))
+
+        if commit_info is None:
+            commit_info = await self._describe_commit(config, resolved)
+
+        return {
+            "success": True,
+            "message": "OK",
+            "ref": resolved,
+            "commit": commit_info,
+            "metadata_version": metadata_version,
+            "categories": categories,
+        }
+
+    @staticmethod
+    def _category_entry(category: RestoreCategory, available: bool, item_count: int, detail: _Detail | None) -> dict:
+        """Shape one ``GitHubRestorePreviewCategory``, translated detail included."""
+        return {
+            "category": category,
+            "available": available,
+            "item_count": item_count,
+            "detail": detail.message if detail else None,
+            "detail_code": detail.code if detail else None,
+            "detail_params": detail.params if detail else {},
+        }
+
+    async def _count_items(
+        self, db: AsyncSession, category: RestoreCategory, parsed: dict
+    ) -> tuple[int, _Detail | None]:
+        """Count restorable items for ``category`` and describe any caveat."""
+        if category == RestoreCategory.SETTINGS:
+            payload = parsed.get(SETTINGS_PATH)
+            values = payload.get("settings") if isinstance(payload, dict) else None
+            if not isinstance(values, dict):
+                return 0, _Detail("settingsNoPayload", "No settings in payload")
+            # Every refusal is subtracted so the count matches what the restore
+            # actually writes. The wording calls out the credential ones (what a
+            # user might expect to come back) and the companion ones (a
+            # behaviour change worth explaining before it happens); the auth
+            # policy keys stay unmentioned on purpose.
+            plan = await self._plan_settings(db, values)
+            detail = None
+            if plan.companion and not plan.blocked:
+                # An exposure toggle becomes a candidate whether or not the
+                # backup carried its credential, so this commit can refuse a
+                # switch without having a single credential-like key to skip —
+                # "0 credential-like key(s) will be skipped" would read as noise.
+                detail = _Detail(
+                    "settingsCompanionOnlyWillSkip",
+                    f"{len(plan.companion)} switch(es) will be left off — the credential each one needs "
+                    "cannot be restored from a backup",
+                    {"companion": len(plan.companion)},
+                )
+            elif plan.companion:
+                detail = _Detail(
+                    "settingsCompanionWillSkip",
+                    f"{len(plan.blocked)} credential-like key(s) will be skipped, and "
+                    f"{len(plan.companion)} switch(es) that depend on them will be left off",
+                    {"count": len(plan.blocked), "companion": len(plan.companion)},
+                )
+            elif plan.blocked:
+                detail = _Detail(
+                    "settingsCredentialsWillSkip",
+                    f"{len(plan.blocked)} credential-like keys will be skipped",
+                    {"count": len(plan.blocked)},
+                )
+            return len(values) - plan.refused_count, detail
+
+        if category == RestoreCategory.SPOOLS:
+            payload = parsed.get(SPOOLS_PATH)
+            spools = payload.get("spools") if isinstance(payload, dict) else None
+            usage_payload = parsed.get(SPOOL_USAGE_PATH)
+            usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+            # Usage records are counted here, not just described in the detail:
+            # _restore_spool_usage increments this category's tally, so counting
+            # only the spools broke restored + skipped + failed == item_count —
+            # the invariant the settings count is careful to hold. The detail
+            # breaks the total down rather than adding to it.
+            count = len(spools) if isinstance(spools, list) else 0
+            detail = None
+            if isinstance(usage, list) and usage:
+                count += len(usage)
+                detail = _Detail("spoolsUsageCount", f"including {len(usage)} usage records", {"count": len(usage)})
+            return count, detail
+
+        if category == RestoreCategory.ARCHIVES:
+            payload = parsed.get(ARCHIVES_PATH)
+            archives = payload.get("archives") if isinstance(payload, dict) else None
+            count = len(archives) if isinstance(archives, list) else 0
+            return count, _Detail(
+                "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup"
+            )
+
+        if category == RestoreCategory.KPROFILES:
+            total = 0
+            serials = set()
+            for path, payload in parsed.items():
+                match = _KPROFILE_PATH_RE.match(path)
+                if not match or not isinstance(payload, dict):
+                    continue
+                serials.add(match.group(1))
+                profiles = payload.get("profiles")
+                if isinstance(profiles, list):
+                    total += len(profiles)
+            detail = None
+            if serials:
+                detail = _Detail("kprofilesPrinterCount", f"across {len(serials)} printer(s)", {"count": len(serials)})
+            return total, detail
+
+        return 0, None
+
+    # --- Restore -----------------------------------------------------------
+
+    async def run_restore(
+        self,
+        config_id: int,
+        ref: str,
+        categories: list[RestoreCategory],
+        overwrite_existing: bool = False,
+    ) -> dict:
+        """Apply selected categories from one backup commit."""
+        # Import locally to avoid a module-level cycle: the backup service takes
+        # the mirror-image lock against us.
+        from backend.app.services.github_backup import github_backup_service
+
+        # The lock serialises two concurrent restores; the backup side has no
+        # lock of its own, and relies on this region staying await-free after the
+        # acquisition. Both flags are plain bools on one event loop, so with no
+        # suspension point between the two reads and the write, the loop cannot
+        # slip github_backup.run_backup's mirror-image check in between. Adding an
+        # `await` below the acquisition and above `self._running_restore = True`
+        # would let a backup and a restore run at once.
+        async with self._lock:
+            if self._running_restore:
+                return {"success": False, "message": "A restore is already running", "results": {}}
+            if github_backup_service.is_running:
+                return {
+                    "success": False,
+                    "message": "A backup is currently running. Wait for it to finish before restoring.",
+                    "results": {},
+                }
+            self._running_restore = True
+
+        log_id = None
+        try:
+            async with async_session() as db:
+                result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
+                config = result.scalar_one_or_none()
+                if not config:
+                    return {"success": False, "message": "Configuration not found", "results": {}}
+
+                self._progress = "Resolving commit..."
+                resolved, error, _ = await self._resolve_ref(config, ref)
+                if resolved is None:
+                    return {"success": False, "message": error, "results": {}}
+
+                log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
+                db.add(log)
+                await db.commit()
+                await db.refresh(log)
+                log_id = log.id
+
+                # Owned here rather than by _apply so the failure path can see
+                # the categories that were already committed when the raise
+                # happened. _apply records a tally only after its category's
+                # commit, so every entry present is on disk.
+                results: dict[str, _CategoryTally] = {}
+                try:
+                    payload, error = await self._read_categories(config, resolved, categories)
+                    if error:
+                        raise RuntimeError(error)
+
+                    settings_keys_written: set[str] = set()
+                    await self._apply(
+                        db, payload, categories, overwrite_existing, settings_keys_written, results=results
+                    )
+                    await db.commit()
+
+                    # After the commit: this reconnects the relay, which is not
+                    # something to do on values that could still roll back.
+                    settings_tally = results.get(RestoreCategory.SETTINGS.value)
+                    if settings_tally is not None:
+                        self._progress = "Reconnecting the MQTT relay..."
+                        await self._reconfigure_mqtt_relay(db, settings_keys_written, settings_tally)
+
+                    total_restored = sum(tally.restored for tally in results.values())
+                    any_failed = any(tally.failed for tally in results.values())
+
+                    log.status = "failed" if any_failed and total_restored == 0 else "success"
+                    log.completed_at = datetime.now(timezone.utc)
+                    log.files_changed = total_restored
+                    if any_failed:
+                        log.error_message = "Some items could not be restored — see the restore result for detail"
+                    await db.commit()
+
+                    return {
+                        "success": True,
+                        "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
+                        "log_id": log_id,
+                        "ref": resolved,
+                        "results": {name: tally.as_dict() for name, tally in results.items()},
+                    }
+
+                except Exception as e:
+                    # Rolls back the category that was mid-flight. Every category
+                    # already in ``results`` committed as it finished (see
+                    # _apply), so those rows survive this — and reporting an
+                    # empty result over them would tell the user nothing was
+                    # restored while their archives and spools are on disk.
+                    logger.exception("Restore failed for config %s ref %s", config_id, resolved)
+                    await db.rollback()
+                    committed = sum(tally.restored for tally in results.values())
+                    log.status = "failed"
+                    log.completed_at = datetime.now(timezone.utc)
+                    log.files_changed = committed
+                    log.error_message = str(e)[:1000]
+                    await db.commit()
+                    return {
+                        "success": False,
+                        "message": str(e),
+                        "log_id": log_id,
+                        "ref": resolved,
+                        "results": {name: tally.as_dict() for name, tally in results.items()},
+                    }
+
+        finally:
+            self._running_restore = False
+            self._progress = None
+
+    async def _read_categories(
+        self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
+    ) -> tuple[dict, str]:
+        """Fetch and parse just the files the requested categories need."""
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+
+        self._progress = "Listing backup contents..."
+        tree = await backend.list_tree(
+            repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
+        )
+        if not tree.get("success"):
+            return {}, tree.get("message") or "Could not list the commit"
+        available: list[str] = tree.get("paths") or []
+
+        wanted: list[str] = []
+        for category in categories:
+            wanted.extend(self._category_paths(category, available))
+        if not wanted:
+            return {}, "None of the selected categories are present in that commit"
+
+        self._progress = "Downloading backup files..."
+        fetched = await backend.fetch_files(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            ref=ref,
+            paths=wanted,
+            client=client,
+            blob_shas=tree.get("blob_shas") or None,
+        )
+        if not fetched.get("success"):
+            return {}, fetched.get("message") or "Could not read the commit contents"
+
+        parsed, bad = self._parse_json_files(fetched.get("files") or {})
+        if bad:
+            return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
+        return parsed, ""
+
+    async def _apply(
+        self,
+        db: AsyncSession,
+        payload: dict,
+        categories: list[RestoreCategory],
+        overwrite: bool,
+        settings_keys_written: set[str] | None = None,
+        results: dict[str, _CategoryTally] | None = None,
+    ) -> dict[str, _CategoryTally]:
+        """Apply categories in dependency order and return per-category tallies.
+
+        ``settings_keys_written``, if given, collects the setting keys actually
+        written, for the caller's post-commit side effects (see
+        ``_reconfigure_mqtt_relay``).
+
+        ``results``, if given, is the caller's own dict rather than a fresh one.
+        Each category is committed before it is recorded there, so on a raise
+        the caller can report exactly what is already on disk — see the
+        per-category commit below.
+        """
+        results = {} if results is None else results
+        archive_id_map: dict[int, int] = {}
+
+        # Every database category commits before the next one starts, and only
+        # then is its tally recorded. Two reasons:
+        #
+        #  * SQLite has one writer. Each category is a long run of one SELECT per
+        #    row or per key — _find_archive, _find_spool, the usage dedupe,
+        #    _restore_settings — interleaved with autoflushed INSERTs, all inside
+        #    the open write transaction. A few thousand archives plus a full
+        #    usage history plausibly passes the 15 s busy_timeout
+        #    (core/database.py), at which point every concurrent writer in the
+        #    app fails with "database is locked". This is the same hold the
+        #    K-profile phase had, arriving by volume rather than by awaiting a
+        #    sulking printer.
+        #  * The ordering tolerates it: the only cross-category state is
+        #    archive_id_map and spool_id_map, both plain dicts in memory, and
+        #    the session is expire_on_commit=False so nothing reloads.
+        #
+        # The cost is that a later failure no longer rolls back an earlier
+        # category — which is why the tally is recorded after the commit, so
+        # run_restore's failure path reports the rows that really landed instead
+        # of claiming nothing was restored.
+
+        # Archives first: spool usage history references archive_id.
+        if RestoreCategory.ARCHIVES in categories:
+            self._progress = "Restoring print archives..."
+            tally = _CategoryTally()
+            await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
+            await db.commit()
+            results[RestoreCategory.ARCHIVES.value] = tally
+
+        if RestoreCategory.SPOOLS in categories:
+            self._progress = "Restoring spool inventory..."
+            tally = _CategoryTally()
+            await self._restore_spools(
+                db,
+                payload.get(SPOOLS_PATH),
+                payload.get(SPOOL_USAGE_PATH),
+                overwrite,
+                tally,
+                archive_id_map,
+            )
+            await db.commit()
+            results[RestoreCategory.SPOOLS.value] = tally
+
+        if RestoreCategory.SETTINGS in categories:
+            self._progress = "Restoring app settings..."
+            tally = _CategoryTally()
+            await self._restore_settings(
+                db, payload.get(SETTINGS_PATH), overwrite, tally, keys_written=settings_keys_written
+            )
+            await db.commit()
+            results[RestoreCategory.SETTINGS.value] = tally
+
+        # Last, because it leaves the database and publishes over MQTT.
+        if RestoreCategory.KPROFILES in categories:
+            # The database categories are already committed by the loop above,
+            # and that is load-bearing here rather than tidiness:
+            # _restore_kprofiles awaits get_kprofiles per printer per nozzle,
+            # which is timeout=5.0 * max_retries=3, i.e. up to ~15 s each against
+            # an unresponsive printer. Holding SQLite's writer across that would
+            # pass the 15 s busy_timeout on a farm with a couple of sulking
+            # printers.
+            #
+            # The cost is that a K-profile failure no longer rolls back the
+            # categories that already succeeded. That is the correct trade
+            # anyway: extrusion_cali_set has left for the printer by then and
+            # cannot be rolled back either, so a rollback would only have made
+            # the database disagree with the hardware.
+
+            self._progress = "Sending K-profiles to printers..."
+            tally = _CategoryTally()
+            try:
+                await self._restore_kprofiles(db, payload, tally)
+            except Exception as e:
+                # Everything above is committed and cannot be un-committed, so
+                # letting this reach run_restore's handler would report
+                # "nothing was restored" over durable archive, spool and
+                # settings rows — and skip the post-commit MQTT reconfigure,
+                # leaving the relay on the pre-restore broker. The K-profile
+                # phase is the last thing that runs, so containing it here is
+                # what keeps the result honest about what actually landed.
+                logger.exception("The K-profile step failed after the database categories were committed")
+                # Discards the phase's own read transaction. The rows above went
+                # in at the commit two statements up; this only stops a session
+                # left in a failed state by a database error from turning the
+                # caller's commit into that same false report.
+                await db.rollback()
+                outstanding = self._kprofile_profile_count(
+                    content for path, content in payload.items() if _KPROFILE_PATH_RE.match(path)
+                )
+                outstanding -= tally.restored + tally.skipped + tally.failed
+                tally.failed += max(outstanding, 0)
+                tally.note(
+                    "kprofilesStepFailed",
+                    f"The K-profile step could not be completed: {e}",
+                    reason=str(e)[:200],
+                )
+            results[RestoreCategory.KPROFILES.value] = tally
+
+        return results
+
+    # --- Per-category appliers --------------------------------------------
+
+    async def _restore_archives(
+        self,
+        db: AsyncSession,
+        payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        id_map: dict[int, int],
+    ) -> None:
+        archives = payload.get("archives") if isinstance(payload, dict) else None
+        if not isinstance(archives, list):
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        valid_projects = set((await db.execute(select(Project.id))).scalars().all())
+        # Ownership decides visibility, not just attribution: an archive with a
+        # NULL created_by_id is a 404 to every caller without archives:read_all
+        # (_ensure_archive_visible fails closed on it) and never appears in the
+        # ownership-scoped list queries. Hoisted like the two above.
+        #
+        # username is the natural key and wins, per the module's rule at the top
+        # of the file; created_by_id is the fallback for a pre-#2656 commit that
+        # carries no username. That ordering is what makes restoring onto a
+        # rebuilt instance safe: the users table renumbers there, so a live id
+        # can land on a different person, and the id path alone cannot tell that
+        # from a correct match. Resolving on the name instead means the one case
+        # it cannot resolve — a user renamed since the backup — falls through to
+        # ownerless-with-a-note below rather than misattributing in silence.
+        users = (await db.execute(select(User.id, User.username))).all()
+        valid_users = {user_id for user_id, _ in users}
+        users_by_name = {username: user_id for user_id, username in users}
+
+        # Only metadata is backed up, never the 3MF/thumbnail bytes, and
+        # print_archives.file_path is NOT NULL — so inserted rows get an empty
+        # path and are history-only. Say so once rather than per row.
+        warned_files = False
+
+        for entry in archives:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            started_at = _parse_dt(entry.get("started_at"))
+            existing = await self._find_archive(db, entry, started_at)
+
+            fields = {
+                "print_name": entry.get("print_name"),
+                "print_time_seconds": entry.get("print_time_seconds"),
+                "filament_used_grams": entry.get("filament_used_grams"),
+                "filament_type": entry.get("filament_type"),
+                "filament_color": entry.get("filament_color"),
+                "layer_height": entry.get("layer_height"),
+                "total_layers": entry.get("total_layers"),
+                "nozzle_diameter": entry.get("nozzle_diameter"),
+                "bed_temperature": entry.get("bed_temperature"),
+                "nozzle_temperature": entry.get("nozzle_temperature"),
+                "sliced_for_model": entry.get("sliced_for_model"),
+                "status": entry.get("status") or "completed",
+                "started_at": started_at,
+                "completed_at": _parse_dt(entry.get("completed_at")),
+                "makerworld_url": entry.get("makerworld_url"),
+                "designer": entry.get("designer"),
+                "external_url": entry.get("external_url"),
+                "is_favorite": bool(entry.get("is_favorite")),
+                "tags": entry.get("tags"),
+                "notes": entry.get("notes"),
+                "cost": entry.get("cost"),
+                "failure_reason": entry.get("failure_reason"),
+                "quantity": entry.get("quantity") or 1,
+                "energy_kwh": entry.get("energy_kwh"),
+                "energy_cost": entry.get("energy_cost"),
+            }
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                tally.note(
+                    "archivesPrinterMissing", "Some archives referenced printers that no longer exist — link cleared"
+                )
+                printer_id = None
+            project_id = entry.get("project_id")
+            if project_id is not None and project_id not in valid_projects:
+                tally.note(
+                    "archivesProjectMissing", "Some archives referenced projects that no longer exist — link cleared"
+                )
+                project_id = None
+            fields["printer_id"] = printer_id
+            fields["project_id"] = project_id
+
+            # The ownership pair and deleted_at are the late arrivals — a backup
+            # commit taken before the collector wrote them carries neither key.
+            # Absent is NOT the same as null here, because the overwrite branch
+            # below is a blanket setattr: treating a missing key as None would
+            # write NULL over a live owner (_ensure_archive_visible then 404s the
+            # archive for the very user who owns it — the failure carrying the
+            # column was added to fix) and silently un-delete a row the user
+            # deleted. So only carry a column the backup actually knows about;
+            # on insert, an absent key just takes the model default.
+            # An owner the backup names but this instance cannot resolve is the
+            # same epistemic state as an absent key — we do not know who owns
+            # this archive — so it takes the same action: the column is left out
+            # of ``fields`` entirely rather than set to None. Writing NULL there
+            # would take the owner away from a local archive that has a perfectly
+            # good one, which is the 404-for-its-own-owner failure this column is
+            # carried across to fix, and it would do it on the overwrite path
+            # where there is a local answer to keep. On insert there is nothing
+            # to keep, so the row takes the model default and lands ownerless,
+            # which is what the note says.
+            owner_cleared = False
+            backup_username = entry.get("created_by_username")
+            if isinstance(backup_username, str) and backup_username:
+                # The natural-key path. A miss here is a user renamed or deleted
+                # since the backup, and there is nothing else to resolve on: the
+                # id alongside it is from the source instance's numbering, so
+                # trusting it is exactly the misattribution the name is here to
+                # prevent. Not a reason to fail the row — the archive is still
+                # worth having, and an admin can reassign it — but said out loud
+                # on insert, because an ownerless archive is not silent-safe.
+                created_by_id = users_by_name.get(backup_username)
+                if created_by_id is None:
+                    if existing is None:
+                        tally.note(
+                            "archivesOwnerUnmatched",
+                            "Some archives name an owner this instance does not have — owner cleared rather than "
+                            "guessed from the backup's user id, so they are visible only to users with the "
+                            "archives:read_all permission until an admin reassigns them",
+                        )
+                        owner_cleared = True
+                else:
+                    fields["created_by_id"] = created_by_id
+            elif "created_by_id" in entry:
+                # Fallback for a commit taken before the collector recorded the
+                # username. Validated rather than trusted, so a *stale* id is
+                # dropped instead of pointing somewhere wrong; a live id
+                # belonging to a different person on a rebuilt instance is the
+                # case this path cannot see, and is why the branch above exists.
+                # An explicit null is not a miss — the backup is saying the
+                # archive had no owner — so it is written, and overwrite keeps
+                # meaning "make the local row match the backup".
+                created_by_id = entry.get("created_by_id")
+                if created_by_id is not None and created_by_id not in valid_users:
+                    if existing is None:
+                        tally.note(
+                            "archivesOwnerCleared",
+                            "Some archives referenced users that no longer exist — owner cleared, so they are "
+                            "visible only to users with the archives:read_all permission until an admin "
+                            "reassigns them",
+                        )
+                        owner_cleared = True
+                else:
+                    fields["created_by_id"] = created_by_id
+            if "deleted_at" in entry:
+                # A soft-deleted archive is still in the backup (its row is kept
+                # so stats keep counting it), so carry the flag across or the
+                # restore turns something the user deleted back into a visible
+                # archive.
+                fields["deleted_at"] = _parse_dt(entry.get("deleted_at"))
+
+            if existing is not None:
+                if old_id is not None:
+                    id_map[old_id] = existing.id
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                # Overwrite means "make the local row match the backup", which
+                # includes un-deleting one the user deleted after the backup was
+                # taken. Legitimate, but not obvious from a restored/skipped
+                # count, so say it.
+                if existing.deleted_at is not None and "deleted_at" in fields and fields["deleted_at"] is None:
+                    tally.note(
+                        "archivesUndeleted",
+                        "Archive(s) deleted since the backup are visible again — overwrite was on",
+                    )
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+                continue
+
+            if not warned_files:
+                tally.note(
+                    "archivesMetadataOnly",
+                    "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup",
+                )
+                warned_files = True
+
+            # Insert-only, and the mirror of the rule above: an owner the backup
+            # cannot tell us is never written, so on overwrite the local one
+            # survives — but there is no local row here to fall back on, so the
+            # archive lands ownerless, a 404 for everyone without
+            # archives:read_all. Three ways to get here: a commit taken before
+            # the collector recorded the column (every pre-#2656 backup), an
+            # archive that genuinely had no owner on the source instance, or one
+            # whose owner this instance cannot resolve. All restore fine and all
+            # were silent, so the tally said "N archives restored" while the user
+            # who asked for them saw none. The unresolved cases above already
+            # said their piece; don't say it twice for the same row.
+            if fields.get("created_by_id") is None and not owner_cleared:
+                tally.note(
+                    "archivesOwnerUnknown",
+                    "Some archives were restored without an owner — this backup does not record one, so they "
+                    "are visible only to users with the archives:read_all permission until an admin reassigns "
+                    "them",
+                )
+
+            row = PrintArchive(
+                filename=entry.get("filename") or "restored-from-backup",
+                file_path="",
+                file_size=entry.get("file_size") or 0,
+                content_hash=entry.get("content_hash"),
+                **fields,
+            )
+            created_at = _parse_dt(entry.get("created_at"))
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            await db.flush()
+            if old_id is not None:
+                id_map[old_id] = row.id
+            tally.restored += 1
+
+    async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
+        """Match a backed-up archive to a local row by natural key.
+
+        ``started_at`` is nullable and genuinely NULL for a whole class of rows —
+        the re-slice path in ``library.py`` constructs ``PrintArchive`` without
+        one — so it cannot be *required* by the key. It narrows the match instead:
+        a backed-up row with no ``started_at`` matches a local row that has none
+        either. Requiring it meant those archives never matched, so each restore
+        re-inserted them as duplicates and overwrite mode could never update them.
+
+        ``content_hash`` identifies the sliced file on its own, which is why it is
+        the branch allowed to run without a ``started_at``; ``filename`` is too
+        weak for that (re-slices share it) and still requires one. Two backed-up
+        rows sharing a hash *and* having no ``started_at`` are indistinguishable
+        in the backup, so they collapse onto one local row — better than
+        duplicating both on every restore.
+
+        Soft-deleted rows are matched deliberately: there is no ``deleted_at``
+        filter here because the row still exists, and matching it is what stops a
+        restore inserting a live duplicate of an archive the user has deleted.
+        """
+        started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
+
+        content_hash = entry.get("content_hash")
+        if content_hash:
+            result = await db.execute(
+                select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
+            )
+            row = result.scalars().first()
+            if row is not None:
+                return row
+
+        filename = entry.get("filename")
+        if filename and started_at:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
+            return result.scalars().first()
+        return None
+
+    async def _restore_spools(
+        self,
+        db: AsyncSession,
+        inventory,
+        usage_payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        archive_id_map: dict[int, int],
+    ) -> None:
+        spools = inventory.get("spools") if isinstance(inventory, dict) else None
+        if not isinstance(spools, list):
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        spool_id_map: dict[int, int] = {}
+        tags_kept = 0
+
+        for entry in spools:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            existing, matched_on = await self._find_spool(db, entry)
+
+            fields = {
+                "material": entry.get("material") or "PLA",
+                "subtype": entry.get("subtype"),
+                "color_name": entry.get("color_name"),
+                "rgba": entry.get("rgba"),
+                "brand": entry.get("brand"),
+                "label_weight": entry.get("label_weight") or 1000,
+                "core_weight": entry.get("core_weight") or 250,
+                "weight_used": entry.get("weight_used") or 0,
+                "weight_locked": bool(entry.get("weight_locked")),
+                "slicer_filament": entry.get("slicer_filament"),
+                "slicer_filament_name": entry.get("slicer_filament_name"),
+                "nozzle_temp_min": entry.get("nozzle_temp_min"),
+                "nozzle_temp_max": entry.get("nozzle_temp_max"),
+                "note": entry.get("note"),
+                "cost_per_kg": entry.get("cost_per_kg"),
+                "tag_uid": entry.get("tag_uid"),
+                "tray_uuid": entry.get("tray_uuid"),
+                "data_origin": entry.get("data_origin"),
+                "tag_type": entry.get("tag_type"),
+                "archived_at": _parse_dt(entry.get("archived_at")),
+            }
+
+            if existing is not None:
+                if old_id is not None:
+                    spool_id_map[old_id] = existing.id
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                tags_kept += await self._guard_tag_overwrite(db, existing, fields, matched_on)
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+                continue
+
+            row = Spool(**fields)
+            # Carry the original created_at across. Without it the row would be
+            # stamped "now", and the composite fallback in _find_spool (which
+            # keys on created_at) would miss on a second restore and insert a
+            # duplicate instead of matching.
+            created_at = _parse_dt(entry.get("created_at"))
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            await db.flush()
+            if old_id is not None:
+                spool_id_map[old_id] = row.id
+            tally.restored += 1
+
+        if tags_kept:
+            tally.note(
+                "spoolTagKept",
+                f"{tags_kept} spool tag(s) left as they are — the backup would have cleared a tag that "
+                "has since been scanned, or moved one onto a second spool.",
+                count=tags_kept,
+            )
+
+        await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
+
+    async def _find_spool(self, db: AsyncSession, entry: dict) -> tuple[Spool | None, str | None]:
+        """Match a backed-up spool to a local row, and say which key matched.
+
+        Physical identity first (an RFID/Bambu tag is the spool), then a
+        descriptive composite including ``created_at`` so two otherwise
+        identical spools added at different times stay distinct.
+
+        The second element names the column that matched — ``"tag_uid"``,
+        ``"tray_uuid"`` or ``None`` for the composite. ``_guard_tag_overwrite``
+        needs it: the matched column holds the incoming value by definition, so
+        it is the *other* one that overwrite can corrupt.
+        """
+        tag_uid = entry.get("tag_uid")
+        if tag_uid:
+            result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
+            row = result.scalars().first()
+            if row is not None:
+                return row, "tag_uid"
+
+        tray_uuid = entry.get("tray_uuid")
+        if tray_uuid:
+            result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
+            row = result.scalars().first()
+            if row is not None:
+                return row, "tray_uuid"
+
+        created_at = _parse_dt(entry.get("created_at"))
+        if created_at is None:
+            return None, None
+        # created_at is filtered in Python, not here — see _created_at_matches.
+        result = await db.execute(
+            select(Spool).where(
+                Spool.material == (entry.get("material") or "PLA"),
+                Spool.brand == entry.get("brand"),
+                Spool.subtype == entry.get("subtype"),
+                Spool.color_name == entry.get("color_name"),
+            )
+        )
+        for row in result.scalars():
+            if _created_at_matches(row, created_at):
+                return row, None
+        return None, None
+
+    @staticmethod
+    async def _guard_tag_overwrite(db: AsyncSession, existing: Spool, fields: dict, matched_on: str | None) -> int:
+        """Remove tag columns from ``fields`` that an overwrite would corrupt.
+
+        ``tag_uid`` and ``tray_uuid`` are both in ``fields`` and overwrite is a
+        blanket ``setattr`` loop, so a spool matched on one key gets the backup's
+        *other* key written onto it. Neither column has a unique constraint
+        (``models/spool.py``, and no unique index in the migrations), so nothing
+        errors — a duplicate tag simply appears, after which ``_find_spool``'s
+        ``.first()`` is non-deterministic and an AMS tag lookup resolves to an
+        arbitrary one of the two spools. The same loop can also *clear* a tag the
+        user has scanned since the backup was taken, when the backup entry holds
+        ``None``.
+
+        Two refusals, and the row is otherwise overwritten as normal:
+
+        * the incoming value is empty and the local row has one — the backup
+          predates the scan, so the local tag is the newer fact;
+        * the incoming value is already held by a different local spool — writing
+          it would create the duplicate described above.
+
+        Returns how many columns were left alone, so the caller can say so in the
+        tally rather than doing it silently.
+        """
+        kept = 0
+        for column in ("tag_uid", "tray_uuid"):
+            # The column we matched on already holds the incoming value.
+            if column == matched_on:
+                continue
+
+            incoming = fields.get(column)
+            current = getattr(existing, column)
+            if incoming == current:
+                continue
+
+            if not incoming:
+                if current:
+                    fields.pop(column)
+                    kept += 1
+                continue
+
+            clash = await db.execute(
+                select(Spool.id).where(getattr(Spool, column) == incoming, Spool.id != existing.id)
+            )
+            if clash.scalars().first() is not None:
+                fields.pop(column)
+                kept += 1
+        return kept
+
+    async def _restore_spool_usage(
+        self,
+        db: AsyncSession,
+        usage_payload,
+        tally: _CategoryTally,
+        spool_id_map: dict[int, int],
+        archive_id_map: dict[int, int],
+    ) -> None:
+        usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+        if not isinstance(usage, list) or not usage:
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        unresolved = 0
+        unlinked_archives = 0
+
+        for entry in usage:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_spool_id = entry.get("spool_id")
+            spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
+            if spool_id is None:
+                # The parent spool never made it into the map: the backup's spool
+                # list didn't include it, or its entry carried no integer id. A
+                # spool that was merely *skipped* (matched locally, overwrite off)
+                # is mapped a few lines up in _restore_spools, so it never lands
+                # here — which is why the note below offers no remedy.
+                unresolved += 1
+                tally.skipped += 1
+                continue
+
+            created_at = _parse_dt(entry.get("created_at"))
+            # Usage history has no natural key of its own, so dedupe on the
+            # tuple that makes a consumption event unique in practice. As in
+            # _find_spool, created_at is compared in Python — see
+            # _created_at_matches. An entry carrying no created_at at all
+            # cannot be recognised and is re-inserted, which is what the
+            # IS NULL comparison this replaced did too: the column is
+            # non-nullable, so it never matched either.
+            existing = await db.execute(
+                select(SpoolUsageHistory).where(
+                    SpoolUsageHistory.spool_id == spool_id,
+                    SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
+                    SpoolUsageHistory.print_name == entry.get("print_name"),
+                )
+            )
+            if any(_created_at_matches(row, created_at) for row in existing.scalars()):
+                tally.skipped += 1
+                continue
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                printer_id = None
+
+            old_archive_id = entry.get("archive_id")
+            archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
+            if archive_id is None and isinstance(old_archive_id, int):
+                # Restoring spools without archives leaves archive_id_map empty,
+                # so every "this print consumed that spool" link is dropped — the
+                # local archive may well exist, but its payload wasn't fetched,
+                # so there is no natural key here to match it on. Nor is it
+                # repairable by a later archives-only restore: the dedupe key
+                # above doesn't include archive_id, so these rows are recognised
+                # as already-present and skipped. Worth telling the user while
+                # they can still redo the run with both categories ticked.
+                unlinked_archives += 1
+
+            row = SpoolUsageHistory(
+                spool_id=spool_id,
+                printer_id=printer_id,
+                print_name=entry.get("print_name"),
+                archive_id=archive_id,
+                weight_used=entry.get("weight_used") or 0,
+                percent_used=entry.get("percent_used") or 0,
+                status=entry.get("status") or "completed",
+                cost=entry.get("cost"),
+            )
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            tally.restored += 1
+
+        if unresolved:
+            tally.note(
+                "spoolUsageUnresolved",
+                f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
+                "spool list, so there is nothing to attach them to.",
+                count=unresolved,
+            )
+        if unlinked_archives:
+            tally.note(
+                "spoolUsageUnlinked",
+                f"{unlinked_archives} usage record(s) restored without their print-history link — "
+                "select Print archives alongside Spool inventory to keep it.",
+                count=unlinked_archives,
+            )
+
+    async def _restore_settings(
+        self,
+        db: AsyncSession,
+        payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        keys_written: set[str] | None = None,
+    ) -> None:
+        values = payload.get("settings") if isinstance(payload, dict) else None
+        if not isinstance(values, dict):
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        # Planned before the first write, so the companion rule reads genuinely
+        # pre-restore local state, and so the preview and this run classify the
+        # payload identically.
+        plan = await self._plan_settings(db, values)
+        refused = plan.refused
+
+        for key, value in values.items():
+            if not isinstance(key, str) or not key:
+                tally.failed += 1
+                continue
+            if key in refused:
+                # Refusals are reported in the notes and nowhere else. They are
+                # already outside the preview's item count, and the preview is
+                # the number the user was shown, so counting them here would
+                # make restored + skipped + failed exceed it. The two skips
+                # below stay counted because they depend on this run's flags,
+                # which the preview cannot see.
+                continue
+            if value is None:
+                tally.skipped += 1
+                continue
+
+            result = await db.execute(select(Settings).where(Settings.key == key))
+            existing = result.scalar_one_or_none()
+            if existing is not None:
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                existing.value = str(value)
+                tally.restored += 1
+                if keys_written is not None:
+                    keys_written.add(key)
+                continue
+
+            db.add(Settings(key=key, value=str(value)))
+            tally.restored += 1
+            if keys_written is not None:
+                keys_written.add(key)
+
+        if plan.blocked:
+            tally.note(
+                "settingsCredentialsSkipped",
+                f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually",
+                count=len(plan.blocked),
+            )
+        if plan.protected:
+            tally.note(
+                "settingsAuthSkipped",
+                f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > "
+                "Authentication so the lockout checks still run",
+                count=len(plan.protected),
+            )
+        if plan.companion:
+            keys = ", ".join(sorted(plan.companion))
+            tally.note(
+                "settingsCompanionSkipped",
+                f"{keys} left switched off — the credential each one needs cannot be restored from a "
+                "backup and this instance has none stored, so switching them on would leave the "
+                "integration unauthenticated",
+                keys=keys,
+                count=len(plan.companion),
+            )
+
+    async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
+        """Push restored mqtt_* settings into the live relay.
+
+        The relay reads its broker config once, at configure() time — the
+        settings PUT handler reconfigures it for exactly this reason
+        (api/routes/settings.py). Writing the rows alone left the relay on the
+        pre-restore broker until the next backend restart while the UI showed
+        the restored values, which is the one way a restore could look applied
+        and not be.
+
+        Called after the commit, never before: configure() tears the connection
+        down and rebuilds it, so it must not run against values a later failure
+        could roll back. Only mqtt_password can't come back this way (the
+        credential blocklist skips it) — the row already in the database is
+        reused, so an unchanged broker keeps working.
+        """
+        if not _MQTT_SETTING_KEYS & keys_written:
+            return
+
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            rows = await db.execute(select(Settings).where(Settings.key.in_(_MQTT_SETTING_KEYS)))
+            stored = {s.key: s.value for s in rows.scalars().all()}
+
+            # Same shape and defaults the settings PUT handler builds.
+            await mqtt_relay.configure(
+                {
+                    "mqtt_enabled": (stored.get("mqtt_enabled") or "false") == "true",
+                    "mqtt_broker": stored.get("mqtt_broker") or "",
+                    "mqtt_port": int(stored.get("mqtt_port") or "1883"),
+                    "mqtt_username": stored.get("mqtt_username") or "",
+                    "mqtt_password": stored.get("mqtt_password") or "",
+                    "mqtt_topic_prefix": stored.get("mqtt_topic_prefix") or "bambuddy",
+                    "mqtt_use_tls": (stored.get("mqtt_use_tls") or "false") == "true",
+                }
+            )
+        except Exception:
+            # Same call is best-effort in the settings PUT handler: the rows are
+            # committed either way, and a broker that refuses the new config
+            # must not turn a successful restore into a failed one. Noted rather
+            # than swallowed silently, so the user knows to restart.
+            logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
+            tally.note(
+                "settingsMqttRelayFailed",
+                "MQTT settings restored, but the relay could not be reconnected — restart Bambuddy",
+            )
+
+    async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
+        by_serial: dict[str, list[tuple[str, dict]]] = {}
+        for path, content in payload.items():
+            match = _KPROFILE_PATH_RE.match(path)
+            if not match or not isinstance(content, dict):
+                continue
+            by_serial.setdefault(match.group(1), []).append((match.group(2), content))
+
+        if not by_serial:
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        result = await db.execute(select(Printer))
+        printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
+
+        # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
+        # the profile occupying a slot, so writing is always an overwrite on the
+        # printer side.
+        tally.note("kprofilesAlwaysOverwrite", "K-profiles always overwrite the matching slot on the printer")
+        # A refusal is now believed and counted failed (#2718 made the ack worth
+        # reading), but silence still counts restored, so the caveat stands —
+        # narrowed to what is actually left uncertain.
+        tally.note(
+            "kprofilesAckUnreliable",
+            "A printer that does not answer still counts as restored — verify the profiles on the printer",
+        )
+
+        for serial, entries in sorted(by_serial.items()):
+            profile_total = self._kprofile_profile_count(c for _, c in entries)
+
+            printer = printers.get(serial)
+            if printer is None:
+                tally.skipped += profile_total
+                tally.note("kprofilesPrinterMissing", f"No printer with serial {serial} — skipped", serial=serial)
+                continue
+
+            client = printer_manager.get_client(printer.id)
+            if not client or not client.state.connected:
+                tally.skipped += profile_total
+                tally.note(
+                    "kprofilesPrinterOffline",
+                    f"{printer.name} ({serial}) is not connected — skipped",
+                    printer=printer.name,
+                    serial=serial,
+                )
+                continue
+
+            for nozzle, content in sorted(entries):
+                profiles = content.get("profiles")
+                if not isinstance(profiles, list) or not profiles:
+                    continue
+                if nozzle not in _KNOWN_NOZZLES:
+                    tally.note(
+                        "kprofilesUnknownNozzle",
+                        f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is",
+                        nozzle=nozzle,
+                        serial=serial,
+                    )
+
+                # The backup's slot_id is a cali_idx, and cali_idx is as
+                # unstable as the autoincrement ids we already refuse to reuse
+                # for spools and archives: editing a profile in Bambuddy is a
+                # delete-then-add on a single-nozzle printer, which re-keys it.
+                # Addressing extrusion_cali_set at a slot that no longer exists
+                # is a silent no-op — the printer drops it and we would still
+                # report the profile restored. So resolve the live index first.
+                current = await self._current_kprofile_index(client, nozzle, serial)
+
+                profile_dicts = []
+                unmatched = 0
+                # A live profile can only stand in for one backed-up entry. Two
+                # entries resolving to the same cali_idx both go into the batch,
+                # the second overwrites the first on the printer, and the tally
+                # counts two restored where one landed.
+                claimed: set[int] = set()
+                for p in profiles:
+                    if not isinstance(p, dict):
+                        # Counted, not dropped. _kprofile_profile_count includes
+                        # it, so the offline and printer-missing paths already
+                        # count the same entry skipped and the failure path
+                        # counts it outstanding — leaving the tally here was the
+                        # one place a profile could vanish from
+                        # restored + skipped + failed entirely.
+                        #
+                        # failed here against skipped there is not a
+                        # disagreement about the entry. The three counters say
+                        # what happened to an item on this run, not whether it
+                        # was ever usable: an offline printer skips everything it
+                        # holds, well-formed or not, because nothing was
+                        # attempted, while here the entry was reached and could
+                        # not be used.
+                        tally.failed += 1
+                        continue
+                    match = self._match_kprofile(p, current, claimed)
+                    if match is None:
+                        unmatched += 1
+                    else:
+                        claimed.add(match.slot_id)
+                    entry = {
+                        "filament_id": p.get("filament_id", ""),
+                        "name": p.get("name", ""),
+                        "k_value": p.get("k_value", "0.020000"),
+                        "extruder_id": p.get("extruder_id", 0),
+                        # Prefer the live setting_id when we matched: it is
+                        # what the printer currently associates with the slot.
+                        "setting_id": (match.setting_id if match else None) or p.get("setting_id"),
+                        # cali_idx -1 tells the printer to add a new profile
+                        # rather than address a slot that isn't there.
+                        "cali_idx": match.slot_id if match else -1,
+                        # Only consulted for the generated-setting_id
+                        # fallback; cali_idx above takes precedence.
+                        "slot_id": 0,
+                    }
+
+                    # Same precedence as setting_id, and set only when known.
+                    # nozzle_id encodes the fitted nozzle's type and diameter
+                    # ("HS00-0.4"), so the live value beats the backup's: the
+                    # user may have swapped the nozzle since. When neither knows,
+                    # the key has to be *absent* — set_kprofiles_batch supplies
+                    # HS00-{diameter} via p.get(..., default), which a key
+                    # present-and-None defeats, publishing a null nozzle_id.
+                    # Printers that omit it are the reason the default is there
+                    # (#1748), so it has to be reachable.
+                    nozzle_id = (getattr(match, "nozzle_id", None) if match else None) or p.get("nozzle_id")
+                    if nozzle_id:
+                        entry["nozzle_id"] = nozzle_id
+                    profile_dicts.append(entry)
+                if not profile_dicts:
+                    continue
+                if unmatched:
+                    tally.note(
+                        "kprofilesUnmatched",
+                        f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
+                        "— added as new profiles",
+                        count=unmatched,
+                        nozzle=nozzle,
+                        printer=printer.name,
+                    )
+
+                try:
+                    seq = client.set_kprofiles_batch(profile_dicts, nozzle)
+                except Exception as e:
+                    logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
+                    seq = None
+
+                if not seq:
+                    tally.failed += len(profile_dicts)
+                    tally.note(
+                        "kprofilesSendFailed",
+                        f"Failed to send {nozzle} profiles to {printer.name} ({serial})",
+                        nozzle=nozzle,
+                        printer=printer.name,
+                        serial=serial,
+                    )
+                    continue
+
+                # What came back is the sequence_id the command was published
+                # under, not a verdict (#2718) — a truthy string only means the
+                # command left the building. The printer answers separately, and
+                # every other caller of this API now reads that answer; without
+                # this the restore would be the one path left that reports a
+                # refused write as saved.
+                ok, detail = await self._kprofile_ack(client, seq, serial, nozzle)
+                if ok:
+                    tally.restored += len(profile_dicts)
+                else:
+                    tally.failed += len(profile_dicts)
+                    tally.note(
+                        "kprofilesRefused",
+                        f"{printer.name} ({serial}) refused the {nozzle} profiles: {detail}",
+                        nozzle=nozzle,
+                        printer=printer.name,
+                        serial=serial,
+                        reason=detail,
+                    )
+
+    @staticmethod
+    def _kprofile_profile_count(contents) -> int:
+        """Count the profiles across parsed K-profile files.
+
+        Defensive on purpose. A hand-edited or truncated backup can carry a
+        ``profiles`` value that is not a list, and this count runs *before* the
+        per-call guards in the loop below — after ``_apply`` has already
+        committed the database categories. A malformed file has to be a skipped
+        category, not an exception thrown over committed rows.
+        """
+        total = 0
+        for content in contents:
+            profiles = content.get("profiles") if isinstance(content, dict) else None
+            if isinstance(profiles, list):
+                total += len(profiles)
+        return total
+
+    @staticmethod
+    async def _kprofile_ack(client, seq: str, serial: str, nozzle: str) -> tuple[bool, str]:
+        """Read the printer's verdict on one batch write.
+
+        ``await_cali_ack`` already treats silence as success — no answer is not
+        evidence of refusal, and firmware that predates the ack never answers at
+        all. An exception reading it is the same situation one layer up, so it
+        degrades the same way rather than turning a write that most likely
+        landed into a reported failure.
+        """
+        try:
+            ok, detail = await client.await_cali_ack(seq)
+            return bool(ok), str(detail or "")
+        except Exception as e:
+            logger.warning("Could not read the K-profile ack for %s nozzle %s: %s", serial, nozzle, e)
+            return True, ""
+
+    @staticmethod
+    async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:
+        """Read the printer's live profiles for one nozzle.
+
+        Best-effort: a read failure degrades to "nothing matched", which makes
+        every profile an add rather than aborting the restore.
+        """
+        try:
+            return list(await client.get_kprofiles(nozzle_diameter=nozzle) or [])
+        except Exception as e:
+            logger.warning("Could not read live K-profiles for %s nozzle %s: %s", serial, nozzle, e)
+            return []
+
+    @staticmethod
+    def _match_kprofile(entry: dict, current: list, claimed: set[int]):
+        """Find the live profile a backed-up entry corresponds to.
+
+        ``setting_id`` is the filament preset the profile was calibrated for and
+        is the strongest signal; a delete-then-add edit regenerates it, so fall
+        back to the display name, which Bambuddy's own editor preserves.
+        Both are scoped by ``filament_id`` — the same preset on a different
+        filament is a different profile — and by ``extruder_id``, because on a
+        dual-nozzle printer the same preset on the other extruder is a different
+        profile too.
+
+        ``claimed`` holds the slot ids already taken by earlier entries in this
+        nozzle's loop, and no live profile may be claimed twice. Without it, two
+        backed-up entries sharing a ``filament_id`` and matching on neither
+        ``setting_id`` nor ``name`` both fell through to the single-candidate
+        arm and both took the same slot — reachable whenever the user has since
+        deleted one of a pair, because the delete-then-add re-key is what strips
+        the ``setting_id`` match. Returning None for the displaced entry means
+        ``cali_idx: -1``, i.e. add-as-new, which is the safe outcome.
+        """
+        filament_id = entry.get("filament_id")
+        if not filament_id:
+            return None
+
+        candidates = [c for c in current if c.filament_id == filament_id]
+
+        # The live index is read per nozzle *diameter*, so on an H2D both
+        # extruders' profiles come back together. With the same filament
+        # calibrated on both — the ordinary case on a dual-nozzle printer, not an
+        # exotic one — filament_id alone lets extruder 0's backed-up entry match
+        # extruder 1's live profile, and the batch then carries
+        # {extruder_id: 0, cali_idx: <extruder-1 slot>}: one extruder's
+        # calibration written over the other's, counted restored.
+        #
+        # Conditional on both sides saying which extruder they mean. A pre-#2656
+        # backup carries no extruder_id, and a live index that reports none must
+        # not turn every entry into an add.
+        extruder_id = entry.get("extruder_id")
+        if isinstance(extruder_id, int) and any(getattr(c, "extruder_id", None) is not None for c in candidates):
+            candidates = [c for c in candidates if getattr(c, "extruder_id", None) == extruder_id]
+
+        available = [c for c in candidates if c.slot_id not in claimed]
+        if not available:
+            return None
+
+        setting_id = entry.get("setting_id")
+        if setting_id:
+            for c in available:
+                if c.setting_id == setting_id:
+                    return c
+
+        name = entry.get("name")
+        if name:
+            for c in available:
+                if c.name == name:
+                    return c
+
+        # Exactly one profile for this filament and no better discriminator:
+        # treat it as the same profile rather than duplicating it. Judged
+        # against every candidate rather than the unclaimed ones, because two
+        # live profiles for one filament are ambiguous whether or not another
+        # entry has already taken one of them.
+        return available[0] if len(candidates) == 1 else None
+
+
+# Singleton instance
+github_restore_service = GitHubRestoreService()

+ 674 - 0
backend/tests/integration/test_github_restore_api.py

@@ -0,0 +1,674 @@
+"""Integration tests for the Git backup restore API endpoints (#2656)."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
+
+
+@pytest.fixture(autouse=True)
+def _mock_private_repo_check():
+    """POST /config refuses to save unless the repo is confirmed private."""
+    with patch(
+        "backend.app.services.github_backup.github_backup_service.test_connection",
+        new=AsyncMock(
+            return_value={
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": "test/repo",
+                "permissions": {"push": True},
+                "is_private": True,
+            }
+        ),
+    ) as m:
+        yield m
+
+
+async def _create_config(async_client: AsyncClient, token: str | None = None) -> dict:
+    response = await async_client.post(
+        "/api/v1/github-backup/config",
+        headers={"Authorization": f"Bearer {token}"} if token else {},
+        json={
+            "repository_url": "https://github.com/test/repo",
+            "access_token": "ghp_testtoken123",
+            "branch": "main",
+            "backup_kprofiles": True,
+            "backup_spools": True,
+            "backup_archives": True,
+            "backup_settings": True,
+            "enabled": True,
+        },
+    )
+    assert response.status_code == 200
+    return response.json()
+
+
+class TestCommitsEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_not_configured(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/github-backup/commits")
+        assert response.status_code == 404
+        assert "Configure backup first" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_commits_from_the_provider(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        commits = [
+            {"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"}
+        ]
+        with patch(
+            "backend.app.services.git_providers.github.GitHubBackend.list_commits",
+            new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}),
+        ):
+            response = await async_client.get("/api/v1/github-backup/commits")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["success"] is True
+        assert body["branch"] == "main"
+        assert body["commits"][0]["sha"] == "aaa1111"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        with patch(
+            "backend.app.services.git_providers.github.GitHubBackend.list_commits",
+            new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}),
+        ):
+            response = await async_client.get("/api/v1/github-backup/commits")
+
+        assert response.status_code == 200
+        assert response.json()["success"] is False
+        assert response.json()["commits"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_limit_is_bounded(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422
+        assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422
+
+
+class TestPreviewEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_not_configured(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/github-backup/restore/preview")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_available_and_missing_categories(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        preview = {
+            "success": True,
+            "message": "OK",
+            "ref": "aaa1111",
+            "commit": None,
+            "metadata_version": "1.0",
+            "categories": [
+                {"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"},
+                {"category": "settings", "available": True, "item_count": 12, "detail": None},
+                {"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"},
+                {"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"},
+            ],
+        }
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.preview",
+            new=AsyncMock(return_value=preview),
+        ):
+            response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["metadata_version"] == "1.0"
+        by_name = {c["category"]: c for c in body["categories"]}
+        assert by_name["kprofiles"]["available"] is False
+        assert by_name["spools"]["item_count"] == 4
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"])
+    async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref):
+        await _create_config(async_client)
+        response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_head(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []})
+        with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock):
+            response = await async_client.get("/api/v1/github-backup/restore/preview")
+
+        assert response.status_code == 200
+        assert mock.await_args.kwargs["ref"] == "HEAD"
+
+
+class TestRestoreEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_not_configured(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_applies_selected_categories(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        outcome = {
+            "success": True,
+            "message": "Restored 5 item(s) from aaa1111",
+            "log_id": 3,
+            "ref": "aaa1111",
+            "results": {
+                "spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
+                "settings": {
+                    "restored": 1,
+                    "skipped": 2,
+                    "failed": 0,
+                    "notes": [
+                        {
+                            "code": "settingsCredentialsSkipped",
+                            "params": {"count": 1},
+                            "message": "1 credential-like key(s) skipped",
+                        }
+                    ],
+                },
+            },
+        }
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value=outcome),
+        ) as mock:
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True},
+            )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["results"]["spools"]["restored"] == 4
+        # Notes cross the wire as code + params + English fallback, so a
+        # non-English client can translate them (#2656).
+        assert body["results"]["settings"]["notes"] == [
+            {
+                "code": "settingsCredentialsSkipped",
+                "params": {"count": 1},
+                "message": "1 credential-like key(s) skipped",
+            }
+        ]
+        assert mock.await_args.kwargs["overwrite_existing"] is True
+        assert mock.await_args.kwargs["ref"] == "aaa1111"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_empty_category_list(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []})
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_unknown_category(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]})
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_malformed_ref(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.post(
+            "/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]}
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_overwrite_to_false(self, async_client: AsyncClient):
+        """The safe default: a restore only inserts what's missing."""
+        await _create_config(async_client)
+        mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
+        with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock):
+            response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
+
+        assert response.status_code == 200
+        assert mock.await_args.kwargs["overwrite_existing"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(
+                return_value={
+                    "success": False,
+                    "message": "A backup is currently running. Wait for it to finish before restoring.",
+                    "results": {},
+                }
+            ),
+        ):
+            response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
+
+        assert response.status_code == 200
+        assert response.json()["success"] is False
+        assert "backup is currently running" in response.json()["message"]
+
+
+class TestStatusExposesRestoreState:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.get("/api/v1/github-backup/status")
+        assert response.status_code == 200
+        assert response.json()["restore_running"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_running_is_reported(self, async_client: AsyncClient):
+        """The UI disables both action buttons off this flag."""
+        await _create_config(async_client)
+        from backend.app.services.github_restore import github_restore_service
+
+        github_restore_service._running_restore = True
+        github_restore_service._progress = "Restoring spool inventory..."
+        try:
+            response = await async_client.get("/api/v1/github-backup/status")
+        finally:
+            github_restore_service._running_restore = False
+            github_restore_service._progress = None
+
+        assert response.json()["restore_running"] is True
+        assert response.json()["progress"] == "Restoring spool inventory..."
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/github-backup/status")
+        assert response.status_code == 200
+        assert response.json()["restore_running"] is False
+
+
+class TestRestoredArchivesAreVisibleToTheirOwner(TestOwnershipPermissionsSetup):
+    """The archive-ownership blocker, proved through the route that enforces it.
+
+    ``_ensure_archive_visible`` fails closed on a NULL ``created_by_id`` — 404 for
+    any caller without ``archives:read_all`` — so before the collector and the
+    restore carried the column across, a multi-user instance got archives the
+    tally called restored and their owner could not open.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_owning_non_admin_can_open_a_restored_archive(
+        self, async_client: AsyncClient, auth_setup, db_session
+    ):
+        from backend.app.models.archive import PrintArchive
+        from backend.app.services.github_restore import _CategoryTally, github_restore_service
+
+        owner_id = auth_setup["operator_user"]["id"]
+        payload = {
+            "archives": [
+                {
+                    "id": 77,
+                    "filename": "benchy.3mf",
+                    "file_size": 2048,
+                    "content_hash": "abc123",
+                    "print_name": "Benchy",
+                    "started_at": "2026-03-01 10:00:00",
+                    "created_at": "2026-03-01 10:00:00",
+                    "created_by_id": owner_id,
+                }
+            ]
+        }
+        await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        restored = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert restored.id != 77, "the backup's primary key must not be reused"
+
+        response = await async_client.get(
+            f"/api/v1/archives/{restored.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200, "the owner cannot see their own restored archive"
+        assert response.json()["print_name"] == "Benchy"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_different_operator_still_cannot(self, async_client: AsyncClient, auth_setup, db_session):
+        """Control: carrying the owner across must not widen who can read it."""
+        from backend.app.models.archive import PrintArchive
+        from backend.app.services.github_restore import _CategoryTally, github_restore_service
+
+        payload = {
+            "archives": [
+                {
+                    "id": 77,
+                    "filename": "benchy.3mf",
+                    "file_size": 2048,
+                    "content_hash": "abc123",
+                    "started_at": "2026-03-01 10:00:00",
+                    "created_by_id": auth_setup["operator_user"]["id"],
+                }
+            ]
+        }
+        await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        restored = (await db_session.execute(select(PrintArchive))).scalar_one()
+        response = await async_client.get(
+            f"/api/v1/archives/{restored.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
+        )
+
+        assert response.status_code == 404
+
+
+class TestRestoreDoesNotOpenTheMetricsEndpoint:
+    """The companion-credential rule, proved against the endpoint it protects.
+
+    ``/api/v1/metrics`` is on ``PUBLIC_API_ROUTES`` and its only gate is
+    ``if token:``, so writing ``prometheus_enabled`` onto an instance with no
+    ``prometheus_token`` row hands the entire metrics body to anyone who can
+    reach the port. The restore refuses that token as credential-shaped, so
+    before this change the pair came apart and the endpoint opened — with
+    overwrite *off*, since the local row is missing rather than present.
+
+    Driven through the real service and the real endpoint against one database:
+    the unit tests can show the toggle is not written, only this can show what
+    that means.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restoring_prometheus_enabled_leaves_the_endpoint_shut(self, async_client: AsyncClient, db_session):
+        from backend.app.services.github_restore import _CategoryTally, github_restore_service
+
+        # An instance that never enabled Prometheus: no toggle row, no token row.
+        assert (await async_client.get("/api/v1/metrics")).status_code == 404
+
+        tally = _CategoryTally()
+        await github_restore_service._restore_settings(
+            db_session,
+            {"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret", "currency": "EUR"}},
+            overwrite=False,
+            tally=tally,
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/metrics")
+        assert response.status_code == 404, "a settings restore opened the metrics endpoint"
+        assert "bambuddy_build_info" not in response.text
+        assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "payload",
+        [
+            {"prometheus_enabled": "true", "currency": "EUR"},
+            {"prometheus_enabled": "true", "prometheus_token": "", "currency": "EUR"},
+        ],
+        ids=["token-key-absent", "token-blank"],
+    )
+    async def test_a_token_less_backup_leaves_the_endpoint_shut_too(
+        self, async_client: AsyncClient, db_session, payload
+    ):
+        """The route the test above does not cover, and the likelier one.
+
+        ``prometheus_token`` is optional, so an instance can enable Prometheus
+        without ever setting it. Such a backup carries the toggle and no usable
+        token — and because the companion rule's second condition asks whether
+        the *backup* had a credential, that payload used to sail straight past
+        the refusal and open the endpoint the case above proves shut.
+        """
+        from backend.app.services.github_restore import _CategoryTally, github_restore_service
+
+        assert (await async_client.get("/api/v1/metrics")).status_code == 404
+
+        tally = _CategoryTally()
+        await github_restore_service._restore_settings(db_session, {"settings": payload}, overwrite=False, tally=tally)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/metrics")
+        assert response.status_code == 404, "a token-less Prometheus backup opened the metrics endpoint"
+        assert "bambuddy_build_info" not in response.text
+        assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_instance_with_its_own_token_still_gets_the_toggle_back(
+        self, async_client: AsyncClient, db_session
+    ):
+        """Control. The rule must not break a legitimate Prometheus restore."""
+        from backend.app.services.github_restore import _CategoryTally, github_restore_service
+
+        await async_client.put(
+            "/api/v1/settings/", json={"prometheus_enabled": False, "prometheus_token": "local-token"}
+        )
+
+        await github_restore_service._restore_settings(
+            db_session,
+            {"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret"}},
+            overwrite=True,
+            tally=_CategoryTally(),
+        )
+        await db_session.commit()
+
+        assert (await async_client.get("/api/v1/metrics")).status_code == 401
+        authorised = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer local-token"})
+        assert authorised.status_code == 200
+        assert "bambuddy_build_info" in authorised.text
+
+
+class TestSettingsRestoreNeedsSettingsUpdate(TestOwnershipPermissionsSetup):
+    """A Backup-only role must not reach around the gate that owns the rows (#2656).
+
+    Each category rewrites rows some other endpoint already owns —
+    ``PUT /api/v1/settings/`` gates on ``settings:update``, the inventory writes
+    on ``inventory:update``, an archive that is not yours on
+    ``archives:update_all``, and the K-profile batch on ``kprofiles:update``.
+    Backup is its own permission group, so gating the restore endpoint on
+    ``github:restore`` alone let a role holding only Backup write, through a
+    restore, what it could not write through the endpoint that owns them. This
+    module already makes that argument — it is why the four protected auth keys
+    are refused outright — so the gap was an inconsistency in ours.
+
+    Settings was gated first; the other three followed on review, because gating
+    one and not the rest is the only state that is not defensible.
+    """
+
+    async def _token_for(self, async_client: AsyncClient, admin_token: str, name: str, permissions: list[str]) -> str:
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        group = await async_client.post(
+            "/api/v1/groups/",
+            headers=headers,
+            json={"name": name, "permissions": permissions},
+        )
+        assert group.status_code == 201, group.text
+        created = await async_client.post(
+            "/api/v1/users/",
+            headers=headers,
+            json={"username": name, "password": "Restorepass1!", "group_ids": [group.json()["id"]]},
+        )
+        assert created.status_code in (200, 201), created.text
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": name, "password": "Restorepass1!"},
+        )
+        assert login.status_code == 200, login.text
+        return login.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_backup_only_role_cannot_restore_settings(self, async_client: AsyncClient, auth_setup):
+        token = await self._token_for(
+            async_client, auth_setup["admin_token"], "backuponly", ["github:backup", "github:restore"]
+        )
+        await _create_config(async_client, auth_setup["admin_token"])
+
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
+        ) as mock:
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                headers={"Authorization": f"Bearer {token}"},
+                json={"categories": ["settings"]},
+            )
+
+        assert response.status_code == 403
+        assert "settings:update" in response.json()["detail"]
+        mock.assert_not_awaited(), "the refusal has to happen before anything is written"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("category", "permission"),
+        [
+            ("spools", "inventory:update"),
+            ("archives", "archives:update_all"),
+            ("kprofiles", "kprofiles:update"),
+        ],
+    )
+    async def test_backup_only_role_cannot_restore_the_other_categories(
+        self, async_client: AsyncClient, auth_setup, category, permission
+    ):
+        """Same argument as settings: these rows have an owning permission too."""
+        token = await self._token_for(
+            async_client, auth_setup["admin_token"], f"backuponly-{category}", ["github:backup", "github:restore"]
+        )
+        await _create_config(async_client, auth_setup["admin_token"])
+
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
+        ) as mock:
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                headers={"Authorization": f"Bearer {token}"},
+                json={"categories": [category]},
+            )
+
+        assert response.status_code == 403
+        assert permission in response.json()["detail"]
+        mock.assert_not_awaited(), "the refusal has to happen before anything is written"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_every_missing_permission_is_named_at_once(self, async_client: AsyncClient, auth_setup):
+        """One round trip tells the caller everything to fix, not just the first.
+
+        A restore is a multi-select, so reporting one category at a time turns
+        picking four into four refusals.
+        """
+        token = await self._token_for(
+            async_client, auth_setup["admin_token"], "backuponly-all", ["github:backup", "github:restore"]
+        )
+        await _create_config(async_client, auth_setup["admin_token"])
+
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                headers={"Authorization": f"Bearer {token}"},
+                json={"categories": ["settings", "spools", "archives", "kprofiles"]},
+            )
+
+        assert response.status_code == 403
+        detail = response.json()["detail"]
+        for permission in ("settings:update", "inventory:update", "archives:update_all", "kprofiles:update"):
+            assert permission in detail
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_gate_is_per_category_not_a_blanket_demotion(self, async_client: AsyncClient, auth_setup):
+        """Control: holding one category's permission is enough to restore that one."""
+        token = await self._token_for(
+            async_client,
+            auth_setup["admin_token"],
+            "backupandinventory",
+            ["github:backup", "github:restore", "inventory:read", "inventory:update"],
+        )
+        await _create_config(async_client, auth_setup["admin_token"])
+
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                headers={"Authorization": f"Bearer {token}"},
+                json={"categories": ["spools"]},
+            )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_every_restorable_category_has_an_owning_permission(self):
+        """Guards the map against a category added without a gate.
+
+        A new ``RestoreCategory`` that is missing here is not a failing test
+        anywhere else — it simply restores under ``github:restore`` alone, which
+        is the hole this whole class exists to close.
+        """
+        from backend.app.api.routes.github_backup import _CATEGORY_WRITE_PERMISSION
+        from backend.app.schemas.github_backup import RestoreCategory
+
+        assert set(_CATEGORY_WRITE_PERMISSION) == set(RestoreCategory)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_role_holding_both_can_restore_settings(self, async_client: AsyncClient, auth_setup):
+        """Control: the gate must not lock out a role that legitimately holds both."""
+        token = await self._token_for(
+            async_client,
+            auth_setup["admin_token"],
+            "backupandsettings",
+            ["github:backup", "github:restore", "settings:read", "settings:update"],
+        )
+        await _create_config(async_client, auth_setup["admin_token"])
+
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                headers={"Authorization": f"Bearer {token}"},
+                json={"categories": ["settings"]},
+            )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_disabled_is_unaffected(self, async_client: AsyncClient):
+        """Control: with auth off there is no user to check, and the dep returns None."""
+        await _create_config(async_client)
+
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                json={"categories": ["settings"]},
+            )
+
+        assert response.status_code == 200

+ 753 - 0
backend/tests/unit/test_git_providers_restore.py

@@ -0,0 +1,753 @@
+"""Unit tests for the git_providers read side used by restore (#2656).
+
+Covers list_commits / list_tree / fetch_files across all four providers,
+including that Gitea and Forgejo inherit GitHub's Git Data API implementation
+rather than needing their own.
+"""
+
+import base64
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.git_providers.forgejo import ForgejoBackend
+from backend.app.services.git_providers.gitea import GiteaBackend
+from backend.app.services.git_providers.github import GitHubBackend
+from backend.app.services.git_providers.gitlab import GitLabBackend
+
+
+def _make_mock_response(status_code: int, body=None, text: str = ""):
+    resp = MagicMock()
+    resp.status_code = status_code
+    resp.text = text
+    resp.json = MagicMock(return_value=body if body is not None else {})
+    return resp
+
+
+def _b64(text: str) -> str:
+    return base64.b64encode(text.encode("utf-8")).decode()
+
+
+def _github_commit(sha: str, message: str = "Bambuddy backup", date: str = "2026-07-01T10:00:00Z"):
+    return {"sha": sha, "commit": {"message": message, "author": {"name": "Bambuddy", "date": date}}}
+
+
+class TestGitHubListCommits:
+    def setup_method(self):
+        self.backend = GitHubBackend()
+        self.repo_url = "https://github.com/owner/repo"
+        self.token = "ghp_token"
+
+    @pytest.mark.asyncio
+    async def test_returns_normalised_commits_newest_first(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                [
+                    _github_commit("aaa111", "Bambuddy backup - newest", "2026-07-02T10:00:00Z"),
+                    _github_commit("bbb222", "Bambuddy backup - older", "2026-07-01T10:00:00Z"),
+                ],
+            )
+        )
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert result["success"] is True
+        assert [c["sha"] for c in result["commits"]] == ["aaa111", "bbb222"]
+        assert result["commits"][0]["message"] == "Bambuddy backup - newest"
+        assert result["commits"][0]["author"] == "Bambuddy"
+        assert result["commits"][0]["date"] == "2026-07-02T10:00:00Z"
+
+    @pytest.mark.asyncio
+    async def test_sends_both_per_page_and_limit(self):
+        """GitHub honours per_page, Gitea honours limit — one call must carry both
+        so GiteaBackend can inherit this method unchanged."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, []))
+
+        await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=7)
+
+        params = client.get.await_args.kwargs["params"]
+        assert params["per_page"] == 7
+        assert params["limit"] == 7
+        assert params["sha"] == "main"
+
+    @pytest.mark.asyncio
+    async def test_respects_limit_even_if_provider_overshoots(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit(f"sha{i}") for i in range(10)]))
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=3)
+
+        assert len(result["commits"]) == 3
+
+    @pytest.mark.asyncio
+    async def test_404_explains_empty_repository(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "nope", client)
+
+        assert result["success"] is False
+        assert "no commits yet" in result["message"]
+        assert result["commits"] == []
+
+    @pytest.mark.asyncio
+    async def test_skips_entries_without_a_sha(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(200, [{"commit": {"message": "no sha"}}, _github_commit("good")])
+        )
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert [c["sha"] for c in result["commits"]] == ["good"]
+
+    @pytest.mark.asyncio
+    async def test_non_list_body_is_an_error_not_a_crash(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"}))
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert result["success"] is False
+        assert "Unexpected shape" in result["message"]
+
+
+class TestGetCommit:
+    """A ref older than the list window still needs a subject line and a date."""
+
+    @pytest.mark.asyncio
+    async def test_github_reads_one_commit_by_sha(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, _github_commit("abc1234567")))
+
+        result = await GitHubBackend().get_commit("https://github.com/owner/repo", "tok", "abc1234567", client)
+
+        assert result["success"] is True
+        assert result["commit"] == {
+            "sha": "abc1234567",
+            "message": "Bambuddy backup",
+            "author": "Bambuddy",
+            "date": "2026-07-01T10:00:00Z",
+        }
+        assert "repos/owner/repo/commits/abc1234567" in client.get.await_args.args[0]
+
+    @pytest.mark.asyncio
+    async def test_github_404_names_the_ref(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await GitHubBackend().get_commit("https://github.com/owner/repo", "tok", "deadbee", client)
+
+        assert result["success"] is False
+        assert result["commit"] is None
+        assert "deadbee" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_gitlab_reads_its_flattened_shape(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                {
+                    "id": "abc1234567",
+                    "message": "Bambuddy backup",
+                    "author_name": "Bambuddy",
+                    "committed_date": "2026-07-02T10:00:00Z",
+                },
+            )
+        )
+
+        result = await GitLabBackend().get_commit("https://gitlab.com/owner/repo", "tok", "abc1234567", client)
+
+        assert result["commit"]["author"] == "Bambuddy"
+        assert result["commit"]["date"] == "2026-07-02T10:00:00Z"
+
+    @pytest.mark.asyncio
+    async def test_gitlab_404_names_the_ref(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await GitLabBackend().get_commit("https://gitlab.com/owner/repo", "tok", "deadbee", client)
+
+        assert result["success"] is False
+        assert "deadbee" in result["message"]
+
+
+class TestGitHubListTree:
+    def setup_method(self):
+        self.backend = GitHubBackend()
+        self.repo_url = "https://github.com/owner/repo"
+        self.token = "ghp_token"
+
+    @pytest.mark.asyncio
+    async def test_returns_sorted_blob_paths_only(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                {
+                    "tree": [
+                        {"type": "blob", "path": "spools/inventory.json", "sha": "s1"},
+                        {"type": "tree", "path": "spools", "sha": "d1"},
+                        {"type": "blob", "path": "backup_metadata.json", "sha": "m1"},
+                    ]
+                },
+            )
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is True
+        assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"]
+
+    @pytest.mark.asyncio
+    async def test_truncated_tree_fails_loudly(self):
+        """A truncated listing would make restore silently miss categories."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True}))
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is False
+        assert "truncated" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_404_names_the_missing_ref(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client)
+
+        assert result["success"] is False
+        assert "deadbee" in result["message"]
+
+
+class TestGitHubFetchFiles:
+    def setup_method(self):
+        self.backend = GitHubBackend()
+        self.repo_url = "https://github.com/owner/repo"
+        self.token = "ghp_token"
+
+    @pytest.mark.asyncio
+    async def test_reads_requested_paths_via_blob_api(self):
+        tree = _make_mock_response(
+            200,
+            {
+                "tree": [
+                    {"type": "blob", "path": "a.json", "sha": "sha-a"},
+                    {"type": "blob", "path": "b.json", "sha": "sha-b"},
+                ]
+            },
+        )
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                tree,
+                _make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}),
+            ]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {"a.json": '{"a": 1}'}
+        # One tree listing regardless of how many files are read.
+        assert client.get.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_lists_the_tree_once_for_many_files(self):
+        tree = _make_mock_response(
+            200,
+            {
+                "tree": [
+                    {"type": "blob", "path": "a.json", "sha": "sha-a"},
+                    {"type": "blob", "path": "b.json", "sha": "sha-b"},
+                ]
+            },
+        )
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                tree,
+                _make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}),
+                _make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}),
+            ]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client)
+
+        assert result["files"] == {"a.json": "1", "b.json": "2"}
+        assert client.get.await_count == 3
+
+    @pytest.mark.asyncio
+    async def test_a_supplied_blob_map_skips_the_second_tree_read(self):
+        """list_tree already fetched this; fetching it again was a wasted GET."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}))
+
+        result = await self.backend.fetch_files(
+            self.repo_url, self.token, "abc1234", ["a.json"], client, blob_shas={"a.json": "sha-a"}
+        )
+
+        assert result["files"] == {"a.json": "1"}
+        # The blob read and nothing else.
+        assert client.get.await_count == 1
+        assert "git/blobs/sha-a" in client.get.await_args.args[0]
+
+    @pytest.mark.asyncio
+    async def test_list_tree_hands_back_the_map_it_built(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                {
+                    "tree": [
+                        {"type": "blob", "path": "a.json", "sha": "sha-a"},
+                        {"type": "tree", "path": "dir", "sha": "sha-d"},
+                    ]
+                },
+            )
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["blob_shas"] == {"a.json": "sha-a"}
+
+    @pytest.mark.asyncio
+    async def test_missing_path_is_skipped_not_an_error(self):
+        """Which categories a backup contains varies by config, so an absent
+        path is expected rather than a failure."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {}
+
+    @pytest.mark.asyncio
+    async def test_blob_error_fails_the_whole_read(self):
+        tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")])
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is False
+        assert "a.json" in result["message"]
+        assert result["files"] == {}
+
+    @pytest.mark.asyncio
+    async def test_utf8_content_survives_round_trip(self):
+        payload = '{"color_name": "Jadeweiß", "note": "日本語"}'
+        tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["files"]["a.json"] == payload
+
+    @pytest.mark.asyncio
+    async def test_unsupported_encoding_is_reported(self):
+        tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is False
+        assert "Unsupported blob encoding" in result["message"]
+
+
+class TestGiteaAndForgejoInheritReads:
+    """Gitea overrides the *write* path, plus the one read that genuinely differs."""
+
+    @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
+    def test_read_methods_are_not_overridden(self, backend_cls):
+        for method in ("list_commits", "list_tree", "fetch_files", "get_commit"):
+            assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
+
+    @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
+    def test_the_tree_read_is_paged_rather_than_inherited(self, backend_cls):
+        """GitHub's trees endpoint is not paginated; Gitea's is (#2656)."""
+        assert backend_cls._blob_shas_at is not GitHubBackend._blob_shas_at
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
+    async def test_a_paged_tree_is_read_to_the_end(self, backend_cls):
+        """Inheriting GitHub's single GET read only the first page.
+
+        The rest of the backup then looked absent from the commit, and the
+        preview reported those categories as "not present" — a restore silently
+        skipping data, which is exactly what GitHub's truncated=true check
+        exists to prevent.
+        """
+        page1 = {
+            "tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(1000)],
+            "total_count": 1002,
+        }
+        page2 = {
+            "tree": [
+                {"type": "blob", "path": "settings/app_settings.json", "sha": "sx"},
+                {"type": "tree", "path": "settings", "sha": "dx"},
+            ],
+            "total_count": 1002,
+        }
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=[_make_mock_response(200, page1), _make_mock_response(200, page2)])
+
+        result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is True
+        assert client.get.await_count == 2
+        assert "settings/app_settings.json" in result["paths"]
+        assert len(result["paths"]) == 1001
+
+    @pytest.mark.asyncio
+    async def test_a_single_page_tree_costs_one_request(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200, {"tree": [{"type": "blob", "path": "a.json", "sha": "s1"}], "total_count": 1}
+            )
+        )
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["paths"] == ["a.json"]
+        assert client.get.await_count == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
+    async def test_a_clamped_page_size_is_still_read_to_the_end(self, backend_cls):
+        """Gitea clamps per_page to MAX_RESPONSE_ITEMS — 50 by default (#2656).
+
+        Paging off the *requested* 1000 made page 2 believe it had seen 1050
+        entries, which clears any total_count below that. The loop then returned
+        the first 100 entries of a 120-entry tree as a success, and the restore
+        reported the categories it could not see as absent from the commit.
+        """
+        clamped = 50
+        total = 120
+        pages = []
+        for start in range(0, total, clamped):
+            count = min(clamped, total - start)
+            pages.append(
+                _make_mock_response(
+                    200,
+                    {
+                        "tree": [
+                            {"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(start, start + count)
+                        ],
+                        "total_count": total,
+                    },
+                )
+            )
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=pages)
+
+        result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is True
+        assert client.get.await_count == 3
+        assert len(result["paths"]) == total
+        assert "f119.json" in result["paths"], "the tail of the tree is what a clamped pager loses"
+
+    # --- a response with no usable total_count must not fail open -----------
+    #
+    # The pager used to short-circuit into a *success* holding page 1 whenever
+    # total_count was missing or not an int — 50 entries of an arbitrarily large
+    # tree under Gitea's default clamp. The restore then reported the categories
+    # it could not see as "not present in this backup commit", the same silent
+    # skip this whole override exists to prevent. GitHub and GitLab both
+    # hard-fail in the equivalent spot; only Gitea guessed.
+
+    @staticmethod
+    def _page(start, count, **extra):
+        return _make_mock_response(
+            200,
+            {
+                "tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(start, start + count)],
+                **extra,
+            },
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
+    async def test_a_countless_response_is_paged_to_the_end(self, backend_cls):
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=[self._page(0, 50), self._page(50, 50), self._page(100, 0)])
+
+        result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is True
+        assert client.get.await_count == 3
+        assert len(result["paths"]) == 100
+        assert "f99.json" in result["paths"], "the tail is what a fail-open pager loses"
+
+    @pytest.mark.asyncio
+    async def test_a_countless_short_page_ends_the_paging(self):
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=[self._page(0, 50), self._page(50, 7)])
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is True
+        assert client.get.await_count == 2
+        assert len(result["paths"]) == 57
+
+    @pytest.mark.asyncio
+    async def test_a_countless_single_page_tree_still_costs_one_request(self):
+        """Control: a small tree must not pay for the fix."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=self._page(0, 3))
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["paths"] == ["f0.json", "f1.json", "f2.json"]
+        assert client.get.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_a_non_int_total_count_is_treated_as_no_count(self):
+        """The arm the code was written to defend against, and then trusted."""
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=[self._page(0, 50, total_count="120"), self._page(50, 4)])
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is True
+        assert client.get.await_count == 2
+        assert len(result["paths"]) == 54
+
+    @pytest.mark.asyncio
+    async def test_a_countless_tree_beyond_the_page_cap_still_fails(self):
+        """The page ceiling is what keeps "page until short" from truncating."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=self._page(0, 1000))
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is False
+        assert "listing limit" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_a_tree_beyond_the_page_cap_fails_rather_than_truncating(self):
+        page = {"tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(1000)]}
+        page["total_count"] = 10_000_000
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, page))
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
+
+        assert result["success"] is False
+        assert "listing limit" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_a_missing_ref_is_still_named(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "deadbee", client)
+
+        assert result["success"] is False
+        assert "deadbee" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_gitea_list_commits_uses_its_own_api_base(self):
+        backend = GiteaBackend()
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")]))
+
+        result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client)
+
+        assert result["success"] is True
+        url = client.get.await_args.args[0]
+        assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits")
+
+    @pytest.mark.asyncio
+    async def test_gitea_subpath_install_is_respected(self):
+        """Gitea/Forgejo behind a ROOT_URL sub-path (#2642)."""
+        backend = GiteaBackend()
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
+
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "total_count": 0}))
+        await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client)
+
+        url = client.get.await_args.args[0]
+        assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url
+
+
+class TestGitLabReads:
+    def setup_method(self):
+        self.backend = GitLabBackend()
+        self.repo_url = "https://gitlab.com/owner/repo"
+        self.token = "glpat-test"
+
+    @pytest.mark.asyncio
+    async def test_list_commits_reads_flattened_author_fields(self):
+        """GitLab puts message/author/date on the entry, not under 'commit'."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                [
+                    {
+                        "id": "abc123",
+                        "message": "Bambuddy backup",
+                        "author_name": "Bambuddy",
+                        "committed_date": "2026-07-02T10:00:00Z",
+                    }
+                ],
+            )
+        )
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert result["success"] is True
+        assert result["commits"] == [
+            {
+                "sha": "abc123",
+                "message": "Bambuddy backup",
+                "author": "Bambuddy",
+                "date": "2026-07-02T10:00:00Z",
+            }
+        ]
+
+    @pytest.mark.asyncio
+    async def test_list_commits_uses_ref_name(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, []))
+
+        await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5)
+
+        params = client.get.await_args.kwargs["params"]
+        assert params["ref_name"] == "bambuddy-backup"
+        assert params["per_page"] == 5
+
+    @pytest.mark.asyncio
+    async def test_subgroup_path_is_url_encoded(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, []))
+
+        await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client)
+
+        url = client.get.await_args.args[0]
+        assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url
+
+    @pytest.mark.asyncio
+    async def test_list_tree_returns_blob_paths(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                [
+                    {"type": "blob", "path": "spools/inventory.json"},
+                    {"type": "tree", "path": "spools"},
+                ],
+            )
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is True
+        assert result["paths"] == ["spools/inventory.json"]
+
+    @pytest.mark.asyncio
+    async def test_list_tree_follows_pagination(self):
+        """GitLab paginates instead of exposing a truncated flag."""
+        full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(200, full_page),
+                _make_mock_response(200, [{"type": "blob", "path": "last.json"}]),
+            ]
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert client.get.await_count == 2
+        assert len(result["paths"]) == 101
+        assert "last.json" in result["paths"]
+
+    @pytest.mark.asyncio
+    async def test_hitting_the_page_cap_is_a_failure_not_a_partial_list(self):
+        """The mirror image of GitHub's truncated=true check.
+
+        Falling out of the `while page <= 50` condition used to return
+        success: True with a silently partial path list, which the restore then
+        reported as "those categories are not present in this commit" — data
+        skipped without anyone being told.
+        """
+        full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, full_page))
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is False
+        assert result["paths"] == []
+        assert "cannot be enumerated reliably" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_list_tree_returns_no_blob_map(self):
+        """GitLab reads files by path, so there is nothing to share."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, [{"type": "blob", "path": "a.json"}]))
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["blob_shas"] == {}
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_ignores_a_blob_map(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}))
+
+        result = await self.backend.fetch_files(
+            self.repo_url, self.token, "abc1234", ["a.json"], client, blob_shas={"a.json": "irrelevant"}
+        )
+
+        assert result["files"] == {"a.json": "1"}
+        assert "repository/files/a.json" in client.get.await_args.args[0]
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_decodes_base64(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"})
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {"a.json": '{"k": 1}'}
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_encodes_nested_path(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"}))
+
+        await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client)
+
+        url = client.get.await_args.args[0]
+        assert "repository/files/spools%2Finventory.json" in url
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_skips_404(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {}

+ 3426 - 0
backend/tests/unit/test_github_restore.py

@@ -0,0 +1,3426 @@
+"""Unit tests for the Git backup restore service (#2656).
+
+Focus is on the per-category appliers: natural-key matching, the deliberate
+refusal to reuse the backup's primary keys, old_id -> new_id remapping for
+dependent rows, overwrite-vs-skip, the settings credential blocklist, and the
+K-profile paths that depend on live printers.
+"""
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.user import User
+from backend.app.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
+from backend.app.services.github_restore import (
+    _COMPANION_CREDENTIAL_ENV,
+    _COMPANION_CREDENTIALS,
+    _COMPANION_EXPOSURE_TOGGLES,
+    ARCHIVES_PATH,
+    SETTINGS_PATH,
+    SPOOL_USAGE_PATH,
+    SPOOLS_PATH,
+    GitHubRestoreService,
+    _CategoryTally,
+    _is_blocked_setting_key,
+    _is_protected_setting_key,
+    _is_usable_credential,
+    _parse_dt,
+    _setting_value_is_true,
+    _SettingsPlan,
+)
+
+
+def _service() -> GitHubRestoreService:
+    return GitHubRestoreService()
+
+
+def _messages(tally: _CategoryTally) -> list[str]:
+    """The English rendering of each note.
+
+    Notes are ``{code, params, message}`` since they became translatable
+    (#2656); asserting on the message keeps these tests readable while
+    ``_codes`` covers the half a client actually keys on.
+    """
+    return [note["message"] for note in tally.notes]
+
+
+def _codes(tally: _CategoryTally) -> list[str]:
+    return [note["code"] for note in tally.notes]
+
+
+class TestParseDt:
+    def test_parses_str_datetime_the_backup_writes(self):
+        assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
+
+    def test_parses_iso_with_t_separator(self):
+        assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5)
+
+    @pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}])
+    def test_returns_none_for_junk(self, value):
+        assert _parse_dt(value) is None
+
+    def test_an_offset_is_normalised_to_naive_utc(self):
+        """Every DateTime column here is naive UTC; an aware value cannot be
+        written to one without silently shifting the wall clock, nor compared
+        against one without raising."""
+        assert _parse_dt("2026-07-27T08:02:05+02:00") == datetime(2026, 7, 27, 6, 2, 5)
+        assert _parse_dt("2026-07-27T06:02:05+00:00").tzinfo is None
+
+
+class TestSettingKeyBlocklist:
+    @pytest.mark.parametrize(
+        "key",
+        [
+            "bambu_cloud_token",
+            "auth_secret_key",
+            "ha_token",
+            "prometheus_token",
+            "printer_access_code",
+            "smtp_password",
+            "some_api_key",
+            "ftp_passphrase",
+            "MQTT_SECRET",
+        ],
+    )
+    def test_credential_like_keys_are_blocked(self, key):
+        assert _is_blocked_setting_key(key) is True
+
+    @pytest.mark.parametrize(
+        "key",
+        ["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"],
+    )
+    def test_ordinary_keys_are_allowed(self, key):
+        assert _is_blocked_setting_key(key) is False
+
+    @pytest.mark.parametrize(
+        "key",
+        ["auth_enabled", "advanced_auth_enabled", "local_login_enabled", "setup_completed"],
+    )
+    def test_auth_policy_keys_are_protected(self, key):
+        # Not credential-shaped, so the secret hints never catch them.
+        assert _is_blocked_setting_key(key) is False
+        assert _is_protected_setting_key(key) is True
+
+    @pytest.mark.parametrize("key", ["currency", "auth_secret_key", "mqtt_enabled", "prometheus_enabled"])
+    def test_protected_set_does_not_swallow_ordinary_or_credential_keys(self, key):
+        assert _is_protected_setting_key(key) is False
+
+    @pytest.mark.parametrize(
+        "key",
+        [
+            "ldap_enabled",
+            "ldap_server_url",
+            "ldap_search_base",
+            "ldap_user_filter",
+            "ldap_security",
+            "ldap_group_mapping",
+            "ldap_auto_provision",
+            "ldap_ca_cert_path",
+            "ldap_default_group",
+            "ldap_bind_dn",
+            "LDAP_ENABLED",
+            "ldap_something_added_later",
+        ],
+    )
+    def test_the_whole_ldap_family_is_protected(self, key):
+        """Together these name *which directory decides who you are*.
+
+        ``auth.py`` reads them live from this table on every login, so a restore
+        that writes them substitutes the authentication source: point
+        ``ldap_server_url`` at another directory, set ``ldap_auto_provision``,
+        and ``ldap_default_group`` decides what the account it creates gets.
+
+        The companion rule did not cover this and could not: it pairs
+        ``ldap_enabled`` with ``ldap_bind_password`` and asks whether the
+        integration will *work*, and an anonymous bind works — so a payload that
+        simply omitted the password had its toggle written. Refused by prefix so
+        a key added to the LDAP schema later is refused by default, and matched
+        case-insensitively because the key comes from the backup's JSON rather
+        than from our own writer.
+        """
+        assert _is_protected_setting_key(key) is True
+
+    def test_ldap_enabled_is_not_also_a_companion_toggle(self):
+        """It was, and the pair is what let the family through.
+
+        Kept as a test rather than a comment because re-adding it would read as
+        tightening the rule while actually being dead code —
+        ``_is_protected_setting_key`` runs first in ``_plan_settings``.
+        """
+        assert "ldap_enabled" not in _COMPANION_CREDENTIALS
+
+    def test_ha_token_from_env_is_deliberately_not_carved_out(self):
+        """Recorded so the review's question about it is not re-litigated.
+
+        ``ha_token_from_env`` looks like a false positive for the ``token`` hint,
+        but it is only ever constructed in the settings GET response
+        (``get_homeassistant_settings``). It is absent from ``AppSettingsUpdate``
+        and so is never a ``Settings`` row — it cannot reach a backup, which
+        makes an allowlist entry for it dead code.
+
+        Carving it out would also be a live hole rather than a tidy-up: an
+        attacker-authored ``settings/app_settings.json`` could then get a
+        ``*token*``-named row written simply by choosing that name. This
+        The hints are the primary refusal for every credential the collector
+        does not filter, so a name-shaped exception to them is exactly the wrong
+        shape of fix.
+        """
+        assert _is_blocked_setting_key("ha_token_from_env") is True
+
+
+class TestCategoryTally:
+    def test_a_note_carries_code_params_and_english(self):
+        tally = _CategoryTally()
+        tally.note("noData", "No data of this kind in this backup")
+        tally.note("spoolUsageUnresolved", "2 usage record(s) skipped", count=2)
+
+        assert tally.notes == [
+            {"code": "noData", "params": {}, "message": "No data of this kind in this backup"},
+            {"code": "spoolUsageUnresolved", "params": {"count": 2}, "message": "2 usage record(s) skipped"},
+        ]
+
+    def test_notes_are_deduplicated(self):
+        tally = _CategoryTally()
+        tally.note("noData", "same")
+        tally.note("noData", "same")
+        assert len(tally.notes) == 1
+
+    def test_the_same_code_with_different_params_is_kept(self):
+        """Two printers can both be offline, and the user needs both names."""
+        tally = _CategoryTally()
+        tally.note("kprofilesPrinterOffline", "A is not connected", printer="A")
+        tally.note("kprofilesPrinterOffline", "B is not connected", printer="B")
+        assert len(tally.notes) == 2
+
+    def test_notes_are_bounded(self):
+        tally = _CategoryTally()
+        for i in range(50):
+            tally.note("noData", f"note {i}", index=i)
+        assert len(tally.notes) == 20
+
+
+class TestRestoreRequestSchema:
+    def test_rejects_empty_category_list(self):
+        with pytest.raises(ValueError):
+            GitHubRestoreRequest(categories=[])
+
+    def test_deduplicates_categories(self):
+        request = GitHubRestoreRequest(
+            categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
+        )
+        assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
+
+    def test_defaults_to_head(self):
+        assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD"
+
+    @pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40])
+    def test_accepts_valid_refs(self, ref):
+        assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref
+
+    @pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"])
+    def test_rejects_refs_that_are_not_object_names(self, ref):
+        with pytest.raises(ValueError):
+            GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS])
+
+
+class TestRestoreSettings:
+    @pytest.mark.asyncio
+    async def test_inserts_missing_keys(self, db_session):
+        tally = _CategoryTally()
+        payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
+
+        await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
+        await db_session.commit()
+
+        rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert rows == {"currency": "EUR", "theme": "dark"}
+        assert tally.restored == 2
+
+    @pytest.mark.asyncio
+    async def test_skips_existing_key_when_overwrite_off(self, db_session):
+        db_session.add(Settings(key="currency", value="USD"))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally)
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
+        assert row.value == "USD"
+        assert tally.skipped == 1
+        assert tally.restored == 0
+
+    @pytest.mark.asyncio
+    async def test_overwrites_existing_key_when_enabled(self, db_session):
+        db_session.add(Settings(key="currency", value="USD"))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally)
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
+        assert row.value == "EUR"
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_credential_keys_are_never_restored(self, db_session):
+        """A backup predating the collector's denylist can still contain secrets."""
+        tally = _CategoryTally()
+        payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}}
+
+        await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
+        await db_session.commit()
+
+        keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert keys == {"currency"}
+        # Refusals are notes, not tally rows: the preview never counted these
+        # keys, so counting them here would put the total above what the user
+        # was shown before they pressed Restore.
+        assert tally.skipped == 0
+        assert any("credential-like" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_auth_settings_are_never_restored(self, db_session):
+        """Restoring auth_enabled=false would disable auth behind the cache's back."""
+        db_session.add(Settings(key="auth_enabled", value="true"))
+        db_session.add(Settings(key="local_login_enabled", value="true"))
+        await db_session.commit()
+        tally = _CategoryTally()
+        payload = {
+            "settings": {
+                "currency": "EUR",
+                "auth_enabled": "false",
+                "advanced_auth_enabled": "false",
+                "local_login_enabled": "false",
+                "setup_completed": "false",
+            }
+        }
+
+        await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
+        await db_session.commit()
+
+        rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert rows["auth_enabled"] == "true"
+        assert rows["local_login_enabled"] == "true"
+        assert "advanced_auth_enabled" not in rows
+        assert "setup_completed" not in rows
+        assert rows["currency"] == "EUR"
+        assert tally.restored == 1
+        # As above: refused keys are outside the preview's count, so outside the
+        # tally too.
+        assert tally.skipped == 0
+        assert any("authentication setting" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_missing_payload_is_noted_not_fatal(self, db_session):
+        tally = _CategoryTally()
+        await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
+        assert tally.restored == 0
+        assert _codes(tally) == ["noData"]
+
+
+class TestSettingValueIsTrue:
+    """Only the spellings a reader actually treats as "on" count as on."""
+
+    @pytest.mark.parametrize("value", ["true", "TRUE", " True ", True])
+    def test_on(self, value):
+        assert _setting_value_is_true(value) is True
+
+    @pytest.mark.parametrize("value", ["false", "1", "on", "yes", "", None, False, 0])
+    def test_off(self, value):
+        # "1"/"on"/"yes" are deliberately off: no reader in the codebase treats
+        # them as on, so restoring one cannot switch anything on either.
+        assert _setting_value_is_true(value) is False
+
+
+class TestUsableCredential:
+    @pytest.mark.parametrize("value", ["s3cret", " x "])
+    def test_present_values_are_usable(self, value):
+        assert _is_usable_credential(value) is True
+
+    @pytest.mark.parametrize("value", [None, "", "   "])
+    def test_absent_or_blank_is_not(self, value):
+        # A present-but-blank prometheus_token row is exactly the `if token:`
+        # hole in the metrics route, so it must not count as protection.
+        assert _is_usable_credential(value) is False
+
+
+class TestCompanionCredentials:
+    """Toggles whose safety depends on a credential the restore refuses to write.
+
+    ``prometheus_enabled`` is the sharp one. ``/api/v1/metrics`` is a public
+    route whose only gate is a non-empty ``prometheus_token``, so restoring the
+    toggle onto an instance that has no token row publishes the entire metrics
+    body to anyone who can reach the port — and with overwrite *off*, since the
+    row is missing rather than present. The other four break an integration
+    rather than open one, but they are the same shape.
+    """
+
+    async def _restore(self, db, tally=None, overwrite=False, **settings) -> _CategoryTally:
+        tally = tally or _CategoryTally()
+        await _service()._restore_settings(db, {"settings": settings}, overwrite=overwrite, tally=tally)
+        await db.commit()
+        return tally
+
+    async def _rows(self, db) -> dict:
+        return {s.key: s.value for s in (await db.execute(select(Settings))).scalars().all()}
+
+    # --- The refusal itself ------------------------------------------------
+
+    @pytest.mark.asyncio
+    async def test_prometheus_toggle_is_refused_when_its_token_was_skipped(self, db_session):
+        """The headline case: overwrite off, empty database, endpoint stays shut."""
+        tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true", prometheus_token="s3cret")
+
+        rows = await self._rows(db_session)
+        assert rows == {"currency": "EUR"}
+        assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("toggle,credential", sorted(_COMPANION_CREDENTIALS.items()))
+    async def test_every_pair_refuses_its_toggle(self, db_session, toggle, credential, monkeypatch):
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._restore(db_session, **{toggle: "true", credential: "s3cret"})
+        assert toggle not in await self._rows(db_session)
+
+    @pytest.mark.asyncio
+    async def test_an_authored_ldap_payload_cannot_substitute_the_directory(self, db_session):
+        """The attack the companion rule could not see, refused end to end.
+
+        Anyone who can write to the backup repository can author this file, and
+        the shape that beat the old rule is the natural one for an attacker:
+        *omit* ``ldap_bind_password``. They own the directory being pointed at,
+        so they need no bind credential from us — and an anonymous bind is a
+        working config, which is exactly what the availability rule was built to
+        allow through.
+
+        Left unrefused, the next login against a fresh username binds to
+        ``ldap_server_url``, ``ldap_auto_provision`` creates the local account,
+        and ``ldap_default_group`` decides it is an Administrator. Overwrite-off
+        is enough on an instance that never configured LDAP: there are no rows
+        to skip.
+        """
+        tally = await self._restore(
+            db_session,
+            currency="EUR",
+            ldap_enabled="true",
+            ldap_server_url="ldaps://evil.example.com:636",
+            ldap_security="ldaps",
+            ldap_search_base="dc=evil,dc=com",
+            ldap_user_filter="(uid={username})",
+            ldap_auto_provision="true",
+            ldap_default_group="Administrators",
+        )
+
+        rows = await self._rows(db_session)
+        assert rows == {"currency": "EUR"}, "not one LDAP row may land"
+        assert any("authentication" in note.lower() for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_ha_toggle_is_refused_when_the_environment_has_no_token(self, db_session, monkeypatch):
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._restore(db_session, ha_enabled="true", ha_token="s3cret", ha_url="http://ha.local")
+
+        rows = await self._rows(db_session)
+        assert "ha_enabled" not in rows
+        assert rows["ha_url"] == "http://ha.local"
+
+    @pytest.mark.asyncio
+    async def test_a_blank_local_credential_row_is_not_usable(self, db_session):
+        db_session.add(Settings(key="prometheus_token", value=""))
+        await db_session.commit()
+
+        await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
+
+        assert "prometheus_enabled" not in await self._rows(db_session)
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("value", ["TRUE", " True ", True])
+    async def test_true_is_refused_however_it_is_spelled(self, db_session, value):
+        await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
+        assert "prometheus_enabled" not in await self._rows(db_session)
+
+    # --- Ruling 3: the tally counts what the preview counted ---------------
+
+    @pytest.mark.asyncio
+    async def test_refusals_are_not_counted_in_the_tally(self, db_session):
+        tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true", prometheus_token="s3cret")
+        assert (tally.restored, tally.skipped, tally.failed) == (1, 0, 0)
+
+    @pytest.mark.asyncio
+    async def test_tally_total_equals_the_preview_item_count(self, db_session):
+        """The ruling, encoded: the user is shown a number, and it has to hold.
+
+        Off by three before this change — the two name-based refusals and the
+        companion one were all counted as ``skipped`` despite never being in the
+        preview's count.
+        """
+        db_session.add(Settings(key="theme", value="light"))
+        await db_session.commit()
+
+        values = {
+            "currency": "EUR",  # inserted    -> restored
+            "theme": "dark",  # exists, overwrite off -> skipped
+            "low_stock_threshold": None,  # no value    -> skipped
+            "": "junk",  # unusable key -> failed
+            "bambu_cloud_token": "x",  # blocked     -> refused
+            "auth_enabled": "false",  # protected   -> refused
+            "prometheus_enabled": "true",  # companion   -> refused
+            "prometheus_token": "s3cret",  # blocked     -> refused
+        }
+        item_count, _ = await _service()._count_items(
+            db_session, RestoreCategory.SETTINGS, {SETTINGS_PATH: {"settings": values}}
+        )
+
+        tally = _CategoryTally()
+        await _service()._restore_settings(db_session, {"settings": values}, overwrite=False, tally=tally)
+        await db_session.commit()
+
+        assert tally.restored + tally.skipped + tally.failed == item_count
+        assert (tally.restored, tally.skipped, tally.failed) == (1, 2, 1)
+
+    @pytest.mark.asyncio
+    async def test_the_spools_tally_holds_the_same_invariant(self, db_session):
+        """Spools broke it the other way: the tally counted more than the preview.
+
+        ``_restore_spool_usage`` increments this category's tally, but the
+        preview counted only the spools and mentioned the usage records in the
+        detail — so a backup with any usage history reported a total larger than
+        the number the user was shown.
+        """
+        spools = {
+            "spools": [
+                {"id": 1, "material": "PLA", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
+                {"id": 2, "material": "PETG", "brand": "Bambu Lab", "created_at": "2026-01-05 12:00:00"},
+            ]
+        }
+        usage = {
+            "usage_history": [
+                {"id": 9, "spool_id": 1, "grams_used": 12.5, "created_at": "2026-01-06 09:00:00"},
+                {"id": 10, "spool_id": 2, "grams_used": 4.0, "created_at": "2026-01-06 10:00:00"},
+                {"id": 11, "spool_id": 404, "grams_used": 1.0, "created_at": "2026-01-06 11:00:00"},
+            ]
+        }
+        item_count, _ = await _service()._count_items(
+            db_session, RestoreCategory.SPOOLS, {SPOOLS_PATH: spools, SPOOL_USAGE_PATH: usage}
+        )
+
+        tally = _CategoryTally()
+        await _service()._restore_spools(db_session, spools, usage, False, tally, {})
+        await db_session.commit()
+
+        assert item_count == 5, "two spools plus three usage records, all of which the tally counts"
+        assert tally.restored + tally.skipped + tally.failed == item_count
+
+    @pytest.mark.asyncio
+    async def test_preview_count_drops_by_one_when_the_local_credential_is_missing(self, db_session):
+        parsed = {
+            SETTINGS_PATH: {"settings": {"currency": "EUR", "prometheus_enabled": "true", "prometheus_token": "s3cret"}}
+        }
+
+        refused_count, refused_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+        db_session.add(Settings(key="prometheus_token", value="already-set"))
+        await db_session.commit()
+        allowed_count, allowed_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+        assert refused_count == allowed_count - 1
+        assert refused_detail.code == "settingsCompanionWillSkip"
+        assert refused_detail.params == {"count": 1, "companion": 1}
+        # Nothing is being left off now, so the wording drops back to the plain
+        # credential caveat.
+        assert allowed_detail.code == "settingsCredentialsWillSkip"
+
+    # --- The exposure class: a blank backup credential is the hole ----------
+    #
+    # The rule's second condition — "the backup carried a usable credential" —
+    # is what stops it refusing an anonymous MQTT broker. It does not transfer to
+    # Prometheus: a backup taken on an instance that enabled Prometheus without a
+    # token (the field is optional and defaults to "") carries the toggle and no
+    # usable token, and writing it opens /api/v1/metrics just as wide. That is
+    # the *more* likely source of the exposure, not the less.
+
+    @pytest.mark.asyncio
+    async def test_prometheus_is_refused_when_the_backup_has_no_token_key_at_all(self, db_session):
+        tally = await self._restore(db_session, currency="EUR", prometheus_enabled="true")
+
+        rows = await self._rows(db_session)
+        assert rows == {"currency": "EUR"}
+        assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("token", ["", "   "])
+    async def test_prometheus_is_refused_when_the_backup_token_is_blank(self, db_session, token):
+        tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token=token)
+
+        assert "prometheus_enabled" not in await self._rows(db_session)
+        assert "settingsCompanionSkipped" in _codes(tally)
+
+    @pytest.mark.asyncio
+    async def test_the_preview_says_so_with_no_credential_key_to_skip(self, db_session):
+        """The wording has to survive ``blocked`` being empty.
+
+        The shared caveat counts credential-like keys *and* switches; on this
+        payload there are no credential-like keys, so "0 credential-like key(s)
+        will be skipped" would be noise.
+        """
+        parsed = {SETTINGS_PATH: {"settings": {"currency": "EUR", "prometheus_enabled": "true"}}}
+
+        count, detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+        assert count == 1
+        assert detail.code == "settingsCompanionOnlyWillSkip"
+        assert detail.params == {"companion": 1}
+
+    @pytest.mark.asyncio
+    async def test_the_availability_class_keeps_the_backup_credential_condition(self, db_session):
+        """The other half of the same change: only Prometheus loses condition 2.
+
+        Absent is treated like blank here — an anonymous broker is a working
+        config, so refusing it would be a false positive.
+
+        LDAP used to be in this list and is not any more: the same reasoning that
+        makes an anonymous bind legitimate is what let an authored payload point
+        the instance at another directory, so the family is refused outright
+        rather than judged on availability. See
+        ``test_the_whole_ldap_family_is_protected``.
+        """
+        await self._restore(db_session, mqtt_enabled="true", virtual_printer_enabled="true")
+
+        rows = await self._rows(db_session)
+        assert rows["mqtt_enabled"] == "true"
+        assert rows["virtual_printer_enabled"] == "true"
+
+    def test_every_exposure_toggle_is_a_companion_toggle(self):
+        assert _COMPANION_EXPOSURE_TOGGLES.issubset(_COMPANION_CREDENTIALS)
+
+    # --- Controls: over-refusal is the real risk here ----------------------
+
+    @pytest.mark.asyncio
+    async def test_a_usable_local_credential_lets_the_toggle_through(self, db_session):
+        db_session.add(Settings(key="prometheus_token", value="already-set"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
+
+        assert (await self._rows(db_session))["prometheus_enabled"] == "true"
+        assert not any("switched off" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_exposure_route_still_stands_down_for_a_local_token(self, db_session):
+        """Skipping condition 2 must not skip the local-state pass with it."""
+        db_session.add(Settings(key="prometheus_token", value="already-set"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, prometheus_enabled="true")
+
+        assert (await self._rows(db_session))["prometheus_enabled"] == "true"
+        assert not any("switched off" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_exposure_route_still_stands_down_when_already_on(self, db_session):
+        """The exposure pre-dates this restore either way — see ruling 3."""
+        db_session.add(Settings(key="prometheus_enabled", value="true"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true")
+
+        assert (await self._rows(db_session))["prometheus_enabled"] == "true"
+        assert not any("switched off" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_anonymous_broker_is_not_a_false_positive(self, db_session):
+        """mqtt_relay passes an empty password straight through — a real config."""
+        tally = await self._restore(db_session, mqtt_enabled="true", mqtt_broker="10.0.0.5")
+
+        assert (await self._rows(db_session))["mqtt_enabled"] == "true"
+        assert not any("switched off" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_blank_ldap_bind_password_no_longer_lets_the_toggle_through(self, db_session):
+        """The inverted control, and the reason the LDAP pair had to go.
+
+        A blank bind password used to read as "anonymous bind, a working config,
+        do not over-refuse". It reads the same way to an attacker authoring the
+        file, who wants no bind credential precisely because the directory is
+        theirs — so the availability question cannot be asked about an
+        authentication source at all.
+        """
+        await self._restore(db_session, ldap_enabled="true", ldap_bind_password="   ")
+
+        assert "ldap_enabled" not in await self._rows(db_session)
+
+    @pytest.mark.asyncio
+    async def test_turning_a_toggle_off_is_always_written(self, db_session):
+        await self._restore(db_session, prometheus_enabled="false", prometheus_token="s3cret")
+        assert (await self._rows(db_session))["prometheus_enabled"] == "false"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("value", ["1", "on", "yes"])
+    async def test_spellings_no_reader_treats_as_on_are_written(self, db_session, value):
+        await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
+        assert (await self._rows(db_session))["prometheus_enabled"] == value
+
+    @pytest.mark.asyncio
+    async def test_ha_token_in_the_environment_counts_as_usable(self, db_session, monkeypatch):
+        monkeypatch.setenv("HA_TOKEN", "from-env")
+        await self._restore(db_session, ha_enabled="true", ha_token="s3cret")
+        assert (await self._rows(db_session))["ha_enabled"] == "true"
+
+    @pytest.mark.asyncio
+    async def test_a_toggle_already_on_locally_is_written(self, db_session):
+        """The exposure pre-dates the restore, so "left switched off" would be a lie."""
+        db_session.add(Settings(key="prometheus_enabled", value="true"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true", prometheus_token="s3cret")
+
+        assert (await self._rows(db_session))["prometheus_enabled"] == "true"
+        assert not any("switched off" in note for note in _messages(tally))
+
+    # --- The map itself ----------------------------------------------------
+
+    def test_every_companion_credential_is_blocked_and_no_toggle_is(self):
+        """Guards the rule against a future edit to _SECRET_KEY_HINTS.
+
+        If a credential stopped being blocked, its toggle would travel with it
+        and the refusal would be pointless; if a toggle started being blocked,
+        the pair would never be reached at all.
+        """
+        for toggle, credential in _COMPANION_CREDENTIALS.items():
+            assert _is_blocked_setting_key(credential) is True, credential
+            assert _is_blocked_setting_key(toggle) is False, toggle
+            assert _is_protected_setting_key(toggle) is False, toggle
+
+    def test_every_environment_override_names_a_companion_credential(self):
+        assert set(_COMPANION_CREDENTIAL_ENV) <= set(_COMPANION_CREDENTIALS.values())
+
+    @pytest.mark.asyncio
+    async def test_plan_leaves_unusable_key_names_in_no_bucket(self, db_session):
+        """They are the restore's ``failed``, not a refusal."""
+        plan = await _service()._plan_settings(db_session, {"": "x", 7: "y", "currency": "EUR"})
+        assert plan == _SettingsPlan()
+
+
+class TestSpoolTagOverwrite:
+    """Overwrite must not write the backup's *other* tag key onto a matched spool.
+
+    ``tag_uid`` and ``tray_uuid`` are both in the overwrite ``setattr`` loop, and
+    neither column has a unique constraint, so writing one onto a spool matched
+    by the other silently creates a duplicate tag rather than erroring. After
+    that ``_find_spool``'s ``.first()`` is non-deterministic and an AMS tag
+    lookup resolves to an arbitrary one of the two. The same loop can also clear
+    a tag the user has scanned since the backup was taken.
+    """
+
+    def _entry(self, **overrides):
+        entry = {
+            "id": 41,
+            "material": "PLA",
+            "brand": "Bambu Lab",
+            "created_at": "2026-01-05 12:00:00",
+            "tag_uid": "TAG-A",
+            "tray_uuid": None,
+        }
+        entry.update(overrides)
+        return entry
+
+    async def _restore(self, db, entry, tally=None):
+        tally = tally or _CategoryTally()
+        await _service()._restore_spools(db, {"spools": [entry]}, None, True, tally, {})
+        await db.commit()
+        return tally
+
+    @pytest.mark.asyncio
+    async def test_an_empty_incoming_tag_does_not_clear_a_scanned_one(self, db_session):
+        """The backup predates the scan, so the local tag is the newer fact."""
+        db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-LIVE"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, self._entry(tray_uuid=None))
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.tray_uuid == "TRAY-LIVE"
+        assert any(note["code"] == "spoolTagKept" for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_a_tag_another_spool_already_holds_is_not_written(self, db_session):
+        db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A"))
+        db_session.add(Spool(material="PETG", brand="Other", tray_uuid="TRAY-B"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-B"))
+
+        holders = (await db_session.execute(select(Spool).where(Spool.tray_uuid == "TRAY-B"))).scalars().all()
+        assert len(holders) == 1, "a duplicate tray_uuid makes AMS lookups non-deterministic"
+        assert holders[0].material == "PETG"
+        assert any(note["code"] == "spoolTagKept" for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_the_note_counts_every_column_it_kept(self, db_session):
+        db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-LIVE"))
+        db_session.add(Spool(material="PETG", brand="Other", tag_uid="TAG-CLASH"))
+        await db_session.commit()
+
+        # Matched on tray_uuid, so the guard judges tag_uid: it clashes.
+        tally = await self._restore(db_session, self._entry(tag_uid="TAG-CLASH", tray_uuid="TRAY-LIVE"))
+
+        row = (await db_session.execute(select(Spool).where(Spool.tray_uuid == "TRAY-LIVE"))).scalar_one()
+        assert row.tag_uid == "TAG-A"
+        note = next(n for n in tally.notes if n["code"] == "spoolTagKept")
+        assert note["params"] == {"count": 1}
+
+    # --- Controls ----------------------------------------------------------
+
+    @pytest.mark.asyncio
+    async def test_a_free_tag_is_still_written(self, db_session):
+        """The point of overwrite: a spool that gained a tray_uuid gets it."""
+        db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-NEW"))
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.tray_uuid == "TRAY-NEW"
+        assert not any(note["code"] == "spoolTagKept" for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_an_unchanged_tag_is_not_reported_as_kept(self, db_session):
+        db_session.add(Spool(material="PLA", brand="Bambu Lab", tag_uid="TAG-A", tray_uuid="TRAY-A"))
+        await db_session.commit()
+
+        tally = await self._restore(db_session, self._entry(tray_uuid="TRAY-A"))
+
+        assert not any(note["code"] == "spoolTagKept" for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_a_new_spool_keeps_both_tags_from_the_backup(self, db_session):
+        """The guard is an overwrite-only concern; an insert is unaffected."""
+        await self._restore(db_session, self._entry(tag_uid="TAG-NEW", tray_uuid="TRAY-NEW"))
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert (row.tag_uid, row.tray_uuid) == ("TAG-NEW", "TRAY-NEW")
+
+    @pytest.mark.asyncio
+    async def test_find_spool_reports_which_key_matched(self, db_session):
+        db_session.add(Spool(material="PLA", tag_uid="TAG-A"))
+        db_session.add(Spool(material="PETG", tray_uuid="TRAY-B"))
+        await db_session.commit()
+        service = _service()
+
+        assert (await service._find_spool(db_session, {"tag_uid": "TAG-A"}))[1] == "tag_uid"
+        assert (await service._find_spool(db_session, {"tray_uuid": "TRAY-B"}))[1] == "tray_uuid"
+        assert await service._find_spool(db_session, {"tag_uid": "NOPE"}) == (None, None)
+
+
+class TestRestoreSpools:
+    def _spool_entry(self, **overrides):
+        entry = {
+            "id": 41,
+            "material": "PLA",
+            "subtype": "Basic",
+            "color_name": "Jade White",
+            "brand": "Bambu Lab",
+            "tag_uid": "AABBCCDD",
+            "created_at": "2026-01-05 12:00:00",
+            "weight_used": 120.5,
+        }
+        entry.update(overrides)
+        return entry
+
+    @pytest.mark.asyncio
+    async def test_inserts_without_reusing_backup_id(self, db_session):
+        """The backup's spool.id belongs to an unrelated row today."""
+        db_session.add(Spool(material="PETG"))  # occupies id 1
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        payload = {"spools": [self._spool_entry(id=1)]}
+
+        await _service()._restore_spools(db_session, payload, None, False, tally, {})
+        await db_session.commit()
+
+        spools = (await db_session.execute(select(Spool))).scalars().all()
+        assert len(spools) == 2
+        restored = next(s for s in spools if s.tag_uid == "AABBCCDD")
+        assert restored.id != 1
+        assert restored.material == "PLA"
+
+    @pytest.mark.asyncio
+    async def test_matches_existing_spool_by_tag_uid(self, db_session):
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_existing_spool_by_tray_uuid(self, db_session):
+        db_session.add(Spool(material="PLA", tray_uuid="1234" * 8))
+        await db_session.commit()
+        tally = _CategoryTally()
+        entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8)
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_tagless_spool_by_descriptive_composite(self, db_session):
+        """Manually added spools have no tag, so fall back to created_at + description."""
+        db_session.add(
+            Spool(
+                material="PLA",
+                subtype="Basic",
+                color_name="Jade White",
+                brand="Bambu Lab",
+                created_at=datetime(2026, 1, 5, 12, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._spool_entry(tag_uid=None)
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_overwrite_updates_matched_spool(self, db_session):
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.color_name == "Jade White"
+        assert row.weight_used == 120.5
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session):
+        """Second restore of the same backup must match, not duplicate."""
+        service = _service()
+        payload = {"spools": [self._spool_entry(tag_uid=None)]}
+
+        await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
+        await db_session.commit()
+        await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        spools = (await db_session.execute(select(Spool))).scalars().all()
+        assert len(spools) == 1
+        assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
+
+    @pytest.mark.asyncio
+    async def test_usage_history_spool_id_is_remapped(self, db_session):
+        """Usage rows must point at the new local spool id, not the backup's."""
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {
+                    "id": 900,
+                    "spool_id": 41,
+                    "printer_id": None,
+                    "print_name": "benchy.3mf",
+                    "archive_id": None,
+                    "weight_used": 12.0,
+                    "percent_used": 5,
+                    "status": "completed",
+                    "created_at": "2026-02-01 09:00:00",
+                }
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        spool = (await db_session.execute(select(Spool))).scalar_one()
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.spool_id == spool.id
+        assert row.print_name == "benchy.3mf"
+
+    @pytest.mark.asyncio
+    async def test_usage_history_archive_id_is_remapped(self, db_session):
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {
+                    "spool_id": 41,
+                    "archive_id": 77,
+                    "weight_used": 1.0,
+                    "created_at": "2026-02-01 09:00:00",
+                }
+            ]
+        }
+        archive = PrintArchive(filename="a.3mf", file_path="", file_size=1)
+        db_session.add(archive)
+        await db_session.flush()
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.archive_id == archive.id
+
+    @pytest.mark.asyncio
+    async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
+        tally = _CategoryTally()
+        usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]}
+
+        await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
+        await db_session.commit()
+
+        assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
+        assert tally.skipped == 1
+        assert any("their spool is not in this backup's spool list" in note for note in _messages(tally))
+        # No remedy is offered, because none exists: overwrite does not change
+        # which spools land in the map (a skipped spool is mapped anyway), and
+        # usage history is always restored alongside the spools category.
+        assert not any("overwrite" in note.lower() for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
+        """A skipped spool is still mapped, so its usage rows are not "unresolved".
+
+        This is why the note above offers no remedy: turning overwrite on would
+        not rescue anything, and saying so misdescribed which records are lost.
+        """
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
+        await db_session.commit()
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        spool = (await db_session.execute(select(Spool))).scalar_one()
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.spool_id == spool.id
+        assert not any("spool list" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
+        service = _service()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+
+        await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
+        await db_session.commit()
+        await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
+        assert len(rows) == 1
+
+    @pytest.mark.asyncio
+    async def test_dropped_archive_link_is_explained(self, db_session):
+        """Spools without archives nulls every usage -> archive link, silently."""
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "archive_id": 7, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"},
+                {"spool_id": 41, "archive_id": 8, "weight_used": 2.0, "created_at": "2026-02-01 10:00:00"},
+                {"spool_id": 41, "weight_used": 3.0, "created_at": "2026-02-01 11:00:00"},
+            ]
+        }
+
+        # Empty archive_id_map: the archives category wasn't selected, so its
+        # payload was never fetched and there is nothing to match against.
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
+        assert len(rows) == 3
+        assert all(row.archive_id is None for row in rows)
+        # Only the two that had a link to lose are counted.
+        assert any("2 usage record(s) restored without their print-history link" in n for n in _messages(tally))
+        assert any("select Print archives alongside" in n for n in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_no_note_when_every_archive_link_resolves(self, db_session):
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "archive_id": 7, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+        archive = PrintArchive(filename="linked.3mf", file_path="", file_size=1)
+        db_session.add(archive)
+        await db_session.flush()
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {7: archive.id})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.archive_id == archive.id
+        assert not any("print-history link" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_dangling_printer_id_is_cleared(self, db_session):
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.printer_id is None
+
+
+class TestServerDefaultCreatedAtDedupe:
+    """Dedupe against rows whose ``created_at`` came from the server default.
+
+    Every test above seeds its "existing" row through the restore itself, which
+    binds ``created_at`` explicitly — so both sides end up in SQLAlchemy's
+    microsecond format and a SQL ``==`` matches. Rows the *application* created
+    do not: SQLite fills ``server_default=func.now()`` from
+    ``CURRENT_TIMESTAMP``, which has second precision, and the two strings
+    never compare equal. That is the ordinary case — a user's own spools and
+    their print history — and it duplicated the lot on every restore.
+    """
+
+    @staticmethod
+    async def _native_spool(db_session, **kwargs):
+        """A spool created the way the app creates one: no explicit created_at."""
+        spool = Spool(material="PLA", brand="Bambu Lab", subtype="Basic", color_name="Jade White", **kwargs)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    def _entry_for(self, spool, **overrides):
+        """The backup entry the collector writes for ``spool``."""
+        entry = {
+            "id": 41,
+            "material": spool.material,
+            "brand": spool.brand,
+            "subtype": spool.subtype,
+            "color_name": spool.color_name,
+            "created_at": str(spool.created_at),
+        }
+        entry.update(overrides)
+        return entry
+
+    @pytest.mark.asyncio
+    async def test_find_spool_matches_on_the_composite_fallback(self, db_session):
+        spool = await self._native_spool(db_session)
+
+        found, matched_on = await _service()._find_spool(db_session, self._entry_for(spool))
+
+        assert found is not None and found.id == spool.id
+        assert matched_on is None  # the composite, not a tag column
+
+    @pytest.mark.asyncio
+    async def test_a_tagless_spool_is_not_duplicated(self, db_session):
+        spool = await self._native_spool(db_session)
+        payload = {"spools": [self._entry_for(spool)]}
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, payload, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_overwrite_updates_the_original_instead_of_inserting(self, db_session):
+        spool = await self._native_spool(db_session)
+        payload = {"spools": [self._entry_for(spool, weight_used=250.0)]}
+
+        await _service()._restore_spools(db_session, payload, None, True, _CategoryTally(), {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.id == spool.id
+        assert row.weight_used == 250.0
+
+    @pytest.mark.asyncio
+    async def test_a_second_spool_added_later_stays_distinct(self, db_session):
+        """The composite is only unique because of created_at, so the Python
+        comparison has to stay exact — not a same-day tolerance."""
+        spool = await self._native_spool(db_session)
+        twin = Spool(material=spool.material, brand=spool.brand, subtype=spool.subtype, color_name=spool.color_name)
+        twin.created_at = spool.created_at + timedelta(hours=1)
+        db_session.add(twin)
+        await db_session.commit()
+
+        found, _ = await _service()._find_spool(db_session, self._entry_for(spool))
+
+        assert found.id == spool.id
+
+    @pytest.mark.asyncio
+    async def test_existing_usage_history_is_not_re_inserted(self, db_session):
+        spool = await self._native_spool(db_session, tag_uid="AABBCCDD")
+        usage_row = SpoolUsageHistory(spool_id=spool.id, print_name="b.3mf", weight_used=5.0)
+        db_session.add(usage_row)
+        await db_session.commit()
+        await db_session.refresh(usage_row)
+
+        tally = _CategoryTally()
+        inventory = {"spools": [self._entry_for(spool, tag_uid="AABBCCDD")]}
+        usage = {
+            "usage_history": [
+                {
+                    "spool_id": 41,
+                    "print_name": "b.3mf",
+                    "weight_used": 5.0,
+                    "created_at": str(usage_row.created_at),
+                }
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
+        assert len(rows) == 1
+        assert tally.skipped == 2  # the spool and its one usage row
+
+    @pytest.mark.asyncio
+    async def test_a_genuinely_new_usage_row_still_lands(self, db_session):
+        """Dedupe by timestamp must not swallow a repeat of the same print."""
+        spool = await self._native_spool(db_session, tag_uid="AABBCCDD")
+        usage_row = SpoolUsageHistory(spool_id=spool.id, print_name="b.3mf", weight_used=5.0)
+        db_session.add(usage_row)
+        await db_session.commit()
+        await db_session.refresh(usage_row)
+
+        inventory = {"spools": [self._entry_for(spool, tag_uid="AABBCCDD")]}
+        usage = {
+            "usage_history": [
+                {
+                    "spool_id": 41,
+                    "print_name": "b.3mf",
+                    "weight_used": 5.0,
+                    "created_at": str(usage_row.created_at + timedelta(days=1)),
+                }
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(SpoolUsageHistory))).scalars().all()) == 2
+
+
+class TestRestoreArchives:
+    def _archive_entry(self, **overrides):
+        entry = {
+            "id": 77,
+            "filename": "benchy.3mf",
+            "file_size": 2048,
+            "content_hash": "abc123",
+            "print_name": "Benchy",
+            "status": "completed",
+            "started_at": "2026-03-01 10:00:00",
+            "completed_at": "2026-03-01 11:00:00",
+            "created_at": "2026-03-01 10:00:00",
+            "quantity": 1,
+            "is_favorite": False,
+        }
+        entry.update(overrides)
+        return entry
+
+    @pytest.mark.asyncio
+    async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
+        """print_archives.file_path is NOT NULL but is not in the backup."""
+        tally = _CategoryTally()
+        id_map: dict[int, int] = {}
+
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map)
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.file_path == ""
+        assert row.filename == "benchy.3mf"
+        assert row.id != 77
+        assert id_map == {77: row.id}
+        assert any("metadata only" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_matches_existing_archive_by_hash_and_start(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
+        await db_session.commit()
+
+        rows = (await db_session.execute(select(PrintArchive))).scalars().all()
+        assert len(rows) == 1
+        assert rows[0].file_path == "/data/benchy.3mf"
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_filename_and_start_without_hash(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(content_hash=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_archive_with_no_started_at_by_hash(self, db_session):
+        """started_at is NULL for re-sliced archives, so it cannot be required.
+
+        Gating both match branches on it meant these rows never matched: every
+        restore re-inserted them and overwrite mode could never update them.
+        """
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=None,
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(started_at=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_started_at_still_discriminates_when_present(self, db_session):
+        """A NULL-tolerant match must not collapse rows that do differ."""
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        # Same file, no start time recorded — a different row, not that one.
+        entry = self._archive_entry(started_at=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session):
+        """A backup keeps soft-deleted rows, so the flag has to survive.
+
+        Their row is retained on purpose (stats keep counting the filament and
+        energy), so without carrying deleted_at a restore turns an archive the
+        user deleted back into a visible one.
+        """
+        tally = _CategoryTally()
+        entry = self._archive_entry(deleted_at="2026-03-02 08:00:00")
+
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0)
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=datetime(2026, 3, 5, 9, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        # The backup predates the deletion, so its copy is live.
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0)
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session):
+        """The entry has to *say* the archive was live — absent no longer means null.
+
+        A commit taken before the collector wrote ``deleted_at`` carries no
+        opinion about it, and overwrite now leaves the column alone in that
+        case; see ``TestRestoredArchiveOwnership``.
+        """
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=datetime(2026, 3, 5, 9, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._archive_entry(deleted_at=None)]}, True, tally, {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at is None
+        assert tally.restored == 1
+        assert any("visible again" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                notes="old",
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(notes="restored note")
+        await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.notes == "restored note"
+        # The 3MF on disk must not be orphaned by a metadata restore.
+        assert row.file_path == "/data/benchy.3mf"
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_dangling_printer_and_project_links_are_cleared(self, db_session):
+        tally = _CategoryTally()
+        entry = self._archive_entry(printer_id=4242, project_id=4343)
+
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.printer_id is None
+        assert row.project_id is None
+        assert any("no longer exist" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
+        printer = await printer_factory()
+        tally = _CategoryTally()
+        entry = self._archive_entry(printer_id=printer.id)
+
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.printer_id == printer.id
+
+    @pytest.mark.asyncio
+    async def test_non_dict_entry_counts_as_failed(self, db_session):
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {})
+        assert tally.failed == 1
+
+
+class TestRestoreKprofiles:
+    @staticmethod
+    def _live(
+        slot_id,
+        filament_id="GFA00",
+        name="Bambu PLA",
+        setting_id="PFUS123",
+        extruder_id=0,
+        nozzle_id="HS00-0.4",
+    ):
+        """One profile as the printer currently reports it.
+
+        ``extruder_id`` and ``nozzle_id`` mirror ``KProfile`` (bambu_mqtt.py),
+        which has carried both all along; single-nozzle printers report
+        extruder 0. Both are non-default fields there, so a live profile always
+        has them — the double must too, or it licenses code that would break on
+        the real object.
+        """
+        return SimpleNamespace(
+            slot_id=slot_id,
+            filament_id=filament_id,
+            name=name,
+            setting_id=setting_id,
+            extruder_id=extruder_id,
+            nozzle_id=nozzle_id,
+        )
+
+    def _client(self, live=None, sent="7", ack=(True, "")):
+        """A connected printer client.
+
+        ``set_kprofiles_batch`` returns the sequence_id it published under, not
+        a success flag (#2718), and the verdict arrives separately from
+        ``await_cali_ack`` as ``(ok, detail)``.
+        """
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value=sent)
+        client.await_cali_ack = AsyncMock(return_value=ack)
+        client.get_kprofiles = AsyncMock(return_value=list(live or []))
+        return client
+
+    def _payload(self, serial="00M09A123456789", nozzle="0.4"):
+        return {
+            f"kprofiles/{serial}/{nozzle}.json": {
+                "version": "1.0",
+                "printer_serial": serial,
+                "nozzle_diameter": nozzle,
+                "profiles": [
+                    {
+                        "slot_id": 0,
+                        "name": "Bambu PLA",
+                        "k_value": "0.020000",
+                        "filament_id": "GFA00",
+                        "nozzle_id": "HS00-0.4",
+                        "extruder_id": 0,
+                        "setting_id": "PFUS123",
+                    }
+                ],
+            }
+        }
+
+    @pytest.mark.asyncio
+    async def test_sends_batch_to_connected_printer(self, db_session, printer_factory):
+        printer = await printer_factory(serial_number="00M09A123456789")
+        client = self._client()
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        client.set_kprofiles_batch.assert_called_once()
+        profiles, nozzle = client.set_kprofiles_batch.call_args.args
+        assert nozzle == "0.4"
+        assert profiles[0]["name"] == "Bambu PLA"
+        assert profiles[0]["filament_id"] == "GFA00"
+        assert tally.restored == 1
+        assert manager.get_client.call_args.args == (printer.id,)
+
+    @pytest.mark.asyncio
+    async def test_always_warns_to_verify_on_the_printer(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client()
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        # A refusal is now read and counted failed, so the caveat is narrowed to
+        # what is genuinely left uncertain: a printer that never answers.
+        assert any("verify the profiles on the printer" in note for note in _messages(tally))
+        assert any("does not answer still counts as restored" in note for note in _messages(tally))
+        assert not any("without acknowledgement" in note for note in _messages(tally))
+        assert any("always overwrite" in note for note in _messages(tally))
+
+    # --- cali_idx is resolved live, never taken from the backup -------------
+    #
+    # Regression cover for the silent no-op found testing on an X1E: the backup
+    # stored cali_idx 8151, a Bambuddy edit re-keyed the profile to 4606, and
+    # the restore aimed extrusion_cali_set at 8151. The printer dropped it and
+    # the tally still said "1 restored".
+
+    @pytest.mark.asyncio
+    async def test_uses_the_live_cali_idx_not_the_backed_up_slot(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]["slot_id"] = 8151
+        client = self._client(live=[self._live(slot_id=4606)])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        client.get_kprofiles.assert_awaited_once_with(nozzle_diameter="0.4")
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == 4606, "must address the slot that exists now"
+        assert profiles[0]["cali_idx"] != 8151, "must not reuse the backup's cali_idx"
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_on_name_when_setting_id_was_regenerated(self, db_session, printer_factory):
+        # A delete-then-add edit mints a fresh setting_id, so the name carries
+        # the match instead.
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(live=[self._live(slot_id=4606, setting_id="PF9999999999")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == 4606
+        # The live setting_id wins: it is what the printer associates with the slot.
+        assert profiles[0]["setting_id"] == "PF9999999999"
+
+    @pytest.mark.asyncio
+    async def test_unmatched_profile_is_added_rather_than_aimed_at_a_dead_slot(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(live=[])  # printer has nothing for this nozzle
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == -1, "-1 tells the printer to add a new profile"
+        assert profiles[0]["setting_id"] == "PFUS123", "falls back to the backed-up preset"
+        assert any("added as new profiles" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_different_filament_is_not_treated_as_a_match(self, db_session, printer_factory):
+        # Same slot, different filament — matching on slot alone would clobber
+        # an unrelated profile.
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(live=[self._live(slot_id=4606, filament_id="GFB99", name="Bambu PLA")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == -1
+
+    @pytest.mark.asyncio
+    async def test_unreadable_live_index_degrades_to_adding(self, db_session, printer_factory):
+        # A failed read must not abort the restore.
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client()
+        client.get_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt timeout"))
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == -1
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_sole_profile_for_a_filament_matches_without_setting_id_or_name(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entry = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]
+        entry["setting_id"] = None
+        entry["name"] = ""
+        client = self._client(live=[self._live(slot_id=4606, setting_id="PFOTHER", name="Renamed")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == 4606
+
+    @pytest.mark.asyncio
+    async def test_ambiguous_filament_without_discriminator_is_added_not_guessed(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entry = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0]
+        entry["setting_id"] = None
+        entry["name"] = ""
+        client = self._client(live=[self._live(slot_id=1, setting_id="A"), self._live(slot_id=2, setting_id="B")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == -1, "two candidates and nothing to tell them apart"
+
+    @pytest.mark.asyncio
+    async def test_two_entries_cannot_claim_the_same_live_slot(self, db_session, printer_factory):
+        """One live profile cannot stand in for two backed-up ones (#2656).
+
+        Both entries fell through to the single-candidate arm, both took
+        cali_idx 4606, both went into the batch — so the second overwrote the
+        first on the printer while the tally counted two restored. Reachable
+        whenever the user has deleted one of a pair since the backup, because
+        the delete-then-add re-key is what strips the setting_id match.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
+        entries[0].update(setting_id="PFGONE1", name="PLA Basic")
+        entries.append({**entries[0], "setting_id": "PFGONE2", "name": "PLA Matte"})
+        client = self._client(live=[self._live(slot_id=4606, setting_id="PFUS123", name="Bambu PLA")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert [p["cali_idx"] for p in profiles] == [4606, -1], "the displaced entry has to be added, not aliased"
+        assert sum(1 for p in profiles if p["cali_idx"] == 4606) == 1
+        assert any("added as new profiles" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_displaced_entry_does_not_inherit_the_claimed_setting_id(self, db_session, printer_factory):
+        """An add-as-new keeps its own preset, or it lands on top of the match anyway.
+
+        cali_idx -1 is only safe if the rest of the payload doesn't point at the
+        profile the first entry just claimed — the generated-setting_id fallback
+        reads setting_id when cali_idx is -1.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
+        entries[0].update(setting_id="PFGONE1", name="PLA Basic")
+        entries.append({**entries[0], "setting_id": "PFGONE2", "name": "PLA Matte"})
+        client = self._client(live=[self._live(slot_id=4606, setting_id="PFUS123", name="Bambu PLA")])
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, _CategoryTally())
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["setting_id"] == "PFUS123", "the match prefers the live preset"
+        assert profiles[1]["setting_id"] == "PFGONE2", "the displaced entry keeps its own"
+
+    @pytest.mark.asyncio
+    async def test_two_entries_matching_two_live_profiles_keep_their_own_slots(self, db_session, printer_factory):
+        """Control: the guard must not displace a legitimate second match."""
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
+        entries.append({**entries[0], "setting_id": "PFUS456", "name": "Bambu PETG"})
+        client = self._client(
+            live=[
+                self._live(slot_id=4606, setting_id="PFUS123", name="Bambu PLA"),
+                self._live(slot_id=4607, setting_id="PFUS456", name="Bambu PETG"),
+            ]
+        )
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert [p["cali_idx"] for p in profiles] == [4606, 4607]
+        assert not any("added as new profiles" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_claimed_slot_does_not_make_an_ambiguous_pair_matchable(self, db_session, printer_factory):
+        """Two live profiles for one filament stay ambiguous after one is taken.
+
+        The single-candidate fallback is judged against every candidate, not the
+        unclaimed ones — otherwise claiming the first would leave exactly one
+        "available" and turn a guess the code deliberately refuses into a match.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
+        entries[0].update(setting_id="PFUS123", name="Bambu PLA")
+        entries.append({**entries[0], "setting_id": None, "name": ""})
+        client = self._client(
+            live=[
+                self._live(slot_id=1, setting_id="PFUS123", name="Bambu PLA"),
+                self._live(slot_id=2, setting_id="PFOTHER", name="Renamed"),
+            ]
+        )
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, _CategoryTally())
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert [p["cali_idx"] for p in profiles] == [1, -1]
+
+    # --- the match is scoped to the extruder it was calibrated on -----------
+    #
+    # get_kprofiles reads per nozzle *diameter*, so on a dual-nozzle printer
+    # both extruders come back in one list. Scoping candidates on filament_id
+    # alone let one extruder's calibration be written over the other's.
+
+    @pytest.mark.asyncio
+    async def test_each_extruders_profile_lands_on_its_own_extruder(self, db_session, printer_factory):
+        """The same preset calibrated on both extruders of an H2D.
+
+        Both live profiles share a filament_id *and* a setting_id, so the
+        setting_id arm matched whichever the printer happened to list first —
+        and with an entry per extruder the two swapped slots, each overwriting
+        the other's calibration while the tally counted both restored.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        entries = payload["kprofiles/00M09A123456789/0.4.json"]["profiles"]
+        entries.append({**entries[0], "extruder_id": 1, "nozzle_id": "HS00-0.4-R"})
+        client = self._client(
+            live=[
+                # Right extruder first, which is what made the bug bite.
+                self._live(slot_id=1001, extruder_id=1),
+                self._live(slot_id=1000, extruder_id=0),
+            ]
+        )
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert [(p["extruder_id"], p["cali_idx"]) for p in profiles] == [(0, 1000), (1, 1001)]
+        assert tally.restored == 2
+        assert not any("added as new profiles" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_other_extruders_profile_is_not_a_candidate(self, db_session, printer_factory):
+        """One backed-up entry, and the only live profile is the other extruder's.
+
+        Adding as new is the right answer: extruder 0's calibration is not a
+        stand-in for extruder 1's, however well the filament and preset line up.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(live=[self._live(slot_id=1001, extruder_id=1)])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == -1
+        assert any("added as new profiles" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_entry_without_an_extruder_id_still_matches(self, db_session, printer_factory):
+        """Control: a pre-#2656 backup carries no extruder_id.
+
+        A missing key must leave the match exactly as it was, not turn every
+        entry into an add.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0].pop("extruder_id")
+        client = self._client(live=[self._live(slot_id=4606)])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == 4606
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_a_live_index_that_reports_no_extruder_still_matches(self, db_session, printer_factory):
+        """Control: the same, for a printer whose profiles carry no extruder_id."""
+        await printer_factory(serial_number="00M09A123456789")
+        live = SimpleNamespace(slot_id=4606, filament_id="GFA00", name="Bambu PLA", setting_id="PFUS123")
+        client = self._client(live=[live])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["cali_idx"] == 4606
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_a_backup_without_a_nozzle_id_omits_the_key(self, db_session, printer_factory):
+        """``set_kprofiles_batch`` defaults it, and only an absent key lets it.
+
+        The default is ``p.get("nozzle_id", f"HS00-{diameter}")``, which a key
+        present-and-None defeats — the batch would publish a null nozzle_id to
+        the printer. Printers that omit the field (#1748) are the reason the
+        default exists, so it has to be reachable.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        payload["kprofiles/00M09A123456789/0.4.json"]["profiles"][0].pop("nozzle_id")
+        # No live match either, so neither source can supply one.
+        client = self._client(live=[])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert "nozzle_id" not in profiles[0]
+
+    @pytest.mark.asyncio
+    async def test_the_backups_nozzle_id_is_used_when_nothing_is_live(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(live=[])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["nozzle_id"] == "HS00-0.4"
+
+    @pytest.mark.asyncio
+    async def test_the_live_nozzle_id_beats_the_backups(self, db_session, printer_factory):
+        """The nozzle may have been swapped since the backup; we write to the
+        one that is fitted now, exactly as with setting_id."""
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(live=[self._live(slot_id=4606, nozzle_id="SS00-0.4")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["nozzle_id"] == "SS00-0.4"
+
+    @pytest.mark.asyncio
+    async def test_a_live_profile_without_a_nozzle_id_falls_back_to_the_backup(self, db_session, printer_factory):
+        """Same defensive read as extruder_id: not every live profile carries
+        every field."""
+        await printer_factory(serial_number="00M09A123456789")
+        live = SimpleNamespace(slot_id=4606, filament_id="GFA00", name="Bambu PLA", setting_id="PFUS123")
+        client = self._client(live=[live])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        profiles, _ = client.set_kprofiles_batch.call_args.args
+        assert profiles[0]["nozzle_id"] == "HS00-0.4"
+
+    @pytest.mark.asyncio
+    async def test_unknown_serial_is_skipped_with_reason(self, db_session):
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager"):
+            await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally)
+
+        assert tally.restored == 0
+        assert tally.skipped == 1
+        assert any("No printer with serial NOSUCH" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
+        client = MagicMock()
+        client.state.connected = False
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.skipped == 1
+        assert tally.failed == 0
+        assert any("not connected" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_non_dict_profile_is_counted_failed_not_dropped(self, db_session, printer_factory):
+        """The online path was the one place an entry left the tally entirely.
+
+        ``_kprofile_profile_count`` counts it, so the offline and
+        printer-missing paths already count the same entry skipped and the
+        failure path counts it outstanding — only the connected path skipped it
+        silently, so restored + skipped + failed came up short of the number the
+        preview showed.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        path = next(iter(payload))
+        payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=self._client())
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert tally.failed == 1
+        assert tally.restored + tally.skipped + tally.failed == 2
+
+    @pytest.mark.asyncio
+    async def test_the_offline_path_counts_the_same_entry(self, db_session, printer_factory):
+        """Control for the above: the two paths have to agree on the total."""
+        await printer_factory(serial_number="00M09A123456789")
+        payload = self._payload()
+        path = next(iter(payload))
+        payload[path]["profiles"] = [payload[path]["profiles"][0], "nonsense"]
+        client = MagicMock()
+        client.state.connected = False
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert tally.restored + tally.skipped + tally.failed == 2
+
+    @pytest.mark.asyncio
+    async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=None)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_publish_failure_counts_as_failed(self, db_session, printer_factory):
+        # None is what set_kprofiles_batch returns when it could not publish —
+        # a disconnected client. There is no ack to wait for in that case.
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(sent=None)
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.failed == 1
+        assert tally.restored == 0
+        assert "kprofilesSendFailed" in _codes(tally)
+        assert "kprofilesRefused" not in _codes(tally), "nothing was sent, so the printer refused nothing"
+        client.await_cali_ack.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_publish_exception_is_contained(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down"))
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.failed == 1
+
+    # --- the printer's verdict decides the tally, not the publish ------------
+    #
+    # #2718 changed set_kprofiles_batch from returning a bool to returning the
+    # sequence_id it published under. A sequence_id string is truthy, so a
+    # restore that branches on the return value alone reports every refused
+    # write as saved — the defect that fix closed in every other caller.
+
+    @pytest.mark.asyncio
+    async def test_awaits_the_ack_for_the_sequence_id_it_was_given(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(sent="4211")
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        client.await_cali_ack.assert_awaited_once_with("4211")
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_a_refused_batch_counts_failed_not_restored(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
+        client = self._client(ack=(False, "invalid tray_id"))
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.restored == 0
+        assert tally.failed == 1
+        assert "kprofilesRefused" in _codes(tally)
+        assert "kprofilesSendFailed" not in _codes(tally), "it was sent — the printer answered no"
+        note = next(n for n in tally.notes if n["code"] == "kprofilesRefused")
+        assert note["params"]["reason"] == "invalid tray_id", "the printer's own reason has to survive"
+        assert "Shelf Printer" in note["message"] and "invalid tray_id" in note["message"]
+
+    @pytest.mark.asyncio
+    async def test_a_silent_printer_still_counts_restored(self, db_session, printer_factory):
+        # maziggy's rule, and await_cali_ack's own contract: no answer is not
+        # evidence of refusal. Firmware that predates the ack never answers.
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client(ack=(True, "no acknowledgement from printer"))
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.restored == 1
+        assert tally.failed == 0
+        assert "kprofilesRefused" not in _codes(tally)
+
+    @pytest.mark.asyncio
+    async def test_an_unreadable_ack_does_not_fail_the_batch(self, db_session, printer_factory):
+        # Same situation one layer up: the write most likely landed, so this
+        # degrades the way a timeout does rather than inventing a failure.
+        await printer_factory(serial_number="00M09A123456789")
+        client = self._client()
+        client.await_cali_ack = AsyncMock(side_effect=RuntimeError("mqtt down"))
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.restored == 1
+        assert tally.failed == 0
+
+    @pytest.mark.asyncio
+    async def test_one_refused_nozzle_does_not_condemn_the_other(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
+        client = self._client()
+        client.await_cali_ack = AsyncMock(side_effect=[(False, "busy"), (True, "")])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert tally.restored == 1
+        assert tally.failed == 1
+
+    @pytest.mark.asyncio
+    async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
+        client = self._client()
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert client.set_kprofiles_batch.call_count == 2
+        assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"}
+        assert tally.restored == 2
+
+    @pytest.mark.asyncio
+    async def test_empty_payload_is_noted(self, db_session):
+        tally = _CategoryTally()
+        await _service()._restore_kprofiles(db_session, {}, tally)
+        assert _codes(tally) == ["noData"]
+
+
+class TestSoftDeletedArchiveRoundTrip:
+    """The two halves of the soft-delete fix only work together.
+
+    The collector keeps soft-deleted rows on purpose (their stats still count),
+    so if it doesn't write ``deleted_at`` there is nothing for the restore to
+    carry across and a deleted archive comes back visible. Covered end to end
+    because each half looks harmless on its own.
+    """
+
+    @pytest.mark.asyncio
+    async def test_deleted_at_survives_collect_then_restore(self, db_session):
+        from backend.app.services.github_backup import github_backup_service
+
+        deleted_at = datetime(2026, 3, 5, 9, 0, 0)
+        db_session.add(
+            PrintArchive(
+                filename="trashed.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-trashed",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=deleted_at,
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+        payload = files[ARCHIVES_PATH]
+        assert payload["archives"][0]["deleted_at"] == str(deleted_at)
+
+        # Restore that payload into an instance where the row is gone entirely.
+        await db_session.execute(PrintArchive.__table__.delete())
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, payload, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
+
+
+class TestRestoredArchiveOwnership:
+    """A restored archive without an owner is invisible to the person who owns it.
+
+    ``created_by_id`` is not attribution, it is the column the access check runs
+    on: ``_ensure_archive_visible`` fails closed on NULL (404 for any caller
+    without ``archives:read_all``) and the list paths filter
+    ``created_by_id == user.id``. So on a multi-user instance the tally reported
+    archives restored while their owner could neither list nor open them.
+    """
+
+    def _entry(self, **overrides):
+        entry = {
+            "id": 77,
+            "filename": "benchy.3mf",
+            "file_size": 2048,
+            "content_hash": "abc123",
+            "started_at": "2026-03-01 10:00:00",
+            "created_at": "2026-03-01 10:00:00",
+        }
+        entry.update(overrides)
+        return entry
+
+    async def _user(self, db, username="alice"):
+        user = User(username=username, role="operator")
+        db.add(user)
+        await db.flush()
+        return user
+
+    @pytest.mark.asyncio
+    async def test_owner_is_carried_across(self, db_session):
+        user = await self._user(db_session)
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == user.id
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session):
+        """The archive is still worth having; an admin can reassign it."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id is None
+        assert tally.restored == 1 and tally.failed == 0
+        assert any("owner cleared" in note and "archives:read_all" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session):
+        tally = _CategoryTally()
+        archives = [
+            self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_id=4242),
+            self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_id=4243),
+        ]
+
+        await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
+        await db_session.commit()
+
+        assert sum(1 for note in _messages(tally) if "owner cleared" in note) == 1
+
+    @pytest.mark.asyncio
+    async def test_a_backup_without_the_key_still_restores_and_says_so(self, db_session):
+        """Backups taken before the collector recorded it just can't know the owner.
+
+        The archive is worth restoring anyway, but it lands ownerless — which is
+        a 404 for everyone without ``archives:read_all``. Reporting N restored
+        while the user who asked for them sees none is the failure mode the note
+        exists to prevent.
+        """
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id is None
+        assert tally.restored == 1
+        assert not any("owner cleared" in note for note in _messages(tally))
+        assert any("without an owner" in note and "archives:read_all" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_explicitly_ownerless_archive_is_reported_too(self, db_session):
+        """Same consequence, so the same note: the source row had no owner either."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=None)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_stale_owner_is_not_reported_twice(self, db_session):
+        """One row, one cause, one note — the cleared-owner branch already spoke."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert any("owner cleared" in note for note in _messages(tally))
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_known_owner_is_not_reported(self, db_session):
+        user = await self._user(db_session)
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_unknown_owner_note_is_not_emitted_on_overwrite(self, db_session):
+        """Overwrite keeps the local owner, so there is nothing to warn about."""
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):
+        alice = await self._user(db_session, "alice")
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=alice.id)]}, True, _CategoryTally(), {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_the_backup_names_someone_unknown(self, db_session):
+        """A name this instance cannot resolve is not an instruction to clear.
+
+        Same epistemic state as the absent key below -- the backup has not told
+        us who owns this archive -- so it takes the same action. Writing NULL
+        instead inflicted the 404-for-its-own-owner failure on a local row that
+        was fine, and on a rebuilt instance every user renamed since the backup
+        took a whole archive history with them.
+        """
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=4242, created_by_username="carol")]},
+            True,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id, "an owner we cannot resolve must not displace one we can"
+        assert tally.restored == 1
+        # Nothing was taken away, so there is nothing to warn about -- the same
+        # rule the absent-key case follows.
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_the_backups_id_is_stale(self, db_session):
+        """The pre-username fallback takes the rule too."""
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": [self._entry(created_by_id=4242)]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_unresolvable_name_still_lands_ownerless_on_insert(self, db_session):
+        """Control for the two above: with no local row there is nothing to keep.
+
+        The archive is still restored -- it is worth having -- but it is
+        invisible to everyone without archives:read_all, so it is said out loud.
+        """
+        await self._user(db_session, "alice")
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=4242, created_by_username="carol")]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id is None
+        assert tally.restored == 1 and tally.failed == 0
+        assert any("does not have" in note and "archives:read_all" in note for note in _messages(tally))
+        # One cause, one note -- the ownerless-insert note must not pile on.
+        assert not any("does not record one" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_the_backup_predates_the_key(self, db_session):
+        """A pre-#2656 commit must not blank the owner of a row that was fine.
+
+        The entry carries no ``created_by_id`` at all, so there is nothing to
+        write. Treating that as an explicit null inflicted the exact bug the
+        column was added to fix — a 404 for the owner — on rows the restore had
+        no business touching, silently, while still counting them restored.
+        """
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id, "an old backup does not know the owner, so it must not clear one"
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_deleted_at_alone_when_the_backup_predates_the_key(self, db_session):
+        """The mirror case: an old commit must not un-delete, and must not claim to.
+
+        ``archivesUndeleted`` reads the same absent value, so the un-delete was
+        not merely wrong but unannounced.
+        """
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=datetime(2026, 3, 4, 8, 0, 0),
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == datetime(2026, 3, 4, 8, 0, 0), "an old backup must not resurrect a deleted archive"
+        assert not any("visible again" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_still_clears_an_owner_the_backup_explicitly_nulls(self, db_session):
+        """Control: absent is ignored, but an explicit null is still honoured.
+
+        A current-format backup of an unowned archive has to be able to say so,
+        or overwrite stops meaning "make the local row match the backup".
+        """
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=None)]}, True, _CategoryTally(), {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id is None
+
+    @pytest.mark.asyncio
+    async def test_overwrite_still_undeletes_when_the_backup_explicitly_nulls(self, db_session):
+        """Control for the deleted_at half, with the note that goes with it."""
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=datetime(2026, 3, 4, 8, 0, 0),
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": [self._entry(deleted_at=None)]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at is None
+        assert any("visible again" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_owner_survives_collect_then_restore(self, db_session):
+        """Both halves, because each looks harmless alone.
+
+        The collector never wrote the key, so there was nothing for the restore
+        to carry across even once it wanted to.
+        """
+        from backend.app.services.github_backup import github_backup_service
+
+        user = await self._user(db_session)
+        db_session.add(
+            PrintArchive(
+                filename="owned.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-owned",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=user.id,
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+        payload = files[ARCHIVES_PATH]
+        assert payload["archives"][0]["created_by_id"] == user.id
+
+        await db_session.execute(PrintArchive.__table__.delete())
+        await db_session.commit()
+
+        await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == user.id, "a restored archive its owner cannot see is not restored"
+
+
+class TestArchiveOwnerNaturalKey:
+    """``created_by_username`` decides the owner; the id is only the fallback.
+
+    Restoring onto a rebuilt instance is this feature's main use case, and the
+    users table renumbers there. A raw ``created_by_id`` cannot tell a correct
+    match from a live id that now belongs to somebody else, so the id path hands
+    one person's print history to another under ``ARCHIVES_READ_OWN`` — silently,
+    because ``archivesOwnerCleared`` only fires for an id that is *absent*.
+    ``username`` is unique on ``users``, so resolving on it turns that silent
+    misattribution into an ownerless row with a note.
+    """
+
+    def _entry(self, **overrides):
+        entry = {
+            "id": 77,
+            "filename": "benchy.3mf",
+            "file_size": 2048,
+            "content_hash": "abc123",
+            "started_at": "2026-03-01 10:00:00",
+            "created_at": "2026-03-01 10:00:00",
+        }
+        entry.update(overrides)
+        return entry
+
+    async def _user(self, db, username):
+        user = User(username=username, role="operator")
+        db.add(user)
+        await db.flush()
+        return user
+
+    @pytest.mark.asyncio
+    async def test_the_name_resolves_across_a_renumbered_users_table(self, db_session):
+        """The whole point: same person, different id, restore still finds them."""
+        alice = await self._user(db_session, "alice")
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=alice.id + 500, created_by_username="alice")]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_name_beats_a_live_id_belonging_to_someone_else(self, db_session):
+        """The misattribution case, and the one the id path cannot even detect.
+
+        Both ids exist locally, so the id path would write bob's — a valid row,
+        no note, alice's print history readable by bob.
+        """
+        alice = await self._user(db_session, "alice")
+        bob = await self._user(db_session, "bob")
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]},
+            False,
+            _CategoryTally(),
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id, "the name is the natural key; the id is from another instance"
+
+    @pytest.mark.asyncio
+    async def test_a_renamed_owner_lands_ownerless_with_a_note(self, db_session):
+        """No local match, so nothing to resolve — and the id is not a fallback here.
+
+        Falling back to it is exactly the guess the name exists to prevent, so
+        the row is cleared and said out loud instead.
+        """
+        bob = await self._user(db_session, "bob")
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id is None
+        assert tally.restored == 1 and tally.failed == 0
+        assert any("does not have" in note and "archives:read_all" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_unmatched_note_is_emitted_once_for_many_rows(self, db_session):
+        tally = _CategoryTally()
+        archives = [
+            self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_username="alice"),
+            self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_username="carol"),
+        ]
+
+        await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
+        await db_session.commit()
+
+        assert sum(1 for note in _messages(tally) if "does not have" in note) == 1
+
+    @pytest.mark.asyncio
+    async def test_an_unmatched_name_does_not_also_claim_no_owner_was_recorded(self, db_session):
+        """One row, one cause, one note — as with the stale-id branch."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_username="alice")]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert any("does not have" in note for note in _messages(tally))
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_pre_username_backup_still_resolves_on_the_id(self, db_session):
+        """The fallback has to keep working — every backup taken before this change."""
+        alice = await self._user(db_session, "alice")
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=alice.id)]}, False, _CategoryTally(), {}
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == alice.id
+
+    @pytest.mark.asyncio
+    async def test_an_explicitly_ownerless_archive_reads_as_no_owner_not_as_unmatched(self, db_session):
+        """A current-format backup of an unowned archive writes both keys null."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=None, created_by_username=None)]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        assert any("without an owner" in note for note in _messages(tally))
+        assert not any("does not have" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_neither_key_is_present(self, db_session):
+        """The absent-is-not-null rule still holds now that there are two keys."""
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, _CategoryTally(), {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id
+
+    @pytest.mark.asyncio
+    async def test_the_name_survives_collect_then_restore(self, db_session):
+        """Both halves, because the collector writing nothing looks harmless alone."""
+        from backend.app.services.github_backup import github_backup_service
+
+        alice = await self._user(db_session, "alice")
+        db_session.add(
+            PrintArchive(
+                filename="owned.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-owned",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=alice.id,
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+        payload = files[ARCHIVES_PATH]
+        assert payload["archives"][0]["created_by_username"] == "alice"
+
+        # Rebuilt instance: same person, and nothing else holds their old id.
+        await db_session.execute(PrintArchive.__table__.delete())
+        await db_session.execute(User.__table__.delete())
+        await db_session.commit()
+        rebuilt = await self._user(db_session, "alice")
+        await db_session.commit()
+
+        await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == rebuilt.id
+
+    @pytest.mark.asyncio
+    async def test_the_collector_names_no_owner_for_an_unowned_archive(self, db_session):
+        """Null rather than absent, so a restore can tell "none" from "not recorded"."""
+        from backend.app.services.github_backup import github_backup_service
+
+        db_session.add(
+            PrintArchive(
+                filename="unowned.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-unowned",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+
+        entry = files[ARCHIVES_PATH]["archives"][0]
+        assert entry["created_by_username"] is None
+        assert "created_by_username" in entry
+
+
+class TestCategoryPathMapping:
+    def setup_method(self):
+        self.service = _service()
+        self.available = [
+            "backup_metadata.json",
+            SETTINGS_PATH,
+            SPOOLS_PATH,
+            SPOOL_USAGE_PATH,
+            ARCHIVES_PATH,
+            "kprofiles/SERIAL1/0.4.json",
+            "kprofiles/SERIAL1/0.8.json",
+            "cloud_profiles/filament.json",
+        ]
+
+    def test_spools_includes_usage_history(self):
+        paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available)
+        assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH]
+
+    def test_kprofiles_globs_all_serials_and_nozzles(self):
+        paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available)
+        assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"]
+
+    def test_absent_paths_are_omitted(self):
+        paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"])
+        assert paths == []
+
+    def test_cloud_profiles_are_not_a_restore_category(self):
+        assert "cloud_profiles" not in {c.value for c in RestoreCategory}
+
+
+class TestMutex:
+    @pytest.mark.asyncio
+    async def test_restore_refuses_while_a_backup_is_running(self):
+        service = _service()
+        with patch("backend.app.services.github_backup.github_backup_service") as backup:
+            backup.is_running = True
+            result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
+
+        assert result["success"] is False
+        assert "backup is currently running" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_restore_refuses_while_another_restore_is_running(self):
+        service = _service()
+        service._running_restore = True
+
+        result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
+
+        assert result["success"] is False
+        assert "restore is already running" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_backup_refuses_while_a_restore_is_running(self):
+        from backend.app.services.github_backup import GitHubBackupService
+
+        backup_service = GitHubBackupService()
+        with patch("backend.app.services.github_restore.github_restore_service") as restore:
+            restore.is_running = True
+            result = await backup_service.run_backup(1, trigger="manual")
+
+        assert result["success"] is False
+        assert "restore is currently running" in result["message"]
+
+
+class TestMqttRelayReconfigure:
+    """Restoring mqtt_* rows has to reach the live relay, not just the table."""
+
+    @pytest.mark.asyncio
+    async def test_reconfigures_from_the_committed_rows(self, db_session):
+        db_session.add(Settings(key="mqtt_enabled", value="true"))
+        db_session.add(Settings(key="mqtt_broker", value="restored.local"))
+        db_session.add(Settings(key="mqtt_port", value="8883"))
+        db_session.add(Settings(key="mqtt_use_tls", value="true"))
+        # Never restorable (credential blocklist), so it comes from the row that
+        # was already there.
+        db_session.add(Settings(key="mqtt_password", value="kept"))
+        await db_session.commit()
+        tally = _CategoryTally()
+        relay = MagicMock()
+        relay.configure = AsyncMock(return_value=True)
+
+        with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_broker"}, tally)
+
+        relay.configure.assert_awaited_once()
+        sent = relay.configure.await_args.args[0]
+        assert sent["mqtt_enabled"] is True
+        assert sent["mqtt_broker"] == "restored.local"
+        assert sent["mqtt_port"] == 8883
+        assert sent["mqtt_use_tls"] is True
+        assert sent["mqtt_password"] == "kept"
+        assert sent["mqtt_topic_prefix"] == "bambuddy"
+        assert tally.notes == []
+
+    @pytest.mark.asyncio
+    async def test_no_reconnect_when_no_mqtt_key_was_written(self, db_session):
+        """configure() tears the connection down, so don't call it for a theme change."""
+        tally = _CategoryTally()
+        relay = MagicMock()
+        relay.configure = AsyncMock()
+
+        with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            await _service()._reconfigure_mqtt_relay(db_session, {"currency", "theme"}, tally)
+
+        relay.configure.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_broker_failure_is_noted_not_fatal(self, db_session):
+        tally = _CategoryTally()
+        relay = MagicMock()
+        relay.configure = AsyncMock(side_effect=OSError("no route to broker"))
+
+        with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_enabled"}, tally)
+
+        assert any("restart Bambuddy" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):
+        db_session.add(Settings(key="mqtt_broker", value="old.local"))
+        await db_session.commit()
+        written: set[str] = set()
+        payload = {
+            "settings": {
+                "mqtt_broker": "new.local",
+                "currency": "EUR",
+                "mqtt_password": "leaked",
+                "auth_enabled": "false",
+            }
+        }
+
+        await _service()._restore_settings(
+            db_session, payload, overwrite=True, tally=_CategoryTally(), keys_written=written
+        )
+
+        # Skipped keys are not "written", or a blocked mqtt_password would
+        # trigger a pointless reconnect.
+        assert written == {"mqtt_broker", "currency"}
+
+    @pytest.mark.asyncio
+    async def test_keys_skipped_for_overwrite_off_are_not_reported(self, db_session):
+        db_session.add(Settings(key="mqtt_broker", value="old.local"))
+        await db_session.commit()
+        written: set[str] = set()
+
+        await _service()._restore_settings(
+            db_session,
+            {"settings": {"mqtt_broker": "new.local"}},
+            overwrite=False,
+            tally=_CategoryTally(),
+            keys_written=written,
+        )
+
+        assert written == set()
+
+    @pytest.mark.asyncio
+    async def test_a_refused_mqtt_enabled_is_not_reported_as_written(self, db_session):
+        """So the relay reconfigures from the *local* mqtt_enabled, not the backup's.
+
+        The companion rule refuses ``mqtt_enabled`` when the backup's password
+        cannot come across and there is none stored locally. It must not then
+        appear in ``keys_written``, or _reconfigure_mqtt_relay would be asked to
+        bring up a broker connection the restore deliberately declined to enable.
+        """
+        written: set[str] = set()
+
+        await _service()._restore_settings(
+            db_session,
+            {"settings": {"mqtt_enabled": "true", "mqtt_password": "refused", "mqtt_broker": "new.local"}},
+            overwrite=True,
+            tally=_CategoryTally(),
+            keys_written=written,
+        )
+
+        assert written == {"mqtt_broker"}
+
+
+class TestApplyOrdering:
+    """_apply must not hold SQLite's single writer any longer than one category.
+
+    Two ways to overrun the 15 s busy_timeout, and the same fix closes both: the
+    K-profile phase awaits an unresponsive printer (3 x 5 s per printer/nozzle),
+    and a database category is one SELECT per row or per key against a few
+    thousand archives plus a full usage history. Every concurrent writer in the
+    app fails with "database is locked" while either runs.
+    """
+
+    def _recording_service(self, calls: list[str]):
+        service = _service()
+        # Sync side effects on purpose: an AsyncMock returns a coroutine its
+        # side_effect hands back rather than awaiting it, so an async recorder
+        # would never run.
+        service._restore_archives = AsyncMock(side_effect=lambda *a, **k: calls.append("archives"))
+        service._restore_spools = AsyncMock(side_effect=lambda *a, **k: calls.append("spools"))
+        service._restore_settings = AsyncMock(side_effect=lambda *a, **k: calls.append("settings"))
+        service._restore_kprofiles = AsyncMock(side_effect=lambda *a, **k: calls.append("kprofiles"))
+        return service
+
+    @pytest.mark.asyncio
+    async def test_every_database_category_commits_before_the_next_one_starts(self):
+        calls: list[str] = []
+        service = self._recording_service(calls)
+        db = MagicMock()
+        db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
+
+        await service._apply(
+            db,
+            {},
+            [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS],
+            False,
+        )
+
+        assert calls == ["archives", "commit", "spools", "commit", "settings", "commit"]
+
+    @pytest.mark.asyncio
+    async def test_the_printer_phase_runs_with_no_write_transaction_open(self):
+        """The K-profile phase is last, and everything before it is already committed."""
+        calls: list[str] = []
+        service = self._recording_service(calls)
+        db = MagicMock()
+        db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
+
+        await service._apply(
+            db,
+            {},
+            [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS, RestoreCategory.KPROFILES],
+            False,
+        )
+
+        assert calls == ["archives", "commit", "spools", "commit", "kprofiles"]
+
+    @pytest.mark.asyncio
+    async def test_a_tally_is_recorded_only_after_its_category_commits(self):
+        """What run_restore's failure path relies on to report honestly.
+
+        A tally present in ``results`` has to mean "these rows are on disk". If
+        the commit raises, the category must not appear — otherwise a failed
+        restore reports rows that rolled back.
+        """
+        service = self._recording_service([])
+        db = MagicMock()
+        db.commit = AsyncMock(side_effect=RuntimeError("database is locked"))
+        results: dict = {}
+
+        with pytest.raises(RuntimeError):
+            await service._apply(db, {}, [RestoreCategory.ARCHIVES], False, results=results)
+
+        assert results == {}
+
+    @pytest.mark.asyncio
+    async def test_the_callers_results_dict_is_populated_in_place(self):
+        """So a raise mid-run still leaves the committed categories visible."""
+        service = self._recording_service([])
+        db = MagicMock()
+        db.commit = AsyncMock()
+        service._restore_spools = AsyncMock(side_effect=RuntimeError("boom"))
+        results: dict = {}
+
+        with pytest.raises(RuntimeError):
+            await service._apply(db, {}, [RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS], False, results=results)
+
+        assert set(results) == {"archives"}, "archives committed before spools ran; the caller must see it"
+
+
+class TestKprofilePhaseFailure:
+    """The K-profile phase runs after _apply has committed everything else.
+
+    So an exception there used to reach run_restore's handler, which reports
+    ``success: False`` with an empty ``results`` — over archive, spool and
+    settings rows that are durable on disk. The honest-reporting theme of this
+    feature inverted on exactly the path where it matters, and the post-commit
+    MQTT reconfigure (downstream of the raise, inside the same try) was skipped,
+    leaving the relay pointed at the pre-restore broker.
+    """
+
+    _SETTINGS = {"version": "1.0", "settings": {"mqtt_broker": "restored.local", "currency": "EUR"}}
+
+    def _payload(self, profiles=None):
+        return {
+            SETTINGS_PATH: dict(self._SETTINGS),
+            "kprofiles/00M09A123456789/0.4.json": {
+                "profiles": [{"filament_id": "GFA00", "name": "Bambu PLA"}] if profiles is None else profiles
+            },
+        }
+
+    def _session_patch(self, db_session):
+        cm = AsyncMock()
+        cm.__aenter__ = AsyncMock(return_value=db_session)
+        cm.__aexit__ = AsyncMock(return_value=None)
+        return patch("backend.app.services.github_restore.async_session", return_value=cm)
+
+    async def _configured_service(self, db_session, payload):
+        from backend.app.models.github_backup import GitHubBackupConfig
+
+        config = GitHubBackupConfig(repository_url="https://github.com/o/r", access_token="tok", provider="github")
+        db_session.add(config)
+        await db_session.commit()
+
+        service = _service()
+        service._resolve_ref = AsyncMock(return_value=("a" * 40, "", None))
+        service._read_categories = AsyncMock(return_value=(payload, ""))
+        return service, config.id
+
+    @pytest.mark.asyncio
+    async def test_the_committed_categories_are_still_reported(self, db_session):
+        service = _service()
+        service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
+
+        results = await service._apply(
+            db_session,
+            self._payload(),
+            [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES],
+            False,
+        )
+
+        assert results[RestoreCategory.SETTINGS.value].restored == 2
+        rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert rows == {"mqtt_broker": "restored.local", "currency": "EUR"}, "committed before the phase that failed"
+
+    @pytest.mark.asyncio
+    async def test_the_failure_is_counted_and_explained(self, db_session):
+        service = _service()
+        service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
+
+        results = await service._apply(
+            db_session,
+            self._payload(profiles=[{"filament_id": "GFA00"}, {"filament_id": "GFB99"}]),
+            [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES],
+            False,
+        )
+
+        tally = results[RestoreCategory.KPROFILES.value]
+        assert tally.failed == 2, "every profile the payload carried is unaccounted for"
+        assert tally.restored == 0
+        assert _codes(tally) == ["kprofilesStepFailed"]
+        assert tally.notes[0]["params"]["reason"] == "mqtt exploded"
+
+    @pytest.mark.asyncio
+    async def test_the_relay_is_reconfigured_even_though_the_phase_failed(self, db_session):
+        """The reconfigure sits downstream of the raise in run_restore's try."""
+        service, config_id = await self._configured_service(db_session, self._payload())
+        service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
+        relay = MagicMock()
+        relay.configure = AsyncMock(return_value=True)
+
+        with self._session_patch(db_session), patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            result = await service.run_restore(
+                config_id, "a" * 40, [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES]
+            )
+
+        assert result["success"] is True
+        assert result["results"][RestoreCategory.SETTINGS.value]["restored"] == 2
+        assert result["results"][RestoreCategory.KPROFILES.value]["failed"] == 1
+        relay.configure.assert_awaited_once()
+        assert relay.configure.await_args.args[0]["mqtt_broker"] == "restored.local"
+
+    @pytest.mark.asyncio
+    async def test_a_failure_before_the_commit_still_reports_nothing_restored(self, db_session):
+        """Control: rolling back and saying so is right when nothing landed."""
+        service, config_id = await self._configured_service(db_session, self._payload())
+        service._restore_settings = AsyncMock(side_effect=RuntimeError("read failed"))
+        relay = MagicMock()
+        relay.configure = AsyncMock(return_value=True)
+
+        with self._session_patch(db_session), patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            result = await service.run_restore(
+                config_id, "a" * 40, [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES]
+            )
+
+        assert result["success"] is False
+        assert result["results"] == {}
+        assert (await db_session.execute(select(Settings))).scalars().first() is None
+        relay.configure.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_later_category_failing_still_reports_the_earlier_one(self, db_session):
+        """The database phase commits per category, so this is now reachable there too.
+
+        Archives land and are committed; settings then raises. Reporting an empty
+        result would be the same false "nothing was restored" the K-profile split
+        already had to fix, over rows that are durable on disk.
+        """
+        service, config_id = await self._configured_service(
+            db_session,
+            {
+                ARCHIVES_PATH: {
+                    "version": "1.0",
+                    "archives": [
+                        {
+                            "id": 1,
+                            "filename": "benchy.3mf",
+                            "content_hash": "hash-later",
+                            "started_at": "2026-03-01 10:00:00",
+                        }
+                    ],
+                },
+                SETTINGS_PATH: dict(self._SETTINGS),
+            },
+        )
+        service._restore_settings = AsyncMock(side_effect=RuntimeError("read failed"))
+
+        with self._session_patch(db_session):
+            result = await service.run_restore(
+                config_id, "a" * 40, [RestoreCategory.ARCHIVES, RestoreCategory.SETTINGS]
+            )
+
+        assert result["success"] is False
+        assert result["results"][RestoreCategory.ARCHIVES.value]["restored"] == 1
+        assert RestoreCategory.SETTINGS.value not in result["results"], "settings rolled back; do not claim it"
+        assert (await db_session.execute(select(PrintArchive))).scalars().first() is not None
+
+    @pytest.mark.asyncio
+    async def test_a_malformed_profiles_value_is_a_skipped_category_not_a_raise(self, db_session, printer_factory):
+        """Belt-and-braces: the pre-loop count ran ahead of the per-call guards.
+
+        ``sum(len(c.get("profiles") or []) ...)`` raises TypeError on a
+        hand-edited or truncated backup whose ``profiles`` is not a list — and it
+        raises after the database categories are already on disk.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value="7")
+        client.get_kprofiles = AsyncMock(return_value=[])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(profiles=5), tally)
+
+        client.set_kprofiles_batch.assert_not_called()
+        assert (tally.restored, tally.failed) == (0, 0)
+        assert "kprofilesStepFailed" not in _codes(tally)
+
+
+class TestResolveRef:
+    @pytest.mark.asyncio
+    async def test_concrete_sha_passes_through_without_an_api_call(self):
+        service = _service()
+        service.list_commits = AsyncMock()
+        config = MagicMock(branch="main")
+
+        resolved, error, commit = await service._resolve_ref(config, "abc1234")
+
+        assert resolved == "abc1234"
+        assert error == ""
+        # Nothing was fetched, so there is no entry to describe it with.
+        assert commit is None
+        service.list_commits.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_head_resolves_to_the_tip_sha(self):
+        service = _service()
+        service.list_commits = AsyncMock(
+            return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]}
+        )
+        config = MagicMock(branch="main")
+
+        resolved, error, commit = await service._resolve_ref(config, "HEAD")
+
+        assert resolved == "tipsha1"
+        assert error == ""
+        # Handed back so preview does not list commits a second time just to
+        # describe the one it already fetched.
+        assert commit == {"sha": "tipsha1"}
+
+    @pytest.mark.asyncio
+    async def test_empty_history_is_an_error(self):
+        service = _service()
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
+        config = MagicMock(branch="main")
+
+        resolved, error, commit = await service._resolve_ref(config, "HEAD")
+
+        assert resolved is None
+        assert "no commits" in error
+        assert commit is None
+
+
+class TestDescribeCommit:
+    """A preview that says `commit: null` gives the user no idea what they picked."""
+
+    def _config(self):
+        return MagicMock(branch="main", provider="github", repository_url="https://github.com/o/r", access_token="t")
+
+    def _entry(self, sha: str):
+        return {"sha": sha, "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-01T10:00:00Z"}
+
+    @pytest.mark.asyncio
+    async def test_an_abbreviated_ref_matches_a_full_sha_in_the_window(self):
+        """REF_PATTERN accepts 7 characters; providers return 40.
+
+        The old exact `==` therefore never matched an abbreviated ref, even when
+        the commit was right there in the top 20.
+        """
+        service = _service()
+        full = "abc1234" + "0" * 33
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry(full)]})
+
+        found = await service._describe_commit(self._config(), "abc1234")
+
+        assert found is not None
+        assert found["sha"] == full
+
+    @pytest.mark.asyncio
+    async def test_a_full_sha_matches_an_abbreviated_entry(self):
+        service = _service()
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry("abc1234")]})
+
+        found = await service._describe_commit(self._config(), "abc1234" + "0" * 33)
+
+        assert found is not None
+
+    @pytest.mark.asyncio
+    async def test_a_commit_outside_the_window_is_fetched_directly(self):
+        service = _service()
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": [self._entry("f" * 40)]})
+        backend = MagicMock()
+        backend.get_commit = AsyncMock(return_value={"success": True, "commit": self._entry("old" + "0" * 37)})
+
+        with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
+            found = await service._describe_commit(self._config(), "old" + "0" * 37)
+
+        assert found["sha"] == "old" + "0" * 37
+        backend.get_commit.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_a_direct_lookup_failure_is_not_fatal(self):
+        """It is a subject line: render the preview without it."""
+        service = _service()
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
+        backend = MagicMock()
+        backend.get_commit = AsyncMock(return_value={"success": False, "message": "boom", "commit": None})
+
+        with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
+            assert await service._describe_commit(self._config(), "a" * 40) is None
+
+    @pytest.mark.asyncio
+    async def test_the_window_scan_is_not_run_twice(self):
+        """_resolve_ref already listed commits for HEAD; preview reuses that."""
+        service = _service()
+        tip = self._entry("t" * 40)
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": [tip]})
+
+        resolved, _, commit = await service._resolve_ref(self._config(), "HEAD")
+
+        assert resolved == "t" * 40
+        assert commit == tip
+        assert service.list_commits.await_count == 1

+ 97 - 0
frontend/src/__tests__/components/GitHubBackupSettings.history.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * Backup History must distinguish a restore from a backup (#2656).
+ *
+ * A restore writes a `github_backup_logs` row too — same table, same statuses,
+ * `trigger: 'restore'`. The table rendered date / status / commit only, so the
+ * row read as a successful backup dated now, while "Last backup" said something
+ * else entirely. The column below is the only thing telling the two apart.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, within } from '@testing-library/react';
+import { render } from '../utils';
+import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getGitHubBackupConfig: vi.fn().mockResolvedValue({
+      id: 1,
+      repository_url: 'https://github.com/someone/backup',
+      enabled: true,
+    }),
+    getGitHubBackupStatus: vi.fn().mockResolvedValue({ is_running: false, configured: true, enabled: true }),
+    getGitHubBackupLogs: vi.fn(),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+    getLocalBackups: vi.fn().mockResolvedValue([]),
+    getLocalBackupStatus: vi.fn().mockResolvedValue({
+      enabled: false,
+      is_running: false,
+      last_backup_at: null,
+      last_status: null,
+      last_message: null,
+      next_run: null,
+    }),
+    checkLocalBackupPath: vi.fn().mockResolvedValue({ writable: true, path: '/data', code: 'ok' }),
+  },
+}));
+
+const log = (id: number, trigger: string) => ({
+  id,
+  config_id: 1,
+  started_at: '2026-08-02T09:00:00',
+  completed_at: '2026-08-02T09:00:05',
+  status: 'success',
+  trigger,
+  commit_sha: null,
+  files_changed: 0,
+  error_message: null,
+});
+
+const historyRows = async () => {
+  const table = (await screen.findByText('History')).closest('div[id="card-backup-history"]');
+  return within(table as HTMLElement).getAllByRole('row').slice(1); // drop the header
+};
+
+describe('GitHubBackupSettings — backup history', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('labels a restore as a restore, not a successful backup', async () => {
+    vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'restore')]);
+
+    render(<GitHubBackupSettings />);
+
+    const [row] = await historyRows();
+    expect(within(row).getByText('Restore')).toBeInTheDocument();
+  });
+
+  it('tells the three trigger kinds apart in one history', async () => {
+    vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([
+      log(1, 'restore'),
+      log(2, 'scheduled'),
+      log(3, 'manual'),
+    ]);
+
+    render(<GitHubBackupSettings />);
+
+    const rows = await historyRows();
+    expect(within(rows[0]).getByText('Restore')).toBeInTheDocument();
+    expect(within(rows[1]).getByText('Backup (scheduled)')).toBeInTheDocument();
+    expect(within(rows[2]).getByText('Backup (manual)')).toBeInTheDocument();
+  });
+
+  it('falls back to the raw trigger rather than blanking an unknown one', async () => {
+    vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'something-new')]);
+
+    render(<GitHubBackupSettings />);
+
+    const [row] = await historyRows();
+    expect(within(row).getByText('something-new')).toBeInTheDocument();
+  });
+});

+ 115 - 0
frontend/src/__tests__/components/GitHubBackupSettingsPermissions.test.tsx

@@ -0,0 +1,115 @@
+/**
+ * The Git Restore button must respect github:restore client-side (#2656).
+ *
+ * All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
+ * user without it gets a 403 the moment the modal opens its preview. Offering
+ * the button anyway is an action that cannot work.
+ *
+ * Scoped to the button on purpose: the backup card itself stays visible,
+ * because configuring backups is a separate permission.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
+import { setAuthToken } from '../../api/client';
+
+afterEach(() => {
+  server.resetHandlers();
+  setAuthToken(null);
+});
+
+/** A configured backup, which is what makes the action row render at all. */
+function mockConfiguredBackup() {
+  server.use(
+    http.get('*/api/v1/github-backup/config', () =>
+      HttpResponse.json({
+        id: 1,
+        provider: 'github',
+        repository_url: 'https://github.com/test/repo',
+        branch: 'main',
+        enabled: true,
+        schedule_enabled: false,
+        schedule_type: 'daily',
+        schedule_time: '02:00',
+        backup_kprofiles: true,
+        backup_cloud_profiles: false,
+        backup_spools: true,
+        backup_archives: true,
+        backup_settings: true,
+        last_backup_at: null,
+        last_backup_status: null,
+      }),
+    ),
+    http.get('*/api/v1/github-backup/status', () =>
+      HttpResponse.json({
+        configured: true,
+        enabled: true,
+        is_running: false,
+        restore_running: false,
+        progress: null,
+        last_backup_at: null,
+        last_backup_status: null,
+        next_run: null,
+      }),
+    ),
+    http.get('*/api/v1/github-backup/logs', () => HttpResponse.json([])),
+  );
+}
+
+function mockUserWith(permissions: string[]) {
+  setAuthToken('test-token', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () =>
+      HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+    ),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({ id: 1, username: 'operator', is_admin: false, permissions }),
+    ),
+  );
+}
+
+describe('GitHubBackupSettings - github:restore gate', () => {
+  it('hides the Restore from Git button without the permission', async () => {
+    mockConfiguredBackup();
+    mockUserWith(['settings:read', 'settings:update']);
+
+    render(<GitHubBackupSettings />);
+
+    // Wait for the action row itself, so an absent button is a real absence
+    // rather than the card simply not having rendered yet.
+    await waitFor(() => expect(screen.getByRole('button', { name: /Backup Now/i })).toBeInTheDocument());
+    expect(screen.queryByRole('button', { name: /Restore from Git/i })).not.toBeInTheDocument();
+  });
+
+  it('shows it when the user has github:restore', async () => {
+    mockConfiguredBackup();
+    mockUserWith(['settings:read', 'github:restore']);
+
+    render(<GitHubBackupSettings />);
+
+    await waitFor(() =>
+      expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
+    );
+  });
+
+  it('shows it when auth is disabled entirely', async () => {
+    // hasPermission returns true with auth off, and it must stay that way -
+    // a single-user instance has no permissions to grant.
+    mockConfiguredBackup();
+    server.use(
+      http.get('*/api/v1/auth/status', () =>
+        HttpResponse.json({ auth_enabled: false, requires_setup: false }),
+      ),
+    );
+
+    render(<GitHubBackupSettings />);
+
+    await waitFor(() =>
+      expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
+    );
+  });
+});

+ 696 - 0
frontend/src/__tests__/components/GitHubRestoreModal.test.tsx

@@ -0,0 +1,696 @@
+/**
+ * Tests for the Restore from Git Backup modal (#2656).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { delay, http, HttpResponse } from 'msw';
+import { QueryClient } from '@tanstack/react-query';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { GitHubRestoreModal } from '../../components/GitHubRestoreModal';
+
+const mockCommits = {
+  success: true,
+  message: 'OK',
+  branch: 'main',
+  commits: [
+    {
+      sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
+      message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC',
+      author: 'Bambuddy',
+      date: '2026-07-02T10:00:00Z',
+    },
+    {
+      sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111',
+      message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC',
+      author: 'Bambuddy',
+      date: '2026-07-01T10:00:00Z',
+    },
+  ],
+};
+
+const mockPreview = {
+  success: true,
+  message: 'OK',
+  ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
+  commit: mockCommits.commits[0],
+  metadata_version: '1.0',
+  // The server describes each caveat as a code plus typed params, carrying the
+  // English rendering as `detail` for i18next's defaultValue (#2656). Note the
+  // fixture's English deliberately differs from en.ts, so an assertion on the
+  // locale string proves the code was translated rather than echoed.
+  categories: [
+    {
+      category: 'archives',
+      available: true,
+      item_count: 30,
+      detail: 'raw server English, should not be rendered',
+      detail_code: 'archivesMetadataOnly',
+      detail_params: {},
+    },
+    { category: 'spools', available: true, item_count: 4, detail: null, detail_code: null, detail_params: {} },
+    { category: 'settings', available: true, item_count: 12, detail: null, detail_code: null, detail_params: {} },
+    {
+      category: 'kprofiles',
+      available: false,
+      item_count: 0,
+      detail: 'raw server English, should not be rendered',
+      detail_code: 'notPresent',
+      detail_params: {},
+    },
+  ],
+};
+
+// The default fixture has no K-profiles in the commit, which is the one category
+// whose row cannot be selected there.
+const mockPreviewWithKprofiles = {
+  ...mockPreview,
+  categories: mockPreview.categories.map((c) =>
+    c.category === 'kprofiles'
+      ? { category: 'kprofiles', available: true, item_count: 3, detail: null, detail_code: null, detail_params: {} }
+      : c
+  ),
+};
+
+type JsonBody = Record<string, unknown>;
+
+function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
+  server.use(
+    http.get('/api/v1/github-backup/commits', () =>
+      HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
+    ),
+    http.get('/api/v1/github-backup/restore/preview', () =>
+      HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
+    ),
+  );
+}
+
+describe('GitHubRestoreModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockEndpoints();
+  });
+
+  it('renders the title and commit picker', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
+    });
+    expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
+  });
+
+  it('defaults to the latest commit and lists recent commits', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
+    expect(select.value).toBe('HEAD');
+    await waitFor(() => {
+      expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
+    });
+    // Commits are labelled by short SHA.
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
+      expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
+    });
+  });
+
+  it('shows item counts for categories present in the commit', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('30 in backup')).toBeInTheDocument();
+    });
+    expect(screen.getByText('4 in backup')).toBeInTheDocument();
+    expect(screen.getByText('12 in backup')).toBeInTheDocument();
+  });
+
+  it('translates preview caveats rather than echoing the server English', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(
+        screen.getByText('Metadata only - 3MF files and thumbnails are not in a Git backup')
+      ).toBeInTheDocument();
+    });
+    expect(screen.queryAllByText('raw server English, should not be rendered')).toHaveLength(0);
+  });
+
+  it('falls back to the server English for a code it does not know', async () => {
+    // A newer backend adding a detail_code this build has no key for must not
+    // print the raw key at the user. Same defaultValue arm backup.pathCheck uses.
+    mockEndpoints({
+      preview: {
+        ...mockPreview,
+        categories: [
+          {
+            category: 'spools',
+            available: true,
+            item_count: 4,
+            detail: 'Something a future release explains',
+            detail_code: 'somethingThisBuildHasNeverHeardOf',
+            detail_params: {},
+          },
+        ],
+      },
+    });
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Something a future release explains')).toBeInTheDocument();
+    });
+  });
+
+  it('disables a category that is absent from the commit', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
+    });
+
+    const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    // Four categories in fixed order: archives, spools, settings, kprofiles.
+    expect(checkboxes).toHaveLength(4);
+    expect(checkboxes[3].disabled).toBe(true);
+    expect(checkboxes[0].disabled).toBe(false);
+  });
+
+  it('keeps Restore disabled until a category is selected', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
+    expect(restoreButton).toBeDisabled();
+
+    // Wait for the preview to populate the category list before selecting.
+    const checkboxes = await waitFor(() => {
+      const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
+      expect(found).toHaveLength(4);
+      return found;
+    });
+    await userEvent.click(checkboxes[1]);
+
+    await waitFor(() => expect(restoreButton).not.toBeDisabled());
+    expect(screen.getByText('1 selected')).toBeInTheDocument();
+  });
+
+  it('requires confirmation before sending the restore', async () => {
+    let restoreCalls = 0;
+    server.use(
+      http.post('/api/v1/github-backup/restore', async () => {
+        restoreCalls += 1;
+        return HttpResponse.json({
+          success: true,
+          message: 'Restored 4 item(s) from aaa1111',
+          log_id: 3,
+          ref: mockPreview.ref,
+          results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
+        });
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+    // Confirm dialog appears; nothing sent yet.
+    await waitFor(() => {
+      expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
+    });
+    expect(restoreCalls).toBe(0);
+  });
+
+  it('sends the selected categories and shows per-category results', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('/api/v1/github-backup/restore', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({
+          success: true,
+          message: 'Restored 4 item(s) from aaa1111',
+          log_id: 3,
+          ref: mockPreview.ref,
+          results: {
+            spools: {
+              restored: 4,
+              skipped: 1,
+              failed: 0,
+              notes: [
+                {
+                  code: 'spoolUsageUnresolved',
+                  params: { count: 1 },
+                  message: 'raw server English, should not be rendered',
+                },
+              ],
+            },
+          },
+        });
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
+    });
+    // The commit posted is the sha the preview resolved to, not the symbolic
+    // 'HEAD' the picker defaults to: re-resolving server-side would restore a
+    // backup that landed after the preview the user actually approved.
+    expect(body).toMatchObject({
+      categories: ['spools'],
+      overwrite_existing: false,
+      ref: mockPreview.ref,
+    });
+    expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
+    // The locale string with {{count}} filled in, not the server's English —
+    // which is what makes the note translatable for a non-English user.
+    expect(
+      screen.getByText(/^1 usage record\(s\) skipped - their spool is not in this backup's spool list/)
+    ).toBeInTheDocument();
+    expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument();
+  });
+
+  it('drops the selection while a newly-picked commit is still being inspected', async () => {
+    // Switching commits keeps `selected` (it is only pruned once the new preview
+    // lands), so the footer must not keep counting it: the categories belong to
+    // the commit that was switched away from, and the user has not seen an item
+    // count for the new one.
+    let previewCalls = 0;
+    server.use(
+      http.get('/api/v1/github-backup/restore/preview', async () => {
+        previewCalls += 1;
+        // The second commit's preview never resolves, holding the modal in the
+        // in-flight state the assertions below describe.
+        if (previewCalls > 1) await delay('infinite');
+        return HttpResponse.json(mockPreview as unknown as JsonBody);
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument());
+
+    await userEvent.selectOptions(screen.getByLabelText('Backup commit'), mockCommits.commits[1].sha);
+
+    await waitFor(() => expect(screen.getByText('Reading backup contents...')).toBeInTheDocument());
+    expect(screen.getByText('0 selected')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Restore$/ })).toBeDisabled();
+  });
+
+  it('sends overwrite_existing when the toggle is on', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('/api/v1/github-backup/restore', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('switch'));
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
+  });
+
+  it('warns more strongly when overwrite is enabled', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('switch'));
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+    await waitFor(() => {
+      expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
+    });
+  });
+
+  // Overwrite-off says existing entries stay as they are. K-profiles are the one
+  // category that cannot honour that — writing a slot always replaces the
+  // calibration on the printer — and the backend's note saying so only arrives
+  // in the result panel, after the MQTT send. So the disclosure has to be on the
+  // screen where the promise is made, before the user commits to it.
+  describe('the K-profile exception to overwrite-off', () => {
+    beforeEach(() => {
+      mockEndpoints({ preview: mockPreviewWithKprofiles as unknown as JsonBody });
+    });
+
+    it('appears beside the category as soon as it is selected', async () => {
+      render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[3]);
+
+      await waitFor(() => {
+        expect(screen.getByText(/K-profiles are the exception/)).toBeInTheDocument();
+      });
+
+      // And it goes once overwrite is on, where nothing is promising otherwise.
+      await userEvent.click(screen.getByRole('switch'));
+      expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
+    });
+
+    it('is part of the confirmation the user actually clicks through', async () => {
+      render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[3]);
+      await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+      await waitFor(() => screen.getByText('Restore from backup?'));
+      expect(
+        screen.getByText(/existing entries stay as they are\. K-profiles are the exception/)
+      ).toBeInTheDocument();
+    });
+
+    it('stays out of the confirmation for the categories that do keep the promise', async () => {
+      render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[1]);
+      await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+      await waitFor(() => screen.getByText('Restore from backup?'));
+      expect(screen.getByText(/existing entries stay as they are\.$/)).toBeInTheDocument();
+      expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
+    });
+
+    it('is redundant with overwrite on, so it is not shown there', async () => {
+      render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[3]);
+      await userEvent.click(screen.getByRole('switch'));
+      await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+      await waitFor(() => screen.getByText(/This cannot be undone/));
+      expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
+    });
+  });
+
+  it('surfaces a preview failure instead of an empty category list', async () => {
+    mockEndpoints({
+      preview: {
+        success: false,
+        message: 'Commit or tree deadbee not found in the repository',
+        ref: 'deadbee',
+        categories: [],
+      },
+    });
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
+    });
+    expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
+  });
+
+  it('surfaces a commit listing failure', async () => {
+    mockEndpoints({
+      commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
+    });
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Invalid access token')).toBeInTheDocument();
+    });
+  });
+
+  // A refused restore answers 200 with `success: false` and an empty `results`,
+  // and two of the five refusals are ordinary conditions rather than errors — a
+  // restore already running, and a backup mid-flight. Rendering the result panel
+  // for those put a green tick and "reload so the restored data appears" above a
+  // message saying nothing had been restored, i.e. a failure that read as a
+  // success. Empty `results` is the load-bearing half: a failure that did write
+  // carries its committed categories and does get the panel — see the partial
+  // test below.
+  it('reports a backend refusal such as the backup/restore mutex', async () => {
+    server.use(
+      http.post('/api/v1/github-backup/restore', () =>
+        HttpResponse.json({
+          success: false,
+          message: 'A backup is currently running. Wait for it to finish before restoring.',
+          results: {},
+        })
+      )
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(
+        screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
+      ).toBeInTheDocument();
+    });
+
+    // Not the success panel: no reload hint, no "Reload now", and the form is
+    // still there so the user can retry once the backup finishes.
+    expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
+    expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
+  });
+
+  it('does not refresh the data caches when a restore was refused', async () => {
+    server.use(
+      http.post('/api/v1/github-backup/restore', () =>
+        HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
+      )
+    );
+    const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+    await waitFor(() => screen.getByText('A restore is already running'));
+
+    const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
+    // This refusal never reached a category, so `results` is empty and there is
+    // nothing to re-read. A failure that committed one does invalidate — the
+    // partial test below covers that side...
+    expect(keys).not.toContain(JSON.stringify(['spools']));
+    expect(keys).not.toContain(JSON.stringify(['archives']));
+    // ...but a failure past the commit resolve writes a "failed" log row, so the
+    // history is refreshed whatever the outcome.
+    expect(keys).toContain(JSON.stringify(['github-backup-logs']));
+    invalidate.mockRestore();
+  });
+
+  // Categories commit as each one finishes, so a run that fails part-way leaves
+  // the earlier ones on disk and reports them. The modal used to gate the whole
+  // result panel — and the cache invalidation with it — on `success`, so those
+  // rows were written, never shown, and never re-read: the app carried on
+  // displaying pre-restore settings while the database held the restored ones.
+  const partialRestore = {
+    success: false,
+    message: 'database is locked',
+    log_id: 7,
+    ref: 'a'.repeat(40),
+    results: {
+      archives: { restored: 12, skipped: 0, failed: 0, notes: [] },
+      settings: { restored: 4, skipped: 1, failed: 0, notes: [] },
+    },
+  };
+
+  const runRestore = async () => {
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+  };
+
+  it('reports the categories a part-way failure already committed', async () => {
+    server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await runRestore();
+
+    // The tallies are the point: they name what is on disk.
+    await waitFor(() => expect(screen.getByText('database is locked')).toBeInTheDocument());
+    expect(screen.getByText(/12 restored/)).toBeInTheDocument();
+    expect(screen.getByText(/4 restored/)).toBeInTheDocument();
+    expect(screen.getByText(/The categories listed above finished and are on disk/)).toBeInTheDocument();
+    // And it must not read as a success — the run did not finish, so the
+    // warning icon stands in for the green tick.
+    expect(document.querySelector('svg.text-yellow-500')).toBeInTheDocument();
+    expect(document.querySelector('svg.text-bambu-green')).not.toBeInTheDocument();
+  });
+
+  it('refreshes the data caches for a part-way failure, because rows landed', async () => {
+    server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
+    const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await runRestore();
+    await waitFor(() => screen.getByText('database is locked'));
+
+    const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
+    expect(keys).toContain(JSON.stringify(['archives']));
+    expect(keys).toContain(JSON.stringify(['settings']));
+    invalidate.mockRestore();
+  });
+
+  // A provider-side failure answers 200 with `success: false`; a rejected
+  // *request* throws in `request()`, leaving `data` undefined. Reading the
+  // message off `data` alone meant the second kind rendered an empty modal —
+  // picker holding only "Latest", every category greyed out, no explanation.
+  it('explains a rejected preview request instead of greying out every category', async () => {
+    server.use(
+      http.get('/api/v1/github-backup/restore/preview', () =>
+        HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
+      )
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Not authenticated')).toBeInTheDocument();
+    });
+    // The category list is replaced by the error, not rendered disabled.
+    expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
+  });
+
+  it('explains a rejected commit-list request', async () => {
+    server.use(
+      http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    // No detail in the body, so the generic string carries the message.
+    await waitFor(() => {
+      expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
+    });
+  });
+
+  it('closes via the close button', async () => {
+    const onClose = vi.fn();
+    render(<GitHubRestoreModal onClose={onClose} />);
+
+    await waitFor(() => screen.getByText('Restore from Git Backup'));
+    await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  // A settings restore has to reach the rest of the app. It used to be the
+  // opposite problem: SettingsPage's debounced auto-save wrote its pre-restore
+  // form state back over the restore whenever ['settings'] refetched, so this
+  // modal skipped that invalidation and pinned the cache instead. #2716 fixed
+  // the page — it now reconciles a moved server snapshot field by field — and
+  // the workaround came out with this commit.
+  describe('a settings restore reaches the rest of the app', () => {
+    /** Runs a restore returning `results`, leaving the modal on its summary. */
+    async function restoreWith(results: Record<string, unknown>, onClose = vi.fn()) {
+      server.use(
+        http.post('/api/v1/github-backup/restore', () =>
+          HttpResponse.json({
+            success: true,
+            message: 'Restored 77 item(s) from aaa1111',
+            log_id: 7,
+            ref: mockPreview.ref,
+            results,
+          })
+        )
+      );
+      render(<GitHubRestoreModal onClose={onClose} />);
+
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[1]);
+      await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+      await waitFor(() => screen.getByText('Restore from backup?'));
+      const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+      await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+      await waitFor(() => screen.getByText('Restored 77 item(s) from aaa1111'));
+      return onClose;
+    }
+
+    /** Replaces window.location with a reload spy for the duration of a test. */
+    function stubReload() {
+      const original = window.location;
+      const reload = vi.fn();
+      Object.defineProperty(window, 'location', {
+        configurable: true,
+        value: { ...original, reload },
+      });
+      return {
+        reload,
+        restore: () =>
+          Object.defineProperty(window, 'location', { configurable: true, value: original }),
+      };
+    }
+
+    it('invalidates the settings query alongside the other rewritten caches', async () => {
+      const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
+      await restoreWith({ settings: { restored: 77, skipped: 3, failed: 0, notes: [] } });
+
+      const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
+      expect(keys).toContain(JSON.stringify(['spools']));
+      expect(keys).toContain(JSON.stringify(['settings']));
+      invalidate.mockRestore();
+    });
+
+    it('reloads instead of merely closing after a settings restore', async () => {
+      const loc = stubReload();
+      try {
+        const onClose = await restoreWith({
+          settings: { restored: 77, skipped: 3, failed: 0, notes: [] },
+        });
+
+        const closeButtons = screen.getAllByRole('button', { name: 'Close' });
+        await userEvent.click(closeButtons[closeButtons.length - 1]);
+
+        expect(loc.reload).toHaveBeenCalled();
+        // Invalidating ['settings'] only resyncs what reads that query. The
+        // interface language and the auth state do not, so closing in place
+        // would leave both showing their pre-restore values.
+        expect(onClose).not.toHaveBeenCalled();
+      } finally {
+        loc.restore();
+      }
+    });
+
+    it('closes normally when settings were not part of the restore', async () => {
+      const loc = stubReload();
+      try {
+        const onClose = await restoreWith({
+          spools: { restored: 4, skipped: 0, failed: 0, notes: [] },
+        });
+
+        const closeButtons = screen.getAllByRole('button', { name: 'Close' });
+        await userEvent.click(closeButtons[closeButtons.length - 1]);
+
+        expect(onClose).toHaveBeenCalled();
+        expect(loc.reload).not.toHaveBeenCalled();
+      } finally {
+        loc.restore();
+      }
+    });
+  });
+});

+ 95 - 0
frontend/src/api/client.ts

@@ -2892,12 +2892,94 @@ export interface GitHubBackupStatus {
   configured: boolean;
   enabled: boolean;
   is_running: boolean;
+  restore_running: boolean;
   progress: string | null;
   last_backup_at: string | null;
   last_backup_status: string | null;
   next_scheduled_run: string | null;
 }
 
+// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
+// the backup collector never writes them, so there is nothing to restore.
+export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
+
+export interface GitHubCommitInfo {
+  sha: string;
+  message: string;
+  author: string;
+  date: string;
+}
+
+export interface GitHubCommitListResponse {
+  success: boolean;
+  message: string;
+  branch: string;
+  commits: GitHubCommitInfo[];
+}
+
+/**
+ * Values the server interpolates into a translated note or preview detail.
+ * Kept to strings and numbers on purpose — anything richer would have to be
+ * formatted server-side and could not be translated.
+ */
+export type GitHubRestoreParams = Record<string, string | number>;
+
+export interface GitHubRestorePreviewCategory {
+  category: RestoreCategory;
+  available: boolean;
+  item_count: number;
+  /** English rendering. Used as i18next's defaultValue, never shown on its own. */
+  detail: string | null;
+  /** Key under backup.restoreFromGit.details, or null when there is no caveat. */
+  detail_code: string | null;
+  detail_params: GitHubRestoreParams;
+}
+
+export interface GitHubRestorePreview {
+  success: boolean;
+  message: string;
+  ref: string;
+  commit: GitHubCommitInfo | null;
+  metadata_version: string | null;
+  categories: GitHubRestorePreviewCategory[];
+}
+
+export interface GitHubRestoreRequest {
+  ref?: string;
+  categories: RestoreCategory[];
+  overwrite_existing?: boolean;
+}
+
+/**
+ * One tally note, as a translation code plus its parameters (#2656).
+ *
+ * Same contract as {@link LocalBackupPathCheck} one card down: the server picks
+ * the code and supplies typed params, and the client renders
+ * ``t(`backup.restoreFromGit.notes.${code}`, { ...params, defaultValue: message })``.
+ * A code the client does not know yet falls back to the English `message`
+ * rather than showing the raw key.
+ */
+export interface GitHubRestoreNote {
+  code: string;
+  params: GitHubRestoreParams;
+  message: string;
+}
+
+export interface GitHubRestoreCategoryResult {
+  restored: number;
+  skipped: number;
+  failed: number;
+  notes: GitHubRestoreNote[];
+}
+
+export interface GitHubRestoreResponse {
+  success: boolean;
+  message: string;
+  log_id: number | null;
+  ref: string | null;
+  results: Record<string, GitHubRestoreCategoryResult>;
+}
+
 export interface LocalBackupStatus {
   enabled: boolean;
   schedule: string;
@@ -6772,6 +6854,19 @@ export const api = {
   clearGitHubBackupLogs: (keepLast: number = 10) =>
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
 
+  // Restore from a Git backup (#2656)
+  getGitHubBackupCommits: (limit: number = 20) =>
+    request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
+
+  getGitHubRestorePreview: (ref: string = 'HEAD') =>
+    request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
+
+  restoreFromGitHub: (payload: GitHubRestoreRequest) =>
+    request<GitHubRestoreResponse>('/github-backup/restore', {
+      method: 'POST',
+      body: JSON.stringify(payload),
+    }),
+
   // Scheduled local backups
   getLocalBackupStatus: () =>
     request<LocalBackupStatus>('/local-backup/status'),

+ 35 - 0
frontend/src/components/GitHubBackupSettings.tsx

@@ -36,10 +36,12 @@ import type {
   CloudAuthStatus,
   Printer,
 } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
+import { GitHubRestoreModal } from './GitHubRestoreModal';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime, parseUTCDate } from '../utils/date';
 
@@ -133,6 +135,14 @@ export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+
+  // All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
+  // user without it gets a 403 the moment the modal opens its preview. Hide the
+  // button rather than offer an action that cannot work. Deliberately scoped to
+  // the button: the card itself stays visible, since backup configuration is a
+  // separate permission. hasPermission returns true when auth is off.
+  const canRestoreFromGit = hasPermission('github:restore');
 
   // Local state for form
   const [repoUrl, setRepoUrl] = useState('');
@@ -158,6 +168,9 @@ export function GitHubBackupSettings() {
   const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
+  // Restore from the Git backup repository (#2656)
+  const [showGitRestore, setShowGitRestore] = useState(false);
+
   // Scheduled local backup state
   const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
   const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
@@ -951,6 +964,18 @@ export function GitHubBackupSettings() {
                           {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
                           {t('backup.test')}
                         </Button>
+                        {/* Restore from the backup repo (#2656) */}
+                        {canRestoreFromGit && (
+                          <Button
+                            variant="secondary"
+                            size="sm"
+                            onClick={() => setShowGitRestore(true)}
+                            disabled={status.restore_running}
+                          >
+                            <RotateCcw className="w-4 h-4" />
+                            {t('backup.restoreFromGit.button')}
+                          </Button>
+                        )}
                       </>
                     )}
                   </>
@@ -1007,6 +1032,7 @@ export function GitHubBackupSettings() {
                   <thead>
                     <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
                       <th className="text-left py-2 px-2">{t('backup.date')}</th>
+                      <th className="text-left py-2 px-2">{t('backup.trigger')}</th>
                       <th className="text-left py-2 px-2">{t('backup.status')}</th>
                       <th className="text-left py-2 px-2">{t('backup.commit')}</th>
                     </tr>
@@ -1015,6 +1041,12 @@ export function GitHubBackupSettings() {
                     {logs.slice(0, 10).map((log) => (
                       <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
                         <td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
+                        {/* A restore writes a log row too, and without this it
+                            was indistinguishable from a backup: a successful
+                            run dated now, while Last backup said otherwise. */}
+                        <td className="py-2 px-2 text-bambu-gray">
+                          {t(`backup.triggers.${log.trigger}`, { defaultValue: log.trigger })}
+                        </td>
                         <td className="py-2 px-2"><StatusBadge status={log.status} /></td>
                         <td className="py-2 px-2">
                           {log.commit_sha ? (
@@ -1445,6 +1477,9 @@ export function GitHubBackupSettings() {
         </Card>
       </div>
 
+      {/* Restore from the Git backup repository (#2656) */}
+      {showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
+
       {/* Delete Backup Confirmation Modal */}
       {deleteConfirmFile && (
         <ConfirmModal

+ 542 - 0
frontend/src/components/GitHubRestoreModal.tsx

@@ -0,0 +1,542 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  AlertTriangle,
+  Archive,
+  CheckCircle2,
+  Info,
+  Loader2,
+  Palette,
+  RotateCcw,
+  Settings as SettingsIcon,
+  Thermometer,
+  X,
+} from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { ConfirmModal } from './ConfirmModal';
+import {
+  api,
+  type RestoreCategory,
+  type GitHubRestoreParams,
+  type GitHubRestoreResponse,
+} from '../api/client';
+import type { TFunction } from 'i18next';
+
+interface GitHubRestoreModalProps {
+  onClose: () => void;
+}
+
+/**
+ * Render a server-supplied translation code, falling back to its English text.
+ *
+ * The restore endpoints describe every note and preview caveat as a `code` plus
+ * typed `params`, and carry the English rendering along as `message`. That is
+ * the same contract `backup.pathCheck` already uses one card down in
+ * GitHubBackupSettings — including the `defaultValue` arm, which is what keeps a
+ * newer backend's unfamiliar code readable instead of printing the raw key.
+ */
+function translateCoded(
+  t: TFunction,
+  group: 'notes' | 'details',
+  code: string | null | undefined,
+  params: GitHubRestoreParams | undefined,
+  fallback: string | null
+): string | null {
+  if (!code) return fallback;
+  return t(`backup.restoreFromGit.${group}.${code}`, {
+    ...(params ?? {}),
+    defaultValue: fallback ?? code,
+  });
+}
+
+interface CategoryMeta {
+  id: RestoreCategory;
+  labelKey: string;
+  icon: React.ReactNode;
+}
+
+// Order mirrors the order the backend applies them in. Labels reuse the keys
+// the backup checkbox group already ships in all locales.
+const CATEGORIES: CategoryMeta[] = [
+  { id: 'archives', labelKey: 'backup.printArchives', icon: <Archive className="w-4 h-4" /> },
+  { id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
+  { id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
+  { id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
+];
+
+const CATEGORY_LABEL_KEYS: Record<string, string> = Object.fromEntries(
+  CATEGORIES.map((c) => [c.id, c.labelKey])
+);
+
+const LATEST = 'HEAD';
+
+export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+
+  const [selectedRef, setSelectedRef] = useState<string>(LATEST);
+  const [selected, setSelected] = useState<Record<string, boolean>>({});
+  const [overwriteExisting, setOverwriteExisting] = useState(false);
+  const [showConfirm, setShowConfirm] = useState(false);
+  const [result, setResult] = useState<GitHubRestoreResponse | null>(null);
+
+  const commitsQuery = useQuery({
+    queryKey: ['github-backup-commits'],
+    queryFn: () => api.getGitHubBackupCommits(20),
+  });
+
+  const previewQuery = useQuery({
+    queryKey: ['github-restore-preview', selectedRef],
+    queryFn: () => api.getGitHubRestorePreview(selectedRef),
+  });
+
+  // Restore the exact commit the preview described, not the ref that was asked
+  // for. They differ for the default "Latest backup" selection, which posts the
+  // symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing
+  // between preview and restore would silently restore a different commit than
+  // the one whose contents the user just approved.
+  const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef;
+
+  const availability = useMemo(() => {
+    const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
+    previewQuery.data?.categories?.forEach((c) => {
+      map[c.category] = {
+        available: c.available,
+        itemCount: c.item_count,
+        detail: translateCoded(t, 'details', c.detail_code, c.detail_params, c.detail),
+      };
+    });
+    return map;
+  }, [previewQuery.data, t]);
+
+  // What a Restore click would actually send. `selected` on its own is not that:
+  // it survives a commit switch by design (the pruning effect below only runs
+  // once the new preview lands), so between picking a commit and its preview
+  // resolving, `selected` still describes the *previous* commit while the
+  // checkbox list is replaced by a spinner. Counting it raw put "2 selected"
+  // and an enabled Restore button under that spinner, and clicking restored the
+  // new commit with the old commit's categories — none of which the user had
+  // seen an item count for. Gating on availability, exactly as the checkboxes
+  // do, empties the list until the preview says otherwise, which also disables
+  // the button.
+  const selectedCategories = useMemo(
+    () => CATEGORIES.filter((c) => selected[c.id] && availability[c.id]?.available).map((c) => c.id),
+    [selected, availability]
+  );
+  const selectedCount = selectedCategories.length;
+
+  // Overwrite-off tells the user that existing entries stay as they are, and for
+  // three of the four categories it keeps that promise. K-profiles cannot:
+  // _restore_kprofiles takes no overwrite flag, because writing a slot is always
+  // an overwrite on the printer — resolving the live cali_idx and publishing
+  // extrusion_cali_set replaces whatever calibration that slot holds. The
+  // backend does say so, but as a note in the result panel, i.e. after the MQTT
+  // send has already happened and cannot be taken back. So the one screen that
+  // explains overwrite-off has to carry the exception too, before the click.
+  const warnKprofilesOverwrite = !overwriteExisting && selectedCategories.includes('kprofiles');
+
+  const restoreMutation = useMutation({
+    mutationFn: () =>
+      api.restoreFromGitHub({
+        ref: resolvedRef,
+        categories: selectedCategories,
+        overwrite_existing: overwriteExisting,
+      }),
+    onSuccess: (data) => {
+      setShowConfirm(false);
+      // The endpoint answers 200 for a refused or failed restore too, with
+      // `success: false` — and two of those are ordinary conditions, not
+      // errors: another restore already running, and a backup being mid-flight.
+      // Nothing was written for either, so they keep the form and show the red
+      // block below; rendering the result panel for them put a green tick, no
+      // tally at all and a "reload so the restored data appears" hint above a
+      // message saying nothing had been restored.
+      //
+      // A failure that got as far as writing is the opposite case. Categories
+      // commit as each one finishes, so a non-empty `results` names the ones
+      // that are on disk — and the form over the top of them would be the same
+      // "nothing was restored" misreading, this time with the data actually in.
+      // So the panel is what wrote, not what succeeded.
+      const wroteSomething = Object.keys(data.results ?? {}).length > 0;
+      if (data.success || wroteSomething) {
+        setResult(data);
+        // A restore rewrites rows these caches hold. ['settings'] is one of
+        // them: until #2716 was fixed on dev, invalidating it made
+        // SettingsPage's debounced auto-save write the pre-restore form state
+        // straight back over the restore, so this modal skipped it and pinned
+        // the cache instead. That page now reconciles a moved server snapshot
+        // field by field, so the restore no longer needs an exception.
+        queryClient.invalidateQueries({ queryKey: ['spools'] });
+        queryClient.invalidateQueries({ queryKey: ['archives'] });
+        queryClient.invalidateQueries({ queryKey: ['settings'] });
+      }
+      // A failure that got as far as resolving the commit still writes a log row
+      // (status "failed"), so refresh the history and status either way.
+      queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
+      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
+    },
+    onError: () => setShowConfirm(false),
+  });
+
+  const isRestoring = restoreMutation.isPending;
+
+  // A settings restore rewrites rows the whole app reads, and not all of them
+  // through a query this modal can invalidate. The interface language is applied
+  // by i18n.changeLanguage, called only from the SettingsPage dropdown and the
+  // appliance-locale bootstrap; the auth state comes from AuthProvider's
+  // mount-time getAuthStatus, not from ['settings'] at all. So every exit path
+  // after a settings restore reloads rather than just closing.
+  const settingsRestored = Boolean(result && 'settings' in result.results);
+  const closeModal = useCallback(() => {
+    if (settingsRestored) {
+      window.location.reload();
+      return;
+    }
+    onClose();
+  }, [settingsRestored, onClose]);
+
+  // Close on Escape, except while a restore is in flight.
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isRestoring && !showConfirm) closeModal();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [closeModal, isRestoring, showConfirm]);
+
+  // Interrupting a restore mid-flight can leave a partly-applied category.
+  useEffect(() => {
+    if (!isRestoring) return;
+    const handler = (e: BeforeUnloadEvent) => {
+      e.preventDefault();
+      e.returnValue = '';
+    };
+    window.addEventListener('beforeunload', handler);
+    return () => window.removeEventListener('beforeunload', handler);
+  }, [isRestoring]);
+
+  // Selecting a category that isn't in the newly-picked commit would send a
+  // request the backend rejects, so drop those whenever the preview changes.
+  useEffect(() => {
+    if (!previewQuery.data) return;
+    setSelected((prev) => {
+      const next: Record<string, boolean> = {};
+      CATEGORIES.forEach((c) => {
+        next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available);
+      });
+      return next;
+    });
+  }, [previewQuery.data, availability]);
+
+  const commits = commitsQuery.data?.commits ?? [];
+
+  const formatCommitLabel = (sha: string, message: string, date: string) => {
+    const firstLine = (message || '').split('\n')[0];
+    const when = date ? new Date(date).toLocaleString() : '';
+    return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`;
+  };
+
+  // Two ways these can fail, and both have to reach the user. A provider-side
+  // failure (bad token, repo unreachable) answers 200 with `success: false` and
+  // a message. A rejected *request* — a 401/403 once the session expires with
+  // the modal open, a 500, the network dropping — throws in `request()`, so
+  // `data` is undefined: reading the message off `data` alone left the picker
+  // holding only "Latest" and every category greyed out by an empty availability
+  // map, with nothing on screen saying why.
+  const queryError = (query: { isError: boolean; error: unknown }) =>
+    query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null;
+
+  const previewError =
+    queryError(previewQuery) ??
+    (previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null);
+  const commitsError =
+    queryError(commitsQuery) ??
+    (commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null);
+
+  return (
+    <>
+      <div
+        className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+        onClick={isRestoring ? undefined : closeModal}
+      >
+        <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+          <CardContent className="p-0">
+            {/* Header */}
+            <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+              <div className="flex items-center gap-3">
+                <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+                  <RotateCcw className="w-5 h-5" />
+                </div>
+                <div>
+                  <h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
+                  <p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
+                </div>
+              </div>
+              <button
+                onClick={closeModal}
+                disabled={isRestoring}
+                aria-label={t('common.close')}
+                className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            {result ? (
+              /* Result summary */
+              <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
+                {/* A partial restore reaches this panel too — categories commit
+                    as they finish, so the tallies below are on disk even though
+                    the run did not get through them all. It must not read as a
+                    success: the message is the failure, and what follows is what
+                    survived it rather than what was asked for. */}
+                <div className="flex items-start gap-2 text-sm">
+                  {result.success ? (
+                    <CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                  ) : (
+                    <AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
+                  )}
+                  <span className="text-white">{result.message}</span>
+                </div>
+                {!result.success && (
+                  <p className="text-xs text-bambu-gray">{t('backup.restoreFromGit.partialHint')}</p>
+                )}
+                {Object.entries(result.results).map(([name, tally]) => (
+                  <div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+                    <div className="flex items-center justify-between">
+                      <span className="text-sm font-medium text-white">
+                        {CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
+                      </span>
+                      <span className="text-xs text-bambu-gray">
+                        {t('backup.restoreFromGit.tally', {
+                          restored: tally.restored,
+                          skipped: tally.skipped,
+                          failed: tally.failed,
+                        })}
+                      </span>
+                    </div>
+                    {tally.notes.length > 0 && (
+                      <ul className="mt-2 space-y-1">
+                        {tally.notes.map((note) => (
+                          // The server dedupes on (code, params), not on code
+                          // alone — two printers can both be offline — so the
+                          // key has to carry the params too.
+                          <li
+                            key={`${note.code}:${JSON.stringify(note.params)}`}
+                            className="text-xs text-bambu-gray flex items-start gap-1.5"
+                          >
+                            <Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                            <span>{translateCoded(t, 'notes', note.code, note.params, note.message)}</span>
+                          </li>
+                        ))}
+                      </ul>
+                    )}
+                  </div>
+                ))}
+                <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
+                  <p className="text-xs text-yellow-700 dark:text-yellow-200">
+                    {t('backup.restoreFromGit.reloadHint')}
+                  </p>
+                </div>
+              </div>
+            ) : (
+              <div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
+                {/* A restore that was refused or failed comes back here rather
+                    than to the result panel, so keep these above the fold. */}
+                {restoreMutation.isError && (
+                  <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                    <p className="text-sm text-red-700 dark:text-red-400">
+                      {(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
+                    </p>
+                  </div>
+                )}
+                {restoreMutation.data && !restoreMutation.data.success && (
+                  <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                    <p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
+                  </div>
+                )}
+
+                {/* Commit picker */}
+                <div>
+                  <label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
+                    {t('backup.restoreFromGit.commitLabel')}
+                  </label>
+                  <select
+                    id="restore-commit"
+                    value={selectedRef}
+                    onChange={(e) => {
+                      setSelectedRef(e.target.value);
+                      // Drop the previous attempt's failure banner: it refers to
+                      // the commit that was just switched away from. (A *result*
+                      // cannot be showing here — the summary replaces this form.)
+                      restoreMutation.reset();
+                    }}
+                    disabled={isRestoring || commitsQuery.isLoading}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                  >
+                    <option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
+                    {commits.map((c) => (
+                      <option key={c.sha} value={c.sha}>
+                        {formatCommitLabel(c.sha, c.message, c.date)}
+                      </option>
+                    ))}
+                  </select>
+                  {commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
+                </div>
+
+                {/* Category selection */}
+                <div>
+                  <p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
+                  {previewQuery.isLoading ? (
+                    <div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {t('backup.restoreFromGit.inspecting')}
+                    </div>
+                  ) : previewError ? (
+                    <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                      <p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
+                    </div>
+                  ) : (
+                    <div className="space-y-2">
+                      {CATEGORIES.map((category) => {
+                        const info = availability[category.id];
+                        const isAvailable = Boolean(info?.available);
+                        const isChecked = Boolean(selected[category.id]) && isAvailable;
+                        return (
+                          <label
+                            key={category.id}
+                            className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
+                              isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
+                            } ${
+                              isChecked
+                                ? 'bg-bambu-green/10 border border-bambu-green/30'
+                                : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
+                            }`}
+                          >
+                            <input
+                              type="checkbox"
+                              checked={isChecked}
+                              disabled={!isAvailable || isRestoring}
+                              onChange={() =>
+                                setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
+                              }
+                              className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                            />
+                            <div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
+                            <div className="flex-1">
+                              <div className="text-white text-sm font-medium">
+                                {t(category.labelKey)}
+                                {isAvailable && info?.itemCount ? (
+                                  <span className="ml-2 text-xs text-bambu-gray">
+                                    {t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
+                                  </span>
+                                ) : null}
+                              </div>
+                              {info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
+                              {category.id === 'kprofiles' && isChecked && warnKprofilesOverwrite && (
+                                <div className="text-xs text-yellow-700 dark:text-yellow-200">
+                                  {t('backup.restoreFromGit.kprofilesOverwriteCaveat')}
+                                </div>
+                              )}
+                            </div>
+                          </label>
+                        );
+                      })}
+                    </div>
+                  )}
+                </div>
+
+                {/* Overwrite toggle */}
+                <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+                  <div className="flex items-center justify-between gap-3">
+                    <div>
+                      <p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
+                      <p className="text-xs text-bambu-gray">
+                        {overwriteExisting
+                          ? t('backup.restoreFromGit.overwriteOn')
+                          : t('backup.restoreFromGit.overwriteOff')}
+                      </p>
+                    </div>
+                    <Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
+                  </div>
+                </div>
+
+              </div>
+            )}
+
+            {/* Footer */}
+            <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
+              {result ? (
+                <>
+                  <span />
+                  <div className="flex gap-3">
+                    <Button variant="secondary" onClick={closeModal}>
+                      {t('common.close')}
+                    </Button>
+                    <Button
+                      onClick={() => window.location.reload()}
+                      className="bg-bambu-green hover:bg-bambu-green-dark"
+                    >
+                      {t('backup.reloadNow')}
+                    </Button>
+                  </div>
+                </>
+              ) : (
+                <>
+                  <span className="text-sm text-bambu-gray">
+                    {t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
+                  </span>
+                  <div className="flex gap-3">
+                    <Button variant="secondary" onClick={closeModal} disabled={isRestoring}>
+                      {t('common.cancel')}
+                    </Button>
+                    <Button
+                      onClick={() => setShowConfirm(true)}
+                      disabled={selectedCount === 0 || isRestoring}
+                      className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+                    >
+                      {isRestoring ? (
+                        <>
+                          <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                          {t('backup.restoreFromGit.restoring')}
+                        </>
+                      ) : (
+                        <>
+                          <RotateCcw className="w-4 h-4 mr-2" />
+                          {t('backup.restore')}
+                        </>
+                      )}
+                    </Button>
+                  </div>
+                </>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {showConfirm && (
+        <ConfirmModal
+          variant="danger"
+          overlayZIndex="z-[110]"
+          title={t('backup.restoreFromGit.confirmTitle')}
+          message={
+            overwriteExisting
+              ? t('backup.restoreFromGit.confirmMessageOverwrite')
+              : warnKprofilesOverwrite
+                ? `${t('backup.restoreFromGit.confirmMessage')} ${t('backup.restoreFromGit.kprofilesOverwriteCaveat')}`
+                : t('backup.restoreFromGit.confirmMessage')
+          }
+          confirmText={t('backup.restore')}
+          isLoading={isRestoring}
+          loadingText={t('backup.restoreFromGit.restoring')}
+          onConfirm={() => restoreMutation.mutate()}
+          onCancel={() => setShowConfirm(false)}
+        />
+      )}
+    </>
+  );
+}

+ 69 - 0
frontend/src/i18n/locales/de.ts

@@ -4874,11 +4874,80 @@ export default {
     clearedLogs: '{{count}} Protokolle gelöscht',
     failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Aus Git wiederherstellen',
+      title: 'Aus Git-Backup wiederherstellen',
+      subtitle: 'Commit auswählen und festlegen, was wiederhergestellt wird',
+      commitLabel: 'Backup-Commit',
+      latestCommit: 'Neuestes Backup (Branch-Spitze)',
+      categoriesLabel: 'Was wiederherstellen',
+      inspecting: 'Backup-Inhalt wird gelesen...',
+      itemCount: '{{count}} im Backup',
+      overwriteLabel: 'Vorhandene Einträge überschreiben',
+      overwriteOn: 'Vorhandene Einträge werden aus dem Backup aktualisiert.',
+      overwriteOff: 'Es werden nur fehlende Einträge ergänzt; vorhandene bleiben unverändert.',
+      selectedCount: '{{count}} ausgewählt',
+      restoring: 'Wird wiederhergestellt...',
+      confirmTitle: 'Aus Backup wiederherstellen?',
+      confirmMessage: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt. Fehlende Einträge werden ergänzt, vorhandene bleiben unverändert.',
+      confirmMessageOverwrite: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt und lokal vorhandene Einträge überschrieben. Dies kann nicht rückgängig gemacht werden.',
+      kprofilesOverwriteCaveat: 'K-Profile sind die Ausnahme: Das Schreiben eines Slots ersetzt immer die Kalibrierung auf dem Drucker.',
+      tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
+      reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
+      partialHint: 'Die oben aufgeführten Kategorien wurden abgeschlossen und sind gespeichert. Fehlende Kategorien wurden nicht ausgeführt.',
+      failed: 'Wiederherstellung fehlgeschlagen.',
+      loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
+      details: {
+        notPresent: 'In diesem Backup-Commit nicht vorhanden',
+        unreadableJson: 'Unlesbares JSON: {{paths}}',
+        settingsNoPayload: 'Keine Einstellungen in den Daten',
+        settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
+        settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
+        settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
+        spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
+        archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
+        kprofilesPrinterCount: 'über {{count}} Drucker',
+      },
+      notes: {
+        noData: 'Keine Daten dieser Art in diesem Backup',
+        archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt',
+        archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt',
+        archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
+        archivesOwnerUnmatched: 'Einige Archive nennen einen Eigentümer, den es auf dieser Instanz nicht gibt - der Eigentümer wurde entfernt statt aus der Benutzer-ID der Sicherung geraten. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
+        archivesOwnerUnknown: 'Einige Archive wurden ohne Eigentümer wiederhergestellt - diese Sicherung enthält keinen, daher sind sie nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
+        archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv',
+        archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten',
+        spoolUsageUnresolved: '{{count}} Verbrauchseinträge übersprungen - ihre Spule ist nicht in der Spulenliste dieses Backups, es gibt also nichts, woran sie hängen könnten.',
+        spoolUsageUnlinked: '{{count}} Verbrauchseinträge ohne Verknüpfung zum Druckverlauf wiederhergestellt - wählen Sie Druckarchive zusammen mit dem Spulenbestand, um sie zu behalten.',
+        spoolTagKept: '{{count}} Spulen-Tags unverändert gelassen - das Backup hätte einen inzwischen gescannten Tag gelöscht oder ihn auf eine zweite Spule verschoben.',
+        settingsCredentialsSkipped: '{{count}} zugangsdatenähnliche Schlüssel übersprungen - Geheimnisse bitte manuell erneut eingeben',
+        settingsAuthSkipped: '{{count}} Authentifizierungseinstellungen übersprungen - ändern Sie diese unter Einstellungen > Authentifizierung, damit die Aussperrprüfungen greifen',
+        settingsCompanionSkipped: '{{keys}} bleiben ausgeschaltet - die jeweils benötigten Zugangsdaten lassen sich nicht aus einem Backup wiederherstellen und sind auf dieser Instanz nicht hinterlegt, ein Einschalten würde die Integration also ohne Authentifizierung lassen',
+        settingsMqttRelayFailed: 'MQTT-Einstellungen wiederhergestellt, aber das Relay konnte nicht neu verbunden werden - Bambuddy neu starten',
+        kprofilesAlwaysOverwrite: 'K-Profile überschreiben immer den passenden Slot auf dem Drucker',
+        kprofilesAckUnreliable: 'Ein Drucker, der nicht antwortet, zählt weiterhin als wiederhergestellt - überprüfen Sie die Profile am Drucker',
+        kprofilesPrinterMissing: 'Kein Drucker mit der Seriennummer {{serial}} - übersprungen',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) ist nicht verbunden - übersprungen',
+        kprofilesUnknownNozzle: 'Unerwarteter Düsendurchmesser {{nozzle}} für {{serial}} - unverändert gesendet',
+        kprofilesUnmatched: '{{count}} Profile für {{nozzle}} hatten kein Gegenstück auf {{printer}} - als neue Profile hinzugefügt',
+        kprofilesSendFailed: '{{nozzle}}-Profile konnten nicht an {{printer}} ({{serial}}) gesendet werden',
+        kprofilesRefused: '{{printer}} ({{serial}}) hat die {{nozzle}}-Profile abgelehnt: {{reason}}',
+        kprofilesStepFailed: 'Der K-Profil-Schritt konnte nicht abgeschlossen werden - {{reason}}. Was zuvor wiederhergestellt wurde, ist trotzdem gespeichert.',
+      },
+    },
+
     // History
     history: 'Verlauf',
     clear: 'Löschen',
     date: 'Datum',
     status: 'Status',
+    trigger: 'Typ',
+    triggers: {
+      manual: 'Sicherung (manuell)',
+      scheduled: 'Sicherung (geplant)',
+      restore: 'Wiederherstellung',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 74 - 0
frontend/src/i18n/locales/en.ts

@@ -4917,11 +4917,85 @@ export default {
     clearedLogs: 'Cleared {{count}} logs',
     failedToClearLogs: 'Failed to clear logs: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restore from Git',
+      title: 'Restore from Git Backup',
+      subtitle: 'Pick a commit and choose what to restore',
+      commitLabel: 'Backup commit',
+      latestCommit: 'Latest backup (branch tip)',
+      categoriesLabel: 'What to restore',
+      inspecting: 'Reading backup contents...',
+      itemCount: '{{count}} in backup',
+      overwriteLabel: 'Overwrite existing entries',
+      overwriteOn: 'Existing entries will be updated from the backup.',
+      overwriteOff: 'Only missing entries are added; existing ones are left untouched.',
+      selectedCount: '{{count}} selected',
+      restoring: 'Restoring...',
+      confirmTitle: 'Restore from backup?',
+      confirmMessage: 'The selected categories will be restored from this commit. Missing entries are added; existing entries stay as they are.',
+      confirmMessageOverwrite: 'The selected categories will be restored from this commit, overwriting entries that already exist locally. This cannot be undone.',
+      kprofilesOverwriteCaveat: 'K-profiles are the exception: writing a slot always replaces the calibration on the printer.',
+      tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
+      reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
+      partialHint: 'The categories listed above finished and are on disk. Any that are missing did not run.',
+      failed: 'Restore failed.',
+      loadFailed: 'Could not read the backup repository.',
+      // Preview caveats. The server sends detail_code + detail_params and the
+      // English detail as defaultValue, same contract as backup.pathCheck.
+      details: {
+        notPresent: 'Not present in this backup commit',
+        unreadableJson: 'Unreadable JSON: {{paths}}',
+        settingsNoPayload: 'No settings in payload',
+        settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
+        settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
+        settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
+        spoolsUsageCount: 'including {{count}} usage record(s)',
+        archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
+        kprofilesPrinterCount: 'across {{count}} printer(s)',
+      },
+      // Tally notes, same contract. noData is shared by all four categories:
+      // the category heading renders beside it, so naming the category again
+      // would be redundant.
+      notes: {
+        noData: 'No data of this kind in this backup',
+        archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared',
+        archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared',
+        archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
+        archivesOwnerUnmatched: 'Some archives name an owner this instance does not have - owner cleared rather than guessed from the backup\'s user id, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
+        archivesOwnerUnknown: 'Some archives were restored without an owner - this backup does not record one, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
+        archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on',
+        archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup',
+        spoolUsageUnresolved: '{{count}} usage record(s) skipped - their spool is not in this backup\'s spool list, so there is nothing to attach them to.',
+        spoolUsageUnlinked: '{{count}} usage record(s) restored without their print-history link - select Print archives alongside Spool inventory to keep it.',
+        spoolTagKept: '{{count}} spool tag(s) left as they are - the backup would have cleared a tag that has since been scanned, or moved one onto a second spool.',
+        settingsCredentialsSkipped: '{{count}} credential-like key(s) skipped - re-enter secrets manually',
+        settingsAuthSkipped: '{{count}} authentication setting(s) skipped - change those in Settings > Authentication so the lockout checks still run',
+        settingsCompanionSkipped: '{{keys}} left switched off - the credential each one needs cannot be restored from a backup and this instance has none stored, so switching them on would leave the integration unauthenticated',
+        settingsMqttRelayFailed: 'MQTT settings restored, but the relay could not be reconnected - restart Bambuddy',
+        kprofilesAlwaysOverwrite: 'K-profiles always overwrite the matching slot on the printer',
+        kprofilesAckUnreliable: 'A printer that does not answer still counts as restored - verify the profiles on the printer',
+        kprofilesPrinterMissing: 'No printer with serial {{serial}} - skipped',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) is not connected - skipped',
+        kprofilesUnknownNozzle: 'Unexpected nozzle diameter {{nozzle}} for {{serial}} - sent as-is',
+        kprofilesUnmatched: '{{count}} profile(s) for {{nozzle}} had no counterpart on {{printer}} - added as new profiles',
+        kprofilesSendFailed: 'Failed to send {{nozzle}} profiles to {{printer}} ({{serial}})',
+        kprofilesRefused: '{{printer}} ({{serial}}) refused the {{nozzle}} profiles: {{reason}}',
+        kprofilesStepFailed: 'The K-profile step could not be completed - {{reason}}. Anything restored before it is still saved.',
+      },
+    },
+
     // History
     history: 'History',
     clear: 'Clear',
     date: 'Date',
     status: 'Status',
+    trigger: 'Type',
+    triggers: {
+      manual: 'Backup (manual)',
+      scheduled: 'Backup (scheduled)',
+      restore: 'Restore',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/es.ts

@@ -4882,11 +4882,80 @@ export default {
     clearedLogs: 'Se borraron {{count}} registros',
     failedToClearLogs: 'Error al borrar los registros: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restaurar desde Git',
+      title: 'Restaurar desde copia de Git',
+      subtitle: 'Elige un commit y qué se debe restaurar',
+      commitLabel: 'Commit de la copia',
+      latestCommit: 'Última copia (punta de la rama)',
+      categoriesLabel: 'Qué restaurar',
+      inspecting: 'Leyendo el contenido de la copia...',
+      itemCount: '{{count}} en la copia',
+      overwriteLabel: 'Sobrescribir entradas existentes',
+      overwriteOn: 'Las entradas existentes se actualizarán desde la copia.',
+      overwriteOff: 'Solo se añaden las entradas que falten; las existentes no se modifican.',
+      selectedCount: '{{count}} seleccionados',
+      restoring: 'Restaurando...',
+      confirmTitle: '¿Restaurar desde la copia?',
+      confirmMessage: 'Las categorías seleccionadas se restaurarán desde este commit. Se añaden las entradas que falten y las existentes se mantienen igual.',
+      confirmMessageOverwrite: 'Las categorías seleccionadas se restaurarán desde este commit y se sobrescribirán las entradas que ya existan localmente. Esto no se puede deshacer.',
+      kprofilesOverwriteCaveat: 'Los perfiles K son la excepción: escribir una ranura siempre reemplaza la calibración en la impresora.',
+      tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
+      reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
+      partialHint: 'Las categorías indicadas arriba se completaron y están guardadas. Las que faltan no llegaron a ejecutarse.',
+      failed: 'La restauración ha fallado.',
+      loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
+      details: {
+        notPresent: 'No está presente en este commit de la copia',
+        unreadableJson: 'JSON ilegible: {{paths}}',
+        settingsNoPayload: 'No hay ajustes en los datos',
+        settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
+        settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
+        settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
+        spoolsUsageCount: 'incluidos {{count}} registros de consumo',
+        archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
+        kprofilesPrinterCount: 'en {{count}} impresoras',
+      },
+      notes: {
+        noData: 'No hay datos de este tipo en esta copia de seguridad',
+        archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado',
+        archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado',
+        archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
+        archivesOwnerUnmatched: 'Algunos archivos indican un propietario que no existe en esta instancia - se ha borrado el propietario en lugar de deducirlo del id de usuario de la copia de seguridad, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
+        archivesOwnerUnknown: 'Algunos archivos se restauraron sin propietario - esta copia de seguridad no registra ninguno, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
+        archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada',
+        archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git',
+        spoolUsageUnresolved: '{{count}} registros de consumo omitidos - su bobina no está en la lista de bobinas de esta copia, así que no hay nada a lo que asociarlos.',
+        spoolUsageUnlinked: '{{count}} registros de consumo restaurados sin su enlace al historial de impresión - selecciona Archivos de impresión junto con Inventario de bobinas para conservarlo.',
+        spoolTagKept: '{{count}} etiquetas de bobina se han dejado como estaban - la copia habría borrado una etiqueta escaneada desde entonces, o la habría movido a una segunda bobina.',
+        settingsCredentialsSkipped: '{{count}} claves con aspecto de credencial omitidas - vuelve a introducir los secretos manualmente',
+        settingsAuthSkipped: '{{count}} ajustes de autenticación omitidos - cámbialos en Ajustes > Autenticación para que sigan aplicándose las comprobaciones de bloqueo',
+        settingsCompanionSkipped: '{{keys}} se han dejado desactivados - la credencial que cada uno necesita no puede restaurarse desde una copia y esta instancia no tiene ninguna guardada, así que activarlos dejaría la integración sin autenticación',
+        settingsMqttRelayFailed: 'Ajustes MQTT restaurados, pero no se pudo reconectar el relé - reinicia Bambuddy',
+        kprofilesAlwaysOverwrite: 'Los perfiles K siempre sobrescriben la ranura correspondiente en la impresora',
+        kprofilesAckUnreliable: 'Una impresora que no responde sigue contando como restaurada - verifica los perfiles en la impresora',
+        kprofilesPrinterMissing: 'No hay ninguna impresora con el número de serie {{serial}} - omitido',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) no está conectada - omitido',
+        kprofilesUnknownNozzle: 'Diámetro de boquilla inesperado {{nozzle}} para {{serial}} - enviado tal cual',
+        kprofilesUnmatched: '{{count}} perfiles para {{nozzle}} no tenían equivalente en {{printer}} - añadidos como perfiles nuevos',
+        kprofilesSendFailed: 'No se pudieron enviar los perfiles de {{nozzle}} a {{printer}} ({{serial}})',
+        kprofilesRefused: '{{printer}} ({{serial}}) rechazó los perfiles de {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'No se pudo completar el paso de los perfiles K - {{reason}}. Lo que se restauró antes sigue guardado.',
+      },
+    },
+
     // History
     history: 'Historial',
     clear: 'Borrar',
     date: 'Fecha',
     status: 'Estado',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Copia (manual)',
+      scheduled: 'Copia (programada)',
+      restore: 'Restauración',
+    },
     commit: 'Confirmación',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/fr.ts

@@ -4863,11 +4863,80 @@ export default {
     clearedLogs: '{{count}} journaux supprimés',
     failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restaurer depuis Git',
+      title: 'Restaurer depuis la sauvegarde Git',
+      subtitle: 'Choisissez un commit et ce qui doit être restauré',
+      commitLabel: 'Commit de sauvegarde',
+      latestCommit: 'Dernière sauvegarde (tête de branche)',
+      categoriesLabel: 'Éléments à restaurer',
+      inspecting: 'Lecture du contenu de la sauvegarde...',
+      itemCount: '{{count}} dans la sauvegarde',
+      overwriteLabel: 'Écraser les entrées existantes',
+      overwriteOn: 'Les entrées existantes seront mises à jour depuis la sauvegarde.',
+      overwriteOff: 'Seules les entrées manquantes sont ajoutées ; les existantes ne sont pas modifiées.',
+      selectedCount: '{{count}} sélectionné(s)',
+      restoring: 'Restauration...',
+      confirmTitle: 'Restaurer depuis la sauvegarde ?',
+      confirmMessage: 'Les catégories sélectionnées seront restaurées depuis ce commit. Les entrées manquantes sont ajoutées, les existantes restent inchangées.',
+      confirmMessageOverwrite: 'Les catégories sélectionnées seront restaurées depuis ce commit et les entrées déjà présentes localement seront écrasées. Cette action est irréversible.',
+      kprofilesOverwriteCaveat: "Les profils K sont l'exception : écrire un emplacement remplace toujours la calibration sur l'imprimante.",
+      tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
+      reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
+      partialHint: "Les catégories listées ci-dessus sont terminées et enregistrées. Celles qui manquent n'ont pas été exécutées.",
+      failed: 'Échec de la restauration.',
+      loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
+      details: {
+        notPresent: 'Absent de ce commit de sauvegarde',
+        unreadableJson: 'JSON illisible : {{paths}}',
+        settingsNoPayload: 'Aucun réglage dans les données',
+        settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
+        settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
+        settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
+        spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
+        archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
+        kprofilesPrinterCount: 'sur {{count}} imprimantes',
+      },
+      notes: {
+        noData: 'Aucune donnée de ce type dans cette sauvegarde',
+        archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé',
+        archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé',
+        archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
+        archivesOwnerUnmatched: 'Certaines archives désignent un propriétaire absent de cette instance - le propriétaire a été effacé plutôt que déduit de l\'identifiant utilisateur de la sauvegarde, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
+        archivesOwnerUnknown: 'Certaines archives ont été restaurées sans propriétaire - cette sauvegarde n\'en enregistre aucun, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
+        archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé',
+        archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
+        spoolUsageUnresolved: '{{count}} enregistrements de consommation ignorés - leur bobine ne figure pas dans la liste des bobines de cette sauvegarde, il n\'y a donc rien à quoi les rattacher.',
+        spoolUsageUnlinked: '{{count}} enregistrements de consommation restaurés sans leur lien vers l\'historique d\'impression - sélectionnez Archives d\'impression en même temps que l\'Inventaire des bobines pour le conserver.',
+        spoolTagKept: '{{count}} étiquettes de bobine laissées telles quelles - la sauvegarde aurait effacé une étiquette scannée depuis, ou l\'aurait déplacée sur une seconde bobine.',
+        settingsCredentialsSkipped: '{{count}} clés ressemblant à des identifiants ignorées - ressaisissez les secrets manuellement',
+        settingsAuthSkipped: '{{count}} réglages d\'authentification ignorés - modifiez-les dans Réglages > Authentification pour que les contrôles de verrouillage s\'appliquent',
+        settingsCompanionSkipped: '{{keys}} laissés désactivés - l\'identifiant dont chacun a besoin ne peut pas être restauré depuis une sauvegarde et cette instance n\'en a aucun enregistré ; les activer laisserait donc l\'intégration sans authentification',
+        settingsMqttRelayFailed: 'Réglages MQTT restaurés, mais le relais n\'a pas pu être reconnecté - redémarrez Bambuddy',
+        kprofilesAlwaysOverwrite: 'Les profils K écrasent toujours l\'emplacement correspondant sur l\'imprimante',
+        kprofilesAckUnreliable: 'Une imprimante qui ne répond pas compte quand même comme restaurée - vérifiez les profils sur l\'imprimante',
+        kprofilesPrinterMissing: 'Aucune imprimante avec le numéro de série {{serial}} - ignoré',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) n\'est pas connectée - ignoré',
+        kprofilesUnknownNozzle: 'Diamètre de buse inattendu {{nozzle}} pour {{serial}} - envoyé tel quel',
+        kprofilesUnmatched: '{{count}} profils pour {{nozzle}} n\'avaient pas d\'équivalent sur {{printer}} - ajoutés comme nouveaux profils',
+        kprofilesSendFailed: 'Impossible d\'envoyer les profils {{nozzle}} à {{printer}} ({{serial}})',
+        kprofilesRefused: '{{printer}} ({{serial}}) a refusé les profils {{nozzle}} : {{reason}}',
+        kprofilesStepFailed: 'L\'étape des profils K n\'a pas pu être terminée - {{reason}}. Ce qui a été restauré auparavant reste enregistré.',
+      },
+    },
+
     // History
     history: 'Historique',
     clear: 'Effacer',
     date: 'Date',
     status: 'Statut',
+    trigger: 'Type',
+    triggers: {
+      manual: 'Sauvegarde (manuelle)',
+      scheduled: 'Sauvegarde (planifiée)',
+      restore: 'Restauration',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/it.ts

@@ -4862,11 +4862,80 @@ export default {
     clearedLogs: '{{count}} log eliminati',
     failedToClearLogs: 'Eliminazione log fallita: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Ripristina da Git',
+      title: 'Ripristina dal backup Git',
+      subtitle: 'Scegli un commit e cosa ripristinare',
+      commitLabel: 'Commit del backup',
+      latestCommit: 'Ultimo backup (punta del branch)',
+      categoriesLabel: 'Cosa ripristinare',
+      inspecting: 'Lettura del contenuto del backup...',
+      itemCount: '{{count}} nel backup',
+      overwriteLabel: 'Sovrascrivi le voci esistenti',
+      overwriteOn: 'Le voci esistenti verranno aggiornate dal backup.',
+      overwriteOff: 'Vengono aggiunte solo le voci mancanti; quelle esistenti restano invariate.',
+      selectedCount: '{{count}} selezionati',
+      restoring: 'Ripristino in corso...',
+      confirmTitle: 'Ripristinare dal backup?',
+      confirmMessage: 'Le categorie selezionate verranno ripristinate da questo commit. Le voci mancanti vengono aggiunte, quelle esistenti restano invariate.',
+      confirmMessageOverwrite: 'Le categorie selezionate verranno ripristinate da questo commit sovrascrivendo le voci già presenti in locale. Operazione non annullabile.',
+      kprofilesOverwriteCaveat: "I profili K sono l'eccezione: scrivere uno slot sostituisce sempre la calibrazione sulla stampante.",
+      tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
+      reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
+      partialHint: 'Le categorie elencate sopra sono state completate e salvate. Quelle mancanti non sono state eseguite.',
+      failed: 'Ripristino non riuscito.',
+      loadFailed: 'Impossibile leggere il repository di backup.',
+      details: {
+        notPresent: 'Non presente in questo commit di backup',
+        unreadableJson: 'JSON illeggibile: {{paths}}',
+        settingsNoPayload: 'Nessuna impostazione nei dati',
+        settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
+        settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
+        settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
+        spoolsUsageCount: 'inclusi {{count}} record di consumo',
+        archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
+        kprofilesPrinterCount: 'su {{count}} stampanti',
+      },
+      notes: {
+        noData: 'Nessun dato di questo tipo in questo backup',
+        archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso',
+        archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso',
+        archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
+        archivesOwnerUnmatched: 'Alcuni archivi indicano un proprietario che questa istanza non ha - il proprietario è stato rimosso anziché dedotto dall\'id utente del backup, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
+        archivesOwnerUnknown: 'Alcuni archivi sono stati ripristinati senza proprietario - questo backup non ne registra alcuno, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
+        archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva',
+        archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git',
+        spoolUsageUnresolved: '{{count}} record di consumo saltati - la loro bobina non è nell\'elenco bobine di questo backup, quindi non c\'è nulla a cui collegarli.',
+        spoolUsageUnlinked: '{{count}} record di consumo ripristinati senza il collegamento alla cronologia di stampa - seleziona Archivi di stampa insieme a Inventario bobine per mantenerlo.',
+        spoolTagKept: '{{count}} tag bobina lasciati invariati - il backup avrebbe cancellato un tag nel frattempo scansionato, oppure lo avrebbe spostato su una seconda bobina.',
+        settingsCredentialsSkipped: '{{count}} chiavi simili a credenziali saltate - reinserisci i segreti manualmente',
+        settingsAuthSkipped: '{{count}} impostazioni di autenticazione saltate - modificale in Impostazioni > Autenticazione così i controlli di blocco restano attivi',
+        settingsCompanionSkipped: '{{keys}} lasciati disattivati - la credenziale richiesta da ciascuno non può essere ripristinata da un backup e questa istanza non ne ha nessuna salvata, quindi attivarli lascerebbe l\'integrazione senza autenticazione',
+        settingsMqttRelayFailed: 'Impostazioni MQTT ripristinate, ma il relay non è stato riconnesso - riavvia Bambuddy',
+        kprofilesAlwaysOverwrite: 'I profili K sovrascrivono sempre lo slot corrispondente sulla stampante',
+        kprofilesAckUnreliable: 'Una stampante che non risponde conta comunque come ripristinata - verifica i profili sulla stampante',
+        kprofilesPrinterMissing: 'Nessuna stampante con numero di serie {{serial}} - saltato',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) non è connessa - saltato',
+        kprofilesUnknownNozzle: 'Diametro ugello inatteso {{nozzle}} per {{serial}} - inviato così com\'è',
+        kprofilesUnmatched: '{{count}} profili per {{nozzle}} non avevano corrispondenza su {{printer}} - aggiunti come nuovi profili',
+        kprofilesSendFailed: 'Impossibile inviare i profili {{nozzle}} a {{printer}} ({{serial}})',
+        kprofilesRefused: '{{printer}} ({{serial}}) ha rifiutato i profili {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'Non è stato possibile completare il passaggio dei profili K - {{reason}}. Quanto ripristinato prima resta salvato.',
+      },
+    },
+
     // History
     history: 'Cronologia',
     clear: 'Cancella',
     date: 'Data',
     status: 'Stato',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Backup (manuale)',
+      scheduled: 'Backup (pianificato)',
+      restore: 'Ripristino',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/ja.ts

@@ -4874,11 +4874,80 @@ export default {
     clearedLogs: '{{count}}件のログを削除しました',
     failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Git から復元',
+      title: 'Git バックアップから復元',
+      subtitle: 'コミットと復元する項目を選択します',
+      commitLabel: 'バックアップのコミット',
+      latestCommit: '最新のバックアップ (ブランチ先端)',
+      categoriesLabel: '復元する項目',
+      inspecting: 'バックアップの内容を読み込んでいます...',
+      itemCount: 'バックアップ内に {{count}} 件',
+      overwriteLabel: '既存のエントリを上書きする',
+      overwriteOn: '既存のエントリはバックアップの内容で更新されます。',
+      overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。',
+      selectedCount: '{{count}} 件選択中',
+      restoring: '復元しています...',
+      confirmTitle: 'バックアップから復元しますか?',
+      confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。',
+      confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。',
+      kprofilesOverwriteCaveat: 'Kプロファイルは例外です。スロットへの書き込みは、プリンター上のキャリブレーションを常に置き換えます。',
+      tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
+      reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
+      partialHint: '上に表示されたカテゴリーは完了し、保存されています。表示されていないカテゴリーは実行されていません。',
+      failed: '復元に失敗しました。',
+      loadFailed: 'バックアップリポジトリを読み取れませんでした。',
+      details: {
+        notPresent: 'このバックアップコミットには含まれていません',
+        unreadableJson: '読み取れない JSON: {{paths}}',
+        settingsNoPayload: 'データに設定が含まれていません',
+        settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます',
+        settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
+        settingsCompanionOnlyWillSkip: 'スイッチ {{companion}} 件はオフのままになります - それぞれに必要な認証情報はバックアップから復元できません',
+        spoolsUsageCount: '使用履歴 {{count}} 件を含む',
+        archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
+        kprofilesPrinterCount: 'プリンター {{count}} 台分',
+      },
+      notes: {
+        noData: 'この種類のデータはこのバックアップに含まれていません',
+        archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました',
+        archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました',
+        archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
+        archivesOwnerUnmatched: '一部のアーカイブはこのインスタンスに存在しない所有者を指しています - バックアップのユーザー ID から推測せずに所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
+        archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません',
+        archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした',
+        archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません',
+        spoolUsageUnresolved: '使用履歴 {{count}} 件をスキップしました - 対応するスプールがこのバックアップのスプール一覧にないため、紐付ける先がありません。',
+        spoolUsageUnlinked: '使用履歴 {{count}} 件を印刷履歴へのリンクなしで復元しました - リンクを保持するにはスプール在庫と一緒に印刷アーカイブも選択してください。',
+        spoolTagKept: 'スプールタグ {{count}} 件をそのままにしました - バックアップの内容ではその後スキャンされたタグが消えるか、別のスプールに移ってしまうためです。',
+        settingsCredentialsSkipped: '認証情報のようなキー {{count}} 件をスキップしました - シークレットは手動で再入力してください',
+        settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください',
+        settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります',
+        settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください',
+        kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします',
+        kprofilesAckUnreliable: '応答しないプリンターも復元済みとして数えます - プロファイルはプリンター側で確認してください',
+        kprofilesPrinterMissing: 'シリアル {{serial}} のプリンターがありません - スキップしました',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) は接続されていません - スキップしました',
+        kprofilesUnknownNozzle: '{{serial}} に想定外のノズル径 {{nozzle}} - そのまま送信しました',
+        kprofilesUnmatched: '{{nozzle}} 用のプロファイル {{count}} 件は {{printer}} に該当がありませんでした - 新規プロファイルとして追加しました',
+        kprofilesSendFailed: '{{nozzle}} のプロファイルを {{printer}} ({{serial}}) に送信できませんでした',
+        kprofilesRefused: '{{printer}} ({{serial}}) が {{nozzle}} のプロファイルを拒否しました: {{reason}}',
+        kprofilesStepFailed: 'K プロファイルの処理を完了できませんでした - {{reason}}。それまでに復元された内容は保存されています。',
+      },
+    },
+
     // History
     history: '履歴',
     clear: 'クリア',
     date: '日付',
     status: 'ステータス',
+    trigger: '種類',
+    triggers: {
+      manual: 'バックアップ(手動)',
+      scheduled: 'バックアップ(スケジュール)',
+      restore: '復元',
+    },
     commit: 'コミット',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/ko.ts

@@ -4638,10 +4638,79 @@ export default {
     backupFailed2: '백업 실패: {{message}}',
     clearedLogs: '{{count}}개 로그 초기화됨',
     failedToClearLogs: '로그 초기화 실패: {{message}}',
+
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Git에서 복원',
+      title: 'Git 백업에서 복원',
+      subtitle: '커밋과 복원할 항목을 선택하세요',
+      commitLabel: '백업 커밋',
+      latestCommit: '최신 백업 (브랜치 최신 커밋)',
+      categoriesLabel: '복원할 항목',
+      inspecting: '백업 내용을 읽고 있습니다...',
+      itemCount: '백업에 {{count}}개',
+      overwriteLabel: '기존 항목 덮어쓰기',
+      overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.',
+      overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.',
+      selectedCount: '{{count}}개 선택됨',
+      restoring: '복원 중...',
+      confirmTitle: '백업에서 복원하시겠습니까?',
+      confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.',
+      confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.',
+      kprofilesOverwriteCaveat: 'K 프로파일은 예외입니다. 슬롯에 쓰면 프린터의 캘리브레이션이 항상 교체됩니다.',
+      tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
+      reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
+      partialHint: '위에 표시된 카테고리는 완료되어 저장되었습니다. 표시되지 않은 카테고리는 실행되지 않았습니다.',
+      failed: '복원에 실패했습니다.',
+      loadFailed: '백업 저장소를 읽을 수 없습니다.',
+      details: {
+        notPresent: '이 백업 커밋에는 없습니다',
+        unreadableJson: '읽을 수 없는 JSON: {{paths}}',
+        settingsNoPayload: '데이터에 설정이 없습니다',
+        settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다',
+        settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다',
+        settingsCompanionOnlyWillSkip: '스위치 {{companion}}개는 꺼진 상태로 둡니다 - 각각에 필요한 자격 증명은 백업에서 복원할 수 없습니다',
+        spoolsUsageCount: '사용 기록 {{count}}건 포함',
+        archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
+        kprofilesPrinterCount: '프린터 {{count}}대 분량',
+      },
+      notes: {
+        noData: '이 백업에는 이런 종류의 데이터가 없습니다',
+        archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다',
+        archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다',
+        archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
+        archivesOwnerUnmatched: '일부 아카이브가 이 인스턴스에 없는 소유자를 가리킵니다 - 백업의 사용자 ID로 추측하지 않고 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
+        archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
+        archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다',
+        archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
+        spoolUsageUnresolved: '사용 기록 {{count}}건을 건너뛰었습니다 - 해당 스풀이 이 백업의 스풀 목록에 없어 연결할 대상이 없습니다.',
+        spoolUsageUnlinked: '사용 기록 {{count}}건을 출력 기록 연결 없이 복원했습니다 - 연결을 유지하려면 스풀 재고와 함께 출력 아카이브도 선택하세요.',
+        spoolTagKept: '스풀 태그 {{count}}개를 그대로 두었습니다 - 백업대로라면 그 사이 스캔된 태그가 지워지거나 다른 스풀로 옮겨졌을 것입니다.',
+        settingsCredentialsSkipped: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰었습니다 - 비밀 값은 직접 다시 입력하세요',
+        settingsAuthSkipped: '인증 설정 {{count}}개를 건너뛰었습니다 - 잠금 검사가 계속 동작하도록 설정 > 인증에서 변경하세요',
+        settingsCompanionSkipped: '{{keys}}을(를) 꺼진 상태로 두었습니다 - 각 항목에 필요한 자격 증명은 백업에서 복원할 수 없고 이 인스턴스에도 저장되어 있지 않아, 켜면 연동이 인증 없이 열립니다',
+        settingsMqttRelayFailed: 'MQTT 설정을 복원했지만 릴레이를 다시 연결하지 못했습니다 - Bambuddy를 재시작하세요',
+        kprofilesAlwaysOverwrite: 'K 프로파일은 항상 프린터의 해당 슬롯을 덮어씁니다',
+        kprofilesAckUnreliable: '응답하지 않는 프린터도 복원됨으로 집계됩니다 - 프린터에서 프로파일을 확인하세요',
+        kprofilesPrinterMissing: '시리얼 {{serial}}인 프린터가 없습니다 - 건너뛰었습니다',
+        kprofilesPrinterOffline: '{{printer}}({{serial}})이(가) 연결되어 있지 않습니다 - 건너뛰었습니다',
+        kprofilesUnknownNozzle: '{{serial}}의 예상치 못한 노즐 직경 {{nozzle}} - 그대로 전송했습니다',
+        kprofilesUnmatched: '{{nozzle}}용 프로파일 {{count}}개가 {{printer}}에 대응 항목이 없습니다 - 새 프로파일로 추가했습니다',
+        kprofilesSendFailed: '{{nozzle}} 프로파일을 {{printer}}({{serial}})에 보내지 못했습니다',
+        kprofilesRefused: '{{printer}}({{serial}})이(가) {{nozzle}} 프로파일을 거부했습니다: {{reason}}',
+        kprofilesStepFailed: 'K 프로파일 단계를 완료하지 못했습니다 - {{reason}}. 그 전에 복원된 항목은 그대로 저장되어 있습니다.',
+      },
+    },
     history: '기록',
     clear: '초기화',
     date: '날짜',
     status: '상태',
+    trigger: '유형',
+    triggers: {
+      manual: '백업(수동)',
+      scheduled: '백업(예약)',
+      restore: '복원',
+    },
     commit: '커밋',
     localBackup: '로컬 백업',
     localBackupDescription: '데이터베이스, 아카이브, 업로드 및 모든 파일을 포함한 Bambuddy 데이터의 전체 백업을 만듭니다.',

+ 69 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4862,11 +4862,80 @@ export default {
     clearedLogs: '{{count}} logs removidos',
     failedToClearLogs: 'Falha ao limpar logs: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restaurar do Git',
+      title: 'Restaurar do backup Git',
+      subtitle: 'Escolha um commit e o que deve ser restaurado',
+      commitLabel: 'Commit do backup',
+      latestCommit: 'Backup mais recente (ponta do branch)',
+      categoriesLabel: 'O que restaurar',
+      inspecting: 'Lendo o conteúdo do backup...',
+      itemCount: '{{count}} no backup',
+      overwriteLabel: 'Sobrescrever entradas existentes',
+      overwriteOn: 'As entradas existentes serão atualizadas a partir do backup.',
+      overwriteOff: 'Apenas as entradas ausentes são adicionadas; as existentes permanecem intactas.',
+      selectedCount: '{{count}} selecionados',
+      restoring: 'Restaurando...',
+      confirmTitle: 'Restaurar do backup?',
+      confirmMessage: 'As categorias selecionadas serão restauradas deste commit. As entradas ausentes são adicionadas e as existentes permanecem como estão.',
+      confirmMessageOverwrite: 'As categorias selecionadas serão restauradas deste commit, sobrescrevendo as entradas que já existem localmente. Não é possível desfazer.',
+      kprofilesOverwriteCaveat: 'Os perfis K são a exceção: gravar um slot sempre substitui a calibração na impressora.',
+      tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
+      reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
+      partialHint: 'As categorias listadas acima foram concluídas e estão salvas. As que faltam não chegaram a ser executadas.',
+      failed: 'Falha na restauração.',
+      loadFailed: 'Não foi possível ler o repositório de backup.',
+      details: {
+        notPresent: 'Não está presente neste commit de backup',
+        unreadableJson: 'JSON ilegível: {{paths}}',
+        settingsNoPayload: 'Nenhuma configuração nos dados',
+        settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
+        settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
+        settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
+        spoolsUsageCount: 'incluindo {{count}} registros de consumo',
+        archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
+        kprofilesPrinterCount: 'em {{count}} impressoras',
+      },
+      notes: {
+        noData: 'Não há dados desse tipo neste backup',
+        archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido',
+        archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido',
+        archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
+        archivesOwnerUnmatched: 'Alguns arquivos apontam para um proprietário que esta instância não tem - o proprietário foi limpo em vez de deduzido do id de usuário da cópia, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
+        archivesOwnerUnknown: 'Alguns arquivos foram restaurados sem proprietário - este backup não registra nenhum, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os atribua',
+        archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada',
+        archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git',
+        spoolUsageUnresolved: '{{count}} registros de consumo ignorados - o carretel deles não está na lista de carretéis deste backup, então não há a que vinculá-los.',
+        spoolUsageUnlinked: '{{count}} registros de consumo restaurados sem o vínculo com o histórico de impressão - selecione Arquivos de impressão junto com Inventário de carretéis para mantê-lo.',
+        spoolTagKept: '{{count}} etiquetas de carretel foram mantidas como estavam - o backup teria apagado uma etiqueta lida desde então, ou a teria movido para um segundo carretel.',
+        settingsCredentialsSkipped: '{{count}} chaves parecidas com credenciais ignoradas - digite os segredos novamente à mão',
+        settingsAuthSkipped: '{{count}} configurações de autenticação ignoradas - altere-as em Configurações > Autenticação para que as verificações de bloqueio continuem valendo',
+        settingsCompanionSkipped: '{{keys}} ficaram desligados - a credencial que cada um precisa não pode ser restaurada de um backup e esta instância não tem nenhuma armazenada, então ligá-los deixaria a integração sem autenticação',
+        settingsMqttRelayFailed: 'Configurações MQTT restauradas, mas o relay não pôde ser reconectado - reinicie o Bambuddy',
+        kprofilesAlwaysOverwrite: 'Os perfis K sempre sobrescrevem o slot correspondente na impressora',
+        kprofilesAckUnreliable: 'Uma impressora que não responde ainda conta como restaurada - verifique os perfis na impressora',
+        kprofilesPrinterMissing: 'Nenhuma impressora com o número de série {{serial}} - ignorado',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) não está conectada - ignorado',
+        kprofilesUnknownNozzle: 'Diâmetro de bico inesperado {{nozzle}} para {{serial}} - enviado como está',
+        kprofilesUnmatched: '{{count}} perfis para {{nozzle}} não tinham correspondente em {{printer}} - adicionados como novos perfis',
+        kprofilesSendFailed: 'Não foi possível enviar os perfis de {{nozzle}} para {{printer}} ({{serial}})',
+        kprofilesRefused: '{{printer}} ({{serial}}) recusou os perfis de {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'Não foi possível concluir a etapa dos perfis K - {{reason}}. O que foi restaurado antes continua salvo.',
+      },
+    },
+
     // History
     history: 'Histórico',
     clear: 'Limpar',
     date: 'Data',
     status: 'Status',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Backup manual',
+      scheduled: 'Backup agendado',
+      restore: 'Restauração',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/ru.ts

@@ -4630,10 +4630,79 @@ export default {
     backupFailed2: "Ошибка резервного копирования: {{message}}",
     clearedLogs: "Очищено записей журнала: {{count}}",
     failedToClearLogs: "Не удалось очистить журнал: {{message}}",
+
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Восстановить из Git',
+      title: 'Восстановление из резервной копии Git',
+      subtitle: 'Выберите коммит и данные для восстановления',
+      commitLabel: 'Коммит резервной копии',
+      latestCommit: 'Последняя резервная копия (вершина ветки)',
+      categoriesLabel: 'Что восстановить',
+      inspecting: 'Чтение содержимого резервной копии...',
+      itemCount: '{{count}} в резервной копии',
+      overwriteLabel: 'Перезаписывать существующие записи',
+      overwriteOn: 'Существующие записи будут обновлены из резервной копии.',
+      overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.',
+      selectedCount: 'Выбрано: {{count}}',
+      restoring: 'Восстановление...',
+      confirmTitle: 'Восстановить из резервной копии?',
+      confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.',
+      confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.',
+      kprofilesOverwriteCaveat: 'K-профили — исключение: запись в слот всегда заменяет калибровку на принтере.',
+      tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
+      reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
+      partialHint: 'Перечисленные выше категории завершены и сохранены. Отсутствующие категории не выполнялись.',
+      failed: 'Не удалось выполнить восстановление.',
+      loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
+      details: {
+        notPresent: 'Отсутствует в этом коммите резервной копии',
+        unreadableJson: 'Нечитаемый JSON: {{paths}}',
+        settingsNoPayload: 'В данных нет настроек',
+        settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
+        settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
+        settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
+        spoolsUsageCount: 'включая записей расхода: {{count}}',
+        archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
+        kprofilesPrinterCount: 'по {{count}} принтерам',
+      },
+      notes: {
+        noData: 'В этой резервной копии нет данных такого типа',
+        archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена',
+        archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена',
+        archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
+        archivesOwnerUnmatched: 'Некоторые архивы указывают владельца, которого нет в этом экземпляре - владелец очищен, а не угадан по идентификатору пользователя из резервной копии, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
+        archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца',
+        archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена',
+        archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
+        spoolUsageUnresolved: 'Записей расхода пропущено: {{count}} - их катушки нет в списке катушек этой резервной копии, поэтому привязать их не к чему.',
+        spoolUsageUnlinked: 'Записей расхода восстановлено без связи с историей печати: {{count}} - выберите «Архивы печати» вместе с «Инвентарём катушек», чтобы сохранить связь.',
+        spoolTagKept: 'Меток катушек оставлено без изменений: {{count}} - резервная копия стёрла бы метку, отсканированную позже, или перенесла бы её на другую катушку.',
+        settingsCredentialsSkipped: 'Ключей, похожих на учётные данные, пропущено: {{count}} - введите секреты вручную',
+        settingsAuthSkipped: 'Настроек аутентификации пропущено: {{count}} - меняйте их в разделе «Настройки > Аутентификация», чтобы продолжали работать проверки блокировки',
+        settingsCompanionSkipped: '{{keys}} оставлены выключенными - нужные им учётные данные нельзя восстановить из резервной копии, и в этом экземпляре они не сохранены, поэтому включение оставило бы интеграцию без аутентификации',
+        settingsMqttRelayFailed: 'Настройки MQTT восстановлены, но переподключить реле не удалось - перезапустите Bambuddy',
+        kprofilesAlwaysOverwrite: 'K-профили всегда перезаписывают соответствующий слот на принтере',
+        kprofilesAckUnreliable: 'Принтер, который не отвечает, всё равно считается восстановленным - проверьте профили на принтере',
+        kprofilesPrinterMissing: 'Нет принтера с серийным номером {{serial}} - пропущено',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) не подключён - пропущено',
+        kprofilesUnknownNozzle: 'Неожиданный диаметр сопла {{nozzle}} для {{serial}} - отправлено как есть',
+        kprofilesUnmatched: 'Профилей для {{nozzle}} без соответствия на {{printer}}: {{count}} - добавлены как новые профили',
+        kprofilesSendFailed: 'Не удалось отправить профили {{nozzle}} на {{printer}} ({{serial}})',
+        kprofilesRefused: '{{printer}} ({{serial}}) отклонил профили {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'Не удалось завершить этап K-профилей - {{reason}}. Всё, что было восстановлено до него, сохранено.',
+      },
+    },
     history: "История",
     clear: "Очистить",
     date: "Дата",
     status: "Статус",
+    trigger: "Тип",
+    triggers: {
+      manual: "Резервная копия (вручную)",
+      scheduled: "Резервная копия (по расписанию)",
+      restore: "Восстановление",
+    },
     commit: "Коммит",
     localBackup: "Локальная резервная копия",
     localBackupDescription: "Создать полную резервную копию данных Bambuddy, включая базу данных, архивы, загрузки и все файлы.",

+ 69 - 0
frontend/src/i18n/locales/tr.ts

@@ -4852,10 +4852,79 @@ export default {
     clearedLogs: '{{count}} günlük temizlendi',
     failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Git\'ten Geri Yükle',
+      title: 'Git yedeğinden geri yükle',
+      subtitle: 'Bir commit ve geri yüklenecek verileri seçin',
+      commitLabel: 'Yedek commit\'i',
+      latestCommit: 'En son yedek (dal ucu)',
+      categoriesLabel: 'Neler geri yüklenecek',
+      inspecting: 'Yedek içeriği okunuyor...',
+      itemCount: 'yedekte {{count}} kayıt',
+      overwriteLabel: 'Mevcut kayıtların üzerine yaz',
+      overwriteOn: 'Mevcut kayıtlar yedekten güncellenecek.',
+      overwriteOff: 'Yalnızca eksik kayıtlar eklenir; mevcut olanlara dokunulmaz.',
+      selectedCount: '{{count}} seçildi',
+      restoring: 'Geri yükleniyor...',
+      confirmTitle: 'Yedekten geri yüklensin mi?',
+      confirmMessage: 'Seçilen kategoriler bu commit\'ten geri yüklenecek. Eksik kayıtlar eklenir, mevcut kayıtlar olduğu gibi kalır.',
+      confirmMessageOverwrite: 'Seçilen kategoriler bu commit\'ten geri yüklenecek ve yerelde bulunan kayıtların üzerine yazılacak. Bu işlem geri alınamaz.',
+      kprofilesOverwriteCaveat: 'K profilleri istisnadır: bir yuvaya yazmak yazıcıdaki kalibrasyonu her zaman değiştirir.',
+      tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
+      reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
+      partialHint: 'Yukarıda listelenen kategoriler tamamlandı ve kaydedildi. Eksik olanlar hiç çalıştırılmadı.',
+      failed: 'Geri yükleme başarısız oldu.',
+      loadFailed: 'Yedek deposu okunamadı.',
+      details: {
+        notPresent: 'Bu yedek commit\'inde yok',
+        unreadableJson: 'Okunamayan JSON: {{paths}}',
+        settingsNoPayload: 'Veride ayar yok',
+        settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
+        settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
+        settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
+        spoolsUsageCount: '{{count}} kullanım kaydı dahil',
+        archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
+        kprofilesPrinterCount: '{{count}} yazıcı genelinde',
+      },
+      notes: {
+        noData: 'Bu yedekte bu türde veri yok',
+        archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi',
+        archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi',
+        archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
+        archivesOwnerUnmatched: 'Bazı arşivler bu örnekte bulunmayan bir sahibi belirtiyor - sahip, yedekteki kullanıcı kimliğinden tahmin edilmek yerine temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
+        archivesOwnerUnknown: 'Bazı arşivler sahipsiz geri yüklendi - bu yedek sahip bilgisi içermiyor, bu yüzden bir yönetici atama yapana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
+        archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı',
+        archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
+        spoolUsageUnresolved: '{{count}} kullanım kaydı atlandı - makaraları bu yedeğin makara listesinde olmadığı için bağlanacak bir şey yok.',
+        spoolUsageUnlinked: '{{count}} kullanım kaydı baskı geçmişi bağlantısı olmadan geri yüklendi - bağlantıyı korumak için Baskı arşivlerini Makara envanteriyle birlikte seçin.',
+        spoolTagKept: '{{count}} makara etiketi olduğu gibi bırakıldı - yedek, o zamandan beri okutulmuş bir etiketi silecek ya da ikinci bir makaraya taşıyacaktı.',
+        settingsCredentialsSkipped: 'Kimlik bilgisi benzeri {{count}} anahtar atlandı - gizli değerleri elle yeniden girin',
+        settingsAuthSkipped: '{{count}} kimlik doğrulama ayarı atlandı - kilitlenme kontrolleri çalışmaya devam etsin diye bunları Ayarlar > Kimlik Doğrulama bölümünden değiştirin',
+        settingsCompanionSkipped: '{{keys}} kapalı bırakıldı - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez ve bu örnekte kayıtlı değil, dolayısıyla açmak entegrasyonu kimlik doğrulamasız bırakırdı',
+        settingsMqttRelayFailed: 'MQTT ayarları geri yüklendi ancak röle yeniden bağlanamadı - Bambuddy\'yi yeniden başlatın',
+        kprofilesAlwaysOverwrite: 'K profilleri yazıcıdaki eşleşen yuvanın her zaman üzerine yazar',
+        kprofilesAckUnreliable: 'Yanıt vermeyen bir yazıcı yine de geri yüklendi sayılır - profilleri yazıcıda doğrulayın',
+        kprofilesPrinterMissing: '{{serial}} seri numaralı yazıcı yok - atlandı',
+        kprofilesPrinterOffline: '{{printer}} ({{serial}}) bağlı değil - atlandı',
+        kprofilesUnknownNozzle: '{{serial}} için beklenmeyen nozul çapı {{nozzle}} - olduğu gibi gönderildi',
+        kprofilesUnmatched: '{{nozzle}} için {{count}} profilin {{printer}} üzerinde karşılığı yoktu - yeni profil olarak eklendi',
+        kprofilesSendFailed: '{{nozzle}} profilleri {{printer}} ({{serial}}) yazıcısına gönderilemedi',
+        kprofilesRefused: '{{printer}} ({{serial}}) {{nozzle}} profillerini reddetti: {{reason}}',
+        kprofilesStepFailed: 'K profili adımı tamamlanamadı - {{reason}}. Bundan önce geri yüklenenler yine de kaydedildi.',
+      },
+    },
+
     history: 'Geçmiş',
     clear: 'Temizle',
     date: 'Tarih',
     status: 'Durum',
+    trigger: 'Tür',
+    triggers: {
+      manual: 'Yedek (manuel)',
+      scheduled: 'Yedek (zamanlanmış)',
+      restore: 'Geri yükleme',
+    },
     commit: 'Commit',
 
     // Yerel Yedekleme

+ 69 - 0
frontend/src/i18n/locales/uk.ts

@@ -4917,11 +4917,80 @@ export default {
     clearedLogs: "Очищено журнали {{count}}.",
     failedToClearLogs: "Не вдалося очистити журнали: {{message}}",
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: "Відновити з Git",
+      title: "Відновлення з резервної копії Git",
+      subtitle: "Виберіть коміт і вкажіть, що відновити",
+      commitLabel: "Коміт резервної копії",
+      latestCommit: "Остання резервна копія (вершина гілки)",
+      categoriesLabel: "Що відновити",
+      inspecting: "Читання вмісту резервної копії...",
+      itemCount: "{{count}} у резервній копії",
+      overwriteLabel: "Перезаписувати наявні записи",
+      overwriteOn: "Наявні записи буде оновлено з резервної копії.",
+      overwriteOff: "Додаються лише відсутні записи; наявні залишаються без змін.",
+      selectedCount: "Вибрано: {{count}}",
+      restoring: "Відновлення...",
+      confirmTitle: "Відновити з резервної копії?",
+      confirmMessage: "Вибрані категорії буде відновлено з цього коміту. Відсутні записи буде додано, наявні залишаться без змін.",
+      confirmMessageOverwrite: "Вибрані категорії буде відновлено з цього коміту з перезаписом записів, які вже існують локально. Цю дію не можна скасувати.",
+      kprofilesOverwriteCaveat: 'K-профілі — виняток: запис у слот завжди замінює калібрування на принтері.',
+      tally: "відновлено: {{restored}}, пропущено: {{skipped}}, з помилкою: {{failed}}",
+      reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
+      partialHint: 'Перелічені вище категорії завершено та збережено. Відсутні категорії не виконувалися.',
+      failed: "Не вдалося виконати відновлення.",
+      loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
+      details: {
+        notPresent: "Відсутнє в цьому коміті резервної копії",
+        unreadableJson: "Нечитабельний JSON: {{paths}}",
+        settingsNoPayload: "У даних немає налаштувань",
+        settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
+        settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
+        settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
+        spoolsUsageCount: "включно із записами використання: {{count}}",
+        archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
+        kprofilesPrinterCount: "по {{count}} принтерах",
+      },
+      notes: {
+        noData: "У цій резервній копії немає даних такого типу",
+        archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено",
+        archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено",
+        archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
+        archivesOwnerUnmatched: "Деякі архіви вказують власника, якого немає в цьому екземплярі - власника очищено, а не вгадано за ідентифікатором користувача з резервної копії, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
+        archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника",
+        archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений",
+        archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
+        spoolUsageUnresolved: "Записів використання пропущено: {{count}} - їхньої котушки немає у списку котушок цієї резервної копії, тож немає до чого їх прив'язати.",
+        spoolUsageUnlinked: "Записів використання відновлено без зв'язку з історією друку: {{count}} - виберіть «Архіви друку» разом з «Інвентарем котушок», щоб зберегти зв'язок.",
+        spoolTagKept: "Міток котушок залишено без змін: {{count}} - резервна копія стерла б мітку, відскановану пізніше, або перенесла б її на іншу котушку.",
+        settingsCredentialsSkipped: "Ключів, схожих на облікові дані, пропущено: {{count}} - введіть секрети вручну",
+        settingsAuthSkipped: "Налаштувань автентифікації пропущено: {{count}} - змінюйте їх у розділі «Налаштування > Автентифікація», щоб перевірки блокування й далі працювали",
+        settingsCompanionSkipped: "{{keys}} залишено вимкненими - потрібні їм облікові дані не можна відновити з резервної копії, і в цьому екземплярі вони не збережені, тож увімкнення залишило б інтеграцію без автентифікації",
+        settingsMqttRelayFailed: "Налаштування MQTT відновлено, але реле не вдалося перепідключити - перезапустіть Bambuddy",
+        kprofilesAlwaysOverwrite: "K-профілі завжди перезаписують відповідний слот на принтері",
+        kprofilesAckUnreliable: "Принтер, який не відповідає, усе одно вважається відновленим - перевірте профілі на принтері",
+        kprofilesPrinterMissing: "Немає принтера із серійним номером {{serial}} - пропущено",
+        kprofilesPrinterOffline: "{{printer}} ({{serial}}) не підключено - пропущено",
+        kprofilesUnknownNozzle: "Неочікуваний діаметр сопла {{nozzle}} для {{serial}} - надіслано як є",
+        kprofilesUnmatched: "Профілів для {{nozzle}} без відповідника на {{printer}}: {{count}} - додано як нові профілі",
+        kprofilesSendFailed: "Не вдалося надіслати профілі {{nozzle}} на {{printer}} ({{serial}})",
+        kprofilesRefused: "{{printer}} ({{serial}}) відхилив профілі {{nozzle}}: {{reason}}",
+        kprofilesStepFailed: "Не вдалося завершити етап K-профілів - {{reason}}. Усе, що було відновлено до нього, збережено.",
+      },
+    },
+
     // History
     history: "історія",
     clear: "Очистити",
     date: "Дата",
     status: "Статус",
+    trigger: "Тип",
+    triggers: {
+      manual: "Резервна копія (вручну)",
+      scheduled: "Резервна копія (за розкладом)",
+      restore: "Відновлення",
+    },
     commit: "Коміт",
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4862,11 +4862,80 @@ export default {
     clearedLogs: '已清除 {{count}} 条日志',
     failedToClearLogs: '清除日志失败:{{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: '从 Git 恢复',
+      title: '从 Git 备份恢复',
+      subtitle: '选择提交以及要恢复的内容',
+      commitLabel: '备份提交',
+      latestCommit: '最新备份(分支最新提交)',
+      categoriesLabel: '恢复内容',
+      inspecting: '正在读取备份内容...',
+      itemCount: '备份中有 {{count}} 项',
+      overwriteLabel: '覆盖已有条目',
+      overwriteOn: '已有条目将根据备份内容更新。',
+      overwriteOff: '仅添加缺失的条目,已有条目保持不变。',
+      selectedCount: '已选择 {{count}} 项',
+      restoring: '正在恢复...',
+      confirmTitle: '要从备份恢复吗?',
+      confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。',
+      confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。',
+      kprofilesOverwriteCaveat: 'K 值配置是例外:写入插槽总会替换打印机上的校准数据。',
+      tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
+      reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
+      partialHint: '上面列出的类别已完成并已保存。未列出的类别没有执行。',
+      failed: '恢复失败。',
+      loadFailed: '无法读取备份仓库。',
+      details: {
+        notPresent: '此备份提交中不存在',
+        unreadableJson: '无法解析的 JSON:{{paths}}',
+        settingsNoPayload: '数据中没有设置',
+        settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
+        settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
+        settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
+        spoolsUsageCount: '其中含 {{count}} 条使用记录',
+        archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
+        kprofilesPrinterCount: '涉及 {{count}} 台打印机',
+      },
+      notes: {
+        noData: '此备份中没有这类数据',
+        archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联',
+        archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联',
+        archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
+        archivesOwnerUnmatched: '部分归档指向本实例没有的用户 - 已清除归属,而不是根据备份中的用户 ID 猜测,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
+        archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
+        archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖',
+        archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中',
+        spoolUsageUnresolved: '已跳过 {{count}} 条使用记录 - 其耗材卷不在此备份的耗材列表中,没有可挂接的对象。',
+        spoolUsageUnlinked: '已恢复 {{count}} 条使用记录,但缺少打印历史关联 - 请同时选择“打印归档”和“耗材库存”以保留该关联。',
+        spoolTagKept: '{{count}} 个耗材标签保持原样 - 按备份内容会清除此后扫描过的标签,或把它挪到另一卷耗材上。',
+        settingsCredentialsSkipped: '已跳过 {{count}} 个疑似凭据的键 - 请手动重新输入密钥',
+        settingsAuthSkipped: '已跳过 {{count}} 项认证设置 - 请在“设置 > 认证”中修改,以便锁定检查继续生效',
+        settingsCompanionSkipped: '{{keys}} 保持关闭 - 它们各自所需的凭据无法从备份恢复,本实例也没有存储,开启会让集成处于未认证状态',
+        settingsMqttRelayFailed: 'MQTT 设置已恢复,但中继无法重新连接 - 请重启 Bambuddy',
+        kprofilesAlwaysOverwrite: 'K 值配置总是覆盖打印机上对应的槽位',
+        kprofilesAckUnreliable: '打印机不回应时仍计为已恢复 - 请在打印机上核对配置',
+        kprofilesPrinterMissing: '没有序列号为 {{serial}} 的打印机 - 已跳过',
+        kprofilesPrinterOffline: '{{printer}}({{serial}})未连接 - 已跳过',
+        kprofilesUnknownNozzle: '{{serial}} 的喷嘴直径 {{nozzle}} 不在预期范围内 - 已原样发送',
+        kprofilesUnmatched: '{{nozzle}} 的 {{count}} 个配置在 {{printer}} 上没有对应项 - 已作为新配置添加',
+        kprofilesSendFailed: '无法将 {{nozzle}} 的配置发送到 {{printer}}({{serial}})',
+        kprofilesRefused: '{{printer}}({{serial}})拒绝了 {{nozzle}} 的配置:{{reason}}',
+        kprofilesStepFailed: 'K 值配置步骤未能完成 - {{reason}}。在此之前恢复的内容仍已保存。',
+      },
+    },
+
     // History
     history: '历史记录',
     clear: '清除',
     date: '日期',
     status: '状态',
+    trigger: '类型',
+    triggers: {
+      manual: '备份(手动)',
+      scheduled: '备份(计划)',
+      restore: '恢复',
+    },
     commit: '提交',
 
     // Local Backup

+ 69 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4862,11 +4862,80 @@ export default {
     clearedLogs: '已清除 {{count}} 條日誌',
     failedToClearLogs: '清除日誌失敗:{{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: '從 Git 還原',
+      title: '從 Git 備份還原',
+      subtitle: '選擇提交以及要還原的項目',
+      commitLabel: '備份提交',
+      latestCommit: '最新備份(分支最新提交)',
+      categoriesLabel: '還原項目',
+      inspecting: '正在讀取備份內容...',
+      itemCount: '備份中有 {{count}} 筆',
+      overwriteLabel: '覆寫既有項目',
+      overwriteOn: '既有項目將依備份內容更新。',
+      overwriteOff: '僅新增缺少的項目,既有項目保持不變。',
+      selectedCount: '已選擇 {{count}} 筆',
+      restoring: '正在還原...',
+      confirmTitle: '要從備份還原嗎?',
+      confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。',
+      confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。',
+      kprofilesOverwriteCaveat: 'K 值設定檔是例外:寫入插槽一定會取代印表機上的校準資料。',
+      tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
+      reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
+      partialHint: '上方列出的類別已完成並已儲存。未列出的類別沒有執行。',
+      failed: '還原失敗。',
+      loadFailed: '無法讀取備份儲存庫。',
+      details: {
+        notPresent: '此備份提交中不存在',
+        unreadableJson: '無法解析的 JSON:{{paths}}',
+        settingsNoPayload: '資料中沒有設定',
+        settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
+        settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
+        settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
+        spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
+        archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
+        kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
+      },
+      notes: {
+        noData: '此備份中沒有這類資料',
+        archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結',
+        archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結',
+        archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
+        archivesOwnerUnmatched: '部分封存指向本執行個體沒有的使用者 - 已清除擁有者,而非依備份中的使用者 ID 推測,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
+        archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到',
+        archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫',
+        archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
+        spoolUsageUnresolved: '已略過 {{count}} 筆使用紀錄 - 其耗材捲不在此備份的耗材清單中,沒有可掛接的對象。',
+        spoolUsageUnlinked: '已還原 {{count}} 筆使用紀錄,但缺少列印歷史連結 - 請同時選擇「列印封存」與「耗材庫存」以保留該連結。',
+        spoolTagKept: '{{count}} 個耗材標籤維持原樣 - 依備份內容會清除此後掃描過的標籤,或把它移到另一捲耗材上。',
+        settingsCredentialsSkipped: '已略過 {{count}} 個疑似憑證的鍵 - 請手動重新輸入密鑰',
+        settingsAuthSkipped: '已略過 {{count}} 項驗證設定 - 請在「設定 > 驗證」中修改,讓鎖定檢查繼續生效',
+        settingsCompanionSkipped: '{{keys}} 維持關閉 - 它們各自所需的憑證無法從備份還原,本執行個體也沒有儲存,開啟會讓整合處於未驗證狀態',
+        settingsMqttRelayFailed: 'MQTT 設定已還原,但中繼無法重新連線 - 請重新啟動 Bambuddy',
+        kprofilesAlwaysOverwrite: 'K 值設定檔一律覆寫印表機上對應的插槽',
+        kprofilesAckUnreliable: '印表機未回應時仍計為已還原 - 請在印表機上核對設定檔',
+        kprofilesPrinterMissing: '沒有序號為 {{serial}} 的印表機 - 已略過',
+        kprofilesPrinterOffline: '{{printer}}({{serial}})未連線 - 已略過',
+        kprofilesUnknownNozzle: '{{serial}} 的噴嘴直徑 {{nozzle}} 不在預期範圍內 - 已原樣傳送',
+        kprofilesUnmatched: '{{nozzle}} 的 {{count}} 個設定檔在 {{printer}} 上沒有對應項 - 已新增為新設定檔',
+        kprofilesSendFailed: '無法將 {{nozzle}} 的設定檔傳送到 {{printer}}({{serial}})',
+        kprofilesRefused: '{{printer}}({{serial}})拒絕了 {{nozzle}} 的設定檔:{{reason}}',
+        kprofilesStepFailed: 'K 值設定檔步驟未能完成 - {{reason}}。在此之前還原的內容仍已儲存。',
+      },
+    },
+
     // History
     history: '歷史紀錄',
     clear: '清除',
     date: '日期',
     status: '狀態',
+    trigger: '類型',
+    triggers: {
+      manual: '備份(手動)',
+      scheduled: '備份(排程)',
+      restore: '還原',
+    },
     commit: '提交',
 
     // Local Backup

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-BPSw6nnF.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-joRUZURS.js"></script>
+    <script type="module" crossorigin src="/assets/index-BPSw6nnF.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
   </head>
   <body>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.