ソースを参照

fix(backup): report the categories a failed restore already committed (#2656)

The service reports what landed on a part-way failure -- categories commit as
they finish, so results names the ones on disk -- and the modal gated the
whole result panel on success, so it showed the failure message and dropped
them.

The cache invalidation was inside that same branch, which is the half that
mattered: a run that committed the settings category and then failed left the
app rendering pre-restore settings, with no reload and no re-read, which is
the failure the modal's own reload-on-close exists to prevent.

Gate on what was written instead. A refusal that never reached a category
still carries an empty results and still keeps the form, so the mutex and
backup-in-flight cases are unchanged. A partial does not read as a success:
the tick becomes a warning and a line says the listed categories are the ones
on disk.

---

fix(backup): keep the local owner when the backup names one we cannot resolve (#2656)

An owner the backup names but this instance has no user for was written as
NULL, and overwrite is a blanket setattr -- so restoring over a local archive
that had a perfectly good owner took it away, which is the 404-for-its-own-
owner failure this column is carried across to fix. Resolving by username
widened the trigger from a stale id to any user renamed since the backup.

It is the same state as an absent key: the backup has not told us who owns
this. So it takes the same action -- the column is not written at all.
Overwrite keeps the local owner, insert lands ownerless with the note, and an
explicit null still writes, so overwrite still means "match the backup".

The notes move to the insert path with it. On overwrite nothing was taken
away, so there is nothing to warn about, which is the rule the absent-key
case already follows.
maziggy 1 ヶ月 前
コミット
b5163b94f8

ファイルの差分が大きいため隠しています
+ 0 - 0
CHANGELOG.md


+ 58 - 32
backend/app/services/github_restore.py

@@ -1105,6 +1105,16 @@ class GitHubRestoreService:
             # column was added to fix) and silently un-delete a row the user
             # deleted. So only carry a column the backup actually knows about;
             # on insert, an absent key just takes the model default.
+            # An owner the backup names but this instance cannot resolve is the
+            # same epistemic state as an absent key — we do not know who owns
+            # this archive — so it takes the same action: the column is left out
+            # of ``fields`` entirely rather than set to None. Writing NULL there
+            # would take the owner away from a local archive that has a perfectly
+            # good one, which is the 404-for-its-own-owner failure this column is
+            # carried across to fix, and it would do it on the overwrite path
+            # where there is a local answer to keep. On insert there is nothing
+            # to keep, so the row takes the model default and lands ownerless,
+            # which is what the note says.
             owner_cleared = False
             backup_username = entry.get("created_by_username")
             if isinstance(backup_username, str) and backup_username:
@@ -1112,35 +1122,42 @@ class GitHubRestoreService:
                 # since the backup, and there is nothing else to resolve on: the
                 # id alongside it is from the source instance's numbering, so
                 # trusting it is exactly the misattribution the name is here to
-                # prevent. Cleared rather than failing the row — the archive is
-                # still worth having, and an admin can reassign it — but said out
-                # loud, because a cleared owner is not silent-safe.
+                # prevent. Not a reason to fail the row — the archive is still
+                # worth having, and an admin can reassign it — but said out loud
+                # on insert, because an ownerless archive is not silent-safe.
                 created_by_id = users_by_name.get(backup_username)
                 if created_by_id is None:
-                    tally.note(
-                        "archivesOwnerUnmatched",
-                        "Some archives name an owner this instance does not have — owner cleared rather than "
-                        "guessed from the backup's user id, so they are visible only to users with the "
-                        "archives:read_all permission until an admin reassigns them",
-                    )
-                    owner_cleared = True
-                fields["created_by_id"] = created_by_id
+                    if existing is None:
+                        tally.note(
+                            "archivesOwnerUnmatched",
+                            "Some archives name an owner this instance does not have — owner cleared rather than "
+                            "guessed from the backup's user id, so they are visible only to users with the "
+                            "archives:read_all permission until an admin reassigns them",
+                        )
+                        owner_cleared = True
+                else:
+                    fields["created_by_id"] = created_by_id
             elif "created_by_id" in entry:
                 # Fallback for a commit taken before the collector recorded the
-                # username. Validated rather than trusted, so a *stale* id clears
-                # instead of pointing somewhere wrong; a live id belonging to a
-                # different person on a rebuilt instance is the case this path
-                # cannot see, and is why the branch above exists.
+                # username. Validated rather than trusted, so a *stale* id is
+                # dropped instead of pointing somewhere wrong; a live id
+                # belonging to a different person on a rebuilt instance is the
+                # case this path cannot see, and is why the branch above exists.
+                # An explicit null is not a miss — the backup is saying the
+                # archive had no owner — so it is written, and overwrite keeps
+                # meaning "make the local row match the backup".
                 created_by_id = entry.get("created_by_id")
                 if created_by_id is not None and created_by_id not in valid_users:
-                    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",
-                    )
-                    created_by_id = None
-                    owner_cleared = True
-                fields["created_by_id"] = created_by_id
+                    if existing is None:
+                        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",
+                        )
+                        owner_cleared = True
+                else:
+                    fields["created_by_id"] = created_by_id
             if "deleted_at" in entry:
                 # A soft-deleted archive is still in the backup (its row is kept
                 # so stats keep counting it), so carry the flag across or the
@@ -1175,16 +1192,17 @@ class GitHubRestoreService:
                 )
                 warned_files = True
 
-            # Insert-only, and the mirror of the "absent is not null" rule above:
-            # on overwrite an unknown owner correctly leaves the local one alone,
-            # but there is no local row here to fall back on, so the archive
-            # lands ownerless — a 404 for everyone without archives:read_all.
-            # Two ways to get here: a commit taken before the collector recorded
-            # the column (every pre-#2656 backup), or an archive that genuinely
-            # had no owner on the source instance. Both restore fine and both
+            # Insert-only, and the mirror of the rule above: an owner the backup
+            # cannot tell us is never written, so on overwrite the local one
+            # survives — but there is no local row here to fall back on, so the
+            # archive lands ownerless, a 404 for everyone without
+            # archives:read_all. Three ways to get here: a commit taken before
+            # the collector recorded the column (every pre-#2656 backup), an
+            # archive that genuinely had no owner on the source instance, or one
+            # whose owner this instance cannot resolve. All restore fine and all
             # were silent, so the tally said "N archives restored" while the user
-            # who asked for them saw none. The stale-id case above already said
-            # its piece; don't say it twice for the same row.
+            # who asked for them saw none. The unresolved cases above already
+            # said their piece; don't say it twice for the same row.
             if fields.get("created_by_id") is None and not owner_cleared:
                 tally.note(
                     "archivesOwnerUnknown",
@@ -1729,6 +1747,14 @@ class GitHubRestoreService:
                         # counts it outstanding — leaving the tally here was the
                         # one place a profile could vanish from
                         # restored + skipped + failed entirely.
+                        #
+                        # failed here against skipped there is not a
+                        # disagreement about the entry. The three counters say
+                        # what happened to an item on this run, not whether it
+                        # was ever usable: an offline printer skips everything it
+                        # holds, well-formed or not, because nothing was
+                        # attempted, while here the entry was reached and could
+                        # not be used.
                         tally.failed += 1
                         continue
                     match = self._match_kprofile(p, current, claimed)

+ 90 - 0
backend/tests/unit/test_github_restore.py

@@ -2407,6 +2407,96 @@ class TestRestoredArchiveOwnership:
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.created_by_id == alice.id
 
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_the_backup_names_someone_unknown(self, db_session):
+        """A name this instance cannot resolve is not an instruction to clear.
+
+        Same epistemic state as the absent key below -- the backup has not told
+        us who owns this archive -- so it takes the same action. Writing NULL
+        instead inflicted the 404-for-its-own-owner failure on a local row that
+        was fine, and on a rebuilt instance every user renamed since the backup
+        took a whole archive history with them.
+        """
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=4242, created_by_username="carol")]},
+            True,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id, "an owner we cannot resolve must not displace one we can"
+        assert tally.restored == 1
+        # Nothing was taken away, so there is nothing to warn about -- the same
+        # rule the absent-key case follows.
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_overwrite_leaves_the_owner_alone_when_the_backups_id_is_stale(self, db_session):
+        """The pre-username fallback takes the rule too."""
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": [self._entry(created_by_id=4242)]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id
+        assert not any("owner cleared" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_unresolvable_name_still_lands_ownerless_on_insert(self, db_session):
+        """Control for the two above: with no local row there is nothing to keep.
+
+        The archive is still restored -- it is worth having -- but it is
+        invisible to everyone without archives:read_all, so it is said out loud.
+        """
+        await self._user(db_session, "alice")
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session,
+            {"archives": [self._entry(created_by_id=4242, created_by_username="carol")]},
+            False,
+            tally,
+            {},
+        )
+        await db_session.commit()
+
+        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("does not have" in note and "archives:read_all" in note for note in _messages(tally))
+        # One cause, one note -- the ownerless-insert note must not pile on.
+        assert not any("does not record one" in note for note in _messages(tally))
+
     @pytest.mark.asyncio
     async def test_overwrite_leaves_the_owner_alone_when_the_backup_predates_the_key(self, db_session):
         """A pre-#2656 commit must not blank the owner of a row that was fine.

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

@@ -432,11 +432,14 @@ describe('GitHubRestoreModal', () => {
     });
   });
 
-  // A refused restore answers 200 with `success: false`, and two of the five
-  // refusals are ordinary conditions rather than errors — a restore already
-  // running, and a backup mid-flight. Rendering the result panel for those put a
-  // green tick and "reload so the restored data appears" above a message saying
-  // nothing had been restored, i.e. a failure that read as a success.
+  // A refused restore answers 200 with `success: false` and an empty `results`,
+  // and two of the five refusals are ordinary conditions rather than errors — a
+  // restore already running, and a backup mid-flight. Rendering the result panel
+  // for those put a green tick and "reload so the restored data appears" above a
+  // message saying nothing had been restored, i.e. a failure that read as a
+  // success. Empty `results` is the load-bearing half: a failure that did write
+  // carries its committed categories and does get the panel — see the partial
+  // test below.
   it('reports a backend refusal such as the backup/restore mutex', async () => {
     server.use(
       http.post('/api/v1/github-backup/restore', () =>
@@ -488,7 +491,9 @@ describe('GitHubRestoreModal', () => {
     await waitFor(() => screen.getByText('A restore is already running'));
 
     const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
-    // Nothing was written, so nothing to re-read...
+    // This refusal never reached a category, so `results` is empty and there is
+    // nothing to re-read. A failure that committed one does invalidate — the
+    // partial test below covers that side...
     expect(keys).not.toContain(JSON.stringify(['spools']));
     expect(keys).not.toContain(JSON.stringify(['archives']));
     // ...but a failure past the commit resolve writes a "failed" log row, so the
@@ -497,6 +502,62 @@ describe('GitHubRestoreModal', () => {
     invalidate.mockRestore();
   });
 
+  // Categories commit as each one finishes, so a run that fails part-way leaves
+  // the earlier ones on disk and reports them. The modal used to gate the whole
+  // result panel — and the cache invalidation with it — on `success`, so those
+  // rows were written, never shown, and never re-read: the app carried on
+  // displaying pre-restore settings while the database held the restored ones.
+  const partialRestore = {
+    success: false,
+    message: 'database is locked',
+    log_id: 7,
+    ref: 'a'.repeat(40),
+    results: {
+      archives: { restored: 12, skipped: 0, failed: 0, notes: [] },
+      settings: { restored: 4, skipped: 1, failed: 0, notes: [] },
+    },
+  };
+
+  const runRestore = async () => {
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+  };
+
+  it('reports the categories a part-way failure already committed', async () => {
+    server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await runRestore();
+
+    // The tallies are the point: they name what is on disk.
+    await waitFor(() => expect(screen.getByText('database is locked')).toBeInTheDocument());
+    expect(screen.getByText(/12 restored/)).toBeInTheDocument();
+    expect(screen.getByText(/4 restored/)).toBeInTheDocument();
+    expect(screen.getByText(/The categories listed above finished and are on disk/)).toBeInTheDocument();
+    // And it must not read as a success — the run did not finish, so the
+    // warning icon stands in for the green tick.
+    expect(document.querySelector('svg.text-yellow-500')).toBeInTheDocument();
+    expect(document.querySelector('svg.text-bambu-green')).not.toBeInTheDocument();
+  });
+
+  it('refreshes the data caches for a part-way failure, because rows landed', async () => {
+    server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
+    const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await runRestore();
+    await waitFor(() => screen.getByText('database is locked'));
+
+    const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
+    expect(keys).toContain(JSON.stringify(['archives']));
+    expect(keys).toContain(JSON.stringify(['settings']));
+    invalidate.mockRestore();
+  });
+
   // A provider-side failure answers 200 with `success: false`; a rejected
   // *request* throws in `request()`, leaving `data` undefined. Reading the
   // message off `data` alone meant the second kind rendered an empty modal —

+ 28 - 8
frontend/src/components/GitHubRestoreModal.tsx

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
+  AlertTriangle,
   Archive,
   CheckCircle2,
   Info,
@@ -147,13 +148,20 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
     onSuccess: (data) => {
       setShowConfirm(false);
       // The endpoint answers 200 for a refused or failed restore too, with
-      // `success: false` and an empty `results` — and two of those are ordinary
-      // conditions, not errors: another restore already running, and a backup
-      // being mid-flight. Rendering the result panel for them showed a green
-      // tick, no tally at all and a "reload so the restored data appears" hint
-      // above a message saying nothing had been restored. Only a real success
-      // gets the panel; a failure keeps the form and shows the red block below.
-      if (data.success) {
+      // `success: false` — and two of those are ordinary conditions, not
+      // errors: another restore already running, and a backup being mid-flight.
+      // Nothing was written for either, so they keep the form and show the red
+      // block below; rendering the result panel for them put a green tick, no
+      // tally at all and a "reload so the restored data appears" hint above a
+      // message saying nothing had been restored.
+      //
+      // A failure that got as far as writing is the opposite case. Categories
+      // commit as each one finishes, so a non-empty `results` names the ones
+      // that are on disk — and the form over the top of them would be the same
+      // "nothing was restored" misreading, this time with the data actually in.
+      // So the panel is what wrote, not what succeeded.
+      const wroteSomething = Object.keys(data.results ?? {}).length > 0;
+      if (data.success || wroteSomething) {
         setResult(data);
         // A restore rewrites rows these caches hold. ['settings'] is one of
         // them: until #2716 was fixed on dev, invalidating it made
@@ -280,10 +288,22 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
             {result ? (
               /* Result summary */
               <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
+                {/* A partial restore reaches this panel too — categories commit
+                    as they finish, so the tallies below are on disk even though
+                    the run did not get through them all. It must not read as a
+                    success: the message is the failure, and what follows is what
+                    survived it rather than what was asked for. */}
                 <div className="flex items-start gap-2 text-sm">
-                  <CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                  {result.success ? (
+                    <CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                  ) : (
+                    <AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
+                  )}
                   <span className="text-white">{result.message}</span>
                 </div>
+                {!result.success && (
+                  <p className="text-xs text-bambu-gray">{t('backup.restoreFromGit.partialHint')}</p>
+                )}
                 {Object.entries(result.results).map(([name, tally]) => (
                   <div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
                     <div className="flex items-center justify-between">

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

@@ -4895,6 +4895,7 @@ export default {
       kprofilesOverwriteCaveat: 'K-Profile sind die Ausnahme: Das Schreiben eines Slots ersetzt immer die Kalibrierung auf dem Drucker.',
       tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
       reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
+      partialHint: 'Die oben aufgeführten Kategorien wurden abgeschlossen und sind gespeichert. Fehlende Kategorien wurden nicht ausgeführt.',
       failed: 'Wiederherstellung fehlgeschlagen.',
       loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
       details: {

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

@@ -4938,6 +4938,7 @@ export default {
       kprofilesOverwriteCaveat: 'K-profiles are the exception: writing a slot always replaces the calibration on the printer.',
       tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
       reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
+      partialHint: 'The categories listed above finished and are on disk. Any that are missing did not run.',
       failed: 'Restore failed.',
       loadFailed: 'Could not read the backup repository.',
       // Preview caveats. The server sends detail_code + detail_params and the

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

@@ -4903,6 +4903,7 @@ export default {
       kprofilesOverwriteCaveat: 'Los perfiles K son la excepción: escribir una ranura siempre reemplaza la calibración en la impresora.',
       tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
       reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
+      partialHint: 'Las categorías indicadas arriba se completaron y están guardadas. Las que faltan no llegaron a ejecutarse.',
       failed: 'La restauración ha fallado.',
       loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
       details: {

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

@@ -4884,6 +4884,7 @@ export default {
       kprofilesOverwriteCaveat: "Les profils K sont l'exception : écrire un emplacement remplace toujours la calibration sur l'imprimante.",
       tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
       reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
+      partialHint: "Les catégories listées ci-dessus sont terminées et enregistrées. Celles qui manquent n'ont pas été exécutées.",
       failed: 'Échec de la restauration.',
       loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
       details: {

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

@@ -4883,6 +4883,7 @@ export default {
       kprofilesOverwriteCaveat: "I profili K sono l'eccezione: scrivere uno slot sostituisce sempre la calibrazione sulla stampante.",
       tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
       reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
+      partialHint: 'Le categorie elencate sopra sono state completate e salvate. Quelle mancanti non sono state eseguite.',
       failed: 'Ripristino non riuscito.',
       loadFailed: 'Impossibile leggere il repository di backup.',
       details: {

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

@@ -4895,6 +4895,7 @@ export default {
       kprofilesOverwriteCaveat: 'Kプロファイルは例外です。スロットへの書き込みは、プリンター上のキャリブレーションを常に置き換えます。',
       tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
       reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
+      partialHint: '上に表示されたカテゴリーは完了し、保存されています。表示されていないカテゴリーは実行されていません。',
       failed: '復元に失敗しました。',
       loadFailed: 'バックアップリポジトリを読み取れませんでした。',
       details: {

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

@@ -4660,6 +4660,7 @@ export default {
       kprofilesOverwriteCaveat: 'K 프로파일은 예외입니다. 슬롯에 쓰면 프린터의 캘리브레이션이 항상 교체됩니다.',
       tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
       reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
+      partialHint: '위에 표시된 카테고리는 완료되어 저장되었습니다. 표시되지 않은 카테고리는 실행되지 않았습니다.',
       failed: '복원에 실패했습니다.',
       loadFailed: '백업 저장소를 읽을 수 없습니다.',
       details: {

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4883,6 +4883,7 @@ export default {
       kprofilesOverwriteCaveat: 'Os perfis K são a exceção: gravar um slot sempre substitui a calibração na impressora.',
       tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
       reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
+      partialHint: 'As categorias listadas acima foram concluídas e estão salvas. As que faltam não chegaram a ser executadas.',
       failed: 'Falha na restauração.',
       loadFailed: 'Não foi possível ler o repositório de backup.',
       details: {

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

@@ -4652,6 +4652,7 @@ export default {
       kprofilesOverwriteCaveat: 'K-профили — исключение: запись в слот всегда заменяет калибровку на принтере.',
       tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
       reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
+      partialHint: 'Перечисленные выше категории завершены и сохранены. Отсутствующие категории не выполнялись.',
       failed: 'Не удалось выполнить восстановление.',
       loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
       details: {

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4873,6 +4873,7 @@ export default {
       kprofilesOverwriteCaveat: 'K profilleri istisnadır: bir yuvaya yazmak yazıcıdaki kalibrasyonu her zaman değiştirir.',
       tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
       reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
+      partialHint: 'Yukarıda listelenen kategoriler tamamlandı ve kaydedildi. Eksik olanlar hiç çalıştırılmadı.',
       failed: 'Geri yükleme başarısız oldu.',
       loadFailed: 'Yedek deposu okunamadı.',
       details: {

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

@@ -4938,6 +4938,7 @@ export default {
       kprofilesOverwriteCaveat: 'K-профілі — виняток: запис у слот завжди замінює калібрування на принтері.',
       tally: "відновлено: {{restored}}, пропущено: {{skipped}}, з помилкою: {{failed}}",
       reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
+      partialHint: 'Перелічені вище категорії завершено та збережено. Відсутні категорії не виконувалися.',
       failed: "Не вдалося виконати відновлення.",
       loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
       details: {

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

@@ -4883,6 +4883,7 @@ export default {
       kprofilesOverwriteCaveat: 'K 值配置是例外:写入插槽总会替换打印机上的校准数据。',
       tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
       reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
+      partialHint: '上面列出的类别已完成并已保存。未列出的类别没有执行。',
       failed: '恢复失败。',
       loadFailed: '无法读取备份仓库。',
       details: {

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

@@ -4883,6 +4883,7 @@ export default {
       kprofilesOverwriteCaveat: 'K 值設定檔是例外:寫入插槽一定會取代印表機上的校準資料。',
       tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
       reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
+      partialHint: '上方列出的類別已完成並已儲存。未列出的類別沒有執行。',
       failed: '還原失敗。',
       loadFailed: '無法讀取備份儲存庫。',
       details: {

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-BPSw6nnF.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-joRUZURS.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-joRUZURS.js"></script>
+    <script type="module" crossorigin src="/assets/index-BPSw6nnF.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
   </head>
   <body>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません