Explorar o código

Merge branch 'dev' into feature/billing

behrinml hai 1 mes
pai
achega
a9e23910fc
Modificáronse 56 ficheiros con 12491 adicións e 192 borrados
  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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
 from backend.app.schemas.github_backup import (
+    REF_PATTERN,
     CloudAccountCounts,
     CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
     GitHubBackupConfigResponse,
@@ -19,10 +20,16 @@ from backend.app.schemas.github_backup import (
     GitHubBackupLogResponse,
     GitHubBackupLogResponse,
     GitHubBackupStatus,
     GitHubBackupStatus,
     GitHubBackupTriggerResponse,
     GitHubBackupTriggerResponse,
+    GitHubCommitListResponse,
+    GitHubRestorePreview,
+    GitHubRestoreRequest,
+    GitHubRestoreResponse,
     GitHubTestConnectionResponse,
     GitHubTestConnectionResponse,
     ProviderType,
     ProviderType,
+    RestoreCategory,
 )
 )
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
+from backend.app.services.github_restore import github_restore_service
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -44,6 +51,33 @@ _UNKNOWN_VISIBILITY_ERROR = (
     "repo API."
     "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:
 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.
     """Run a test_connection and refuse if the repo is not confirmed private.
@@ -388,13 +422,101 @@ async def get_status(
         configured=True,
         configured=True,
         enabled=config.enabled,
         enabled=config.enabled,
         is_running=github_backup_service.is_running,
         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_at=config.last_backup_at,
         last_backup_status=config.last_backup_status,
         last_backup_status=config.last_backup_status,
         next_scheduled_run=config.next_scheduled_run,
         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])
 @router.get("/logs", response_model=list[GitHubBackupLogResponse])
 async def get_logs(
 async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),
     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(
 async def add_files_to_queue(
     request: AddToQueueRequest,
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
     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.
     """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),
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 position=max_position,
                 status="pending",
                 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)
             db.add(queue_item)
 
 

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

@@ -4,12 +4,14 @@ import logging
 import os
 import os
 import uuid
 import uuid
 import zipfile
 import zipfile
+from collections.abc import Sequence
+from dataclasses import dataclass, fields
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, StreamingResponse
 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.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -68,10 +70,42 @@ _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
 _LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
 _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
     Aggregates from ``print_log_entries`` joined to ``print_archives`` so
     every actual run contributes — pre-fix this counted ``print_archives``
     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
     Orphan log entries (``archive_id IS NULL`` after archive deletion via
     ``ON DELETE SET NULL``) are excluded by the inner join — they can't
     ``ON DELETE SET NULL``) are excluded by the inner join — they can't
     be attributed to a project.
     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(
         select(
+            PrintArchive.project_id.label("project_id"),
             func.count(PrintLogEntry.id).label("total_runs"),
             func.count(PrintLogEntry.id).label("total_runs"),
             func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
             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.filament_used_grams), 0).label("total_filament"),
             func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
             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_kwh), 0).label("total_energy"),
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
             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(PrintArchive.quantity), 0).label("total_items"),
             func.coalesce(
             func.coalesce(
                 func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
                 func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
@@ -119,77 +150,237 @@ async def compute_project_stats(
             ).label("failed_runs"),
             ).label("failed_runs"),
         )
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .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)
     # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     progress_percent = None
     remaining_prints = None
     remaining_prints = None
     if target_count and target_count > 0:
     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)
     # Calculate progress for parts (target_parts_count vs completed_items)
     parts_progress_percent = None
     parts_progress_percent = None
     remaining_parts = None
     remaining_parts = None
     if target_parts_count and target_parts_count > 0:
     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(
     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,
         progress_percent=progress_percent,
         parts_progress_percent=parts_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_prints=remaining_prints,
         remaining_parts=remaining_parts,
         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])
 @router.get("/", response_model=list[ProjectListResponse])
 @router.get("/", response_model=list[ProjectListResponse])
 async def list_projects(
 async def list_projects(
@@ -206,6 +397,20 @@ async def list_projects(
     result = await db.execute(query)
     result = await db.execute(query)
     projects = result.scalars().all()
     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 quick stats for each project. Same per-run aggregation as
     # ``compute_project_stats`` — counts and quantities come from
     # ``compute_project_stats`` — counts and quantities come from
     # ``print_log_entries`` joined to ``print_archives`` so reprints and
     # ``print_log_entries`` joined to ``print_archives`` so reprints and
@@ -290,6 +495,8 @@ async def list_projects(
                 failed_count=failed_count,
                 failed_count=failed_count,
                 queue_count=queue_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
                 progress_percent=progress_percent,
+                parent_id=project.parent_id,
+                child_count=child_counts.get(project.id, 0),
                 archives=archive_previews,
                 archives=archive_previews,
                 url=project.url,
                 url=project.url,
                 cover_image_filename=project.cover_image_filename,
                 cover_image_filename=project.cover_image_filename,
@@ -501,38 +708,6 @@ async def create_project_from_template(
 # ============ Dynamic {project_id} Routes ============
 # ============ 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)
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
 async def get_project(
     project_id: int,
     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_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
         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)
     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,
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         created_at=project.created_at,
         updated_at=project.updated_at,
         updated_at=project.updated_at,
         stats=stats,
         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))
             parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
             if not parent_result.scalar_one_or_none():
             if not parent_result.scalar_one_or_none():
                 raise HTTPException(status_code=400, detail="Parent project not found")
                 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
             project.parent_id = data.parent_id
         else:
         else:
             project.parent_id = None
             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_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
         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)
     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,
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         created_at=project.created_at,
         updated_at=project.updated_at,
         updated_at=project.updated_at,
         stats=stats,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
     )
 
 
 
 
@@ -703,6 +886,12 @@ async def delete_project(
     if not project:
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
         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)
     await db.delete(project)
 
 
     return {"message": "Project deleted"}
     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,
         scheduled_time=scheduled_time,
         require_previous_success=data.require_previous_success,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         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)
     db.add(queue_item)
     await db.flush()
     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())
     started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     status: Mapped[str] = mapped_column(String(20))  # running/success/failed/skipped
     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)
     commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
     files_changed: Mapped[int] = mapped_column(Integer, default=0)
     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")
     configured: bool = Field(description="Whether backup is configured")
     enabled: bool = Field(description="Whether backup is enabled")
     enabled: bool = Field(description="Whether backup is enabled")
     is_running: bool = Field(description="Whether a backup is currently running")
     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")
     progress: str | None = Field(default=None, description="Current backup progress message")
     last_backup_at: datetime | None
     last_backup_at: datetime | None
     last_backup_status: str | None
     last_backup_status: str | None
