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

feat(backup): restore selected categories from a Git backup commit
This commit is contained in:
MartinNYHC
2026-08-06 08:46:34 +02:00
committed by GitHub
34 changed files with 10450 additions and 169 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Added
- **Restore selected categories from a Git backup commit (#2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that.
- **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests.
- **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests.
+123 -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,16 @@ from backend.app.schemas.github_backup import (
GitHubBackupLogResponse,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitHubCommitListResponse,
GitHubRestorePreview,
GitHubRestoreRequest,
GitHubRestoreResponse,
GitHubTestConnectionResponse,
ProviderType,
RestoreCategory,
)
from backend.app.services.github_backup import github_backup_service
from backend.app.services.github_restore import github_restore_service
logger = logging.getLogger(__name__)
@@ -44,6 +51,33 @@ _UNKNOWN_VISIBILITY_ERROR = (
"repo API."
)
# The permission that owns each category's rows, required on top of
# github:restore. Backup is its own permission group, so without this a role
# holding only Backup writes — via a restore — rows it cannot write through the
# endpoint that owns them.
#
# Each entry is the permission that endpoint actually gates its writes on:
#
# * SETTINGS → PUT /api/v1/settings/ (settings:update)
# * SPOOLS → POST/PATCH /api/v1/inventory/spools (inventory:update). Spool
# rows and their usage history both restore under this category.
# * ARCHIVES → archives:update_all, not archives:create. A restore writes
# rows owned by other users — that is the whole point of carrying
# created_by_id — and update_all is the permission that means "may write an
# archive that is not yours". create alone would let an operator with
# archives:create_own-shaped access seed history onto someone else.
# * KPROFILES → POST /api/v1/printers/{id}/kprofiles (kprofiles:update),
# which is what the restore ultimately calls through set_kprofiles_batch.
#
# Cloud profiles are absent because they are not a restorable category
# (RestoreCategory's docstring).
_CATEGORY_WRITE_PERMISSION = {
RestoreCategory.SETTINGS: Permission.SETTINGS_UPDATE,
RestoreCategory.SPOOLS: Permission.INVENTORY_UPDATE,
RestoreCategory.ARCHIVES: Permission.ARCHIVES_UPDATE_ALL,
RestoreCategory.KPROFILES: Permission.KPROFILES_UPDATE,
}
async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> None:
"""Run a test_connection and refuse if the repo is not confirmed private.
@@ -388,13 +422,101 @@ async def get_status(
configured=True,
enabled=config.enabled,
is_running=github_backup_service.is_running,
progress=github_backup_service.progress,
restore_running=github_restore_service.is_running,
progress=github_backup_service.progress or github_restore_service.progress,
last_backup_at=config.last_backup_at,
last_backup_status=config.last_backup_status,
next_scheduled_run=config.next_scheduled_run,
)
@router.get("/commits", response_model=GitHubCommitListResponse)
async def list_commits(
limit: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
):
"""List recent backup commits so the user can pick one to restore from."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
commit_result = await github_restore_service.list_commits(config, limit=limit)
return GitHubCommitListResponse(**commit_result)
@router.get("/restore/preview", response_model=GitHubRestorePreview)
async def preview_restore(
ref: str = Query(default="HEAD", pattern=REF_PATTERN),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
):
"""Report which categories a given backup commit contains."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
preview = await github_restore_service.preview(db, config, ref=ref)
return GitHubRestorePreview(**preview)
@router.post("/restore", response_model=GitHubRestoreResponse)
async def restore_backup(
request: GitHubRestoreRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
):
"""Restore selected categories from one backup commit.
Note there is no private-repo gate here, unlike the config endpoints: that
check exists to stop credentials leaving the instance, and this path only
reads. A config can only be saved against a private repo anyway.
Every category needs the permission that owns the rows it writes, on top of
``github:restore`` — see ``_CATEGORY_WRITE_PERMISSION`` and the check below.
"""
if current_user is not None:
# Each category rewrites rows some other endpoint already owns, and
# Backup is its own permission group — so a role holding only Backup
# could otherwise write, through a restore, what it cannot write through
# the endpoint that owns them. This module already makes that argument;
# it is why the four protected auth keys are refused outright.
#
# current_user is None only when auth is disabled: github:restore is in
# _APIKEY_DENIED_PERMISSIONS, so an API key never gets past the
# dependency to reach this line.
missing = sorted(
{
permission.value
for category, permission in _CATEGORY_WRITE_PERMISSION.items()
if category in request.categories and not current_user.has_all_permissions(permission.value)
}
)
if missing:
raise HTTPException(
status_code=403,
detail=f"Missing required permissions: {', '.join(missing)}",
)
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
restore_result = await github_restore_service.run_restore(
config.id,
ref=request.ref,
categories=request.categories,
overwrite_existing=request.overwrite_existing,
)
return GitHubRestoreResponse(**restore_result)
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
async def get_logs(
limit: int = Query(default=50, ge=1, le=200),
+1 -1
View File
@@ -59,7 +59,7 @@ class GitHubBackupLog(Base):
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(20)) # running/success/failed/skipped
trigger: Mapped[str] = mapped_column(String(20)) # manual/scheduled
trigger: Mapped[str] = mapped_column(String(20)) # manual/scheduled/restore
commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
files_changed: Mapped[int] = mapped_column(Integer, default=0)
+123
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,125 @@ class GitHubBackupTriggerResponse(BaseModel):
log_id: int | None = None
commit_sha: str | None = None
files_changed: int = 0
# --- Restore (issue #2656) --------------------------------------------------
# "HEAD" means "whatever the branch tip is right now"; the service resolves it
# to a concrete SHA before reading anything so preview and apply can't straddle
# two different commits. Anything else must look like a git object name.
REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
class RestoreCategory(StrEnum):
"""Backup categories that can be restored.
Cloud profiles are deliberately absent: restoring a preset means writing to
a Bambu or Orca Cloud account, which is a different operation from every
other category here — those land in the local database, or on a printer the
instance already owns. Tracked separately from #2656.
"""
KPROFILES = "kprofiles"
SETTINGS = "settings"
SPOOLS = "spools"
ARCHIVES = "archives"
class GitHubCommitInfo(BaseModel):
"""One commit in the backup repository."""
sha: str
message: str
author: str
date: str
class GitHubCommitListResponse(BaseModel):
"""Schema for the commit picker."""
success: bool
message: str
branch: str
commits: list[GitHubCommitInfo] = Field(default_factory=list)
class GitHubRestorePreviewCategory(BaseModel):
"""What a single category looks like inside one backup commit."""
category: RestoreCategory
available: bool = Field(description="Whether this category is present in the commit")
item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
detail: str | None = Field(default=None, description="Why unavailable, or extra context, in English")
detail_code: str | None = Field(
default=None, description="Key under backup.restoreFromGit.details, for the client to translate"
)
detail_params: dict[str, str | int] = Field(
default_factory=dict, description="Interpolation values for detail_code"
)
class GitHubRestorePreview(BaseModel):
"""Schema for inspecting a commit before restoring from it."""
success: bool
message: str
ref: str = Field(description="The concrete commit SHA that was inspected")
commit: GitHubCommitInfo | None = None
metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
class GitHubRestoreRequest(BaseModel):
"""Schema for triggering a restore."""
ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
overwrite_existing: bool = Field(
default=False,
description="Update rows that already exist locally. When false, only missing rows are inserted.",
)
@model_validator(mode="after")
def deduplicate_categories(self) -> "GitHubRestoreRequest":
# Same category twice would double-count the result totals.
seen: list[RestoreCategory] = []
for category in self.categories:
if category not in seen:
seen.append(category)
self.categories = seen
return self
class GitHubRestoreNote(BaseModel):
"""One tally note, as a translation code plus the values it interpolates.
Follows the ``backup.pathCheck`` contract already in use one card down in the
same component: the server chooses the code and supplies typed params, and
the client renders ``t(`...${code}`, { ...params, defaultValue: message })``.
``message`` is the English original, so a client that does not know a code
yet still shows something sensible rather than the raw key.
"""
code: str = Field(description="Key under backup.restoreFromGit.notes")
params: dict[str, str | int] = Field(default_factory=dict, description="Interpolation values for code")
message: str = Field(description="English rendering, used as the client's defaultValue")
class GitHubRestoreCategoryResult(BaseModel):
"""Per-category outcome of a restore."""
restored: int = 0
skipped: int = 0
failed: int = 0
notes: list[GitHubRestoreNote] = Field(default_factory=list)
class GitHubRestoreResponse(BaseModel):
"""Schema for the restore result."""
success: bool
message: str
log_id: int | None = None
ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)
@@ -76,3 +76,82 @@ class GitProviderBackend(ABC):
client: httpx.AsyncClient,
) -> dict:
"""Push files to the repository. Returns status/message/commit_sha/files_changed."""
# --- Read side (restore, issue #2656) ---------------------------------
# The backup path only ever writes. Restore needs to walk history, list a
# snapshot and read individual blobs back, so these three mirror the
# ``{"success": bool, "message": str, ...}`` convention ``test_connection``
# already uses rather than raising.
@abstractmethod
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch``, newest first.
Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
"""
@abstractmethod
async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
"""Read one commit's display metadata by SHA.
``list_commits`` only reaches back as far as its limit, so a ref outside
that window has no entry to describe it. This is the direct lookup for
that case.
Returns ``{"success", "message", "commit": {"sha", "message", "author",
"date"} | None}``.
"""
@abstractmethod
async def list_tree(
self,
repo_url: str,
token: str,
ref: str,
client: httpx.AsyncClient,
) -> dict:
"""List every blob path present at ``ref``.
``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
via :meth:`list_commits` first, so the snapshot being previewed and the
one being restored are provably the same commit even if a scheduled
backup lands in between.
Returns ``{"success", "message", "paths": [str], "blob_shas":
{path: sha}}``. ``blob_shas`` is the path -> blob SHA map the listing
already had to build, offered so :meth:`fetch_files` need not fetch the
same tree again; providers that read files by path return ``{}``.
"""
@abstractmethod
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
blob_shas: dict[str, str] | None = None,
) -> dict:
"""Read several files' decoded UTF-8 text at ``ref``.
Batched rather than one-file-at-a-time so providers that need a tree
listing to map path -> blob SHA can do that lookup once for the whole
restore instead of per file.
``blob_shas`` is the map :meth:`list_tree` returned for the same ref, if
the caller has one. Passing it saves a second recursive tree GET; a
provider that reads by path ignores it, and one that needs it fetches
the tree itself when it is absent.
Returns ``{"success", "message", "files": {path: text}}``. Paths absent
from the commit are simply missing from ``files`` — that is not an error,
since which categories a given backup contains varies by config.
"""
+106
View File
@@ -12,6 +12,11 @@ from backend.app.services.git_providers.github import GitHubBackend
logger = logging.getLogger(__name__)
# Gitea clamps per_page to MAX_RESPONSE_ITEMS, which defaults to 50. Consulted
# only when a tree response carries no usable total_count: a page at least this
# long may be a clamped full page and cannot be assumed to be the last one.
_ASSUMED_MIN_PAGE_SIZE = 50
class GiteaBackend(GitHubBackend):
"""Backend for Gitea instances.
@@ -100,6 +105,107 @@ class GiteaBackend(GitHubBackend):
headers["Accept"] = "application/json"
return headers
async def _blob_shas_at(
self,
client: httpx.AsyncClient,
headers: dict,
api_base: str,
owner: str,
repo: str,
ref: str,
) -> tuple[dict[str, str] | None, str]:
"""Paged override of GitHub's single-GET tree read (#2656).
Divergence four, alongside the three in the class docstring. GitHub's
recursive trees endpoint is not paginated and signals overflow with
``truncated: true``, which the inherited implementation hard-fails on.
Gitea and Forgejo *do* page the same endpoint — ``page``/``per_page``,
with ``total_count`` alongside the tree — so the inherited version would
read only the first page and then report every category beyond it as
absent from the commit. A restore that silently skips categories is the
exact failure the GitHub version refuses to allow, so this pages instead.
The cap mirrors GitLab's: reaching it means there are more pages, and
that is a failure rather than a partial result. Because the page size is
the server's choice rather than ours (see below), the cap is a page count
and not a file count.
"""
blobs: dict[str, str] = {}
seen = 0
page = 1
page_size: int | None = None
while page <= 50:
response = await client.get(
f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}",
headers=headers,
params={"recursive": "true", "page": page, "per_page": 1000},
)
if response.status_code == 404:
return None, f"Commit or tree '{ref}' not found in the repository"
if response.status_code != 200:
return None, (
f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
)
try:
data = response.json()
except ValueError:
return None, "Non-JSON response listing tree"
if not isinstance(data, dict):
return None, "Unexpected shape listing tree"
entries = data.get("tree")
if not isinstance(entries, list):
entries = []
for item in entries:
if not isinstance(item, dict) or item.get("type") != "blob":
continue
path, sha = item.get("path"), item.get("sha")
if isinstance(path, str) and isinstance(sha, str) and path and sha:
blobs[path] = sha
# total_count counts every entry, trees included, so compare against
# what came back rather than against len(blobs).
#
# Count what the server actually returned, never the per_page we
# asked for: Gitea clamps per_page to MAX_RESPONSE_ITEMS, which
# defaults to 50. Deriving the offset from the requested 1000 made
# page 2 report 1050 entries seen, which clears any total_count below
# that — so the loop stopped and returned the first two pages of a
# much larger tree as a success. The restore then read every missing
# path as "category not present in this commit" and skipped it
# silently, the exact failure this override exists to prevent.
total = data.get("total_count")
seen += len(entries)
if page_size is None:
page_size = max(len(entries), _ASSUMED_MIN_PAGE_SIZE)
if not entries:
return blobs, ""
if isinstance(total, int):
if seen >= total:
return blobs, ""
elif len(entries) < page_size:
# No usable total_count. This used to return here on the *first*
# page, i.e. fail open into a success holding whatever one page
# happened to be — 50 entries of an arbitrarily large tree under
# the default clamp — and the restore then reported every
# category beyond it as absent from the commit. Page until a
# short or empty page instead; the page-count ceiling below
# still gives the correct hard failure for a tree that really is
# too large. A page shorter than the first one (or than Gitea's
# default clamp, so a genuinely small tree stays one request)
# cannot be followed by another. The residual case is an
# instance whose MAX_RESPONSE_ITEMS is set *below* 50 and which
# also omits total_count; real Gitea and Forgejo always send it
# on this route.
return blobs, ""
page += 1
return None, (
"Repository tree exceeds the listing limit, so the backup contents cannot be "
"enumerated reliably. Rotate the backup repository."
)
async def push_files(
self,
repo_url: str,
@@ -115,6 +115,252 @@ class GitHubBackend(GitProviderBackend):
"is_private": None,
}
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch`` via the repo commits API."""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
# GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
# do the reverse. Sending both lets GiteaBackend inherit this method
# unchanged instead of duplicating it for one query parameter.
response = await client.get(
f"{api_base}/repos/{owner}/{repo}/commits",
headers=headers,
params={"sha": branch, "per_page": limit, "limit": limit},
)
if response.status_code == 404:
return {
"success": False,
"message": (
f"Branch '{branch}' not found, or the repository has no commits yet. "
"Run a backup before restoring."
),
"commits": [],
}
if response.status_code != 200:
msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("list_commits %s/%s: %s", owner, repo, msg)
return {"success": False, "message": msg, "commits": []}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
if not isinstance(data, list):
return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
except Exception as e:
logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
@staticmethod
def _parse_commit_entries(data: list, limit: int) -> list[dict]:
"""Normalise GitHub/Gitea commit list entries to our flat shape."""
commits = []
for entry in data[:limit]:
if not isinstance(entry, dict):
continue
sha = entry.get("sha")
if not isinstance(sha, str) or not sha:
continue
commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
commits.append(
{
"sha": sha,
"message": commit.get("message") or "",
"author": author.get("name") or "",
"date": author.get("date") or "",
}
)
return commits
async def _blob_shas_at(
self,
client: httpx.AsyncClient,
headers: dict,
api_base: str,
owner: str,
repo: str,
ref: str,
) -> tuple[dict[str, str] | None, str]:
"""Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
A commit SHA is a valid tree-ish for the trees API, so this resolves the
commit's tree in one request rather than commit -> tree -> list.
"""
response = await client.get(
f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
headers=headers,
)
if response.status_code == 404:
return None, f"Commit or tree '{ref}' not found in the repository"
if response.status_code != 200:
return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
try:
data = response.json()
except ValueError:
return None, "Non-JSON response listing tree"
# Same limit the push path guards against: a truncated listing would make
# a restore silently skip categories that are actually in the backup.
if data.get("truncated"):
return None, (
"Repository tree exceeds the API listing limit (truncated=true), so the backup "
"contents cannot be enumerated reliably. Rotate the backup repository."
)
blobs: dict[str, str] = {}
for item in data.get("tree", []):
if not isinstance(item, dict) or item.get("type") != "blob":
continue
path, sha = item.get("path"), item.get("sha")
if isinstance(path, str) and isinstance(sha, str) and path and sha:
blobs[path] = sha
return blobs, ""
async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
"""Read one commit's metadata directly, for refs outside the list window."""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
response = await client.get(f"{api_base}/repos/{owner}/{repo}/commits/{ref}", headers=headers)
if response.status_code == 404:
return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
if response.status_code != 200:
msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("get_commit %s/%s ref=%s: %s", owner, repo, ref, msg)
return {"success": False, "message": msg, "commit": None}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
if not isinstance(data, dict):
return {"success": False, "message": "Unexpected shape reading commit", "commit": None}
# Same entry shape as list_commits, so callers can treat the two
# interchangeably.
parsed = self._parse_commit_entries([data], 1)
if not parsed:
return {"success": False, "message": "Commit response carried no SHA", "commit": None}
return {"success": True, "message": "OK", "commit": parsed[0]}
except Exception as e:
logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
async def list_tree(
self,
repo_url: str,
token: str,
ref: str,
client: httpx.AsyncClient,
) -> dict:
"""List blob paths present at ``ref`` via the Git Data trees API."""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
if blobs is None:
logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error)
return {"success": False, "message": error, "paths": [], "blob_shas": {}}
# The map is handed back so fetch_files does not GET the same
# recursive tree a second time for the same ref.
return {"success": True, "message": "OK", "paths": sorted(blobs), "blob_shas": blobs}
except Exception as e:
logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
blob_shas: dict[str, str] | None = None,
) -> dict:
"""Read ``paths`` at ``ref`` via the Git Data blobs API.
The blobs API is used rather than the contents API because contents
inlines only files up to 1 MB — an archive-heavy ``print_history.json``
can exceed that, and it would come back with an empty body instead of an
error.
"""
try:
owner, repo = self.parse_repo_url(repo_url)
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
blobs = blob_shas
if blobs is None:
blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
if blobs is None:
logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
return {"success": False, "message": error, "files": {}}
files: dict[str, str] = {}
for path in paths:
sha = blobs.get(path)
if sha is None:
continue
response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
if response.status_code != 200:
msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
return {"success": False, "message": msg, "files": {}}
text, error = self._decode_blob(response, path)
if text is None:
logger.warning("fetch_files %s/%s: %s", owner, repo, error)
return {"success": False, "message": error, "files": {}}
files[path] = text
return {"success": True, "message": "OK", "files": files}
except Exception as e:
logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
"""Decode a blob API response body to text, or return an error message."""
try:
data = response.json()
except ValueError:
return None, f"Non-JSON response reading {path}"
if not isinstance(data, dict):
return None, f"Unexpected shape reading {path}"
content = data.get("content")
if not isinstance(content, str):
return None, f"Missing content reading {path}"
encoding = data.get("encoding", "base64")
try:
if encoding == "base64":
# Both providers wrap base64 payloads at 60 chars; b64decode
# tolerates the newlines, but be explicit about it.
return base64.b64decode(content).decode("utf-8"), ""
if encoding in ("utf-8", "text", "plain"):
return content, ""
except (ValueError, UnicodeDecodeError) as e:
return None, f"Could not decode {path}: {type(e).__name__}"
return None, f"Unsupported blob encoding {encoding!r} reading {path}"
async def push_files(
self,
repo_url: str,
@@ -115,6 +115,267 @@ class GitLabBackend(GitProviderBackend):
"is_private": None,
}
def _encoded_project(self, repo_url: str) -> str:
"""Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
owner, repo = self.parse_repo_url(repo_url)
return urllib.parse.quote(f"{owner}/{repo}", safe="")
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch`` via /repository/commits."""
try:
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = self._encoded_project(repo_url)
response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/commits",
headers=headers,
params={"ref_name": branch, "per_page": limit},
)
if response.status_code == 404:
return {
"success": False,
"message": (
f"Branch '{branch}' not found, or the repository has no commits yet. "
"Run a backup before restoring."
),
"commits": [],
}
if response.status_code != 200:
msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("list_commits %s: %s", repo_url, msg)
return {"success": False, "message": msg, "commits": []}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
if not isinstance(data, list):
return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
commits = []
for entry in data[:limit]:
if not isinstance(entry, dict):
continue
sha = entry.get("id")
if not isinstance(sha, str) or not sha:
continue
# GitLab flattens author/date onto the commit itself rather than
# nesting them under "commit" the way GitHub does.
commits.append(
{
"sha": sha,
"message": entry.get("message") or "",
"author": entry.get("author_name") or "",
"date": entry.get("committed_date") or entry.get("created_at") or "",
}
)
return {"success": True, "message": "OK", "commits": commits}
except Exception as e:
logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
"""Read one commit's metadata directly, for refs outside the list window."""
try:
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = self._encoded_project(repo_url)
response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/commits/{urllib.parse.quote(ref, safe='')}",
headers=headers,
)
if response.status_code == 404:
return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
if response.status_code != 200:
msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
logger.warning("get_commit %s ref=%s: %s", repo_url, ref, msg)
return {"success": False, "message": msg, "commit": None}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
sha = data.get("id") if isinstance(data, dict) else None
if not isinstance(sha, str) or not sha:
return {"success": False, "message": "Commit response carried no SHA", "commit": None}
# GitLab flattens author/date onto the commit, as in list_commits.
return {
"success": True,
"message": "OK",
"commit": {
"sha": sha,
"message": data.get("message") or "",
"author": data.get("author_name") or "",
"date": data.get("committed_date") or data.get("created_at") or "",
},
}
except Exception as e:
logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
async def list_tree(
self,
repo_url: str,
token: str,
ref: str,
client: httpx.AsyncClient,
) -> dict:
"""List blob paths at ``ref`` via /repository/tree, following pagination."""
try:
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = self._encoded_project(repo_url)
paths: list[str] = []
page = 1
complete = False
# GitLab's tree endpoint paginates instead of exposing a "truncated"
# flag, so walk pages until one comes back short. The page cap stops
# a malformed X-Next-Page loop from spinning forever — and reaching
# it is a failure, not a result: see the check after the loop.
while page <= 50:
response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/tree",
headers=headers,
params={"ref": ref, "recursive": "true", "per_page": 100, "page": page},
)
if response.status_code == 404:
return {
"success": False,
"message": f"Commit or tree '{ref}' not found in the repository",
"paths": [],
"blob_shas": {},
}
if response.status_code != 200:
msg = (
f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
)
logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
try:
data = response.json()
except ValueError:
return {"success": False, "message": "Non-JSON response listing tree", "paths": [], "blob_shas": {}}
if not isinstance(data, list):
return {"success": False, "message": "Unexpected shape listing tree", "paths": [], "blob_shas": {}}
for item in data:
if isinstance(item, dict) and item.get("type") == "blob":
path = item.get("path")
if isinstance(path, str) and path:
paths.append(path)
if len(data) < 100:
complete = True
break
page += 1
if not complete:
# Falling out of the loop means the last page was full and there
# are more. Returning success here would hand the restore a
# silently partial path list, and it would then report the
# categories it could not see as "not present in this commit" —
# the same failure GitHub's truncated=true check refuses to allow.
msg = (
"Repository tree exceeds the listing limit (more than 5000 files), so the backup "
"contents cannot be enumerated reliably. Rotate the backup repository."
)
logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
# GitLab reads files by path, so there is no blob-SHA map to share.
return {"success": True, "message": "OK", "paths": sorted(paths), "blob_shas": {}}
except Exception as e:
logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
blob_shas: dict[str, str] | None = None,
) -> dict:
"""Read ``paths`` at ``ref`` via /repository/files/{path}.
``blob_shas`` is accepted for interface parity and ignored: this backend
addresses files by path, so it never needed the tree listing that makes
the map worth passing.
"""
try:
api_base = self.get_api_base(repo_url)
headers = self.get_headers(token)
encoded_path = self._encoded_project(repo_url)
files: dict[str, str] = {}
for path in paths:
encoded_file = urllib.parse.quote(path, safe="")
response = await client.get(
f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
headers=headers,
params={"ref": ref},
)
# A path absent from this commit is expected — which categories a
# backup contains varies by config — so skip rather than fail.
if response.status_code == 404:
continue
if response.status_code != 200:
msg = (
f"Failed to read {path} (HTTP {response.status_code}): "
f"{self._truncated_response_text(response)}"
)
logger.warning("fetch_files %s: %s", repo_url, msg)
return {"success": False, "message": msg, "files": {}}
try:
data = response.json()
except ValueError:
return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
if not isinstance(data, dict):
return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
content = data.get("content")
if not isinstance(content, str):
return {"success": False, "message": f"Missing content reading {path}", "files": {}}
encoding = data.get("encoding", "base64")
try:
if encoding == "base64":
files[path] = base64.b64decode(content).decode("utf-8")
elif encoding in ("text", "utf-8", "plain"):
files[path] = content
else:
return {
"success": False,
"message": f"Unsupported encoding {encoding!r} reading {path}",
"files": {},
}
except (ValueError, UnicodeDecodeError) as e:
return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
return {"success": True, "message": "OK", "files": files}
except Exception as e:
logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
async def push_files(
self,
repo_url: str,
+50
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
@@ -805,6 +828,14 @@ class GitHubBackupService:
if not archives:
return
# The natural key for an owner. created_by_id alone is only meaningful on
# the instance that wrote it: restoring onto a rebuilt instance — this
# feature's main use case — renumbers the users table, so a live id can
# land on a different person. username is unique on users, so the restore
# can resolve on it and treat a rename as unknown rather than guess.
# One query for the map; archives outnumber users by orders of magnitude.
user_names = dict((await db.execute(select(User.id, User.username))).all())
archive_list = []
for a in archives:
archive_data = {
@@ -840,6 +871,25 @@ class GitHubBackupService:
"energy_kwh": a.energy_kwh,
"energy_cost": a.energy_cost,
"created_at": str(a.created_at) if a.created_at else None,
# Soft-deleted archives are collected too — their row is kept on
# purpose so the stats endpoint keeps counting their filament and
# energy (see archive_service.soft_delete_archive). Recording
# deleted_at is what lets a restore put them back the way they
# were instead of resurrecting them as visible archives.
"deleted_at": str(a.deleted_at) if a.deleted_at else None,
# Who owns the archive, for the same reason deleted_at is here:
# it is not decoration, it is what the access check runs on.
# _ensure_archive_visible (api/routes/archives.py) fails closed on
# a NULL created_by_id and the list paths filter on it, so a
# restored row without it is invisible to everyone but an admin —
# while the restore reports it restored.
"created_by_id": a.created_by_id,
# Preferred over the id on restore; the id stays as the fallback
# for an owner whose row has since gone. Null when the archive
# has no owner, or when it points at a user row that no longer
# exists locally — the same "absent is not null" rule the restore
# applies, so a backup can't claim an owner it cannot name.
"created_by_username": user_names.get(a.created_by_id),
}
archive_list.append(archive_data)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,674 @@
"""Integration tests for the Git backup restore API endpoints (#2656)."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
@pytest.fixture(autouse=True)
def _mock_private_repo_check():
"""POST /config refuses to save unless the repo is confirmed private."""
with patch(
"backend.app.services.github_backup.github_backup_service.test_connection",
new=AsyncMock(
return_value={
"success": True,
"message": "Connection successful",
"repo_name": "test/repo",
"permissions": {"push": True},
"is_private": True,
}
),
) as m:
yield m
async def _create_config(async_client: AsyncClient, token: str | None = None) -> dict:
response = await async_client.post(
"/api/v1/github-backup/config",
headers={"Authorization": f"Bearer {token}"} if token else {},
json={
"repository_url": "https://github.com/test/repo",
"access_token": "ghp_testtoken123",
"branch": "main",
"backup_kprofiles": True,
"backup_spools": True,
"backup_archives": True,
"backup_settings": True,
"enabled": True,
},
)
assert response.status_code == 200
return response.json()
class TestCommitsEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_404_when_not_configured(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/github-backup/commits")
assert response.status_code == 404
assert "Configure backup first" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_returns_commits_from_the_provider(self, async_client: AsyncClient):
await _create_config(async_client)
commits = [
{"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"}
]
with patch(
"backend.app.services.git_providers.github.GitHubBackend.list_commits",
new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}),
):
response = await async_client.get("/api/v1/github-backup/commits")
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert body["branch"] == "main"
assert body["commits"][0]["sha"] == "aaa1111"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient):
await _create_config(async_client)
with patch(
"backend.app.services.git_providers.github.GitHubBackend.list_commits",
new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}),
):
response = await async_client.get("/api/v1/github-backup/commits")
assert response.status_code == 200
assert response.json()["success"] is False
assert response.json()["commits"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_limit_is_bounded(self, async_client: AsyncClient):
await _create_config(async_client)
assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422
assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422
class TestPreviewEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_404_when_not_configured(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/github-backup/restore/preview")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reports_available_and_missing_categories(self, async_client: AsyncClient):
await _create_config(async_client)
preview = {
"success": True,
"message": "OK",
"ref": "aaa1111",
"commit": None,
"metadata_version": "1.0",
"categories": [
{"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"},
{"category": "settings", "available": True, "item_count": 12, "detail": None},
{"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"},
{"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"},
],
}
with patch(
"backend.app.services.github_restore.github_restore_service.preview",
new=AsyncMock(return_value=preview),
):
response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111")
assert response.status_code == 200
body = response.json()
assert body["metadata_version"] == "1.0"
by_name = {c["category"]: c for c in body["categories"]}
assert by_name["kprofiles"]["available"] is False
assert by_name["spools"]["item_count"] == 4
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"])
async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref):
await _create_config(async_client)
response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}")
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_to_head(self, async_client: AsyncClient):
await _create_config(async_client)
mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []})
with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock):
response = await async_client.get("/api/v1/github-backup/restore/preview")
assert response.status_code == 200
assert mock.await_args.kwargs["ref"] == "HEAD"
class TestRestoreEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_404_when_not_configured(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_applies_selected_categories(self, async_client: AsyncClient):
await _create_config(async_client)
outcome = {
"success": True,
"message": "Restored 5 item(s) from aaa1111",
"log_id": 3,
"ref": "aaa1111",
"results": {
"spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
"settings": {
"restored": 1,
"skipped": 2,
"failed": 0,
"notes": [
{
"code": "settingsCredentialsSkipped",
"params": {"count": 1},
"message": "1 credential-like key(s) skipped",
}
],
},
},
}
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value=outcome),
) as mock:
response = await async_client.post(
"/api/v1/github-backup/restore",
json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True},
)
assert response.status_code == 200
body = response.json()
assert body["results"]["spools"]["restored"] == 4
# Notes cross the wire as code + params + English fallback, so a
# non-English client can translate them (#2656).
assert body["results"]["settings"]["notes"] == [
{
"code": "settingsCredentialsSkipped",
"params": {"count": 1},
"message": "1 credential-like key(s) skipped",
}
]
assert mock.await_args.kwargs["overwrite_existing"] is True
assert mock.await_args.kwargs["ref"] == "aaa1111"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_empty_category_list(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_unknown_category(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_malformed_ref(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.post(
"/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]}
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_overwrite_to_false(self, async_client: AsyncClient):
"""The safe default: a restore only inserts what's missing."""
await _create_config(async_client)
mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock):
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
assert response.status_code == 200
assert mock.await_args.kwargs["overwrite_existing"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient):
await _create_config(async_client)
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(
return_value={
"success": False,
"message": "A backup is currently running. Wait for it to finish before restoring.",
"results": {},
}
),
):
response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
assert response.status_code == 200
assert response.json()["success"] is False
assert "backup is currently running" in response.json()["message"]
class TestStatusExposesRestoreState:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient):
await _create_config(async_client)
response = await async_client.get("/api/v1/github-backup/status")
assert response.status_code == 200
assert response.json()["restore_running"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_restore_running_is_reported(self, async_client: AsyncClient):
"""The UI disables both action buttons off this flag."""
await _create_config(async_client)
from backend.app.services.github_restore import github_restore_service
github_restore_service._running_restore = True
github_restore_service._progress = "Restoring spool inventory..."
try:
response = await async_client.get("/api/v1/github-backup/status")
finally:
github_restore_service._running_restore = False
github_restore_service._progress = None
assert response.json()["restore_running"] is True
assert response.json()["progress"] == "Restoring spool inventory..."
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/github-backup/status")
assert response.status_code == 200
assert response.json()["restore_running"] is False
class TestRestoredArchivesAreVisibleToTheirOwner(TestOwnershipPermissionsSetup):
"""The archive-ownership blocker, proved through the route that enforces it.
``_ensure_archive_visible`` fails closed on a NULL ``created_by_id`` — 404 for
any caller without ``archives:read_all`` — so before the collector and the
restore carried the column across, a multi-user instance got archives the
tally called restored and their owner could not open.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_owning_non_admin_can_open_a_restored_archive(
self, async_client: AsyncClient, auth_setup, db_session
):
from backend.app.models.archive import PrintArchive
from backend.app.services.github_restore import _CategoryTally, github_restore_service
owner_id = auth_setup["operator_user"]["id"]
payload = {
"archives": [
{
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"print_name": "Benchy",
"started_at": "2026-03-01 10:00:00",
"created_at": "2026-03-01 10:00:00",
"created_by_id": owner_id,
}
]
}
await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
restored = (await db_session.execute(select(PrintArchive))).scalar_one()
assert restored.id != 77, "the backup's primary key must not be reused"
response = await async_client.get(
f"/api/v1/archives/{restored.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 200, "the owner cannot see their own restored archive"
assert response.json()["print_name"] == "Benchy"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_different_operator_still_cannot(self, async_client: AsyncClient, auth_setup, db_session):
"""Control: carrying the owner across must not widen who can read it."""
from backend.app.models.archive import PrintArchive
from backend.app.services.github_restore import _CategoryTally, github_restore_service
payload = {
"archives": [
{
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"started_at": "2026-03-01 10:00:00",
"created_by_id": auth_setup["operator_user"]["id"],
}
]
}
await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
restored = (await db_session.execute(select(PrintArchive))).scalar_one()
response = await async_client.get(
f"/api/v1/archives/{restored.id}",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
)
assert response.status_code == 404
class TestRestoreDoesNotOpenTheMetricsEndpoint:
"""The companion-credential rule, proved against the endpoint it protects.
``/api/v1/metrics`` is on ``PUBLIC_API_ROUTES`` and its only gate is
``if token:``, so writing ``prometheus_enabled`` onto an instance with no
``prometheus_token`` row hands the entire metrics body to anyone who can
reach the port. The restore refuses that token as credential-shaped, so
before this change the pair came apart and the endpoint opened — with
overwrite *off*, since the local row is missing rather than present.
Driven through the real service and the real endpoint against one database:
the unit tests can show the toggle is not written, only this can show what
that means.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_restoring_prometheus_enabled_leaves_the_endpoint_shut(self, async_client: AsyncClient, db_session):
from backend.app.services.github_restore import _CategoryTally, github_restore_service
# An instance that never enabled Prometheus: no toggle row, no token row.
assert (await async_client.get("/api/v1/metrics")).status_code == 404
tally = _CategoryTally()
await github_restore_service._restore_settings(
db_session,
{"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret", "currency": "EUR"}},
overwrite=False,
tally=tally,
)
await db_session.commit()
response = await async_client.get("/api/v1/metrics")
assert response.status_code == 404, "a settings restore opened the metrics endpoint"
assert "bambuddy_build_info" not in response.text
assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"payload",
[
{"prometheus_enabled": "true", "currency": "EUR"},
{"prometheus_enabled": "true", "prometheus_token": "", "currency": "EUR"},
],
ids=["token-key-absent", "token-blank"],
)
async def test_a_token_less_backup_leaves_the_endpoint_shut_too(
self, async_client: AsyncClient, db_session, payload
):
"""The route the test above does not cover, and the likelier one.
``prometheus_token`` is optional, so an instance can enable Prometheus
without ever setting it. Such a backup carries the toggle and no usable
token — and because the companion rule's second condition asks whether
the *backup* had a credential, that payload used to sail straight past
the refusal and open the endpoint the case above proves shut.
"""
from backend.app.services.github_restore import _CategoryTally, github_restore_service
assert (await async_client.get("/api/v1/metrics")).status_code == 404
tally = _CategoryTally()
await github_restore_service._restore_settings(db_session, {"settings": payload}, overwrite=False, tally=tally)
await db_session.commit()
response = await async_client.get("/api/v1/metrics")
assert response.status_code == 404, "a token-less Prometheus backup opened the metrics endpoint"
assert "bambuddy_build_info" not in response.text
assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_instance_with_its_own_token_still_gets_the_toggle_back(
self, async_client: AsyncClient, db_session
):
"""Control. The rule must not break a legitimate Prometheus restore."""
from backend.app.services.github_restore import _CategoryTally, github_restore_service
await async_client.put(
"/api/v1/settings/", json={"prometheus_enabled": False, "prometheus_token": "local-token"}
)
await github_restore_service._restore_settings(
db_session,
{"settings": {"prometheus_enabled": "true", "prometheus_token": "s3cret"}},
overwrite=True,
tally=_CategoryTally(),
)
await db_session.commit()
assert (await async_client.get("/api/v1/metrics")).status_code == 401
authorised = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer local-token"})
assert authorised.status_code == 200
assert "bambuddy_build_info" in authorised.text
class TestSettingsRestoreNeedsSettingsUpdate(TestOwnershipPermissionsSetup):
"""A Backup-only role must not reach around the gate that owns the rows (#2656).
Each category rewrites rows some other endpoint already owns —
``PUT /api/v1/settings/`` gates on ``settings:update``, the inventory writes
on ``inventory:update``, an archive that is not yours on
``archives:update_all``, and the K-profile batch on ``kprofiles:update``.
Backup is its own permission group, so gating the restore endpoint on
``github:restore`` alone let a role holding only Backup write, through a
restore, what it could not write through the endpoint that owns them. This
module already makes that argument — it is why the four protected auth keys
are refused outright — so the gap was an inconsistency in ours.
Settings was gated first; the other three followed on review, because gating
one and not the rest is the only state that is not defensible.
"""
async def _token_for(self, async_client: AsyncClient, admin_token: str, name: str, permissions: list[str]) -> str:
headers = {"Authorization": f"Bearer {admin_token}"}
group = await async_client.post(
"/api/v1/groups/",
headers=headers,
json={"name": name, "permissions": permissions},
)
assert group.status_code == 201, group.text
created = await async_client.post(
"/api/v1/users/",
headers=headers,
json={"username": name, "password": "Restorepass1!", "group_ids": [group.json()["id"]]},
)
assert created.status_code in (200, 201), created.text
login = await async_client.post(
"/api/v1/auth/login",
json={"username": name, "password": "Restorepass1!"},
)
assert login.status_code == 200, login.text
return login.json()["access_token"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_backup_only_role_cannot_restore_settings(self, async_client: AsyncClient, auth_setup):
token = await self._token_for(
async_client, auth_setup["admin_token"], "backuponly", ["github:backup", "github:restore"]
)
await _create_config(async_client, auth_setup["admin_token"])
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
) as mock:
response = await async_client.post(
"/api/v1/github-backup/restore",
headers={"Authorization": f"Bearer {token}"},
json={"categories": ["settings"]},
)
assert response.status_code == 403
assert "settings:update" in response.json()["detail"]
mock.assert_not_awaited(), "the refusal has to happen before anything is written"
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
("category", "permission"),
[
("spools", "inventory:update"),
("archives", "archives:update_all"),
("kprofiles", "kprofiles:update"),
],
)
async def test_backup_only_role_cannot_restore_the_other_categories(
self, async_client: AsyncClient, auth_setup, category, permission
):
"""Same argument as settings: these rows have an owning permission too."""
token = await self._token_for(
async_client, auth_setup["admin_token"], f"backuponly-{category}", ["github:backup", "github:restore"]
)
await _create_config(async_client, auth_setup["admin_token"])
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
) as mock:
response = await async_client.post(
"/api/v1/github-backup/restore",
headers={"Authorization": f"Bearer {token}"},
json={"categories": [category]},
)
assert response.status_code == 403
assert permission in response.json()["detail"]
mock.assert_not_awaited(), "the refusal has to happen before anything is written"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_every_missing_permission_is_named_at_once(self, async_client: AsyncClient, auth_setup):
"""One round trip tells the caller everything to fix, not just the first.
A restore is a multi-select, so reporting one category at a time turns
picking four into four refusals.
"""
token = await self._token_for(
async_client, auth_setup["admin_token"], "backuponly-all", ["github:backup", "github:restore"]
)
await _create_config(async_client, auth_setup["admin_token"])
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
):
response = await async_client.post(
"/api/v1/github-backup/restore",
headers={"Authorization": f"Bearer {token}"},
json={"categories": ["settings", "spools", "archives", "kprofiles"]},
)
assert response.status_code == 403
detail = response.json()["detail"]
for permission in ("settings:update", "inventory:update", "archives:update_all", "kprofiles:update"):
assert permission in detail
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_gate_is_per_category_not_a_blanket_demotion(self, async_client: AsyncClient, auth_setup):
"""Control: holding one category's permission is enough to restore that one."""
token = await self._token_for(
async_client,
auth_setup["admin_token"],
"backupandinventory",
["github:backup", "github:restore", "inventory:read", "inventory:update"],
)
await _create_config(async_client, auth_setup["admin_token"])
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
):
response = await async_client.post(
"/api/v1/github-backup/restore",
headers={"Authorization": f"Bearer {token}"},
json={"categories": ["spools"]},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_every_restorable_category_has_an_owning_permission(self):
"""Guards the map against a category added without a gate.
A new ``RestoreCategory`` that is missing here is not a failing test
anywhere else — it simply restores under ``github:restore`` alone, which
is the hole this whole class exists to close.
"""
from backend.app.api.routes.github_backup import _CATEGORY_WRITE_PERMISSION
from backend.app.schemas.github_backup import RestoreCategory
assert set(_CATEGORY_WRITE_PERMISSION) == set(RestoreCategory)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_role_holding_both_can_restore_settings(self, async_client: AsyncClient, auth_setup):
"""Control: the gate must not lock out a role that legitimately holds both."""
token = await self._token_for(
async_client,
auth_setup["admin_token"],
"backupandsettings",
["github:backup", "github:restore", "settings:read", "settings:update"],
)
await _create_config(async_client, auth_setup["admin_token"])
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
):
response = await async_client.post(
"/api/v1/github-backup/restore",
headers={"Authorization": f"Bearer {token}"},
json={"categories": ["settings"]},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auth_disabled_is_unaffected(self, async_client: AsyncClient):
"""Control: with auth off there is no user to check, and the dep returns None."""
await _create_config(async_client)
with patch(
"backend.app.services.github_restore.github_restore_service.run_restore",
new=AsyncMock(return_value={"success": True, "message": "", "log_id": 1, "ref": "aaa1111", "results": {}}),
):
response = await async_client.post(
"/api/v1/github-backup/restore",
json={"categories": ["settings"]},
)
assert response.status_code == 200
@@ -0,0 +1,753 @@
"""Unit tests for the git_providers read side used by restore (#2656).
Covers list_commits / list_tree / fetch_files across all four providers,
including that Gitea and Forgejo inherit GitHub's Git Data API implementation
rather than needing their own.
"""
import base64
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.app.services.git_providers.forgejo import ForgejoBackend
from backend.app.services.git_providers.gitea import GiteaBackend
from backend.app.services.git_providers.github import GitHubBackend
from backend.app.services.git_providers.gitlab import GitLabBackend
def _make_mock_response(status_code: int, body=None, text: str = ""):
resp = MagicMock()
resp.status_code = status_code
resp.text = text
resp.json = MagicMock(return_value=body if body is not None else {})
return resp
def _b64(text: str) -> str:
return base64.b64encode(text.encode("utf-8")).decode()
def _github_commit(sha: str, message: str = "Bambuddy backup", date: str = "2026-07-01T10:00:00Z"):
return {"sha": sha, "commit": {"message": message, "author": {"name": "Bambuddy", "date": date}}}
class TestGitHubListCommits:
def setup_method(self):
self.backend = GitHubBackend()
self.repo_url = "https://github.com/owner/repo"
self.token = "ghp_token"
@pytest.mark.asyncio
async def test_returns_normalised_commits_newest_first(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200,
[
_github_commit("aaa111", "Bambuddy backup - newest", "2026-07-02T10:00:00Z"),
_github_commit("bbb222", "Bambuddy backup - older", "2026-07-01T10:00:00Z"),
],
)
)
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
assert result["success"] is True
assert [c["sha"] for c in result["commits"]] == ["aaa111", "bbb222"]
assert result["commits"][0]["message"] == "Bambuddy backup - newest"
assert result["commits"][0]["author"] == "Bambuddy"
assert result["commits"][0]["date"] == "2026-07-02T10:00:00Z"
@pytest.mark.asyncio
async def test_sends_both_per_page_and_limit(self):
"""GitHub honours per_page, Gitea honours limit — one call must carry both
so GiteaBackend can inherit this method unchanged."""
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, []))
await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=7)
params = client.get.await_args.kwargs["params"]
assert params["per_page"] == 7
assert params["limit"] == 7
assert params["sha"] == "main"
@pytest.mark.asyncio
async def test_respects_limit_even_if_provider_overshoots(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit(f"sha{i}") for i in range(10)]))
result = await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=3)
assert len(result["commits"]) == 3
@pytest.mark.asyncio
async def test_404_explains_empty_repository(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
result = await self.backend.list_commits(self.repo_url, self.token, "nope", client)
assert result["success"] is False
assert "no commits yet" in result["message"]
assert result["commits"] == []
@pytest.mark.asyncio
async def test_skips_entries_without_a_sha(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(200, [{"commit": {"message": "no sha"}}, _github_commit("good")])
)
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
assert [c["sha"] for c in result["commits"]] == ["good"]
@pytest.mark.asyncio
async def test_non_list_body_is_an_error_not_a_crash(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"}))
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
assert result["success"] is False
assert "Unexpected shape" in result["message"]
class TestGetCommit:
"""A ref older than the list window still needs a subject line and a date."""
@pytest.mark.asyncio
async def test_github_reads_one_commit_by_sha(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, _github_commit("abc1234567")))
result = await GitHubBackend().get_commit("https://github.com/owner/repo", "tok", "abc1234567", client)
assert result["success"] is True
assert result["commit"] == {
"sha": "abc1234567",
"message": "Bambuddy backup",
"author": "Bambuddy",
"date": "2026-07-01T10:00:00Z",
}
assert "repos/owner/repo/commits/abc1234567" in client.get.await_args.args[0]
@pytest.mark.asyncio
async def test_github_404_names_the_ref(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
result = await GitHubBackend().get_commit("https://github.com/owner/repo", "tok", "deadbee", client)
assert result["success"] is False
assert result["commit"] is None
assert "deadbee" in result["message"]
@pytest.mark.asyncio
async def test_gitlab_reads_its_flattened_shape(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200,
{
"id": "abc1234567",
"message": "Bambuddy backup",
"author_name": "Bambuddy",
"committed_date": "2026-07-02T10:00:00Z",
},
)
)
result = await GitLabBackend().get_commit("https://gitlab.com/owner/repo", "tok", "abc1234567", client)
assert result["commit"]["author"] == "Bambuddy"
assert result["commit"]["date"] == "2026-07-02T10:00:00Z"
@pytest.mark.asyncio
async def test_gitlab_404_names_the_ref(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
result = await GitLabBackend().get_commit("https://gitlab.com/owner/repo", "tok", "deadbee", client)
assert result["success"] is False
assert "deadbee" in result["message"]
class TestGitHubListTree:
def setup_method(self):
self.backend = GitHubBackend()
self.repo_url = "https://github.com/owner/repo"
self.token = "ghp_token"
@pytest.mark.asyncio
async def test_returns_sorted_blob_paths_only(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200,
{
"tree": [
{"type": "blob", "path": "spools/inventory.json", "sha": "s1"},
{"type": "tree", "path": "spools", "sha": "d1"},
{"type": "blob", "path": "backup_metadata.json", "sha": "m1"},
]
},
)
)
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert result["success"] is True
assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"]
@pytest.mark.asyncio
async def test_truncated_tree_fails_loudly(self):
"""A truncated listing would make restore silently miss categories."""
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True}))
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert result["success"] is False
assert "truncated" in result["message"]
@pytest.mark.asyncio
async def test_404_names_the_missing_ref(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client)
assert result["success"] is False
assert "deadbee" in result["message"]
class TestGitHubFetchFiles:
def setup_method(self):
self.backend = GitHubBackend()
self.repo_url = "https://github.com/owner/repo"
self.token = "ghp_token"
@pytest.mark.asyncio
async def test_reads_requested_paths_via_blob_api(self):
tree = _make_mock_response(
200,
{
"tree": [
{"type": "blob", "path": "a.json", "sha": "sha-a"},
{"type": "blob", "path": "b.json", "sha": "sha-b"},
]
},
)
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
tree,
_make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}),
]
)
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
assert result["success"] is True
assert result["files"] == {"a.json": '{"a": 1}'}
# One tree listing regardless of how many files are read.
assert client.get.await_count == 2
@pytest.mark.asyncio
async def test_lists_the_tree_once_for_many_files(self):
tree = _make_mock_response(
200,
{
"tree": [
{"type": "blob", "path": "a.json", "sha": "sha-a"},
{"type": "blob", "path": "b.json", "sha": "sha-b"},
]
},
)
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
tree,
_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}),
_make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}),
]
)
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client)
assert result["files"] == {"a.json": "1", "b.json": "2"}
assert client.get.await_count == 3
@pytest.mark.asyncio
async def test_a_supplied_blob_map_skips_the_second_tree_read(self):
"""list_tree already fetched this; fetching it again was a wasted GET."""
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}))
result = await self.backend.fetch_files(
self.repo_url, self.token, "abc1234", ["a.json"], client, blob_shas={"a.json": "sha-a"}
)
assert result["files"] == {"a.json": "1"}
# The blob read and nothing else.
assert client.get.await_count == 1
assert "git/blobs/sha-a" in client.get.await_args.args[0]
@pytest.mark.asyncio
async def test_list_tree_hands_back_the_map_it_built(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200,
{
"tree": [
{"type": "blob", "path": "a.json", "sha": "sha-a"},
{"type": "tree", "path": "dir", "sha": "sha-d"},
]
},
)
)
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert result["blob_shas"] == {"a.json": "sha-a"}
@pytest.mark.asyncio
async def test_missing_path_is_skipped_not_an_error(self):
"""Which categories a backup contains varies by config, so an absent
path is expected rather than a failure."""
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
assert result["success"] is True
assert result["files"] == {}
@pytest.mark.asyncio
async def test_blob_error_fails_the_whole_read(self):
tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
client = AsyncMock()
client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")])
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
assert result["success"] is False
assert "a.json" in result["message"]
assert result["files"] == {}
@pytest.mark.asyncio
async def test_utf8_content_survives_round_trip(self):
payload = '{"color_name": "Jadeweiß", "note": "日本語"}'
tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
client = AsyncMock()
client.get = AsyncMock(
side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})]
)
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
assert result["files"]["a.json"] == payload
@pytest.mark.asyncio
async def test_unsupported_encoding_is_reported(self):
tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
client = AsyncMock()
client.get = AsyncMock(
side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})]
)
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
assert result["success"] is False
assert "Unsupported blob encoding" in result["message"]
class TestGiteaAndForgejoInheritReads:
"""Gitea overrides the *write* path, plus the one read that genuinely differs."""
@pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
def test_read_methods_are_not_overridden(self, backend_cls):
for method in ("list_commits", "list_tree", "fetch_files", "get_commit"):
assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
@pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
def test_the_tree_read_is_paged_rather_than_inherited(self, backend_cls):
"""GitHub's trees endpoint is not paginated; Gitea's is (#2656)."""
assert backend_cls._blob_shas_at is not GitHubBackend._blob_shas_at
@pytest.mark.asyncio
@pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
async def test_a_paged_tree_is_read_to_the_end(self, backend_cls):
"""Inheriting GitHub's single GET read only the first page.
The rest of the backup then looked absent from the commit, and the
preview reported those categories as "not present" — a restore silently
skipping data, which is exactly what GitHub's truncated=true check
exists to prevent.
"""
page1 = {
"tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(1000)],
"total_count": 1002,
}
page2 = {
"tree": [
{"type": "blob", "path": "settings/app_settings.json", "sha": "sx"},
{"type": "tree", "path": "settings", "sha": "dx"},
],
"total_count": 1002,
}
client = AsyncMock()
client.get = AsyncMock(side_effect=[_make_mock_response(200, page1), _make_mock_response(200, page2)])
result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is True
assert client.get.await_count == 2
assert "settings/app_settings.json" in result["paths"]
assert len(result["paths"]) == 1001
@pytest.mark.asyncio
async def test_a_single_page_tree_costs_one_request(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200, {"tree": [{"type": "blob", "path": "a.json", "sha": "s1"}], "total_count": 1}
)
)
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["paths"] == ["a.json"]
assert client.get.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
async def test_a_clamped_page_size_is_still_read_to_the_end(self, backend_cls):
"""Gitea clamps per_page to MAX_RESPONSE_ITEMS — 50 by default (#2656).
Paging off the *requested* 1000 made page 2 believe it had seen 1050
entries, which clears any total_count below that. The loop then returned
the first 100 entries of a 120-entry tree as a success, and the restore
reported the categories it could not see as absent from the commit.
"""
clamped = 50
total = 120
pages = []
for start in range(0, total, clamped):
count = min(clamped, total - start)
pages.append(
_make_mock_response(
200,
{
"tree": [
{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(start, start + count)
],
"total_count": total,
},
)
)
client = AsyncMock()
client.get = AsyncMock(side_effect=pages)
result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is True
assert client.get.await_count == 3
assert len(result["paths"]) == total
assert "f119.json" in result["paths"], "the tail of the tree is what a clamped pager loses"
# --- a response with no usable total_count must not fail open -----------
#
# The pager used to short-circuit into a *success* holding page 1 whenever
# total_count was missing or not an int — 50 entries of an arbitrarily large
# tree under Gitea's default clamp. The restore then reported the categories
# it could not see as "not present in this backup commit", the same silent
# skip this whole override exists to prevent. GitHub and GitLab both
# hard-fail in the equivalent spot; only Gitea guessed.
@staticmethod
def _page(start, count, **extra):
return _make_mock_response(
200,
{
"tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(start, start + count)],
**extra,
},
)
@pytest.mark.asyncio
@pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
async def test_a_countless_response_is_paged_to_the_end(self, backend_cls):
client = AsyncMock()
client.get = AsyncMock(side_effect=[self._page(0, 50), self._page(50, 50), self._page(100, 0)])
result = await backend_cls().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is True
assert client.get.await_count == 3
assert len(result["paths"]) == 100
assert "f99.json" in result["paths"], "the tail is what a fail-open pager loses"
@pytest.mark.asyncio
async def test_a_countless_short_page_ends_the_paging(self):
client = AsyncMock()
client.get = AsyncMock(side_effect=[self._page(0, 50), self._page(50, 7)])
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is True
assert client.get.await_count == 2
assert len(result["paths"]) == 57
@pytest.mark.asyncio
async def test_a_countless_single_page_tree_still_costs_one_request(self):
"""Control: a small tree must not pay for the fix."""
client = AsyncMock()
client.get = AsyncMock(return_value=self._page(0, 3))
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["paths"] == ["f0.json", "f1.json", "f2.json"]
assert client.get.await_count == 1
@pytest.mark.asyncio
async def test_a_non_int_total_count_is_treated_as_no_count(self):
"""The arm the code was written to defend against, and then trusted."""
client = AsyncMock()
client.get = AsyncMock(side_effect=[self._page(0, 50, total_count="120"), self._page(50, 4)])
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is True
assert client.get.await_count == 2
assert len(result["paths"]) == 54
@pytest.mark.asyncio
async def test_a_countless_tree_beyond_the_page_cap_still_fails(self):
"""The page ceiling is what keeps "page until short" from truncating."""
client = AsyncMock()
client.get = AsyncMock(return_value=self._page(0, 1000))
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is False
assert "listing limit" in result["message"]
@pytest.mark.asyncio
async def test_a_tree_beyond_the_page_cap_fails_rather_than_truncating(self):
page = {"tree": [{"type": "blob", "path": f"f{i}.json", "sha": f"s{i}"} for i in range(1000)]}
page["total_count"] = 10_000_000
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, page))
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "abc1234", client)
assert result["success"] is False
assert "listing limit" in result["message"]
@pytest.mark.asyncio
async def test_a_missing_ref_is_still_named(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
result = await GiteaBackend().list_tree("https://git.example.com/owner/repo", "tok", "deadbee", client)
assert result["success"] is False
assert "deadbee" in result["message"]
@pytest.mark.asyncio
async def test_gitea_list_commits_uses_its_own_api_base(self):
backend = GiteaBackend()
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")]))
result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client)
assert result["success"] is True
url = client.get.await_args.args[0]
assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits")
@pytest.mark.asyncio
async def test_gitea_subpath_install_is_respected(self):
"""Gitea/Forgejo behind a ROOT_URL sub-path (#2642)."""
backend = GiteaBackend()
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "total_count": 0}))
await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client)
url = client.get.await_args.args[0]
assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url
class TestGitLabReads:
def setup_method(self):
self.backend = GitLabBackend()
self.repo_url = "https://gitlab.com/owner/repo"
self.token = "glpat-test"
@pytest.mark.asyncio
async def test_list_commits_reads_flattened_author_fields(self):
"""GitLab puts message/author/date on the entry, not under 'commit'."""
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200,
[
{
"id": "abc123",
"message": "Bambuddy backup",
"author_name": "Bambuddy",
"committed_date": "2026-07-02T10:00:00Z",
}
],
)
)
result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
assert result["success"] is True
assert result["commits"] == [
{
"sha": "abc123",
"message": "Bambuddy backup",
"author": "Bambuddy",
"date": "2026-07-02T10:00:00Z",
}
]
@pytest.mark.asyncio
async def test_list_commits_uses_ref_name(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, []))
await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5)
params = client.get.await_args.kwargs["params"]
assert params["ref_name"] == "bambuddy-backup"
assert params["per_page"] == 5
@pytest.mark.asyncio
async def test_subgroup_path_is_url_encoded(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, []))
await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client)
url = client.get.await_args.args[0]
assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url
@pytest.mark.asyncio
async def test_list_tree_returns_blob_paths(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(
200,
[
{"type": "blob", "path": "spools/inventory.json"},
{"type": "tree", "path": "spools"},
],
)
)
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert result["success"] is True
assert result["paths"] == ["spools/inventory.json"]
@pytest.mark.asyncio
async def test_list_tree_follows_pagination(self):
"""GitLab paginates instead of exposing a truncated flag."""
full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
client = AsyncMock()
client.get = AsyncMock(
side_effect=[
_make_mock_response(200, full_page),
_make_mock_response(200, [{"type": "blob", "path": "last.json"}]),
]
)
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert client.get.await_count == 2
assert len(result["paths"]) == 101
assert "last.json" in result["paths"]
@pytest.mark.asyncio
async def test_hitting_the_page_cap_is_a_failure_not_a_partial_list(self):
"""The mirror image of GitHub's truncated=true check.
Falling out of the `while page <= 50` condition used to return
success: True with a silently partial path list, which the restore then
reported as "those categories are not present in this commit" — data
skipped without anyone being told.
"""
full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, full_page))
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert result["success"] is False
assert result["paths"] == []
assert "cannot be enumerated reliably" in result["message"]
@pytest.mark.asyncio
async def test_list_tree_returns_no_blob_map(self):
"""GitLab reads files by path, so there is nothing to share."""
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, [{"type": "blob", "path": "a.json"}]))
result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
assert result["blob_shas"] == {}
@pytest.mark.asyncio
async def test_fetch_files_ignores_a_blob_map(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}))
result = await self.backend.fetch_files(
self.repo_url, self.token, "abc1234", ["a.json"], client, blob_shas={"a.json": "irrelevant"}
)
assert result["files"] == {"a.json": "1"}
assert "repository/files/a.json" in client.get.await_args.args[0]
@pytest.mark.asyncio
async def test_fetch_files_decodes_base64(self):
client = AsyncMock()
client.get = AsyncMock(
return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"})
)
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
assert result["success"] is True
assert result["files"] == {"a.json": '{"k": 1}'}
@pytest.mark.asyncio
async def test_fetch_files_encodes_nested_path(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"}))
await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client)
url = client.get.await_args.args[0]
assert "repository/files/spools%2Finventory.json" in url
@pytest.mark.asyncio
async def test_fetch_files_skips_404(self):
client = AsyncMock()
client.get = AsyncMock(return_value=_make_mock_response(404, {}))
result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
assert result["success"] is True
assert result["files"] == {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
/**
* Backup History must distinguish a restore from a backup (#2656).
*
* A restore writes a `github_backup_logs` row too — same table, same statuses,
* `trigger: 'restore'`. The table rendered date / status / commit only, so the
* row read as a successful backup dated now, while "Last backup" said something
* else entirely. The column below is the only thing telling the two apart.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, within } from '@testing-library/react';
import { render } from '../utils';
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getGitHubBackupConfig: vi.fn().mockResolvedValue({
id: 1,
repository_url: 'https://github.com/someone/backup',
enabled: true,
}),
getGitHubBackupStatus: vi.fn().mockResolvedValue({ is_running: false, configured: true, enabled: true }),
getGitHubBackupLogs: vi.fn(),
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
getPrinters: vi.fn().mockResolvedValue([]),
getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getLocalBackups: vi.fn().mockResolvedValue([]),
getLocalBackupStatus: vi.fn().mockResolvedValue({
enabled: false,
is_running: false,
last_backup_at: null,
last_status: null,
last_message: null,
next_run: null,
}),
checkLocalBackupPath: vi.fn().mockResolvedValue({ writable: true, path: '/data', code: 'ok' }),
},
}));
const log = (id: number, trigger: string) => ({
id,
config_id: 1,
started_at: '2026-08-02T09:00:00',
completed_at: '2026-08-02T09:00:05',
status: 'success',
trigger,
commit_sha: null,
files_changed: 0,
error_message: null,
});
const historyRows = async () => {
const table = (await screen.findByText('History')).closest('div[id="card-backup-history"]');
return within(table as HTMLElement).getAllByRole('row').slice(1); // drop the header
};
describe('GitHubBackupSettings — backup history', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('labels a restore as a restore, not a successful backup', async () => {
vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'restore')]);
render(<GitHubBackupSettings />);
const [row] = await historyRows();
expect(within(row).getByText('Restore')).toBeInTheDocument();
});
it('tells the three trigger kinds apart in one history', async () => {
vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([
log(1, 'restore'),
log(2, 'scheduled'),
log(3, 'manual'),
]);
render(<GitHubBackupSettings />);
const rows = await historyRows();
expect(within(rows[0]).getByText('Restore')).toBeInTheDocument();
expect(within(rows[1]).getByText('Backup (scheduled)')).toBeInTheDocument();
expect(within(rows[2]).getByText('Backup (manual)')).toBeInTheDocument();
});
it('falls back to the raw trigger rather than blanking an unknown one', async () => {
vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'something-new')]);
render(<GitHubBackupSettings />);
const [row] = await historyRows();
expect(within(row).getByText('something-new')).toBeInTheDocument();
});
});
@@ -0,0 +1,115 @@
/**
* The Git Restore button must respect github:restore client-side (#2656).
*
* All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
* user without it gets a 403 the moment the modal opens its preview. Offering
* the button anyway is an action that cannot work.
*
* Scoped to the button on purpose: the backup card itself stays visible,
* because configuring backups is a separate permission.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
import { setAuthToken } from '../../api/client';
afterEach(() => {
server.resetHandlers();
setAuthToken(null);
});
/** A configured backup, which is what makes the action row render at all. */
function mockConfiguredBackup() {
server.use(
http.get('*/api/v1/github-backup/config', () =>
HttpResponse.json({
id: 1,
provider: 'github',
repository_url: 'https://github.com/test/repo',
branch: 'main',
enabled: true,
schedule_enabled: false,
schedule_type: 'daily',
schedule_time: '02:00',
backup_kprofiles: true,
backup_cloud_profiles: false,
backup_spools: true,
backup_archives: true,
backup_settings: true,
last_backup_at: null,
last_backup_status: null,
}),
),
http.get('*/api/v1/github-backup/status', () =>
HttpResponse.json({
configured: true,
enabled: true,
is_running: false,
restore_running: false,
progress: null,
last_backup_at: null,
last_backup_status: null,
next_run: null,
}),
),
http.get('*/api/v1/github-backup/logs', () => HttpResponse.json([])),
);
}
function mockUserWith(permissions: string[]) {
setAuthToken('test-token', 'session');
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('*/api/v1/auth/me', () =>
HttpResponse.json({ id: 1, username: 'operator', is_admin: false, permissions }),
),
);
}
describe('GitHubBackupSettings - github:restore gate', () => {
it('hides the Restore from Git button without the permission', async () => {
mockConfiguredBackup();
mockUserWith(['settings:read', 'settings:update']);
render(<GitHubBackupSettings />);
// Wait for the action row itself, so an absent button is a real absence
// rather than the card simply not having rendered yet.
await waitFor(() => expect(screen.getByRole('button', { name: /Backup Now/i })).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /Restore from Git/i })).not.toBeInTheDocument();
});
it('shows it when the user has github:restore', async () => {
mockConfiguredBackup();
mockUserWith(['settings:read', 'github:restore']);
render(<GitHubBackupSettings />);
await waitFor(() =>
expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
);
});
it('shows it when auth is disabled entirely', async () => {
// hasPermission returns true with auth off, and it must stay that way -
// a single-user instance has no permissions to grant.
mockConfiguredBackup();
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: false, requires_setup: false }),
),
);
render(<GitHubBackupSettings />);
await waitFor(() =>
expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,696 @@
/**
* Tests for the Restore from Git Backup modal (#2656).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { QueryClient } from '@tanstack/react-query';
import { render } from '../utils';
import { server } from '../mocks/server';
import { GitHubRestoreModal } from '../../components/GitHubRestoreModal';
const mockCommits = {
success: true,
message: 'OK',
branch: 'main',
commits: [
{
sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC',
author: 'Bambuddy',
date: '2026-07-02T10:00:00Z',
},
{
sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111',
message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC',
author: 'Bambuddy',
date: '2026-07-01T10:00:00Z',
},
],
};
const mockPreview = {
success: true,
message: 'OK',
ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
commit: mockCommits.commits[0],
metadata_version: '1.0',
// The server describes each caveat as a code plus typed params, carrying the
// English rendering as `detail` for i18next's defaultValue (#2656). Note the
// fixture's English deliberately differs from en.ts, so an assertion on the
// locale string proves the code was translated rather than echoed.
categories: [
{
category: 'archives',
available: true,
item_count: 30,
detail: 'raw server English, should not be rendered',
detail_code: 'archivesMetadataOnly',
detail_params: {},
},
{ category: 'spools', available: true, item_count: 4, detail: null, detail_code: null, detail_params: {} },
{ category: 'settings', available: true, item_count: 12, detail: null, detail_code: null, detail_params: {} },
{
category: 'kprofiles',
available: false,
item_count: 0,
detail: 'raw server English, should not be rendered',
detail_code: 'notPresent',
detail_params: {},
},
],
};
// The default fixture has no K-profiles in the commit, which is the one category
// whose row cannot be selected there.
const mockPreviewWithKprofiles = {
...mockPreview,
categories: mockPreview.categories.map((c) =>
c.category === 'kprofiles'
? { category: 'kprofiles', available: true, item_count: 3, detail: null, detail_code: null, detail_params: {} }
: c
),
};
type JsonBody = Record<string, unknown>;
function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
server.use(
http.get('/api/v1/github-backup/commits', () =>
HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
),
http.get('/api/v1/github-backup/restore/preview', () =>
HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
),
);
}
describe('GitHubRestoreModal', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEndpoints();
});
it('renders the title and commit picker', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
});
expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
});
it('defaults to the latest commit and lists recent commits', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
expect(select.value).toBe('HEAD');
await waitFor(() => {
expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
});
// Commits are labelled by short SHA.
await waitFor(() => {
expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
});
});
it('shows item counts for categories present in the commit', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('30 in backup')).toBeInTheDocument();
});
expect(screen.getByText('4 in backup')).toBeInTheDocument();
expect(screen.getByText('12 in backup')).toBeInTheDocument();
});
it('translates preview caveats rather than echoing the server English', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(
screen.getByText('Metadata only - 3MF files and thumbnails are not in a Git backup')
).toBeInTheDocument();
});
expect(screen.queryAllByText('raw server English, should not be rendered')).toHaveLength(0);
});
it('falls back to the server English for a code it does not know', async () => {
// A newer backend adding a detail_code this build has no key for must not
// print the raw key at the user. Same defaultValue arm backup.pathCheck uses.
mockEndpoints({
preview: {
...mockPreview,
categories: [
{
category: 'spools',
available: true,
item_count: 4,
detail: 'Something a future release explains',
detail_code: 'somethingThisBuildHasNeverHeardOf',
detail_params: {},
},
],
},
});
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Something a future release explains')).toBeInTheDocument();
});
});
it('disables a category that is absent from the commit', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
});
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
// Four categories in fixed order: archives, spools, settings, kprofiles.
expect(checkboxes).toHaveLength(4);
expect(checkboxes[3].disabled).toBe(true);
expect(checkboxes[0].disabled).toBe(false);
});
it('keeps Restore disabled until a category is selected', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
expect(restoreButton).toBeDisabled();
// Wait for the preview to populate the category list before selecting.
const checkboxes = await waitFor(() => {
const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
expect(found).toHaveLength(4);
return found;
});
await userEvent.click(checkboxes[1]);
await waitFor(() => expect(restoreButton).not.toBeDisabled());
expect(screen.getByText('1 selected')).toBeInTheDocument();
});
it('requires confirmation before sending the restore', async () => {
let restoreCalls = 0;
server.use(
http.post('/api/v1/github-backup/restore', async () => {
restoreCalls += 1;
return HttpResponse.json({
success: true,
message: 'Restored 4 item(s) from aaa1111',
log_id: 3,
ref: mockPreview.ref,
results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
});
})
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
// Confirm dialog appears; nothing sent yet.
await waitFor(() => {
expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
});
expect(restoreCalls).toBe(0);
});
it('sends the selected categories and shows per-category results', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/v1/github-backup/restore', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
success: true,
message: 'Restored 4 item(s) from aaa1111',
log_id: 3,
ref: mockPreview.ref,
results: {
spools: {
restored: 4,
skipped: 1,
failed: 0,
notes: [
{
code: 'spoolUsageUnresolved',
params: { count: 1 },
message: 'raw server English, should not be rendered',
},
],
},
},
});
})
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
});
// The commit posted is the sha the preview resolved to, not the symbolic
// 'HEAD' the picker defaults to: re-resolving server-side would restore a
// backup that landed after the preview the user actually approved.
expect(body).toMatchObject({
categories: ['spools'],
overwrite_existing: false,
ref: mockPreview.ref,
});
expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
// The locale string with {{count}} filled in, not the server's English —
// which is what makes the note translatable for a non-English user.
expect(
screen.getByText(/^1 usage record\(s\) skipped - their spool is not in this backup's spool list/)
).toBeInTheDocument();
expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument();
});
it('drops the selection while a newly-picked commit is still being inspected', async () => {
// Switching commits keeps `selected` (it is only pruned once the new preview
// lands), so the footer must not keep counting it: the categories belong to
// the commit that was switched away from, and the user has not seen an item
// count for the new one.
let previewCalls = 0;
server.use(
http.get('/api/v1/github-backup/restore/preview', async () => {
previewCalls += 1;
// The second commit's preview never resolves, holding the modal in the
// in-flight state the assertions below describe.
if (previewCalls > 1) await delay('infinite');
return HttpResponse.json(mockPreview as unknown as JsonBody);
})
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument());
await userEvent.selectOptions(screen.getByLabelText('Backup commit'), mockCommits.commits[1].sha);
await waitFor(() => expect(screen.getByText('Reading backup contents...')).toBeInTheDocument());
expect(screen.getByText('0 selected')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restore$/ })).toBeDisabled();
});
it('sends overwrite_existing when the toggle is on', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/v1/github-backup/restore', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
})
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('switch'));
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
});
it('warns more strongly when overwrite is enabled', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('switch'));
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => {
expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
});
});
// Overwrite-off says existing entries stay as they are. K-profiles are the one
// category that cannot honour that — writing a slot always replaces the
// calibration on the printer — and the backend's note saying so only arrives
// in the result panel, after the MQTT send. So the disclosure has to be on the
// screen where the promise is made, before the user commits to it.
describe('the K-profile exception to overwrite-off', () => {
beforeEach(() => {
mockEndpoints({ preview: mockPreviewWithKprofiles as unknown as JsonBody });
});
it('appears beside the category as soon as it is selected', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[3]);
await waitFor(() => {
expect(screen.getByText(/K-profiles are the exception/)).toBeInTheDocument();
});
// And it goes once overwrite is on, where nothing is promising otherwise.
await userEvent.click(screen.getByRole('switch'));
expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
});
it('is part of the confirmation the user actually clicks through', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[3]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
expect(
screen.getByText(/existing entries stay as they are\. K-profiles are the exception/)
).toBeInTheDocument();
});
it('stays out of the confirmation for the categories that do keep the promise', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
expect(screen.getByText(/existing entries stay as they are\.$/)).toBeInTheDocument();
expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
});
it('is redundant with overwrite on, so it is not shown there', async () => {
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[3]);
await userEvent.click(screen.getByRole('switch'));
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText(/This cannot be undone/));
expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
});
});
it('surfaces a preview failure instead of an empty category list', async () => {
mockEndpoints({
preview: {
success: false,
message: 'Commit or tree deadbee not found in the repository',
ref: 'deadbee',
categories: [],
},
});
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
});
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('surfaces a commit listing failure', async () => {
mockEndpoints({
commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
});
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Invalid access token')).toBeInTheDocument();
});
});
// A refused restore answers 200 with `success: false` and an empty `results`,
// and two of the five refusals are ordinary conditions rather than errors — a
// restore already running, and a backup mid-flight. Rendering the result panel
// for those put a green tick and "reload so the restored data appears" above a
// message saying nothing had been restored, i.e. a failure that read as a
// success. Empty `results` is the load-bearing half: a failure that did write
// carries its committed categories and does get the panel — see the partial
// test below.
it('reports a backend refusal such as the backup/restore mutex', async () => {
server.use(
http.post('/api/v1/github-backup/restore', () =>
HttpResponse.json({
success: false,
message: 'A backup is currently running. Wait for it to finish before restoring.',
results: {},
})
)
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(
screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
).toBeInTheDocument();
});
// Not the success panel: no reload hint, no "Reload now", and the form is
// still there so the user can retry once the backup finishes.
expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
});
it('does not refresh the data caches when a restore was refused', async () => {
server.use(
http.post('/api/v1/github-backup/restore', () =>
HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
)
);
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
render(<GitHubRestoreModal onClose={vi.fn()} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => screen.getByText('A restore is already running'));
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
// This refusal never reached a category, so `results` is empty and there is
// nothing to re-read. A failure that committed one does invalidate — the
// partial test below covers that side...
expect(keys).not.toContain(JSON.stringify(['spools']));
expect(keys).not.toContain(JSON.stringify(['archives']));
// ...but a failure past the commit resolve writes a "failed" log row, so the
// history is refreshed whatever the outcome.
expect(keys).toContain(JSON.stringify(['github-backup-logs']));
invalidate.mockRestore();
});
// Categories commit as each one finishes, so a run that fails part-way leaves
// the earlier ones on disk and reports them. The modal used to gate the whole
// result panel — and the cache invalidation with it — on `success`, so those
// rows were written, never shown, and never re-read: the app carried on
// displaying pre-restore settings while the database held the restored ones.
const partialRestore = {
success: false,
message: 'database is locked',
log_id: 7,
ref: 'a'.repeat(40),
results: {
archives: { restored: 12, skipped: 0, failed: 0, notes: [] },
settings: { restored: 4, skipped: 1, failed: 0, notes: [] },
},
};
const runRestore = async () => {
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
};
it('reports the categories a part-way failure already committed', async () => {
server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
render(<GitHubRestoreModal onClose={vi.fn()} />);
await runRestore();
// The tallies are the point: they name what is on disk.
await waitFor(() => expect(screen.getByText('database is locked')).toBeInTheDocument());
expect(screen.getByText(/12 restored/)).toBeInTheDocument();
expect(screen.getByText(/4 restored/)).toBeInTheDocument();
expect(screen.getByText(/The categories listed above finished and are on disk/)).toBeInTheDocument();
// And it must not read as a success — the run did not finish, so the
// warning icon stands in for the green tick.
expect(document.querySelector('svg.text-yellow-500')).toBeInTheDocument();
expect(document.querySelector('svg.text-bambu-green')).not.toBeInTheDocument();
});
it('refreshes the data caches for a part-way failure, because rows landed', async () => {
server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
render(<GitHubRestoreModal onClose={vi.fn()} />);
await runRestore();
await waitFor(() => screen.getByText('database is locked'));
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
expect(keys).toContain(JSON.stringify(['archives']));
expect(keys).toContain(JSON.stringify(['settings']));
invalidate.mockRestore();
});
// A provider-side failure answers 200 with `success: false`; a rejected
// *request* throws in `request()`, leaving `data` undefined. Reading the
// message off `data` alone meant the second kind rendered an empty modal —
// picker holding only "Latest", every category greyed out, no explanation.
it('explains a rejected preview request instead of greying out every category', async () => {
server.use(
http.get('/api/v1/github-backup/restore/preview', () =>
HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
)
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Not authenticated')).toBeInTheDocument();
});
// The category list is replaced by the error, not rendered disabled.
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('explains a rejected commit-list request', async () => {
server.use(
http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
);
render(<GitHubRestoreModal onClose={vi.fn()} />);
// No detail in the body, so the generic string carries the message.
await waitFor(() => {
expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
});
});
it('closes via the close button', async () => {
const onClose = vi.fn();
render(<GitHubRestoreModal onClose={onClose} />);
await waitFor(() => screen.getByText('Restore from Git Backup'));
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalled();
});
// A settings restore has to reach the rest of the app. It used to be the
// opposite problem: SettingsPage's debounced auto-save wrote its pre-restore
// form state back over the restore whenever ['settings'] refetched, so this
// modal skipped that invalidation and pinned the cache instead. #2716 fixed
// the page — it now reconciles a moved server snapshot field by field — and
// the workaround came out with this commit.
describe('a settings restore reaches the rest of the app', () => {
/** Runs a restore returning `results`, leaving the modal on its summary. */
async function restoreWith(results: Record<string, unknown>, onClose = vi.fn()) {
server.use(
http.post('/api/v1/github-backup/restore', () =>
HttpResponse.json({
success: true,
message: 'Restored 77 item(s) from aaa1111',
log_id: 7,
ref: mockPreview.ref,
results,
})
)
);
render(<GitHubRestoreModal onClose={onClose} />);
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
await userEvent.click(checkboxes[1]);
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
await waitFor(() => screen.getByText('Restore from backup?'));
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => screen.getByText('Restored 77 item(s) from aaa1111'));
return onClose;
}
/** Replaces window.location with a reload spy for the duration of a test. */
function stubReload() {
const original = window.location;
const reload = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...original, reload },
});
return {
reload,
restore: () =>
Object.defineProperty(window, 'location', { configurable: true, value: original }),
};
}
it('invalidates the settings query alongside the other rewritten caches', async () => {
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
await restoreWith({ settings: { restored: 77, skipped: 3, failed: 0, notes: [] } });
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
expect(keys).toContain(JSON.stringify(['spools']));
expect(keys).toContain(JSON.stringify(['settings']));
invalidate.mockRestore();
});
it('reloads instead of merely closing after a settings restore', async () => {
const loc = stubReload();
try {
const onClose = await restoreWith({
settings: { restored: 77, skipped: 3, failed: 0, notes: [] },
});
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
await userEvent.click(closeButtons[closeButtons.length - 1]);
expect(loc.reload).toHaveBeenCalled();
// Invalidating ['settings'] only resyncs what reads that query. The
// interface language and the auth state do not, so closing in place
// would leave both showing their pre-restore values.
expect(onClose).not.toHaveBeenCalled();
} finally {
loc.restore();
}
});
it('closes normally when settings were not part of the restore', async () => {
const loc = stubReload();
try {
const onClose = await restoreWith({
spools: { restored: 4, skipped: 0, failed: 0, notes: [] },
});
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
await userEvent.click(closeButtons[closeButtons.length - 1]);
expect(onClose).toHaveBeenCalled();
expect(loc.reload).not.toHaveBeenCalled();
} finally {
loc.restore();
}
});
});
});
+95
View File
@@ -2892,12 +2892,94 @@ export interface GitHubBackupStatus {
configured: boolean;
enabled: boolean;
is_running: boolean;
restore_running: boolean;
progress: string | null;
last_backup_at: string | null;
last_backup_status: string | null;
next_scheduled_run: string | null;
}
// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
// the backup collector never writes them, so there is nothing to restore.
export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
export interface GitHubCommitInfo {
sha: string;
message: string;
author: string;
date: string;
}
export interface GitHubCommitListResponse {
success: boolean;
message: string;
branch: string;
commits: GitHubCommitInfo[];
}
/**
* Values the server interpolates into a translated note or preview detail.
* Kept to strings and numbers on purpose — anything richer would have to be
* formatted server-side and could not be translated.
*/
export type GitHubRestoreParams = Record<string, string | number>;
export interface GitHubRestorePreviewCategory {
category: RestoreCategory;
available: boolean;
item_count: number;
/** English rendering. Used as i18next's defaultValue, never shown on its own. */
detail: string | null;
/** Key under backup.restoreFromGit.details, or null when there is no caveat. */
detail_code: string | null;
detail_params: GitHubRestoreParams;
}
export interface GitHubRestorePreview {
success: boolean;
message: string;
ref: string;
commit: GitHubCommitInfo | null;
metadata_version: string | null;
categories: GitHubRestorePreviewCategory[];
}
export interface GitHubRestoreRequest {
ref?: string;
categories: RestoreCategory[];
overwrite_existing?: boolean;
}
/**
* One tally note, as a translation code plus its parameters (#2656).
*
* Same contract as {@link LocalBackupPathCheck} one card down: the server picks
* the code and supplies typed params, and the client renders
* ``t(`backup.restoreFromGit.notes.${code}`, { ...params, defaultValue: message })``.
* A code the client does not know yet falls back to the English `message`
* rather than showing the raw key.
*/
export interface GitHubRestoreNote {
code: string;
params: GitHubRestoreParams;
message: string;
}
export interface GitHubRestoreCategoryResult {
restored: number;
skipped: number;
failed: number;
notes: GitHubRestoreNote[];
}
export interface GitHubRestoreResponse {
success: boolean;
message: string;
log_id: number | null;
ref: string | null;
results: Record<string, GitHubRestoreCategoryResult>;
}
export interface LocalBackupStatus {
enabled: boolean;
schedule: string;
@@ -6772,6 +6854,19 @@ export const api = {
clearGitHubBackupLogs: (keepLast: number = 10) =>
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
// Restore from a Git backup (#2656)
getGitHubBackupCommits: (limit: number = 20) =>
request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
getGitHubRestorePreview: (ref: string = 'HEAD') =>
request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
restoreFromGitHub: (payload: GitHubRestoreRequest) =>
request<GitHubRestoreResponse>('/github-backup/restore', {
method: 'POST',
body: JSON.stringify(payload),
}),
// Scheduled local backups
getLocalBackupStatus: () =>
request<LocalBackupStatus>('/local-backup/status'),
@@ -36,10 +36,12 @@ import type {
CloudAuthStatus,
Printer,
} from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { ConfirmModal } from './ConfirmModal';
import { GitHubRestoreModal } from './GitHubRestoreModal';
import { useToast } from '../contexts/ToastContext';
import { formatRelativeTime, parseUTCDate } from '../utils/date';
@@ -133,6 +135,14 @@ export function GitHubBackupSettings() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { t } = useTranslation();
const { hasPermission } = useAuth();
// All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
// user without it gets a 403 the moment the modal opens its preview. Hide the
// button rather than offer an action that cannot work. Deliberately scoped to
// the button: the card itself stays visible, since backup configuration is a
// separate permission. hasPermission returns true when auth is off.
const canRestoreFromGit = hasPermission('github:restore');
// Local state for form
const [repoUrl, setRepoUrl] = useState('');
@@ -158,6 +168,9 @@ export function GitHubBackupSettings() {
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Restore from the Git backup repository (#2656)
const [showGitRestore, setShowGitRestore] = useState(false);
// Scheduled local backup state
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
@@ -951,6 +964,18 @@ export function GitHubBackupSettings() {
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{t('backup.test')}
</Button>
{/* Restore from the backup repo (#2656) */}
{canRestoreFromGit && (
<Button
variant="secondary"
size="sm"
onClick={() => setShowGitRestore(true)}
disabled={status.restore_running}
>
<RotateCcw className="w-4 h-4" />
{t('backup.restoreFromGit.button')}
</Button>
)}
</>
)}
</>
@@ -1007,6 +1032,7 @@ export function GitHubBackupSettings() {
<thead>
<tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
<th className="text-left py-2 px-2">{t('backup.date')}</th>
<th className="text-left py-2 px-2">{t('backup.trigger')}</th>
<th className="text-left py-2 px-2">{t('backup.status')}</th>
<th className="text-left py-2 px-2">{t('backup.commit')}</th>
</tr>
@@ -1015,6 +1041,12 @@ export function GitHubBackupSettings() {
{logs.slice(0, 10).map((log) => (
<tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
<td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
{/* A restore writes a log row too, and without this it
was indistinguishable from a backup: a successful
run dated now, while Last backup said otherwise. */}
<td className="py-2 px-2 text-bambu-gray">
{t(`backup.triggers.${log.trigger}`, { defaultValue: log.trigger })}
</td>
<td className="py-2 px-2"><StatusBadge status={log.status} /></td>
<td className="py-2 px-2">
{log.commit_sha ? (
@@ -1445,6 +1477,9 @@ export function GitHubBackupSettings() {
</Card>
</div>
{/* Restore from the Git backup repository (#2656) */}
{showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
{/* Delete Backup Confirmation Modal */}
{deleteConfirmFile && (
<ConfirmModal
@@ -0,0 +1,542 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
Archive,
CheckCircle2,
Info,
Loader2,
Palette,
RotateCcw,
Settings as SettingsIcon,
Thermometer,
X,
} from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { ConfirmModal } from './ConfirmModal';
import {
api,
type RestoreCategory,
type GitHubRestoreParams,
type GitHubRestoreResponse,
} from '../api/client';
import type { TFunction } from 'i18next';
interface GitHubRestoreModalProps {
onClose: () => void;
}
/**
* Render a server-supplied translation code, falling back to its English text.
*
* The restore endpoints describe every note and preview caveat as a `code` plus
* typed `params`, and carry the English rendering along as `message`. That is
* the same contract `backup.pathCheck` already uses one card down in
* GitHubBackupSettings — including the `defaultValue` arm, which is what keeps a
* newer backend's unfamiliar code readable instead of printing the raw key.
*/
function translateCoded(
t: TFunction,
group: 'notes' | 'details',
code: string | null | undefined,
params: GitHubRestoreParams | undefined,
fallback: string | null
): string | null {
if (!code) return fallback;
return t(`backup.restoreFromGit.${group}.${code}`, {
...(params ?? {}),
defaultValue: fallback ?? code,
});
}
interface CategoryMeta {
id: RestoreCategory;
labelKey: string;
icon: React.ReactNode;
}
// Order mirrors the order the backend applies them in. Labels reuse the keys
// the backup checkbox group already ships in all locales.
const CATEGORIES: CategoryMeta[] = [
{ id: 'archives', labelKey: 'backup.printArchives', icon: <Archive className="w-4 h-4" /> },
{ id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
{ id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
{ id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
];
const CATEGORY_LABEL_KEYS: Record<string, string> = Object.fromEntries(
CATEGORIES.map((c) => [c.id, c.labelKey])
);
const LATEST = 'HEAD';
export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedRef, setSelectedRef] = useState<string>(LATEST);
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [result, setResult] = useState<GitHubRestoreResponse | null>(null);
const commitsQuery = useQuery({
queryKey: ['github-backup-commits'],
queryFn: () => api.getGitHubBackupCommits(20),
});
const previewQuery = useQuery({
queryKey: ['github-restore-preview', selectedRef],
queryFn: () => api.getGitHubRestorePreview(selectedRef),
});
// Restore the exact commit the preview described, not the ref that was asked
// for. They differ for the default "Latest backup" selection, which posts the
// symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing
// between preview and restore would silently restore a different commit than
// the one whose contents the user just approved.
const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef;
const availability = useMemo(() => {
const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
previewQuery.data?.categories?.forEach((c) => {
map[c.category] = {
available: c.available,
itemCount: c.item_count,
detail: translateCoded(t, 'details', c.detail_code, c.detail_params, c.detail),
};
});
return map;
}, [previewQuery.data, t]);
// What a Restore click would actually send. `selected` on its own is not that:
// it survives a commit switch by design (the pruning effect below only runs
// once the new preview lands), so between picking a commit and its preview
// resolving, `selected` still describes the *previous* commit while the
// checkbox list is replaced by a spinner. Counting it raw put "2 selected"
// and an enabled Restore button under that spinner, and clicking restored the
// new commit with the old commit's categories — none of which the user had
// seen an item count for. Gating on availability, exactly as the checkboxes
// do, empties the list until the preview says otherwise, which also disables
// the button.
const selectedCategories = useMemo(
() => CATEGORIES.filter((c) => selected[c.id] && availability[c.id]?.available).map((c) => c.id),
[selected, availability]
);
const selectedCount = selectedCategories.length;
// Overwrite-off tells the user that existing entries stay as they are, and for
// three of the four categories it keeps that promise. K-profiles cannot:
// _restore_kprofiles takes no overwrite flag, because writing a slot is always
// an overwrite on the printer — resolving the live cali_idx and publishing
// extrusion_cali_set replaces whatever calibration that slot holds. The
// backend does say so, but as a note in the result panel, i.e. after the MQTT
// send has already happened and cannot be taken back. So the one screen that
// explains overwrite-off has to carry the exception too, before the click.
const warnKprofilesOverwrite = !overwriteExisting && selectedCategories.includes('kprofiles');
const restoreMutation = useMutation({
mutationFn: () =>
api.restoreFromGitHub({
ref: resolvedRef,
categories: selectedCategories,
overwrite_existing: overwriteExisting,
}),
onSuccess: (data) => {
setShowConfirm(false);
// The endpoint answers 200 for a refused or failed restore too, with
// `success: false` — and two of those are ordinary conditions, not
// errors: another restore already running, and a backup being mid-flight.
// Nothing was written for either, so they keep the form and show the red
// block below; rendering the result panel for them put a green tick, no
// tally at all and a "reload so the restored data appears" hint above a
// message saying nothing had been restored.
//
// A failure that got as far as writing is the opposite case. Categories
// commit as each one finishes, so a non-empty `results` names the ones
// that are on disk — and the form over the top of them would be the same
// "nothing was restored" misreading, this time with the data actually in.
// So the panel is what wrote, not what succeeded.
const wroteSomething = Object.keys(data.results ?? {}).length > 0;
if (data.success || wroteSomething) {
setResult(data);
// A restore rewrites rows these caches hold. ['settings'] is one of
// them: until #2716 was fixed on dev, invalidating it made
// SettingsPage's debounced auto-save write the pre-restore form state
// straight back over the restore, so this modal skipped it and pinned
// the cache instead. That page now reconciles a moved server snapshot
// field by field, so the restore no longer needs an exception.
queryClient.invalidateQueries({ queryKey: ['spools'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['settings'] });
}
// A failure that got as far as resolving the commit still writes a log row
// (status "failed"), so refresh the history and status either way.
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
},
onError: () => setShowConfirm(false),
});
const isRestoring = restoreMutation.isPending;
// A settings restore rewrites rows the whole app reads, and not all of them
// through a query this modal can invalidate. The interface language is applied
// by i18n.changeLanguage, called only from the SettingsPage dropdown and the
// appliance-locale bootstrap; the auth state comes from AuthProvider's
// mount-time getAuthStatus, not from ['settings'] at all. So every exit path
// after a settings restore reloads rather than just closing.
const settingsRestored = Boolean(result && 'settings' in result.results);
const closeModal = useCallback(() => {
if (settingsRestored) {
window.location.reload();
return;
}
onClose();
}, [settingsRestored, onClose]);
// Close on Escape, except while a restore is in flight.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isRestoring && !showConfirm) closeModal();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeModal, isRestoring, showConfirm]);
// Interrupting a restore mid-flight can leave a partly-applied category.
useEffect(() => {
if (!isRestoring) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isRestoring]);
// Selecting a category that isn't in the newly-picked commit would send a
// request the backend rejects, so drop those whenever the preview changes.
useEffect(() => {
if (!previewQuery.data) return;
setSelected((prev) => {
const next: Record<string, boolean> = {};
CATEGORIES.forEach((c) => {
next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available);
});
return next;
});
}, [previewQuery.data, availability]);
const commits = commitsQuery.data?.commits ?? [];
const formatCommitLabel = (sha: string, message: string, date: string) => {
const firstLine = (message || '').split('\n')[0];
const when = date ? new Date(date).toLocaleString() : '';
return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`;
};
// Two ways these can fail, and both have to reach the user. A provider-side
// failure (bad token, repo unreachable) answers 200 with `success: false` and
// a message. A rejected *request* — a 401/403 once the session expires with
// the modal open, a 500, the network dropping — throws in `request()`, so
// `data` is undefined: reading the message off `data` alone left the picker
// holding only "Latest" and every category greyed out by an empty availability
// map, with nothing on screen saying why.
const queryError = (query: { isError: boolean; error: unknown }) =>
query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null;
const previewError =
queryError(previewQuery) ??
(previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null);
const commitsError =
queryError(commitsQuery) ??
(commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null);
return (
<>
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={isRestoring ? undefined : closeModal}
>
<Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
<RotateCcw className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
<p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
</div>
</div>
<button
onClick={closeModal}
disabled={isRestoring}
aria-label={t('common.close')}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{result ? (
/* Result summary */
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
{/* A partial restore reaches this panel too — categories commit
as they finish, so the tallies below are on disk even though
the run did not get through them all. It must not read as a
success: the message is the failure, and what follows is what
survived it rather than what was asked for. */}
<div className="flex items-start gap-2 text-sm">
{result.success ? (
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
)}
<span className="text-white">{result.message}</span>
</div>
{!result.success && (
<p className="text-xs text-bambu-gray">{t('backup.restoreFromGit.partialHint')}</p>
)}
{Object.entries(result.results).map(([name, tally]) => (
<div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">
{CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
</span>
<span className="text-xs text-bambu-gray">
{t('backup.restoreFromGit.tally', {
restored: tally.restored,
skipped: tally.skipped,
failed: tally.failed,
})}
</span>
</div>
{tally.notes.length > 0 && (
<ul className="mt-2 space-y-1">
{tally.notes.map((note) => (
// The server dedupes on (code, params), not on code
// alone — two printers can both be offline — so the
// key has to carry the params too.
<li
key={`${note.code}:${JSON.stringify(note.params)}`}
className="text-xs text-bambu-gray flex items-start gap-1.5"
>
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{translateCoded(t, 'notes', note.code, note.params, note.message)}</span>
</li>
))}
</ul>
)}
</div>
))}
<div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
<p className="text-xs text-yellow-700 dark:text-yellow-200">
{t('backup.restoreFromGit.reloadHint')}
</p>
</div>
</div>
) : (
<div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
{/* A restore that was refused or failed comes back here rather
than to the result panel, so keep these above the fold. */}
{restoreMutation.isError && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<p className="text-sm text-red-700 dark:text-red-400">
{(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
</p>
</div>
)}
{restoreMutation.data && !restoreMutation.data.success && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
</div>
)}
{/* Commit picker */}
<div>
<label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
{t('backup.restoreFromGit.commitLabel')}
</label>
<select
id="restore-commit"
value={selectedRef}
onChange={(e) => {
setSelectedRef(e.target.value);
// Drop the previous attempt's failure banner: it refers to
// the commit that was just switched away from. (A *result*
// cannot be showing here — the summary replaces this form.)
restoreMutation.reset();
}}
disabled={isRestoring || commitsQuery.isLoading}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
>
<option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
{commits.map((c) => (
<option key={c.sha} value={c.sha}>
{formatCommitLabel(c.sha, c.message, c.date)}
</option>
))}
</select>
{commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
</div>
{/* Category selection */}
<div>
<p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
{previewQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
<Loader2 className="w-4 h-4 animate-spin" />
{t('backup.restoreFromGit.inspecting')}
</div>
) : previewError ? (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
</div>
) : (
<div className="space-y-2">
{CATEGORIES.map((category) => {
const info = availability[category.id];
const isAvailable = Boolean(info?.available);
const isChecked = Boolean(selected[category.id]) && isAvailable;
return (
<label
key={category.id}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
} ${
isChecked
? 'bg-bambu-green/10 border border-bambu-green/30'
: 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
}`}
>
<input
type="checkbox"
checked={isChecked}
disabled={!isAvailable || isRestoring}
onChange={() =>
setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
}
className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
<div className="flex-1">
<div className="text-white text-sm font-medium">
{t(category.labelKey)}
{isAvailable && info?.itemCount ? (
<span className="ml-2 text-xs text-bambu-gray">
{t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
</span>
) : null}
</div>
{info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
{category.id === 'kprofiles' && isChecked && warnKprofilesOverwrite && (
<div className="text-xs text-yellow-700 dark:text-yellow-200">
{t('backup.restoreFromGit.kprofilesOverwriteCaveat')}
</div>
)}
</div>
</label>
);
})}
</div>
)}
</div>
{/* Overwrite toggle */}
<div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
<p className="text-xs text-bambu-gray">
{overwriteExisting
? t('backup.restoreFromGit.overwriteOn')
: t('backup.restoreFromGit.overwriteOff')}
</p>
</div>
<Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
</div>
</div>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
{result ? (
<>
<span />
<div className="flex gap-3">
<Button variant="secondary" onClick={closeModal}>
{t('common.close')}
</Button>
<Button
onClick={() => window.location.reload()}
className="bg-bambu-green hover:bg-bambu-green-dark"
>
{t('backup.reloadNow')}
</Button>
</div>
</>
) : (
<>
<span className="text-sm text-bambu-gray">
{t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={closeModal} disabled={isRestoring}>
{t('common.cancel')}
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={selectedCount === 0 || isRestoring}
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
>
{isRestoring ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('backup.restoreFromGit.restoring')}
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
{t('backup.restore')}
</>
)}
</Button>
</div>
</>
)}
</div>
</CardContent>
</Card>
</div>
{showConfirm && (
<ConfirmModal
variant="danger"
overlayZIndex="z-[110]"
title={t('backup.restoreFromGit.confirmTitle')}
message={
overwriteExisting
? t('backup.restoreFromGit.confirmMessageOverwrite')
: warnKprofilesOverwrite
? `${t('backup.restoreFromGit.confirmMessage')} ${t('backup.restoreFromGit.kprofilesOverwriteCaveat')}`
: t('backup.restoreFromGit.confirmMessage')
}
confirmText={t('backup.restore')}
isLoading={isRestoring}
loadingText={t('backup.restoreFromGit.restoring')}
onConfirm={() => restoreMutation.mutate()}
onCancel={() => setShowConfirm(false)}
/>
)}
</>
);
}
+69
View File
@@ -4874,11 +4874,80 @@ export default {
clearedLogs: '{{count}} Protokolle gelöscht',
failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Aus Git wiederherstellen',
title: 'Aus Git-Backup wiederherstellen',
subtitle: 'Commit auswählen und festlegen, was wiederhergestellt wird',
commitLabel: 'Backup-Commit',
latestCommit: 'Neuestes Backup (Branch-Spitze)',
categoriesLabel: 'Was wiederherstellen',
inspecting: 'Backup-Inhalt wird gelesen...',
itemCount: '{{count}} im Backup',
overwriteLabel: 'Vorhandene Einträge überschreiben',
overwriteOn: 'Vorhandene Einträge werden aus dem Backup aktualisiert.',
overwriteOff: 'Es werden nur fehlende Einträge ergänzt; vorhandene bleiben unverändert.',
selectedCount: '{{count}} ausgewählt',
restoring: 'Wird wiederhergestellt...',
confirmTitle: 'Aus Backup wiederherstellen?',
confirmMessage: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt. Fehlende Einträge werden ergänzt, vorhandene bleiben unverändert.',
confirmMessageOverwrite: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt und lokal vorhandene Einträge überschrieben. Dies kann nicht rückgängig gemacht werden.',
kprofilesOverwriteCaveat: 'K-Profile sind die Ausnahme: Das Schreiben eines Slots ersetzt immer die Kalibrierung auf dem Drucker.',
tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
partialHint: 'Die oben aufgeführten Kategorien wurden abgeschlossen und sind gespeichert. Fehlende Kategorien wurden nicht ausgeführt.',
failed: 'Wiederherstellung fehlgeschlagen.',
loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
details: {
notPresent: 'In diesem Backup-Commit nicht vorhanden',
unreadableJson: 'Unlesbares JSON: {{paths}}',
settingsNoPayload: 'Keine Einstellungen in den Daten',
settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
kprofilesPrinterCount: 'über {{count}} Drucker',
},
notes: {
noData: 'Keine Daten dieser Art in diesem Backup',
archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt',
archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt',
archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
archivesOwnerUnmatched: 'Einige Archive nennen einen Eigentümer, den es auf dieser Instanz nicht gibt - der Eigentümer wurde entfernt statt aus der Benutzer-ID der Sicherung geraten. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
archivesOwnerUnknown: 'Einige Archive wurden ohne Eigentümer wiederhergestellt - diese Sicherung enthält keinen, daher sind sie nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv',
archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten',
spoolUsageUnresolved: '{{count}} Verbrauchseinträge übersprungen - ihre Spule ist nicht in der Spulenliste dieses Backups, es gibt also nichts, woran sie hängen könnten.',
spoolUsageUnlinked: '{{count}} Verbrauchseinträge ohne Verknüpfung zum Druckverlauf wiederhergestellt - wählen Sie Druckarchive zusammen mit dem Spulenbestand, um sie zu behalten.',
spoolTagKept: '{{count}} Spulen-Tags unverändert gelassen - das Backup hätte einen inzwischen gescannten Tag gelöscht oder ihn auf eine zweite Spule verschoben.',
settingsCredentialsSkipped: '{{count}} zugangsdatenähnliche Schlüssel übersprungen - Geheimnisse bitte manuell erneut eingeben',
settingsAuthSkipped: '{{count}} Authentifizierungseinstellungen übersprungen - ändern Sie diese unter Einstellungen > Authentifizierung, damit die Aussperrprüfungen greifen',
settingsCompanionSkipped: '{{keys}} bleiben ausgeschaltet - die jeweils benötigten Zugangsdaten lassen sich nicht aus einem Backup wiederherstellen und sind auf dieser Instanz nicht hinterlegt, ein Einschalten würde die Integration also ohne Authentifizierung lassen',
settingsMqttRelayFailed: 'MQTT-Einstellungen wiederhergestellt, aber das Relay konnte nicht neu verbunden werden - Bambuddy neu starten',
kprofilesAlwaysOverwrite: 'K-Profile überschreiben immer den passenden Slot auf dem Drucker',
kprofilesAckUnreliable: 'Ein Drucker, der nicht antwortet, zählt weiterhin als wiederhergestellt - überprüfen Sie die Profile am Drucker',
kprofilesPrinterMissing: 'Kein Drucker mit der Seriennummer {{serial}} - übersprungen',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) ist nicht verbunden - übersprungen',
kprofilesUnknownNozzle: 'Unerwarteter Düsendurchmesser {{nozzle}} für {{serial}} - unverändert gesendet',
kprofilesUnmatched: '{{count}} Profile für {{nozzle}} hatten kein Gegenstück auf {{printer}} - als neue Profile hinzugefügt',
kprofilesSendFailed: '{{nozzle}}-Profile konnten nicht an {{printer}} ({{serial}}) gesendet werden',
kprofilesRefused: '{{printer}} ({{serial}}) hat die {{nozzle}}-Profile abgelehnt: {{reason}}',
kprofilesStepFailed: 'Der K-Profil-Schritt konnte nicht abgeschlossen werden - {{reason}}. Was zuvor wiederhergestellt wurde, ist trotzdem gespeichert.',
},
},
// History
history: 'Verlauf',
clear: 'Löschen',
date: 'Datum',
status: 'Status',
trigger: 'Typ',
triggers: {
manual: 'Sicherung (manuell)',
scheduled: 'Sicherung (geplant)',
restore: 'Wiederherstellung',
},
commit: 'Commit',
// Local Backup
+74
View File
@@ -4917,11 +4917,85 @@ export default {
clearedLogs: 'Cleared {{count}} logs',
failedToClearLogs: 'Failed to clear logs: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restore from Git',
title: 'Restore from Git Backup',
subtitle: 'Pick a commit and choose what to restore',
commitLabel: 'Backup commit',
latestCommit: 'Latest backup (branch tip)',
categoriesLabel: 'What to restore',
inspecting: 'Reading backup contents...',
itemCount: '{{count}} in backup',
overwriteLabel: 'Overwrite existing entries',
overwriteOn: 'Existing entries will be updated from the backup.',
overwriteOff: 'Only missing entries are added; existing ones are left untouched.',
selectedCount: '{{count}} selected',
restoring: 'Restoring...',
confirmTitle: 'Restore from backup?',
confirmMessage: 'The selected categories will be restored from this commit. Missing entries are added; existing entries stay as they are.',
confirmMessageOverwrite: 'The selected categories will be restored from this commit, overwriting entries that already exist locally. This cannot be undone.',
kprofilesOverwriteCaveat: 'K-profiles are the exception: writing a slot always replaces the calibration on the printer.',
tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
partialHint: 'The categories listed above finished and are on disk. Any that are missing did not run.',
failed: 'Restore failed.',
loadFailed: 'Could not read the backup repository.',
// Preview caveats. The server sends detail_code + detail_params and the
// English detail as defaultValue, same contract as backup.pathCheck.
details: {
notPresent: 'Not present in this backup commit',
unreadableJson: 'Unreadable JSON: {{paths}}',
settingsNoPayload: 'No settings in payload',
settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
spoolsUsageCount: 'including {{count}} usage record(s)',
archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
kprofilesPrinterCount: 'across {{count}} printer(s)',
},
// Tally notes, same contract. noData is shared by all four categories:
// the category heading renders beside it, so naming the category again
// would be redundant.
notes: {
noData: 'No data of this kind in this backup',
archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared',
archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared',
archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
archivesOwnerUnmatched: 'Some archives name an owner this instance does not have - owner cleared rather than guessed from the backup\'s user id, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
archivesOwnerUnknown: 'Some archives were restored without an owner - this backup does not record one, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on',
archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup',
spoolUsageUnresolved: '{{count}} usage record(s) skipped - their spool is not in this backup\'s spool list, so there is nothing to attach them to.',
spoolUsageUnlinked: '{{count}} usage record(s) restored without their print-history link - select Print archives alongside Spool inventory to keep it.',
spoolTagKept: '{{count}} spool tag(s) left as they are - the backup would have cleared a tag that has since been scanned, or moved one onto a second spool.',
settingsCredentialsSkipped: '{{count}} credential-like key(s) skipped - re-enter secrets manually',
settingsAuthSkipped: '{{count}} authentication setting(s) skipped - change those in Settings > Authentication so the lockout checks still run',
settingsCompanionSkipped: '{{keys}} left switched off - the credential each one needs cannot be restored from a backup and this instance has none stored, so switching them on would leave the integration unauthenticated',
settingsMqttRelayFailed: 'MQTT settings restored, but the relay could not be reconnected - restart Bambuddy',
kprofilesAlwaysOverwrite: 'K-profiles always overwrite the matching slot on the printer',
kprofilesAckUnreliable: 'A printer that does not answer still counts as restored - verify the profiles on the printer',
kprofilesPrinterMissing: 'No printer with serial {{serial}} - skipped',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) is not connected - skipped',
kprofilesUnknownNozzle: 'Unexpected nozzle diameter {{nozzle}} for {{serial}} - sent as-is',
kprofilesUnmatched: '{{count}} profile(s) for {{nozzle}} had no counterpart on {{printer}} - added as new profiles',
kprofilesSendFailed: 'Failed to send {{nozzle}} profiles to {{printer}} ({{serial}})',
kprofilesRefused: '{{printer}} ({{serial}}) refused the {{nozzle}} profiles: {{reason}}',
kprofilesStepFailed: 'The K-profile step could not be completed - {{reason}}. Anything restored before it is still saved.',
},
},
// History
history: 'History',
clear: 'Clear',
date: 'Date',
status: 'Status',
trigger: 'Type',
triggers: {
manual: 'Backup (manual)',
scheduled: 'Backup (scheduled)',
restore: 'Restore',
},
commit: 'Commit',
// Local Backup
+69
View File
@@ -4882,11 +4882,80 @@ export default {
clearedLogs: 'Se borraron {{count}} registros',
failedToClearLogs: 'Error al borrar los registros: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restaurar desde Git',
title: 'Restaurar desde copia de Git',
subtitle: 'Elige un commit y qué se debe restaurar',
commitLabel: 'Commit de la copia',
latestCommit: 'Última copia (punta de la rama)',
categoriesLabel: 'Qué restaurar',
inspecting: 'Leyendo el contenido de la copia...',
itemCount: '{{count}} en la copia',
overwriteLabel: 'Sobrescribir entradas existentes',
overwriteOn: 'Las entradas existentes se actualizarán desde la copia.',
overwriteOff: 'Solo se añaden las entradas que falten; las existentes no se modifican.',
selectedCount: '{{count}} seleccionados',
restoring: 'Restaurando...',
confirmTitle: '¿Restaurar desde la copia?',
confirmMessage: 'Las categorías seleccionadas se restaurarán desde este commit. Se añaden las entradas que falten y las existentes se mantienen igual.',
confirmMessageOverwrite: 'Las categorías seleccionadas se restaurarán desde este commit y se sobrescribirán las entradas que ya existan localmente. Esto no se puede deshacer.',
kprofilesOverwriteCaveat: 'Los perfiles K son la excepción: escribir una ranura siempre reemplaza la calibración en la impresora.',
tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
partialHint: 'Las categorías indicadas arriba se completaron y están guardadas. Las que faltan no llegaron a ejecutarse.',
failed: 'La restauración ha fallado.',
loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
details: {
notPresent: 'No está presente en este commit de la copia',
unreadableJson: 'JSON ilegible: {{paths}}',
settingsNoPayload: 'No hay ajustes en los datos',
settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
spoolsUsageCount: 'incluidos {{count}} registros de consumo',
archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
kprofilesPrinterCount: 'en {{count}} impresoras',
},
notes: {
noData: 'No hay datos de este tipo en esta copia de seguridad',
archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado',
archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado',
archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
archivesOwnerUnmatched: 'Algunos archivos indican un propietario que no existe en esta instancia - se ha borrado el propietario en lugar de deducirlo del id de usuario de la copia de seguridad, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
archivesOwnerUnknown: 'Algunos archivos se restauraron sin propietario - esta copia de seguridad no registra ninguno, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada',
archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git',
spoolUsageUnresolved: '{{count}} registros de consumo omitidos - su bobina no está en la lista de bobinas de esta copia, así que no hay nada a lo que asociarlos.',
spoolUsageUnlinked: '{{count}} registros de consumo restaurados sin su enlace al historial de impresión - selecciona Archivos de impresión junto con Inventario de bobinas para conservarlo.',
spoolTagKept: '{{count}} etiquetas de bobina se han dejado como estaban - la copia habría borrado una etiqueta escaneada desde entonces, o la habría movido a una segunda bobina.',
settingsCredentialsSkipped: '{{count}} claves con aspecto de credencial omitidas - vuelve a introducir los secretos manualmente',
settingsAuthSkipped: '{{count}} ajustes de autenticación omitidos - cámbialos en Ajustes > Autenticación para que sigan aplicándose las comprobaciones de bloqueo',
settingsCompanionSkipped: '{{keys}} se han dejado desactivados - la credencial que cada uno necesita no puede restaurarse desde una copia y esta instancia no tiene ninguna guardada, así que activarlos dejaría la integración sin autenticación',
settingsMqttRelayFailed: 'Ajustes MQTT restaurados, pero no se pudo reconectar el relé - reinicia Bambuddy',
kprofilesAlwaysOverwrite: 'Los perfiles K siempre sobrescriben la ranura correspondiente en la impresora',
kprofilesAckUnreliable: 'Una impresora que no responde sigue contando como restaurada - verifica los perfiles en la impresora',
kprofilesPrinterMissing: 'No hay ninguna impresora con el número de serie {{serial}} - omitido',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) no está conectada - omitido',
kprofilesUnknownNozzle: 'Diámetro de boquilla inesperado {{nozzle}} para {{serial}} - enviado tal cual',
kprofilesUnmatched: '{{count}} perfiles para {{nozzle}} no tenían equivalente en {{printer}} - añadidos como perfiles nuevos',
kprofilesSendFailed: 'No se pudieron enviar los perfiles de {{nozzle}} a {{printer}} ({{serial}})',
kprofilesRefused: '{{printer}} ({{serial}}) rechazó los perfiles de {{nozzle}}: {{reason}}',
kprofilesStepFailed: 'No se pudo completar el paso de los perfiles K - {{reason}}. Lo que se restauró antes sigue guardado.',
},
},
// History
history: 'Historial',
clear: 'Borrar',
date: 'Fecha',
status: 'Estado',
trigger: 'Tipo',
triggers: {
manual: 'Copia (manual)',
scheduled: 'Copia (programada)',
restore: 'Restauración',
},
commit: 'Confirmación',
// Local Backup
+69
View File
@@ -4863,11 +4863,80 @@ export default {
clearedLogs: '{{count}} journaux supprimés',
failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restaurer depuis Git',
title: 'Restaurer depuis la sauvegarde Git',
subtitle: 'Choisissez un commit et ce qui doit être restauré',
commitLabel: 'Commit de sauvegarde',
latestCommit: 'Dernière sauvegarde (tête de branche)',
categoriesLabel: 'Éléments à restaurer',
inspecting: 'Lecture du contenu de la sauvegarde...',
itemCount: '{{count}} dans la sauvegarde',
overwriteLabel: 'Écraser les entrées existantes',
overwriteOn: 'Les entrées existantes seront mises à jour depuis la sauvegarde.',
overwriteOff: 'Seules les entrées manquantes sont ajoutées ; les existantes ne sont pas modifiées.',
selectedCount: '{{count}} sélectionné(s)',
restoring: 'Restauration...',
confirmTitle: 'Restaurer depuis la sauvegarde ?',
confirmMessage: 'Les catégories sélectionnées seront restaurées depuis ce commit. Les entrées manquantes sont ajoutées, les existantes restent inchangées.',
confirmMessageOverwrite: 'Les catégories sélectionnées seront restaurées depuis ce commit et les entrées déjà présentes localement seront écrasées. Cette action est irréversible.',
kprofilesOverwriteCaveat: "Les profils K sont l'exception : écrire un emplacement remplace toujours la calibration sur l'imprimante.",
tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
partialHint: "Les catégories listées ci-dessus sont terminées et enregistrées. Celles qui manquent n'ont pas été exécutées.",
failed: 'Échec de la restauration.',
loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
details: {
notPresent: 'Absent de ce commit de sauvegarde',
unreadableJson: 'JSON illisible : {{paths}}',
settingsNoPayload: 'Aucun réglage dans les données',
settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
kprofilesPrinterCount: 'sur {{count}} imprimantes',
},
notes: {
noData: 'Aucune donnée de ce type dans cette sauvegarde',
archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé',
archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé',
archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
archivesOwnerUnmatched: 'Certaines archives désignent un propriétaire absent de cette instance - le propriétaire a été effacé plutôt que déduit de l\'identifiant utilisateur de la sauvegarde, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
archivesOwnerUnknown: 'Certaines archives ont été restaurées sans propriétaire - cette sauvegarde n\'en enregistre aucun, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé',
archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
spoolUsageUnresolved: '{{count}} enregistrements de consommation ignorés - leur bobine ne figure pas dans la liste des bobines de cette sauvegarde, il n\'y a donc rien à quoi les rattacher.',
spoolUsageUnlinked: '{{count}} enregistrements de consommation restaurés sans leur lien vers l\'historique d\'impression - sélectionnez Archives d\'impression en même temps que l\'Inventaire des bobines pour le conserver.',
spoolTagKept: '{{count}} étiquettes de bobine laissées telles quelles - la sauvegarde aurait effacé une étiquette scannée depuis, ou l\'aurait déplacée sur une seconde bobine.',
settingsCredentialsSkipped: '{{count}} clés ressemblant à des identifiants ignorées - ressaisissez les secrets manuellement',
settingsAuthSkipped: '{{count}} réglages d\'authentification ignorés - modifiez-les dans Réglages > Authentification pour que les contrôles de verrouillage s\'appliquent',
settingsCompanionSkipped: '{{keys}} laissés désactivés - l\'identifiant dont chacun a besoin ne peut pas être restauré depuis une sauvegarde et cette instance n\'en a aucun enregistré ; les activer laisserait donc l\'intégration sans authentification',
settingsMqttRelayFailed: 'Réglages MQTT restaurés, mais le relais n\'a pas pu être reconnecté - redémarrez Bambuddy',
kprofilesAlwaysOverwrite: 'Les profils K écrasent toujours l\'emplacement correspondant sur l\'imprimante',
kprofilesAckUnreliable: 'Une imprimante qui ne répond pas compte quand même comme restaurée - vérifiez les profils sur l\'imprimante',
kprofilesPrinterMissing: 'Aucune imprimante avec le numéro de série {{serial}} - ignoré',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) n\'est pas connectée - ignoré',
kprofilesUnknownNozzle: 'Diamètre de buse inattendu {{nozzle}} pour {{serial}} - envoyé tel quel',
kprofilesUnmatched: '{{count}} profils pour {{nozzle}} n\'avaient pas d\'équivalent sur {{printer}} - ajoutés comme nouveaux profils',
kprofilesSendFailed: 'Impossible d\'envoyer les profils {{nozzle}} à {{printer}} ({{serial}})',
kprofilesRefused: '{{printer}} ({{serial}}) a refusé les profils {{nozzle}} : {{reason}}',
kprofilesStepFailed: 'L\'étape des profils K n\'a pas pu être terminée - {{reason}}. Ce qui a été restauré auparavant reste enregistré.',
},
},
// History
history: 'Historique',
clear: 'Effacer',
date: 'Date',
status: 'Statut',
trigger: 'Type',
triggers: {
manual: 'Sauvegarde (manuelle)',
scheduled: 'Sauvegarde (planifiée)',
restore: 'Restauration',
},
commit: 'Commit',
// Local Backup
+69
View File
@@ -4862,11 +4862,80 @@ export default {
clearedLogs: '{{count}} log eliminati',
failedToClearLogs: 'Eliminazione log fallita: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Ripristina da Git',
title: 'Ripristina dal backup Git',
subtitle: 'Scegli un commit e cosa ripristinare',
commitLabel: 'Commit del backup',
latestCommit: 'Ultimo backup (punta del branch)',
categoriesLabel: 'Cosa ripristinare',
inspecting: 'Lettura del contenuto del backup...',
itemCount: '{{count}} nel backup',
overwriteLabel: 'Sovrascrivi le voci esistenti',
overwriteOn: 'Le voci esistenti verranno aggiornate dal backup.',
overwriteOff: 'Vengono aggiunte solo le voci mancanti; quelle esistenti restano invariate.',
selectedCount: '{{count}} selezionati',
restoring: 'Ripristino in corso...',
confirmTitle: 'Ripristinare dal backup?',
confirmMessage: 'Le categorie selezionate verranno ripristinate da questo commit. Le voci mancanti vengono aggiunte, quelle esistenti restano invariate.',
confirmMessageOverwrite: 'Le categorie selezionate verranno ripristinate da questo commit sovrascrivendo le voci già presenti in locale. Operazione non annullabile.',
kprofilesOverwriteCaveat: "I profili K sono l'eccezione: scrivere uno slot sostituisce sempre la calibrazione sulla stampante.",
tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
partialHint: 'Le categorie elencate sopra sono state completate e salvate. Quelle mancanti non sono state eseguite.',
failed: 'Ripristino non riuscito.',
loadFailed: 'Impossibile leggere il repository di backup.',
details: {
notPresent: 'Non presente in questo commit di backup',
unreadableJson: 'JSON illeggibile: {{paths}}',
settingsNoPayload: 'Nessuna impostazione nei dati',
settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
spoolsUsageCount: 'inclusi {{count}} record di consumo',
archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
kprofilesPrinterCount: 'su {{count}} stampanti',
},
notes: {
noData: 'Nessun dato di questo tipo in questo backup',
archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso',
archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso',
archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
archivesOwnerUnmatched: 'Alcuni archivi indicano un proprietario che questa istanza non ha - il proprietario è stato rimosso anziché dedotto dall\'id utente del backup, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
archivesOwnerUnknown: 'Alcuni archivi sono stati ripristinati senza proprietario - questo backup non ne registra alcuno, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva',
archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git',
spoolUsageUnresolved: '{{count}} record di consumo saltati - la loro bobina non è nell\'elenco bobine di questo backup, quindi non c\'è nulla a cui collegarli.',
spoolUsageUnlinked: '{{count}} record di consumo ripristinati senza il collegamento alla cronologia di stampa - seleziona Archivi di stampa insieme a Inventario bobine per mantenerlo.',
spoolTagKept: '{{count}} tag bobina lasciati invariati - il backup avrebbe cancellato un tag nel frattempo scansionato, oppure lo avrebbe spostato su una seconda bobina.',
settingsCredentialsSkipped: '{{count}} chiavi simili a credenziali saltate - reinserisci i segreti manualmente',
settingsAuthSkipped: '{{count}} impostazioni di autenticazione saltate - modificale in Impostazioni > Autenticazione così i controlli di blocco restano attivi',
settingsCompanionSkipped: '{{keys}} lasciati disattivati - la credenziale richiesta da ciascuno non può essere ripristinata da un backup e questa istanza non ne ha nessuna salvata, quindi attivarli lascerebbe l\'integrazione senza autenticazione',
settingsMqttRelayFailed: 'Impostazioni MQTT ripristinate, ma il relay non è stato riconnesso - riavvia Bambuddy',
kprofilesAlwaysOverwrite: 'I profili K sovrascrivono sempre lo slot corrispondente sulla stampante',
kprofilesAckUnreliable: 'Una stampante che non risponde conta comunque come ripristinata - verifica i profili sulla stampante',
kprofilesPrinterMissing: 'Nessuna stampante con numero di serie {{serial}} - saltato',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) non è connessa - saltato',
kprofilesUnknownNozzle: 'Diametro ugello inatteso {{nozzle}} per {{serial}} - inviato così com\'è',
kprofilesUnmatched: '{{count}} profili per {{nozzle}} non avevano corrispondenza su {{printer}} - aggiunti come nuovi profili',
kprofilesSendFailed: 'Impossibile inviare i profili {{nozzle}} a {{printer}} ({{serial}})',
kprofilesRefused: '{{printer}} ({{serial}}) ha rifiutato i profili {{nozzle}}: {{reason}}',
kprofilesStepFailed: 'Non è stato possibile completare il passaggio dei profili K - {{reason}}. Quanto ripristinato prima resta salvato.',
},
},
// History
history: 'Cronologia',
clear: 'Cancella',
date: 'Data',
status: 'Stato',
trigger: 'Tipo',
triggers: {
manual: 'Backup (manuale)',
scheduled: 'Backup (pianificato)',
restore: 'Ripristino',
},
commit: 'Commit',
// Local Backup
+69
View File
@@ -4874,11 +4874,80 @@ export default {
clearedLogs: '{{count}}件のログを削除しました',
failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Git から復元',
title: 'Git バックアップから復元',
subtitle: 'コミットと復元する項目を選択します',
commitLabel: 'バックアップのコミット',
latestCommit: '最新のバックアップ (ブランチ先端)',
categoriesLabel: '復元する項目',
inspecting: 'バックアップの内容を読み込んでいます...',
itemCount: 'バックアップ内に {{count}} 件',
overwriteLabel: '既存のエントリを上書きする',
overwriteOn: '既存のエントリはバックアップの内容で更新されます。',
overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。',
selectedCount: '{{count}} 件選択中',
restoring: '復元しています...',
confirmTitle: 'バックアップから復元しますか?',
confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。',
confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。',
kprofilesOverwriteCaveat: 'Kプロファイルは例外です。スロットへの書き込みは、プリンター上のキャリブレーションを常に置き換えます。',
tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
partialHint: '上に表示されたカテゴリーは完了し、保存されています。表示されていないカテゴリーは実行されていません。',
failed: '復元に失敗しました。',
loadFailed: 'バックアップリポジトリを読み取れませんでした。',
details: {
notPresent: 'このバックアップコミットには含まれていません',
unreadableJson: '読み取れない JSON: {{paths}}',
settingsNoPayload: 'データに設定が含まれていません',
settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます',
settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
settingsCompanionOnlyWillSkip: 'スイッチ {{companion}} 件はオフのままになります - それぞれに必要な認証情報はバックアップから復元できません',
spoolsUsageCount: '使用履歴 {{count}} 件を含む',
archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
kprofilesPrinterCount: 'プリンター {{count}} 台分',
},
notes: {
noData: 'この種類のデータはこのバックアップに含まれていません',
archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました',
archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました',
archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
archivesOwnerUnmatched: '一部のアーカイブはこのインスタンスに存在しない所有者を指しています - バックアップのユーザー ID から推測せずに所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません',
archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした',
archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません',
spoolUsageUnresolved: '使用履歴 {{count}} 件をスキップしました - 対応するスプールがこのバックアップのスプール一覧にないため、紐付ける先がありません。',
spoolUsageUnlinked: '使用履歴 {{count}} 件を印刷履歴へのリンクなしで復元しました - リンクを保持するにはスプール在庫と一緒に印刷アーカイブも選択してください。',
spoolTagKept: 'スプールタグ {{count}} 件をそのままにしました - バックアップの内容ではその後スキャンされたタグが消えるか、別のスプールに移ってしまうためです。',
settingsCredentialsSkipped: '認証情報のようなキー {{count}} 件をスキップしました - シークレットは手動で再入力してください',
settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください',
settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります',
settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください',
kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします',
kprofilesAckUnreliable: '応答しないプリンターも復元済みとして数えます - プロファイルはプリンター側で確認してください',
kprofilesPrinterMissing: 'シリアル {{serial}} のプリンターがありません - スキップしました',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) は接続されていません - スキップしました',
kprofilesUnknownNozzle: '{{serial}} に想定外のノズル径 {{nozzle}} - そのまま送信しました',
kprofilesUnmatched: '{{nozzle}} 用のプロファイル {{count}} 件は {{printer}} に該当がありませんでした - 新規プロファイルとして追加しました',
kprofilesSendFailed: '{{nozzle}} のプロファイルを {{printer}} ({{serial}}) に送信できませんでした',
kprofilesRefused: '{{printer}} ({{serial}}) が {{nozzle}} のプロファイルを拒否しました: {{reason}}',
kprofilesStepFailed: 'K プロファイルの処理を完了できませんでした - {{reason}}。それまでに復元された内容は保存されています。',
},
},
// History
history: '履歴',
clear: 'クリア',
date: '日付',
status: 'ステータス',
trigger: '種類',
triggers: {
manual: 'バックアップ(手動)',
scheduled: 'バックアップ(スケジュール)',
restore: '復元',
},
commit: 'コミット',
// Local Backup
+69
View File
@@ -4638,10 +4638,79 @@ export default {
backupFailed2: '백업 실패: {{message}}',
clearedLogs: '{{count}}개 로그 초기화됨',
failedToClearLogs: '로그 초기화 실패: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Git에서 복원',
title: 'Git 백업에서 복원',
subtitle: '커밋과 복원할 항목을 선택하세요',
commitLabel: '백업 커밋',
latestCommit: '최신 백업 (브랜치 최신 커밋)',
categoriesLabel: '복원할 항목',
inspecting: '백업 내용을 읽고 있습니다...',
itemCount: '백업에 {{count}}개',
overwriteLabel: '기존 항목 덮어쓰기',
overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.',
overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.',
selectedCount: '{{count}}개 선택됨',
restoring: '복원 중...',
confirmTitle: '백업에서 복원하시겠습니까?',
confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.',
confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.',
kprofilesOverwriteCaveat: 'K 프로파일은 예외입니다. 슬롯에 쓰면 프린터의 캘리브레이션이 항상 교체됩니다.',
tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
partialHint: '위에 표시된 카테고리는 완료되어 저장되었습니다. 표시되지 않은 카테고리는 실행되지 않았습니다.',
failed: '복원에 실패했습니다.',
loadFailed: '백업 저장소를 읽을 수 없습니다.',
details: {
notPresent: '이 백업 커밋에는 없습니다',
unreadableJson: '읽을 수 없는 JSON: {{paths}}',
settingsNoPayload: '데이터에 설정이 없습니다',
settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다',
settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다',
settingsCompanionOnlyWillSkip: '스위치 {{companion}}개는 꺼진 상태로 둡니다 - 각각에 필요한 자격 증명은 백업에서 복원할 수 없습니다',
spoolsUsageCount: '사용 기록 {{count}}건 포함',
archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
kprofilesPrinterCount: '프린터 {{count}}대 분량',
},
notes: {
noData: '이 백업에는 이런 종류의 데이터가 없습니다',
archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다',
archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다',
archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
archivesOwnerUnmatched: '일부 아카이브가 이 인스턴스에 없는 소유자를 가리킵니다 - 백업의 사용자 ID로 추측하지 않고 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다',
archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
spoolUsageUnresolved: '사용 기록 {{count}}건을 건너뛰었습니다 - 해당 스풀이 이 백업의 스풀 목록에 없어 연결할 대상이 없습니다.',
spoolUsageUnlinked: '사용 기록 {{count}}건을 출력 기록 연결 없이 복원했습니다 - 연결을 유지하려면 스풀 재고와 함께 출력 아카이브도 선택하세요.',
spoolTagKept: '스풀 태그 {{count}}개를 그대로 두었습니다 - 백업대로라면 그 사이 스캔된 태그가 지워지거나 다른 스풀로 옮겨졌을 것입니다.',
settingsCredentialsSkipped: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰었습니다 - 비밀 값은 직접 다시 입력하세요',
settingsAuthSkipped: '인증 설정 {{count}}개를 건너뛰었습니다 - 잠금 검사가 계속 동작하도록 설정 > 인증에서 변경하세요',
settingsCompanionSkipped: '{{keys}}을(를) 꺼진 상태로 두었습니다 - 각 항목에 필요한 자격 증명은 백업에서 복원할 수 없고 이 인스턴스에도 저장되어 있지 않아, 켜면 연동이 인증 없이 열립니다',
settingsMqttRelayFailed: 'MQTT 설정을 복원했지만 릴레이를 다시 연결하지 못했습니다 - Bambuddy를 재시작하세요',
kprofilesAlwaysOverwrite: 'K 프로파일은 항상 프린터의 해당 슬롯을 덮어씁니다',
kprofilesAckUnreliable: '응답하지 않는 프린터도 복원됨으로 집계됩니다 - 프린터에서 프로파일을 확인하세요',
kprofilesPrinterMissing: '시리얼 {{serial}}인 프린터가 없습니다 - 건너뛰었습니다',
kprofilesPrinterOffline: '{{printer}}({{serial}})이(가) 연결되어 있지 않습니다 - 건너뛰었습니다',
kprofilesUnknownNozzle: '{{serial}}의 예상치 못한 노즐 직경 {{nozzle}} - 그대로 전송했습니다',
kprofilesUnmatched: '{{nozzle}}용 프로파일 {{count}}개가 {{printer}}에 대응 항목이 없습니다 - 새 프로파일로 추가했습니다',
kprofilesSendFailed: '{{nozzle}} 프로파일을 {{printer}}({{serial}})에 보내지 못했습니다',
kprofilesRefused: '{{printer}}({{serial}})이(가) {{nozzle}} 프로파일을 거부했습니다: {{reason}}',
kprofilesStepFailed: 'K 프로파일 단계를 완료하지 못했습니다 - {{reason}}. 그 전에 복원된 항목은 그대로 저장되어 있습니다.',
},
},
history: '기록',
clear: '초기화',
date: '날짜',
status: '상태',
trigger: '유형',
triggers: {
manual: '백업(수동)',
scheduled: '백업(예약)',
restore: '복원',
},
commit: '커밋',
localBackup: '로컬 백업',
localBackupDescription: '데이터베이스, 아카이브, 업로드 및 모든 파일을 포함한 Bambuddy 데이터의 전체 백업을 만듭니다.',
+69
View File
@@ -4862,11 +4862,80 @@ export default {
clearedLogs: '{{count}} logs removidos',
failedToClearLogs: 'Falha ao limpar logs: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Restaurar do Git',
title: 'Restaurar do backup Git',
subtitle: 'Escolha um commit e o que deve ser restaurado',
commitLabel: 'Commit do backup',
latestCommit: 'Backup mais recente (ponta do branch)',
categoriesLabel: 'O que restaurar',
inspecting: 'Lendo o conteúdo do backup...',
itemCount: '{{count}} no backup',
overwriteLabel: 'Sobrescrever entradas existentes',
overwriteOn: 'As entradas existentes serão atualizadas a partir do backup.',
overwriteOff: 'Apenas as entradas ausentes são adicionadas; as existentes permanecem intactas.',
selectedCount: '{{count}} selecionados',
restoring: 'Restaurando...',
confirmTitle: 'Restaurar do backup?',
confirmMessage: 'As categorias selecionadas serão restauradas deste commit. As entradas ausentes são adicionadas e as existentes permanecem como estão.',
confirmMessageOverwrite: 'As categorias selecionadas serão restauradas deste commit, sobrescrevendo as entradas que já existem localmente. Não é possível desfazer.',
kprofilesOverwriteCaveat: 'Os perfis K são a exceção: gravar um slot sempre substitui a calibração na impressora.',
tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
partialHint: 'As categorias listadas acima foram concluídas e estão salvas. As que faltam não chegaram a ser executadas.',
failed: 'Falha na restauração.',
loadFailed: 'Não foi possível ler o repositório de backup.',
details: {
notPresent: 'Não está presente neste commit de backup',
unreadableJson: 'JSON ilegível: {{paths}}',
settingsNoPayload: 'Nenhuma configuração nos dados',
settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
spoolsUsageCount: 'incluindo {{count}} registros de consumo',
archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
kprofilesPrinterCount: 'em {{count}} impressoras',
},
notes: {
noData: 'Não há dados desse tipo neste backup',
archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido',
archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido',
archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
archivesOwnerUnmatched: 'Alguns arquivos apontam para um proprietário que esta instância não tem - o proprietário foi limpo em vez de deduzido do id de usuário da cópia, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
archivesOwnerUnknown: 'Alguns arquivos foram restaurados sem proprietário - este backup não registra nenhum, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os atribua',
archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada',
archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git',
spoolUsageUnresolved: '{{count}} registros de consumo ignorados - o carretel deles não está na lista de carretéis deste backup, então não há a que vinculá-los.',
spoolUsageUnlinked: '{{count}} registros de consumo restaurados sem o vínculo com o histórico de impressão - selecione Arquivos de impressão junto com Inventário de carretéis para mantê-lo.',
spoolTagKept: '{{count}} etiquetas de carretel foram mantidas como estavam - o backup teria apagado uma etiqueta lida desde então, ou a teria movido para um segundo carretel.',
settingsCredentialsSkipped: '{{count}} chaves parecidas com credenciais ignoradas - digite os segredos novamente à mão',
settingsAuthSkipped: '{{count}} configurações de autenticação ignoradas - altere-as em Configurações > Autenticação para que as verificações de bloqueio continuem valendo',
settingsCompanionSkipped: '{{keys}} ficaram desligados - a credencial que cada um precisa não pode ser restaurada de um backup e esta instância não tem nenhuma armazenada, então ligá-los deixaria a integração sem autenticação',
settingsMqttRelayFailed: 'Configurações MQTT restauradas, mas o relay não pôde ser reconectado - reinicie o Bambuddy',
kprofilesAlwaysOverwrite: 'Os perfis K sempre sobrescrevem o slot correspondente na impressora',
kprofilesAckUnreliable: 'Uma impressora que não responde ainda conta como restaurada - verifique os perfis na impressora',
kprofilesPrinterMissing: 'Nenhuma impressora com o número de série {{serial}} - ignorado',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) não está conectada - ignorado',
kprofilesUnknownNozzle: 'Diâmetro de bico inesperado {{nozzle}} para {{serial}} - enviado como está',
kprofilesUnmatched: '{{count}} perfis para {{nozzle}} não tinham correspondente em {{printer}} - adicionados como novos perfis',
kprofilesSendFailed: 'Não foi possível enviar os perfis de {{nozzle}} para {{printer}} ({{serial}})',
kprofilesRefused: '{{printer}} ({{serial}}) recusou os perfis de {{nozzle}}: {{reason}}',
kprofilesStepFailed: 'Não foi possível concluir a etapa dos perfis K - {{reason}}. O que foi restaurado antes continua salvo.',
},
},
// History
history: 'Histórico',
clear: 'Limpar',
date: 'Data',
status: 'Status',
trigger: 'Tipo',
triggers: {
manual: 'Backup manual',
scheduled: 'Backup agendado',
restore: 'Restauração',
},
commit: 'Commit',
// Local Backup
+69
View File
@@ -4630,10 +4630,79 @@ export default {
backupFailed2: "Ошибка резервного копирования: {{message}}",
clearedLogs: "Очищено записей журнала: {{count}}",
failedToClearLogs: "Не удалось очистить журнал: {{message}}",
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Восстановить из Git',
title: 'Восстановление из резервной копии Git',
subtitle: 'Выберите коммит и данные для восстановления',
commitLabel: 'Коммит резервной копии',
latestCommit: 'Последняя резервная копия (вершина ветки)',
categoriesLabel: 'Что восстановить',
inspecting: 'Чтение содержимого резервной копии...',
itemCount: '{{count}} в резервной копии',
overwriteLabel: 'Перезаписывать существующие записи',
overwriteOn: 'Существующие записи будут обновлены из резервной копии.',
overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.',
selectedCount: 'Выбрано: {{count}}',
restoring: 'Восстановление...',
confirmTitle: 'Восстановить из резервной копии?',
confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.',
confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.',
kprofilesOverwriteCaveat: 'K-профили — исключение: запись в слот всегда заменяет калибровку на принтере.',
tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
partialHint: 'Перечисленные выше категории завершены и сохранены. Отсутствующие категории не выполнялись.',
failed: 'Не удалось выполнить восстановление.',
loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
details: {
notPresent: 'Отсутствует в этом коммите резервной копии',
unreadableJson: 'Нечитаемый JSON: {{paths}}',
settingsNoPayload: 'В данных нет настроек',
settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
spoolsUsageCount: 'включая записей расхода: {{count}}',
archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
kprofilesPrinterCount: 'по {{count}} принтерам',
},
notes: {
noData: 'В этой резервной копии нет данных такого типа',
archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена',
archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена',
archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
archivesOwnerUnmatched: 'Некоторые архивы указывают владельца, которого нет в этом экземпляре - владелец очищен, а не угадан по идентификатору пользователя из резервной копии, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца',
archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена',
archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
spoolUsageUnresolved: 'Записей расхода пропущено: {{count}} - их катушки нет в списке катушек этой резервной копии, поэтому привязать их не к чему.',
spoolUsageUnlinked: 'Записей расхода восстановлено без связи с историей печати: {{count}} - выберите «Архивы печати» вместе с «Инвентарём катушек», чтобы сохранить связь.',
spoolTagKept: 'Меток катушек оставлено без изменений: {{count}} - резервная копия стёрла бы метку, отсканированную позже, или перенесла бы её на другую катушку.',
settingsCredentialsSkipped: 'Ключей, похожих на учётные данные, пропущено: {{count}} - введите секреты вручную',
settingsAuthSkipped: 'Настроек аутентификации пропущено: {{count}} - меняйте их в разделе «Настройки > Аутентификация», чтобы продолжали работать проверки блокировки',
settingsCompanionSkipped: '{{keys}} оставлены выключенными - нужные им учётные данные нельзя восстановить из резервной копии, и в этом экземпляре они не сохранены, поэтому включение оставило бы интеграцию без аутентификации',
settingsMqttRelayFailed: 'Настройки MQTT восстановлены, но переподключить реле не удалось - перезапустите Bambuddy',
kprofilesAlwaysOverwrite: 'K-профили всегда перезаписывают соответствующий слот на принтере',
kprofilesAckUnreliable: 'Принтер, который не отвечает, всё равно считается восстановленным - проверьте профили на принтере',
kprofilesPrinterMissing: 'Нет принтера с серийным номером {{serial}} - пропущено',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) не подключён - пропущено',
kprofilesUnknownNozzle: 'Неожиданный диаметр сопла {{nozzle}} для {{serial}} - отправлено как есть',
kprofilesUnmatched: 'Профилей для {{nozzle}} без соответствия на {{printer}}: {{count}} - добавлены как новые профили',
kprofilesSendFailed: 'Не удалось отправить профили {{nozzle}} на {{printer}} ({{serial}})',
kprofilesRefused: '{{printer}} ({{serial}}) отклонил профили {{nozzle}}: {{reason}}',
kprofilesStepFailed: 'Не удалось завершить этап K-профилей - {{reason}}. Всё, что было восстановлено до него, сохранено.',
},
},
history: "История",
clear: "Очистить",
date: "Дата",
status: "Статус",
trigger: "Тип",
triggers: {
manual: "Резервная копия (вручную)",
scheduled: "Резервная копия (по расписанию)",
restore: "Восстановление",
},
commit: "Коммит",
localBackup: "Локальная резервная копия",
localBackupDescription: "Создать полную резервную копию данных Bambuddy, включая базу данных, архивы, загрузки и все файлы.",
+69
View File
@@ -4852,10 +4852,79 @@ export default {
clearedLogs: '{{count}} günlük temizlendi',
failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: 'Git\'ten Geri Yükle',
title: 'Git yedeğinden geri yükle',
subtitle: 'Bir commit ve geri yüklenecek verileri seçin',
commitLabel: 'Yedek commit\'i',
latestCommit: 'En son yedek (dal ucu)',
categoriesLabel: 'Neler geri yüklenecek',
inspecting: 'Yedek içeriği okunuyor...',
itemCount: 'yedekte {{count}} kayıt',
overwriteLabel: 'Mevcut kayıtların üzerine yaz',
overwriteOn: 'Mevcut kayıtlar yedekten güncellenecek.',
overwriteOff: 'Yalnızca eksik kayıtlar eklenir; mevcut olanlara dokunulmaz.',
selectedCount: '{{count}} seçildi',
restoring: 'Geri yükleniyor...',
confirmTitle: 'Yedekten geri yüklensin mi?',
confirmMessage: 'Seçilen kategoriler bu commit\'ten geri yüklenecek. Eksik kayıtlar eklenir, mevcut kayıtlar olduğu gibi kalır.',
confirmMessageOverwrite: 'Seçilen kategoriler bu commit\'ten geri yüklenecek ve yerelde bulunan kayıtların üzerine yazılacak. Bu işlem geri alınamaz.',
kprofilesOverwriteCaveat: 'K profilleri istisnadır: bir yuvaya yazmak yazıcıdaki kalibrasyonu her zaman değiştirir.',
tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
partialHint: 'Yukarıda listelenen kategoriler tamamlandı ve kaydedildi. Eksik olanlar hiç çalıştırılmadı.',
failed: 'Geri yükleme başarısız oldu.',
loadFailed: 'Yedek deposu okunamadı.',
details: {
notPresent: 'Bu yedek commit\'inde yok',
unreadableJson: 'Okunamayan JSON: {{paths}}',
settingsNoPayload: 'Veride ayar yok',
settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
spoolsUsageCount: '{{count}} kullanım kaydı dahil',
archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
kprofilesPrinterCount: '{{count}} yazıcı genelinde',
},
notes: {
noData: 'Bu yedekte bu türde veri yok',
archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi',
archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi',
archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
archivesOwnerUnmatched: 'Bazı arşivler bu örnekte bulunmayan bir sahibi belirtiyor - sahip, yedekteki kullanıcı kimliğinden tahmin edilmek yerine temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
archivesOwnerUnknown: 'Bazı arşivler sahipsiz geri yüklendi - bu yedek sahip bilgisi içermiyor, bu yüzden bir yönetici atama yapana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı',
archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
spoolUsageUnresolved: '{{count}} kullanım kaydı atlandı - makaraları bu yedeğin makara listesinde olmadığı için bağlanacak bir şey yok.',
spoolUsageUnlinked: '{{count}} kullanım kaydı baskı geçmişi bağlantısı olmadan geri yüklendi - bağlantıyı korumak için Baskı arşivlerini Makara envanteriyle birlikte seçin.',
spoolTagKept: '{{count}} makara etiketi olduğu gibi bırakıldı - yedek, o zamandan beri okutulmuş bir etiketi silecek ya da ikinci bir makaraya taşıyacaktı.',
settingsCredentialsSkipped: 'Kimlik bilgisi benzeri {{count}} anahtar atlandı - gizli değerleri elle yeniden girin',
settingsAuthSkipped: '{{count}} kimlik doğrulama ayarı atlandı - kilitlenme kontrolleri çalışmaya devam etsin diye bunları Ayarlar > Kimlik Doğrulama bölümünden değiştirin',
settingsCompanionSkipped: '{{keys}} kapalı bırakıldı - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez ve bu örnekte kayıtlı değil, dolayısıyla açmak entegrasyonu kimlik doğrulamasız bırakırdı',
settingsMqttRelayFailed: 'MQTT ayarları geri yüklendi ancak röle yeniden bağlanamadı - Bambuddy\'yi yeniden başlatın',
kprofilesAlwaysOverwrite: 'K profilleri yazıcıdaki eşleşen yuvanın her zaman üzerine yazar',
kprofilesAckUnreliable: 'Yanıt vermeyen bir yazıcı yine de geri yüklendi sayılır - profilleri yazıcıda doğrulayın',
kprofilesPrinterMissing: '{{serial}} seri numaralı yazıcı yok - atlandı',
kprofilesPrinterOffline: '{{printer}} ({{serial}}) bağlı değil - atlandı',
kprofilesUnknownNozzle: '{{serial}} için beklenmeyen nozul çapı {{nozzle}} - olduğu gibi gönderildi',
kprofilesUnmatched: '{{nozzle}} için {{count}} profilin {{printer}} üzerinde karşılığı yoktu - yeni profil olarak eklendi',
kprofilesSendFailed: '{{nozzle}} profilleri {{printer}} ({{serial}}) yazıcısına gönderilemedi',
kprofilesRefused: '{{printer}} ({{serial}}) {{nozzle}} profillerini reddetti: {{reason}}',
kprofilesStepFailed: 'K profili adımı tamamlanamadı - {{reason}}. Bundan önce geri yüklenenler yine de kaydedildi.',
},
},
history: 'Geçmiş',
clear: 'Temizle',
date: 'Tarih',
status: 'Durum',
trigger: 'Tür',
triggers: {
manual: 'Yedek (manuel)',
scheduled: 'Yedek (zamanlanmış)',
restore: 'Geri yükleme',
},
commit: 'Commit',
// Yerel Yedekleme
+69
View File
@@ -4917,11 +4917,80 @@ export default {
clearedLogs: "Очищено журнали {{count}}.",
failedToClearLogs: "Не вдалося очистити журнали: {{message}}",
// Restore from Git backup (#2656)
restoreFromGit: {
button: "Відновити з Git",
title: "Відновлення з резервної копії Git",
subtitle: "Виберіть коміт і вкажіть, що відновити",
commitLabel: "Коміт резервної копії",
latestCommit: "Остання резервна копія (вершина гілки)",
categoriesLabel: "Що відновити",
inspecting: "Читання вмісту резервної копії...",
itemCount: "{{count}} у резервній копії",
overwriteLabel: "Перезаписувати наявні записи",
overwriteOn: "Наявні записи буде оновлено з резервної копії.",
overwriteOff: "Додаються лише відсутні записи; наявні залишаються без змін.",
selectedCount: "Вибрано: {{count}}",
restoring: "Відновлення...",
confirmTitle: "Відновити з резервної копії?",
confirmMessage: "Вибрані категорії буде відновлено з цього коміту. Відсутні записи буде додано, наявні залишаться без змін.",
confirmMessageOverwrite: "Вибрані категорії буде відновлено з цього коміту з перезаписом записів, які вже існують локально. Цю дію не можна скасувати.",
kprofilesOverwriteCaveat: 'K-профілі — виняток: запис у слот завжди замінює калібрування на принтері.',
tally: "відновлено: {{restored}}, пропущено: {{skipped}}, з помилкою: {{failed}}",
reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
partialHint: 'Перелічені вище категорії завершено та збережено. Відсутні категорії не виконувалися.',
failed: "Не вдалося виконати відновлення.",
loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
details: {
notPresent: "Відсутнє в цьому коміті резервної копії",
unreadableJson: "Нечитабельний JSON: {{paths}}",
settingsNoPayload: "У даних немає налаштувань",
settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
spoolsUsageCount: "включно із записами використання: {{count}}",
archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
kprofilesPrinterCount: "по {{count}} принтерах",
},
notes: {
noData: "У цій резервній копії немає даних такого типу",
archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено",
archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено",
archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
archivesOwnerUnmatched: "Деякі архіви вказують власника, якого немає в цьому екземплярі - власника очищено, а не вгадано за ідентифікатором користувача з резервної копії, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника",
archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений",
archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
spoolUsageUnresolved: "Записів використання пропущено: {{count}} - їхньої котушки немає у списку котушок цієї резервної копії, тож немає до чого їх прив'язати.",
spoolUsageUnlinked: "Записів використання відновлено без зв'язку з історією друку: {{count}} - виберіть «Архіви друку» разом з «Інвентарем котушок», щоб зберегти зв'язок.",
spoolTagKept: "Міток котушок залишено без змін: {{count}} - резервна копія стерла б мітку, відскановану пізніше, або перенесла б її на іншу котушку.",
settingsCredentialsSkipped: "Ключів, схожих на облікові дані, пропущено: {{count}} - введіть секрети вручну",
settingsAuthSkipped: "Налаштувань автентифікації пропущено: {{count}} - змінюйте їх у розділі «Налаштування > Автентифікація», щоб перевірки блокування й далі працювали",
settingsCompanionSkipped: "{{keys}} залишено вимкненими - потрібні їм облікові дані не можна відновити з резервної копії, і в цьому екземплярі вони не збережені, тож увімкнення залишило б інтеграцію без автентифікації",
settingsMqttRelayFailed: "Налаштування MQTT відновлено, але реле не вдалося перепідключити - перезапустіть Bambuddy",
kprofilesAlwaysOverwrite: "K-профілі завжди перезаписують відповідний слот на принтері",
kprofilesAckUnreliable: "Принтер, який не відповідає, усе одно вважається відновленим - перевірте профілі на принтері",
kprofilesPrinterMissing: "Немає принтера із серійним номером {{serial}} - пропущено",
kprofilesPrinterOffline: "{{printer}} ({{serial}}) не підключено - пропущено",
kprofilesUnknownNozzle: "Неочікуваний діаметр сопла {{nozzle}} для {{serial}} - надіслано як є",
kprofilesUnmatched: "Профілів для {{nozzle}} без відповідника на {{printer}}: {{count}} - додано як нові профілі",
kprofilesSendFailed: "Не вдалося надіслати профілі {{nozzle}} на {{printer}} ({{serial}})",
kprofilesRefused: "{{printer}} ({{serial}}) відхилив профілі {{nozzle}}: {{reason}}",
kprofilesStepFailed: "Не вдалося завершити етап K-профілів - {{reason}}. Усе, що було відновлено до нього, збережено.",
},
},
// History
history: "історія",
clear: "Очистити",
date: "Дата",
status: "Статус",
trigger: "Тип",
triggers: {
manual: "Резервна копія (вручну)",
scheduled: "Резервна копія (за розкладом)",
restore: "Відновлення",
},
commit: "Коміт",
// Local Backup
+69
View File
@@ -4862,11 +4862,80 @@ export default {
clearedLogs: '已清除 {{count}} 条日志',
failedToClearLogs: '清除日志失败:{{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '从 Git 恢复',
title: '从 Git 备份恢复',
subtitle: '选择提交以及要恢复的内容',
commitLabel: '备份提交',
latestCommit: '最新备份(分支最新提交)',
categoriesLabel: '恢复内容',
inspecting: '正在读取备份内容...',
itemCount: '备份中有 {{count}} 项',
overwriteLabel: '覆盖已有条目',
overwriteOn: '已有条目将根据备份内容更新。',
overwriteOff: '仅添加缺失的条目,已有条目保持不变。',
selectedCount: '已选择 {{count}} 项',
restoring: '正在恢复...',
confirmTitle: '要从备份恢复吗?',
confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。',
confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。',
kprofilesOverwriteCaveat: 'K 值配置是例外:写入插槽总会替换打印机上的校准数据。',
tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
partialHint: '上面列出的类别已完成并已保存。未列出的类别没有执行。',
failed: '恢复失败。',
loadFailed: '无法读取备份仓库。',
details: {
notPresent: '此备份提交中不存在',
unreadableJson: '无法解析的 JSON:{{paths}}',
settingsNoPayload: '数据中没有设置',
settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
spoolsUsageCount: '其中含 {{count}} 条使用记录',
archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
kprofilesPrinterCount: '涉及 {{count}} 台打印机',
},
notes: {
noData: '此备份中没有这类数据',
archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联',
archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联',
archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
archivesOwnerUnmatched: '部分归档指向本实例没有的用户 - 已清除归属,而不是根据备份中的用户 ID 猜测,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖',
archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中',
spoolUsageUnresolved: '已跳过 {{count}} 条使用记录 - 其耗材卷不在此备份的耗材列表中,没有可挂接的对象。',
spoolUsageUnlinked: '已恢复 {{count}} 条使用记录,但缺少打印历史关联 - 请同时选择“打印归档”和“耗材库存”以保留该关联。',
spoolTagKept: '{{count}} 个耗材标签保持原样 - 按备份内容会清除此后扫描过的标签,或把它挪到另一卷耗材上。',
settingsCredentialsSkipped: '已跳过 {{count}} 个疑似凭据的键 - 请手动重新输入密钥',
settingsAuthSkipped: '已跳过 {{count}} 项认证设置 - 请在“设置 > 认证”中修改,以便锁定检查继续生效',
settingsCompanionSkipped: '{{keys}} 保持关闭 - 它们各自所需的凭据无法从备份恢复,本实例也没有存储,开启会让集成处于未认证状态',
settingsMqttRelayFailed: 'MQTT 设置已恢复,但中继无法重新连接 - 请重启 Bambuddy',
kprofilesAlwaysOverwrite: 'K 值配置总是覆盖打印机上对应的槽位',
kprofilesAckUnreliable: '打印机不回应时仍计为已恢复 - 请在打印机上核对配置',
kprofilesPrinterMissing: '没有序列号为 {{serial}} 的打印机 - 已跳过',
kprofilesPrinterOffline: '{{printer}}({{serial}})未连接 - 已跳过',
kprofilesUnknownNozzle: '{{serial}} 的喷嘴直径 {{nozzle}} 不在预期范围内 - 已原样发送',
kprofilesUnmatched: '{{nozzle}} 的 {{count}} 个配置在 {{printer}} 上没有对应项 - 已作为新配置添加',
kprofilesSendFailed: '无法将 {{nozzle}} 的配置发送到 {{printer}}({{serial}})',
kprofilesRefused: '{{printer}}({{serial}})拒绝了 {{nozzle}} 的配置:{{reason}}',
kprofilesStepFailed: 'K 值配置步骤未能完成 - {{reason}}。在此之前恢复的内容仍已保存。',
},
},
// History
history: '历史记录',
clear: '清除',
date: '日期',
status: '状态',
trigger: '类型',
triggers: {
manual: '备份(手动)',
scheduled: '备份(计划)',
restore: '恢复',
},
commit: '提交',
// Local Backup
+69
View File
@@ -4862,11 +4862,80 @@ export default {
clearedLogs: '已清除 {{count}} 條日誌',
failedToClearLogs: '清除日誌失敗:{{message}}',
// Restore from Git backup (#2656)
restoreFromGit: {
button: '從 Git 還原',
title: '從 Git 備份還原',
subtitle: '選擇提交以及要還原的項目',
commitLabel: '備份提交',
latestCommit: '最新備份(分支最新提交)',
categoriesLabel: '還原項目',
inspecting: '正在讀取備份內容...',
itemCount: '備份中有 {{count}} 筆',
overwriteLabel: '覆寫既有項目',
overwriteOn: '既有項目將依備份內容更新。',
overwriteOff: '僅新增缺少的項目,既有項目保持不變。',
selectedCount: '已選擇 {{count}} 筆',
restoring: '正在還原...',
confirmTitle: '要從備份還原嗎?',
confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。',
confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。',
kprofilesOverwriteCaveat: 'K 值設定檔是例外:寫入插槽一定會取代印表機上的校準資料。',
tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
partialHint: '上方列出的類別已完成並已儲存。未列出的類別沒有執行。',
failed: '還原失敗。',
loadFailed: '無法讀取備份儲存庫。',
details: {
notPresent: '此備份提交中不存在',
unreadableJson: '無法解析的 JSON:{{paths}}',
settingsNoPayload: '資料中沒有設定',
settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
},
notes: {
noData: '此備份中沒有這類資料',
archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結',
archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結',
archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
archivesOwnerUnmatched: '部分封存指向本執行個體沒有的使用者 - 已清除擁有者,而非依備份中的使用者 ID 推測,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到',
archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫',
archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
spoolUsageUnresolved: '已略過 {{count}} 筆使用紀錄 - 其耗材捲不在此備份的耗材清單中,沒有可掛接的對象。',
spoolUsageUnlinked: '已還原 {{count}} 筆使用紀錄,但缺少列印歷史連結 - 請同時選擇「列印封存」與「耗材庫存」以保留該連結。',
spoolTagKept: '{{count}} 個耗材標籤維持原樣 - 依備份內容會清除此後掃描過的標籤,或把它移到另一捲耗材上。',
settingsCredentialsSkipped: '已略過 {{count}} 個疑似憑證的鍵 - 請手動重新輸入密鑰',
settingsAuthSkipped: '已略過 {{count}} 項驗證設定 - 請在「設定 > 驗證」中修改,讓鎖定檢查繼續生效',
settingsCompanionSkipped: '{{keys}} 維持關閉 - 它們各自所需的憑證無法從備份還原,本執行個體也沒有儲存,開啟會讓整合處於未驗證狀態',
settingsMqttRelayFailed: 'MQTT 設定已還原,但中繼無法重新連線 - 請重新啟動 Bambuddy',
kprofilesAlwaysOverwrite: 'K 值設定檔一律覆寫印表機上對應的插槽',
kprofilesAckUnreliable: '印表機未回應時仍計為已還原 - 請在印表機上核對設定檔',
kprofilesPrinterMissing: '沒有序號為 {{serial}} 的印表機 - 已略過',
kprofilesPrinterOffline: '{{printer}}({{serial}})未連線 - 已略過',
kprofilesUnknownNozzle: '{{serial}} 的噴嘴直徑 {{nozzle}} 不在預期範圍內 - 已原樣傳送',
kprofilesUnmatched: '{{nozzle}} 的 {{count}} 個設定檔在 {{printer}} 上沒有對應項 - 已新增為新設定檔',
kprofilesSendFailed: '無法將 {{nozzle}} 的設定檔傳送到 {{printer}}({{serial}})',
kprofilesRefused: '{{printer}}({{serial}})拒絕了 {{nozzle}} 的設定檔:{{reason}}',
kprofilesStepFailed: 'K 值設定檔步驟未能完成 - {{reason}}。在此之前還原的內容仍已儲存。',
},
},
// History
history: '歷史紀錄',
clear: '清除',
date: '日期',
status: '狀態',
trigger: '類型',
triggers: {
manual: '備份(手動)',
scheduled: '備份(排程)',
restore: '還原',
},
commit: '提交',
// Local Backup
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-joRUZURS.js"></script>
<script type="module" crossorigin src="/assets/index-BPSw6nnF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
</head>
<body>