feat(backup): restore selected categories from a Git backup commit (#2656)

The Git backup feature was push-only: there was no equivalent of the local
    backup's Restore button, so recovering meant hand-downloading JSON files from
    the repository. This adds the read side.

    Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
    ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
    that unchanged; GitLab overrides for its own REST shape, including tree
    pagination and subgroup path encoding. fetch_files is batched so the path ->
    blob SHA lookup happens once per restore rather than once per file, and uses the
    blobs API rather than contents because contents silently inlines only the first
    1 MB.

    The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
    preview and the restore that follows act on the same commit even if a scheduled
    backup lands in between. 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 publish over MQTT.

    Restores never reuse the backup's primary keys. spool.id and print_archives.id
    are bare autoincrement columns, so ids from an old backup very likely belong to
    unrelated rows today; rows are matched on natural keys (tag_uid, then
    tray_uuid, then a descriptive composite for spools; content_hash or filename
    plus started_at for archives), inserted without an explicit id, and an
    old_id -> new_id map rewrites the foreign keys in spool usage history.
    created_at is carried across on insert so restoring the same backup twice
    matches instead of duplicating. Dangling printer/project links are cleared and
    reported rather than failing the row.

    Settings restore re-applies the collector's credential denylist on the read
    side, plus a pattern guard, because a backup taken before that denylist existed
    can still contain secrets. Restored archives are metadata-only: the 3MF and
    thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
    NULL, so inserted rows get an empty path and the UI says so.

    Backup and restore take a mutex against each other; both write the same tables
    and talk to the same printers. Restores are logged as GitHubBackupLog rows with
    trigger="restore", which needs no migration and surfaces them in the existing
    History card.

    Cloud profiles are deliberately not a restore category. The collector never
    actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
    Cloud API does not return - and the preset list it would write carries no
    setting payload. Filed separately.

    Permission github:restore already existed and is granted to Administrators, so
    no permission changes were needed.

    Tests: 125 new backend tests (provider reads across all four providers, the
    per-category appliers, the API endpoints) and 13 frontend tests. Full suites
    pass with no regressions; the 35 backend failures on Windows are byte-identical
    with and without this branch.
This commit is contained in:
maziggy
2026-08-15 14:10:10 +02:00
26 changed files with 4409 additions and 1 deletions
+69 -1
View File
@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.user import User
from backend.app.schemas.github_backup import (
REF_PATTERN,
CloudAccountCounts,
GitHubBackupConfigCreate,
GitHubBackupConfigResponse,
@@ -19,10 +20,15 @@ from backend.app.schemas.github_backup import (
GitHubBackupLogResponse,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitHubCommitListResponse,
GitHubRestorePreview,
GitHubRestoreRequest,
GitHubRestoreResponse,
GitHubTestConnectionResponse,
ProviderType,
)
from backend.app.services.github_backup import github_backup_service
from backend.app.services.github_restore import github_restore_service
logger = logging.getLogger(__name__)
@@ -388,13 +394,75 @@ async def get_status(
configured=True,
enabled=config.enabled,
is_running=github_backup_service.is_running,
progress=github_backup_service.progress,
restore_running=github_restore_service.is_running,
progress=github_backup_service.progress or github_restore_service.progress,
last_backup_at=config.last_backup_at,
last_backup_status=config.last_backup_status,
next_scheduled_run=config.next_scheduled_run,
)
@router.get("/commits", response_model=GitHubCommitListResponse)
async def list_commits(
limit: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
):
"""List recent backup commits so the user can pick one to restore from."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
commit_result = await github_restore_service.list_commits(config, limit=limit)
return GitHubCommitListResponse(**commit_result)
@router.get("/restore/preview", response_model=GitHubRestorePreview)
async def preview_restore(
ref: str = Query(default="HEAD", pattern=REF_PATTERN),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
):
"""Report which categories a given backup commit contains."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
preview = await github_restore_service.preview(config, ref=ref)
return GitHubRestorePreview(**preview)
@router.post("/restore", response_model=GitHubRestoreResponse)
async def restore_backup(
request: GitHubRestoreRequest,
db: AsyncSession = Depends(get_db),
_: 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.
"""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
restore_result = await github_restore_service.run_restore(
config.id,
ref=request.ref,
categories=request.categories,
overwrite_existing=request.overwrite_existing,
)
return GitHubRestoreResponse(**restore_result)
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
async def get_logs(
limit: int = Query(default=50, ge=1, le=200),
+102
View File
@@ -176,6 +176,7 @@ class GitHubBackupStatus(BaseModel):
configured: bool = Field(description="Whether backup is configured")
enabled: bool = Field(description="Whether backup is enabled")
is_running: bool = Field(description="Whether a backup is currently running")
restore_running: bool = Field(default=False, description="Whether a restore is currently running")
progress: str | None = Field(default=None, description="Current backup progress message")
last_backup_at: datetime | None
last_backup_status: str | None
@@ -204,3 +205,104 @@ class GitHubBackupTriggerResponse(BaseModel):
log_id: int | None = None
commit_sha: str | None = None
files_changed: int = 0
# --- Restore (issue #2656) --------------------------------------------------
# "HEAD" means "whatever the branch tip is right now"; the service resolves it
# to a concrete SHA before reading anything so preview and apply can't straddle
# two different commits. Anything else must look like a git object name.
REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
class RestoreCategory(StrEnum):
"""Backup categories that can be restored.
Cloud profiles are deliberately absent: the backup collector never actually
writes ``cloud_profiles/*.json`` (it reads a "setting" list key the Bambu
Cloud API does not return), and the preset list it would collect carries no
setting payload to restore from. 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")
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 GitHubRestoreCategoryResult(BaseModel):
"""Per-category outcome of a restore."""
restored: int = 0
skipped: int = 0
failed: int = 0
notes: list[str] = 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)
@@ -76,3 +76,61 @@ class GitProviderBackend(ABC):
client: httpx.AsyncClient,
) -> dict:
"""Push files to the repository. Returns status/message/commit_sha/files_changed."""
# --- Read side (restore, issue #2656) ---------------------------------
# The backup path only ever writes. Restore needs to walk history, list a
# snapshot and read individual blobs back, so these three mirror the
# ``{"success": bool, "message": str, ...}`` convention ``test_connection``
# already uses rather than raising.
@abstractmethod
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch``, newest first.
Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
"""
@abstractmethod
async def 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]}``.
"""
@abstractmethod
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
) -> 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.
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.
"""
@@ -115,6 +115,214 @@ class GitHubBackend(GitProviderBackend):
"is_private": None,
}
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch`` via the repo commits API."""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
# GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
# do the reverse. Sending both lets GiteaBackend inherit this method
# unchanged instead of duplicating it for one query parameter.
response = await client.get(
f"{api_base}/repos/{owner}/{repo}/commits",
headers=headers,
params={"sha": branch, "per_page": limit, "limit": limit},
)
if response.status_code == 404:
return {
"success": False,
"message": (
f"Branch '{branch}' not found, or the repository has no commits yet. "
"Run a backup before restoring."
),
"commits": [],
}
if response.status_code != 200:
msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("list_commits %s/%s: %s", owner, repo, msg)
return {"success": False, "message": msg, "commits": []}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
if not isinstance(data, list):
return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
except Exception as e:
logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
@staticmethod
def _parse_commit_entries(data: list, limit: int) -> list[dict]:
"""Normalise GitHub/Gitea commit list entries to our flat shape."""
commits = []
for entry in data[:limit]:
if not isinstance(entry, dict):
continue
sha = entry.get("sha")
if not isinstance(sha, str) or not sha:
continue
commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
commits.append(
{
"sha": sha,
"message": commit.get("message") or "",
"author": author.get("name") or "",
"date": author.get("date") or "",
}
)
return commits
async def _blob_shas_at(
self,
client: httpx.AsyncClient,
headers: dict,
api_base: str,
owner: str,
repo: str,
ref: str,
) -> tuple[dict[str, str] | None, str]:
"""Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
A commit SHA is a valid tree-ish for the trees API, so this resolves the
commit's tree in one request rather than commit -> tree -> list.
"""
response = await client.get(
f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
headers=headers,
)
if response.status_code == 404:
return None, f"Commit or tree '{ref}' not found in the repository"
if response.status_code != 200:
return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
try:
data = response.json()
except ValueError:
return None, "Non-JSON response listing tree"
# Same limit the push path guards against: a truncated listing would make
# a restore silently skip categories that are actually in the backup.
if data.get("truncated"):
return None, (
"Repository tree exceeds the API listing limit (truncated=true), so the backup "
"contents cannot be enumerated reliably. Rotate the backup repository."
)
blobs: dict[str, str] = {}
for item in data.get("tree", []):
if not isinstance(item, dict) or item.get("type") != "blob":
continue
path, sha = item.get("path"), item.get("sha")
if isinstance(path, str) and isinstance(sha, str) and path and sha:
blobs[path] = sha
return blobs, ""
async def 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": []}
return {"success": True, "message": "OK", "paths": sorted(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": []}
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
) -> 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, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
if blobs is None:
logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
return {"success": False, "message": error, "files": {}}
files: dict[str, str] = {}
for path in paths:
sha = blobs.get(path)
if sha is None:
continue
response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
if response.status_code != 200:
msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
return {"success": False, "message": msg, "files": {}}
text, error = self._decode_blob(response, path)
if text is None:
logger.warning("fetch_files %s/%s: %s", owner, repo, error)
return {"success": False, "message": error, "files": {}}
files[path] = text
return {"success": True, "message": "OK", "files": files}
except Exception as e:
logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
"""Decode a blob API response body to text, or return an error message."""
try:
data = response.json()
except ValueError:
return None, f"Non-JSON response reading {path}"
if not isinstance(data, dict):
return None, f"Unexpected shape reading {path}"
content = data.get("content")
if not isinstance(content, str):
return None, f"Missing content reading {path}"
encoding = data.get("encoding", "base64")
try:
if encoding == "base64":
# Both providers wrap base64 payloads at 60 chars; b64decode
# tolerates the newlines, but be explicit about it.
return base64.b64decode(content).decode("utf-8"), ""
if encoding in ("utf-8", "text", "plain"):
return content, ""
except (ValueError, UnicodeDecodeError) as e:
return None, f"Could not decode {path}: {type(e).__name__}"
return None, f"Unsupported blob encoding {encoding!r} reading {path}"
async def push_files(
self,
repo_url: str,
@@ -115,6 +115,201 @@ class GitLabBackend(GitProviderBackend):
"is_private": None,
}
def _encoded_project(self, repo_url: str) -> str:
"""Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
owner, repo = self.parse_repo_url(repo_url)
return urllib.parse.quote(f"{owner}/{repo}", safe="")
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch`` via /repository/commits."""
try:
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = self._encoded_project(repo_url)
response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/commits",
headers=headers,
params={"ref_name": branch, "per_page": limit},
)
if response.status_code == 404:
return {
"success": False,
"message": (
f"Branch '{branch}' not found, or the repository has no commits yet. "
"Run a backup before restoring."
),
"commits": [],
}
if response.status_code != 200:
msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("list_commits %s: %s", repo_url, msg)
return {"success": False, "message": msg, "commits": []}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
if not isinstance(data, list):
return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
commits = []
for entry in data[:limit]:
if not isinstance(entry, dict):
continue
sha = entry.get("id")
if not isinstance(sha, str) or not sha:
continue
# GitLab flattens author/date onto the commit itself rather than
# nesting them under "commit" the way GitHub does.
commits.append(
{
"sha": sha,
"message": entry.get("message") or "",
"author": entry.get("author_name") or "",
"date": entry.get("committed_date") or entry.get("created_at") or "",
}
)
return {"success": True, "message": "OK", "commits": commits}
except Exception as e:
logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
async def 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
# 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.
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": [],
}
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": []}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response listing tree", "paths": []}
if not isinstance(data, list):
return {"success": False, "message": "Unexpected shape listing tree", "paths": []}
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:
break
page += 1
return {"success": True, "message": "OK", "paths": sorted(paths)}
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": []}
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
) -> dict:
"""Read ``paths`` at ``ref`` via /repository/files/{path}."""
try:
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = self._encoded_project(repo_url)
files: dict[str, str] = {}
for path in paths:
encoded_file = urllib.parse.quote(path, safe="")
response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
headers=headers,
params={"ref": ref},
)
# A path absent from this commit is expected — which categories a
# backup contains varies by config — so skip rather than fail.
if response.status_code == 404:
continue
if response.status_code != 200:
msg = (
f"Failed to read {path} (HTTP {response.status_code}): "
f"{self._truncated_response_text(response)}"
)
logger.warning("fetch_files %s: %s", repo_url, msg)
return {"success": False, "message": msg, "files": {}}
try:
data = response.json()
except ValueError:
return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
if not isinstance(data, dict):
return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
content = data.get("content")
if not isinstance(content, str):
return {"success": False, "message": f"Missing content reading {path}", "files": {}}
encoding = data.get("encoding", "base64")
try:
if encoding == "base64":
files[path] = base64.b64decode(content).decode("utf-8")
elif encoding in ("text", "utf-8", "plain"):
files[path] = content
else:
return {
"success": False,
"message": f"Unsupported encoding {encoding!r} reading {path}",
"files": {},
}
except (ValueError, UnicodeDecodeError) as e:
return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
return {"success": True, "message": "OK", "files": files}
except Exception as e:
logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
async def push_files(
self,
repo_url: str,
+29
View File
@@ -173,9 +173,32 @@ class GitHubBackupService:
Returns:
dict with success, message, log_id, commit_sha, files_changed
"""
# Everything from here to `self._running_backup = True` must stay
# await-free. Both flags are plain bools and both callers are coroutines
# on one event loop, so with no suspension point in between the loop
# cannot run the restore service's mirror-image region (see
# github_restore.run_restore) in the gap — whichever gets here first sets
# its flag before the other can read it. Adding an `await` inside this
# block reintroduces the check-then-set race and lets a backup and a
# restore run at once.
if self._running_backup:
return {"success": False, "message": "A backup is already running", "log_id": None}
# Imported locally to avoid a module-level import cycle — the restore
# service imports this module's singleton to take the mirror-image lock.
# A restore rewrites the same tables this collector reads and publishes
# K-profiles to the same printers, so the two must not interleave.
# (A local `import` of an already-loaded module is not a suspension
# point, so it does not break the await-free rule above.)
from backend.app.services.github_restore import github_restore_service
if github_restore_service.is_running:
return {
"success": False,
"message": "A restore is currently running. Wait for it to finish before backing up.",
"log_id": None,
}
self._running_backup = True
log_id = None
@@ -840,6 +863,12 @@ class GitHubBackupService:
"energy_kwh": a.energy_kwh,
"energy_cost": a.energy_cost,
"created_at": str(a.created_at) if a.created_at else None,
# Soft-deleted archives are collected too — their row is kept on
# purpose so the stats endpoint keeps counting their filament and
# energy (see archive_service.soft_delete_archive). Recording
# deleted_at is what lets a restore put them back the way they
# were instead of resurrecting them as visible archives.
"deleted_at": str(a.deleted_at) if a.deleted_at else None,
}
archive_list.append(archive_data)
+923
View File
@@ -0,0 +1,923 @@
"""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.
* **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.** The backup collector never actually
writes ``cloud_profiles/*.json``, and the preset list it would write carries
no setting payload. Tracked separately from #2656.
"""
import asyncio
import json
import logging
import re
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.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"}
# Belt-and-braces for the same reason: any key that looks like a secret is
# skipped even if it isn't in the explicit denylist above.
_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
# 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(...)``."""
if not value or not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
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)
class _CategoryTally:
"""Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
def __init__(self) -> None:
self.restored = 0
self.skipped = 0
self.failed = 0
self.notes: list[str] = []
def note(self, message: str) -> None:
# Notes are surfaced verbatim in the UI, so keep the list bounded rather
# than emitting one line per row for a large backup.
if message not in self.notes and len(self.notes) < 20:
self.notes.append(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]:
"""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.
"""
if ref and ref.upper() != "HEAD":
return ref, ""
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"
commits = result.get("commits") or []
if not commits:
return None, f"Branch '{config.branch}' has no commits to restore from"
return commits[0]["sha"], ""
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
async def preview(self, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
"""Report which categories a commit contains, and how much is in each."""
resolved, error = 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
)
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(
{
"category": category,
"available": False,
"item_count": 0,
"detail": "Not present in this backup commit",
}
)
continue
unreadable = [p for p in paths if p in bad_paths]
if unreadable:
categories.append(
{
"category": category,
"available": False,
"item_count": 0,
"detail": f"Unreadable JSON: {', '.join(unreadable)}",
}
)
continue
count, detail = self._count_items(category, parsed)
categories.append({"category": category, "available": True, "item_count": count, "detail": detail})
commit_info = None
commits = (await self.list_commits(config, limit=20)).get("commits") or []
for entry in commits:
if entry["sha"] == resolved:
commit_info = entry
break
return {
"success": True,
"message": "OK",
"ref": resolved,
"commit": commit_info,
"metadata_version": metadata_version,
"categories": categories,
}
@staticmethod
def _count_items(category: RestoreCategory, parsed: dict) -> tuple[int, str | 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, "No settings in payload"
blocked = sum(1 for key in values if _is_blocked_setting_key(key))
detail = f"{blocked} credential-like keys will be skipped" if blocked else None
return len(values) - blocked, 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
count = len(spools) if isinstance(spools, list) else 0
detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None
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, "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 = f"across {len(serials)} printer(s)" if serials else None
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
try:
payload, error = await self._read_categories(config, resolved, categories)
if error:
raise RuntimeError(error)
results = await self._apply(db, payload, categories, overwrite_existing)
await db.commit()
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:
logger.exception("Restore failed for config %s ref %s", config_id, resolved)
await db.rollback()
log.status = "failed"
log.completed_at = datetime.now(timezone.utc)
log.error_message = str(e)[:1000]
await db.commit()
return {"success": False, "message": str(e), "log_id": log_id, "ref": resolved, "results": {}}
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
)
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,
) -> dict[str, _CategoryTally]:
"""Apply categories in dependency order and return per-category tallies."""
results: dict[str, _CategoryTally] = {}
archive_id_map: dict[int, int] = {}
# 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)
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,
)
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)
results[RestoreCategory.SETTINGS.value] = tally
# Last, because it leaves the database and publishes over MQTT.
if RestoreCategory.KPROFILES in categories:
self._progress = "Sending K-profiles to printers..."
tally = _CategoryTally()
await self._restore_kprofiles(db, payload, tally)
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("No archive data 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())
# 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"),
# 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. Backups written before this key existed have no
# deleted_at, and those rows can only come back live.
"deleted_at": _parse_dt(entry.get("deleted_at")),
}
printer_id = entry.get("printer_id")
if printer_id is not None and printer_id not in valid_printers:
tally.note("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("Some archives referenced projects that no longer exist — link cleared")
project_id = None
fields["printer_id"] = printer_id
fields["project_id"] = project_id
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 fields["deleted_at"] is None:
tally.note("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(
"Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup"
)
warned_files = True
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("No spool data in this backup")
return
spool_id_map: dict[int, int] = {}
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 = 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
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
await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
async def _find_spool(self, db: AsyncSession, entry: dict) -> Spool | None:
"""Match a backed-up spool to a local row.
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.
"""
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
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
created_at = _parse_dt(entry.get("created_at"))
if created_at is None:
return None
result = await db.execute(
select(Spool).where(
Spool.created_at == created_at,
Spool.material == (entry.get("material") or "PLA"),
Spool.brand == entry.get("brand"),
Spool.subtype == entry.get("subtype"),
Spool.color_name == entry.get("color_name"),
)
)
return result.scalars().first()
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
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.
existing = await db.execute(
select(SpoolUsageHistory).where(
SpoolUsageHistory.spool_id == spool_id,
SpoolUsageHistory.created_at == created_at,
SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
SpoolUsageHistory.print_name == entry.get("print_name"),
)
)
if existing.scalars().first() is not None:
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
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(
f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
"spool list, so there is nothing to attach them to."
)
async def _restore_settings(self, db: AsyncSession, payload, overwrite: bool, tally: _CategoryTally) -> None:
values = payload.get("settings") if isinstance(payload, dict) else None
if not isinstance(values, dict):
tally.note("No settings data in this backup")
return
blocked = 0
for key, value in values.items():
if not isinstance(key, str) or not key:
tally.failed += 1
continue
if _is_blocked_setting_key(key):
blocked += 1
tally.skipped += 1
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
continue
db.add(Settings(key=key, value=str(value)))
tally.restored += 1
if blocked:
tally.note(f"{blocked} credential-like key(s) skipped — re-enter secrets manually")
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("No K-profile data 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("K-profiles always overwrite the matching slot on the printer")
tally.note("Profiles are published over MQTT without acknowledgement — verify on the printer")
for serial, entries in sorted(by_serial.items()):
profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
printer = printers.get(serial)
if printer is None:
tally.skipped += profile_total
tally.note(f"No printer with serial {serial} — skipped")
continue
client = printer_manager.get_client(printer.id)
if not client or not client.state.connected:
tally.skipped += profile_total
tally.note(f"{printer.name} ({serial}) is not connected — skipped")
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(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is")
profile_dicts = [
{
"filament_id": p.get("filament_id", ""),
"name": p.get("name", ""),
"k_value": p.get("k_value", "0.020000"),
"nozzle_id": p.get("nozzle_id"),
"extruder_id": p.get("extruder_id", 0),
"setting_id": p.get("setting_id"),
"slot_id": p.get("slot_id", 0),
}
for p in profiles
if isinstance(p, dict)
]
if not profile_dicts:
continue
try:
sent = 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)
sent = False
if sent:
tally.restored += len(profile_dicts)
else:
tally.failed += len(profile_dicts)
tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})")
# Singleton instance
github_restore_service = GitHubRestoreService()
@@ -0,0 +1,276 @@
"""Integration tests for the Git backup restore API endpoints (#2656)."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
@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) -> dict:
response = await async_client.post(
"/api/v1/github-backup/config",
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": ["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
assert body["results"]["settings"]["notes"] == ["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
@@ -0,0 +1,431 @@
"""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 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_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 only; reads come from GitHubBackend."""
@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"):
assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
@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": []}))
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_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"] == {}
+946
View File
@@ -0,0 +1,946 @@
"""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
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.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
from backend.app.services.github_restore import (
ARCHIVES_PATH,
SETTINGS_PATH,
SPOOL_USAGE_PATH,
SPOOLS_PATH,
GitHubRestoreService,
_CategoryTally,
_is_blocked_setting_key,
_parse_dt,
)
def _service() -> GitHubRestoreService:
return GitHubRestoreService()
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
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
class TestCategoryTally:
def test_notes_are_deduplicated(self):
tally = _CategoryTally()
tally.note("same")
tally.note("same")
assert tally.notes == ["same"]
def test_notes_are_bounded(self):
tally = _CategoryTally()
for i in range(50):
tally.note(f"note {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"}
assert tally.skipped == 2
assert any("credential-like" in note for note in tally.notes)
@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 tally.notes
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 tally.notes)
# 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 tally.notes)
@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 tally.notes)
@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_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 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 tally.notes)
@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):
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()]}, 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 tally.notes)
@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 tally.notes)
@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:
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 = MagicMock()
client.state.connected = True
client.set_kprofiles_batch = MagicMock(return_value=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, 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_that_mqtt_is_unacknowledged(self, db_session, printer_factory):
await printer_factory(serial_number="00M09A123456789")
client = MagicMock()
client.state.connected = True
client.set_kprofiles_batch = MagicMock(return_value=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, self._payload(), tally)
assert any("without acknowledgement" in note for note in tally.notes)
assert any("always overwrite" in note for note in tally.notes)
@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 tally.notes)
@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 tally.notes)
@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):
await printer_factory(serial_number="00M09A123456789")
client = MagicMock()
client.state.connected = True
client.set_kprofiles_batch = MagicMock(return_value=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.failed == 1
assert tally.restored == 0
@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
@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 = MagicMock()
client.state.connected = True
client.set_kprofiles_batch = MagicMock(return_value=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 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 any("No K-profile data" in note for note in tally.notes)
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 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 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 = await service._resolve_ref(config, "abc1234")
assert resolved == "abc1234"
assert error == ""
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 = await service._resolve_ref(config, "HEAD")
assert resolved == "tipsha1"
assert error == ""
@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 = await service._resolve_ref(config, "HEAD")
assert resolved is None
assert "no commits" in error
@@ -0,0 +1,367 @@
/**
* 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 { http, HttpResponse } from 'msw';
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',
categories: [
{ category: 'archives', available: true, item_count: 30, detail: 'Metadata only' },
{ category: 'spools', available: true, item_count: 4, detail: null },
{ category: 'settings', available: true, item_count: 12, detail: null },
{ category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' },
],
};
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('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: ['1 usage record(s) skipped'] },
},
});
})
);
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();
expect(screen.getByText('1 usage record(s) skipped')).toBeInTheDocument();
});
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();
});
});
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 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.
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));
// Nothing was written, so nothing to re-read...
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();
});
// 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();
});
});
+69
View File
@@ -2820,12 +2820,68 @@ export interface GitHubBackupStatus {
configured: boolean;
enabled: boolean;
is_running: boolean;
restore_running: boolean;
progress: string | null;
last_backup_at: string | null;
last_backup_status: string | null;
next_scheduled_run: string | null;
}
// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
// the backup collector never writes them, so there is nothing to restore.
export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
export interface GitHubCommitInfo {
sha: string;
message: string;
author: string;
date: string;
}
export interface GitHubCommitListResponse {
success: boolean;
message: string;
branch: string;
commits: GitHubCommitInfo[];
}
export interface GitHubRestorePreviewCategory {
category: RestoreCategory;
available: boolean;
item_count: number;
detail: string | null;
}
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;
}
export interface GitHubRestoreCategoryResult {
restored: number;
skipped: number;
failed: number;
notes: string[];
}
export interface GitHubRestoreResponse {
success: boolean;
message: string;
log_id: number | null;
ref: string | null;
results: Record<string, GitHubRestoreCategoryResult>;
}
export interface LocalBackupStatus {
enabled: boolean;
schedule: string;
@@ -6684,6 +6740,19 @@ export const api = {
clearGitHubBackupLogs: (keepLast: number = 10) =>
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
// Restore from a Git backup (#2656)
getGitHubBackupCommits: (limit: number = 20) =>
request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
getGitHubRestorePreview: (ref: string = 'HEAD') =>
request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
restoreFromGitHub: (payload: GitHubRestoreRequest) =>
request<GitHubRestoreResponse>('/github-backup/restore', {
method: 'POST',
body: JSON.stringify(payload),
}),
// Scheduled local backups
getLocalBackupStatus: () =>
request<LocalBackupStatus>('/local-backup/status'),
@@ -40,6 +40,7 @@ import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { ConfirmModal } from './ConfirmModal';
import { GitHubRestoreModal } from './GitHubRestoreModal';
import { useToast } from '../contexts/ToastContext';
import { formatRelativeTime, parseUTCDate } from '../utils/date';
@@ -158,6 +159,9 @@ export function GitHubBackupSettings() {
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Restore from the Git backup repository (#2656)
const [showGitRestore, setShowGitRestore] = useState(false);
// Scheduled local backup state
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
@@ -951,6 +955,16 @@ export function GitHubBackupSettings() {
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{t('backup.test')}
</Button>
{/* Restore from the backup repo (#2656) */}
<Button
variant="secondary"
size="sm"
onClick={() => setShowGitRestore(true)}
disabled={status.restore_running}
>
<RotateCcw className="w-4 h-4" />
{t('backup.restoreFromGit.button')}
</Button>
</>
)}
</>
@@ -1445,6 +1459,9 @@ export function GitHubBackupSettings() {
</Card>
</div>
{/* Restore from the Git backup repository (#2656) */}
{showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
{/* Delete Backup Confirmation Modal */}
{deleteConfirmFile && (
<ConfirmModal
@@ -0,0 +1,431 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
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 GitHubRestoreResponse } from '../api/client';
interface GitHubRestoreModalProps {
onClose: () => void;
}
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 restoreMutation = useMutation({
mutationFn: () =>
api.restoreFromGitHub({
ref: resolvedRef,
categories: CATEGORIES.filter((c) => selected[c.id]).map((c) => c.id),
overwrite_existing: overwriteExisting,
}),
onSuccess: (data) => {
setShowConfirm(false);
// The endpoint answers 200 for a refused or failed restore too, with
// `success: false` and an empty `results` — and two of those are ordinary
// conditions, not errors: another restore already running, and a backup
// being mid-flight. Rendering the result panel for them showed a green
// tick, no tally at all and a "reload so the restored data appears" hint
// above a message saying nothing had been restored. Only a real success
// gets the panel; a failure keeps the form and shows the red block below.
if (data.success) {
setResult(data);
// A restore rewrites rows these caches hold.
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;
// Close on Escape, except while a restore is in flight.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isRestoring && !showConfirm) onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, 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]);
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: c.detail };
});
return map;
}, [previewQuery.data]);
// 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 selectedCount = CATEGORIES.filter((c) => selected[c.id]).length;
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 : onClose}
>
<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={onClose}
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">
<div className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span className="text-white">{result.message}</span>
</div>
{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) => (
<li key={note} 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>{note}</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>}
</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={onClose}>
{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={onClose} 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')
: t('backup.restoreFromGit.confirmMessage')
}
confirmText={t('backup.restore')}
isLoading={isRestoring}
loadingText={t('backup.restoreFromGit.restoring')}
onConfirm={() => restoreMutation.mutate()}
onCancel={() => setShowConfirm(false)}
/>
)}
</>
);
}
+24
View File
@@ -4873,6 +4873,30 @@ export default {
clearedLogs: '{{count}} Protokolle gelöscht',
failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '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.',
tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
failed: 'Wiederherstellung fehlgeschlagen.',
loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
},
// History
history: 'Verlauf',
clear: 'Löschen',
+24
View File
@@ -4916,6 +4916,30 @@ export default {
clearedLogs: 'Cleared {{count}} logs',
failedToClearLogs: 'Failed to clear logs: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restore',
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.',
tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
failed: 'Restore failed.',
loadFailed: 'Could not read the backup repository.',
},
// History
history: 'History',
clear: 'Clear',
+24
View File
@@ -4881,6 +4881,30 @@ export default {
clearedLogs: 'Se borraron {{count}} registros',
failedToClearLogs: 'Error al borrar los registros: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restaurar',
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.',
tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
failed: 'La restauración ha fallado.',
loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
},
// History
history: 'Historial',
clear: 'Borrar',
+24
View File
@@ -4862,6 +4862,30 @@ export default {
clearedLogs: '{{count}} journaux supprimés',
failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restaurer',
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.',
tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
failed: 'Échec de la restauration.',
loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
},
// History
history: 'Historique',
clear: 'Effacer',
+24
View File
@@ -4861,6 +4861,30 @@ export default {
clearedLogs: '{{count}} log eliminati',
failedToClearLogs: 'Eliminazione log fallita: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Ripristina',
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.',
tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
failed: 'Ripristino non riuscito.',
loadFailed: 'Impossibile leggere il repository di backup.',
},
// History
history: 'Cronologia',
clear: 'Cancella',
+24
View File
@@ -4873,6 +4873,30 @@ export default {
clearedLogs: '{{count}}件のログを削除しました',
failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '復元',
title: 'Git バックアップから復元',
subtitle: 'コミットと復元する項目を選択します',
commitLabel: 'バックアップのコミット',
latestCommit: '最新のバックアップ (ブランチ先端)',
categoriesLabel: '復元する項目',
inspecting: 'バックアップの内容を読み込んでいます...',
itemCount: 'バックアップ内に {{count}} 件',
overwriteLabel: '既存のエントリを上書きする',
overwriteOn: '既存のエントリはバックアップの内容で更新されます。',
overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。',
selectedCount: '{{count}} 件選択中',
restoring: '復元しています...',
confirmTitle: 'バックアップから復元しますか?',
confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。',
confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。',
tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
failed: '復元に失敗しました。',
loadFailed: 'バックアップリポジトリを読み取れませんでした。',
},
// History
history: '履歴',
clear: 'クリア',
+24
View File
@@ -4637,6 +4637,30 @@ export default {
backupFailed2: '백업 실패: {{message}}',
clearedLogs: '{{count}}개 로그 초기화됨',
failedToClearLogs: '로그 초기화 실패: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '복원',
title: 'Git 백업에서 복원',
subtitle: '커밋과 복원할 항목을 선택하세요',
commitLabel: '백업 커밋',
latestCommit: '최신 백업 (브랜치 최신 커밋)',
categoriesLabel: '복원할 항목',
inspecting: '백업 내용을 읽고 있습니다...',
itemCount: '백업에 {{count}}개',
overwriteLabel: '기존 항목 덮어쓰기',
overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.',
overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.',
selectedCount: '{{count}}개 선택됨',
restoring: '복원 중...',
confirmTitle: '백업에서 복원하시겠습니까?',
confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.',
confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.',
tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
failed: '복원에 실패했습니다.',
loadFailed: '백업 저장소를 읽을 수 없습니다.',
},
history: '기록',
clear: '초기화',
date: '날짜',
+24
View File
@@ -4861,6 +4861,30 @@ export default {
clearedLogs: '{{count}} logs removidos',
failedToClearLogs: 'Falha ao limpar logs: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restaurar',
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.',
tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
failed: 'Falha na restauração.',
loadFailed: 'Não foi possível ler o repositório de backup.',
},
// History
history: 'Histórico',
clear: 'Limpar',
+24
View File
@@ -4629,6 +4629,30 @@ export default {
backupFailed2: "Ошибка резервного копирования: {{message}}",
clearedLogs: "Очищено записей журнала: {{count}}",
failedToClearLogs: "Не удалось очистить журнал: {{message}}",
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Восстановить',
title: 'Восстановление из резервной копии Git',
subtitle: 'Выберите коммит и данные для восстановления',
commitLabel: 'Коммит резервной копии',
latestCommit: 'Последняя резервная копия (вершина ветки)',
categoriesLabel: 'Что восстановить',
inspecting: 'Чтение содержимого резервной копии...',
itemCount: '{{count}} в резервной копии',
overwriteLabel: 'Перезаписывать существующие записи',
overwriteOn: 'Существующие записи будут обновлены из резервной копии.',
overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.',
selectedCount: 'Выбрано: {{count}}',
restoring: 'Восстановление...',
confirmTitle: 'Восстановить из резервной копии?',
confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.',
confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.',
tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
failed: 'Не удалось выполнить восстановление.',
loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
},
history: "История",
clear: "Очистить",
date: "Дата",
+24
View File
@@ -4851,6 +4851,30 @@ export default {
clearedLogs: '{{count}} günlük temizlendi',
failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '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.',
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.',
failed: 'Geri yükleme başarısız oldu.',
loadFailed: 'Yedek deposu okunamadı.',
},
history: 'Geçmiş',
clear: 'Temizle',
date: 'Tarih',
+24
View File
@@ -4861,6 +4861,30 @@ export default {
clearedLogs: '已清除 {{count}} 条日志',
failedToClearLogs: '清除日志失败:{{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '恢复',
title: '从 Git 备份恢复',
subtitle: '选择提交以及要恢复的内容',
commitLabel: '备份提交',
latestCommit: '最新备份(分支最新提交)',
categoriesLabel: '恢复内容',
inspecting: '正在读取备份内容...',
itemCount: '备份中有 {{count}} 项',
overwriteLabel: '覆盖已有条目',
overwriteOn: '已有条目将根据备份内容更新。',
overwriteOff: '仅添加缺失的条目,已有条目保持不变。',
selectedCount: '已选择 {{count}} 项',
restoring: '正在恢复...',
confirmTitle: '要从备份恢复吗?',
confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。',
confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。',
tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
failed: '恢复失败。',
loadFailed: '无法读取备份仓库。',
},
// History
history: '历史记录',
clear: '清除',
+24
View File
@@ -4861,6 +4861,30 @@ export default {
clearedLogs: '已清除 {{count}} 條日誌',
failedToClearLogs: '清除日誌失敗:{{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '還原',
title: '從 Git 備份還原',
subtitle: '選擇提交以及要還原的項目',
commitLabel: '備份提交',
latestCommit: '最新備份(分支最新提交)',
categoriesLabel: '還原項目',
inspecting: '正在讀取備份內容...',
itemCount: '備份中有 {{count}} 筆',
overwriteLabel: '覆寫既有項目',
overwriteOn: '既有項目將依備份內容更新。',
overwriteOff: '僅新增缺少的項目,既有項目保持不變。',
selectedCount: '已選擇 {{count}} 筆',
restoring: '正在還原...',
confirmTitle: '要從備份還原嗎?',
confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。',
confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。',
tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
failed: '還原失敗。',
loadFailed: '無法讀取備份儲存庫。',
},
// History
history: '歷史紀錄',
clear: '清除',