Просмотр исходного кода

Merge branch 'dev' into feature/billing

behrinml 1 месяц назад
Родитель
Сommit
a9e23910fc
56 измененных файлов с 12491 добавлено и 192 удалено
  1. 0 0
      CHANGELOG.md
  2. 123 1
      backend/app/api/routes/github_backup.py
  3. 5 1
      backend/app/api/routes/library.py
  4. 295 106
      backend/app/api/routes/projects.py
  5. 4 0
      backend/app/api/routes/webhook.py
  6. 1 1
      backend/app/models/github_backup.py
  7. 123 0
      backend/app/schemas/github_backup.py
  8. 20 1
      backend/app/schemas/project.py
  9. 19 0
      backend/app/services/bambu_mqtt.py
  10. 32 3
      backend/app/services/filament_deficit.py
  11. 79 0
      backend/app/services/git_providers/base.py
  12. 33 24
      backend/app/services/git_providers/forgejo.py
  13. 106 0
      backend/app/services/git_providers/gitea.py
  14. 246 0
      backend/app/services/git_providers/github.py
  15. 261 0
      backend/app/services/git_providers/gitlab.py
  16. 50 0
      backend/app/services/github_backup.py
  17. 1957 0
      backend/app/services/github_restore.py
  18. 674 0
      backend/tests/integration/test_github_restore_api.py
  19. 312 0
      backend/tests/integration/test_projects_api.py
  20. 211 0
      backend/tests/integration/test_queue_creation_attribution.py
  21. 93 0
      backend/tests/unit/services/test_bambu_mqtt.py
  22. 111 0
      backend/tests/unit/services/test_filament_deficit.py
  23. 65 10
      backend/tests/unit/test_git_providers.py
  24. 753 0
      backend/tests/unit/test_git_providers_restore.py
  25. 3426 0
      backend/tests/unit/test_github_restore.py
  26. 97 0
      frontend/src/__tests__/components/GitHubBackupSettings.history.test.tsx
  27. 25 0
      frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx
  28. 115 0
      frontend/src/__tests__/components/GitHubBackupSettingsPermissions.test.tsx
  29. 696 0
      frontend/src/__tests__/components/GitHubRestoreModal.test.tsx
  30. 123 0
      frontend/src/__tests__/pages/ProjectDetailPage.test.tsx
  31. 190 0
      frontend/src/__tests__/pages/ProjectsPage.test.tsx
  32. 97 0
      frontend/src/__tests__/utils/dryingPresets.test.ts
  33. 110 0
      frontend/src/api/client.ts
  34. 46 1
      frontend/src/components/GitHubBackupSettings.tsx
  35. 542 0
      frontend/src/components/GitHubRestoreModal.tsx
  36. 85 1
      frontend/src/i18n/locales/de.ts
  37. 90 1
      frontend/src/i18n/locales/en.ts
  38. 85 1
      frontend/src/i18n/locales/es.ts
  39. 85 1
      frontend/src/i18n/locales/fr.ts
  40. 85 1
      frontend/src/i18n/locales/it.ts
  41. 85 1
      frontend/src/i18n/locales/ja.ts
  42. 86 2
      frontend/src/i18n/locales/ko.ts
  43. 85 1
      frontend/src/i18n/locales/pt-BR.ts
  44. 85 1
      frontend/src/i18n/locales/ru.ts
  45. 85 1
      frontend/src/i18n/locales/tr.ts
  46. 85 1
      frontend/src/i18n/locales/uk.ts
  47. 85 1
      frontend/src/i18n/locales/zh-CN.ts
  48. 85 1
      frontend/src/i18n/locales/zh-TW.ts
  49. 9 6
      frontend/src/pages/PrintersPage.tsx
  50. 109 10
      frontend/src/pages/ProjectDetailPage.tsx
  51. 145 12
      frontend/src/pages/ProjectsPage.tsx
  52. 45 0
      frontend/src/utils/dryingPresets.ts
  53. 30 0
      frontend/src/utils/projectTree.ts
  54. 0 0
      static/assets/index-CBRJgDPF.js
  55. 0 0
      static/assets/index-DJ8Q_OV9.css
  56. 2 2
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 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),

+ 5 - 1
backend/app/api/routes/library.py

@@ -2670,7 +2670,7 @@ def is_sliced_file(filename: str) -> bool:
 async def add_files_to_queue(
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
     """Add library files to the print queue.
 
@@ -2736,6 +2736,10 @@ async def add_files_to_queue(
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
+                # Without this the row is ownerless, and `queue:read_own` filters
+                # on `created_by_id` — so the user who queued the file could not
+                # see it in their own queue.
+                created_by_id=current_user.id if current_user else None,
             )
             db.add(queue_item)
 

+ 295 - 106
backend/app/api/routes/projects.py

@@ -4,12 +4,14 @@ import logging
 import os
 import uuid
 import zipfile
+from collections.abc import Sequence
+from dataclasses import dataclass, fields
 from datetime import datetime
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, StreamingResponse
-from sqlalchemy import case, func, select
+from sqlalchemy import case, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -68,10 +70,42 @@ _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
 _LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
 
 
-async def compute_project_stats(
-    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
-) -> ProjectStats:
-    """Compute statistics for a project.
+@dataclass
+class _ProjectTotals:
+    """Raw per-project aggregates, before targets turn them into percentages.
+
+    Kept addable so a master project's numbers are the plain sum of its own
+    and every descendant's (#1264) — no second set of SQL that could drift
+    from the single-project path.
+    """
+
+    total_runs: int = 0
+    total_items: int = 0
+    completed_items: int = 0
+    failed_runs: int = 0
+    total_time_seconds: float = 0.0
+    total_filament_grams: float = 0.0
+    filament_cost: float = 0.0
+    energy_kwh: float = 0.0
+    energy_cost: float = 0.0
+    queued_prints: int = 0
+    in_progress_prints: int = 0
+    bom_total_items: int = 0
+    bom_completed_items: int = 0
+    bom_cost: float = 0.0
+
+    def __add__(self, other: "_ProjectTotals") -> "_ProjectTotals":
+        return _ProjectTotals(
+            **{f.name: getattr(self, f.name) + getattr(other, f.name) for f in fields(_ProjectTotals)}
+        )
+
+
+async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int, _ProjectTotals]:
+    """Aggregate prints, queue and BOM for several projects at once.
+
+    Grouped rather than one round trip per project because a master project
+    has to aggregate its whole subtree, and the sub-project list shows each
+    branch's own roll-up alongside it (#1264).
 
     Aggregates from ``print_log_entries`` joined to ``print_archives`` so
     every actual run contributes — pre-fix this counted ``print_archives``
@@ -83,31 +117,28 @@ async def compute_project_stats(
     Orphan log entries (``archive_id IS NULL`` after archive deletion via
     ``ON DELETE SET NULL``) are excluded by the inner join — they can't
     be attributed to a project.
+
+    Projects with nothing recorded are absent from every grouped result, so
+    the caller gets a zeroed ``_ProjectTotals`` for them rather than a KeyError.
     """
-    # Per-run aggregates from print_log_entries joined on archive_id so
-    # the WHERE filters by archives.project_id. Each run's duration,
-    # filament, cost, and energy come from the log row, not the source
-    # archive — so multi-plate 3MFs and reprints both count correctly.
-    log_stats_result = await db.execute(
+    totals: dict[int, _ProjectTotals] = {pid: _ProjectTotals() for pid in project_ids}
+    if not totals:
+        return totals
+
+    # Per-run aggregates. Each run's duration, filament, cost, and energy come
+    # from the log row, not the source archive — so multi-plate 3MFs and
+    # reprints both count correctly. The total/completed/failed splits are all
+    # per-run too: quantity is summed per run, while failures are counted as
+    # runs rather than parts.
+    log_rows = await db.execute(
         select(
+            PrintArchive.project_id.label("project_id"),
             func.count(PrintLogEntry.id).label("total_runs"),
             func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
             func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
             func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
             func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
-        )
-        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
-    )
-    log_stats = log_stats_result.first()
-    total_archives = int(log_stats.total_runs or 0)
-
-    # Total items the project has produced or attempted: sum of quantity
-    # per run (each run contributes its archive's quantity). The total/
-    # completed/failed splits are all per-run, not per-file.
-    items_split_result = await db.execute(
-        select(
             func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
             func.coalesce(
                 func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
@@ -119,77 +150,237 @@ async def compute_project_stats(
             ).label("failed_runs"),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
+        .where(PrintArchive.project_id.in_(list(totals)), _LIVE_ARCHIVE)
+        .group_by(PrintArchive.project_id)
     )
-    items_split = items_split_result.first()
-    total_items = int(items_split.total_items or 0)
-    completed_items = int(items_split.completed_items or 0)
-    failed_prints = int(items_split.failed_runs or 0)
-
-    # Count queued items
-    queued_result = await db.execute(
-        select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
+    for row in log_rows:
+        entry = totals[row.project_id]
+        entry.total_runs = int(row.total_runs or 0)
+        entry.total_time_seconds = float(row.total_time or 0)
+        entry.total_filament_grams = float(row.total_filament or 0)
+        entry.filament_cost = float(row.total_filament_cost or 0)
+        entry.energy_kwh = float(row.total_energy or 0)
+        entry.energy_cost = float(row.total_energy_cost or 0)
+        entry.total_items = int(row.total_items or 0)
+        entry.completed_items = int(row.completed_items or 0)
+        entry.failed_runs = int(row.failed_runs or 0)
+
+    queue_rows = await db.execute(
+        select(
+            PrintQueueItem.project_id.label("project_id"),
+            func.coalesce(func.sum(case((PrintQueueItem.status == "pending", 1), else_=0)), 0).label("queued"),
+            func.coalesce(func.sum(case((PrintQueueItem.status == "printing", 1), else_=0)), 0).label("in_progress"),
         )
+        .where(PrintQueueItem.project_id.in_(list(totals)))
+        .group_by(PrintQueueItem.project_id)
     )
-    queued_prints = queued_result.scalar() or 0
+    for row in queue_rows:
+        entry = totals[row.project_id]
+        entry.queued_prints = int(row.queued or 0)
+        entry.in_progress_prints = int(row.in_progress or 0)
 
-    # Count in-progress items
-    in_progress_result = await db.execute(
-        select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
+    bom_rows = await db.execute(
+        select(
+            ProjectBOMItem.project_id.label("project_id"),
+            func.count(ProjectBOMItem.id).label("total"),
+            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
+                "completed"
+            ),
+            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
         )
+        .where(ProjectBOMItem.project_id.in_(list(totals)))
+        .group_by(ProjectBOMItem.project_id)
     )
-    in_progress_prints = in_progress_result.scalar() or 0
+    for row in bom_rows:
+        entry = totals[row.project_id]
+        entry.bom_total_items = int(row.total or 0)
+        entry.bom_completed_items = int(row.completed or 0)
+        entry.bom_cost = float(row.bom_cost or 0)
+
+    return totals
 
+
+def _stats_from_totals(
+    totals: _ProjectTotals, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
+    """Turn raw aggregates into the response shape, applying the targets."""
     # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     remaining_prints = None
     if target_count and target_count > 0:
-        progress_percent = round((total_archives / target_count) * 100, 1)
-        remaining_prints = max(0, target_count - total_archives)
+        progress_percent = round((totals.total_runs / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - totals.total_runs)
 
     # Calculate progress for parts (target_parts_count vs completed_items)
     parts_progress_percent = None
     remaining_parts = None
     if target_parts_count and target_parts_count > 0:
-        parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
-        remaining_parts = max(0, target_parts_count - completed_items)
-
-    # BOM stats
-    bom_result = await db.execute(
-        select(
-            func.count(ProjectBOMItem.id).label("total"),
-            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
-                "completed"
-            ),
-            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
-        ).where(ProjectBOMItem.project_id == project_id)
-    )
-    bom_stats = bom_result.first()
+        parts_progress_percent = round((totals.completed_items / target_parts_count) * 100, 1)
+        remaining_parts = max(0, target_parts_count - totals.completed_items)
 
     return ProjectStats(
-        total_archives=total_archives,
-        total_items=int(total_items),
-        completed_prints=completed_items,  # Now reflects sum of quantities for completed prints
-        failed_prints=int(failed_prints),
-        queued_prints=queued_prints,
-        in_progress_prints=in_progress_prints,
-        total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
-        total_filament_grams=round(log_stats.total_filament or 0, 2),
+        total_archives=totals.total_runs,
+        total_items=totals.total_items,
+        completed_prints=totals.completed_items,  # Sum of quantities for completed prints
+        failed_prints=totals.failed_runs,
+        queued_prints=totals.queued_prints,
+        in_progress_prints=totals.in_progress_prints,
+        total_print_time_hours=round(totals.total_time_seconds / 3600, 2),
+        total_filament_grams=round(totals.total_filament_grams, 2),
         progress_percent=progress_percent,
         parts_progress_percent=parts_progress_percent,
-        estimated_cost=round((log_stats.total_filament_cost or 0), 2),
-        total_energy_kwh=round((log_stats.total_energy or 0), 3),
-        total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
+        estimated_cost=round(totals.filament_cost, 2),
+        total_energy_kwh=round(totals.energy_kwh, 3),
+        total_energy_cost=round(totals.energy_cost, 3),
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
-        bom_total_items=bom_stats.total or 0,
-        bom_completed_items=int(bom_stats.completed or 0),
-        bom_cost=round(float(bom_stats.bom_cost or 0), 2),
+        bom_total_items=totals.bom_total_items,
+        bom_completed_items=totals.bom_completed_items,
+        bom_cost=round(totals.bom_cost, 2),
     )
 
 
+async def compute_project_stats(
+    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
+    """Compute statistics for a single project, excluding any sub-projects.
+
+    Sub-project roll-ups go through ``compute_subtree_stats`` instead. This
+    stays own-prints-only on purpose: it is what every existing caller means
+    by "this project's numbers", and widening it would silently restate the
+    figures of anyone who had already nested projects over the API.
+    """
+    totals = (await _load_totals(db, [project_id]))[project_id]
+    return _stats_from_totals(totals, target_count, target_parts_count)
+
+
+def _descendants_of(children: dict[int, list[int]], root_id: int) -> list[int]:
+    """Every project nested under ``root_id``, at any depth, root excluded.
+
+    Walked in Python off one already-fetched parent map rather than a recursive
+    CTE, so SQLite and PostgreSQL stay on identical code paths.
+
+    ``seen`` is not belt-and-braces. ``update_project`` only ever rejected a
+    project as its own *direct* parent, so any database written before that
+    guard was widened can hold A -> B -> A, and an unguarded walk over one
+    would never terminate.
+    """
+    found: list[int] = []
+    seen = {root_id}
+    stack = [root_id]
+    while stack:
+        for child in children.get(stack.pop(), ()):
+            if child in seen:
+                continue
+            seen.add(child)
+            found.append(child)
+            stack.append(child)
+    return found
+
+
+async def _project_descendants(db: AsyncSession, root_id: int) -> list[int]:
+    """``_descendants_of`` for callers that only need the ids, not the totals."""
+    rows = (await db.execute(select(Project.id, Project.parent_id).where(Project.parent_id.is_not(None)))).all()
+    children: dict[int, list[int]] = {}
+    for pid, parent_id in rows:
+        children.setdefault(parent_id, []).append(pid)
+    return _descendants_of(children, root_id)
+
+
+@dataclass
+class _SubtreeReport:
+    """What the detail endpoint needs to describe a project and its tree."""
+
+    descendant_count: int
+    # None when the project has no sub-projects: the roll-up would be identical
+    # to the project's own stats, and the UI uses its absence to stay quiet
+    # rather than showing a second, equal set of numbers.
+    rollup: ProjectStats | None
+    child_previews: list[ProjectChildPreview]
+
+
+async def compute_subtree_stats(db: AsyncSession, root_id: int) -> _SubtreeReport:
+    """Roll a project's own numbers up with every sub-project beneath it (#1264).
+
+    Four queries regardless of tree size or depth: one for the parent map, then
+    the three grouped aggregates in ``_load_totals`` covering the whole subtree
+    at once. Each direct child's preview carries *its* branch's roll-up, so the
+    listed rows add up to the master's total minus the master's own prints.
+    """
+    rows = (
+        await db.execute(
+            select(
+                Project.id,
+                Project.parent_id,
+                Project.name,
+                Project.color,
+                Project.status,
+                Project.target_count,
+                Project.target_parts_count,
+            )
+        )
+    ).all()
+    by_id = {row.id: row for row in rows}
+    children: dict[int, list[int]] = {}
+    for row in rows:
+        if row.parent_id is not None:
+            children.setdefault(row.parent_id, []).append(row.id)
+
+    descendants = _descendants_of(children, root_id)
+    if not descendants:
+        return _SubtreeReport(descendant_count=0, rollup=None, child_previews=[])
+
+    totals = await _load_totals(db, [root_id, *descendants])
+
+    def branch(node_id: int) -> tuple[_ProjectTotals, list[int]]:
+        """Totals for ``node_id`` plus everything under it, and that id list."""
+        ids = [node_id, *_descendants_of(children, node_id)]
+        summed = _ProjectTotals()
+        for pid in ids:
+            summed = summed + totals[pid]
+        return summed, ids
+
+    def summed_target(ids: Sequence[int], attr: str) -> int | None:
+        """Targets add up across the tree; all-unset stays unset, not zero."""
+        total = sum(getattr(by_id[pid], attr) or 0 for pid in ids)
+        return total or None
+
+    subtree_ids = [root_id, *descendants]
+    root_totals, _ = branch(root_id)
+    rollup = _stats_from_totals(
+        root_totals,
+        summed_target(subtree_ids, "target_count"),
+        summed_target(subtree_ids, "target_parts_count"),
+    )
+
+    previews: list[ProjectChildPreview] = []
+    for child_id in sorted(children.get(root_id, ()), key=lambda cid: by_id[cid].name):
+        child = by_id[child_id]
+        child_totals, child_ids = branch(child_id)
+        # Progress here is runs-against-plate-target, matching what the child's
+        # own page reports. It used to be completed *quantities* against the
+        # same target, so a row's percentage disagreed with the page it linked
+        # to.
+        child_stats = _stats_from_totals(child_totals, summed_target(child_ids, "target_count"))
+        previews.append(
+            ProjectChildPreview(
+                id=child.id,
+                name=child.name,
+                color=child.color,
+                status=child.status,
+                progress_percent=child_stats.progress_percent,
+                descendant_count=len(child_ids) - 1,
+                total_archives=child_stats.total_archives,
+                completed_prints=child_stats.completed_prints,
+                total_print_time_hours=child_stats.total_print_time_hours,
+                total_filament_grams=child_stats.total_filament_grams,
+                total_cost=round(child_stats.estimated_cost + child_stats.total_energy_cost + child_stats.bom_cost, 2),
+            )
+        )
+
+    return _SubtreeReport(descendant_count=len(descendants), rollup=rollup, child_previews=previews)
+
+
 @router.get("", response_model=list[ProjectListResponse])
 @router.get("/", response_model=list[ProjectListResponse])
 async def list_projects(
@@ -206,6 +397,20 @@ async def list_projects(
     result = await db.execute(query)
     projects = result.scalars().all()
 
+    # Direct sub-project counts for every project in one pass (#1264). Counted
+    # across all projects rather than the filtered page: a sub-project hidden
+    # by the status filter is still a sub-project, and a parent that claimed
+    # none would invite deleting it as if nothing hung off it.
+    child_counts = dict(
+        (
+            await db.execute(
+                select(Project.parent_id, func.count(Project.id))
+                .where(Project.parent_id.is_not(None))
+                .group_by(Project.parent_id)
+            )
+        ).all()
+    )
+
     # Compute quick stats for each project. Same per-run aggregation as
     # ``compute_project_stats`` — counts and quantities come from
     # ``print_log_entries`` joined to ``print_archives`` so reprints and
@@ -290,6 +495,8 @@ async def list_projects(
                 failed_count=failed_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
+                parent_id=project.parent_id,
+                child_count=child_counts.get(project.id, 0),
                 archives=archive_previews,
                 url=project.url,
                 cover_image_filename=project.cover_image_filename,
@@ -501,38 +708,6 @@ async def create_project_from_template(
 # ============ Dynamic {project_id} Routes ============
 
 
-async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
-    """Get preview info for child projects."""
-    result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
-    children = result.scalars().all()
-
-    previews = []
-    for child in children:
-        # Get completed count for progress (sum of quantities)
-        completed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == child.id,
-                PrintArchive.status == "completed",
-                _LIVE_ARCHIVE,
-            )
-        )
-        completed_count = completed_result.scalar() or 0
-        progress = None
-        if child.target_count and child.target_count > 0:
-            progress = round((int(completed_count) / child.target_count) * 100, 1)
-
-        previews.append(
-            ProjectChildPreview(
-                id=child.id,
-                name=child.name,
-                color=child.color,
-                status=child.status,
-                progress_percent=progress,
-            )
-        )
-    return previews
-
-
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: int,
@@ -552,8 +727,7 @@ async def get_project(
         parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
 
-    # Get children
-    children = await get_child_previews(db, project.id)
+    subtree = await compute_subtree_stats(db, project.id)
 
     stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
@@ -578,10 +752,12 @@ async def get_project(
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
 
 
@@ -644,6 +820,12 @@ async def update_project(
             parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
             if not parent_result.scalar_one_or_none():
                 raise HTTPException(status_code=400, detail="Parent project not found")
+            # Refusing only the project itself left A -> B -> A reachable in two
+            # calls, and a cycle has no root to roll figures up to — the walk in
+            # ``_descendants_of`` would revisit forever without its seen-set
+            # (#1264).
+            if data.parent_id in await _project_descendants(db, project_id):
+                raise HTTPException(status_code=400, detail="Project cannot be moved under one of its own sub-projects")
             project.parent_id = data.parent_id
         else:
             project.parent_id = None
@@ -657,8 +839,7 @@ async def update_project(
         parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
 
-    # Get children
-    children = await get_child_previews(db, project.id)
+    subtree = await compute_subtree_stats(db, project.id)
 
     stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
@@ -683,10 +864,12 @@ async def update_project(
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
 
 
@@ -703,6 +886,12 @@ async def delete_project(
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
 
+    # Sub-projects move up to the deleted project's own parent rather than
+    # being cut loose at the top level, so deleting a middle layer collapses
+    # the tree by one instead of scattering a branch (#1264). Left to the ORM
+    # this would null their parent_id instead, which loses the grandparent.
+    await db.execute(update(Project).where(Project.parent_id == project_id).values(parent_id=project.parent_id))
+
     await db.delete(project)
 
     return {"message": "Project deleted"}

+ 4 - 0
backend/app/api/routes/webhook.py

@@ -115,6 +115,10 @@ async def webhook_add_to_queue(
         scheduled_time=scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
+        # Attribute to the key's owner so the item shows up under `queue:read_own`
+        # for the person whose key it is. Legacy keys predating per-user ownership
+        # have no `user_id`, and those rows stay ownerless.
+        created_by_id=api_key.user_id,
     )
     db.add(queue_item)
     await db.flush()

+ 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)

+ 20 - 1
backend/app/schemas/project.py

@@ -91,13 +91,24 @@ class ProjectStats(BaseModel):
 
 
 class ProjectChildPreview(BaseModel):
-    """Minimal project data for child preview."""
+    """A sub-project as listed on its parent's page.
+
+    The figures cover the child's *own* subtree, not just its own prints, so
+    the listed rows add up to the parent's roll-up minus the parent's own
+    prints (#1264).
+    """
 
     id: int
     name: str
     color: str | None
     status: str
     progress_percent: float | None = None
+    descendant_count: int = 0  # Sub-projects nested under this one, at any depth
+    total_archives: int = 0
+    completed_prints: int = 0
+    total_print_time_hours: float = 0.0
+    total_filament_grams: float = 0.0
+    total_cost: float = 0.0  # Filament + energy + BOM, matching the parent's cost card
 
 
 class ProjectResponse(BaseModel):
@@ -122,9 +133,13 @@ class ProjectResponse(BaseModel):
     parent_id: int | None = None
     parent_name: str | None = None  # For display
     children: list[ProjectChildPreview] = []
+    descendant_count: int = 0  # Sub-projects at any depth beneath this one (#1264)
     created_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
+    # This project's numbers combined with every sub-project's. Null when there
+    # are none, since it would only repeat ``stats`` (#1264).
+    rollup_stats: ProjectStats | None = None
     url: str | None = None
     cover_image_filename: str | None = None
 
@@ -177,6 +192,10 @@ class ProjectListResponse(BaseModel):
     failed_count: int = 0  # Sum of quantities for failed prints
     queue_count: int = 0
     progress_percent: float | None = None
+    # Nesting (#1264) — the grid needs both to tell a sub-project apart from a
+    # top-level one without fetching every project's detail.
+    parent_id: int | None = None
+    child_count: int = 0  # Direct sub-projects only
     # Preview of archives (up to 5)
     archives: list[ArchivePreview] = []
     # #1155: card-level metadata

+ 19 - 0
backend/app/services/bambu_mqtt.py

@@ -1364,6 +1364,25 @@ class BambuMQTTClient:
 
             # Intercept request-topic messages (print commands from slicer/Bambuddy)
             if msg.topic == self.topic_publish:
+                # Record it before returning. This topic carries every command
+                # travelling *to* the printer, including the ones Bambu Studio
+                # sends, and it used to be the one thing an MQTT capture could
+                # never show -- which is why "what does Studio put in the drying
+                # command?" had no answer from a user's log (#2774). Filed as
+                # "out" so the direction filter groups it with our own commands
+                # rather than with printer telemetry; anything sent through
+                # send_command lands twice, once on publish and once on the
+                # broker's echo, and the pair is itself evidence the command
+                # reached the broker.
+                if self._logging_enabled:
+                    self._message_log.append(
+                        MQTTLogEntry(
+                            timestamp=datetime.now(timezone.utc).isoformat(),
+                            topic=msg.topic,
+                            direction="out",
+                            payload=payload,
+                        )
+                    )
                 self._handle_request_message(payload)
                 return
 

+ 32 - 3
backend/app/services/filament_deficit.py

@@ -79,11 +79,28 @@ def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
 
 
 def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
-    """Locate the 3MF file backing this queue item (archive or library)."""
+    """Locate the 3MF file backing this queue item (archive or library).
+
+    ``LibraryFile.file_path`` is stored relative to ``base_dir`` (rows written
+    before that convention hold absolute paths, which is why every reader
+    guards on ``is_absolute``). Resolving a relative one against the process
+    working directory finds nothing, and a source that cannot be found is
+    treated as "nothing to verify" — so this check silently passed every
+    library-backed item, which is every Slicer Pipeline job and everything
+    queued from the Library page (#2779).
+    """
     if item.archive is not None and item.archive.file_path:
         return app_settings.base_dir / item.archive.file_path
     if item.library_file is not None and item.library_file.file_path:
-        return Path(item.library_file.file_path)
+        library_path = Path(item.library_file.file_path)
+        if library_path.is_absolute():
+            return library_path
+        # SEC-PATH-OK: file_path is DB-stored and generated by the Library
+        # ingest (archive/library/files/<uuid>.<ext>), never request input. The
+        # same value already resolves the file for upload in print_queue.py and
+        # print_scheduler.py — this check reads what the printer is about to be
+        # sent, so it must resolve it identically.
+        return app_settings.base_dir / item.library_file.file_path
     return None
 
 
@@ -316,7 +333,19 @@ async def compute_deficit_for_queue_item(
     item = refreshed.scalar_one_or_none() or item
 
     source_path = _resolve_source_3mf(item)
-    if source_path is None or not source_path.exists():
+    if source_path is None:
+        # No archive and no library file — nothing was ever attached to check.
+        return []
+    if not source_path.exists():
+        # Dispatch is not blocked: the upload that follows needs the same file
+        # and fails within seconds, where wedging the queue here would strand
+        # it. But skipping a safety check must leave a trace — a silent skip is
+        # what hid #2779 for every library-backed item.
+        logger.warning(
+            "Filament check skipped for queue item %s: source 3MF not found at %s",
+            item.id,
+            source_path,
+        )
         return []
 
     requirements = extract_filament_requirements(source_path, item.plate_id)

+ 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.
+        """

+ 33 - 24
backend/app/services/git_providers/forgejo.py

@@ -13,8 +13,9 @@ class ForgejoBackend(GiteaBackend):
     """Backend for Forgejo instances.
 
     Forgejo v15+ returns 404 (not 403) for private repositories when the token
-    lacks repository scope, requiring a /user pre-check to distinguish bad tokens
-    from inaccessible repos. test_connection is overridden to handle this.
+    lacks repository scope, so a bare repo call cannot tell "bad token" from
+    "repo not visible" on its own. test_connection probes /user first to catch
+    the outright-rejected token, then lets the repo call decide everything else.
     Other methods are inherited from GiteaBackend unchanged.
     """
 
@@ -24,37 +25,45 @@ class ForgejoBackend(GiteaBackend):
             api_base = self.get_api_base(repo_url)
             headers = self.get_headers(token)
 
-            # Verify token validity before hitting the repo. On Forgejo v15+,
-            # private repos return 404 (not 403) when the token lacks repo scope,
-            # so we must distinguish "bad token" from "token OK but repo not visible".
+            # Probe /user, but only a 401 here is conclusive: the instance rejects
+            # the token outright, and saying so beats the 404 the repo call may
+            # answer with instead (Forgejo v15+ hides private repos behind 404
+            # rather than 403).
+            #
+            # Every other status falls through to the repo check (#2775). A
+            # repository-scoped token — the kind Forgejo v15 recommends, limited
+            # to one repo — can only carry read/write:issue and
+            # read/write:repository, so /user answers 403 for exactly the tokens
+            # worth encouraging. Treating that as fatal rejected a token that
+            # reaches its own repository perfectly well, which is all a backup
+            # needs: the push path uses the Contents API and the restore path
+            # reads commits, trees and blobs, all under /repos/{owner}/{repo}.
             user_resp = await client.get(f"{api_base}/user", headers=headers)
             if user_resp.status_code == 401:
                 return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
-            if user_resp.status_code == 403:
-                return {
-                    "success": False,
-                    "message": "Token has no read:user scope; cannot validate identity",
-                    "repo_name": None,
-                    "permissions": None,
-                }
-            if user_resp.status_code != 200:
-                return {
-                    "success": False,
-                    "message": f"Forgejo API error on /user: {user_resp.status_code}",
-                    "repo_name": None,
-                    "permissions": None,
-                }
+            # Whether the token's identity was confirmed. Only used to word the
+            # 404 below — an unconfirmed identity leaves "the token is invalid"
+            # on the list of causes, a confirmed one rules it out.
+            identity_confirmed = user_resp.status_code == 200
 
             repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
 
+            if repo_resp.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+
             if repo_resp.status_code == 404:
+                message = (
+                    "Repository not found or token cannot access it. "
+                    "On Forgejo v15+, private repositories return 404 (not 403) "
+                    "when the token lacks repository scope. Check that the token has "
+                    "write:repository, and that this repository is one it covers if the "
+                    "token is scoped to specific repositories."
+                )
+                if not identity_confirmed:
+                    message += " The token itself may also be invalid or expired."
                 return {
                     "success": False,
-                    "message": (
-                        "Repository not found or token cannot access it. "
-                        "On Forgejo v15+, private repositories return 404 (not 403) "
-                        "when the token lacks repository scope."
-                    ),
+                    "message": message,
                     "repo_name": None,
                     "permissions": None,
                 }

+ 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

+ 312 - 0
backend/tests/integration/test_projects_api.py

@@ -1820,3 +1820,315 @@ class TestSoftDeletedArchivesLeaveTheProject:
         db_session.expire_all()
         result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
         assert result.scalar_one() is None
+
+
+class TestSubProjectRollup:
+    """Tests for #1264 — nesting projects and rolling their figures up.
+
+    The parent/child columns predate this; what these cover is the roll-up,
+    the cycle guard that a roll-up needs to terminate, and what a delete does
+    to the branch hanging off it.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "Rollup Project", "color": "#FF0000"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def run_factory(self, db_session):
+        """One completed run against a project, with figures worth summing."""
+
+        async def _create_run(project_id, *, grams=100.0, cost=5.0, seconds=3600, status="completed", quantity=1):
+            from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
+
+            archive = PrintArchive(
+                filename="test.3mf",
+                file_path="test/test.3mf",
+                file_size=1000,
+                print_name="Run",
+                status=status,
+                quantity=quantity,
+                project_id=project_id,
+            )
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=archive.id,
+                    print_name=archive.print_name,
+                    status=status,
+                    duration_seconds=seconds,
+                    filament_used_grams=grams,
+                    cost=cost,
+                )
+            )
+            await db_session.commit()
+            return archive
+
+        return _create_run
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_master_project_rolls_up_every_sub_project(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """The whole point of the feature: one number for the programme."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        tail = await project_factory(name="Tail", parent_id=master.id)
+
+        await run_factory(master.id, grams=10.0, cost=1.0, seconds=3600)
+        await run_factory(wing.id, grams=20.0, cost=2.0, seconds=7200)
+        await run_factory(tail.id, grams=30.0, cost=3.0, seconds=1800)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["descendant_count"] == 2
+        assert body["rollup_stats"]["total_archives"] == 3
+        assert body["rollup_stats"]["total_filament_grams"] == 60.0
+        assert body["rollup_stats"]["estimated_cost"] == 6.0
+        assert body["rollup_stats"]["total_print_time_hours"] == 3.5
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_masters_own_stats_still_mean_its_own_prints(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """``stats`` keeps its existing meaning — anyone who nested projects
+        over the API before this shipped must not see their figures restated."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        await run_factory(master.id, grams=10.0)
+        await run_factory(wing.id, grams=20.0)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["stats"]["total_archives"] == 1
+        assert body["stats"]["total_filament_grams"] == 10.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_roll_up_reaches_past_the_first_generation(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """Nesting is arbitrary depth, so a grandchild has to count too."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+        await run_factory(spar.id, grams=50.0)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["descendant_count"] == 2
+        assert body["rollup_stats"]["total_filament_grams"] == 50.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_childless_project_reports_no_roll_up_at_all(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """Null, not a copy of ``stats`` — the page uses the absence to stay
+        quiet rather than printing the same figures twice."""
+        lonely = await project_factory(name="Solo")
+        await run_factory(lonely.id)
+
+        body = (await async_client.get(f"/api/v1/projects/{lonely.id}")).json()
+
+        assert body["rollup_stats"] is None
+        assert body["descendant_count"] == 0
+        assert body["children"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_each_listed_child_carries_its_own_branch_total(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """Otherwise the listed rows do not add up to the master's total and
+        the page contradicts itself."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+        await run_factory(wing.id, grams=20.0, cost=2.0)
+        await run_factory(spar.id, grams=30.0, cost=3.0)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert len(body["children"]) == 1
+        row = body["children"][0]
+        assert row["name"] == "Wing"
+        assert row["descendant_count"] == 1
+        assert row["total_archives"] == 2
+        assert row["total_filament_grams"] == 50.0
+        assert row["total_cost"] == 5.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_roll_up_progress_measures_against_the_summed_targets(
+        self, async_client: AsyncClient, project_factory, run_factory
+    ):
+        """A target on each part of the tree is a target for the whole."""
+        master = await project_factory(name="Airframe", target_count=2)
+        wing = await project_factory(name="Wing", parent_id=master.id, target_count=2)
+        await run_factory(master.id)
+        await run_factory(wing.id)
+        await run_factory(wing.id)
+
+        body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
+
+        assert body["stats"]["progress_percent"] == 50.0  # 1 of its own 2
+        assert body["rollup_stats"]["progress_percent"] == 75.0  # 3 of the tree's 4
+        assert body["rollup_stats"]["remaining_prints"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_project_cannot_be_moved_under_its_own_sub_project(
+        self, async_client: AsyncClient, project_factory
+    ):
+        """Rejecting only the direct self-parent left A -> B -> A reachable in
+        two calls, and a cycle has no root to roll anything up to."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+
+        response = await async_client.patch(f"/api/v1/projects/{master.id}", json={"parent_id": wing.id})
+
+        assert response.status_code == 400
+        assert "sub-projects" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_guard_reaches_a_distant_descendant_too(self, async_client: AsyncClient, project_factory):
+        """A three-deep loop is no more legal than a two-deep one."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+
+        response = await async_client.patch(f"/api/v1/projects/{master.id}", json={"parent_id": spar.id})
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_unrelated_project_is_still_a_legal_parent(self, async_client: AsyncClient, project_factory):
+        """The guard must not refuse ordinary nesting."""
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        other = await project_factory(name="Ground Station")
+
+        response = await async_client.patch(f"/api/v1/projects/{other.id}", json={"parent_id": wing.id})
+
+        assert response.status_code == 200
+        assert response.json()["parent_id"] == wing.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_cycle_already_in_the_database_does_not_hang_the_roll_up(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Databases written before the guard was widened can hold A -> B -> A.
+        Reading one has to terminate, not spin."""
+        from sqlalchemy import update as sa_update
+
+        from backend.app.models.project import Project
+
+        first = await project_factory(name="First")
+        second = await project_factory(name="Second", parent_id=first.id)
+        # Straight to the table: the API now refuses to write this.
+        await db_session.execute(sa_update(Project).where(Project.id == first.id).values(parent_id=second.id))
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{first.id}")
+
+        assert response.status_code == 200
+        assert response.json()["descendant_count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_middle_layer_promotes_its_children(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Collapse the tree by one rather than scattering the branch."""
+        from sqlalchemy import select
+
+        from backend.app.models.project import Project
+
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        spar = await project_factory(name="Spar", parent_id=wing.id)
+        # Read the ids out before expiring: an expired instance refreshes itself
+        # on attribute access, which is a lazy load in a sync frame.
+        master_id, spar_id = master.id, spar.id
+
+        response = await async_client.delete(f"/api/v1/projects/{wing.id}")
+        assert response.status_code == 200
+
+        db_session.expire_all()
+        parent_id = (await db_session.execute(select(Project.parent_id).where(Project.id == spar_id))).scalar_one()
+        assert parent_id == master_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_top_level_project_frees_its_children(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Nothing to promote to, so the child becomes top-level — and the
+        delete has to succeed at all, which the bare FK would have refused."""
+        from sqlalchemy import select
+
+        from backend.app.models.project import Project
+
+        master = await project_factory(name="Airframe")
+        wing = await project_factory(name="Wing", parent_id=master.id)
+        wing_id = wing.id  # See the sibling test: expiring invalidates the instance.
+
+        response = await async_client.delete(f"/api/v1/projects/{master.id}")
+        assert response.status_code == 200
+
+        db_session.expire_all()
+        parent_id = (await db_session.execute(select(Project.parent_id).where(Project.id == wing_id))).scalar_one()
+        assert parent_id is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_grid_can_tell_a_sub_project_from_a_top_level_one(
+        self, async_client: AsyncClient, project_factory
+    ):
+        """Without these the list view shows eight sub-projects as eight
+        unrelated ones."""
+        master = await project_factory(name="Airframe")
+        await project_factory(name="Wing", parent_id=master.id)
+
+        rows = {p["name"]: p for p in (await async_client.get("/api/v1/projects/")).json()}
+
+        assert rows["Airframe"]["parent_id"] is None
+        assert rows["Airframe"]["child_count"] == 1
+        assert rows["Wing"]["parent_id"] == master.id
+        assert rows["Wing"]["child_count"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_filtered_listing_still_admits_to_its_hidden_children(
+        self, async_client: AsyncClient, project_factory
+    ):
+        """A parent that claimed no children would invite deleting it as if
+        nothing hung off it."""
+        master = await project_factory(name="Airframe", status="active")
+        await project_factory(name="Wing", parent_id=master.id, status="completed")
+
+        rows = {p["name"]: p for p in (await async_client.get("/api/v1/projects/?status=active")).json()}
+
+        assert "Wing" not in rows
+        assert rows["Airframe"]["child_count"] == 1

+ 211 - 0
backend/tests/integration/test_queue_creation_attribution.py

@@ -0,0 +1,211 @@
+"""Regression tests: queue items created outside POST /queue/ lost their owner.
+
+`PrintQueueItem.created_by_id` is what the `queue:read_own` / `queue:update_own` /
+`queue:delete_own` permissions filter on (`api/routes/print_queue.py`). Two
+creation paths never set it, so the rows they produced were ownerless:
+
+  - `POST /library/files/add-to-queue` — the bulk "Add to queue" action on the
+    Library page. The route already required `Permission.QUEUE_CREATE` but bound
+    the dependency to `_` and threw the user away, so a non-admin who queued
+    files from the Library could not then see them in their own queue.
+  - `POST /webhook/queue/add` — API-key inbound. `APIKey.user_id` records the
+    key's owner, which is the acting identity for everything else the key does.
+
+Ownerless rows are still legitimate for callers with no user behind them (auth
+disabled, virtual-printer FTP uploads, legacy keys minted before per-user
+ownership), so the tests pin those cases too — the fix must not invent a
+placeholder user id. Compare `test_queue_start_user_attribution.py`, which pins
+the same NULL-is-meaningful contract on the `/start` path.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.core.auth import generate_api_key
+from backend.app.core.config import settings as app_settings
+from backend.app.models.api_key import APIKey
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.user import User
+
+
+async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
+    """Fresh-session DB read — the `db_session` fixture's connection can look
+    stale after a route call dispatches through its own `Depends(get_db)`
+    session. Same helper shape as test_queue_start_user_attribution.py."""
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as fresh:
+        return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+async def _enable_auth_with_admin(client: AsyncClient, username: str) -> tuple[str, dict]:
+    """Boot auth setup and return (bearer_token, user_dict)."""
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "AdminPass1!"},
+    )
+    body = login.json()
+    return body["access_token"], body["user"]
+
+
+@pytest.fixture
+async def sliced_library_file(db_session):
+    """A library file that passes both of add-to-queue's gates: the filename
+    must look sliced, and the bytes must actually exist under `base_dir` (the
+    route rejects rows whose file is missing from disk)."""
+    from backend.app.models.library import LibraryFile
+
+    rel_path = "archive/library/files/attribution_probe.gcode.3mf"
+    abs_path = Path(app_settings.base_dir) / rel_path
+    abs_path.parent.mkdir(parents=True, exist_ok=True)
+    abs_path.write_bytes(b"probe")
+
+    lib_file = LibraryFile(
+        filename="attribution_probe.gcode.3mf",
+        file_path=rel_path,
+        file_size=5,
+        file_type="3mf",
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    await db_session.refresh(lib_file)
+
+    yield lib_file
+
+    abs_path.unlink(missing_ok=True)
+
+
+class TestLibraryAddToQueueAttribution:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_credits_the_authenticated_user(self, async_client: AsyncClient, test_engine, sliced_library_file):
+        """The Library bulk-add is the path a user reaches for when queueing
+        many files at once — exactly the case where losing attribution hurts."""
+        token, user = await _enable_auth_with_admin(async_client, "libqueueadmin")
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [sliced_library_file.id]},
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 200
+        added = response.json()["added"]
+        assert len(added) == 1
+
+        item = await _read_item(test_engine, added[0]["queue_item_id"])
+        assert item.created_by_id == user["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_disabled_leaves_item_ownerless(
+        self, async_client: AsyncClient, test_engine, sliced_library_file
+    ):
+        """With auth off the permission dep yields None. The row must stay
+        NULL rather than gaining a synthetic owner."""
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue",
+            json={"file_ids": [sliced_library_file.id]},
+        )
+        assert response.status_code == 200
+        added = response.json()["added"]
+        assert len(added) == 1
+
+        item = await _read_item(test_engine, added[0]["queue_item_id"])
+        assert item.created_by_id is None
+
+
+class TestWebhookQueueAddAttribution:
+    @pytest.fixture
+    async def printer_and_archive(self, db_session):
+        from backend.app.models.archive import PrintArchive
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="Webhook Target",
+            ip_address="192.168.2.202",
+            serial_number="00M00A9876543210",
+            access_code="12345678",
+            model="P1S",
+        )
+        archive = PrintArchive(
+            filename="Plate_1.gcode.3mf",
+            print_name="Plate 1",
+            file_path="/tmp/webhook_attribution.3mf",  # nosec B108
+            file_size=1024,
+            content_hash="webhookattributionhash",
+            status="completed",
+        )
+        db_session.add_all([printer, archive])
+        await db_session.commit()
+        await db_session.refresh(printer)
+        await db_session.refresh(archive)
+        return printer, archive
+
+    async def _mint_key(self, db_session, owner_id: int | None) -> str:
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="attribution probe",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                user_id=owner_id,
+                can_queue=True,
+            )
+        )
+        await db_session.commit()
+        return full_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
+        printer, archive = printer_and_archive
+
+        owner = User(username="keyowner", password_hash="x", is_active=True)
+        db_session.add(owner)
+        await db_session.commit()
+        await db_session.refresh(owner)
+
+        key = await self._mint_key(db_session, owner.id)
+
+        response = await async_client.post(
+            "/api/v1/webhook/queue/add",
+            json={"printer_id": printer.id, "archive_id": archive.id},
+            headers={"X-API-Key": key},
+        )
+        assert response.status_code == 200
+
+        item = await _read_item(test_engine, response.json()["id"])
+        assert item.created_by_id == owner.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_ownerless_key_leaves_item_ownerless(
+        self, async_client: AsyncClient, db_session, test_engine, printer_and_archive
+    ):
+        """`APIKey.user_id` is nullable only for keys minted before per-user
+        ownership existed. Those must produce an ownerless row, not a crash."""
+        printer, archive = printer_and_archive
+        key = await self._mint_key(db_session, None)
+
+        response = await async_client.post(
+            "/api/v1/webhook/queue/add",
+            json={"printer_id": printer.id, "archive_id": archive.id},
+            headers={"X-API-Key": key},
+        )
+        assert response.status_code == 200
+
+        item = await _read_item(test_engine, response.json()["id"])
+        assert item.created_by_id is None

+ 93 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1987,6 +1987,99 @@ class TestRequestTopicFailSafe:
         assert BambuMQTTClient._request_topic_cache["TEST_REJECT"] is False
 
 
+class TestRequestTopicIsCaptured:
+    """The MQTT debug log has to show commands going *to* the printer.
+
+    Bambuddy subscribes to the request topic as well as the report topic, so
+    every command the printer is given crosses this client -- ours echoed back
+    by the broker, and whatever Bambu Studio sends. Those messages used to
+    return from _on_message before the logging block, which left a capture able
+    to prove only what the printer said and never what it was told. Answering
+    "what does Studio put in this field?" from a user's log depends on it
+    (#2774).
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client.enable_logging(True)
+        return client
+
+    @staticmethod
+    def _deliver(client, topic, payload):
+        class _Msg:
+            pass
+
+        msg = _Msg()
+        msg.topic = topic
+        msg.payload = json.dumps(payload).encode()
+        client._on_message(None, None, msg)
+
+    def test_a_command_on_the_request_topic_is_logged(self, mqtt_client):
+        payload = {
+            "print": {
+                "command": "ams_filament_drying",
+                "ams_id": 128,
+                "temp": 45,
+                "duration": 12,
+                "filament": "PLA",
+            }
+        }
+        self._deliver(mqtt_client, mqtt_client.topic_publish, payload)
+
+        logs = mqtt_client.get_logs()
+        assert len(logs) == 1
+        assert logs[0].topic == mqtt_client.topic_publish
+        # Filed with the commands rather than with telemetry: the direction
+        # filter is how someone finds what was sent to the printer.
+        assert logs[0].direction == "out"
+        assert logs[0].payload == payload
+
+    def test_the_payload_is_kept_whole(self, mqtt_client):
+        """The point of the capture is the fields we don't parse."""
+        payload = {"print": {"command": "ams_filament_drying", "unparsed_field": "keep me"}}
+        self._deliver(mqtt_client, mqtt_client.topic_publish, payload)
+
+        assert mqtt_client.get_logs()[0].payload["print"]["unparsed_field"] == "keep me"
+
+    def test_request_topic_messages_are_still_parsed(self, mqtt_client):
+        """Logging is additive -- the ams_mapping capture must survive it."""
+        self._deliver(
+            mqtt_client,
+            mqtt_client.topic_publish,
+            {"print": {"command": "project_file", "ams_mapping": [0, 4, -1, -1]}},
+        )
+
+        assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
+        assert len(mqtt_client.get_logs()) == 1
+
+    def test_nothing_is_logged_while_logging_is_off(self, mqtt_client):
+        mqtt_client.enable_logging(False)
+
+        self._deliver(
+            mqtt_client,
+            mqtt_client.topic_publish,
+            {"print": {"command": "project_file", "ams_mapping": [0, -1, -1, -1]}},
+        )
+
+        assert mqtt_client.get_logs() == []
+        assert mqtt_client._captured_ams_mapping == [0, -1, -1, -1]
+
+    def test_the_report_topic_is_still_logged_as_incoming(self, mqtt_client):
+        """Telemetry keeps its direction -- the two must stay distinguishable."""
+        self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"gcode_state": "IDLE"}})
+
+        logs = mqtt_client.get_logs()
+        assert len(logs) == 1
+        assert logs[0].direction == "in"
+
+
 class TestRequestTopicAmsMapping:
     """Tests for capturing ams_mapping from the MQTT request topic."""
 

+ 111 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -13,6 +13,7 @@ the contract for the cases that matter:
 from __future__ import annotations
 
 import json
+import logging
 import zipfile
 from pathlib import Path
 from unittest.mock import patch
@@ -63,6 +64,32 @@ async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict])
     return archive
 
 
+async def _setup_library_3mf(db_session, base_dir: Path, filaments: list[dict], *, absolute: bool = False):
+    """Create a 3MF under ``base_dir`` and a LibraryFile row pointing at it.
+
+    Mirrors production storage: the file lands in
+    ``<base_dir>/archive/library/files/`` and the row stores the path
+    *relative* to base_dir, exactly as ``library.py`` writes it (#2779).
+    """
+    from backend.app.models.library import LibraryFile
+
+    rel_path = Path("archive/library/files/deficit_probe.gcode.3mf")
+    abs_path = base_dir / rel_path
+    abs_path.parent.mkdir(parents=True, exist_ok=True)
+    _write_3mf(abs_path, filaments)
+
+    lib_file = LibraryFile(
+        filename="deficit_probe.gcode.3mf",
+        file_path=str(abs_path) if absolute else str(rel_path),
+        file_type="3mf",
+        file_size=abs_path.stat().st_size,
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    await db_session.refresh(lib_file)
+    return lib_file
+
+
 async def _spool(
     db_session,
     *,
@@ -103,10 +130,12 @@ async def _queue_item(
     archive: PrintArchive | None,
     ams_mapping: list[int] | None,
     plate_id: int | None = None,
+    library_file=None,
 ) -> PrintQueueItem:
     item = PrintQueueItem(
         printer_id=printer_id,
         archive_id=archive.id if archive else None,
+        library_file_id=library_file.id if library_file else None,
         ams_mapping=json.dumps(ams_mapping) if ams_mapping is not None else None,
         plate_id=plate_id,
         status="pending",
@@ -226,6 +255,88 @@ class TestFilamentDeficit:
 
         assert deficit == []
 
+    @pytest.mark.asyncio
+    async def test_library_file_with_relative_path_is_checked(self, db_session, printer_factory, tmp_path):
+        """#2779: a Library-backed item stores its path relative to base_dir.
+
+        Resolving it against the process working directory finds nothing, and
+        "no source" is treated as "nothing to verify" — so the check returned
+        no deficit and the scheduler dispatched onto a spool that could not
+        finish the print. Every Slicer Pipeline item and everything queued via
+        the Library's Add to queue is library-backed, so the guard was absent
+        for all of them. Numbers are the reporter's: 20.5 g needed, 9 g left.
+        """
+        printer = await printer_factory()
+        lib_file = await _setup_library_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "20.5"}],
+        )
+        assert not Path(lib_file.file_path).is_absolute()  # the shape that broke
+
+        spool = await _spool(db_session, label_weight=1000, weight_used=991.0)  # 9g left
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(
+            db_session, printer_id=printer.id, archive=None, library_file=lib_file, ams_mapping=[0]
+        )
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", tmp_path):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert len(deficit) == 1
+        assert deficit[0].required_grams == 20.5
+        assert deficit[0].remaining_grams == 9.0
+
+    @pytest.mark.asyncio
+    async def test_library_file_with_absolute_path_is_checked(self, db_session, printer_factory, tmp_path):
+        """The other half of the resolver: a row that already holds an absolute
+        path must not be joined onto base_dir a second time."""
+        printer = await printer_factory()
+        lib_file = await _setup_library_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+            absolute=True,
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=970.0)  # 30g left
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(
+            db_session, printer_id=printer.id, archive=None, library_file=lib_file, ams_mapping=[0]
+        )
+
+        # A base_dir the file is NOT under — joining it on would break the path.
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", tmp_path / "elsewhere"):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert len(deficit) == 1
+        assert deficit[0].required_grams == 100.0
+
+    @pytest.mark.asyncio
+    async def test_missing_source_is_logged_not_just_skipped(self, db_session, printer_factory, caplog):
+        """A source that is configured but absent still dispatches — the upload
+        would fail seconds later anyway, and wedging the queue on a missing
+        file is the worse trade. But it must not pass silently: skipping the
+        check without a trace is what let #2779 go unnoticed for every
+        library-backed item.
+        """
+        printer = await printer_factory()
+        archive = PrintArchive(
+            filename="ghost.3mf",
+            file_path="/nonexistent/ghost.3mf",
+            file_size=0,
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.filament_deficit"):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+        assert any("ghost.3mf" in r.getMessage() for r in caplog.records)
+
     @pytest.mark.asyncio
     async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
         printer = await printer_factory()

+ 65 - 10
backend/tests/unit/test_git_providers.py

@@ -1424,28 +1424,80 @@ class TestForgejoTestConnection:
         assert client.get.call_count == 1  # only /user was called
 
     @pytest.mark.asyncio
-    async def test_zero_scope_token_403_on_user_returns_scope_hint(self):
-        """A 403 from /user (v15+ zero-scope token) returns a clear message without hitting the repo."""
+    async def test_repository_scoped_token_403_on_user_still_connects(self):
+        """#2775: a Forgejo v15 repository-scoped token can only hold
+        read/write on issues and repositories, so /user answers 403. That says
+        nothing about whether the token reaches its own repository — which is
+        all a backup needs — so the repo call decides."""
         client = AsyncMock()
-        client.get = AsyncMock(return_value=_make_mock_response(403, {}))
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(403, {}),
+                _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}),
+            ]
+        )
+
+        result = await self.backend.test_connection(self.repo_url, self.token, client)
+
+        assert result["success"] is True
+        assert result["repo_name"] == "owner/repo"
+        assert client.get.call_count == 2  # /user did not short-circuit the repo call
+
+    @pytest.mark.asyncio
+    async def test_unexpected_user_status_does_not_block_the_repo_call(self):
+        """A transient non-200 from /user (429, 5xx) is not a verdict on the
+        token either — the repo call is the one that matters."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(429, {}),
+                _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}),
+            ]
+        )
+
+        result = await self.backend.test_connection(self.repo_url, self.token, client)
+
+        assert result["success"] is True
+        assert client.get.call_count == 2
+
+    @pytest.mark.asyncio
+    async def test_scoped_token_without_this_repo_names_the_scope_to_fix(self):
+        """The 404 is the only signal left when /user was inconclusive, so its
+        message has to name both remaining causes: a repository the token's
+        scope doesn't cover, and a token that was never valid."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(403, {}),
+                _make_mock_response(404, {}),
+            ]
+        )
 
         result = await self.backend.test_connection(self.repo_url, self.token, client)
 
         assert result["success"] is False
-        assert "read:user scope" in result["message"]
-        assert client.get.call_count == 1
+        assert "write:repository" in result["message"]
+        assert "specific repositories" in result["message"]
+        assert "may also be invalid" in result["message"]
 
     @pytest.mark.asyncio
-    async def test_unexpected_user_status_returns_status_code(self):
-        """A non-200/401/403 response from /user (e.g. 429, 5xx) surfaces the status code."""
+    async def test_bad_token_rejected_by_the_repo_call_is_named_as_such(self):
+        """An instance that answers /user with something inconclusive but 401s
+        the repo call must still surface 'invalid token', not a generic API
+        error — otherwise relaxing the /user gate would have cost the clearest
+        message we can give."""
         client = AsyncMock()
-        client.get = AsyncMock(return_value=_make_mock_response(429, {}))
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(403, {}),
+                _make_mock_response(401, {}),
+            ]
+        )
 
         result = await self.backend.test_connection(self.repo_url, self.token, client)
 
         assert result["success"] is False
-        assert "429" in result["message"]
-        assert client.get.call_count == 1
+        assert result["message"] == "Invalid access token"
 
     @pytest.mark.asyncio
     async def test_repo_404_after_valid_token_surfaces_v15_scope_hint(self):
@@ -1462,6 +1514,9 @@ class TestForgejoTestConnection:
         assert result["success"] is False
         assert "v15" in result["message"]
         assert "scope" in result["message"]
+        # /user confirmed the identity, so the token itself is not a suspect
+        # here and the message must not send the user off checking it.
+        assert "may also be invalid" not in result["message"]
 
     @pytest.mark.asyncio
     async def test_token_lacks_push_permission_returns_failed(self):

+ 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();
+  });
+});

+ 25 - 0
frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx

@@ -112,6 +112,31 @@ describe('GitHubBackupSettings - Provider Selection', () => {
     expect(options).toContain('forgejo');
   });
 
+  it('names the scopes of the selected provider under the token field', async () => {
+    // #2775: one shared hint could only ever be right for one provider, and the
+    // one it was right for was GitHub. A Forgejo user reading "Contents read and
+    // write" has no such setting to find, and guesses wide.
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument();
+    });
+
+    expect(screen.getByText(/Contents read and write access/i)).toBeInTheDocument();
+
+    const select = screen.getByRole('combobox', { name: /git provider/i });
+    fireEvent.change(select, { target: { value: 'forgejo' } });
+
+    await waitFor(() => {
+      expect(screen.getByText(/write:repository/i)).toBeInTheDocument();
+    });
+    expect(screen.queryByText(/Contents read and write access/i)).not.toBeInTheDocument();
+
+    fireEvent.change(select, { target: { value: 'gitlab' } });
+    await waitFor(() => {
+      expect(screen.getByText(/read_repository/i)).toBeInTheDocument();
+    });
+  });
+
   it('loads forgejo provider from existing config', async () => {
     server.use(
       http.get('/api/v1/github-backup/config', () =>

+ 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();
+      }
+    });
+  });
+});

+ 123 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -304,4 +304,127 @@ describe('ProjectDetailPage', () => {
       expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
     });
   });
+  describe('sub-project roll-up (#1264)', () => {
+    const withChildren = {
+      ...mockProject,
+      // Supplying `stats` opens the cost card, which reads `budget` — the API
+      // always sends the key, so the mock has to as well.
+      budget: null,
+      descendant_count: 2,
+      children: [
+        {
+          id: 2,
+          name: 'Wing',
+          color: '#ff0000',
+          status: 'active',
+          progress_percent: 50,
+          descendant_count: 1,
+          total_archives: 4,
+          completed_prints: 4,
+          total_print_time_hours: 6,
+          total_filament_grams: 250,
+          total_cost: 12.5,
+        },
+      ],
+      stats: {
+        total_archives: 1,
+        total_items: 1,
+        completed_prints: 1,
+        failed_prints: 0,
+        queued_prints: 0,
+        in_progress_prints: 0,
+        total_print_time_hours: 2,
+        total_filament_grams: 100,
+        progress_percent: null,
+        parts_progress_percent: null,
+        estimated_cost: 5,
+        total_energy_kwh: 0,
+        total_energy_cost: 0,
+        remaining_prints: null,
+        remaining_parts: null,
+        bom_total_items: 0,
+        bom_completed_items: 0,
+        bom_cost: 0,
+      },
+      rollup_stats: {
+        total_archives: 5,
+        total_items: 5,
+        completed_prints: 5,
+        failed_prints: 0,
+        queued_prints: 0,
+        in_progress_prints: 0,
+        total_print_time_hours: 8,
+        total_filament_grams: 350,
+        progress_percent: 40,
+        parts_progress_percent: null,
+        estimated_cost: 17.5,
+        total_energy_kwh: 0,
+        total_energy_cost: 0,
+        remaining_prints: 3,
+        remaining_parts: null,
+        bom_total_items: 0,
+        bom_completed_items: 0,
+        bom_cost: 0,
+      },
+    };
+
+    it('shows the whole programme alongside the project own numbers', async () => {
+      server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Including 2 sub-projects')).toBeInTheDocument();
+      });
+      // Both sets are on screen at once, which is the point — the roll-up must
+      // not quietly replace what the project itself printed.
+      expect(screen.getByText('350g')).toBeInTheDocument();
+      expect(screen.getByText('100g')).toBeInTheDocument();
+    });
+
+    it('stays quiet when the project has no sub-projects', async () => {
+      // Reversion-proof: if the roll-up were computed unconditionally it would
+      // print a second, identical set of figures under every ordinary project.
+      server.use(
+        http.get('/api/v1/projects/:id', () =>
+          HttpResponse.json({ ...withChildren, children: [], descendant_count: 0, rollup_stats: null })
+        )
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100g')).toBeInTheDocument();
+      });
+      expect(screen.queryByText(/Including .* sub-projects/)).not.toBeInTheDocument();
+    });
+
+    it('gives each listed sub-project its own branch total', async () => {
+      server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Wing')).toBeInTheDocument();
+      });
+      expect(screen.getByText('4 jobs')).toBeInTheDocument();
+      expect(screen.getByText('250g')).toBeInTheDocument();
+      expect(screen.getByText('50%')).toBeInTheDocument();
+    });
+
+    it('marks a sub-project that is itself a parent', async () => {
+      // Otherwise its figures look inflated for a single project rather than
+      // covering the branch under it.
+      server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Wing')).toBeInTheDocument();
+      });
+      const row = screen.getByText('Wing').closest('a');
+      expect(row).not.toBeNull();
+      expect(row!.textContent).toContain('1');
+    });
+  });
 });

+ 190 - 0
frontend/src/__tests__/pages/ProjectsPage.test.tsx

@@ -7,6 +7,7 @@ import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
+import { eligibleParents } from '../../utils/projectTree';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
@@ -466,4 +467,193 @@ describe('ProjectsPage', () => {
       expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
     });
   });
+  describe('nesting projects under a master project (#1264)', () => {
+    const listItem = (over: Record<string, unknown>) => ({
+      id: 1,
+      name: 'Airframe',
+      description: null,
+      color: '#00ae42',
+      status: 'active',
+      target_count: null,
+      target_parts_count: null,
+      target_sets: null,
+      budget: null,
+      tags: null,
+      due_date: null,
+      priority: 'normal',
+      created_at: '2024-01-01T00:00:00Z',
+      archive_count: 0,
+      total_items: 0,
+      completed_count: 0,
+      failed_count: 0,
+      queue_count: 0,
+      progress_percent: null,
+      parent_id: null,
+      child_count: 0,
+      archives: [],
+      url: null,
+      cover_image_filename: null,
+      ...over,
+    });
+
+    describe('eligibleParents', () => {
+      it('offers every other project when creating a new one', () => {
+        const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
+
+        expect(eligibleParents(projects, undefined).map((p) => p.id)).toEqual([1, 2]);
+      });
+
+      it('never offers the project itself', () => {
+        const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
+
+        expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([2]);
+      });
+
+      it('never offers a project already nested underneath', () => {
+        // The API rejects a cycle, so offering one would only produce an error
+        // the user can do nothing about.
+        const projects = [
+          listItem({ id: 1 }),
+          listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          listItem({ id: 3, name: 'Unrelated' }),
+        ];
+
+        expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([3]);
+      });
+
+      it('excludes a grandchild listed before its own parent', () => {
+        // Reversion-proof for a single-pass version: the list arrives in update
+        // order, so a grandchild can precede the parent that blocks it.
+        const projects = [
+          listItem({ id: 3, name: 'Spar', parent_id: 2 }),
+          listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          listItem({ id: 1 }),
+        ];
+
+        expect(eligibleParents(projects, 1)).toEqual([]);
+      });
+    });
+
+    it('sends 0 to clear a parent, because null would read as omitted', async () => {
+      const user = userEvent.setup();
+      const onSave = vi.fn();
+      server.use(http.get('/api/v1/projects/', () => HttpResponse.json([listItem({ id: 9, name: 'Other' })])));
+
+      render(
+        <ProjectModal
+          project={listItem({ id: 1, parent_id: 9 })}
+          onClose={() => {}}
+          onSave={onSave as never}
+          isLoading={false}
+          currencySymbol="EUR"
+          t={((k: string) => k) as never}
+        />,
+      );
+
+      const parentSelect = () =>
+        Array.from(document.querySelectorAll('select')).find((s) =>
+          s.querySelector('option[value=""]'),
+        ) as HTMLSelectElement;
+      await waitFor(() => expect(parentSelect().value).toBe('9'));
+
+      await user.selectOptions(parentSelect(), '');
+      await user.click(screen.getByRole('button', { name: 'common.save' }));
+
+      expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ parent_id: 0 }));
+    });
+
+    it('draws a sub-project inside its parent group, not loose in the grid', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            listItem({ id: 1, name: 'Airframe', child_count: 1 }),
+            listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          ]),
+        ),
+      );
+
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
+      });
+      // Two cards sitting columns apart cannot show that they belong together,
+      // however they are captioned — the child has to be physically inside the
+      // parent's group.
+      const group = screen.getByText('Sub-projects of Airframe').closest('.col-span-full');
+      expect(group).not.toBeNull();
+      expect(group!.textContent).toContain('Wing');
+      // And the caption is then redundant.
+      expect(screen.queryByText('Part of Airframe')).not.toBeInTheDocument();
+    });
+
+    it('keeps a sub-project visible when the filter hides its parent', async () => {
+      server.use(
+        http.get('/api/v1/projects/', ({ request }) => {
+          const status = new URL(request.url).searchParams.get('status');
+          const all = [
+            listItem({ id: 1, name: 'Airframe', status: 'completed', child_count: 1 }),
+            listItem({ id: 2, name: 'Wing', parent_id: 1 }),
+          ];
+          return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
+        }),
+      );
+
+      // The page opens on the Active filter, which already hides the completed
+      // parent and leaves its active child with nowhere to nest.
+      render(<ProjectsPage />);
+
+      // Nothing to nest under, so it stays a top-level card — and then the
+      // caption is the only thing that can say where it belongs.
+      await waitFor(() => {
+        expect(screen.getByText('Part of Airframe')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Sub-projects of Airframe')).not.toBeInTheDocument();
+    });
+
+    it('still shows every project when the data holds a parent cycle', async () => {
+      // Grouping hides anything that has a visible parent, and a cycle has no
+      // root — so A -> B -> A would take both projects off the page entirely.
+      // A database written before the API refused a cycle can still hold one.
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            listItem({ id: 1, name: 'Alpha', parent_id: 2 }),
+            listItem({ id: 2, name: 'Beta', parent_id: 1 }),
+          ]),
+        ),
+      );
+
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Alpha')).toBeInTheDocument();
+      });
+      // Exactly once each: following the cycle round would redraw them.
+      expect(screen.getAllByText('Alpha')).toHaveLength(1);
+      expect(screen.getAllByText('Beta')).toHaveLength(1);
+    });
+
+    it('nests a grandchild under its own parent, not under the top of the tree', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            listItem({ id: 1, name: 'Airframe', child_count: 1 }),
+            listItem({ id: 2, name: 'Wing', parent_id: 1, child_count: 1 }),
+            listItem({ id: 3, name: 'Spar', parent_id: 2 }),
+          ]),
+        ),
+      );
+
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sub-projects of Wing')).toBeInTheDocument();
+      });
+      const wingGroup = screen.getByText('Sub-projects of Wing').closest('.col-span-full');
+      expect(wingGroup!.textContent).toContain('Spar');
+      // Flattening every descendant onto the root would lose the depth.
+      expect(wingGroup!.textContent).not.toContain('Airframe');
+    });
+  });
 });

+ 97 - 0
frontend/src/__tests__/utils/dryingPresets.test.ts

@@ -0,0 +1,97 @@
+/**
+ * The drying popover has to name a material the printer will recognise.
+ *
+ * It seeds two things from one lookup: the temperature it prefills, and the
+ * filament name the start command carries. The dropdown rendering that name
+ * falls back to its first option when the value isn't in its list -- silently
+ * -- so a lookup that can return something outside the table shows one material
+ * and sends another. An AMS-HT holding Support for PLA/PETG displayed PLA and
+ * told the printer PLA-S (#2774). These tests pin the invariant that closes
+ * that: whatever comes back is a key the table has.
+ */
+import { describe, it, expect } from 'vitest';
+
+import { resolveDryingPresetKey, type DryingPreset } from '../../utils/dryingPresets';
+
+// The shipped table, as PrintersPage.tsx defines it.
+const PRESETS: Record<string, DryingPreset> = {
+  'PLA':   { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
+  'PETG':  { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
+  'TPU':   { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
+  'ABS':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'ASA':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'PA':    { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
+  'PC':    { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+  'PVA':   { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
+};
+
+describe('resolveDryingPresetKey', () => {
+  it('always answers with a key the table has', () => {
+    // The invariant behind #2774. Anything outside the table reaches the
+    // dropdown as a value it will not display and the printer as a material
+    // the user never chose.
+    const trayTypes = [
+      'PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA',
+      'PLA-S', 'PLA-CF', 'PETG-CF', 'PET-CF', 'ABS-GF', 'ASA-CF', 'PAHT-CF',
+      'PA6-CF', 'PPS-CF', 'PPA-CF', 'HIPS', 'PP', 'PE', 'EVA', 'PHA', 'PCTG',
+      'Nylon', 'TPU for AMS', '', '   ', 'wildly unknown',
+    ];
+    for (const trayType of trayTypes) {
+      expect(PRESETS).toHaveProperty(resolveDryingPresetKey(trayType, PRESETS));
+    }
+  });
+
+  it('passes a listed material straight through', () => {
+    expect(resolveDryingPresetKey('PETG', PRESETS)).toBe('PETG');
+    expect(resolveDryingPresetKey('PVA', PRESETS)).toBe('PVA');
+  });
+
+  it('is case-insensitive and ignores a trailing qualifier', () => {
+    expect(resolveDryingPresetKey('petg', PRESETS)).toBe('PETG');
+    expect(resolveDryingPresetKey('TPU for AMS', PRESETS)).toBe('TPU');
+  });
+
+  it('dries a support material as its base', () => {
+    // Support for PLA/PETG reports as PLA-S -- the case from the report.
+    expect(resolveDryingPresetKey('PLA-S', PRESETS)).toBe('PLA');
+  });
+
+  it('dries a composite as its base rather than defaulting to PLA', () => {
+    // The reason the suffix is stripped instead of just falling back: PETG-CF
+    // wants PETG's 65 degrees, and landing on PLA's 45 would quietly waste the
+    // cycle.
+    expect(resolveDryingPresetKey('PETG-CF', PRESETS)).toBe('PETG');
+    expect(resolveDryingPresetKey('PLA-CF', PRESETS)).toBe('PLA');
+    expect(resolveDryingPresetKey('ABS-GF', PRESETS)).toBe('ABS');
+    expect(resolveDryingPresetKey('ASA-CF', PRESETS)).toBe('ASA');
+  });
+
+  it('recognises the polyamide family under its own spellings', () => {
+    expect(resolveDryingPresetKey('PA6-CF', PRESETS)).toBe('PA');
+    expect(resolveDryingPresetKey('PAHT-CF', PRESETS)).toBe('PA');
+    expect(resolveDryingPresetKey('Nylon', PRESETS)).toBe('PA');
+  });
+
+  it('falls back to the coolest row for an unknown material', () => {
+    // Under-drying an exotic filament wastes a cycle; PA's 85 degrees would
+    // deform a PLA spool. So an unrecognised material must never inherit a
+    // hotter row than PLA's.
+    for (const unknown of ['PPS-CF', 'PEEK', 'wildly unknown']) {
+      expect(resolveDryingPresetKey(unknown, PRESETS)).toBe('PLA');
+    }
+  });
+
+  it('handles an empty tray', () => {
+    // No spool loaded -- the popover still has to open on something.
+    expect(resolveDryingPresetKey(undefined, PRESETS)).toBe('PLA');
+    expect(resolveDryingPresetKey(null, PRESETS)).toBe('PLA');
+    expect(resolveDryingPresetKey('', PRESETS)).toBe('PLA');
+  });
+
+  it('respects a custom preset table', () => {
+    // Users can override the table from settings, so the lookup answers about
+    // the table it was handed, not the shipped one.
+    const custom = { ...PRESETS, 'PLA-S': { n3f: 55, n3s: 55, n3f_hours: 8, n3s_hours: 8 } };
+    expect(resolveDryingPresetKey('PLA-S', custom)).toBe('PLA-S');
+  });
+});

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

@@ -908,12 +908,21 @@ export interface ProjectStats {
   bom_cost: number;
 }
 
+// A sub-project as listed on its parent's page. The figures cover the child's
+// own subtree, so the listed rows add up to the parent's roll-up minus the
+// parent's own prints (#1264).
 export interface ProjectChildPreview {
   id: number;
   name: string;
   color: string | null;
   status: string;
   progress_percent: number | null;
+  descendant_count: number;
+  total_archives: number;
+  completed_prints: number;
+  total_print_time_hours: number;
+  total_filament_grams: number;
+  total_cost: number;  // Filament + energy + BOM, matching the parent's cost card
 }
 
 export interface Project {
@@ -936,9 +945,13 @@ export interface Project {
   parent_id: number | null;
   parent_name: string | null;
   children: ProjectChildPreview[];
+  descendant_count: number;  // Sub-projects at any depth beneath this one (#1264)
   created_at: string;
   updated_at: string;
   stats?: ProjectStats;
+  // This project's numbers combined with every sub-project's. Null when there
+  // are none, since it would only repeat `stats` (#1264).
+  rollup_stats?: ProjectStats | null;
   url: string | null;  // External link rendered next to project name on the card (#1155)
   cover_image_filename: string | null;  // Filename within project attachments dir (#1155)
 }
@@ -985,6 +998,8 @@ export interface ProjectListItem {
   failed_count: number;  // Sum of quantities for failed prints
   queue_count: number;
   progress_percent: number | null;  // Plates progress
+  parent_id: number | null;  // #1264 — set when this is a sub-project
+  child_count: number;  // #1264 — direct sub-projects only
   archives: ArchivePreview[];
   url: string | null;  // #1155
   cover_image_filename: string | null;  // #1155
@@ -2908,12 +2923,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;
@@ -6970,6 +7067,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'),

+ 46 - 1
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';
 
@@ -93,6 +95,16 @@ const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
   gitlab: 'glpat-xxxxxxxxxxxx',
 };
 
+// Each provider names its scopes differently, so a single hint could only ever
+// be right for one of them (#2775). Naming the scopes up front is also what
+// keeps a token from being minted more permissive than a backup needs.
+const PROVIDER_TOKEN_HINT_I18N_KEY: Record<GitProviderType, string> = {
+  github: 'backup.tokenHintGitHub',
+  gitea: 'backup.tokenHintGitea',
+  forgejo: 'backup.tokenHintForgejo',
+  gitlab: 'backup.tokenHintGitLab',
+};
+
 interface GitHubBackupAutosaveState {
   repository_url: string;
   branch: string;
@@ -133,6 +145,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 +178,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);
@@ -701,7 +724,7 @@ export function GitHubBackupSettings() {
                     className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
                   <p className="text-xs text-bambu-gray mt-1">
-                    {t('backup.tokenHint')}
+                    {t(PROVIDER_TOKEN_HINT_I18N_KEY[provider])}
                   </p>
                 </div>
 
@@ -951,6 +974,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 +1042,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 +1051,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 +1487,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)}
+        />
+      )}
+    </>
+  );
+}

+ 85 - 1
frontend/src/i18n/locales/de.ts

@@ -3955,6 +3955,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Übergeordnetes Projekt',
+    parentNone: 'Keines (Hauptprojekt)',
+    parentHint: 'Dieses Projekt einem anderen unterordnen, damit seine Zahlen in ein Hauptprojekt einfließen',
+    partOf: 'Teil von {{name}}',
+    subProjectCount: '{{count}} Unterprojekte',
+    subProjectsOf: 'Unterprojekte von {{name}}',
     title: 'Projekte',
     subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
     newProject: 'Neues Projekt',
@@ -4102,6 +4108,12 @@ export default {
     },
     subProjects: {
       title: 'Unterprojekte ({{count}})',
+      jobs: '{{count}} Aufträge',
+    },
+    rollup: {
+      title: 'Inklusive {{count}} Unterprojekten',
+      progress: 'Gesamtfortschritt',
+      percentComplete: '{{percent}}% abgeschlossen',
     },
     notes: {
       title: 'Notizen',
@@ -4941,7 +4953,10 @@ export default {
     personalAccessToken: 'Persönlicher Zugriffstoken',
     tokenSaved: '(gespeichert)',
     enterNewToken: 'Neuen Token eingeben zum Aktualisieren',
-    tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte',
+    tokenHintGitHub: 'Feingranularer Token mit Lese- und Schreibzugriff auf Contents oder klassischer Token mit dem Bereich repo.',
+    tokenHintGitLab: 'Token mit dem Bereich api oder read_repository zusammen mit write_repository.',
+    tokenHintGitea: 'Token mit dem Bereich write:repository.',
+    tokenHintForgejo: 'Token mit dem Bereich write:repository. Ein Token, der nur für dieses eine Repository gilt, genügt.',
     branch: 'Branch',
     provider: 'Git-Anbieter',
     providerGitHub: 'GitHub',
@@ -4987,11 +5002,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

+ 90 - 1
frontend/src/i18n/locales/en.ts

@@ -3984,6 +3984,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Parent project',
+    parentNone: 'None (top-level project)',
+    parentHint: 'Nest this project under another one so its figures roll up into a master project',
+    partOf: 'Part of {{name}}',
+    subProjectCount: '{{count}} sub-projects',
+    subProjectsOf: 'Sub-projects of {{name}}',
     title: 'Projects',
     subtitle: 'Organize and track your 3D printing projects',
     newProject: 'New Project',
@@ -4131,6 +4137,12 @@ export default {
     },
     subProjects: {
       title: 'Sub-projects ({{count}})',
+      jobs: '{{count}} jobs',
+    },
+    rollup: {
+      title: 'Including {{count}} sub-projects',
+      progress: 'Overall progress',
+      percentComplete: '{{percent}}% complete',
     },
     notes: {
       title: 'Notes',
@@ -4984,7 +4996,10 @@ export default {
     personalAccessToken: 'Personal Access Token',
     tokenSaved: '(saved)',
     enterNewToken: 'Enter new token to update',
-    tokenHint: 'Fine-grained token with Contents read/write permission',
+    tokenHintGitHub: 'Fine-grained token with Contents read and write access, or a classic token with the repo scope.',
+    tokenHintGitLab: 'Token with the api scope, or read_repository and write_repository together.',
+    tokenHintGitea: 'Token with the write:repository scope.',
+    tokenHintForgejo: 'Token with the write:repository scope. A token limited to this one repository is enough.',
     branch: 'Branch',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
@@ -5030,11 +5045,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

+ 85 - 1
frontend/src/i18n/locales/es.ts

@@ -3957,6 +3957,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Proyecto principal',
+    parentNone: 'Ninguno (proyecto de nivel superior)',
+    parentHint: 'Anida este proyecto en otro para que sus cifras se sumen en un proyecto maestro',
+    partOf: 'Parte de {{name}}',
+    subProjectCount: '{{count}} subproyectos',
+    subProjectsOf: 'Subproyectos de {{name}}',
     title: 'Proyectos',
     subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
     newProject: 'Nuevo proyecto',
@@ -4104,6 +4110,12 @@ export default {
     },
     subProjects: {
       title: 'Subproyectos ({{count}})',
+      jobs: '{{count}} trabajos',
+    },
+    rollup: {
+      title: 'Incluyendo {{count}} subproyectos',
+      progress: 'Progreso general',
+      percentComplete: '{{percent}}% completado',
     },
     notes: {
       title: 'Notas',
@@ -4948,7 +4960,10 @@ export default {
     personalAccessToken: 'Token de acceso personal',
     tokenSaved: '(guardado)',
     enterNewToken: 'Introduzca un nuevo token para actualizar',
-    tokenHint: 'Token de granularidad fina con permiso de lectura/escritura de Contents',
+    tokenHintGitHub: 'Token de granularidad fina con acceso de lectura y escritura a Contents, o token clásico con el ámbito repo.',
+    tokenHintGitLab: 'Token con el ámbito api, o read_repository junto con write_repository.',
+    tokenHintGitea: 'Token con el ámbito write:repository.',
+    tokenHintForgejo: 'Token con el ámbito write:repository. Basta con un token limitado a este único repositorio.',
     branch: 'Rama',
     provider: 'Proveedor de Git',
     providerGitHub: 'GitHub',
@@ -4994,11 +5009,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

+ 85 - 1
frontend/src/i18n/locales/fr.ts

@@ -3944,6 +3944,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Projet parent',
+    parentNone: 'Aucun (projet de premier niveau)',
+    parentHint: 'Imbriquer ce projet dans un autre pour que ses chiffres remontent vers un projet principal',
+    partOf: 'Fait partie de {{name}}',
+    subProjectCount: '{{count}} sous-projets',
+    subProjectsOf: 'Sous-projets de {{name}}',
     title: 'Projets',
     subtitle: 'Suivez vos projets d\'impression 3D',
     newProject: 'Nouveau Projet',
@@ -4091,6 +4097,12 @@ export default {
     },
     subProjects: {
       title: 'Sous-projets ({{count}})',
+      jobs: '{{count}} travaux',
+    },
+    rollup: {
+      title: 'Y compris {{count}} sous-projets',
+      progress: 'Progression globale',
+      percentComplete: '{{percent}}% terminé',
     },
     notes: {
       title: 'Notes',
@@ -4930,7 +4942,10 @@ export default {
     personalAccessToken: 'Jeton d\'accès personnel',
     tokenSaved: '(enregistré)',
     enterNewToken: 'Entrez un nouveau jeton pour mettre à jour',
-    tokenHint: 'Jeton à granularité fine avec permission de lecture/écriture du contenu',
+    tokenHintGitHub: 'Jeton à granularité fine avec accès en lecture et écriture à Contents, ou jeton classique avec la portée repo.',
+    tokenHintGitLab: 'Jeton avec la portée api, ou read_repository et write_repository ensemble.',
+    tokenHintGitea: 'Jeton avec la portée write:repository.',
+    tokenHintForgejo: 'Jeton avec la portée write:repository. Un jeton limité à ce seul dépôt suffit.',
     branch: 'Branche',
     provider: 'Fournisseur Git',
     providerGitHub: 'GitHub',
@@ -4976,11 +4991,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

+ 85 - 1
frontend/src/i18n/locales/it.ts

@@ -3943,6 +3943,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Progetto principale',
+    parentNone: 'Nessuno (progetto di primo livello)',
+    parentHint: 'Annida questo progetto in un altro perché i suoi numeri confluiscano in un progetto principale',
+    partOf: 'Parte di {{name}}',
+    subProjectCount: '{{count}} sotto-progetti',
+    subProjectsOf: 'Sotto-progetti di {{name}}',
     title: 'Progetti',
     subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
     newProject: 'Nuovo progetto',
@@ -4090,6 +4096,12 @@ export default {
     },
     subProjects: {
       title: 'Sotto-progetti ({{count}})',
+      jobs: '{{count}} lavori',
+    },
+    rollup: {
+      title: 'Inclusi {{count}} sotto-progetti',
+      progress: 'Avanzamento complessivo',
+      percentComplete: '{{percent}}% completato',
     },
     notes: {
       title: 'Note',
@@ -4929,7 +4941,10 @@ export default {
     personalAccessToken: 'Token di accesso personale',
     tokenSaved: '(salvato)',
     enterNewToken: 'Inserisci un nuovo token per aggiornare',
-    tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti',
+    tokenHintGitHub: 'Token a grana fine con accesso in lettura e scrittura a Contents, oppure token classico con ambito repo.',
+    tokenHintGitLab: 'Token con ambito api, oppure read_repository insieme a write_repository.',
+    tokenHintGitea: 'Token con ambito write:repository.',
+    tokenHintForgejo: 'Token con ambito write:repository. Basta un token limitato a questo solo repository.',
     branch: 'Branch',
     provider: 'Provider Git',
     providerGitHub: 'GitHub',
@@ -4975,11 +4990,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

+ 85 - 1
frontend/src/i18n/locales/ja.ts

@@ -3955,6 +3955,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: '親プロジェクト',
+    parentNone: 'なし(トップレベルプロジェクト)',
+    parentHint: 'このプロジェクトを別のプロジェクトの下に配置すると、数値がマスタープロジェクトに集計されます',
+    partOf: '{{name}} の一部',
+    subProjectCount: 'サブプロジェクト {{count}} 件',
+    subProjectsOf: '{{name}} のサブプロジェクト',
     title: 'プロジェクト',
     subtitle: '印刷プロジェクトを管理',
     newProject: '新規プロジェクト',
@@ -4102,6 +4108,12 @@ export default {
     },
     subProjects: {
       title: 'サブプロジェクト ({{count}})',
+      jobs: 'ジョブ {{count}} 件',
+    },
+    rollup: {
+      title: 'サブプロジェクト {{count}} 件を含む',
+      progress: '全体の進捗',
+      percentComplete: '{{percent}}% 完了',
     },
     notes: {
       title: 'メモ',
@@ -4941,7 +4953,10 @@ export default {
     personalAccessToken: '個人アクセストークン',
     tokenSaved: '(保存済み)',
     enterNewToken: '新しいトークンを入力して更新',
-    tokenHint: 'Contents読み書き権限を持つきめ細かいトークン',
+    tokenHintGitHub: 'Contentsの読み書き権限を持つきめ細かいトークン、またはrepoスコープを持つクラシックトークン。',
+    tokenHintGitLab: 'apiスコープ、またはread_repositoryとwrite_repositoryの両方を持つトークン。',
+    tokenHintGitea: 'write:repositoryスコープを持つトークン。',
+    tokenHintForgejo: 'write:repositoryスコープを持つトークン。このリポジトリだけに限定したトークンで十分です。',
     branch: 'ブランチ',
     provider: 'Gitプロバイダー',
     providerGitHub: 'GitHub',
@@ -4987,11 +5002,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

+ 86 - 2
frontend/src/i18n/locales/ko.ts

@@ -3762,6 +3762,12 @@ export default {
     }
   },
   projects: {
+    parentLabel: '상위 프로젝트',
+    parentNone: '없음 (최상위 프로젝트)',
+    parentHint: '이 프로젝트를 다른 프로젝트 아래에 두면 수치가 마스터 프로젝트로 합산됩니다',
+    partOf: '{{name}}의 일부',
+    subProjectCount: '하위 프로젝트 {{count}}개',
+    subProjectsOf: '{{name}}의 하위 프로젝트',
     title: '프로젝트',
     subtitle: '3D 인쇄 프로젝트 정리 및 추적',
     newProject: '새 프로젝트',
@@ -3900,7 +3906,13 @@ export default {
       remaining: '남은 예산'
     },
     subProjects: {
-      title: '하위 프로젝트 ({{count}})'
+      title: '하위 프로젝트 ({{count}})',
+      jobs: '작업 {{count}}개',
+    },
+    rollup: {
+      title: '하위 프로젝트 {{count}}개 포함',
+      progress: '전체 진행률',
+      percentComplete: '{{percent}}% 완료',
     },
     notes: {
       title: '메모',
@@ -4704,7 +4716,10 @@ export default {
     personalAccessToken: '개인 액세스 토큰',
     tokenSaved: '(저장됨)',
     enterNewToken: '업데이트하려면 새 토큰 입력',
-    tokenHint: '콘텐츠 읽기/쓰기 권한이 있는 세분화된 토큰',
+    tokenHintGitHub: 'Contents 읽기 및 쓰기 권한이 있는 세분화된 토큰 또는 repo 범위를 가진 클래식 토큰.',
+    tokenHintGitLab: 'api 범위 또는 read_repository와 write_repository를 함께 가진 토큰.',
+    tokenHintGitea: 'write:repository 범위를 가진 토큰.',
+    tokenHintForgejo: 'write:repository 범위를 가진 토큰. 이 저장소 하나로만 제한된 토큰이면 충분합니다.',
     branch: '브랜치',
     provider: 'Git 제공자',
     providerGitHub: 'GitHub',
@@ -4749,10 +4764,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 데이터의 전체 백업을 만듭니다.',

+ 85 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -3943,6 +3943,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: 'Projeto principal',
+    parentNone: 'Nenhum (projeto de nível superior)',
+    parentHint: 'Aninhe este projeto em outro para que seus números sejam somados em um projeto mestre',
+    partOf: 'Parte de {{name}}',
+    subProjectCount: '{{count}} sub-projetos',
+    subProjectsOf: 'Sub-projetos de {{name}}',
     title: 'Projetos',
     subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
     newProject: 'Novo Projeto',
@@ -4090,6 +4096,12 @@ export default {
     },
     subProjects: {
       title: 'Sub-projetos ({{count}})',
+      jobs: '{{count}} trabalhos',
+    },
+    rollup: {
+      title: 'Incluindo {{count}} sub-projetos',
+      progress: 'Progresso geral',
+      percentComplete: '{{percent}}% concluído',
     },
     notes: {
       title: 'Notas',
@@ -4929,7 +4941,10 @@ export default {
     personalAccessToken: 'Token de acesso pessoal',
     tokenSaved: '(salvo)',
     enterNewToken: 'Digite um novo token para atualizar',
-    tokenHint: 'Token de granularidade fina com permissão de leitura/escrita de conteúdo',
+    tokenHintGitHub: 'Token de granularidade fina com acesso de leitura e escrita a Contents, ou token clássico com o escopo repo.',
+    tokenHintGitLab: 'Token com o escopo api, ou read_repository junto com write_repository.',
+    tokenHintGitea: 'Token com o escopo write:repository.',
+    tokenHintForgejo: 'Token com o escopo write:repository. Um token limitado somente a este repositório já basta.',
     branch: 'Branch',
     provider: 'Provedor Git',
     providerGitHub: 'GitHub',
@@ -4975,11 +4990,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

+ 85 - 1
frontend/src/i18n/locales/ru.ts

@@ -3754,6 +3754,12 @@ export default {
     },
   },
   projects: {
+    parentLabel: "Родительский проект",
+    parentNone: "Нет (проект верхнего уровня)",
+    parentHint: "Вложите этот проект в другой, чтобы его показатели суммировались в главном проекте",
+    partOf: "Часть проекта {{name}}",
+    subProjectCount: "Подпроектов: {{count}}",
+    subProjectsOf: "Подпроекты проекта {{name}}",
     title: "Проекты",
     subtitle: "Организация и отслеживание проектов 3D-печати",
     newProject: "Новый проект",
@@ -3893,6 +3899,12 @@ export default {
     },
     subProjects: {
       title: "Подпроекты ({{count}})",
+      jobs: "Заданий: {{count}}",
+    },
+    rollup: {
+      title: "Включая подпроекты: {{count}}",
+      progress: "Общий прогресс",
+      percentComplete: "Завершено {{percent}}%",
     },
     notes: {
       title: "Заметки",
@@ -4696,7 +4708,10 @@ export default {
     personalAccessToken: "Персональный токен доступа",
     tokenSaved: "(сохранён)",
     enterNewToken: "Введите новый токен для обновления",
-    tokenHint: "Токен с точечными правами на чтение и запись содержимого",
+    tokenHintGitHub: "Токен с точечными правами на чтение и запись Contents либо классический токен с областью repo.",
+    tokenHintGitLab: "Токен с областью api либо read_repository вместе с write_repository.",
+    tokenHintGitea: "Токен с областью write:repository.",
+    tokenHintForgejo: "Токен с областью write:repository. Достаточно токена, ограниченного только этим репозиторием.",
     branch: "Ветка",
     provider: "Git-провайдер",
     providerGitHub: "GitHub",
@@ -4741,10 +4756,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, включая базу данных, архивы, загрузки и все файлы.",

+ 85 - 1
frontend/src/i18n/locales/tr.ts

@@ -3950,6 +3950,12 @@ export default {
 
   // Projeler
   projects: {
+    parentLabel: 'Üst proje',
+    parentNone: 'Yok (üst düzey proje)',
+    parentHint: 'Bu projeyi başka bir projenin altına yerleştirin, böylece rakamları ana projede toplanır',
+    partOf: '{{name}} projesinin parçası',
+    subProjectCount: '{{count}} alt proje',
+    subProjectsOf: '{{name}} alt projeleri',
     title: 'Projeler',
     subtitle: '3D baskı projelerinizi organize edin ve takip edin',
     newProject: 'Yeni Proje',
@@ -4091,6 +4097,12 @@ export default {
     },
     subProjects: {
       title: 'Alt projeler ({{count}})',
+      jobs: '{{count}} iş',
+    },
+    rollup: {
+      title: '{{count}} alt proje dahil',
+      progress: 'Genel ilerleme',
+      percentComplete: '%{{percent}} tamamlandı',
     },
     notes: {
       title: 'Notlar',
@@ -4918,7 +4930,10 @@ export default {
     personalAccessToken: 'Kişisel Erişim Belirteci',
     tokenSaved: '(kaydedildi)',
     enterNewToken: 'Güncellemek için yeni belirteç girin',
-    tokenHint: 'Contents okuma/yazma izni olan ayrıntılı belirteç',
+    tokenHintGitHub: 'Contents okuma ve yazma erişimi olan ayrıntılı belirteç ya da repo kapsamlı klasik belirteç.',
+    tokenHintGitLab: 'api kapsamlı belirteç ya da read_repository ile write_repository birlikte.',
+    tokenHintGitea: 'write:repository kapsamlı belirteç.',
+    tokenHintForgejo: 'write:repository kapsamlı belirteç. Yalnızca bu depoyla sınırlı bir belirteç yeterlidir.',
     branch: 'Dal',
     provider: 'Git Sağlayıcısı',
     providerGitHub: 'GitHub',
@@ -4964,10 +4979,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

+ 85 - 1
frontend/src/i18n/locales/uk.ts

@@ -3983,6 +3983,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: "Батьківський проєкт",
+    parentNone: "Немає (проєкт верхнього рівня)",
+    parentHint: "Вкладіть цей проєкт в інший, щоб його показники підсумовувалися в головному проєкті",
+    partOf: "Частина проєкту {{name}}",
+    subProjectCount: "Підпроєктів: {{count}}",
+    subProjectsOf: "Підпроєкти проєкту {{name}}",
     title: "Проєкти",
     subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
     newProject: "Новий проєкт",
@@ -4130,6 +4136,12 @@ export default {
     },
     subProjects: {
       title: "Підпроєкти ({{count}})",
+      jobs: "Завдань: {{count}}",
+    },
+    rollup: {
+      title: "Разом із підпроєктами: {{count}}",
+      progress: "Загальний прогрес",
+      percentComplete: "Завершено {{percent}}%",
     },
     notes: {
       title: "Примітки",
@@ -4983,7 +4995,10 @@ export default {
     personalAccessToken: "Персональний токен доступу",
     tokenSaved: "(збережено)",
     enterNewToken: "Введіть новий токен для оновлення",
-    tokenHint: "Токен із деталізованими правами та дозволом на читання й запис вмісту",
+    tokenHintGitHub: "Токен із деталізованими правами на читання й запис Contents або класичний токен з областю repo.",
+    tokenHintGitLab: "Токен з областю api або read_repository разом із write_repository.",
+    tokenHintGitea: "Токен з областю write:repository.",
+    tokenHintForgejo: "Токен з областю write:repository. Достатньо токена, обмеженого лише цим репозиторієм.",
     branch: "Гілка",
     provider: "Постачальник Git",
     providerGitHub: "GitHub",
@@ -5029,11 +5044,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

+ 85 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -3943,6 +3943,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: '父项目',
+    parentNone: '无(顶级项目)',
+    parentHint: '将此项目嵌套在另一个项目下,其数据将汇总到主项目中',
+    partOf: '属于 {{name}}',
+    subProjectCount: '{{count}} 个子项目',
+    subProjectsOf: '{{name}} 的子项目',
     title: '项目',
     subtitle: '组织和跟踪您的 3D 打印项目',
     newProject: '新建项目',
@@ -4090,6 +4096,12 @@ export default {
     },
     subProjects: {
       title: '子项目 ({{count}})',
+      jobs: '{{count}} 个任务',
+    },
+    rollup: {
+      title: '包含 {{count}} 个子项目',
+      progress: '总体进度',
+      percentComplete: '已完成 {{percent}}%',
     },
     notes: {
       title: '备注',
@@ -4929,7 +4941,10 @@ export default {
     personalAccessToken: '个人访问令牌',
     tokenSaved: '(已保存)',
     enterNewToken: '输入新令牌以更新',
-    tokenHint: '具有内容读写权限的细粒度令牌',
+    tokenHintGitHub: '具有 Contents 读写权限的细粒度令牌,或具有 repo 范围的经典令牌。',
+    tokenHintGitLab: '具有 api 范围的令牌,或同时具有 read_repository 和 write_repository。',
+    tokenHintGitea: '具有 write:repository 范围的令牌。',
+    tokenHintForgejo: '具有 write:repository 范围的令牌。仅限于此仓库的令牌即可。',
     branch: '分支',
     provider: 'Git 提供商',
     providerGitHub: 'GitHub',
@@ -4975,11 +4990,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

+ 85 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -3943,6 +3943,12 @@ export default {
 
   // Projects
   projects: {
+    parentLabel: '父專案',
+    parentNone: '無(頂層專案)',
+    parentHint: '將此專案巢狀置於另一個專案下,其數據將彙總至主專案',
+    partOf: '屬於 {{name}}',
+    subProjectCount: '{{count}} 個子專案',
+    subProjectsOf: '{{name}} 的子專案',
     title: '專案',
     subtitle: '組織和追蹤您的 3D 列印專案',
     newProject: '新增專案',
@@ -4090,6 +4096,12 @@ export default {
     },
     subProjects: {
       title: '子專案 ({{count}})',
+      jobs: '{{count}} 個任務',
+    },
+    rollup: {
+      title: '包含 {{count}} 個子專案',
+      progress: '整體進度',
+      percentComplete: '已完成 {{percent}}%',
     },
     notes: {
       title: '備註',
@@ -4929,7 +4941,10 @@ export default {
     personalAccessToken: '個人存取權杖',
     tokenSaved: '(已儲存)',
     enterNewToken: '輸入新權杖以更新',
-    tokenHint: '具有內容讀寫權限的細粒度權杖',
+    tokenHintGitHub: '具有 Contents 讀寫權限的細粒度權杖,或具有 repo 範圍的傳統權杖。',
+    tokenHintGitLab: '具有 api 範圍的權杖,或同時具有 read_repository 與 write_repository。',
+    tokenHintGitea: '具有 write:repository 範圍的權杖。',
+    tokenHintForgejo: '具有 write:repository 範圍的權杖。僅限於此存放庫的權杖即可。',
     branch: '分支',
     provider: 'Git 供應商',
     providerGitHub: 'GitHub',
@@ -4975,11 +4990,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

+ 9 - 6
frontend/src/pages/PrintersPage.tsx

@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
 import { compareFwVersions } from '../utils/firmwareVersion';
 import { formatPrintName } from '../utils/printName';
 import { computePopoverPosition } from '../utils/popoverPosition';
+import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets';
 import {
   isExternalSpoolHidden,
   setExternalSpoolHidden as persistExternalSpoolHidden,
@@ -1781,7 +1782,7 @@ export function AmsNameHoverCard({
 
 // AMS drying presets from BambuStudio filament profiles (idle mode temps)
 // Format: { n3f temp, n3s temp, n3f hours, n3s hours }
-const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
+const DRYING_PRESETS: Record<string, DryingPreset> = {
   'PLA':   { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
   'PETG':  { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
   'TPU':   { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
@@ -1882,7 +1883,7 @@ function PrinterCard({
   cameraViewMode?: 'window' | 'embedded';
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
   checkPrinterFirmware?: boolean;
-  dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
+  dryingPresets?: Record<string, DryingPreset>;
   requirePlateClear?: boolean;
   selectionMode?: boolean;
   isSelected?: boolean;
@@ -4879,8 +4880,9 @@ function PrinterCard({
                                           setDryingPopoverAmsId(null);
                                         } else {
                                           const firstTray = ams.tray.find(t => t.tray_type);
-                                          const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
-                                          const preset = dryingPresets[filType] || dryingPresets['PLA'];
+                                          const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
+                                          // Only reachable if a custom preset set dropped PLA itself.
+                                          const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
                                           const moduleType = ams.module_type as 'n3f' | 'n3s';
                                           setDryingFilament(filType);
                                           setDryingTemp(preset[moduleType] || preset.n3f);
@@ -5429,8 +5431,9 @@ function PrinterCard({
                                         setDryingPopoverAmsId(null);
                                       } else {
                                         const firstTray = ams.tray.find(t => t.tray_type);
-                                        const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
-                                        const preset = dryingPresets[filType] || dryingPresets['PLA'];
+                                        const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
+                                        // Only reachable if a custom preset set dropped PLA itself.
+                                        const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
                                         const moduleType = ams.module_type as 'n3f' | 'n3s';
                                         setDryingFilament(filType);
                                         setDryingTemp(preset[moduleType] || preset.n3f);

+ 109 - 10
frontend/src/pages/ProjectDetailPage.tsx

@@ -682,6 +682,80 @@ export function ProjectDetailPage() {
         </div>
       )}
 
+      {/* #1264: the whole programme in one place. Deliberately a separate card
+          from the stats grid above — the figures there are this project's own
+          prints, and the two must not read as one set. The API sends
+          rollup_stats only when there is a sub-project, so there is never an
+          identical pair on screen. */}
+      {project.rollup_stats && (
+        <Card>
+          <CardContent className="p-4">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
+              <FolderTree className="w-5 h-5" />
+              {t('projectDetail.rollup.title', { count: project.descendant_count })}
+            </h2>
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printJobs')}</p>
+                <p className="text-lg font-semibold text-white">{project.rollup_stats.total_archives}</p>
+                <p className="text-sm text-bambu-gray">
+                  {t('projectDetail.stats.partsPrinted', { count: project.rollup_stats.completed_prints })}
+                </p>
+              </div>
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printTime')}</p>
+                <p className="text-lg font-semibold text-white">
+                  {formatDurationFromHours(project.rollup_stats.total_print_time_hours)}
+                </p>
+              </div>
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.filamentUsed')}</p>
+                <p className="text-lg font-semibold text-white">
+                  {formatFilament(project.rollup_stats.total_filament_grams)}
+                </p>
+              </div>
+              {(() => {
+                const rollupCost =
+                  project.rollup_stats.estimated_cost +
+                  project.rollup_stats.total_energy_cost +
+                  project.rollup_stats.bom_cost;
+                if (rollupCost <= 0) return null;
+                return (
+                  <div>
+                    <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
+                    <p className="text-lg font-semibold text-bambu-green">
+                      {currency}{rollupCost.toFixed(2)}
+                    </p>
+                  </div>
+                );
+              })()}
+            </div>
+            {project.rollup_stats.progress_percent !== null && (
+              <div className="mt-4">
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.rollup.progress')}</span>
+                  <span className="text-sm font-medium text-white">
+                    {t('projectDetail.rollup.percentComplete', {
+                      percent: project.rollup_stats.progress_percent.toFixed(0),
+                    })}
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(project.rollup_stats.progress_percent, 100)}%`,
+                      backgroundColor:
+                        project.rollup_stats.progress_percent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      )}
+
       {/* Cost tracking */}
       {stats && (() => {
         const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
@@ -760,27 +834,48 @@ export function ProjectDetailPage() {
                 <Link
                   key={child.id}
                   to={`/projects/${child.id}`}
-                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+                  className="flex items-center justify-between gap-4 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
                 >
-                  <div className="flex items-center gap-3">
+                  <div className="flex items-center gap-3 min-w-0">
                     <div
-                      className="w-3 h-3 rounded-full"
+                      className="w-3 h-3 rounded-full flex-shrink-0"
                       style={{ backgroundColor: child.color || '#6b7280' }}
                     />
-                    <span className="text-white">{child.name}</span>
-                    <span className={`text-xs px-2 py-0.5 rounded ${
+                    <span className="text-white truncate">{child.name}</span>
+                    <span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
                       child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
                       child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
                       'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
                     }`}>
                       {child.status}
                     </span>
+                    {/* A branch of its own, so its figures cover more than the
+                        one project named on this row (#1264). */}
+                    {child.descendant_count > 0 && (
+                      <span className="text-xs text-bambu-gray flex-shrink-0 inline-flex items-center gap-1">
+                        <FolderTree className="w-3 h-3" />
+                        {child.descendant_count}
+                      </span>
+                    )}
+                  </div>
+                  {/* Each row carries its own branch's roll-up, so the rows add
+                      up to the card above minus this project's own prints. */}
+                  <div className="flex items-center gap-4 text-sm text-bambu-gray flex-shrink-0">
+                    {child.total_archives > 0 && (
+                      <span className="hidden sm:inline">
+                        {t('projectDetail.subProjects.jobs', { count: child.total_archives })}
+                      </span>
+                    )}
+                    {child.total_filament_grams > 0 && (
+                      <span className="hidden md:inline">{formatFilament(child.total_filament_grams)}</span>
+                    )}
+                    {child.total_cost > 0 && (
+                      <span className="hidden md:inline">{currency}{child.total_cost.toFixed(2)}</span>
+                    )}
+                    {child.progress_percent !== null && (
+                      <span>{child.progress_percent.toFixed(0)}%</span>
+                    )}
                   </div>
-                  {child.progress_percent !== null && (
-                    <span className="text-sm text-bambu-gray">
-                      {child.progress_percent.toFixed(0)}%
-                    </span>
-                  )}
                 </Link>
               ))}
             </div>
@@ -1474,6 +1569,10 @@ export function ProjectDetailPage() {
             failed_count: stats?.failed_prints || 0,
             queue_count: stats?.queued_prints || 0,
             progress_percent: stats?.progress_percent || null,
+            // Required by ProjectListItem, but nothing in the dialog reads it —
+            // the parent picker works off the project list it fetches itself.
+            // Guarded like every other read of `children` on this page (#1264).
+            child_count: project.children?.length ?? 0,
             archives: [],
           }}
           onClose={() => setShowEditModal(false)}

+ 145 - 12
frontend/src/pages/ProjectsPage.tsx

@@ -13,6 +13,7 @@ import {
   ListTodo,
   Package,
   Layers,
+  FolderTree,
   Clock,
   CheckCircle2,
   AlertTriangle,
@@ -31,6 +32,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { getCurrencySymbol } from '../utils/currency';
+import { eligibleParents } from '../utils/projectTree';
 
 const PROJECT_COLORS = [
   '#ef4444', // red
@@ -69,7 +71,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [budget, setBudget] = useState(project?.budget?.toString() || '');
   const [url, setUrl] = useState(project?.url || '');
   const [urlError, setUrlError] = useState<string | null>(null);
+  const [parentId, setParentId] = useState<number | null>(project?.parent_id ?? null);
   const queryClient = useQueryClient();
+
+  // Unfiltered on purpose: a completed or archived project is still a legal
+  // parent, and the picker offering fewer options than the list does would be
+  // hard to explain.
+  const { data: allProjects } = useQuery({
+    queryKey: ['projects', undefined],
+    queryFn: () => api.getProjects(),
+  });
+  const parentOptions = eligibleParents(allProjects || [], project?.id);
   const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
   const coverFileInputRef = useRef<HTMLInputElement>(null);
   const [coverUploading, setCoverUploading] = useState(false);
@@ -132,6 +144,9 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
       // Pydantic accepts null to clear the URL; an empty string would fail the
       // http(s) prefix validator.
       url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
+      // The API reads 0 as "remove the parent" — null would be indistinguishable
+      // from the field having been omitted (#1264).
+      parent_id: project ? (parentId ?? 0) : (parentId ?? undefined),
       ...(project && { status }),
     });
   };
@@ -195,6 +210,26 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
             {urlError && <p className="text-xs text-red-600 dark:text-red-400 mt-1">{urlError}</p>}
           </div>
 
+          {/* #1264: Nest this project under another one */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('projects.parentLabel')}
+            </label>
+            <select
+              value={parentId ?? ''}
+              onChange={(e) => setParentId(e.target.value ? parseInt(e.target.value, 10) : null)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+            >
+              <option value="">{t('projects.parentNone')}</option>
+              {parentOptions.map((option) => (
+                <option key={option.id} value={option.id}>
+                  {option.name}
+                </option>
+              ))}
+            </select>
+            <p className="text-xs text-bambu-gray mt-1">{t('projects.parentHint')}</p>
+          </div>
+
           {/* #1155: Cover image — only available when editing an existing project,
               since uploading needs a project_id. New projects can add it after save. */}
           {project && (
@@ -510,6 +545,7 @@ function ProjectCoverThumbnail({
 
 interface ProjectCardProps {
   project: ProjectListItem;
+  parentName?: string;  // #1264 — resolved by the caller, which holds the whole list
   onClick: () => void;
   onEdit: () => void;
   onDelete: () => void;
@@ -517,7 +553,7 @@ interface ProjectCardProps {
   t: TFunction;
 }
 
-function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
+function ProjectCard({ project, parentName, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
   // Plates progress: archive_count / target_count
   const platesProgressPercent = project.target_count
     ? Math.round((project.archive_count / project.target_count) * 100)
@@ -621,7 +657,24 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: P
                     {t('projects.statusArchived')}
                   </span>
                 )}
+                {/* #1264: without these, eight sub-projects of one programme
+                    look like eight unrelated projects in the grid. */}
+                {project.child_count > 0 && (
+                  <span
+                    className="text-xs bg-bambu-dark text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap inline-flex items-center gap-1"
+                    title={t('projects.subProjectCount', { count: project.child_count })}
+                  >
+                    <FolderTree className="w-3 h-3" />
+                    {project.child_count}
+                  </span>
+                )}
               </div>
+              {parentName && (
+                <p className="text-xs text-bambu-gray mt-1 flex items-center gap-1">
+                  <Layers className="w-3 h-3 flex-shrink-0" />
+                  <span className="truncate">{t('projects.partOf', { name: parentName })}</span>
+                </p>
+              )}
               {project.description && (
                 <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
                   {project.description}
@@ -890,6 +943,96 @@ export function ProjectsPage() {
     queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
   });
 
+  // Parent names come from the unfiltered list, so a sub-project still says
+  // what it belongs to when the status filter has hidden its parent (#1264).
+  // Same query key the parent picker uses — one request shared between them,
+  // and the same one as above whenever the filter is 'all'.
+  const { data: allProjects } = useQuery({
+    queryKey: ['projects', undefined],
+    queryFn: () => api.getProjects(),
+  });
+  const namesById = new Map((allProjects || []).map((p) => [p.id, p.name]));
+
+  // A sub-project is drawn inside its parent's group rather than as another
+  // card somewhere in the grid — two cards that belong together are not
+  // something a caption can convey when they sit columns apart (#1264).
+  const visible = projects || [];
+  const visibleIds = new Set(visible.map((p) => p.id));
+  const childrenByParent = new Map<number, ProjectListItem[]>();
+  for (const project of visible) {
+    // Parent hidden by the status filter: the child has nothing to nest under
+    // here, so it stays a top-level card and keeps its "part of" caption.
+    if (project.parent_id !== null && visibleIds.has(project.parent_id)) {
+      const siblings = childrenByParent.get(project.parent_id) || [];
+      siblings.push(project);
+      childrenByParent.set(project.parent_id, siblings);
+    }
+  }
+  // Everything drawn at the top of the grid, in order. Roots first, then any
+  // project the roots cannot reach: a database written before the API refused
+  // A -> B -> A can still hold a cycle, and a cycle has no root, so filtering
+  // on "has no visible parent" alone would drop every project in it off the
+  // page. Each entry point marks its whole branch as drawn, so nothing appears
+  // twice either.
+  const drawn = new Set<number>();
+  const markDrawn = (id: number) => {
+    if (drawn.has(id)) return;
+    drawn.add(id);
+    for (const child of childrenByParent.get(id) || []) markDrawn(child.id);
+  };
+  const topLevel: ProjectListItem[] = [];
+  for (const project of visible) {
+    if (project.parent_id !== null && visibleIds.has(project.parent_id)) continue;
+    topLevel.push(project);
+    markDrawn(project.id);
+  }
+  for (const project of visible) {
+    if (drawn.has(project.id)) continue;
+    topLevel.push(project);
+    markDrawn(project.id);
+  }
+
+  const renderProjectTree = (project: ProjectListItem, depth: number, seen: Set<number>) => {
+    const card = (
+      <ProjectCard
+        project={project}
+        // Only when it is detached from its parent — inside the group the
+        // nesting is already visible, and the caption would be noise.
+        parentName={depth === 0 && project.parent_id !== null ? namesById.get(project.parent_id) : undefined}
+        onClick={() => handleClick(project)}
+        onEdit={() => handleEdit(project)}
+        onDelete={() => handleDeleteClick(project.id)}
+        hasPermission={hasPermission}
+        t={t}
+      />
+    );
+
+    // Drop any child that is already an ancestor on this path. A database
+    // written before the API refused A -> B -> A can still hold a cycle, and
+    // following one would draw the same project over and over.
+    const descended = new Set(seen).add(project.id);
+    const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
+    if (children.length === 0) return <div key={project.id}>{card}</div>;
+
+    return (
+      <div key={project.id} className="col-span-full space-y-4">
+        {card}
+        <div
+          className="ml-4 md:ml-8 pl-4 md:pl-6 border-l-2 rounded-l space-y-4"
+          style={{ borderColor: project.color || '#6b7280' }}
+        >
+          <p className="text-xs uppercase tracking-wide text-bambu-gray flex items-center gap-1.5">
+            <FolderTree className="w-3.5 h-3.5" />
+            {t('projects.subProjectsOf', { name: project.name })}
+          </p>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
+            {children.map((child) => renderProjectTree(child, depth + 1, descended))}
+          </div>
+        </div>
+      </div>
+    );
+  };
+
   const createMutation = useMutation({
     mutationFn: (data: ProjectCreate) => api.createProject(data),
     onSuccess: () => {
@@ -1159,17 +1302,7 @@ export function ProjectsPage() {
         </div>
       ) : (
         <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
-          {projects?.map((project) => (
-            <ProjectCard
-              key={project.id}
-              project={project}
-              onClick={() => handleClick(project)}
-              onEdit={() => handleEdit(project)}
-              onDelete={() => handleDeleteClick(project.id)}
-              hasPermission={hasPermission}
-              t={t}
-            />
-          ))}
+          {topLevel.map((project) => renderProjectTree(project, 0, new Set()))}
         </div>
       )}
 

+ 45 - 0
frontend/src/utils/dryingPresets.ts

@@ -0,0 +1,45 @@
+// Resolving an AMS tray's material to a row in the drying preset table.
+//
+// The table itself lives with the UI that renders it; only the lookup is here,
+// so it can be exercised without dragging a page module into the test.
+
+export type DryingPreset = { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number };
+
+// Materials whose AMS spelling differs from the preset table's key. Bambu
+// labels nylon "PA" while its own composites spell the family out, so PA6 and
+// PAHT would otherwise miss a table that has a perfectly good PA row.
+const DRYING_MATERIAL_ALIASES: Record<string, string> = {
+  'NYLON': 'PA',
+  'PA6': 'PA',
+  'PAHT': 'PA',
+};
+
+/**
+ * Pick the preset key for a tray's material.
+ *
+ * The answer is always a key the table actually has, which is the whole point:
+ * the drying popover seeds both the temperature and the filament name the start
+ * command carries from this, and the dropdown silently falls back to its first
+ * option when handed a value that isn't in its list. Seeding it with a raw
+ * `tray_type` therefore displayed "PLA" while sending the raw string -- an
+ * AMS-HT holding Support for PLA/PETG (`tray_type` "PLA-S") showed PLA in the
+ * dropdown and told the printer PLA-S (#2774).
+ *
+ * `tray_type` carries plenty of spellings the table doesn't list: support
+ * materials (PLA-S) and composites (PETG-CF, PLA-CF, ABS-GF, PAHT-CF) all dry
+ * as their base material, so the suffix is dropped before giving up. Anything
+ * still unrecognised lands on PLA, deliberately the coolest row -- under-drying
+ * an exotic filament wastes a cycle, where defaulting to PA's 85 degrees would
+ * deform a PLA spool.
+ */
+export function resolveDryingPresetKey(
+  trayType: string | null | undefined,
+  presets: Record<string, DryingPreset>,
+): string {
+  const raw = (trayType || '').split(' ')[0].toUpperCase();
+  for (const candidate of [raw, raw.split('-')[0]]) {
+    const key = DRYING_MATERIAL_ALIASES[candidate] ?? candidate;
+    if (presets[key]) return key;
+  }
+  return 'PLA';
+}

+ 30 - 0
frontend/src/utils/projectTree.ts

@@ -0,0 +1,30 @@
+import type { ProjectListItem } from '../api/client';
+
+/**
+ * Projects that may legally become `projectId`'s parent (#1264).
+ *
+ * Its own descendants are excluded as well as itself: nesting a project under
+ * something already beneath it makes a cycle, which the API rejects anyway, so
+ * offering it would only produce an error the user cannot act on. Walked from
+ * the flat list rather than fetched, since every row carries its `parent_id`.
+ */
+export function eligibleParents(
+  projects: ProjectListItem[],
+  projectId: number | undefined,
+): ProjectListItem[] {
+  if (projectId === undefined) return projects;
+  const blocked = new Set([projectId]);
+  // Repeat until nothing new is blocked: the list is in no particular order, so
+  // a grandchild can appear before its parent has been blocked.
+  let grew = true;
+  while (grew) {
+    grew = false;
+    for (const candidate of projects) {
+      if (candidate.parent_id !== null && blocked.has(candidate.parent_id) && !blocked.has(candidate.id)) {
+        blocked.add(candidate.id);
+        grew = true;
+      }
+    }
+  }
+  return projects.filter((p) => !blocked.has(p.id));
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CBRJgDPF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DJ8Q_OV9.css


+ 2 - 2
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов