diff --git a/backend/app/schemas/github_backup.py b/backend/app/schemas/github_backup.py index 7ee0cb2c5..b66011b21 100644 --- a/backend/app/schemas/github_backup.py +++ b/backend/app/schemas/github_backup.py @@ -254,7 +254,13 @@ class GitHubRestorePreviewCategory(BaseModel): category: RestoreCategory available: bool = Field(description="Whether this category is present in the commit") item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable") - detail: str | None = Field(default=None, description="Why unavailable, or extra context") + detail: str | None = Field(default=None, description="Why unavailable, or extra context, in English") + detail_code: str | None = Field( + default=None, description="Key under backup.restoreFromGit.details, for the client to translate" + ) + detail_params: dict[str, str | int] = Field( + default_factory=dict, description="Interpolation values for detail_code" + ) class GitHubRestorePreview(BaseModel): @@ -289,13 +295,28 @@ class GitHubRestoreRequest(BaseModel): return self +class GitHubRestoreNote(BaseModel): + """One tally note, as a translation code plus the values it interpolates. + + Follows the ``backup.pathCheck`` contract already in use one card down in the + same component: the server chooses the code and supplies typed params, and + the client renders ``t(`...${code}`, { ...params, defaultValue: message })``. + ``message`` is the English original, so a client that does not know a code + yet still shows something sensible rather than the raw key. + """ + + code: str = Field(description="Key under backup.restoreFromGit.notes") + params: dict[str, str | int] = Field(default_factory=dict, description="Interpolation values for code") + message: str = Field(description="English rendering, used as the client's defaultValue") + + class GitHubRestoreCategoryResult(BaseModel): """Per-category outcome of a restore.""" restored: int = 0 skipped: int = 0 failed: int = 0 - notes: list[str] = Field(default_factory=list) + notes: list[GitHubRestoreNote] = Field(default_factory=list) class GitHubRestoreResponse(BaseModel): diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py index 4a7f1b688..e067d63d0 100644 --- a/backend/app/services/github_restore.py +++ b/backend/app/services/github_restore.py @@ -33,7 +33,7 @@ import json import logging import os import re -from dataclasses import dataclass +from dataclasses import dataclass, field as dataclasses_field from datetime import datetime, timezone import httpx @@ -227,6 +227,19 @@ class _SettingsPlan: return len(self.blocked) + len(self.protected) + len(self.companion) +@dataclass(frozen=True) +class _Detail: + """A preview caveat, as a translation code plus its English rendering. + + Same contract as a note: the client translates ``code`` with ``params`` and + falls back to ``message``. + """ + + code: str + message: str + params: dict[str, str | int] = dataclasses_field(default_factory=dict) + + class _CategoryTally: """Mutable accumulator matching ``GitHubRestoreCategoryResult``.""" @@ -234,13 +247,21 @@ class _CategoryTally: self.restored = 0 self.skipped = 0 self.failed = 0 - self.notes: list[str] = [] + self.notes: list[dict] = [] - def note(self, message: str) -> None: - # Notes are surfaced verbatim in the UI, so keep the list bounded rather - # than emitting one line per row for a large backup. - if message not in self.notes and len(self.notes) < 20: - self.notes.append(message) + def note(self, code: str, message: str, **params) -> None: + """Record a note as a translation code, its params and an English fallback. + + Deduped on ``(code, params)`` rather than on the rendered text, which is + the same thing today but keeps two notes that differ only in a printer + name from collapsing into one. Bounded for the reason it always was: the + UI renders every note, so a large backup must not emit one per row. + """ + if any(existing["code"] == code and existing["params"] == params for existing in self.notes): + return + if len(self.notes) >= 20: + return + self.notes.append({"code": code, "params": params, "message": message}) def as_dict(self) -> dict: return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes} @@ -446,27 +467,23 @@ class GitHubRestoreService: paths = self._category_paths(category, available) if not paths: categories.append( - { - "category": category, - "available": False, - "item_count": 0, - "detail": "Not present in this backup commit", - } + self._category_entry(category, False, 0, _Detail("notPresent", "Not present in this backup commit")) ) continue unreadable = [p for p in paths if p in bad_paths] if unreadable: + joined = ", ".join(unreadable) categories.append( - { - "category": category, - "available": False, - "item_count": 0, - "detail": f"Unreadable JSON: {', '.join(unreadable)}", - } + self._category_entry( + category, + False, + 0, + _Detail("unreadableJson", f"Unreadable JSON: {joined}", {"paths": joined}), + ) ) continue count, detail = await self._count_items(db, category, parsed) - categories.append({"category": category, "available": True, "item_count": count, "detail": detail}) + categories.append(self._category_entry(category, True, count, detail)) commit_info = None commits = (await self.list_commits(config, limit=20)).get("commits") or [] @@ -484,13 +501,27 @@ class GitHubRestoreService: "categories": categories, } - async def _count_items(self, db: AsyncSession, category: RestoreCategory, parsed: dict) -> tuple[int, str | None]: + @staticmethod + def _category_entry(category: RestoreCategory, available: bool, item_count: int, detail: _Detail | None) -> dict: + """Shape one ``GitHubRestorePreviewCategory``, translated detail included.""" + return { + "category": category, + "available": available, + "item_count": item_count, + "detail": detail.message if detail else None, + "detail_code": detail.code if detail else None, + "detail_params": detail.params if detail else {}, + } + + async def _count_items( + self, db: AsyncSession, category: RestoreCategory, parsed: dict + ) -> tuple[int, _Detail | None]: """Count restorable items for ``category`` and describe any caveat.""" if category == RestoreCategory.SETTINGS: payload = parsed.get(SETTINGS_PATH) values = payload.get("settings") if isinstance(payload, dict) else None if not isinstance(values, dict): - return 0, "No settings in payload" + return 0, _Detail("settingsNoPayload", "No settings in payload") # Every refusal is subtracted so the count matches what the restore # actually writes. The wording calls out the credential ones (what a # user might expect to come back) and the companion ones (a @@ -499,12 +530,18 @@ class GitHubRestoreService: plan = await self._plan_settings(db, values) detail = None if plan.companion: - detail = ( + detail = _Detail( + "settingsCompanionWillSkip", f"{len(plan.blocked)} credential-like key(s) will be skipped, and " - f"{len(plan.companion)} switch(es) that depend on them will be left off" + f"{len(plan.companion)} switch(es) that depend on them will be left off", + {"count": len(plan.blocked), "companion": len(plan.companion)}, ) elif plan.blocked: - detail = f"{len(plan.blocked)} credential-like keys will be skipped" + detail = _Detail( + "settingsCredentialsWillSkip", + f"{len(plan.blocked)} credential-like keys will be skipped", + {"count": len(plan.blocked)}, + ) return len(values) - plan.refused_count, detail if category == RestoreCategory.SPOOLS: @@ -513,14 +550,18 @@ class GitHubRestoreService: usage_payload = parsed.get(SPOOL_USAGE_PATH) usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None count = len(spools) if isinstance(spools, list) else 0 - detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None + detail = None + if isinstance(usage, list) and usage: + detail = _Detail("spoolsUsageCount", f"plus {len(usage)} usage records", {"count": len(usage)}) return count, detail if category == RestoreCategory.ARCHIVES: payload = parsed.get(ARCHIVES_PATH) archives = payload.get("archives") if isinstance(payload, dict) else None count = len(archives) if isinstance(archives, list) else 0 - return count, "Metadata only — 3MF files and thumbnails are not in a Git backup" + return count, _Detail( + "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup" + ) if category == RestoreCategory.KPROFILES: total = 0 @@ -533,7 +574,9 @@ class GitHubRestoreService: profiles = payload.get("profiles") if isinstance(profiles, list): total += len(profiles) - detail = f"across {len(serials)} printer(s)" if serials else None + detail = None + if serials: + detail = _Detail("kprofilesPrinterCount", f"across {len(serials)} printer(s)", {"count": len(serials)}) return total, detail return 0, None @@ -756,7 +799,7 @@ class GitHubRestoreService: ) -> None: archives = payload.get("archives") if isinstance(payload, dict) else None if not isinstance(archives, list): - tally.note("No archive data in this backup") + tally.note("noData", "No data of this kind in this backup") return valid_printers = set((await db.execute(select(Printer.id))).scalars().all()) @@ -825,11 +868,15 @@ class GitHubRestoreService: printer_id = entry.get("printer_id") if printer_id is not None and printer_id not in valid_printers: - tally.note("Some archives referenced printers that no longer exist — link cleared") + tally.note( + "archivesPrinterMissing", "Some archives referenced printers that no longer exist — link cleared" + ) printer_id = None project_id = entry.get("project_id") if project_id is not None and project_id not in valid_projects: - tally.note("Some archives referenced projects that no longer exist — link cleared") + tally.note( + "archivesProjectMissing", "Some archives referenced projects that no longer exist — link cleared" + ) project_id = None created_by_id = entry.get("created_by_id") if created_by_id is not None and created_by_id not in valid_users: @@ -838,8 +885,9 @@ class GitHubRestoreService: # cleared owner is not silent-safe — the archive becomes visible # only to archives:read_all until someone does. tally.note( + "archivesOwnerCleared", "Some archives referenced users that no longer exist — owner cleared, so they are " - "visible only to users with the archives:read_all permission until an admin reassigns them" + "visible only to users with the archives:read_all permission until an admin reassigns them", ) created_by_id = None fields["printer_id"] = printer_id @@ -857,7 +905,10 @@ class GitHubRestoreService: # taken. Legitimate, but not obvious from a restored/skipped # count, so say it. if existing.deleted_at is not None and fields["deleted_at"] is None: - tally.note("Archive(s) deleted since the backup are visible again — overwrite was on") + tally.note( + "archivesUndeleted", + "Archive(s) deleted since the backup are visible again — overwrite was on", + ) for key, value in fields.items(): setattr(existing, key, value) tally.restored += 1 @@ -865,7 +916,8 @@ class GitHubRestoreService: if not warned_files: tally.note( - "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup" + "archivesMetadataOnly", + "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup", ) warned_files = True @@ -934,7 +986,7 @@ class GitHubRestoreService: ) -> None: spools = inventory.get("spools") if isinstance(inventory, dict) else None if not isinstance(spools, list): - tally.note("No spool data in this backup") + tally.note("noData", "No data of this kind in this backup") return spool_id_map: dict[int, int] = {} @@ -1114,13 +1166,17 @@ class GitHubRestoreService: if unresolved: tally.note( + "spoolUsageUnresolved", f"{unresolved} usage record(s) skipped — their spool is not in this backup's " - "spool list, so there is nothing to attach them to." + "spool list, so there is nothing to attach them to.", + count=unresolved, ) if unlinked_archives: tally.note( + "spoolUsageUnlinked", f"{unlinked_archives} usage record(s) restored without their print-history link — " - "select Print archives alongside Spool inventory to keep it." + "select Print archives alongside Spool inventory to keep it.", + count=unlinked_archives, ) async def _restore_settings( @@ -1133,7 +1189,7 @@ class GitHubRestoreService: ) -> None: values = payload.get("settings") if isinstance(payload, dict) else None if not isinstance(values, dict): - tally.note("No settings data in this backup") + tally.note("noData", "No data of this kind in this backup") return # Planned before the first write, so the companion rule reads genuinely @@ -1176,17 +1232,27 @@ class GitHubRestoreService: keys_written.add(key) if plan.blocked: - tally.note(f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually") + tally.note( + "settingsCredentialsSkipped", + f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually", + count=len(plan.blocked), + ) if plan.protected: tally.note( + "settingsAuthSkipped", f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > " - "Authentication so the lockout checks still run" + "Authentication so the lockout checks still run", + count=len(plan.protected), ) if plan.companion: + keys = ", ".join(sorted(plan.companion)) tally.note( - f"{', '.join(sorted(plan.companion))} left switched off — the credential each one needs " - "cannot be restored from a backup and this instance has none stored, so switching them " - "on would leave the integration unauthenticated" + "settingsCompanionSkipped", + f"{keys} left switched off — the credential each one needs cannot be restored from a " + "backup and this instance has none stored, so switching them on would leave the " + "integration unauthenticated", + keys=keys, + count=len(plan.companion), ) async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None: @@ -1232,7 +1298,10 @@ class GitHubRestoreService: # must not turn a successful restore into a failed one. Noted rather # than swallowed silently, so the user knows to restart. logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True) - tally.note("MQTT settings restored, but the relay could not be reconnected — restart Bambuddy") + tally.note( + "settingsMqttRelayFailed", + "MQTT settings restored, but the relay could not be reconnected — restart Bambuddy", + ) async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None: by_serial: dict[str, list[tuple[str, dict]]] = {} @@ -1243,7 +1312,7 @@ class GitHubRestoreService: by_serial.setdefault(match.group(1), []).append((match.group(2), content)) if not by_serial: - tally.note("No K-profile data in this backup") + tally.note("noData", "No data of this kind in this backup") return result = await db.execute(select(Printer)) @@ -1252,8 +1321,11 @@ class GitHubRestoreService: # Overwrite is not offered for K-profiles: extrusion_cali_set replaces # the profile occupying a slot, so writing is always an overwrite on the # printer side. - tally.note("K-profiles always overwrite the matching slot on the printer") - tally.note("The printer's acknowledgement is not reliable — verify the profiles on the printer") + tally.note("kprofilesAlwaysOverwrite", "K-profiles always overwrite the matching slot on the printer") + tally.note( + "kprofilesAckUnreliable", + "The printer's acknowledgement is not reliable — verify the profiles on the printer", + ) for serial, entries in sorted(by_serial.items()): profile_total = sum(len(c.get("profiles") or []) for _, c in entries) @@ -1261,13 +1333,18 @@ class GitHubRestoreService: printer = printers.get(serial) if printer is None: tally.skipped += profile_total - tally.note(f"No printer with serial {serial} — skipped") + tally.note("kprofilesPrinterMissing", f"No printer with serial {serial} — skipped", serial=serial) continue client = printer_manager.get_client(printer.id) if not client or not client.state.connected: tally.skipped += profile_total - tally.note(f"{printer.name} ({serial}) is not connected — skipped") + tally.note( + "kprofilesPrinterOffline", + f"{printer.name} ({serial}) is not connected — skipped", + printer=printer.name, + serial=serial, + ) continue for nozzle, content in sorted(entries): @@ -1275,7 +1352,12 @@ class GitHubRestoreService: if not isinstance(profiles, list) or not profiles: continue if nozzle not in _KNOWN_NOZZLES: - tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is") + tally.note( + "kprofilesUnknownNozzle", + f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is", + nozzle=nozzle, + serial=serial, + ) # The backup's slot_id is a cali_idx, and cali_idx is as # unstable as the autoincrement ids we already refuse to reuse @@ -1316,8 +1398,12 @@ class GitHubRestoreService: continue if unmatched: tally.note( + "kprofilesUnmatched", f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} " - "— added as new profiles" + "— added as new profiles", + count=unmatched, + nozzle=nozzle, + printer=printer.name, ) try: @@ -1330,7 +1416,13 @@ class GitHubRestoreService: tally.restored += len(profile_dicts) else: tally.failed += len(profile_dicts) - tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})") + tally.note( + "kprofilesSendFailed", + f"Failed to send {nozzle} profiles to {printer.name} ({serial})", + nozzle=nozzle, + printer=printer.name, + serial=serial, + ) @staticmethod async def _current_kprofile_index(client, nozzle: str, serial: str) -> list: diff --git a/backend/tests/integration/test_github_restore_api.py b/backend/tests/integration/test_github_restore_api.py index 3032dd8de..673b3edc9 100644 --- a/backend/tests/integration/test_github_restore_api.py +++ b/backend/tests/integration/test_github_restore_api.py @@ -169,7 +169,18 @@ class TestRestoreEndpoint: "ref": "aaa1111", "results": { "spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []}, - "settings": {"restored": 1, "skipped": 2, "failed": 0, "notes": ["1 credential-like key(s) skipped"]}, + "settings": { + "restored": 1, + "skipped": 2, + "failed": 0, + "notes": [ + { + "code": "settingsCredentialsSkipped", + "params": {"count": 1}, + "message": "1 credential-like key(s) skipped", + } + ], + }, }, } with patch( @@ -184,7 +195,15 @@ class TestRestoreEndpoint: assert response.status_code == 200 body = response.json() assert body["results"]["spools"]["restored"] == 4 - assert body["results"]["settings"]["notes"] == ["1 credential-like key(s) skipped"] + # Notes cross the wire as code + params + English fallback, so a + # non-English client can translate them (#2656). + assert body["results"]["settings"]["notes"] == [ + { + "code": "settingsCredentialsSkipped", + "params": {"count": 1}, + "message": "1 credential-like key(s) skipped", + } + ] assert mock.await_args.kwargs["overwrite_existing"] is True assert mock.await_args.kwargs["ref"] == "aaa1111" @@ -391,7 +410,7 @@ class TestRestoreDoesNotOpenTheMetricsEndpoint: response = await async_client.get("/api/v1/metrics") assert response.status_code == 404, "a settings restore opened the metrics endpoint" assert "bambuddy_build_info" not in response.text - assert any("switched off" in note for note in tally.notes) + assert any(note["code"] == "settingsCompanionSkipped" for note in tally.notes) @pytest.mark.asyncio @pytest.mark.integration diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py index a7170c7c3..60675b1e9 100644 --- a/backend/tests/unit/test_github_restore.py +++ b/backend/tests/unit/test_github_restore.py @@ -41,6 +41,20 @@ def _service() -> GitHubRestoreService: return GitHubRestoreService() +def _messages(tally: _CategoryTally) -> list[str]: + """The English rendering of each note. + + Notes are ``{code, params, message}`` since they became translatable + (#2656); asserting on the message keeps these tests readable while + ``_codes`` covers the half a client actually keys on. + """ + return [note["message"] for note in tally.notes] + + +def _codes(tally: _CategoryTally) -> list[str]: + return [note["code"] for note in tally.notes] + + class TestParseDt: def test_parses_str_datetime_the_backup_writes(self): assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456) @@ -93,16 +107,33 @@ class TestSettingKeyBlocklist: class TestCategoryTally: + def test_a_note_carries_code_params_and_english(self): + tally = _CategoryTally() + tally.note("noData", "No data of this kind in this backup") + tally.note("spoolUsageUnresolved", "2 usage record(s) skipped", count=2) + + assert tally.notes == [ + {"code": "noData", "params": {}, "message": "No data of this kind in this backup"}, + {"code": "spoolUsageUnresolved", "params": {"count": 2}, "message": "2 usage record(s) skipped"}, + ] + def test_notes_are_deduplicated(self): tally = _CategoryTally() - tally.note("same") - tally.note("same") - assert tally.notes == ["same"] + tally.note("noData", "same") + tally.note("noData", "same") + assert len(tally.notes) == 1 + + def test_the_same_code_with_different_params_is_kept(self): + """Two printers can both be offline, and the user needs both names.""" + tally = _CategoryTally() + tally.note("kprofilesPrinterOffline", "A is not connected", printer="A") + tally.note("kprofilesPrinterOffline", "B is not connected", printer="B") + assert len(tally.notes) == 2 def test_notes_are_bounded(self): tally = _CategoryTally() for i in range(50): - tally.note(f"note {i}") + tally.note("noData", f"note {i}", index=i) assert len(tally.notes) == 20 @@ -185,7 +216,7 @@ class TestRestoreSettings: # keys, so counting them here would put the total above what the user # was shown before they pressed Restore. assert tally.skipped == 0 - assert any("credential-like" in note for note in tally.notes) + assert any("credential-like" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_auth_settings_are_never_restored(self, db_session): @@ -217,14 +248,14 @@ class TestRestoreSettings: # As above: refused keys are outside the preview's count, so outside the # tally too. assert tally.skipped == 0 - assert any("authentication setting" in note for note in tally.notes) + assert any("authentication setting" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_missing_payload_is_noted_not_fatal(self, db_session): tally = _CategoryTally() await _service()._restore_settings(db_session, None, overwrite=True, tally=tally) assert tally.restored == 0 - assert tally.notes + assert _codes(tally) == ["noData"] class TestSettingValueIsTrue: @@ -282,7 +313,7 @@ class TestCompanionCredentials: rows = await self._rows(db_session) assert rows == {"currency": "EUR"} - assert any("prometheus_enabled" in note and "switched off" in note for note in tally.notes) + assert any("prometheus_enabled" in note and "switched off" in note for note in _messages(tally)) @pytest.mark.asyncio @pytest.mark.parametrize("toggle,credential", sorted(_COMPANION_CREDENTIALS.items())) @@ -367,8 +398,11 @@ class TestCompanionCredentials: allowed_count, allowed_detail = await _service()._count_items(db_session, RestoreCategory.SETTINGS, parsed) assert refused_count == allowed_count - 1 - assert "switch(es)" in refused_detail - assert "switch(es)" not in allowed_detail + assert refused_detail.code == "settingsCompanionWillSkip" + assert refused_detail.params == {"count": 1, "companion": 1} + # Nothing is being left off now, so the wording drops back to the plain + # credential caveat. + assert allowed_detail.code == "settingsCredentialsWillSkip" # --- Controls: over-refusal is the real risk here ---------------------- @@ -380,7 +414,7 @@ class TestCompanionCredentials: tally = await self._restore(db_session, prometheus_enabled="true", prometheus_token="s3cret") assert (await self._rows(db_session))["prometheus_enabled"] == "true" - assert not any("switched off" in note for note in tally.notes) + assert not any("switched off" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_an_anonymous_broker_is_not_a_false_positive(self, db_session): @@ -388,7 +422,7 @@ class TestCompanionCredentials: tally = await self._restore(db_session, mqtt_enabled="true", mqtt_broker="10.0.0.5") assert (await self._rows(db_session))["mqtt_enabled"] == "true" - assert not any("switched off" in note for note in tally.notes) + assert not any("switched off" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_an_anonymous_ldap_bind_is_not_a_false_positive(self, db_session): @@ -396,7 +430,7 @@ class TestCompanionCredentials: tally = await self._restore(db_session, ldap_enabled="true", ldap_bind_password=" ") assert (await self._rows(db_session))["ldap_enabled"] == "true" - assert not any("switched off" in note for note in tally.notes) + assert not any("switched off" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_turning_a_toggle_off_is_always_written(self, db_session): @@ -424,7 +458,7 @@ class TestCompanionCredentials: tally = await self._restore(db_session, overwrite=True, prometheus_enabled="true", prometheus_token="s3cret") assert (await self._rows(db_session))["prometheus_enabled"] == "true" - assert not any("switched off" in note for note in tally.notes) + assert not any("switched off" in note for note in _messages(tally)) # --- The map itself ---------------------------------------------------- @@ -622,11 +656,11 @@ class TestRestoreSpools: assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None assert tally.skipped == 1 - assert any("their spool is not in this backup's spool list" in note for note in tally.notes) + assert any("their spool is not in this backup's spool list" in note for note in _messages(tally)) # No remedy is offered, because none exists: overwrite does not change # which spools land in the map (a skipped spool is mapped anyway), and # usage history is always restored alongside the spools category. - assert not any("overwrite" in note.lower() for note in tally.notes) + assert not any("overwrite" in note.lower() for note in _messages(tally)) @pytest.mark.asyncio async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session): @@ -651,7 +685,7 @@ class TestRestoreSpools: spool = (await db_session.execute(select(Spool))).scalar_one() row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one() assert row.spool_id == spool.id - assert not any("spool list" in note for note in tally.notes) + assert not any("spool list" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session): @@ -693,8 +727,8 @@ class TestRestoreSpools: assert len(rows) == 3 assert all(row.archive_id is None for row in rows) # Only the two that had a link to lose are counted. - assert any("2 usage record(s) restored without their print-history link" in n for n in tally.notes) - assert any("select Print archives alongside" in n for n in tally.notes) + assert any("2 usage record(s) restored without their print-history link" in n for n in _messages(tally)) + assert any("select Print archives alongside" in n for n in _messages(tally)) @pytest.mark.asyncio async def test_no_note_when_every_archive_link_resolves(self, db_session): @@ -714,7 +748,7 @@ class TestRestoreSpools: row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one() assert row.archive_id == archive.id - assert not any("print-history link" in note for note in tally.notes) + assert not any("print-history link" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_dangling_printer_id_is_cleared(self, db_session): @@ -765,7 +799,7 @@ class TestRestoreArchives: assert row.filename == "benchy.3mf" assert row.id != 77 assert id_map == {77: row.id} - assert any("metadata only" in note for note in tally.notes) + assert any("metadata only" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_matches_existing_archive_by_hash_and_start(self, db_session): @@ -920,7 +954,7 @@ class TestRestoreArchives: row = (await db_session.execute(select(PrintArchive))).scalar_one() assert row.deleted_at is None assert tally.restored == 1 - assert any("visible again" in note for note in tally.notes) + assert any("visible again" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session): @@ -958,7 +992,7 @@ class TestRestoreArchives: row = (await db_session.execute(select(PrintArchive))).scalar_one() assert row.printer_id is None assert row.project_id is None - assert any("no longer exist" in note for note in tally.notes) + assert any("no longer exist" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_valid_printer_link_is_preserved(self, db_session, printer_factory): @@ -1044,9 +1078,9 @@ class TestRestoreKprofiles: # The printer does answer extrusion_cali_set, but it reports "fail" on # writes that land, so the note must not promise either way. - assert any("verify the profiles on the printer" in note for note in tally.notes) - assert not any("without acknowledgement" in note for note in tally.notes) - assert any("always overwrite" in note for note in tally.notes) + assert any("verify the profiles on the printer" in note for note in _messages(tally)) + assert not any("without acknowledgement" in note for note in _messages(tally)) + assert any("always overwrite" in note for note in _messages(tally)) # --- cali_idx is resolved live, never taken from the backup ------------- # @@ -1103,7 +1137,7 @@ class TestRestoreKprofiles: profiles, _ = client.set_kprofiles_batch.call_args.args assert profiles[0]["cali_idx"] == -1, "-1 tells the printer to add a new profile" assert profiles[0]["setting_id"] == "PFUS123", "falls back to the backed-up preset" - assert any("added as new profiles" in note for note in tally.notes) + assert any("added as new profiles" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_different_filament_is_not_treated_as_a_match(self, db_session, printer_factory): @@ -1179,7 +1213,7 @@ class TestRestoreKprofiles: assert tally.restored == 0 assert tally.skipped == 1 - assert any("No printer with serial NOSUCH" in note for note in tally.notes) + assert any("No printer with serial NOSUCH" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory): @@ -1194,7 +1228,7 @@ class TestRestoreKprofiles: assert tally.skipped == 1 assert tally.failed == 0 - assert any("not connected" in note for note in tally.notes) + assert any("not connected" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_no_client_at_all_is_skipped(self, db_session, printer_factory): @@ -1257,7 +1291,7 @@ class TestRestoreKprofiles: async def test_empty_payload_is_noted(self, db_session): tally = _CategoryTally() await _service()._restore_kprofiles(db_session, {}, tally) - assert any("No K-profile data" in note for note in tally.notes) + assert _codes(tally) == ["noData"] class TestSoftDeletedArchiveRoundTrip: @@ -1343,7 +1377,7 @@ class TestRestoredArchiveOwnership: row = (await db_session.execute(select(PrintArchive))).scalar_one() assert row.created_by_id == user.id - assert not any("owner cleared" in note for note in tally.notes) + assert not any("owner cleared" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session): @@ -1358,7 +1392,7 @@ class TestRestoredArchiveOwnership: row = (await db_session.execute(select(PrintArchive))).scalar_one() assert row.created_by_id is None assert tally.restored == 1 and tally.failed == 0 - assert any("owner cleared" in note and "archives:read_all" in note for note in tally.notes) + assert any("owner cleared" in note and "archives:read_all" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session): @@ -1371,7 +1405,7 @@ class TestRestoredArchiveOwnership: await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {}) await db_session.commit() - assert sum(1 for note in tally.notes if "owner cleared" in note) == 1 + assert sum(1 for note in _messages(tally) if "owner cleared" in note) == 1 @pytest.mark.asyncio async def test_a_backup_without_the_key_still_restores(self, db_session): @@ -1383,7 +1417,7 @@ class TestRestoredArchiveOwnership: row = (await db_session.execute(select(PrintArchive))).scalar_one() assert row.created_by_id is None - assert not any("owner cleared" in note for note in tally.notes) + assert not any("owner cleared" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session): @@ -1561,7 +1595,7 @@ class TestMqttRelayReconfigure: with patch("backend.app.services.mqtt_relay.mqtt_relay", relay): await _service()._reconfigure_mqtt_relay(db_session, {"mqtt_enabled"}, tally) - assert any("restart Bambuddy" in note for note in tally.notes) + assert any("restart Bambuddy" in note for note in _messages(tally)) @pytest.mark.asyncio async def test_restore_settings_reports_the_keys_it_wrote(self, db_session): diff --git a/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx b/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx index 4b39ce15f..94c97eeb8 100644 --- a/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx +++ b/frontend/src/__tests__/components/GitHubRestoreModal.test.tsx @@ -37,11 +37,29 @@ const mockPreview = { ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0', commit: mockCommits.commits[0], metadata_version: '1.0', + // The server describes each caveat as a code plus typed params, carrying the + // English rendering as `detail` for i18next's defaultValue (#2656). Note the + // fixture's English deliberately differs from en.ts, so an assertion on the + // locale string proves the code was translated rather than echoed. categories: [ - { category: 'archives', available: true, item_count: 30, detail: 'Metadata only' }, - { category: 'spools', available: true, item_count: 4, detail: null }, - { category: 'settings', available: true, item_count: 12, detail: null }, - { category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' }, + { + category: 'archives', + available: true, + item_count: 30, + detail: 'raw server English, should not be rendered', + detail_code: 'archivesMetadataOnly', + detail_params: {}, + }, + { category: 'spools', available: true, item_count: 4, detail: null, detail_code: null, detail_params: {} }, + { category: 'settings', available: true, item_count: 12, detail: null, detail_code: null, detail_params: {} }, + { + category: 'kprofiles', + available: false, + item_count: 0, + detail: 'raw server English, should not be rendered', + detail_code: 'notPresent', + detail_params: {}, + }, ], }; @@ -98,6 +116,42 @@ describe('GitHubRestoreModal', () => { expect(screen.getByText('12 in backup')).toBeInTheDocument(); }); + it('translates preview caveats rather than echoing the server English', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Metadata only - 3MF files and thumbnails are not in a Git backup') + ).toBeInTheDocument(); + }); + expect(screen.queryAllByText('raw server English, should not be rendered')).toHaveLength(0); + }); + + it('falls back to the server English for a code it does not know', async () => { + // A newer backend adding a detail_code this build has no key for must not + // print the raw key at the user. Same defaultValue arm backup.pathCheck uses. + mockEndpoints({ + preview: { + ...mockPreview, + categories: [ + { + category: 'spools', + available: true, + item_count: 4, + detail: 'Something a future release explains', + detail_code: 'somethingThisBuildHasNeverHeardOf', + detail_params: {}, + }, + ], + }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText('Something a future release explains')).toBeInTheDocument(); + }); + }); + it('disables a category that is absent from the commit', async () => { render(); @@ -168,7 +222,18 @@ describe('GitHubRestoreModal', () => { log_id: 3, ref: mockPreview.ref, results: { - spools: { restored: 4, skipped: 1, failed: 0, notes: ['1 usage record(s) skipped'] }, + spools: { + restored: 4, + skipped: 1, + failed: 0, + notes: [ + { + code: 'spoolUsageUnresolved', + params: { count: 1 }, + message: 'raw server English, should not be rendered', + }, + ], + }, }, }); }) @@ -195,7 +260,12 @@ describe('GitHubRestoreModal', () => { ref: mockPreview.ref, }); expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument(); - expect(screen.getByText('1 usage record(s) skipped')).toBeInTheDocument(); + // The locale string with {{count}} filled in, not the server's English — + // which is what makes the note translatable for a non-English user. + expect( + screen.getByText(/^1 usage record\(s\) skipped - their spool is not in this backup's spool list/) + ).toBeInTheDocument(); + expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument(); }); it('drops the selection while a newly-picked commit is still being inspected', async () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9289fc0d3..4f2b1955c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2845,11 +2845,22 @@ export interface GitHubCommitListResponse { commits: GitHubCommitInfo[]; } +/** + * Values the server interpolates into a translated note or preview detail. + * Kept to strings and numbers on purpose — anything richer would have to be + * formatted server-side and could not be translated. + */ +export type GitHubRestoreParams = Record; + export interface GitHubRestorePreviewCategory { category: RestoreCategory; available: boolean; item_count: number; + /** English rendering. Used as i18next's defaultValue, never shown on its own. */ detail: string | null; + /** Key under backup.restoreFromGit.details, or null when there is no caveat. */ + detail_code: string | null; + detail_params: GitHubRestoreParams; } export interface GitHubRestorePreview { @@ -2867,11 +2878,26 @@ export interface GitHubRestoreRequest { overwrite_existing?: boolean; } +/** + * One tally note, as a translation code plus its parameters (#2656). + * + * Same contract as {@link LocalBackupPathCheck} one card down: the server picks + * the code and supplies typed params, and the client renders + * ``t(`backup.restoreFromGit.notes.${code}`, { ...params, defaultValue: message })``. + * A code the client does not know yet falls back to the English `message` + * rather than showing the raw key. + */ +export interface GitHubRestoreNote { + code: string; + params: GitHubRestoreParams; + message: string; +} + export interface GitHubRestoreCategoryResult { restored: number; skipped: number; failed: number; - notes: string[]; + notes: GitHubRestoreNote[]; } export interface GitHubRestoreResponse { diff --git a/frontend/src/components/GitHubRestoreModal.tsx b/frontend/src/components/GitHubRestoreModal.tsx index e88ab6995..3ec798a6c 100644 --- a/frontend/src/components/GitHubRestoreModal.tsx +++ b/frontend/src/components/GitHubRestoreModal.tsx @@ -16,12 +16,41 @@ import { Card, CardContent } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { ConfirmModal } from './ConfirmModal'; -import { api, type RestoreCategory, type GitHubRestoreResponse } from '../api/client'; +import { + api, + type RestoreCategory, + type GitHubRestoreParams, + type GitHubRestoreResponse, +} from '../api/client'; +import type { TFunction } from 'i18next'; interface GitHubRestoreModalProps { onClose: () => void; } +/** + * Render a server-supplied translation code, falling back to its English text. + * + * The restore endpoints describe every note and preview caveat as a `code` plus + * typed `params`, and carry the English rendering along as `message`. That is + * the same contract `backup.pathCheck` already uses one card down in + * GitHubBackupSettings — including the `defaultValue` arm, which is what keeps a + * newer backend's unfamiliar code readable instead of printing the raw key. + */ +function translateCoded( + t: TFunction, + group: 'notes' | 'details', + code: string | null | undefined, + params: GitHubRestoreParams | undefined, + fallback: string | null +): string | null { + if (!code) return fallback; + return t(`backup.restoreFromGit.${group}.${code}`, { + ...(params ?? {}), + defaultValue: fallback ?? code, + }); +} + interface CategoryMeta { id: RestoreCategory; labelKey: string; @@ -77,10 +106,14 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) { const availability = useMemo(() => { const map: Record = {}; previewQuery.data?.categories?.forEach((c) => { - map[c.category] = { available: c.available, itemCount: c.item_count, detail: c.detail }; + map[c.category] = { + available: c.available, + itemCount: c.item_count, + detail: translateCoded(t, 'details', c.detail_code, c.detail_params, c.detail), + }; }); return map; - }, [previewQuery.data]); + }, [previewQuery.data, t]); // What a Restore click would actually send. `selected` on its own is not that: // it survives a commit switch by design (the pruning effect below only runs @@ -292,9 +325,15 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) { {tally.notes.length > 0 && (
    {tally.notes.map((note) => ( -
  • + // The server dedupes on (code, params), not on code + // alone — two printers can both be offline — so the + // key has to carry the params too. +
  • - {note} + {translateCoded(t, 'notes', note.code, note.params, note.message)}
  • ))}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 417d7edcb..70e85b4ed 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -4895,6 +4895,38 @@ export default { reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.', failed: 'Wiederherstellung fehlgeschlagen.', loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.', + details: { + notPresent: 'In diesem Backup-Commit nicht vorhanden', + unreadableJson: 'Unlesbares JSON: {{paths}}', + settingsNoPayload: 'Keine Einstellungen in den Daten', + settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen', + settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus', + spoolsUsageCount: 'zzgl. {{count}} Verbrauchseinträge', + archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten', + kprofilesPrinterCount: 'über {{count}} Drucker', + }, + notes: { + noData: 'Keine Daten dieser Art in diesem Backup', + archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt', + archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt', + archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist', + archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv', + archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten', + spoolUsageUnresolved: '{{count}} Verbrauchseinträge übersprungen - ihre Spule ist nicht in der Spulenliste dieses Backups, es gibt also nichts, woran sie hängen könnten.', + spoolUsageUnlinked: '{{count}} Verbrauchseinträge ohne Verknüpfung zum Druckverlauf wiederhergestellt - wählen Sie Druckarchive zusammen mit dem Spulenbestand, um sie zu behalten.', + spoolTagKept: '{{count}} Spulen-Tags unverändert gelassen - das Backup hätte einen inzwischen gescannten Tag gelöscht oder ihn auf eine zweite Spule verschoben.', + settingsCredentialsSkipped: '{{count}} zugangsdatenähnliche Schlüssel übersprungen - Geheimnisse bitte manuell erneut eingeben', + settingsAuthSkipped: '{{count}} Authentifizierungseinstellungen übersprungen - ändern Sie diese unter Einstellungen > Authentifizierung, damit die Aussperrprüfungen greifen', + settingsCompanionSkipped: '{{keys}} bleiben ausgeschaltet - die jeweils benötigten Zugangsdaten lassen sich nicht aus einem Backup wiederherstellen und sind auf dieser Instanz nicht hinterlegt, ein Einschalten würde die Integration also ohne Authentifizierung lassen', + settingsMqttRelayFailed: 'MQTT-Einstellungen wiederhergestellt, aber das Relay konnte nicht neu verbunden werden - Bambuddy neu starten', + kprofilesAlwaysOverwrite: 'K-Profile überschreiben immer den passenden Slot auf dem Drucker', + kprofilesAckUnreliable: 'Die Bestätigung des Druckers ist nicht zuverlässig - überprüfen Sie die Profile am Drucker', + kprofilesPrinterMissing: 'Kein Drucker mit der Seriennummer {{serial}} - übersprungen', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) ist nicht verbunden - übersprungen', + kprofilesUnknownNozzle: 'Unerwarteter Düsendurchmesser {{nozzle}} für {{serial}} - unverändert gesendet', + kprofilesUnmatched: '{{count}} Profile für {{nozzle}} hatten kein Gegenstück auf {{printer}} - als neue Profile hinzugefügt', + kprofilesSendFailed: '{{nozzle}}-Profile konnten nicht an {{printer}} ({{serial}}) gesendet werden', + }, }, // History diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 25bc8df00..0cafad7d9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4938,6 +4938,43 @@ export default { reloadHint: 'Reload Bambuddy so the restored data appears everywhere.', failed: 'Restore failed.', loadFailed: 'Could not read the backup repository.', + // Preview caveats. The server sends detail_code + detail_params and the + // English detail as defaultValue, same contract as backup.pathCheck. + details: { + notPresent: 'Not present in this backup commit', + unreadableJson: 'Unreadable JSON: {{paths}}', + settingsNoPayload: 'No settings in payload', + settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped', + settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off', + spoolsUsageCount: 'plus {{count}} usage record(s)', + archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup', + kprofilesPrinterCount: 'across {{count}} printer(s)', + }, + // Tally notes, same contract. noData is shared by all four categories: + // the category heading renders beside it, so naming the category again + // would be redundant. + notes: { + noData: 'No data of this kind in this backup', + archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared', + archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared', + archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them', + archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on', + archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup', + spoolUsageUnresolved: '{{count}} usage record(s) skipped - their spool is not in this backup\'s spool list, so there is nothing to attach them to.', + spoolUsageUnlinked: '{{count}} usage record(s) restored without their print-history link - select Print archives alongside Spool inventory to keep it.', + spoolTagKept: '{{count}} spool tag(s) left as they are - the backup would have cleared a tag that has since been scanned, or moved one onto a second spool.', + settingsCredentialsSkipped: '{{count}} credential-like key(s) skipped - re-enter secrets manually', + settingsAuthSkipped: '{{count}} authentication setting(s) skipped - change those in Settings > Authentication so the lockout checks still run', + settingsCompanionSkipped: '{{keys}} left switched off - the credential each one needs cannot be restored from a backup and this instance has none stored, so switching them on would leave the integration unauthenticated', + settingsMqttRelayFailed: 'MQTT settings restored, but the relay could not be reconnected - restart Bambuddy', + kprofilesAlwaysOverwrite: 'K-profiles always overwrite the matching slot on the printer', + kprofilesAckUnreliable: 'The printer\'s acknowledgement is not reliable - verify the profiles on the printer', + kprofilesPrinterMissing: 'No printer with serial {{serial}} - skipped', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) is not connected - skipped', + kprofilesUnknownNozzle: 'Unexpected nozzle diameter {{nozzle}} for {{serial}} - sent as-is', + kprofilesUnmatched: '{{count}} profile(s) for {{nozzle}} had no counterpart on {{printer}} - added as new profiles', + kprofilesSendFailed: 'Failed to send {{nozzle}} profiles to {{printer}} ({{serial}})', + }, }, // History diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 77a80b138..0e964a13c 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -4903,6 +4903,38 @@ export default { reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.', failed: 'La restauración ha fallado.', loadFailed: 'No se pudo leer el repositorio de copias de seguridad.', + details: { + notPresent: 'No está presente en este commit de la copia', + unreadableJson: 'JSON ilegible: {{paths}}', + settingsNoPayload: 'No hay ajustes en los datos', + settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial', + settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados', + spoolsUsageCount: 'más {{count}} registros de consumo', + archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git', + kprofilesPrinterCount: 'en {{count}} impresoras', + }, + notes: { + noData: 'No hay datos de este tipo en esta copia de seguridad', + archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado', + archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado', + archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne', + archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada', + archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git', + spoolUsageUnresolved: '{{count}} registros de consumo omitidos - su bobina no está en la lista de bobinas de esta copia, así que no hay nada a lo que asociarlos.', + spoolUsageUnlinked: '{{count}} registros de consumo restaurados sin su enlace al historial de impresión - selecciona Archivos de impresión junto con Inventario de bobinas para conservarlo.', + spoolTagKept: '{{count}} etiquetas de bobina se han dejado como estaban - la copia habría borrado una etiqueta escaneada desde entonces, o la habría movido a una segunda bobina.', + settingsCredentialsSkipped: '{{count}} claves con aspecto de credencial omitidas - vuelve a introducir los secretos manualmente', + settingsAuthSkipped: '{{count}} ajustes de autenticación omitidos - cámbialos en Ajustes > Autenticación para que sigan aplicándose las comprobaciones de bloqueo', + settingsCompanionSkipped: '{{keys}} se han dejado desactivados - la credencial que cada uno necesita no puede restaurarse desde una copia y esta instancia no tiene ninguna guardada, así que activarlos dejaría la integración sin autenticación', + settingsMqttRelayFailed: 'Ajustes MQTT restaurados, pero no se pudo reconectar el relé - reinicia Bambuddy', + kprofilesAlwaysOverwrite: 'Los perfiles K siempre sobrescriben la ranura correspondiente en la impresora', + kprofilesAckUnreliable: 'La confirmación de la impresora no es fiable - verifica los perfiles en la impresora', + kprofilesPrinterMissing: 'No hay ninguna impresora con el número de serie {{serial}} - omitido', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) no está conectada - omitido', + kprofilesUnknownNozzle: 'Diámetro de boquilla inesperado {{nozzle}} para {{serial}} - enviado tal cual', + kprofilesUnmatched: '{{count}} perfiles para {{nozzle}} no tenían equivalente en {{printer}} - añadidos como perfiles nuevos', + kprofilesSendFailed: 'No se pudieron enviar los perfiles de {{nozzle}} a {{printer}} ({{serial}})', + }, }, // History diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index dc99b710f..ba40e6c27 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -4884,6 +4884,38 @@ export default { reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.', failed: 'Échec de la restauration.', loadFailed: 'Impossible de lire le dépôt de sauvegarde.', + details: { + notPresent: 'Absent de ce commit de sauvegarde', + unreadableJson: 'JSON illisible : {{paths}}', + settingsNoPayload: 'Aucun réglage dans les données', + settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées', + settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés', + spoolsUsageCount: 'plus {{count}} enregistrements de consommation', + archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git', + kprofilesPrinterCount: 'sur {{count}} imprimantes', + }, + notes: { + noData: 'Aucune donnée de ce type dans cette sauvegarde', + archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé', + archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé', + archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue', + archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé', + archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git', + spoolUsageUnresolved: '{{count}} enregistrements de consommation ignorés - leur bobine ne figure pas dans la liste des bobines de cette sauvegarde, il n\'y a donc rien à quoi les rattacher.', + spoolUsageUnlinked: '{{count}} enregistrements de consommation restaurés sans leur lien vers l\'historique d\'impression - sélectionnez Archives d\'impression en même temps que l\'Inventaire des bobines pour le conserver.', + spoolTagKept: '{{count}} étiquettes de bobine laissées telles quelles - la sauvegarde aurait effacé une étiquette scannée depuis, ou l\'aurait déplacée sur une seconde bobine.', + settingsCredentialsSkipped: '{{count}} clés ressemblant à des identifiants ignorées - ressaisissez les secrets manuellement', + settingsAuthSkipped: '{{count}} réglages d\'authentification ignorés - modifiez-les dans Réglages > Authentification pour que les contrôles de verrouillage s\'appliquent', + settingsCompanionSkipped: '{{keys}} laissés désactivés - l\'identifiant dont chacun a besoin ne peut pas être restauré depuis une sauvegarde et cette instance n\'en a aucun enregistré ; les activer laisserait donc l\'intégration sans authentification', + settingsMqttRelayFailed: 'Réglages MQTT restaurés, mais le relais n\'a pas pu être reconnecté - redémarrez Bambuddy', + kprofilesAlwaysOverwrite: 'Les profils K écrasent toujours l\'emplacement correspondant sur l\'imprimante', + kprofilesAckUnreliable: 'L\'accusé de réception de l\'imprimante n\'est pas fiable - vérifiez les profils sur l\'imprimante', + kprofilesPrinterMissing: 'Aucune imprimante avec le numéro de série {{serial}} - ignoré', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) n\'est pas connectée - ignoré', + kprofilesUnknownNozzle: 'Diamètre de buse inattendu {{nozzle}} pour {{serial}} - envoyé tel quel', + kprofilesUnmatched: '{{count}} profils pour {{nozzle}} n\'avaient pas d\'équivalent sur {{printer}} - ajoutés comme nouveaux profils', + kprofilesSendFailed: 'Impossible d\'envoyer les profils {{nozzle}} à {{printer}} ({{serial}})', + }, }, // History diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index aa0872ea2..147b2b7b6 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -4883,6 +4883,38 @@ export default { reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.', failed: 'Ripristino non riuscito.', loadFailed: 'Impossibile leggere il repository di backup.', + details: { + notPresent: 'Non presente in questo commit di backup', + unreadableJson: 'JSON illeggibile: {{paths}}', + settingsNoPayload: 'Nessuna impostazione nei dati', + settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate', + settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati', + spoolsUsageCount: 'più {{count}} record di consumo', + archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git', + kprofilesPrinterCount: 'su {{count}} stampanti', + }, + notes: { + noData: 'Nessun dato di questo tipo in questo backup', + archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso', + archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso', + archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna', + archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva', + archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git', + spoolUsageUnresolved: '{{count}} record di consumo saltati - la loro bobina non è nell\'elenco bobine di questo backup, quindi non c\'è nulla a cui collegarli.', + spoolUsageUnlinked: '{{count}} record di consumo ripristinati senza il collegamento alla cronologia di stampa - seleziona Archivi di stampa insieme a Inventario bobine per mantenerlo.', + spoolTagKept: '{{count}} tag bobina lasciati invariati - il backup avrebbe cancellato un tag nel frattempo scansionato, oppure lo avrebbe spostato su una seconda bobina.', + settingsCredentialsSkipped: '{{count}} chiavi simili a credenziali saltate - reinserisci i segreti manualmente', + settingsAuthSkipped: '{{count}} impostazioni di autenticazione saltate - modificale in Impostazioni > Autenticazione così i controlli di blocco restano attivi', + settingsCompanionSkipped: '{{keys}} lasciati disattivati - la credenziale richiesta da ciascuno non può essere ripristinata da un backup e questa istanza non ne ha nessuna salvata, quindi attivarli lascerebbe l\'integrazione senza autenticazione', + settingsMqttRelayFailed: 'Impostazioni MQTT ripristinate, ma il relay non è stato riconnesso - riavvia Bambuddy', + kprofilesAlwaysOverwrite: 'I profili K sovrascrivono sempre lo slot corrispondente sulla stampante', + kprofilesAckUnreliable: 'La conferma della stampante non è affidabile - verifica i profili sulla stampante', + kprofilesPrinterMissing: 'Nessuna stampante con numero di serie {{serial}} - saltato', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) non è connessa - saltato', + kprofilesUnknownNozzle: 'Diametro ugello inatteso {{nozzle}} per {{serial}} - inviato così com\'è', + kprofilesUnmatched: '{{count}} profili per {{nozzle}} non avevano corrispondenza su {{printer}} - aggiunti come nuovi profili', + kprofilesSendFailed: 'Impossibile inviare i profili {{nozzle}} a {{printer}} ({{serial}})', + }, }, // History diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 937901e44..dbcae6f6a 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -4895,6 +4895,38 @@ export default { reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。', failed: '復元に失敗しました。', loadFailed: 'バックアップリポジトリを読み取れませんでした。', + details: { + notPresent: 'このバックアップコミットには含まれていません', + unreadableJson: '読み取れない JSON: {{paths}}', + settingsNoPayload: 'データに設定が含まれていません', + settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます', + settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります', + spoolsUsageCount: '使用履歴 {{count}} 件を含む', + archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません', + kprofilesPrinterCount: 'プリンター {{count}} 台分', + }, + notes: { + noData: 'この種類のデータはこのバックアップに含まれていません', + archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました', + archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました', + archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません', + archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした', + archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません', + spoolUsageUnresolved: '使用履歴 {{count}} 件をスキップしました - 対応するスプールがこのバックアップのスプール一覧にないため、紐付ける先がありません。', + spoolUsageUnlinked: '使用履歴 {{count}} 件を印刷履歴へのリンクなしで復元しました - リンクを保持するにはスプール在庫と一緒に印刷アーカイブも選択してください。', + spoolTagKept: 'スプールタグ {{count}} 件をそのままにしました - バックアップの内容ではその後スキャンされたタグが消えるか、別のスプールに移ってしまうためです。', + settingsCredentialsSkipped: '認証情報のようなキー {{count}} 件をスキップしました - シークレットは手動で再入力してください', + settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください', + settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります', + settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください', + kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします', + kprofilesAckUnreliable: 'プリンターの応答は信頼できません - プロファイルはプリンター側で確認してください', + kprofilesPrinterMissing: 'シリアル {{serial}} のプリンターがありません - スキップしました', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) は接続されていません - スキップしました', + kprofilesUnknownNozzle: '{{serial}} に想定外のノズル径 {{nozzle}} - そのまま送信しました', + kprofilesUnmatched: '{{nozzle}} 用のプロファイル {{count}} 件は {{printer}} に該当がありませんでした - 新規プロファイルとして追加しました', + kprofilesSendFailed: '{{nozzle}} のプロファイルを {{printer}} ({{serial}}) に送信できませんでした', + }, }, // History diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 35832c4df..7085207ae 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -4660,6 +4660,38 @@ export default { reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.', failed: '복원에 실패했습니다.', loadFailed: '백업 저장소를 읽을 수 없습니다.', + details: { + notPresent: '이 백업 커밋에는 없습니다', + unreadableJson: '읽을 수 없는 JSON: {{paths}}', + settingsNoPayload: '데이터에 설정이 없습니다', + settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다', + settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다', + spoolsUsageCount: '사용 기록 {{count}}건 포함', + archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다', + kprofilesPrinterCount: '프린터 {{count}}대 분량', + }, + notes: { + noData: '이 백업에는 이런 종류의 데이터가 없습니다', + archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다', + archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다', + archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다', + archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다', + archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다', + spoolUsageUnresolved: '사용 기록 {{count}}건을 건너뛰었습니다 - 해당 스풀이 이 백업의 스풀 목록에 없어 연결할 대상이 없습니다.', + spoolUsageUnlinked: '사용 기록 {{count}}건을 출력 기록 연결 없이 복원했습니다 - 연결을 유지하려면 스풀 재고와 함께 출력 아카이브도 선택하세요.', + spoolTagKept: '스풀 태그 {{count}}개를 그대로 두었습니다 - 백업대로라면 그 사이 스캔된 태그가 지워지거나 다른 스풀로 옮겨졌을 것입니다.', + settingsCredentialsSkipped: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰었습니다 - 비밀 값은 직접 다시 입력하세요', + settingsAuthSkipped: '인증 설정 {{count}}개를 건너뛰었습니다 - 잠금 검사가 계속 동작하도록 설정 > 인증에서 변경하세요', + settingsCompanionSkipped: '{{keys}}을(를) 꺼진 상태로 두었습니다 - 각 항목에 필요한 자격 증명은 백업에서 복원할 수 없고 이 인스턴스에도 저장되어 있지 않아, 켜면 연동이 인증 없이 열립니다', + settingsMqttRelayFailed: 'MQTT 설정을 복원했지만 릴레이를 다시 연결하지 못했습니다 - Bambuddy를 재시작하세요', + kprofilesAlwaysOverwrite: 'K 프로파일은 항상 프린터의 해당 슬롯을 덮어씁니다', + kprofilesAckUnreliable: '프린터의 응답은 신뢰할 수 없습니다 - 프린터에서 프로파일을 확인하세요', + kprofilesPrinterMissing: '시리얼 {{serial}}인 프린터가 없습니다 - 건너뛰었습니다', + kprofilesPrinterOffline: '{{printer}}({{serial}})이(가) 연결되어 있지 않습니다 - 건너뛰었습니다', + kprofilesUnknownNozzle: '{{serial}}의 예상치 못한 노즐 직경 {{nozzle}} - 그대로 전송했습니다', + kprofilesUnmatched: '{{nozzle}}용 프로파일 {{count}}개가 {{printer}}에 대응 항목이 없습니다 - 새 프로파일로 추가했습니다', + kprofilesSendFailed: '{{nozzle}} 프로파일을 {{printer}}({{serial}})에 보내지 못했습니다', + }, }, history: '기록', clear: '초기화', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 6e75f13fe..b26cb3103 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -4883,6 +4883,38 @@ export default { reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.', failed: 'Falha na restauração.', loadFailed: 'Não foi possível ler o repositório de backup.', + details: { + notPresent: 'Não está presente neste commit de backup', + unreadableJson: 'JSON ilegível: {{paths}}', + settingsNoPayload: 'Nenhuma configuração nos dados', + settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas', + settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas', + spoolsUsageCount: 'mais {{count}} registros de consumo', + archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git', + kprofilesPrinterCount: 'em {{count}} impressoras', + }, + notes: { + noData: 'Não há dados desse tipo neste backup', + archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido', + archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido', + archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua', + archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada', + archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git', + spoolUsageUnresolved: '{{count}} registros de consumo ignorados - o carretel deles não está na lista de carretéis deste backup, então não há a que vinculá-los.', + spoolUsageUnlinked: '{{count}} registros de consumo restaurados sem o vínculo com o histórico de impressão - selecione Arquivos de impressão junto com Inventário de carretéis para mantê-lo.', + spoolTagKept: '{{count}} etiquetas de carretel foram mantidas como estavam - o backup teria apagado uma etiqueta lida desde então, ou a teria movido para um segundo carretel.', + settingsCredentialsSkipped: '{{count}} chaves parecidas com credenciais ignoradas - digite os segredos novamente à mão', + settingsAuthSkipped: '{{count}} configurações de autenticação ignoradas - altere-as em Configurações > Autenticação para que as verificações de bloqueio continuem valendo', + settingsCompanionSkipped: '{{keys}} ficaram desligados - a credencial que cada um precisa não pode ser restaurada de um backup e esta instância não tem nenhuma armazenada, então ligá-los deixaria a integração sem autenticação', + settingsMqttRelayFailed: 'Configurações MQTT restauradas, mas o relay não pôde ser reconectado - reinicie o Bambuddy', + kprofilesAlwaysOverwrite: 'Os perfis K sempre sobrescrevem o slot correspondente na impressora', + kprofilesAckUnreliable: 'A confirmação da impressora não é confiável - verifique os perfis na impressora', + kprofilesPrinterMissing: 'Nenhuma impressora com o número de série {{serial}} - ignorado', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) não está conectada - ignorado', + kprofilesUnknownNozzle: 'Diâmetro de bico inesperado {{nozzle}} para {{serial}} - enviado como está', + kprofilesUnmatched: '{{count}} perfis para {{nozzle}} não tinham correspondente em {{printer}} - adicionados como novos perfis', + kprofilesSendFailed: 'Não foi possível enviar os perfis de {{nozzle}} para {{printer}} ({{serial}})', + }, }, // History diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 798a3a582..097af3b30 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -4652,6 +4652,38 @@ export default { reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.', failed: 'Не удалось выполнить восстановление.', loadFailed: 'Не удалось прочитать репозиторий резервных копий.', + details: { + notPresent: 'Отсутствует в этом коммите резервной копии', + unreadableJson: 'Нечитаемый JSON: {{paths}}', + settingsNoPayload: 'В данных нет настроек', + settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}', + settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными', + spoolsUsageCount: 'плюс записей расхода: {{count}}', + archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git', + kprofilesPrinterCount: 'по {{count}} принтерам', + }, + notes: { + noData: 'В этой резервной копии нет данных такого типа', + archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена', + archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена', + archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново', + archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена', + archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git', + spoolUsageUnresolved: 'Записей расхода пропущено: {{count}} - их катушки нет в списке катушек этой резервной копии, поэтому привязать их не к чему.', + spoolUsageUnlinked: 'Записей расхода восстановлено без связи с историей печати: {{count}} - выберите «Архивы печати» вместе с «Инвентарём катушек», чтобы сохранить связь.', + spoolTagKept: 'Меток катушек оставлено без изменений: {{count}} - резервная копия стёрла бы метку, отсканированную позже, или перенесла бы её на другую катушку.', + settingsCredentialsSkipped: 'Ключей, похожих на учётные данные, пропущено: {{count}} - введите секреты вручную', + settingsAuthSkipped: 'Настроек аутентификации пропущено: {{count}} - меняйте их в разделе «Настройки > Аутентификация», чтобы продолжали работать проверки блокировки', + settingsCompanionSkipped: '{{keys}} оставлены выключенными - нужные им учётные данные нельзя восстановить из резервной копии, и в этом экземпляре они не сохранены, поэтому включение оставило бы интеграцию без аутентификации', + settingsMqttRelayFailed: 'Настройки MQTT восстановлены, но переподключить реле не удалось - перезапустите Bambuddy', + kprofilesAlwaysOverwrite: 'K-профили всегда перезаписывают соответствующий слот на принтере', + kprofilesAckUnreliable: 'Подтверждение принтера ненадёжно - проверьте профили на принтере', + kprofilesPrinterMissing: 'Нет принтера с серийным номером {{serial}} - пропущено', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) не подключён - пропущено', + kprofilesUnknownNozzle: 'Неожиданный диаметр сопла {{nozzle}} для {{serial}} - отправлено как есть', + kprofilesUnmatched: 'Профилей для {{nozzle}} без соответствия на {{printer}}: {{count}} - добавлены как новые профили', + kprofilesSendFailed: 'Не удалось отправить профили {{nozzle}} на {{printer}} ({{serial}})', + }, }, history: "История", clear: "Очистить", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index afcbda9a0..3d9e406e6 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -4873,6 +4873,38 @@ export default { reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.', failed: 'Geri yükleme başarısız oldu.', loadFailed: 'Yedek deposu okunamadı.', + details: { + notPresent: 'Bu yedek commit\'inde yok', + unreadableJson: 'Okunamayan JSON: {{paths}}', + settingsNoPayload: 'Veride ayar yok', + settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak', + settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak', + spoolsUsageCount: 'ayrıca {{count}} kullanım kaydı', + archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz', + kprofilesPrinterCount: '{{count}} yazıcı genelinde', + }, + notes: { + noData: 'Bu yedekte bu türde veri yok', + archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi', + archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi', + archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler', + archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı', + archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz', + spoolUsageUnresolved: '{{count}} kullanım kaydı atlandı - makaraları bu yedeğin makara listesinde olmadığı için bağlanacak bir şey yok.', + spoolUsageUnlinked: '{{count}} kullanım kaydı baskı geçmişi bağlantısı olmadan geri yüklendi - bağlantıyı korumak için Baskı arşivlerini Makara envanteriyle birlikte seçin.', + spoolTagKept: '{{count}} makara etiketi olduğu gibi bırakıldı - yedek, o zamandan beri okutulmuş bir etiketi silecek ya da ikinci bir makaraya taşıyacaktı.', + settingsCredentialsSkipped: 'Kimlik bilgisi benzeri {{count}} anahtar atlandı - gizli değerleri elle yeniden girin', + settingsAuthSkipped: '{{count}} kimlik doğrulama ayarı atlandı - kilitlenme kontrolleri çalışmaya devam etsin diye bunları Ayarlar > Kimlik Doğrulama bölümünden değiştirin', + settingsCompanionSkipped: '{{keys}} kapalı bırakıldı - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez ve bu örnekte kayıtlı değil, dolayısıyla açmak entegrasyonu kimlik doğrulamasız bırakırdı', + settingsMqttRelayFailed: 'MQTT ayarları geri yüklendi ancak röle yeniden bağlanamadı - Bambuddy\'yi yeniden başlatın', + kprofilesAlwaysOverwrite: 'K profilleri yazıcıdaki eşleşen yuvanın her zaman üzerine yazar', + kprofilesAckUnreliable: 'Yazıcının onayı güvenilir değil - profilleri yazıcıda doğrulayın', + kprofilesPrinterMissing: '{{serial}} seri numaralı yazıcı yok - atlandı', + kprofilesPrinterOffline: '{{printer}} ({{serial}}) bağlı değil - atlandı', + kprofilesUnknownNozzle: '{{serial}} için beklenmeyen nozul çapı {{nozzle}} - olduğu gibi gönderildi', + kprofilesUnmatched: '{{nozzle}} için {{count}} profilin {{printer}} üzerinde karşılığı yoktu - yeni profil olarak eklendi', + kprofilesSendFailed: '{{nozzle}} profilleri {{printer}} ({{serial}}) yazıcısına gönderilemedi', + }, }, history: 'Geçmiş', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 47baf2f39..fce156de0 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -4938,6 +4938,38 @@ export default { reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.", failed: "Не вдалося виконати відновлення.", loadFailed: "Не вдалося прочитати репозиторій резервних копій.", + details: { + notPresent: "Відсутнє в цьому коміті резервної копії", + unreadableJson: "Нечитабельний JSON: {{paths}}", + settingsNoPayload: "У даних немає налаштувань", + settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}", + settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими", + spoolsUsageCount: "плюс записів використання: {{count}}", + archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git", + kprofilesPrinterCount: "по {{count}} принтерах", + }, + notes: { + noData: "У цій резервній копії немає даних такого типу", + archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено", + archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено", + archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову", + archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений", + archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git", + spoolUsageUnresolved: "Записів використання пропущено: {{count}} - їхньої котушки немає у списку котушок цієї резервної копії, тож немає до чого їх прив'язати.", + spoolUsageUnlinked: "Записів використання відновлено без зв'язку з історією друку: {{count}} - виберіть «Архіви друку» разом з «Інвентарем котушок», щоб зберегти зв'язок.", + spoolTagKept: "Міток котушок залишено без змін: {{count}} - резервна копія стерла б мітку, відскановану пізніше, або перенесла б її на іншу котушку.", + settingsCredentialsSkipped: "Ключів, схожих на облікові дані, пропущено: {{count}} - введіть секрети вручну", + settingsAuthSkipped: "Налаштувань автентифікації пропущено: {{count}} - змінюйте їх у розділі «Налаштування > Автентифікація», щоб перевірки блокування й далі працювали", + settingsCompanionSkipped: "{{keys}} залишено вимкненими - потрібні їм облікові дані не можна відновити з резервної копії, і в цьому екземплярі вони не збережені, тож увімкнення залишило б інтеграцію без автентифікації", + settingsMqttRelayFailed: "Налаштування MQTT відновлено, але реле не вдалося перепідключити - перезапустіть Bambuddy", + kprofilesAlwaysOverwrite: "K-профілі завжди перезаписують відповідний слот на принтері", + kprofilesAckUnreliable: "Підтвердження принтера ненадійне - перевірте профілі на принтері", + kprofilesPrinterMissing: "Немає принтера із серійним номером {{serial}} - пропущено", + kprofilesPrinterOffline: "{{printer}} ({{serial}}) не підключено - пропущено", + kprofilesUnknownNozzle: "Неочікуваний діаметр сопла {{nozzle}} для {{serial}} - надіслано як є", + kprofilesUnmatched: "Профілів для {{nozzle}} без відповідника на {{printer}}: {{count}} - додано як нові профілі", + kprofilesSendFailed: "Не вдалося надіслати профілі {{nozzle}} на {{printer}} ({{serial}})", + }, }, // History diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 222806356..289a76fd4 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4883,6 +4883,38 @@ export default { reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。', failed: '恢复失败。', loadFailed: '无法读取备份仓库。', + details: { + notPresent: '此备份提交中不存在', + unreadableJson: '无法解析的 JSON:{{paths}}', + settingsNoPayload: '数据中没有设置', + settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键', + settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭', + spoolsUsageCount: '另有 {{count}} 条使用记录', + archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中', + kprofilesPrinterCount: '涉及 {{count}} 台打印机', + }, + notes: { + noData: '此备份中没有这类数据', + archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联', + archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联', + archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们', + archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖', + archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中', + spoolUsageUnresolved: '已跳过 {{count}} 条使用记录 - 其耗材卷不在此备份的耗材列表中,没有可挂接的对象。', + spoolUsageUnlinked: '已恢复 {{count}} 条使用记录,但缺少打印历史关联 - 请同时选择“打印归档”和“耗材库存”以保留该关联。', + spoolTagKept: '{{count}} 个耗材标签保持原样 - 按备份内容会清除此后扫描过的标签,或把它挪到另一卷耗材上。', + settingsCredentialsSkipped: '已跳过 {{count}} 个疑似凭据的键 - 请手动重新输入密钥', + settingsAuthSkipped: '已跳过 {{count}} 项认证设置 - 请在“设置 > 认证”中修改,以便锁定检查继续生效', + settingsCompanionSkipped: '{{keys}} 保持关闭 - 它们各自所需的凭据无法从备份恢复,本实例也没有存储,开启会让集成处于未认证状态', + settingsMqttRelayFailed: 'MQTT 设置已恢复,但中继无法重新连接 - 请重启 Bambuddy', + kprofilesAlwaysOverwrite: 'K 值配置总是覆盖打印机上对应的槽位', + kprofilesAckUnreliable: '打印机的确认信息不可靠 - 请在打印机上核对配置', + kprofilesPrinterMissing: '没有序列号为 {{serial}} 的打印机 - 已跳过', + kprofilesPrinterOffline: '{{printer}}({{serial}})未连接 - 已跳过', + kprofilesUnknownNozzle: '{{serial}} 的喷嘴直径 {{nozzle}} 不在预期范围内 - 已原样发送', + kprofilesUnmatched: '{{nozzle}} 的 {{count}} 个配置在 {{printer}} 上没有对应项 - 已作为新配置添加', + kprofilesSendFailed: '无法将 {{nozzle}} 的配置发送到 {{printer}}({{serial}})', + }, }, // History diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 9c95133b2..f70a5400d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -4883,6 +4883,38 @@ export default { reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。', failed: '還原失敗。', loadFailed: '無法讀取備份儲存庫。', + details: { + notPresent: '此備份提交中不存在', + unreadableJson: '無法解析的 JSON:{{paths}}', + settingsNoPayload: '資料中沒有設定', + settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵', + settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉', + spoolsUsageCount: '另有 {{count}} 筆使用紀錄', + archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中', + kprofilesPrinterCount: '涵蓋 {{count}} 台印表機', + }, + notes: { + noData: '此備份中沒有這類資料', + archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結', + archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結', + archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到', + archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫', + archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中', + spoolUsageUnresolved: '已略過 {{count}} 筆使用紀錄 - 其耗材捲不在此備份的耗材清單中,沒有可掛接的對象。', + spoolUsageUnlinked: '已還原 {{count}} 筆使用紀錄,但缺少列印歷史連結 - 請同時選擇「列印封存」與「耗材庫存」以保留該連結。', + spoolTagKept: '{{count}} 個耗材標籤維持原樣 - 依備份內容會清除此後掃描過的標籤,或把它移到另一捲耗材上。', + settingsCredentialsSkipped: '已略過 {{count}} 個疑似憑證的鍵 - 請手動重新輸入密鑰', + settingsAuthSkipped: '已略過 {{count}} 項驗證設定 - 請在「設定 > 驗證」中修改,讓鎖定檢查繼續生效', + settingsCompanionSkipped: '{{keys}} 維持關閉 - 它們各自所需的憑證無法從備份還原,本執行個體也沒有儲存,開啟會讓整合處於未驗證狀態', + settingsMqttRelayFailed: 'MQTT 設定已還原,但中繼無法重新連線 - 請重新啟動 Bambuddy', + kprofilesAlwaysOverwrite: 'K 值設定檔一律覆寫印表機上對應的插槽', + kprofilesAckUnreliable: '印表機的確認訊息不可靠 - 請在印表機上核對設定檔', + kprofilesPrinterMissing: '沒有序號為 {{serial}} 的印表機 - 已略過', + kprofilesPrinterOffline: '{{printer}}({{serial}})未連線 - 已略過', + kprofilesUnknownNozzle: '{{serial}} 的噴嘴直徑 {{nozzle}} 不在預期範圍內 - 已原樣傳送', + kprofilesUnmatched: '{{nozzle}} 的 {{count}} 個設定檔在 {{printer}} 上沒有對應項 - 已新增為新設定檔', + kprofilesSendFailed: '無法將 {{nozzle}} 的設定檔傳送到 {{printer}}({{serial}})', + }, }, // History