Sfoglia il codice sorgente

i18n(backup): make the restore notes and preview details translatable (#2656)

A German user got a translated modal with "Not present in this backup commit" in
the middle of it. Every tally note and preview caveat was a server-built English
sentence rendered verbatim.

Follows the backup.pathCheck contract already in use one card down in the same
component, deliberately rather than inventing a second convention: the server
sends a `code` plus typed `params` and carries the English along as the
fallback, and the client renders
`t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
what keeps a newer backend's unfamiliar code readable instead of printing the
raw key — covered by its own test.

Shapes:
- notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
  but the field is unreleased in this same PR.
- GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
  as the English fallback.
- _CategoryTally.note(code, message, **params), deduped on (code, params) rather
  than on the rendered text, so two offline printers both keep their names. The
  20-note cap is unchanged.

28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
the four per-category "No X data in this backup" strings into one, since the
category heading already renders beside it. Counts use single-form {{count}} in
the existing "N record(s)" style rather than i18next plural suffixes — nothing in
this block uses _one/_other and the parity script has extra rules for them.
Parity holds at 5737 leaves in all 13 locales.

Deliberately out of scope, and worth saying so rather than leaving it to look
like an oversight: result.message, the commit-picker subject lines and the HTTP
error strings stay English. Those also originate in the provider backends, so
code-ifying them widens the diff well past the restore service.

spoolTagKept is added here with the rest of the locale churn but is not emitted
until the next commit, so the 13-locale change lands once.
jmoore-skild 1 mese fa
parent
commit
af8d14d796

+ 23 - 2
backend/app/schemas/github_backup.py

@@ -254,7 +254,13 @@ class GitHubRestorePreviewCategory(BaseModel):
     category: RestoreCategory
     category: RestoreCategory
     available: bool = Field(description="Whether this category is present in the commit")
     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")
     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):
 class GitHubRestorePreview(BaseModel):
@@ -289,13 +295,28 @@ class GitHubRestoreRequest(BaseModel):
         return self
         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):
 class GitHubRestoreCategoryResult(BaseModel):
     """Per-category outcome of a restore."""
     """Per-category outcome of a restore."""
 
 
     restored: int = 0
     restored: int = 0
     skipped: int = 0
     skipped: int = 0
     failed: int = 0
     failed: int = 0
-    notes: list[str] = Field(default_factory=list)
+    notes: list[GitHubRestoreNote] = Field(default_factory=list)
 
 
 
 
 class GitHubRestoreResponse(BaseModel):
 class GitHubRestoreResponse(BaseModel):

+ 144 - 52
backend/app/services/github_restore.py

@@ -33,7 +33,7 @@ import json
 import logging
 import logging
 import os
 import os
 import re
 import re
-from dataclasses import dataclass
+from dataclasses import dataclass, field as dataclasses_field
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 
 
 import httpx
 import httpx
@@ -227,6 +227,19 @@ class _SettingsPlan:
         return len(self.blocked) + len(self.protected) + len(self.companion)
         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:
 class _CategoryTally:
     """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
     """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
 
 
@@ -234,13 +247,21 @@ class _CategoryTally:
         self.restored = 0
         self.restored = 0
         self.skipped = 0
         self.skipped = 0
         self.failed = 0
         self.failed = 0
-        self.notes: list[str] = []
+        self.notes: list[dict] = []
+
+    def note(self, code: str, message: str, **params) -> None:
+        """Record a note as a translation code, its params and an English fallback.
 
 
-    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)
+        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:
     def as_dict(self) -> dict:
         return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
         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)
             paths = self._category_paths(category, available)
             if not paths:
             if not paths:
                 categories.append(
                 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
                 continue
             unreadable = [p for p in paths if p in bad_paths]
             unreadable = [p for p in paths if p in bad_paths]
             if unreadable:
             if unreadable:
+                joined = ", ".join(unreadable)
                 categories.append(
                 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
                 continue
             count, detail = await self._count_items(db, category, parsed)
             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
         commit_info = None
         commits = (await self.list_commits(config, limit=20)).get("commits") or []
         commits = (await self.list_commits(config, limit=20)).get("commits") or []
@@ -484,13 +501,27 @@ class GitHubRestoreService:
             "categories": categories,
             "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."""
         """Count restorable items for ``category`` and describe any caveat."""
         if category == RestoreCategory.SETTINGS:
         if category == RestoreCategory.SETTINGS:
             payload = parsed.get(SETTINGS_PATH)
             payload = parsed.get(SETTINGS_PATH)
             values = payload.get("settings") if isinstance(payload, dict) else None
             values = payload.get("settings") if isinstance(payload, dict) else None
             if not isinstance(values, dict):
             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
             # Every refusal is subtracted so the count matches what the restore
             # actually writes. The wording calls out the credential ones (what a
             # actually writes. The wording calls out the credential ones (what a
             # user might expect to come back) and the companion ones (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)
             plan = await self._plan_settings(db, values)
             detail = None
             detail = None
             if plan.companion:
             if plan.companion:
-                detail = (
+                detail = _Detail(
+                    "settingsCompanionWillSkip",
                     f"{len(plan.blocked)} credential-like key(s) will be skipped, and "
                     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:
             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
             return len(values) - plan.refused_count, detail
 
 
         if category == RestoreCategory.SPOOLS:
         if category == RestoreCategory.SPOOLS:
@@ -513,14 +550,18 @@ class GitHubRestoreService:
             usage_payload = parsed.get(SPOOL_USAGE_PATH)
             usage_payload = parsed.get(SPOOL_USAGE_PATH)
             usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
             usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
             count = len(spools) if isinstance(spools, list) else 0
             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
             return count, detail
 
 
         if category == RestoreCategory.ARCHIVES:
         if category == RestoreCategory.ARCHIVES:
             payload = parsed.get(ARCHIVES_PATH)
             payload = parsed.get(ARCHIVES_PATH)
             archives = payload.get("archives") if isinstance(payload, dict) else None
             archives = payload.get("archives") if isinstance(payload, dict) else None
             count = len(archives) if isinstance(archives, list) else 0
             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:
         if category == RestoreCategory.KPROFILES:
             total = 0
             total = 0
@@ -533,7 +574,9 @@ class GitHubRestoreService:
                 profiles = payload.get("profiles")
                 profiles = payload.get("profiles")
                 if isinstance(profiles, list):
                 if isinstance(profiles, list):
                     total += len(profiles)
                     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 total, detail
 
 
         return 0, None
         return 0, None
@@ -756,7 +799,7 @@ class GitHubRestoreService:
     ) -> None:
     ) -> None:
         archives = payload.get("archives") if isinstance(payload, dict) else None
         archives = payload.get("archives") if isinstance(payload, dict) else None
         if not isinstance(archives, list):
         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
             return
 
 
         valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
         valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
@@ -825,11 +868,15 @@ class GitHubRestoreService:
 
 
             printer_id = entry.get("printer_id")
             printer_id = entry.get("printer_id")
             if printer_id is not None and printer_id not in valid_printers:
             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
                 printer_id = None
             project_id = entry.get("project_id")
             project_id = entry.get("project_id")
             if project_id is not None and project_id not in valid_projects:
             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
                 project_id = None
             created_by_id = entry.get("created_by_id")
             created_by_id = entry.get("created_by_id")
             if created_by_id is not None and created_by_id not in valid_users:
             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
                 # cleared owner is not silent-safe — the archive becomes visible
                 # only to archives:read_all until someone does.
                 # only to archives:read_all until someone does.
                 tally.note(
                 tally.note(
+                    "archivesOwnerCleared",
                     "Some archives referenced users that no longer exist — owner cleared, so they are "
                     "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
                 created_by_id = None
             fields["printer_id"] = printer_id
             fields["printer_id"] = printer_id
@@ -857,7 +905,10 @@ class GitHubRestoreService:
                 # taken. Legitimate, but not obvious from a restored/skipped
                 # taken. Legitimate, but not obvious from a restored/skipped
                 # count, so say it.
                 # count, so say it.
                 if existing.deleted_at is not None and fields["deleted_at"] is None:
                 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():
                 for key, value in fields.items():
                     setattr(existing, key, value)
                     setattr(existing, key, value)
                 tally.restored += 1
                 tally.restored += 1
@@ -865,7 +916,8 @@ class GitHubRestoreService:
 
 
             if not warned_files:
             if not warned_files:
                 tally.note(
                 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
                 warned_files = True
 
 
@@ -934,7 +986,7 @@ class GitHubRestoreService:
     ) -> None:
     ) -> None:
         spools = inventory.get("spools") if isinstance(inventory, dict) else None
         spools = inventory.get("spools") if isinstance(inventory, dict) else None
         if not isinstance(spools, list):
         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
             return
 
 
         spool_id_map: dict[int, int] = {}
         spool_id_map: dict[int, int] = {}
@@ -1114,13 +1166,17 @@ class GitHubRestoreService:
 
 
         if unresolved:
         if unresolved:
             tally.note(
             tally.note(
+                "spoolUsageUnresolved",
                 f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
                 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:
         if unlinked_archives:
             tally.note(
             tally.note(
+                "spoolUsageUnlinked",
                 f"{unlinked_archives} usage record(s) restored without their print-history link — "
                 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(
     async def _restore_settings(
@@ -1133,7 +1189,7 @@ class GitHubRestoreService:
     ) -> None:
     ) -> None:
         values = payload.get("settings") if isinstance(payload, dict) else None
         values = payload.get("settings") if isinstance(payload, dict) else None
         if not isinstance(values, dict):
         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
             return
 
 
         # Planned before the first write, so the companion rule reads genuinely
         # Planned before the first write, so the companion rule reads genuinely
@@ -1176,17 +1232,27 @@ class GitHubRestoreService:
                 keys_written.add(key)
                 keys_written.add(key)
 
 
         if plan.blocked:
         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:
         if plan.protected:
             tally.note(
             tally.note(
+                "settingsAuthSkipped",
                 f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > "
                 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:
         if plan.companion:
+            keys = ", ".join(sorted(plan.companion))
             tally.note(
             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:
     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
             # must not turn a successful restore into a failed one. Noted rather
             # than swallowed silently, so the user knows to restart.
             # than swallowed silently, so the user knows to restart.
             logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
             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:
     async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
         by_serial: dict[str, list[tuple[str, dict]]] = {}
         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))
             by_serial.setdefault(match.group(1), []).append((match.group(2), content))
 
 
         if not by_serial:
         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
             return
 
 
         result = await db.execute(select(Printer))
         result = await db.execute(select(Printer))
@@ -1252,8 +1321,11 @@ class GitHubRestoreService:
         # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
         # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
         # the profile occupying a slot, so writing is always an overwrite on the
         # the profile occupying a slot, so writing is always an overwrite on the
         # printer side.
         # 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()):
         for serial, entries in sorted(by_serial.items()):
             profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
             profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
@@ -1261,13 +1333,18 @@ class GitHubRestoreService:
             printer = printers.get(serial)
             printer = printers.get(serial)
             if printer is None:
             if printer is None:
                 tally.skipped += profile_total
                 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
                 continue
 
 
             client = printer_manager.get_client(printer.id)
             client = printer_manager.get_client(printer.id)
             if not client or not client.state.connected:
             if not client or not client.state.connected:
                 tally.skipped += profile_total
                 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
                 continue
 
 
             for nozzle, content in sorted(entries):
             for nozzle, content in sorted(entries):
@@ -1275,7 +1352,12 @@ class GitHubRestoreService:
                 if not isinstance(profiles, list) or not profiles:
                 if not isinstance(profiles, list) or not profiles:
                     continue
                     continue
                 if nozzle not in _KNOWN_NOZZLES:
                 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
                 # The backup's slot_id is a cali_idx, and cali_idx is as
                 # unstable as the autoincrement ids we already refuse to reuse
                 # unstable as the autoincrement ids we already refuse to reuse
@@ -1316,8 +1398,12 @@ class GitHubRestoreService:
                     continue
                     continue
                 if unmatched:
                 if unmatched:
                     tally.note(
                     tally.note(
+                        "kprofilesUnmatched",
                         f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
                         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:
                 try:
@@ -1330,7 +1416,13 @@ class GitHubRestoreService:
                     tally.restored += len(profile_dicts)
                     tally.restored += len(profile_dicts)
                 else:
                 else:
                     tally.failed += len(profile_dicts)
                     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
     @staticmethod
     async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:
     async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:

+ 22 - 3
backend/tests/integration/test_github_restore_api.py

@@ -169,7 +169,18 @@ class TestRestoreEndpoint:
             "ref": "aaa1111",
             "ref": "aaa1111",
             "results": {
             "results": {
                 "spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
                 "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(
         with patch(
@@ -184,7 +195,15 @@ class TestRestoreEndpoint:
         assert response.status_code == 200
         assert response.status_code == 200
         body = response.json()
         body = response.json()
         assert body["results"]["spools"]["restored"] == 4
         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["overwrite_existing"] is True
         assert mock.await_args.kwargs["ref"] == "aaa1111"
         assert mock.await_args.kwargs["ref"] == "aaa1111"
 
 
@@ -391,7 +410,7 @@ class TestRestoreDoesNotOpenTheMetricsEndpoint:
         response = await async_client.get("/api/v1/metrics")
         response = await async_client.get("/api/v1/metrics")
         assert response.status_code == 404, "a settings restore opened the metrics endpoint"
         assert response.status_code == 404, "a settings restore opened the metrics endpoint"
         assert "bambuddy_build_info" not in response.text
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration

+ 69 - 35
backend/tests/unit/test_github_restore.py

@@ -41,6 +41,20 @@ def _service() -> GitHubRestoreService:
     return 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:
 class TestParseDt:
     def test_parses_str_datetime_the_backup_writes(self):
     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)
         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:
 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):
     def test_notes_are_deduplicated(self):
         tally = _CategoryTally()
         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):
     def test_notes_are_bounded(self):
         tally = _CategoryTally()
         tally = _CategoryTally()
         for i in range(50):
         for i in range(50):
-            tally.note(f"note {i}")
+            tally.note("noData", f"note {i}", index=i)
         assert len(tally.notes) == 20
         assert len(tally.notes) == 20
 
 
 
 
@@ -185,7 +216,7 @@ class TestRestoreSettings:
         # keys, so counting them here would put the total above what the user
         # keys, so counting them here would put the total above what the user
         # was shown before they pressed Restore.
         # was shown before they pressed Restore.
         assert tally.skipped == 0
         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
     @pytest.mark.asyncio
     async def test_auth_settings_are_never_restored(self, db_session):
     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
         # As above: refused keys are outside the preview's count, so outside the
         # tally too.
         # tally too.
         assert tally.skipped == 0
         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
     @pytest.mark.asyncio
     async def test_missing_payload_is_noted_not_fatal(self, db_session):
     async def test_missing_payload_is_noted_not_fatal(self, db_session):
         tally = _CategoryTally()
         tally = _CategoryTally()
         await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
         await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
         assert tally.restored == 0
         assert tally.restored == 0
-        assert tally.notes
+        assert _codes(tally) == ["noData"]
 
 
 
 
 class TestSettingValueIsTrue:
 class TestSettingValueIsTrue:
@@ -282,7 +313,7 @@ class TestCompanionCredentials:
 
 
         rows = await self._rows(db_session)
         rows = await self._rows(db_session)
         assert rows == {"currency": "EUR"}
         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.asyncio
     @pytest.mark.parametrize("toggle,credential", sorted(_COMPANION_CREDENTIALS.items()))
     @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)
         allowed_count, allowed_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
 
 
         assert refused_count == allowed_count - 1
         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 ----------------------
     # --- 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")
         tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret")
 
 
         assert (await self._rows(db_session))["prometheus_enabled"] == "true"
         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
     @pytest.mark.asyncio
     async def test_an_anonymous_broker_is_not_a_false_positive(self, db_session):
     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")
         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 (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
     @pytest.mark.asyncio
     async def test_an_anonymous_ldap_bind_is_not_a_false_positive(self, db_session):
     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="   ")
         tally = await self._restore(db_session, ldap_enabled="true", ldap_bind_password="   ")
 
 
         assert (await self._rows(db_session))["ldap_enabled"] == "true"
         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
     @pytest.mark.asyncio
     async def test_turning_a_toggle_off_is_always_written(self, db_session):
     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")
         tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true", prometheus_token="s3cret")
 
 
         assert (await self._rows(db_session))["prometheus_enabled"] == "true"
         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 ----------------------------------------------------
     # --- The map itself ----------------------------------------------------
 
 
@@ -622,11 +656,11 @@ class TestRestoreSpools:
 
 
         assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
         assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
         assert tally.skipped == 1
         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
         # No remedy is offered, because none exists: overwrite does not change
         # which spools land in the map (a skipped spool is mapped anyway), and
         # which spools land in the map (a skipped spool is mapped anyway), and
         # usage history is always restored alongside the spools category.
         # 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
     @pytest.mark.asyncio
     async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
     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()
         spool = (await db_session.execute(select(Spool))).scalar_one()
         row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
         row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
         assert row.spool_id == spool.id
         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
     @pytest.mark.asyncio
     async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
     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 len(rows) == 3
         assert all(row.archive_id is None for row in rows)
         assert all(row.archive_id is None for row in rows)
         # Only the two that had a link to lose are counted.
         # 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
     @pytest.mark.asyncio
     async def test_no_note_when_every_archive_link_resolves(self, db_session):
     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()
         row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
         assert row.archive_id == archive.id
         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
     @pytest.mark.asyncio
     async def test_dangling_printer_id_is_cleared(self, db_session):
     async def test_dangling_printer_id_is_cleared(self, db_session):
@@ -765,7 +799,7 @@ class TestRestoreArchives:
         assert row.filename == "benchy.3mf"
         assert row.filename == "benchy.3mf"
         assert row.id != 77
         assert row.id != 77
         assert id_map == {77: row.id}
         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
     @pytest.mark.asyncio
     async def test_matches_existing_archive_by_hash_and_start(self, db_session):
     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()
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.deleted_at is None
         assert row.deleted_at is None
         assert tally.restored == 1
         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
     @pytest.mark.asyncio
     async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
     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()
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.printer_id is None
         assert row.printer_id is None
         assert row.project_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
     @pytest.mark.asyncio
     async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
     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
         # The printer does answer extrusion_cali_set, but it reports "fail" on
         # writes that land, so the note must not promise either way.
         # 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 -------------
     # --- cali_idx is resolved live, never taken from the backup -------------
     #
     #
@@ -1103,7 +1137,7 @@ class TestRestoreKprofiles:
         profiles, _ = client.set_kprofiles_batch.call_args.args
         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]["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 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
     @pytest.mark.asyncio
     async def test_different_filament_is_not_treated_as_a_match(self, db_session, printer_factory):
     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.restored == 0
         assert tally.skipped == 1
         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
     @pytest.mark.asyncio
     async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
     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.skipped == 1
         assert tally.failed == 0
         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
     @pytest.mark.asyncio
     async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
     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):
     async def test_empty_payload_is_noted(self, db_session):
         tally = _CategoryTally()
         tally = _CategoryTally()
         await _service()._restore_kprofiles(db_session, {}, tally)
         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:
 class TestSoftDeletedArchiveRoundTrip:
@@ -1343,7 +1377,7 @@ class TestRestoredArchiveOwnership:
 
 
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.created_by_id == user.id
         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
     @pytest.mark.asyncio
     async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session):
     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()
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.created_by_id is None
         assert row.created_by_id is None
         assert tally.restored == 1 and tally.failed == 0
         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
     @pytest.mark.asyncio
     async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session):
     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 _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
         await db_session.commit()
         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
     @pytest.mark.asyncio
     async def test_a_backup_without_the_key_still_restores(self, db_session):
     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()
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.created_by_id is None
         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
     @pytest.mark.asyncio
     async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):
     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):
         with patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
             await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_enabled"}, tally)
             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
     @pytest.mark.asyncio
     async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):
     async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):

+ 76 - 6
frontend/src/__tests__/components/GitHubRestoreModal.test.tsx

@@ -37,11 +37,29 @@ const mockPreview = {
   ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
   ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
   commit: mockCommits.commits[0],
   commit: mockCommits.commits[0],
   metadata_version: '1.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: [
   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();
     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 () => {
   it('disables a category that is absent from the commit', async () => {
     render(<GitHubRestoreModal onClose={vi.fn()} />);
     render(<GitHubRestoreModal onClose={vi.fn()} />);
 
 
@@ -168,7 +222,18 @@ describe('GitHubRestoreModal', () => {
           log_id: 3,
           log_id: 3,
           ref: mockPreview.ref,
           ref: mockPreview.ref,
           results: {
           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,
       ref: mockPreview.ref,
     });
     });
     expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
     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 () => {
   it('drops the selection while a newly-picked commit is still being inspected', async () => {

+ 27 - 1
frontend/src/api/client.ts

@@ -2845,11 +2845,22 @@ export interface GitHubCommitListResponse {
   commits: GitHubCommitInfo[];
   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 {
 export interface GitHubRestorePreviewCategory {
   category: RestoreCategory;
   category: RestoreCategory;
   available: boolean;
   available: boolean;
   item_count: number;
   item_count: number;
+  /** English rendering. Used as i18next's defaultValue, never shown on its own. */
   detail: string | null;
   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 {
 export interface GitHubRestorePreview {
@@ -2867,11 +2878,26 @@ export interface GitHubRestoreRequest {
   overwrite_existing?: boolean;
   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 {
 export interface GitHubRestoreCategoryResult {
   restored: number;
   restored: number;
   skipped: number;
   skipped: number;
   failed: number;
   failed: number;
-  notes: string[];
+  notes: GitHubRestoreNote[];
 }
 }
 
 
 export interface GitHubRestoreResponse {
 export interface GitHubRestoreResponse {

+ 44 - 5
frontend/src/components/GitHubRestoreModal.tsx

@@ -16,12 +16,41 @@ import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
-import { 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 {
 interface GitHubRestoreModalProps {
   onClose: () => void;
   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 {
 interface CategoryMeta {
   id: RestoreCategory;
   id: RestoreCategory;
   labelKey: string;
   labelKey: string;
@@ -77,10 +106,14 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
   const availability = useMemo(() => {
   const availability = useMemo(() => {
     const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
     const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
     previewQuery.data?.categories?.forEach((c) => {
     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;
     return map;
-  }, [previewQuery.data]);
+  }, [previewQuery.data, t]);
 
 
   // What a Restore click would actually send. `selected` on its own is not that:
   // 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
   // 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.length > 0 && (
                       <ul className="mt-2 space-y-1">
                       <ul className="mt-2 space-y-1">
                         {tally.notes.map((note) => (
                         {tally.notes.map((note) => (
-                          <li key={note} className="text-xs text-bambu-gray flex items-start gap-1.5">
+                          // 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" />
                             <Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
-                            <span>{note}</span>
+                            <span>{translateCoded(t, 'notes', note.code, note.params, note.message)}</span>
                           </li>
                           </li>
                         ))}
                         ))}
                       </ul>
                       </ul>

+ 32 - 0
frontend/src/i18n/locales/de.ts

@@ -4895,6 +4895,38 @@ export default {
       reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
       reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
       failed: 'Wiederherstellung fehlgeschlagen.',
       failed: 'Wiederherstellung fehlgeschlagen.',
       loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
       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
     // History

+ 37 - 0
frontend/src/i18n/locales/en.ts

@@ -4938,6 +4938,43 @@ export default {
       reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
       reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
       failed: 'Restore failed.',
       failed: 'Restore failed.',
       loadFailed: 'Could not read the backup repository.',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/es.ts

@@ -4903,6 +4903,38 @@ export default {
       reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
       reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
       failed: 'La restauración ha fallado.',
       failed: 'La restauración ha fallado.',
       loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/fr.ts

@@ -4884,6 +4884,38 @@ export default {
       reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
       reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
       failed: 'Échec de la restauration.',
       failed: 'Échec de la restauration.',
       loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/it.ts

@@ -4883,6 +4883,38 @@ export default {
       reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
       reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
       failed: 'Ripristino non riuscito.',
       failed: 'Ripristino non riuscito.',
       loadFailed: 'Impossibile leggere il repository di backup.',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/ja.ts

@@ -4895,6 +4895,38 @@ export default {
       reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
       reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
       failed: '復元に失敗しました。',
       failed: '復元に失敗しました。',
       loadFailed: 'バックアップリポジトリを読み取れませんでした。',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/ko.ts

@@ -4660,6 +4660,38 @@ export default {
       reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
       reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
       failed: '복원에 실패했습니다.',
       failed: '복원에 실패했습니다.',
       loadFailed: '백업 저장소를 읽을 수 없습니다.',
       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: '기록',
     history: '기록',
     clear: '초기화',
     clear: '초기화',

+ 32 - 0
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.',
       reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
       failed: 'Falha na restauração.',
       failed: 'Falha na restauração.',
       loadFailed: 'Não foi possível ler o repositório de backup.',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/ru.ts

@@ -4652,6 +4652,38 @@ export default {
       reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
       reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
       failed: 'Не удалось выполнить восстановление.',
       failed: 'Не удалось выполнить восстановление.',
       loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
       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: "История",
     history: "История",
     clear: "Очистить",
     clear: "Очистить",

+ 32 - 0
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.',
       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.',
       failed: 'Geri yükleme başarısız oldu.',
       loadFailed: 'Yedek deposu okunamadı.',
       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ş',
     history: 'Geçmiş',

+ 32 - 0
frontend/src/i18n/locales/uk.ts

@@ -4938,6 +4938,38 @@ export default {
       reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
       reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
       failed: "Не вдалося виконати відновлення.",
       failed: "Не вдалося виконати відновлення.",
       loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4883,6 +4883,38 @@ export default {
       reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
       reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
       failed: '恢复失败。',
       failed: '恢复失败。',
       loadFailed: '无法读取备份仓库。',
       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
     // History

+ 32 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4883,6 +4883,38 @@ export default {
       reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
       reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
       failed: '還原失敗。',
       failed: '還原失敗。',
       loadFailed: '無法讀取備份儲存庫。',
       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
     // History