@@ -204,3 +205,125 @@ class GitHubBackupTriggerResponse(BaseModel):
     log_id: int | None = None
     log_id: int | None = None
     commit_sha: str | None = None
     commit_sha: str | None = None
     files_changed: int = 0
     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):
 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
     id: int
     name: str
     name: str
     color: str | None
     color: str | None
     status: str
     status: str
     progress_percent: float | None = None
     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):
 class ProjectResponse(BaseModel):
@@ -122,9 +133,13 @@ class ProjectResponse(BaseModel):
     parent_id: int | None = None
     parent_id: int | None = None
     parent_name: str | None = None  # For display
     parent_name: str | None = None  # For display
     children: list[ProjectChildPreview] = []
     children: list[ProjectChildPreview] = []
+    descendant_count: int = 0  # Sub-projects at any depth beneath this one (#1264)
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
     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
     url: str | None = None
     cover_image_filename: 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
     failed_count: int = 0  # Sum of quantities for failed prints
     queue_count: int = 0
     queue_count: int = 0
     progress_percent: float | None = None
     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)
     # Preview of archives (up to 5)
     archives: list[ArchivePreview] = []
     archives: list[ArchivePreview] = []
     # #1155: card-level metadata
     # #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)
             # Intercept request-topic messages (print commands from slicer/Bambuddy)
             if msg.topic == self.topic_publish:
             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)
                 self._handle_request_message(payload)
                 return
                 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:
 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:
     if item.archive is not None and item.archive.file_path:
         return app_settings.base_dir / 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:
     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
     return None
 
 
 
 
@@ -316,7 +333,19 @@ async def compute_deficit_for_queue_item(
     item = refreshed.scalar_one_or_none() or item
     item = refreshed.scalar_one_or_none() or item
 
 
     source_path = _resolve_source_3mf(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 []
         return []
 
 
     requirements = extract_filament_requirements(source_path, item.plate_id)
     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,
         client: httpx.AsyncClient,
     ) -> dict:
     ) -> dict:
         """Push files to the repository. Returns status/message/commit_sha/files_changed."""
         """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.
     """Backend for Forgejo instances.
 
 
     Forgejo v15+ returns 404 (not 403) for private repositories when the token
     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.
     Other methods are inherited from GiteaBackend unchanged.
     """
     """
 
 
@@ -24,37 +25,45 @@ class ForgejoBackend(GiteaBackend):
             api_base = self.get_api_base(repo_url)
             api_base = self.get_api_base(repo_url)
             headers = self.get_headers(token)
             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)
             user_resp = await client.get(f"{api_base}/user", headers=headers)
             if user_resp.status_code == 401:
             if user_resp.status_code == 401:
                 return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
                 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)
             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:
             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 {
                 return {
                     "success": False,
                     "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,
                     "repo_name": None,
                     "permissions": 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__)
 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):
 class GiteaBackend(GitHubBackend):
     """Backend for Gitea instances.
     """Backend for Gitea instances.
@@ -100,6 +105,107 @@ class GiteaBackend(GitHubBackend):
         headers["Accept"] = "application/json"
         headers["Accept"] = "application/json"
         return headers
         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(
     async def push_files(
         self,
         self,
         repo_url: str,
         repo_url: str,

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

@@ -115,6 +115,252 @@ class GitHubBackend(GitProviderBackend):
                 "is_private": None,
                 "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(
     async def push_files(
         self,
         self,
         repo_url: str,
         repo_url: str,

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

@@ -115,6 +115,267 @@ class GitLabBackend(GitProviderBackend):
                 "is_private": None,
                 "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(
     async def push_files(
         self,
         self,
         repo_url: str,
         repo_url: str,

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

@@ -173,9 +173,32 @@ class GitHubBackupService:
         Returns:
         Returns:
             dict with success, message, log_id, commit_sha, files_changed
             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:
         if self._running_backup:
             return {"success": False, "message": "A backup is already running", "log_id": None}
             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
         self._running_backup = True
         log_id = None
         log_id = None
 
 
@@ -805,6 +828,14 @@ class GitHubBackupService:
         if not archives:
         if not archives:
             return
             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 = []
         archive_list = []
         for a in archives:
         for a in archives:
             archive_data = {
             archive_data = {
@@ -840,6 +871,25 @@ class GitHubBackupService:
                 "energy_kwh": a.energy_kwh,
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "energy_cost": a.energy_cost,
                 "created_at": str(a.created_at) if a.created_at else None,
                 "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)
             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()
         db_session.expire_all()
         result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
         result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
         assert result.scalar_one() is None
         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
         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:
 class TestRequestTopicAmsMapping:
     """Tests for capturing ams_mapping from the MQTT request topic."""
     """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
 from __future__ import annotations
 
 
 import json
 import json
+import logging
 import zipfile
 import zipfile
 from pathlib import Path
 from pathlib import Path
 from unittest.mock import patch
 from unittest.mock import patch
@@ -63,6 +64,32 @@ async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict])
     return archive
     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(
 async def _spool(
     db_session,
     db_session,
     *,
     *,
@@ -103,10 +130,12 @@ async def _queue_item(
     archive: PrintArchive | None,
     archive: PrintArchive | None,
     ams_mapping: list[int] | None,
     ams_mapping: list[int] | None,
     plate_id: int | None = None,
     plate_id: int | None = None,
+    library_file=None,
 ) -> PrintQueueItem:
 ) -> PrintQueueItem:
     item = PrintQueueItem(
     item = PrintQueueItem(
         printer_id=printer_id,
         printer_id=printer_id,
         archive_id=archive.id if archive else None,
         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,
         ams_mapping=json.dumps(ams_mapping) if ams_mapping is not None else None,
         plate_id=plate_id,
         plate_id=plate_id,
         status="pending",
         status="pending",
@@ -226,6 +255,88 @@ class TestFilamentDeficit:
 
 
         assert deficit == []
         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
     @pytest.mark.asyncio
     async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
     async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
         printer = await 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
         assert client.get.call_count == 1  # only /user was called
 
 
     @pytest.mark.asyncio
     @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 = 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)
         result = await self.backend.test_connection(self.repo_url, self.token, client)
 
 
         assert result["success"] is False
         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
     @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 = 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)
         result = await self.backend.test_connection(self.repo_url, self.token, client)
 
 
         assert result["success"] is False
         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
     @pytest.mark.asyncio
     async def test_repo_404_after_valid_token_surfaces_v15_scope_hint(self):
     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 result["success"] is False
         assert "v15" in result["message"]
         assert "v15" in result["message"]
         assert "scope" 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
     @pytest.mark.asyncio
     async def test_token_lacks_push_permission_returns_failed(self):
     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');
     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 () => {
   it('loads forgejo provider from existing config', async () => {
     server.use(
     server.use(
       http.get('/api/v1/github-backup/config', () =>
       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();
       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 userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
 import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
+import { eligibleParents } from '../../utils/projectTree';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 
 
@@ -466,4 +467,193 @@ describe('ProjectsPage', () => {
       expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
       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;
   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 {
 export interface ProjectChildPreview {
   id: number;
   id: number;
   name: string;
   name: string;
   color: string | null;
   color: string | null;
   status: string;
   status: string;
   progress_percent: number | null;
   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 {
 export interface Project {
@@ -936,9 +945,13 @@ export interface Project {
   parent_id: number | null;
   parent_id: number | null;
   parent_name: string | null;
   parent_name: string | null;
   children: ProjectChildPreview[];
   children: ProjectChildPreview[];
+  descendant_count: number;  // Sub-projects at any depth beneath this one (#1264)
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
   stats?: ProjectStats;
   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)
   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)
   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
   failed_count: number;  // Sum of quantities for failed prints
   queue_count: number;
   queue_count: number;
   progress_percent: number | null;  // Plates progress
   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[];
   archives: ArchivePreview[];
   url: string | null;  // #1155
   url: string | null;  // #1155
   cover_image_filename: string | null;  // #1155
   cover_image_filename: string | null;  // #1155
@@ -2908,12 +2923,94 @@ export interface GitHubBackupStatus {
   configured: boolean;
   configured: boolean;
   enabled: boolean;
   enabled: boolean;
   is_running: boolean;
   is_running: boolean;
+  restore_running: boolean;
   progress: string | null;
   progress: string | null;
   last_backup_at: string | null;
   last_backup_at: string | null;
   last_backup_status: string | null;
   last_backup_status: string | null;
   next_scheduled_run: 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 {
 export interface LocalBackupStatus {
   enabled: boolean;
   enabled: boolean;
   schedule: string;
   schedule: string;
@@ -6970,6 +7067,19 @@ export const api = {
   clearGitHubBackupLogs: (keepLast: number = 10) =>
   clearGitHubBackupLogs: (keepLast: number = 10) =>
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
     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
   // Scheduled local backups
   getLocalBackupStatus: () =>
   getLocalBackupStatus: () =>
     request<LocalBackupStatus>('/local-backup/status'),
     request<LocalBackupStatus>('/local-backup/status'),

+ 46 - 1
frontend/src/components/GitHubBackupSettings.tsx

@@ -36,10 +36,12 @@ import type {
   CloudAuthStatus,
   CloudAuthStatus,
   Printer,
   Printer,
 } from '../api/client';
 } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
 import { Card, CardContent, CardHeader } from './Card';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { GitHubRestoreModal } from './GitHubRestoreModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime, parseUTCDate } from '../utils/date';
 import { formatRelativeTime, parseUTCDate } from '../utils/date';
 
 
@@ -93,6 +95,16 @@ const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
   gitlab: 'glpat-xxxxxxxxxxxx',
   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 {
 interface GitHubBackupAutosaveState {
   repository_url: string;
   repository_url: string;
   branch: string;
   branch: string;
@@ -133,6 +145,14 @@ export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { t } = useTranslation();
   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
   // Local state for form
   const [repoUrl, setRepoUrl] = useState('');
   const [repoUrl, setRepoUrl] = useState('');
@@ -158,6 +178,9 @@ export function GitHubBackupSettings() {
   const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
   const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
 
+  // Restore from the Git backup repository (#2656)
+  const [showGitRestore, setShowGitRestore] = useState(false);
+
   // Scheduled local backup state
   // Scheduled local backup state
   const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
   const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
   const [restoreConfirmFile, setRestoreConfirmFile] = 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"
                     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">
                   <p className="text-xs text-bambu-gray mt-1">
-                    {t('backup.tokenHint')}
+                    {t(PROVIDER_TOKEN_HINT_I18N_KEY[provider])}
                   </p>
                   </p>
                 </div>
                 </div>
 
 
@@ -951,6 +974,18 @@ export function GitHubBackupSettings() {
                           {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
                           {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
                           {t('backup.test')}
                           {t('backup.test')}
                         </Button>
                         </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>
                   <thead>
                     <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
                     <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.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.status')}</th>
                       <th className="text-left py-2 px-2">{t('backup.commit')}</th>
                       <th className="text-left py-2 px-2">{t('backup.commit')}</th>
                     </tr>
                     </tr>
@@ -1015,6 +1051,12 @@ export function GitHubBackupSettings() {
                     {logs.slice(0, 10).map((log) => (
                     {logs.slice(0, 10).map((log) => (
                       <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
                       <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>
                         <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"><StatusBadge status={log.status} /></td>
                         <td className="py-2 px-2">
                         <td className="py-2 px-2">
                           {log.commit_sha ? (
                           {log.commit_sha ? (
@@ -1445,6 +1487,9 @@ export function GitHubBackupSettings() {
         </Card>
         </Card>
       </div>
       </div>
 
 
+      {/* Restore from the Git backup repository (#2656) */}
+      {showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
+
       {/* Delete Backup Confirmation Modal */}
       {/* Delete Backup Confirmation Modal */}
       {deleteConfirmFile && (
       {deleteConfirmFile && (
         <ConfirmModal
         <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
   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',
     title: 'Projekte',
     subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
     subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
     newProject: 'Neues Projekt',
     newProject: 'Neues Projekt',
@@ -4102,6 +4108,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Unterprojekte ({{count}})',
       title: 'Unterprojekte ({{count}})',
+      jobs: '{{count}} Aufträge',
+    },
+    rollup: {
+      title: 'Inklusive {{count}} Unterprojekten',
+      progress: 'Gesamtfortschritt',
+      percentComplete: '{{percent}}% abgeschlossen',
     },
     },
     notes: {
     notes: {
       title: 'Notizen',
       title: 'Notizen',
@@ -4941,7 +4953,10 @@ export default {
     personalAccessToken: 'Persönlicher Zugriffstoken',
     personalAccessToken: 'Persönlicher Zugriffstoken',
     tokenSaved: '(gespeichert)',
     tokenSaved: '(gespeichert)',
     enterNewToken: 'Neuen Token eingeben zum Aktualisieren',
     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',
     branch: 'Branch',
     provider: 'Git-Anbieter',
     provider: 'Git-Anbieter',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -4987,11 +5002,80 @@ export default {
     clearedLogs: '{{count}} Protokolle gelöscht',
     clearedLogs: '{{count}} Protokolle gelöscht',
     failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
     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
     history: 'Verlauf',
     history: 'Verlauf',
     clear: 'Löschen',
     clear: 'Löschen',
     date: 'Datum',
     date: 'Datum',
     status: 'Status',
     status: 'Status',
+    trigger: 'Typ',
+    triggers: {
+      manual: 'Sicherung (manuell)',
+      scheduled: 'Sicherung (geplant)',
+      restore: 'Wiederherstellung',
+    },
     commit: 'Commit',
     commit: 'Commit',
 
 
     // Local Backup
     // Local Backup

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

@@ -3984,6 +3984,12 @@ export default {
 
 
   // Projects
   // Projects
   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',
     title: 'Projects',
     subtitle: 'Organize and track your 3D printing projects',
     subtitle: 'Organize and track your 3D printing projects',
     newProject: 'New Project',
     newProject: 'New Project',
@@ -4131,6 +4137,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Sub-projects ({{count}})',
       title: 'Sub-projects ({{count}})',
+      jobs: '{{count}} jobs',
+    },
+    rollup: {
+      title: 'Including {{count}} sub-projects',
+      progress: 'Overall progress',
+      percentComplete: '{{percent}}% complete',
     },
     },
     notes: {
     notes: {
       title: 'Notes',
       title: 'Notes',
@@ -4984,7 +4996,10 @@ export default {
     personalAccessToken: 'Personal Access Token',
     personalAccessToken: 'Personal Access Token',
     tokenSaved: '(saved)',
     tokenSaved: '(saved)',
     enterNewToken: 'Enter new token to update',
     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',
     branch: 'Branch',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -5030,11 +5045,85 @@ export default {
     clearedLogs: 'Cleared {{count}} logs',
     clearedLogs: 'Cleared {{count}} logs',
     failedToClearLogs: 'Failed to clear logs: {{message}}',
     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: 'History',
     history: 'History',
     clear: 'Clear',
     clear: 'Clear',
     date: 'Date',
     date: 'Date',
     status: 'Status',
     status: 'Status',
+    trigger: 'Type',
+    triggers: {
+      manual: 'Backup (manual)',
+      scheduled: 'Backup (scheduled)',
+      restore: 'Restore',
+    },
     commit: 'Commit',
     commit: 'Commit',
 
 
     // Local Backup
     // Local Backup

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

@@ -3957,6 +3957,12 @@ export default {
 
 
   // Projects
   // Projects
   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',
     title: 'Proyectos',
     subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
     subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
     newProject: 'Nuevo proyecto',
     newProject: 'Nuevo proyecto',
@@ -4104,6 +4110,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Subproyectos ({{count}})',
       title: 'Subproyectos ({{count}})',
+      jobs: '{{count}} trabajos',
+    },
+    rollup: {
+      title: 'Incluyendo {{count}} subproyectos',
+      progress: 'Progreso general',
+      percentComplete: '{{percent}}% completado',
     },
     },
     notes: {
     notes: {
       title: 'Notas',
       title: 'Notas',
@@ -4948,7 +4960,10 @@ export default {
     personalAccessToken: 'Token de acceso personal',
     personalAccessToken: 'Token de acceso personal',
     tokenSaved: '(guardado)',
     tokenSaved: '(guardado)',
     enterNewToken: 'Introduzca un nuevo token para actualizar',
     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',
     branch: 'Rama',
     provider: 'Proveedor de Git',
     provider: 'Proveedor de Git',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -4994,11 +5009,80 @@ export default {
     clearedLogs: 'Se borraron {{count}} registros',
     clearedLogs: 'Se borraron {{count}} registros',
     failedToClearLogs: 'Error al borrar los registros: {{message}}',
     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
     history: 'Historial',
     history: 'Historial',
     clear: 'Borrar',
     clear: 'Borrar',
     date: 'Fecha',
     date: 'Fecha',
     status: 'Estado',
     status: 'Estado',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Copia (manual)',
+      scheduled: 'Copia (programada)',
+      restore: 'Restauración',
+    },
     commit: 'Confirmación',
     commit: 'Confirmación',
 
 
     // Local Backup
     // Local Backup

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

@@ -3944,6 +3944,12 @@ export default {
 
 
   // Projects
   // Projects
   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',
     title: 'Projets',
     subtitle: 'Suivez vos projets d\'impression 3D',
     subtitle: 'Suivez vos projets d\'impression 3D',
     newProject: 'Nouveau Projet',
     newProject: 'Nouveau Projet',
@@ -4091,6 +4097,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Sous-projets ({{count}})',
       title: 'Sous-projets ({{count}})',
+      jobs: '{{count}} travaux',
+    },
+    rollup: {
+      title: 'Y compris {{count}} sous-projets',
+      progress: 'Progression globale',
+      percentComplete: '{{percent}}% terminé',
     },
     },
     notes: {
     notes: {
       title: 'Notes',
       title: 'Notes',
@@ -4930,7 +4942,10 @@ export default {
     personalAccessToken: 'Jeton d\'accès personnel',
     personalAccessToken: 'Jeton d\'accès personnel',
     tokenSaved: '(enregistré)',
     tokenSaved: '(enregistré)',
     enterNewToken: 'Entrez un nouveau jeton pour mettre à jour',
     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',
     branch: 'Branche',
     provider: 'Fournisseur Git',
     provider: 'Fournisseur Git',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -4976,11 +4991,80 @@ export default {
     clearedLogs: '{{count}} journaux supprimés',
     clearedLogs: '{{count}} journaux supprimés',
     failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
     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
     history: 'Historique',
     history: 'Historique',
     clear: 'Effacer',
     clear: 'Effacer',
     date: 'Date',
     date: 'Date',
     status: 'Statut',
     status: 'Statut',
+    trigger: 'Type',
+    triggers: {
+      manual: 'Sauvegarde (manuelle)',
+      scheduled: 'Sauvegarde (planifiée)',
+      restore: 'Restauration',
+    },
     commit: 'Commit',
     commit: 'Commit',
 
 
     // Local Backup
     // Local Backup

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

@@ -3943,6 +3943,12 @@ export default {
 
 
   // Projects
   // Projects
   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',
     title: 'Progetti',
     subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
     subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
     newProject: 'Nuovo progetto',
     newProject: 'Nuovo progetto',
@@ -4090,6 +4096,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Sotto-progetti ({{count}})',
       title: 'Sotto-progetti ({{count}})',
+      jobs: '{{count}} lavori',
+    },
+    rollup: {
+      title: 'Inclusi {{count}} sotto-progetti',
+      progress: 'Avanzamento complessivo',
+      percentComplete: '{{percent}}% completato',
     },
     },
     notes: {
     notes: {
       title: 'Note',
       title: 'Note',
@@ -4929,7 +4941,10 @@ export default {
     personalAccessToken: 'Token di accesso personale',
     personalAccessToken: 'Token di accesso personale',
     tokenSaved: '(salvato)',
     tokenSaved: '(salvato)',
     enterNewToken: 'Inserisci un nuovo token per aggiornare',
     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',
     branch: 'Branch',
     provider: 'Provider Git',
     provider: 'Provider Git',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -4975,11 +4990,80 @@ export default {
     clearedLogs: '{{count}} log eliminati',
     clearedLogs: '{{count}} log eliminati',
     failedToClearLogs: 'Eliminazione log fallita: {{message}}',
     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
     history: 'Cronologia',
     history: 'Cronologia',
     clear: 'Cancella',
     clear: 'Cancella',
     date: 'Data',
     date: 'Data',
     status: 'Stato',
     status: 'Stato',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Backup (manuale)',
+      scheduled: 'Backup (pianificato)',
+      restore: 'Ripristino',
+    },
     commit: 'Commit',
     commit: 'Commit',
 
 
     // Local Backup
     // Local Backup

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

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

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

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

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

@@ -3943,6 +3943,12 @@ export default {
 
 
   // Projects
   // Projects
   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',
     title: 'Projetos',
     subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
     subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
     newProject: 'Novo Projeto',
     newProject: 'Novo Projeto',
@@ -4090,6 +4096,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Sub-projetos ({{count}})',
       title: 'Sub-projetos ({{count}})',
+      jobs: '{{count}} trabalhos',
+    },
+    rollup: {
+      title: 'Incluindo {{count}} sub-projetos',
+      progress: 'Progresso geral',
+      percentComplete: '{{percent}}% concluído',
     },
     },
     notes: {
     notes: {
       title: 'Notas',
       title: 'Notas',
@@ -4929,7 +4941,10 @@ export default {
     personalAccessToken: 'Token de acesso pessoal',
     personalAccessToken: 'Token de acesso pessoal',
     tokenSaved: '(salvo)',
     tokenSaved: '(salvo)',
     enterNewToken: 'Digite um novo token para atualizar',
     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',
     branch: 'Branch',
     provider: 'Provedor Git',
     provider: 'Provedor Git',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -4975,11 +4990,80 @@ export default {
     clearedLogs: '{{count}} logs removidos',
     clearedLogs: '{{count}} logs removidos',
     failedToClearLogs: 'Falha ao limpar logs: {{message}}',
     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
     history: 'Histórico',
     history: 'Histórico',
     clear: 'Limpar',
     clear: 'Limpar',
     date: 'Data',
     date: 'Data',
     status: 'Status',
     status: 'Status',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Backup manual',
+      scheduled: 'Backup agendado',
+      restore: 'Restauração',
+    },
     commit: 'Commit',
     commit: 'Commit',
 
 
     // Local Backup
     // Local Backup

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

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

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

@@ -3950,6 +3950,12 @@ export default {
 
 
   // Projeler
   // Projeler
   projects: {
   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',
     title: 'Projeler',
     subtitle: '3D baskı projelerinizi organize edin ve takip edin',
     subtitle: '3D baskı projelerinizi organize edin ve takip edin',
     newProject: 'Yeni Proje',
     newProject: 'Yeni Proje',
@@ -4091,6 +4097,12 @@ export default {
     },
     },
     subProjects: {
     subProjects: {
       title: 'Alt projeler ({{count}})',
       title: 'Alt projeler ({{count}})',
+      jobs: '{{count}} iş',
+    },
+    rollup: {
+      title: '{{count}} alt proje dahil',
+      progress: 'Genel ilerleme',
+      percentComplete: '%{{percent}} tamamlandı',
     },
     },
     notes: {
     notes: {
       title: 'Notlar',
       title: 'Notlar',
@@ -4918,7 +4930,10 @@ export default {
     personalAccessToken: 'Kişisel Erişim Belirteci',
     personalAccessToken: 'Kişisel Erişim Belirteci',
     tokenSaved: '(kaydedildi)',
     tokenSaved: '(kaydedildi)',
     enterNewToken: 'Güncellemek için yeni belirteç girin',
     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',
     branch: 'Dal',
     provider: 'Git Sağlayıcısı',
     provider: 'Git Sağlayıcısı',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
@@ -4964,10 +4979,79 @@ export default {
     clearedLogs: '{{count}} günlük temizlendi',
     clearedLogs: '{{count}} günlük temizlendi',
     failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
     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ş',
     history: 'Geçmiş',
     clear: 'Temizle',
     clear: 'Temizle',
     date: 'Tarih',
     date: 'Tarih',
     status: 'Durum',
     status: 'Durum',
+    trigger: 'Tür',
+    triggers: {
+      manual: 'Yedek (manuel)',
+      scheduled: 'Yedek (zamanlanmış)',
+      restore: 'Geri yükleme',
+    },
     commit: 'Commit',
     commit: 'Commit',
 
 
     // Yerel Yedekleme
     // Yerel Yedekleme

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

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

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

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

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

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

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

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

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

@@ -682,6 +682,80 @@ export function ProjectDetailPage() {
         </div>
         </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 */}
       {/* Cost tracking */}
       {stats && (() => {
       {stats && (() => {
         const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
         const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
@@ -760,27 +834,48 @@ export function ProjectDetailPage() {
                 <Link
                 <Link
                   key={child.id}
                   key={child.id}
                   to={`/projects/${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
                     <div
-                      className="w-3 h-3 rounded-full"
+                      className="w-3 h-3 rounded-full flex-shrink-0"
                       style={{ backgroundColor: child.color || '#6b7280' }}
                       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 === 'completed' ? 'bg-status-ok/20 text-status-ok' :
                       child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
                       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'
                       'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
                     }`}>
                     }`}>
                       {child.status}
                       {child.status}
                     </span>
                     </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>
                   </div>
-                  {child.progress_percent !== null && (
-                    <span className="text-sm text-bambu-gray">
-                      {child.progress_percent.toFixed(0)}%
-                    </span>
-                  )}
                 </Link>
                 </Link>
               ))}
               ))}
             </div>
             </div>
@@ -1474,6 +1569,10 @@ export function ProjectDetailPage() {
             failed_count: stats?.failed_prints || 0,
             failed_count: stats?.failed_prints || 0,
             queue_count: stats?.queued_prints || 0,
             queue_count: stats?.queued_prints || 0,
             progress_percent: stats?.progress_percent || null,
             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: [],
             archives: [],
           }}
           }}
           onClose={() => setShowEditModal(false)}
           onClose={() => setShowEditModal(false)}

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

@@ -13,6 +13,7 @@ import {
   ListTodo,
   ListTodo,
   Package,
   Package,
   Layers,
   Layers,
+  FolderTree,
   Clock,
   Clock,
   CheckCircle2,
   CheckCircle2,
   AlertTriangle,
   AlertTriangle,
@@ -31,6 +32,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
+import { eligibleParents } from '../utils/projectTree';
 
 
 const PROJECT_COLORS = [
 const PROJECT_COLORS = [
   '#ef4444', // red
   '#ef4444', // red
@@ -69,7 +71,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [budget, setBudget] = useState(project?.budget?.toString() || '');
   const [budget, setBudget] = useState(project?.budget?.toString() || '');
   const [url, setUrl] = useState(project?.url || '');
   const [url, setUrl] = useState(project?.url || '');
   const [urlError, setUrlError] = useState<string | null>(null);
   const [urlError, setUrlError] = useState<string | null>(null);
+  const [parentId, setParentId] = useState<number | null>(project?.parent_id ?? null);
   const queryClient = useQueryClient();
   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 [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
   const coverFileInputRef = useRef<HTMLInputElement>(null);
   const coverFileInputRef = useRef<HTMLInputElement>(null);
   const [coverUploading, setCoverUploading] = useState(false);
   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
       // Pydantic accepts null to clear the URL; an empty string would fail the
       // http(s) prefix validator.
       // http(s) prefix validator.
       url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
       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 }),
       ...(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>}
             {urlError && <p className="text-xs text-red-600 dark:text-red-400 mt-1">{urlError}</p>}
           </div>
           </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,
           {/* #1155: Cover image — only available when editing an existing project,
               since uploading needs a project_id. New projects can add it after save. */}
               since uploading needs a project_id. New projects can add it after save. */}
           {project && (
           {project && (
@@ -510,6 +545,7 @@ function ProjectCoverThumbnail({
 
 
 interface ProjectCardProps {
 interface ProjectCardProps {
   project: ProjectListItem;
   project: ProjectListItem;
+  parentName?: string;  // #1264 — resolved by the caller, which holds the whole list
   onClick: () => void;
   onClick: () => void;
   onEdit: () => void;
   onEdit: () => void;
   onDelete: () => void;
   onDelete: () => void;
@@ -517,7 +553,7 @@ interface ProjectCardProps {
   t: TFunction;
   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
   // Plates progress: archive_count / target_count
   const platesProgressPercent = project.target_count
   const platesProgressPercent = project.target_count
     ? Math.round((project.archive_count / project.target_count) * 100)
     ? 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')}
                     {t('projects.statusArchived')}
                   </span>
                   </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>
               </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 && (
               {project.description && (
                 <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
                 <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
                   {project.description}
                   {project.description}
@@ -890,6 +943,96 @@ export function ProjectsPage() {
     queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
     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({
   const createMutation = useMutation({
     mutationFn: (data: ProjectCreate) => api.createProject(data),
     mutationFn: (data: ProjectCreate) => api.createProject(data),
     onSuccess: () => {
     onSuccess: () => {
@@ -1159,17 +1302,7 @@ export function ProjectsPage() {
         </div>
         </div>
       ) : (
       ) : (
         <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
         <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>
         </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));
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-CBRJgDPF.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-DJ8Q_OV9.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio