diff --git a/backend/app/schemas/github_backup.py b/backend/app/schemas/github_backup.py
index 7ee0cb2c5..b66011b21 100644
--- a/backend/app/schemas/github_backup.py
+++ b/backend/app/schemas/github_backup.py
@@ -254,7 +254,13 @@ class GitHubRestorePreviewCategory(BaseModel):
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")
+ 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):
@@ -289,13 +295,28 @@ class GitHubRestoreRequest(BaseModel):
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[str] = Field(default_factory=list)
+ notes: list[GitHubRestoreNote] = Field(default_factory=list)
class GitHubRestoreResponse(BaseModel):
diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py
index 4a7f1b688..e067d63d0 100644
--- a/backend/app/services/github_restore.py
+++ b/backend/app/services/github_restore.py
@@ -33,7 +33,7 @@ import json
import logging
import os
import re
-from dataclasses import dataclass
+from dataclasses import dataclass, field as dataclasses_field
from datetime import datetime, timezone
import httpx
@@ -227,6 +227,19 @@ class _SettingsPlan:
return len(self.blocked) + len(self.protected) + len(self.companion)
+@dataclass(frozen=True)
+class _Detail:
+ """A preview caveat, as a translation code plus its English rendering.
+
+ Same contract as a note: the client translates ``code`` with ``params`` and
+ falls back to ``message``.
+ """
+
+ code: str
+ message: str
+ params: dict[str, str | int] = dataclasses_field(default_factory=dict)
+
+
class _CategoryTally:
"""Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
@@ -234,13 +247,21 @@ class _CategoryTally:
self.restored = 0
self.skipped = 0
self.failed = 0
- self.notes: list[str] = []
+ self.notes: list[dict] = []
- def note(self, message: str) -> None:
- # Notes are surfaced verbatim in the UI, so keep the list bounded rather
- # than emitting one line per row for a large backup.
- if message not in self.notes and len(self.notes) < 20:
- self.notes.append(message)
+ def note(self, code: str, message: str, **params) -> None:
+ """Record a note as a translation code, its params and an English fallback.
+
+ Deduped on ``(code, params)`` rather than on the rendered text, which is
+ the same thing today but keeps two notes that differ only in a printer
+ name from collapsing into one. Bounded for the reason it always was: the
+ UI renders every note, so a large backup must not emit one per row.
+ """
+ if any(existing["code"] == code and existing["params"] == params for existing in self.notes):
+ return
+ if len(self.notes) >= 20:
+ return
+ self.notes.append({"code": code, "params": params, "message": message})
def as_dict(self) -> dict:
return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
@@ -446,27 +467,23 @@ class GitHubRestoreService:
paths = self._category_paths(category, available)
if not paths:
categories.append(
- {
- "category": category,
- "available": False,
- "item_count": 0,
- "detail": "Not present in this backup commit",
- }
+ self._category_entry(category, False, 0, _Detail("notPresent", "Not present in this backup commit"))
)
continue
unreadable = [p for p in paths if p in bad_paths]
if unreadable:
+ joined = ", ".join(unreadable)
categories.append(
- {
- "category": category,
- "available": False,
- "item_count": 0,
- "detail": f"Unreadable JSON: {', '.join(unreadable)}",
- }
+ self._category_entry(
+ category,
+ False,
+ 0,
+ _Detail("unreadableJson", f"Unreadable JSON: {joined}", {"paths": joined}),
+ )
)
continue
count, detail = await self._count_items(db, category, parsed)
- categories.append({"category": category, "available": True, "item_count": count, "detail": detail})
+ categories.append(self._category_entry(category, True, count, detail))
commit_info = None
commits = (await self.list_commits(config, limit=20)).get("commits") or []
@@ -484,13 +501,27 @@ class GitHubRestoreService:
"categories": categories,
}
- async def _count_items(self, db: AsyncSession, category: RestoreCategory, parsed: dict) -> tuple[int, str | None]:
+ @staticmethod
+ def _category_entry(category: RestoreCategory, available: bool, item_count: int, detail: _Detail | None) -> dict:
+ """Shape one ``GitHubRestorePreviewCategory``, translated detail included."""
+ return {
+ "category": category,
+ "available": available,
+ "item_count": item_count,
+ "detail": detail.message if detail else None,
+ "detail_code": detail.code if detail else None,
+ "detail_params": detail.params if detail else {},
+ }
+
+ async def _count_items(
+ self, db: AsyncSession, category: RestoreCategory, parsed: dict
+ ) -> tuple[int, _Detail | None]:
"""Count restorable items for ``category`` and describe any caveat."""
if category == RestoreCategory.SETTINGS:
payload = parsed.get(SETTINGS_PATH)
values = payload.get("settings") if isinstance(payload, dict) else None
if not isinstance(values, dict):
- return 0, "No settings in payload"
+ return 0, _Detail("settingsNoPayload", "No settings in payload")
# Every refusal is subtracted so the count matches what the restore
# actually writes. The wording calls out the credential ones (what a
# user might expect to come back) and the companion ones (a
@@ -499,12 +530,18 @@ class GitHubRestoreService:
plan = await self._plan_settings(db, values)
detail = None
if plan.companion:
- detail = (
+ detail = _Detail(
+ "settingsCompanionWillSkip",
f"{len(plan.blocked)} credential-like key(s) will be skipped, and "
- f"{len(plan.companion)} switch(es) that depend on them will be left off"
+ f"{len(plan.companion)} switch(es) that depend on them will be left off",
+ {"count": len(plan.blocked), "companion": len(plan.companion)},
)
elif plan.blocked:
- detail = f"{len(plan.blocked)} credential-like keys will be skipped"
+ detail = _Detail(
+ "settingsCredentialsWillSkip",
+ f"{len(plan.blocked)} credential-like keys will be skipped",
+ {"count": len(plan.blocked)},
+ )
return len(values) - plan.refused_count, detail
if category == RestoreCategory.SPOOLS:
@@ -513,14 +550,18 @@ class GitHubRestoreService:
usage_payload = parsed.get(SPOOL_USAGE_PATH)
usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
count = len(spools) if isinstance(spools, list) else 0
- detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None
+ detail = None
+ if isinstance(usage, list) and usage:
+ detail = _Detail("spoolsUsageCount", f"plus {len(usage)} usage records", {"count": len(usage)})
return count, detail
if category == RestoreCategory.ARCHIVES:
payload = parsed.get(ARCHIVES_PATH)
archives = payload.get("archives") if isinstance(payload, dict) else None
count = len(archives) if isinstance(archives, list) else 0
- return count, "Metadata only — 3MF files and thumbnails are not in a Git backup"
+ return count, _Detail(
+ "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup"
+ )
if category == RestoreCategory.KPROFILES:
total = 0
@@ -533,7 +574,9 @@ class GitHubRestoreService:
profiles = payload.get("profiles")
if isinstance(profiles, list):
total += len(profiles)
- detail = f"across {len(serials)} printer(s)" if serials else None
+ detail = None
+ if serials:
+ detail = _Detail("kprofilesPrinterCount", f"across {len(serials)} printer(s)", {"count": len(serials)})
return total, detail
return 0, None
@@ -756,7 +799,7 @@ class GitHubRestoreService:
) -> None:
archives = payload.get("archives") if isinstance(payload, dict) else None
if not isinstance(archives, list):
- tally.note("No archive data in this backup")
+ tally.note("noData", "No data of this kind in this backup")
return
valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
@@ -825,11 +868,15 @@ class GitHubRestoreService:
printer_id = entry.get("printer_id")
if printer_id is not None and printer_id not in valid_printers:
- tally.note("Some archives referenced printers that no longer exist — link cleared")
+ tally.note(
+ "archivesPrinterMissing", "Some archives referenced printers that no longer exist — link cleared"
+ )
printer_id = None
project_id = entry.get("project_id")
if project_id is not None and project_id not in valid_projects:
- tally.note("Some archives referenced projects that no longer exist — link cleared")
+ tally.note(
+ "archivesProjectMissing", "Some archives referenced projects that no longer exist — link cleared"
+ )
project_id = None
created_by_id = entry.get("created_by_id")
if created_by_id is not None and created_by_id not in valid_users:
@@ -838,8 +885,9 @@ class GitHubRestoreService:
# cleared owner is not silent-safe — the archive becomes visible
# only to archives:read_all until someone does.
tally.note(
+ "archivesOwnerCleared",
"Some archives referenced users that no longer exist — owner cleared, so they are "
- "visible only to users with the archives:read_all permission until an admin reassigns them"
+ "visible only to users with the archives:read_all permission until an admin reassigns them",
)
created_by_id = None
fields["printer_id"] = printer_id
@@ -857,7 +905,10 @@ class GitHubRestoreService:
# taken. Legitimate, but not obvious from a restored/skipped
# count, so say it.
if existing.deleted_at is not None and fields["deleted_at"] is None:
- tally.note("Archive(s) deleted since the backup are visible again — overwrite was on")
+ tally.note(
+ "archivesUndeleted",
+ "Archive(s) deleted since the backup are visible again — overwrite was on",
+ )
for key, value in fields.items():
setattr(existing, key, value)
tally.restored += 1
@@ -865,7 +916,8 @@ class GitHubRestoreService:
if not warned_files:
tally.note(
- "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup"
+ "archivesMetadataOnly",
+ "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup",
)
warned_files = True
@@ -934,7 +986,7 @@ class GitHubRestoreService:
) -> None:
spools = inventory.get("spools") if isinstance(inventory, dict) else None
if not isinstance(spools, list):
- tally.note("No spool data in this backup")
+ tally.note("noData", "No data of this kind in this backup")
return
spool_id_map: dict[int, int] = {}
@@ -1114,13 +1166,17 @@ class GitHubRestoreService:
if unresolved:
tally.note(
+ "spoolUsageUnresolved",
f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
- "spool list, so there is nothing to attach them to."
+ "spool list, so there is nothing to attach them to.",
+ count=unresolved,
)
if unlinked_archives:
tally.note(
+ "spoolUsageUnlinked",
f"{unlinked_archives} usage record(s) restored without their print-history link — "
- "select Print archives alongside Spool inventory to keep it."
+ "select Print archives alongside Spool inventory to keep it.",
+ count=unlinked_archives,
)
async def _restore_settings(
@@ -1133,7 +1189,7 @@ class GitHubRestoreService:
) -> None:
values = payload.get("settings") if isinstance(payload, dict) else None
if not isinstance(values, dict):
- tally.note("No settings data in this backup")
+ tally.note("noData", "No data of this kind in this backup")
return
# Planned before the first write, so the companion rule reads genuinely
@@ -1176,17 +1232,27 @@ class GitHubRestoreService:
keys_written.add(key)
if plan.blocked:
- tally.note(f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually")
+ tally.note(
+ "settingsCredentialsSkipped",
+ f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually",
+ count=len(plan.blocked),
+ )
if plan.protected:
tally.note(
+ "settingsAuthSkipped",
f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > "
- "Authentication so the lockout checks still run"
+ "Authentication so the lockout checks still run",
+ count=len(plan.protected),
)
if plan.companion:
+ keys = ", ".join(sorted(plan.companion))
tally.note(
- f"{', '.join(sorted(plan.companion))} 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"
+ "settingsCompanionSkipped",
+ f"{keys} left switched off — the credential each one needs cannot be restored from a "
+ "backup and this instance has none stored, so switching them on would leave the "
+ "integration unauthenticated",
+ keys=keys,
+ count=len(plan.companion),
)
async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
@@ -1232,7 +1298,10 @@ class GitHubRestoreService:
# must not turn a successful restore into a failed one. Noted rather
# than swallowed silently, so the user knows to restart.
logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
- tally.note("MQTT settings restored, but the relay could not be reconnected — restart Bambuddy")
+ tally.note(
+ "settingsMqttRelayFailed",
+ "MQTT settings restored, but the relay could not be reconnected — restart Bambuddy",
+ )
async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
by_serial: dict[str, list[tuple[str, dict]]] = {}
@@ -1243,7 +1312,7 @@ class GitHubRestoreService:
by_serial.setdefault(match.group(1), []).append((match.group(2), content))
if not by_serial:
- tally.note("No K-profile data in this backup")
+ tally.note("noData", "No data of this kind in this backup")
return
result = await db.execute(select(Printer))
@@ -1252,8 +1321,11 @@ class GitHubRestoreService:
# Overwrite is not offered for K-profiles: extrusion_cali_set replaces
# the profile occupying a slot, so writing is always an overwrite on the
# printer side.
- tally.note("K-profiles always overwrite the matching slot on the printer")
- tally.note("The printer's acknowledgement is not reliable — verify the profiles on the printer")
+ tally.note("kprofilesAlwaysOverwrite", "K-profiles always overwrite the matching slot on the printer")
+ tally.note(
+ "kprofilesAckUnreliable",
+ "The printer's acknowledgement is not reliable — verify the profiles on the printer",
+ )
for serial, entries in sorted(by_serial.items()):
profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
@@ -1261,13 +1333,18 @@ class GitHubRestoreService:
printer = printers.get(serial)
if printer is None:
tally.skipped += profile_total
- tally.note(f"No printer with serial {serial} — skipped")
+ tally.note("kprofilesPrinterMissing", f"No printer with serial {serial} — skipped", serial=serial)
continue
client = printer_manager.get_client(printer.id)
if not client or not client.state.connected:
tally.skipped += profile_total
- tally.note(f"{printer.name} ({serial}) is not connected — skipped")
+ tally.note(
+ "kprofilesPrinterOffline",
+ f"{printer.name} ({serial}) is not connected — skipped",
+ printer=printer.name,
+ serial=serial,
+ )
continue
for nozzle, content in sorted(entries):
@@ -1275,7 +1352,12 @@ class GitHubRestoreService:
if not isinstance(profiles, list) or not profiles:
continue
if nozzle not in _KNOWN_NOZZLES:
- tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is")
+ tally.note(
+ "kprofilesUnknownNozzle",
+ f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is",
+ nozzle=nozzle,
+ serial=serial,
+ )
# The backup's slot_id is a cali_idx, and cali_idx is as
# unstable as the autoincrement ids we already refuse to reuse
@@ -1316,8 +1398,12 @@ class GitHubRestoreService:
continue
if unmatched:
tally.note(
+ "kprofilesUnmatched",
f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
- "— added as new profiles"
+ "— added as new profiles",
+ count=unmatched,
+ nozzle=nozzle,
+ printer=printer.name,
)
try:
@@ -1330,7 +1416,13 @@ class GitHubRestoreService:
tally.restored += len(profile_dicts)
else:
tally.failed += len(profile_dicts)
- tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})")
+ tally.note(
+ "kprofilesSendFailed",
+ f"Failed to send {nozzle} profiles to {printer.name} ({serial})",
+ nozzle=nozzle,
+ printer=printer.name,
+ serial=serial,
+ )
@staticmethod
async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:
diff --git a/backend/tests/integration/test_github_restore_api.py b/backend/tests/integration/test_github_restore_api.py
index 3032dd8de..673b3edc9 100644
--- a/backend/tests/integration/test_github_restore_api.py
+++ b/backend/tests/integration/test_github_restore_api.py
@@ -169,7 +169,18 @@ class TestRestoreEndpoint:
"ref": "aaa1111",
"results": {
"spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
- "settings": {"restored": 1, "skipped": 2, "failed": 0, "notes": ["1 credential-like key(s) skipped"]},
+ "settings": {
+ "restored": 1,
+ "skipped": 2,
+ "failed": 0,
+ "notes": [
+ {
+ "code": "settingsCredentialsSkipped",
+ "params": {"count": 1},
+ "message": "1 credential-like key(s) skipped",
+ }
+ ],
+ },
},
}
with patch(
@@ -184,7 +195,15 @@ class TestRestoreEndpoint:
assert response.status_code == 200
body = response.json()
assert body["results"]["spools"]["restored"] == 4
- assert body["results"]["settings"]["notes"] == ["1 credential-like key(s) skipped"]
+ # 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"
@@ -391,7 +410,7 @@ class TestRestoreDoesNotOpenTheMetricsEndpoint:
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("switched off" in note for note in tally.notes)
+ assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes)
@pytest.mark.asyncio
@pytest.mark.integration
diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py
index a7170c7c3..60675b1e9 100644
--- a/backend/tests/unit/test_github_restore.py
+++ b/backend/tests/unit/test_github_restore.py
@@ -41,6 +41,20 @@ def _service() -> GitHubRestoreService:
return GitHubRestoreService()
+def _messages(tally: _CategoryTally) -> list[str]:
+ """The English rendering of each note.
+
+ Notes are ``{code, params, message}`` since they became translatable
+ (#2656); asserting on the message keeps these tests readable while
+ ``_codes`` covers the half a client actually keys on.
+ """
+ return [note["message"] for note in tally.notes]
+
+
+def _codes(tally: _CategoryTally) -> list[str]:
+ return [note["code"] for note in tally.notes]
+
+
class TestParseDt:
def test_parses_str_datetime_the_backup_writes(self):
assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
@@ -93,16 +107,33 @@ class TestSettingKeyBlocklist:
class TestCategoryTally:
+ def test_a_note_carries_code_params_and_english(self):
+ tally = _CategoryTally()
+ tally.note("noData", "No data of this kind in this backup")
+ tally.note("spoolUsageUnresolved", "2 usage record(s) skipped", count=2)
+
+ assert tally.notes == [
+ {"code": "noData", "params": {}, "message": "No data of this kind in this backup"},
+ {"code": "spoolUsageUnresolved", "params": {"count": 2}, "message": "2 usage record(s) skipped"},
+ ]
+
def test_notes_are_deduplicated(self):
tally = _CategoryTally()
- tally.note("same")
- tally.note("same")
- assert tally.notes == ["same"]
+ tally.note("noData", "same")
+ tally.note("noData", "same")
+ assert len(tally.notes) == 1
+
+ def test_the_same_code_with_different_params_is_kept(self):
+ """Two printers can both be offline, and the user needs both names."""
+ tally = _CategoryTally()
+ tally.note("kprofilesPrinterOffline", "A is not connected", printer="A")
+ tally.note("kprofilesPrinterOffline", "B is not connected", printer="B")
+ assert len(tally.notes) == 2
def test_notes_are_bounded(self):
tally = _CategoryTally()
for i in range(50):
- tally.note(f"note {i}")
+ tally.note("noData", f"note {i}", index=i)
assert len(tally.notes) == 20
@@ -185,7 +216,7 @@ class TestRestoreSettings:
# keys, so counting them here would put the total above what the user
# was shown before they pressed Restore.
assert tally.skipped == 0
- assert any("credential-like" in note for note in tally.notes)
+ assert any("credential-like" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_auth_settings_are_never_restored(self, db_session):
@@ -217,14 +248,14 @@ class TestRestoreSettings:
# As above: refused keys are outside the preview's count, so outside the
# tally too.
assert tally.skipped == 0
- assert any("authentication setting" in note for note in tally.notes)
+ assert any("authentication setting" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_missing_payload_is_noted_not_fatal(self, db_session):
tally = _CategoryTally()
await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
assert tally.restored == 0
- assert tally.notes
+ assert _codes(tally) == ["noData"]
class TestSettingValueIsTrue:
@@ -282,7 +313,7 @@ class TestCompanionCredentials:
rows = await self._rows(db_session)
assert rows == {"currency": "EUR"}
- assert any("prometheus_enabled" in note and "switched off" in note for note in tally.notes)
+ assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally))
@pytest.mark.asyncio
@pytest.mark.parametrize("toggle,credential", sorted(_COMPANION_CREDENTIALS.items()))
@@ -367,8 +398,11 @@ class TestCompanionCredentials:
allowed_count, allowed_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
assert refused_count == allowed_count - 1
- assert "switch(es)" in refused_detail
- assert "switch(es)" not in allowed_detail
+ assert refused_detail.code == "settingsCompanionWillSkip"
+ assert refused_detail.params == {"count": 1, "companion": 1}
+ # Nothing is being left off now, so the wording drops back to the plain
+ # credential caveat.
+ assert allowed_detail.code == "settingsCredentialsWillSkip"
# --- Controls: over-refusal is the real risk here ----------------------
@@ -380,7 +414,7 @@ class TestCompanionCredentials:
tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
assert (await self._rows(db_session))["prometheus_enabled"] == "true"
- assert not any("switched off" in note for note in tally.notes)
+ assert not any("switched off" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_an_anonymous_broker_is_not_a_false_positive(self, db_session):
@@ -388,7 +422,7 @@ class TestCompanionCredentials:
tally = await self._restore(db_session, mqtt_enabled="true", mqtt_broker="10.0.0.5")
assert (await self._rows(db_session))["mqtt_enabled"] == "true"
- assert not any("switched off" in note for note in tally.notes)
+ assert not any("switched off" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_an_anonymous_ldap_bind_is_not_a_false_positive(self, db_session):
@@ -396,7 +430,7 @@ class TestCompanionCredentials:
tally = await self._restore(db_session, ldap_enabled="true", ldap_bind_password=" ")
assert (await self._rows(db_session))["ldap_enabled"] == "true"
- assert not any("switched off" in note for note in tally.notes)
+ assert not any("switched off" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_turning_a_toggle_off_is_always_written(self, db_session):
@@ -424,7 +458,7 @@ class TestCompanionCredentials:
tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true", prometheus_token="s3cret")
assert (await self._rows(db_session))["prometheus_enabled"] == "true"
- assert not any("switched off" in note for note in tally.notes)
+ assert not any("switched off" in note for note in _messages(tally))
# --- The map itself ----------------------------------------------------
@@ -622,11 +656,11 @@ class TestRestoreSpools:
assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
assert tally.skipped == 1
- assert any("their spool is not in this backup's spool list" in note for note in tally.notes)
+ assert any("their spool is not in this backup's spool list" in note for note in _messages(tally))
# No remedy is offered, because none exists: overwrite does not change
# which spools land in the map (a skipped spool is mapped anyway), and
# usage history is always restored alongside the spools category.
- assert not any("overwrite" in note.lower() for note in tally.notes)
+ assert not any("overwrite" in note.lower() for note in _messages(tally))
@pytest.mark.asyncio
async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
@@ -651,7 +685,7 @@ class TestRestoreSpools:
spool = (await db_session.execute(select(Spool))).scalar_one()
row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
assert row.spool_id == spool.id
- assert not any("spool list" in note for note in tally.notes)
+ assert not any("spool list" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
@@ -693,8 +727,8 @@ class TestRestoreSpools:
assert len(rows) == 3
assert all(row.archive_id is None for row in rows)
# Only the two that had a link to lose are counted.
- assert any("2 usage record(s) restored without their print-history link" in n for n in tally.notes)
- assert any("select Print archives alongside" in n for n in tally.notes)
+ assert any("2 usage record(s) restored without their print-history link" in n for n in _messages(tally))
+ assert any("select Print archives alongside" in n for n in _messages(tally))
@pytest.mark.asyncio
async def test_no_note_when_every_archive_link_resolves(self, db_session):
@@ -714,7 +748,7 @@ class TestRestoreSpools:
row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
assert row.archive_id == archive.id
- assert not any("print-history link" in note for note in tally.notes)
+ assert not any("print-history link" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_dangling_printer_id_is_cleared(self, db_session):
@@ -765,7 +799,7 @@ class TestRestoreArchives:
assert row.filename == "benchy.3mf"
assert row.id != 77
assert id_map == {77: row.id}
- assert any("metadata only" in note for note in tally.notes)
+ assert any("metadata only" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_matches_existing_archive_by_hash_and_start(self, db_session):
@@ -920,7 +954,7 @@ class TestRestoreArchives:
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.deleted_at is None
assert tally.restored == 1
- assert any("visible again" in note for note in tally.notes)
+ assert any("visible again" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
@@ -958,7 +992,7 @@ class TestRestoreArchives:
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.printer_id is None
assert row.project_id is None
- assert any("no longer exist" in note for note in tally.notes)
+ assert any("no longer exist" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
@@ -1044,9 +1078,9 @@ class TestRestoreKprofiles:
# The printer does answer extrusion_cali_set, but it reports "fail" on
# writes that land, so the note must not promise either way.
- assert any("verify the profiles on the printer" in note for note in tally.notes)
- assert not any("without acknowledgement" in note for note in tally.notes)
- assert any("always overwrite" in note for note in tally.notes)
+ assert any("verify the profiles on the printer" in note for note in _messages(tally))
+ assert not any("without acknowledgement" in note for note in _messages(tally))
+ assert any("always overwrite" in note for note in _messages(tally))
# --- cali_idx is resolved live, never taken from the backup -------------
#
@@ -1103,7 +1137,7 @@ class TestRestoreKprofiles:
profiles, _ = client.set_kprofiles_batch.call_args.args
assert profiles[0]["cali_idx"] == -1, "-1 tells the printer to add a new profile"
assert profiles[0]["setting_id"] == "PFUS123", "falls back to the backed-up preset"
- assert any("added as new profiles" in note for note in tally.notes)
+ assert any("added as new profiles" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_different_filament_is_not_treated_as_a_match(self, db_session, printer_factory):
@@ -1179,7 +1213,7 @@ class TestRestoreKprofiles:
assert tally.restored == 0
assert tally.skipped == 1
- assert any("No printer with serial NOSUCH" in note for note in tally.notes)
+ assert any("No printer with serial NOSUCH" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
@@ -1194,7 +1228,7 @@ class TestRestoreKprofiles:
assert tally.skipped == 1
assert tally.failed == 0
- assert any("not connected" in note for note in tally.notes)
+ assert any("not connected" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
@@ -1257,7 +1291,7 @@ class TestRestoreKprofiles:
async def test_empty_payload_is_noted(self, db_session):
tally = _CategoryTally()
await _service()._restore_kprofiles(db_session, {}, tally)
- assert any("No K-profile data" in note for note in tally.notes)
+ assert _codes(tally) == ["noData"]
class TestSoftDeletedArchiveRoundTrip:
@@ -1343,7 +1377,7 @@ class TestRestoredArchiveOwnership:
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id == user.id
- assert not any("owner cleared" in note for note in tally.notes)
+ assert not any("owner cleared" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session):
@@ -1358,7 +1392,7 @@ class TestRestoredArchiveOwnership:
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id is None
assert tally.restored == 1 and tally.failed == 0
- assert any("owner cleared" in note and "archives:read_all" in note for note in tally.notes)
+ assert any("owner cleared" in note and "archives:read_all" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session):
@@ -1371,7 +1405,7 @@ class TestRestoredArchiveOwnership:
await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
await db_session.commit()
- assert sum(1 for note in tally.notes if "owner cleared" in note) == 1
+ assert sum(1 for note in _messages(tally) if "owner cleared" in note) == 1
@pytest.mark.asyncio
async def test_a_backup_without_the_key_still_restores(self, db_session):
@@ -1383,7 +1417,7 @@ class TestRestoredArchiveOwnership:
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id is None
- assert not any("owner cleared" in note for note in tally.notes)
+ assert not any("owner cleared" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):
@@ -1561,7 +1595,7 @@ class TestMqttRelayReconfigure:
with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_enabled"}, tally)
- assert any("restart Bambuddy" in note for note in tally.notes)
+ assert any("restart Bambuddy" in note for note in _messages(tally))
@pytest.mark.asyncio
async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):
diff --git a/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx b/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx
index 4b39ce15f..94c97eeb8 100644
--- a/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx
+++ b/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx
@@ -37,11 +37,29 @@ const mockPreview = {
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: 'Metadata only' },
- { category: 'spools', available: true, item_count: 4, detail: null },
- { category: 'settings', available: true, item_count: 12, detail: null },
- { category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' },
+ {
+ 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: {},
+ },
],
};
@@ -98,6 +116,42 @@ describe('GitHubRestoreModal', () => {
expect(screen.getByText('12 in backup')).toBeInTheDocument();
});
+ it('translates preview caveats rather than echoing the server English', async () => {
+ render();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('Something a future release explains')).toBeInTheDocument();
+ });
+ });
+
it('disables a category that is absent from the commit', async () => {
render();
@@ -168,7 +222,18 @@ describe('GitHubRestoreModal', () => {
log_id: 3,
ref: mockPreview.ref,
results: {
- spools: { restored: 4, skipped: 1, failed: 0, notes: ['1 usage record(s) skipped'] },
+ spools: {
+ restored: 4,
+ skipped: 1,
+ failed: 0,
+ notes: [
+ {
+ code: 'spoolUsageUnresolved',
+ params: { count: 1 },
+ message: 'raw server English, should not be rendered',
+ },
+ ],
+ },
},
});
})
@@ -195,7 +260,12 @@ describe('GitHubRestoreModal', () => {
ref: mockPreview.ref,
});
expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
- expect(screen.getByText('1 usage record(s) skipped')).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 () => {
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 9289fc0d3..4f2b1955c 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -2845,11 +2845,22 @@ export interface GitHubCommitListResponse {
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;
+
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 {
@@ -2867,11 +2878,26 @@ export interface GitHubRestoreRequest {
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: string[];
+ notes: GitHubRestoreNote[];
}
export interface GitHubRestoreResponse {
diff --git a/frontend/src/components/GitHubRestoreModal.tsx b/frontend/src/components/GitHubRestoreModal.tsx
index e88ab6995..3ec798a6c 100644
--- a/frontend/src/components/GitHubRestoreModal.tsx
+++ b/frontend/src/components/GitHubRestoreModal.tsx
@@ -16,12 +16,41 @@ import { Card, CardContent } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { ConfirmModal } from './ConfirmModal';
-import { api, type RestoreCategory, type GitHubRestoreResponse } from '../api/client';
+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;
@@ -77,10 +106,14 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
const availability = useMemo(() => {
const map: Record = {};
previewQuery.data?.categories?.forEach((c) => {
- map[c.category] = { available: c.available, itemCount: c.item_count, detail: c.detail };
+ 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]);
+ }, [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
@@ -292,9 +325,15 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
{tally.notes.length > 0 && (
{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.
+
-
- {note}
+ {translateCoded(t, 'notes', note.code, note.params, note.message)}
))}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 417d7edcb..70e85b4ed 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -4895,6 +4895,38 @@ export default {
reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
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',
+ spoolsUsageCount: 'zzgl. {{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',
+ 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: 'Die Bestätigung des Druckers ist nicht zuverlässig - ü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',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 25bc8df00..0cafad7d9 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -4938,6 +4938,43 @@ export default {
reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
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',
+ spoolsUsageCount: 'plus {{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',
+ 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: 'The printer\'s acknowledgement is not reliable - 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}})',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 77a80b138..0e964a13c 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -4903,6 +4903,38 @@ export default {
reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
failed: 'La restauración ha fallado.',
loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
+ 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',
+ spoolsUsageCount: 'más {{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',
+ 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: 'La confirmación de la impresora no es fiable - 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}})',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index dc99b710f..ba40e6c27 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -4884,6 +4884,38 @@ export default {
reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
failed: 'Échec de la restauration.',
loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
+ 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',
+ spoolsUsageCount: 'plus {{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',
+ 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: 'L\'accusé de réception de l\'imprimante n\'est pas fiable - 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}})',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index aa0872ea2..147b2b7b6 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -4883,6 +4883,38 @@ export default {
reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
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',
+ spoolsUsageCount: 'più {{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',
+ 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: 'La conferma della stampante non è affidabile - 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}})',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index 937901e44..dbcae6f6a 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -4895,6 +4895,38 @@ export default {
reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
failed: '復元に失敗しました。',
loadFailed: 'バックアップリポジトリを読み取れませんでした。',
+ details: {
+ notPresent: 'このバックアップコミットには含まれていません',
+ unreadableJson: '読み取れない JSON: {{paths}}',
+ settingsNoPayload: 'データに設定が含まれていません',
+ settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます',
+ settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
+ spoolsUsageCount: '使用履歴 {{count}} 件を含む',
+ archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
+ kprofilesPrinterCount: 'プリンター {{count}} 台分',
+ },
+ notes: {
+ noData: 'この種類のデータはこのバックアップに含まれていません',
+ archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました',
+ archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました',
+ archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで 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}}) に送信できませんでした',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts
index 35832c4df..7085207ae 100644
--- a/frontend/src/i18n/locales/ko.ts
+++ b/frontend/src/i18n/locales/ko.ts
@@ -4660,6 +4660,38 @@ export default {
reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
failed: '복원에 실패했습니다.',
loadFailed: '백업 저장소를 읽을 수 없습니다.',
+ details: {
+ notPresent: '이 백업 커밋에는 없습니다',
+ unreadableJson: '읽을 수 없는 JSON: {{paths}}',
+ settingsNoPayload: '데이터에 설정이 없습니다',
+ settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다',
+ settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다',
+ spoolsUsageCount: '사용 기록 {{count}}건 포함',
+ archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
+ kprofilesPrinterCount: '프린터 {{count}}대 분량',
+ },
+ notes: {
+ noData: '이 백업에는 이런 종류의 데이터가 없습니다',
+ archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다',
+ archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다',
+ archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 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}})에 보내지 못했습니다',
+ },
},
history: '기록',
clear: '초기화',
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index 6e75f13fe..b26cb3103 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -4883,6 +4883,38 @@ export default {
reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
failed: 'Falha na restauração.',
loadFailed: 'Não foi possível ler o repositório de backup.',
+ 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',
+ spoolsUsageCount: 'mais {{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',
+ 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: 'A confirmação da impressora não é confiável - 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}})',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts
index 798a3a582..097af3b30 100644
--- a/frontend/src/i18n/locales/ru.ts
+++ b/frontend/src/i18n/locales/ru.ts
@@ -4652,6 +4652,38 @@ export default {
reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
failed: 'Не удалось выполнить восстановление.',
loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
+ details: {
+ notPresent: 'Отсутствует в этом коммите резервной копии',
+ unreadableJson: 'Нечитаемый JSON: {{paths}}',
+ settingsNoPayload: 'В данных нет настроек',
+ settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
+ settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
+ spoolsUsageCount: 'плюс записей расхода: {{count}}',
+ archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
+ kprofilesPrinterCount: 'по {{count}} принтерам',
+ },
+ notes: {
+ noData: 'В этой резервной копии нет данных такого типа',
+ archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена',
+ archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена',
+ archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом 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}})',
+ },
},
history: "История",
clear: "Очистить",
diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts
index afcbda9a0..3d9e406e6 100644
--- a/frontend/src/i18n/locales/tr.ts
+++ b/frontend/src/i18n/locales/tr.ts
@@ -4873,6 +4873,38 @@ export default {
reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
failed: 'Geri yükleme başarısız oldu.',
loadFailed: 'Yedek deposu okunamadı.',
+ 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',
+ spoolsUsageCount: 'ayrıca {{count}} kullanım kaydı',
+ 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',
+ 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: 'Yazıcının onayı güvenilir değil - 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',
+ },
},
history: 'Geçmiş',
diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts
index 47baf2f39..fce156de0 100644
--- a/frontend/src/i18n/locales/uk.ts
+++ b/frontend/src/i18n/locales/uk.ts
@@ -4938,6 +4938,38 @@ export default {
reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
failed: "Не вдалося виконати відновлення.",
loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
+ details: {
+ notPresent: "Відсутнє в цьому коміті резервної копії",
+ unreadableJson: "Нечитабельний JSON: {{paths}}",
+ settingsNoPayload: "У даних немає налаштувань",
+ settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
+ settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
+ spoolsUsageCount: "плюс записів використання: {{count}}",
+ archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
+ kprofilesPrinterCount: "по {{count}} принтерах",
+ },
+ notes: {
+ noData: "У цій резервній копії немає даних такого типу",
+ archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено",
+ archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено",
+ archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом 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}})",
+ },
},
// History
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 222806356..289a76fd4 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -4883,6 +4883,38 @@ export default {
reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
failed: '恢复失败。',
loadFailed: '无法读取备份仓库。',
+ details: {
+ notPresent: '此备份提交中不存在',
+ unreadableJson: '无法解析的 JSON:{{paths}}',
+ settingsNoPayload: '数据中没有设置',
+ settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
+ settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
+ spoolsUsageCount: '另有 {{count}} 条使用记录',
+ archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
+ kprofilesPrinterCount: '涉及 {{count}} 台打印机',
+ },
+ notes: {
+ noData: '此备份中没有这类数据',
+ archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联',
+ archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联',
+ archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 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}})',
+ },
},
// History
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 9c95133b2..f70a5400d 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -4883,6 +4883,38 @@ export default {
reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
failed: '還原失敗。',
loadFailed: '無法讀取備份儲存庫。',
+ details: {
+ notPresent: '此備份提交中不存在',
+ unreadableJson: '無法解析的 JSON:{{paths}}',
+ settingsNoPayload: '資料中沒有設定',
+ settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
+ settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
+ spoolsUsageCount: '另有 {{count}} 筆使用紀錄',
+ archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
+ kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
+ },
+ notes: {
+ noData: '此備份中沒有這類資料',
+ archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結',
+ archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結',
+ archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 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}})',
+ },
},
// History