Просмотр исходного кода

Archives: offer to merge duplicates after a restore (archive redesign P2)

- After a GitHub restore that brought back archives, the result says
  how many groups of duplicates there are and offers the merge
- The archives page offers it while duplicates exist, after an update
  or a local restore too; dismissed until there are more groups
- GET /archives/merge-count: the groups, cheaply, for the offer
- Nothing merges unless the user does it
maziggy 2 дней назад
Родитель
Сommit
a09d90a165
100 измененных файлов с 404 добавлено и 18 удалено
  1. 14 0
      backend/app/api/routes/archives.py
  2. 14 5
      backend/app/services/archive_merge.py
  3. 25 0
      backend/tests/integration/test_archive_merge.py
  4. 87 0
      frontend/src/__tests__/components/GitHubRestoreModal.test.tsx
  5. 2 0
      frontend/src/__tests__/pages/ArchivesHighlightParam.test.tsx
  6. 95 1
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  7. 2 0
      frontend/src/api/client.ts
  8. 41 3
      frontend/src/components/GitHubRestoreModal.tsx
  9. 1 1
      frontend/src/components/MergeDuplicatesModal.tsx
  10. 4 0
      frontend/src/i18n/locales/de.ts
  11. 4 0
      frontend/src/i18n/locales/en.ts
  12. 4 0
      frontend/src/i18n/locales/es.ts
  13. 4 0
      frontend/src/i18n/locales/fr.ts
  14. 4 0
      frontend/src/i18n/locales/it.ts
  15. 4 0
      frontend/src/i18n/locales/ja.ts
  16. 4 0
      frontend/src/i18n/locales/ko.ts
  17. 4 0
      frontend/src/i18n/locales/nl.ts
  18. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  19. 4 0
      frontend/src/i18n/locales/ru.ts
  20. 4 0
      frontend/src/i18n/locales/sv.ts
  21. 4 0
      frontend/src/i18n/locales/tr.ts
  22. 4 0
      frontend/src/i18n/locales/uk.ts
  23. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  24. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  25. 55 0
      frontend/src/pages/ArchivesPage.tsx
  26. 0 0
      static/assets/AdditionalSection-D2zuRt4q.js
  27. 0 0
      static/assets/AmsUnitCard-BqY1UE0E.js
  28. 0 0
      static/assets/ArchivesPage-ChHPHRBy.js
  29. 0 0
      static/assets/ArchivesPage-DMDhrgSq.js
  30. 0 0
      static/assets/AreaChart-C5g103Jy.js
  31. 0 0
      static/assets/AssignToAmsModal-dmPqKY22.js
  32. 0 0
      static/assets/AuthContext-BlnL2gGQ.js
  33. 1 1
      static/assets/Button-DPTAdE-r.js
  34. 0 0
      static/assets/CamWallPage-CZA3lFGY.js
  35. 0 0
      static/assets/CameraDiagnoseModal-ltpwLHpN.js
  36. 0 0
      static/assets/CameraPage-D8ti0i0s.js
  37. 0 0
      static/assets/CameraWall-BzJbS8u_.js
  38. 1 1
      static/assets/Card-DSfTTQ2S.js
  39. 0 0
      static/assets/CartesianChart-CySNwb-8.js
  40. 0 0
      static/assets/ColumnConfigModal-CLgHP2AU.js
  41. 0 0
      static/assets/ConfigureAmsSlotModal-C8sLW7UM.js
  42. 0 0
      static/assets/ConfirmModal-BZ6PdVis.js
  43. 0 0
      static/assets/ConfirmOutcomeDialog-aLxq9HvU.js
  44. 0 0
      static/assets/ConnectAuthorizePage-D8681GnQ.js
  45. 0 0
      static/assets/ConnectionDiagnostic-Bf84BtBl.js
  46. 0 0
      static/assets/ContextMenu-BI8kiND5.js
  47. 0 0
      static/assets/CopyButton-DCJgXGkS.js
  48. 0 0
      static/assets/EmbeddedCameraViewer-C4YKaz1m.js
  49. 0 0
      static/assets/ErrorBarContext-Csenv7Eu.js
  50. 0 0
      static/assets/ExternalLinkPage-BXhJTdsc.js
  51. 1 1
      static/assets/FilamentSwatch-Dz5HrDb8.js
  52. 0 1
      static/assets/FileManagerPage-TOl55G1u.js
  53. 1 0
      static/assets/FileManagerPage-moXFsMm1.js
  54. 0 0
      static/assets/FileUploadModal-as3zaDnV.js
  55. 0 0
      static/assets/FinancePage-BEiOAIm9.js
  56. 0 0
      static/assets/GCodeViewerPage-C0jz3Lsh.js
  57. 0 0
      static/assets/GcodeToolpathViewer-D6U0qj5b.js
  58. 0 0
      static/assets/GroupEditPage-BycJVdWV.js
  59. 0 0
      static/assets/IconPicker-BimhUo0f.js
  60. 0 0
      static/assets/ImagePreviewModal-Ch4Dt0Df.js
  61. 0 0
      static/assets/InventoryPage-D7vdaHWJ.js
  62. 0 0
      static/assets/Layout-ruwL5Qle.js
  63. 0 0
      static/assets/LibraryTrashPage-CwHirNI9.js
  64. 0 0
      static/assets/LoginPage-DbKZyYlZ.js
  65. 0 0
      static/assets/MaintenancePage-CazkonbW.js
  66. 0 0
      static/assets/MergeDuplicatesModal-Cx2alSPc.js
  67. 0 0
      static/assets/ModelSourcesPage-DL_xEZF4.js
  68. 0 0
      static/assets/NotificationsPage-BJyNTiF-.js
  69. 1 1
      static/assets/NumberInput-DmWSadae.js
  70. 1 1
      static/assets/OverlayFrame-BURM4Vsq.js
  71. 0 0
      static/assets/PdfPreviewModal-DRt2cAGe.js
  72. 0 0
      static/assets/PhotoGalleryModal-9zaPoTRT.js
  73. 0 0
      static/assets/PhotoGalleryModal-sLE4KKad.js
  74. 0 0
      static/assets/PrintModal-DgAKJq3q.js
  75. 0 0
      static/assets/PrinterLocationsPage-Jdh5nP9S.js
  76. 0 0
      static/assets/PrintersPage-A7rvUJCH.js
  77. 0 0
      static/assets/ProfilesPage-DldeW-O3.js
  78. 0 0
      static/assets/ProjectDetailPage-TpYGQy9E.js
  79. 0 0
      static/assets/ProjectsPage-tC9IhYtI.js
  80. 0 0
      static/assets/QueryClientProvider-q3IMwOW_.js
  81. 0 0
      static/assets/QueuePage-DKq6AP14.js
  82. 0 0
      static/assets/ReferenceLine-CmaP3ZnZ.js
  83. 0 0
      static/assets/RichTextEditor-BnSjGMVp.js
  84. 0 0
      static/assets/SettingsPage-1p3Qk4WD.js
  85. 0 0
      static/assets/SettingsPage-BJ7Ybvr4.js
  86. 0 0
      static/assets/SetupPage-DwxALWbn.js
  87. 0 0
      static/assets/SkipObjectsModal-DFEHNDAQ.js
  88. 0 0
      static/assets/SliceModal-BMyHp8bG.js
  89. 0 0
      static/assets/SpoolBuddyAmsPage-Cqe3-5CL.js
  90. 0 0
      static/assets/SpoolBuddyDashboard-CFDp0yue.js
  91. 0 0
      static/assets/SpoolBuddyInventoryPage-CYTdes0j.js
  92. 1 1
      static/assets/SpoolBuddySettingsPage-BYexExQD.js
  93. 0 0
      static/assets/SpoolBuddyWriteTagPage-D5b_6s8x.js
  94. 1 1
      static/assets/SpoolIcon-DT-vc2hk.js
  95. 0 0
      static/assets/SpreadsheetPreviewModal-DOudyikg.js
  96. 0 0
      static/assets/StatsPage-B4XND1LE.js
  97. 0 0
      static/assets/StreamOverlayPage-CzeaKGa2.js
  98. 0 0
      static/assets/SystemHealthPanel-B_XI5cp1.js
  99. 0 0
      static/assets/SystemInfoPage-Bwp1tobE.js
  100. 0 0
      static/assets/ThemeContext-B-OKeSi9.js

+ 14 - 0
backend/app/api/routes/archives.py

@@ -703,6 +703,20 @@ async def list_archives(
     return result
     return result
 
 
 
 
+@router.get("/merge-count")
+async def count_archive_merge_groups(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL, Permission.ARCHIVES_DELETE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """How many groups of duplicate archives there are (archive redesign P2):
+    what the archives page and a restore offer to merge. Cheap: it reads no
+    files and does not check which group can merge now (``/merge-preview``)."""
+    from backend.app.services.archive_merge import count_groups, merge_scope_clauses
+
+    return {"groups": await count_groups(db, merge_scope_clauses(printer_scope))}
+
+
 @router.get("/merge-preview")
 @router.get("/merge-preview")
 async def preview_archive_merge(
 async def preview_archive_merge(
     db: AsyncSession = Depends(get_db, scope="function"),
     db: AsyncSession = Depends(get_db, scope="function"),

+ 14 - 5
backend/app/services/archive_merge.py

@@ -79,16 +79,25 @@ def _key(archive: PrintArchive) -> tuple:
     return (archive.content_hash, archive.created_by_id, archive.project_id)
     return (archive.content_hash, archive.created_by_id, archive.project_id)
 
 
 
 
-async def find_groups(db: AsyncSession, visible: list) -> list[MergeGroup]:
-    """The duplicate groups among the archives ``visible`` (WHERE clauses)
-    admits, oldest first, each with why it is left out (``skipped``)."""
-    keys = (
+def _group_keys(visible: list):
+    return (
         select(PrintArchive.content_hash, PrintArchive.created_by_id, PrintArchive.project_id)
         select(PrintArchive.content_hash, PrintArchive.created_by_id, PrintArchive.project_id)
         .where(PrintArchive.content_hash.is_not(None), PrintArchive.deleted_at.is_(None), *visible)
         .where(PrintArchive.content_hash.is_not(None), PrintArchive.deleted_at.is_(None), *visible)
         .group_by(PrintArchive.content_hash, PrintArchive.created_by_id, PrintArchive.project_id)
         .group_by(PrintArchive.content_hash, PrintArchive.created_by_id, PrintArchive.project_id)
         .having(func.count(PrintArchive.id) > 1)
         .having(func.count(PrintArchive.id) > 1)
     )
     )
-    hashes = {row.content_hash for row in (await db.execute(keys)).all()}
+
+
+async def count_groups(db: AsyncSession, visible: list) -> int:
+    """How many duplicate groups there are, whether or not one can merge now:
+    cheap, for offering the merge (no files read, no work checked)."""
+    return await db.scalar(select(func.count()).select_from(_group_keys(visible).subquery())) or 0
+
+
+async def find_groups(db: AsyncSession, visible: list) -> list[MergeGroup]:
+    """The duplicate groups among the archives ``visible`` (WHERE clauses)
+    admits, oldest first, each with why it is left out (``skipped``)."""
+    hashes = {row.content_hash for row in (await db.execute(_group_keys(visible))).all()}
     if not hashes:
     if not hashes:
         return []
         return []
     archives = (
     archives = (

+ 25 - 0
backend/tests/integration/test_archive_merge.py

@@ -602,3 +602,28 @@ async def test_an_avi_conversion_holds_its_archive_from_the_spawn(db_session, fi
     assert archive_work.is_held(dupes.x)
     assert archive_work.is_held(dupes.x)
     for coro in spawned:
     for coro in spawned:
         coro.close()
         coro.close()
+
+
+async def test_the_count_offers_every_group(async_client, db_session, dupes):
+    """Also one at work: the count only offers the merge."""
+    db_session.add(PrintLogEntry(archive_id=dupes.y, printer_id=dupes.printer, status=RUN_OPEN, started_at=T1))
+    other = [
+        PrintArchive(filename="o.3mf", file_path=f"archive/1/o{i}/f.3mf", file_size=1, content_hash="c" * 64)
+        for i in range(2)
+    ]
+    db_session.add_all(other)
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/archives/merge-count")
+
+    assert response.status_code == 200, response.text
+    assert response.json() == {"groups": 2}
+
+
+async def test_the_count_needs_delete_all(async_client, dupes):
+    from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
+
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(async_client, admin, "counter", ["archives:read_all", "archives:update_all"])
+
+    assert (await async_client.get("/api/v1/archives/merge-count", headers=_auth(jwt))).status_code == 403

+ 87 - 0
frontend/src/__tests__/components/GitHubRestoreModal.test.tsx

@@ -279,6 +279,93 @@ describe('GitHubRestoreModal', () => {
     expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument();
     expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument();
   });
   });
 
 
+  describe('after restoring archives (archive redesign P2)', () => {
+    const restoreArchives = async (groups: number, category = 'archives') => {
+      server.use(
+        http.post('/api/v1/github-backup/restore', () =>
+          HttpResponse.json({
+            success: true,
+            message: 'Restored 30 item(s) from aaa1111',
+            log_id: 3,
+            ref: mockPreview.ref,
+            results: { [category]: { restored: 30, skipped: 0, failed: 0, notes: [] } },
+          })
+        ),
+        http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups })),
+        http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups: [] })),
+      );
+      render(<GitHubRestoreModal onClose={vi.fn()} />);
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[0]);
+      await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+      await waitFor(() => screen.getByText('Restore from backup?'));
+      const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+      await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+      await screen.findByText('Restored 30 item(s) from aaa1111');
+    };
+
+    it('offers to merge the duplicate archives it brought back', async () => {
+      await restoreArchives(2);
+
+      const offer = await screen.findByTestId('restore-merge-offer');
+      expect(offer).toHaveTextContent('2 group(s) of duplicate archives were found.');
+      await userEvent.click(screen.getByRole('button', { name: 'Merge duplicates…' }));
+      expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
+    });
+
+    it('counts again: the duplicates are not what was cached before it', async () => {
+      const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
+      await restoreArchives(2);
+
+      expect(invalidate).toHaveBeenCalledWith({ queryKey: ['archive-merge-count'] });
+      invalidate.mockRestore();
+    });
+
+    it('leaves Escape in the merge dialog to that dialog', async () => {
+      const onClose = vi.fn();
+      server.use(
+        http.post('/api/v1/github-backup/restore', () =>
+          HttpResponse.json({
+            success: true,
+            message: 'Restored 30 item(s) from aaa1111',
+            log_id: 3,
+            ref: mockPreview.ref,
+            results: { archives: { restored: 30, skipped: 0, failed: 0, notes: [] } },
+          })
+        ),
+        http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 2 })),
+        http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups: [] })),
+      );
+      render(<GitHubRestoreModal onClose={onClose} />);
+      const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+      await userEvent.click(checkboxes[0]);
+      await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+      await waitFor(() => screen.getByText('Restore from backup?'));
+      const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+      await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+      await userEvent.click(await screen.findByRole('button', { name: 'Merge duplicates…' }));
+      await screen.findByText('No duplicates to merge.');
+
+      await userEvent.keyboard('{Escape}');
+
+      await waitFor(() => expect(screen.queryByText('No duplicates to merge.')).toBeNull());
+      expect(onClose).not.toHaveBeenCalled();
+      expect(screen.getByText('Restored 30 item(s) from aaa1111')).toBeInTheDocument();
+    });
+
+    it('offers nothing after a restore without archives', async () => {
+      await restoreArchives(2, 'spools');
+
+      await waitFor(() => expect(screen.queryByTestId('restore-merge-offer')).toBeNull());
+    });
+
+    it('offers nothing without duplicates', async () => {
+      await restoreArchives(0);
+
+      await waitFor(() => expect(screen.queryByTestId('restore-merge-offer')).toBeNull());
+    });
+  });
+
   it('drops the selection while a newly-picked commit is still being inspected', async () => {
   it('drops the selection while a newly-picked commit is still being inspected', async () => {
     // Switching commits keeps `selected` (it is only pruned once the new preview
     // Switching commits keeps `selected` (it is only pruned once the new preview
     // lands), so the footer must not keep counting it: the categories belong to
     // lands), so the footer must not keep counting it: the categories belong to

+ 2 - 0
frontend/src/__tests__/pages/ArchivesHighlightParam.test.tsx

@@ -99,6 +99,8 @@ describe('ArchivesPage ?highlight=', () => {
   });
   });
 
 
   it('warns when the linked archive is not in the list', async () => {
   it('warns when the linked archive is not in the list', async () => {
+    // Not merged into another either: its own lookup finds nothing.
+    server.use(http.get('/api/v1/archives/99', () => HttpResponse.json({ detail: 'Archive not found' }, { status: 404 })));
     window.history.pushState({}, '', '/archives?highlight=99');
     window.history.pushState({}, '', '/archives?highlight=99');
     render(<ArchivesPage />);
     render(<ArchivesPage />);
 
 

+ 95 - 1
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -2,13 +2,14 @@
  * Tests for the ArchivesPage component.
  * Tests for the ArchivesPage component.
  */
  */
 
 
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
 import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { render } from '../utils';
 import { render } from '../utils';
 import { ArchivesPage } from '../../pages/ArchivesPage';
 import { ArchivesPage } from '../../pages/ArchivesPage';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 import { setAuthToken } from '../../api/client';
 import { setAuthToken } from '../../api/client';
+import { useNavigate } from 'react-router-dom';
 
 
 const mockArchives = [
 const mockArchives = [
   {
   {
@@ -655,6 +656,16 @@ describe('ArchivesPage', () => {
   // Archive redesign P2: merging changes and removes whole archives, so the
   // Archive redesign P2: merging changes and removes whole archives, so the
   // button is there only for those the merge API lets through.
   // button is there only for those the merge API lets through.
   describe('merge duplicates button', () => {
   describe('merge duplicates button', () => {
+    // jsdom has no scrolling, and following an archive link scrolls to its
+    // card, later than the test that followed it may run.
+    const scrollIntoView = Element.prototype.scrollIntoView;
+    beforeAll(() => {
+      Element.prototype.scrollIntoView = vi.fn();
+    });
+    afterAll(() => {
+      Element.prototype.scrollIntoView = scrollIntoView;
+    });
+
     const signInWith = (permissions: string[]) => {
     const signInWith = (permissions: string[]) => {
       setAuthToken('merge-token', 'session');
       setAuthToken('merge-token', 'session');
       server.use(
       server.use(
@@ -680,6 +691,89 @@ describe('ArchivesPage', () => {
       expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
       expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
     });
     });
 
 
+    it('offers the merge while duplicates exist, until dismissed', async () => {
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 3 })));
+      render(<ArchivesPage />);
+
+      const banner = await screen.findByTestId('merge-banner');
+      expect(banner).toHaveTextContent('3 group(s) of archives hold the same file');
+      fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
+
+      await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
+      expect(window.localStorage.setItem).toHaveBeenCalledWith('archiveMergeBannerDismissed', '3');
+    });
+
+    it('offers it again once the count has changed since it was dismissed', async () => {
+      vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+        key === 'archiveMergeBannerDismissed' ? '3' : null,
+      );
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups: 4 })));
+      render(<ArchivesPage />);
+
+      expect(await screen.findByTestId('merge-banner')).toBeInTheDocument();
+      vi.mocked(window.localStorage.getItem).mockReset();
+    });
+
+    it('counts again when the archives change', async () => {
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      let groups = 1;
+      server.use(http.get('*/api/v1/archives/merge-count', () => HttpResponse.json({ groups })));
+      // Following an archive link reloads the list, as a delete or a new print does.
+      function GoTo() {
+        const navigate = useNavigate();
+        return <button onClick={() => navigate('/archives?highlight=1')}>go</button>;
+      }
+      render(<><ArchivesPage /><GoTo /></>);
+      await screen.findByTestId('merge-banner');
+
+      groups = 0;
+      fireEvent.click(screen.getByText('go'));
+
+      await waitFor(() => expect(screen.queryByTestId('merge-banner')).toBeNull());
+    });
+
+    it('stays dismissed when merging some left fewer', async () => {
+      vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+        key === 'archiveMergeBannerDismissed' ? '3' : null,
+      );
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      let counted = false;
+      server.use(
+        http.get('*/api/v1/archives/merge-count', () => {
+          counted = true;
+          return HttpResponse.json({ groups: 2 });
+        }),
+      );
+      render(<ArchivesPage />);
+
+      await waitFor(() => expect(counted).toBe(true));
+      await screen.findByRole('button', { name: 'Merge duplicates' });
+      expect(screen.queryByTestId('merge-banner')).toBeNull();
+      vi.mocked(window.localStorage.getItem).mockReset();
+    });
+
+    it('stays dismissed while the count is the same', async () => {
+      vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+        key === 'archiveMergeBannerDismissed' ? '3' : null,
+      );
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      let counted = false;
+      server.use(
+        http.get('*/api/v1/archives/merge-count', () => {
+          counted = true;
+          return HttpResponse.json({ groups: 3 });
+        }),
+      );
+      render(<ArchivesPage />);
+
+      await waitFor(() => expect(counted).toBe(true));
+      await screen.findByRole('button', { name: 'Merge duplicates' });
+      expect(screen.queryByTestId('merge-banner')).toBeNull();
+      vi.mocked(window.localStorage.getItem).mockReset();
+    });
+
     it('is offered in the Duplicates collection', async () => {
     it('is offered in the Duplicates collection', async () => {
       signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
       signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
       render(<ArchivesPage />);
       render(<ArchivesPage />);

+ 2 - 0
frontend/src/api/client.ts

@@ -5749,6 +5749,8 @@ export const api = {
     }),
     }),
   // Archives of one file merge into the oldest (archive redesign P2).
   // Archives of one file merge into the oldest (archive redesign P2).
   previewArchiveMerge: () => request<ArchiveMergePreview>('/archives/merge-preview'),
   previewArchiveMerge: () => request<ArchiveMergePreview>('/archives/merge-preview'),
+  /** How many duplicate groups there are; cheap, for offering the merge. */
+  getArchiveMergeCount: () => request<{ groups: number }>('/archives/merge-count'),
   mergeArchives: (survivorIds: number[]) =>
   mergeArchives: (survivorIds: number[]) =>
     request<ArchiveMergeResult>('/archives/merge', {
     request<ArchiveMergeResult>('/archives/merge', {
       method: 'POST',
       method: 'POST',

+ 41 - 3
frontend/src/components/GitHubRestoreModal.tsx

@@ -17,6 +17,8 @@ import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { MergeDuplicatesModal } from './MergeDuplicatesModal';
+import { useAuth } from '../contexts/AuthContext';
 import {
 import {
   api,
   api,
   type RestoreCategory,
   type RestoreCategory,
@@ -171,6 +173,7 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
         // field by field, so the restore no longer needs an exception.
         // field by field, so the restore no longer needs an exception.
         queryClient.invalidateQueries({ queryKey: ['spools'] });
         queryClient.invalidateQueries({ queryKey: ['spools'] });
         queryClient.invalidateQueries({ queryKey: ['archives'] });
         queryClient.invalidateQueries({ queryKey: ['archives'] });
+        queryClient.invalidateQueries({ queryKey: ['archive-merge-count'] });
         queryClient.invalidateQueries({ queryKey: ['settings'] });
         queryClient.invalidateQueries({ queryKey: ['settings'] });
       }
       }
       // A failure that got as far as resolving the commit still writes a log row
       // A failure that got as far as resolving the commit still writes a log row
@@ -183,6 +186,19 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
 
 
   const isRestoring = restoreMutation.isPending;
   const isRestoring = restoreMutation.isPending;
 
 
+  // An old backup brings back archives of one file that were merged since,
+  // or never were: offer the merge, never do it (archive redesign P2).
+  const { hasPermission } = useAuth();
+  const canMerge = hasPermission('archives:update_all') && hasPermission('archives:delete_all');
+  const archivesRestored = Boolean(result && 'archives' in result.results);
+  const mergeCountQuery = useQuery({
+    queryKey: ['archive-merge-count'],
+    queryFn: () => api.getArchiveMergeCount(),
+    enabled: archivesRestored && canMerge,
+  });
+  const duplicateGroups = archivesRestored && canMerge ? (mergeCountQuery.data?.groups ?? 0) : 0;
+  const [showMerge, setShowMerge] = useState(false);
+
   // A settings restore rewrites rows the whole app reads, and not all of them
   // A settings restore rewrites rows the whole app reads, and not all of them
   // through a query this modal can invalidate. The interface language is applied
   // through a query this modal can invalidate. The interface language is applied
   // by i18n.changeLanguage, called only from the SettingsPage dropdown and the
   // by i18n.changeLanguage, called only from the SettingsPage dropdown and the
@@ -198,14 +214,15 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
     onClose();
     onClose();
   }, [settingsRestored, onClose]);
   }, [settingsRestored, onClose]);
 
 
-  // Close on Escape, except while a restore is in flight.
+  // Close on Escape, except while a restore is in flight, or while the merge
+  // dialog is open on top: its Escape is its own.
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape' && !isRestoring && !showConfirm) closeModal();
+      if (e.key === 'Escape' && !isRestoring && !showConfirm && !showMerge) closeModal();
     };
     };
     window.addEventListener('keydown', handleKeyDown);
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [closeModal, isRestoring, showConfirm]);
+  }, [closeModal, isRestoring, showConfirm, showMerge]);
 
 
   // Interrupting a restore mid-flight can leave a partly-applied category.
   // Interrupting a restore mid-flight can leave a partly-applied category.
   useEffect(() => {
   useEffect(() => {
@@ -336,6 +353,19 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
                     )}
                     )}
                   </div>
                   </div>
                 ))}
                 ))}
+                {duplicateGroups > 0 && (
+                  <div
+                    className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary flex items-center gap-3"
+                    data-testid="restore-merge-offer"
+                  >
+                    <span className="flex-1 text-sm text-white">
+                      {t('archiveMerge.found', { count: duplicateGroups })}
+                    </span>
+                    <Button variant="secondary" size="sm" onClick={() => setShowMerge(true)}>
+                      {t('archiveMerge.openCta')}
+                    </Button>
+                  </div>
+                )}
                 <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
                 <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
                   <p className="text-xs text-yellow-700 dark:text-yellow-200">
                   <p className="text-xs text-yellow-700 dark:text-yellow-200">
                     {t('backup.restoreFromGit.reloadHint')}
                     {t('backup.restoreFromGit.reloadHint')}
@@ -537,6 +567,14 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
           onCancel={() => setShowConfirm(false)}
           onCancel={() => setShowConfirm(false)}
         />
         />
       )}
       )}
+      {showMerge && (
+        <MergeDuplicatesModal
+          onClose={() => {
+            setShowMerge(false);
+            mergeCountQuery.refetch();
+          }}
+        />
+      )}
     </>
     </>
   );
   );
 }
 }

+ 1 - 1
frontend/src/components/MergeDuplicatesModal.tsx

@@ -53,7 +53,7 @@ export function MergeDuplicatesModal({ onClose }: MergeDuplicatesModalProps) {
       const merged = result.merged.length;
       const merged = result.merged.length;
       const left = result.skipped.length;
       const left = result.skipped.length;
       // Runs moved between archives: every list of archives and runs.
       // Runs moved between archives: every list of archives and runs.
-      for (const key of ['archives', 'archive', 'archiveStats', 'archives-stats', 'print-log', 'archive-runs']) {
+      for (const key of ['archives', 'archive', 'archiveStats', 'archives-stats', 'print-log', 'archive-runs', 'archive-merge-count']) {
         queryClient.invalidateQueries({ queryKey: [key] });
         queryClient.invalidateQueries({ queryKey: [key] });
       }
       }
       if (left === 0) {
       if (left === 0) {

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

@@ -8139,6 +8139,10 @@ export default {
     keeps: 'Bleibt · erstellt {{date}} · {{count}} Druck(e)',
     keeps: 'Bleibt · erstellt {{date}} · {{count}} Druck(e)',
     mergesIn: '{{name}} · erstellt {{date}} · {{count}} Druck(e)',
     mergesIn: '{{name}} · erstellt {{date}} · {{count}} Druck(e)',
     leftOutTitle: 'Beim Zusammenführen ausgelassen',
     leftOutTitle: 'Beim Zusammenführen ausgelassen',
+    found: '{{count}} Gruppe(n) doppelter Archive gefunden.',
+    openCta: 'Duplikate zusammenführen…',
+    bannerText: '{{count}} Gruppe(n) von Archiven enthalten dieselbe Datei. Zusammenführen macht jede Gruppe zu einem Archiv mit allen ihren Drucken.',
+    dismiss: 'Ausblenden',
     waitingTitle: 'Vorerst ausgelassen',
     waitingTitle: 'Vorerst ausgelassen',
     mergeCta: '{{count}} Gruppe(n) zusammenführen',
     mergeCta: '{{count}} Gruppe(n) zusammenführen',
     confirmCta: 'Ja, {{count}} Gruppe(n) zusammenführen',
     confirmCta: 'Ja, {{count}} Gruppe(n) zusammenführen',

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

@@ -8192,6 +8192,10 @@ export default {
     keeps: 'Stays · created {{date}} · {{count}} print(s)',
     keeps: 'Stays · created {{date}} · {{count}} print(s)',
     mergesIn: '{{name}} · created {{date}} · {{count}} print(s)',
     mergesIn: '{{name}} · created {{date}} · {{count}} print(s)',
     leftOutTitle: 'Left out by the merge',
     leftOutTitle: 'Left out by the merge',
+    found: '{{count}} group(s) of duplicate archives were found.',
+    openCta: 'Merge duplicates…',
+    bannerText: '{{count}} group(s) of archives hold the same file. Merging makes each group one archive, with every print of it.',
+    dismiss: 'Dismiss',
     waitingTitle: 'Left out for now',
     waitingTitle: 'Left out for now',
     mergeCta: 'Merge {{count}} group(s)',
     mergeCta: 'Merge {{count}} group(s)',
     confirmCta: 'Yes, merge {{count}} group(s)',
     confirmCta: 'Yes, merge {{count}} group(s)',

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

@@ -8147,6 +8147,10 @@ export default {
     keeps: 'Se mantiene · creado {{date}} · {{count}} impresión(es)',
     keeps: 'Se mantiene · creado {{date}} · {{count}} impresión(es)',
     mergesIn: '{{name}} · creado {{date}} · {{count}} impresión(es)',
     mergesIn: '{{name}} · creado {{date}} · {{count}} impresión(es)',
     leftOutTitle: 'Omitidos al fusionar',
     leftOutTitle: 'Omitidos al fusionar',
+    found: 'Se encontraron {{count}} grupo(s) de archivos duplicados.',
+    openCta: 'Fusionar duplicados…',
+    bannerText: '{{count}} grupo(s) de archivos contienen el mismo fichero. Fusionar convierte cada grupo en un archivo, con todas sus impresiones.',
+    dismiss: 'Descartar',
     waitingTitle: 'Omitidos por ahora',
     waitingTitle: 'Omitidos por ahora',
     mergeCta: 'Fusionar {{count}} grupo(s)',
     mergeCta: 'Fusionar {{count}} grupo(s)',
     confirmCta: 'Sí, fusionar {{count}} grupo(s)',
     confirmCta: 'Sí, fusionar {{count}} grupo(s)',

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

@@ -8125,6 +8125,10 @@ export default {
     keeps: 'Conservée · créée le {{date}} · {{count}} impression(s)',
     keeps: 'Conservée · créée le {{date}} · {{count}} impression(s)',
     mergesIn: '{{name}} · créée le {{date}} · {{count}} impression(s)',
     mergesIn: '{{name}} · créée le {{date}} · {{count}} impression(s)',
     leftOutTitle: 'Écartés lors de la fusion',
     leftOutTitle: 'Écartés lors de la fusion',
+    found: "{{count}} groupe(s) d'archives en double trouvé(s).",
+    openCta: 'Fusionner les doublons…',
+    bannerText: "{{count}} groupe(s) d'archives contiennent le même fichier. La fusion fait de chaque groupe une seule archive, avec toutes ses impressions.",
+    dismiss: 'Masquer',
     waitingTitle: "Écartés pour l'instant",
     waitingTitle: "Écartés pour l'instant",
     mergeCta: 'Fusionner {{count}} groupe(s)',
     mergeCta: 'Fusionner {{count}} groupe(s)',
     confirmCta: 'Oui, fusionner {{count}} groupe(s)',
     confirmCta: 'Oui, fusionner {{count}} groupe(s)',

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

@@ -8124,6 +8124,10 @@ export default {
     keeps: 'Resta · creato il {{date}} · {{count}} stampa/e',
     keeps: 'Resta · creato il {{date}} · {{count}} stampa/e',
     mergesIn: '{{name}} · creato il {{date}} · {{count}} stampa/e',
     mergesIn: '{{name}} · creato il {{date}} · {{count}} stampa/e',
     leftOutTitle: "Esclusi dall'unione",
     leftOutTitle: "Esclusi dall'unione",
+    found: 'Trovati {{count}} gruppo/i di archivi duplicati.',
+    openCta: 'Unisci duplicati…',
+    bannerText: "{{count}} gruppo/i di archivi contengono lo stesso file. L'unione rende ogni gruppo un solo archivio, con tutte le sue stampe.",
+    dismiss: 'Ignora',
     waitingTitle: 'Esclusi per ora',
     waitingTitle: 'Esclusi per ora',
     mergeCta: 'Unisci {{count}} gruppo/i',
     mergeCta: 'Unisci {{count}} gruppo/i',
     confirmCta: 'Sì, unisci {{count}} gruppo/i',
     confirmCta: 'Sì, unisci {{count}} gruppo/i',

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

@@ -8138,6 +8138,10 @@ export default {
     keeps: '残す · 作成日 {{date}} · 印刷 {{count}} 回',
     keeps: '残す · 作成日 {{date}} · 印刷 {{count}} 回',
     mergesIn: '{{name}} · 作成日 {{date}} · 印刷 {{count}} 回',
     mergesIn: '{{name}} · 作成日 {{date}} · 印刷 {{count}} 回',
     leftOutTitle: '統合で対象外になったもの',
     leftOutTitle: '統合で対象外になったもの',
+    found: '重複アーカイブのグループが {{count}} 件見つかりました。',
+    openCta: '重複を統合…',
+    bannerText: '{{count}} 件のグループのアーカイブが同じファイルを持っています。統合すると各グループが、すべての印刷を含む1つのアーカイブになります。',
+    dismiss: '閉じる',
     waitingTitle: '今回は対象外',
     waitingTitle: '今回は対象外',
     mergeCta: '{{count}} グループを統合',
     mergeCta: '{{count}} グループを統合',
     confirmCta: 'はい、{{count}} グループを統合',
     confirmCta: 'はい、{{count}} グループを統合',

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

@@ -7581,6 +7581,10 @@ export default {
     keeps: '유지 · {{date}} 생성 · 출력 {{count}}회',
     keeps: '유지 · {{date}} 생성 · 출력 {{count}}회',
     mergesIn: '{{name}} · {{date}} 생성 · 출력 {{count}}회',
     mergesIn: '{{name}} · {{date}} 생성 · 출력 {{count}}회',
     leftOutTitle: '병합에서 제외됨',
     leftOutTitle: '병합에서 제외됨',
+    found: '중복 아카이브 그룹 {{count}}개를 찾았습니다.',
+    openCta: '중복 병합…',
+    bannerText: '{{count}}개 그룹의 아카이브가 같은 파일을 가지고 있습니다. 병합하면 각 그룹이 모든 출력을 포함한 하나의 아카이브가 됩니다.',
+    dismiss: '닫기',
     waitingTitle: '이번에는 제외됨',
     waitingTitle: '이번에는 제외됨',
     mergeCta: '{{count}}개 그룹 병합',
     mergeCta: '{{count}}개 그룹 병합',
     confirmCta: '예, {{count}}개 그룹 병합',
     confirmCta: '예, {{count}}개 그룹 병합',

+ 4 - 0
frontend/src/i18n/locales/nl.ts

@@ -8190,6 +8190,10 @@ export default {
     keeps: 'Blijft · aangemaakt {{date}} · {{count}} print(s)',
     keeps: 'Blijft · aangemaakt {{date}} · {{count}} print(s)',
     mergesIn: '{{name}} · aangemaakt {{date}} · {{count}} print(s)',
     mergesIn: '{{name}} · aangemaakt {{date}} · {{count}} print(s)',
     leftOutTitle: 'Overgeslagen bij het samenvoegen',
     leftOutTitle: 'Overgeslagen bij het samenvoegen',
+    found: '{{count}} groep(en) dubbele archieven gevonden.',
+    openCta: 'Duplicaten samenvoegen…',
+    bannerText: '{{count}} groep(en) archieven bevatten hetzelfde bestand. Samenvoegen maakt van elke groep één archief, met al zijn prints.',
+    dismiss: 'Verbergen',
     waitingTitle: 'Voorlopig overgeslagen',
     waitingTitle: 'Voorlopig overgeslagen',
     mergeCta: '{{count}} groep(en) samenvoegen',
     mergeCta: '{{count}} groep(en) samenvoegen',
     confirmCta: 'Ja, {{count}} groep(en) samenvoegen',
     confirmCta: 'Ja, {{count}} groep(en) samenvoegen',

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

@@ -8124,6 +8124,10 @@ export default {
     keeps: 'Fica · criado em {{date}} · {{count}} impressão(ões)',
     keeps: 'Fica · criado em {{date}} · {{count}} impressão(ões)',
     mergesIn: '{{name}} · criado em {{date}} · {{count}} impressão(ões)',
     mergesIn: '{{name}} · criado em {{date}} · {{count}} impressão(ões)',
     leftOutTitle: 'Deixados de fora na mesclagem',
     leftOutTitle: 'Deixados de fora na mesclagem',
+    found: '{{count}} grupo(s) de arquivos duplicados encontrado(s).',
+    openCta: 'Mesclar duplicados…',
+    bannerText: '{{count}} grupo(s) de arquivos contêm o mesmo arquivo. Mesclar transforma cada grupo em um arquivo, com todas as suas impressões.',
+    dismiss: 'Dispensar',
     waitingTitle: 'Deixados de fora por enquanto',
     waitingTitle: 'Deixados de fora por enquanto',
     mergeCta: 'Mesclar {{count}} grupo(s)',
     mergeCta: 'Mesclar {{count}} grupo(s)',
     confirmCta: 'Sim, mesclar {{count}} grupo(s)',
     confirmCta: 'Sim, mesclar {{count}} grupo(s)',

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

@@ -7765,6 +7765,10 @@ export default {
     keeps: 'Остаётся · создан {{date}} · печатей: {{count}}',
     keeps: 'Остаётся · создан {{date}} · печатей: {{count}}',
     mergesIn: '{{name}} · создан {{date}} · печатей: {{count}}',
     mergesIn: '{{name}} · создан {{date}} · печатей: {{count}}',
     leftOutTitle: 'Пропущено при объединении',
     leftOutTitle: 'Пропущено при объединении',
+    found: 'Найдено групп дубликатов архивов: {{count}}.',
+    openCta: 'Объединить дубликаты…',
+    bannerText: 'Групп архивов с одним и тем же файлом: {{count}}. Объединение превращает каждую группу в один архив со всеми её печатями.',
+    dismiss: 'Скрыть',
     waitingTitle: 'Пока пропущены',
     waitingTitle: 'Пока пропущены',
     mergeCta: 'Объединить групп: {{count}}',
     mergeCta: 'Объединить групп: {{count}}',
     confirmCta: 'Да, объединить групп: {{count}}',
     confirmCta: 'Да, объединить групп: {{count}}',

+ 4 - 0
frontend/src/i18n/locales/sv.ts

@@ -8188,6 +8188,10 @@ errors: {
     keeps: 'Blir kvar · skapad {{date}} · {{count}} utskrift(er)',
     keeps: 'Blir kvar · skapad {{date}} · {{count}} utskrift(er)',
     mergesIn: '{{name}} · skapad {{date}} · {{count}} utskrift(er)',
     mergesIn: '{{name}} · skapad {{date}} · {{count}} utskrift(er)',
     leftOutTitle: 'Utelämnade vid sammanslagningen',
     leftOutTitle: 'Utelämnade vid sammanslagningen',
+    found: '{{count}} grupp(er) med dubblettarkiv hittades.',
+    openCta: 'Slå ihop dubbletter…',
+    bannerText: '{{count}} grupp(er) av arkiv har samma fil. Att slå ihop gör varje grupp till ett arkiv, med alla dess utskrifter.',
+    dismiss: 'Dölj',
     waitingTitle: 'Utelämnade för tillfället',
     waitingTitle: 'Utelämnade för tillfället',
     mergeCta: 'Slå ihop {{count}} grupp(er)',
     mergeCta: 'Slå ihop {{count}} grupp(er)',
     confirmCta: 'Ja, slå ihop {{count}} grupp(er)',
     confirmCta: 'Ja, slå ihop {{count}} grupp(er)',

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

@@ -8078,6 +8078,10 @@ export default {
     keeps: 'Kalır · oluşturma {{date}} · {{count}} baskı',
     keeps: 'Kalır · oluşturma {{date}} · {{count}} baskı',
     mergesIn: '{{name}} · oluşturma {{date}} · {{count}} baskı',
     mergesIn: '{{name}} · oluşturma {{date}} · {{count}} baskı',
     leftOutTitle: 'Birleştirmede dışarıda bırakılanlar',
     leftOutTitle: 'Birleştirmede dışarıda bırakılanlar',
+    found: '{{count}} yinelenen arşiv grubu bulundu.',
+    openCta: 'Yinelenenleri birleştir…',
+    bannerText: '{{count}} arşiv grubu aynı dosyayı içeriyor. Birleştirme her grubu tüm baskılarıyla birlikte tek arşiv yapar.',
+    dismiss: 'Kapat',
     waitingTitle: 'Şimdilik dışarıda bırakıldı',
     waitingTitle: 'Şimdilik dışarıda bırakıldı',
     mergeCta: '{{count}} grubu birleştir',
     mergeCta: '{{count}} grubu birleştir',
     confirmCta: 'Evet, {{count}} grubu birleştir',
     confirmCta: 'Evet, {{count}} grubu birleştir',

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

@@ -8180,6 +8180,10 @@ export default {
     keeps: 'Залишається · створено {{date}} · друків: {{count}}',
     keeps: 'Залишається · створено {{date}} · друків: {{count}}',
     mergesIn: '{{name}} · створено {{date}} · друків: {{count}}',
     mergesIn: '{{name}} · створено {{date}} · друків: {{count}}',
     leftOutTitle: "Пропущено під час об'єднання",
     leftOutTitle: "Пропущено під час об'єднання",
+    found: 'Знайдено груп дублікатів архівів: {{count}}.',
+    openCta: "Об'єднати дублікати…",
+    bannerText: "Груп архівів з тим самим файлом: {{count}}. Об'єднання робить кожну групу одним архівом з усіма її друками.",
+    dismiss: 'Сховати',
     waitingTitle: 'Поки пропущено',
     waitingTitle: 'Поки пропущено',
     mergeCta: "Об'єднати груп: {{count}}",
     mergeCta: "Об'єднати груп: {{count}}",
     confirmCta: "Так, об'єднати груп: {{count}}",
     confirmCta: "Так, об'єднати груп: {{count}}",

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

@@ -8122,6 +8122,10 @@ export default {
     keeps: '保留 · 创建于 {{date}} · 打印 {{count}} 次',
     keeps: '保留 · 创建于 {{date}} · 打印 {{count}} 次',
     mergesIn: '{{name}} · 创建于 {{date}} · 打印 {{count}} 次',
     mergesIn: '{{name}} · 创建于 {{date}} · 打印 {{count}} 次',
     leftOutTitle: '合并时未处理',
     leftOutTitle: '合并时未处理',
+    found: '发现 {{count}} 组重复归档。',
+    openCta: '合并重复项…',
+    bannerText: '有 {{count}} 组归档包含同一文件。合并会把每组变成一个归档,并保留其所有打印。',
+    dismiss: '关闭',
     waitingTitle: '暂不处理',
     waitingTitle: '暂不处理',
     mergeCta: '合并 {{count}} 组',
     mergeCta: '合并 {{count}} 组',
     confirmCta: '是,合并 {{count}} 组',
     confirmCta: '是,合并 {{count}} 组',

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

@@ -8122,6 +8122,10 @@ export default {
     keeps: '保留 · 建立於 {{date}} · 列印 {{count}} 次',
     keeps: '保留 · 建立於 {{date}} · 列印 {{count}} 次',
     mergesIn: '{{name}} · 建立於 {{date}} · 列印 {{count}} 次',
     mergesIn: '{{name}} · 建立於 {{date}} · 列印 {{count}} 次',
     leftOutTitle: '合併時未處理',
     leftOutTitle: '合併時未處理',
+    found: '發現 {{count}} 組重複封存。',
+    openCta: '合併重複項目…',
+    bannerText: '有 {{count}} 組封存包含同一檔案。合併會把每組變成一個封存,並保留其所有列印。',
+    dismiss: '關閉',
     waitingTitle: '暫不處理',
     waitingTitle: '暫不處理',
     mergeCta: '合併 {{count}} 組',
     mergeCta: '合併 {{count}} 組',
     confirmCta: '是,合併 {{count}} 組',
     confirmCta: '是,合併 {{count}} 組',

+ 55 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -3146,6 +3146,33 @@ export function ArchivesPage() {
   const [showMergeModal, setShowMergeModal] = useState(false);
   const [showMergeModal, setShowMergeModal] = useState(false);
   // What the merge API asks for: it changes and removes whole archives.
   // What the merge API asks for: it changes and removes whole archives.
   const canMergeDuplicates = hasPermission('archives:update_all') && hasPermission('archives:delete_all');
   const canMergeDuplicates = hasPermission('archives:update_all') && hasPermission('archives:delete_all');
+  // Offered while archives of one file exist more than once: after an
+  // update, or a restore of an old backup. Dismissed until the count changes.
+  const { data: mergeCount } = useQuery({
+    queryKey: ['archive-merge-count'],
+    queryFn: () => api.getArchiveMergeCount(),
+    enabled: canMergeDuplicates,
+  });
+  const [mergeBannerDismissedAt, setMergeBannerDismissedAt] = useState<number | null>(() => {
+    try {
+      const stored = localStorage.getItem('archiveMergeBannerDismissed');
+      return stored ? Number(stored) : null;
+    } catch {
+      return null;
+    }
+  });
+  const duplicateGroups = canMergeDuplicates ? (mergeCount?.groups ?? 0) : 0;
+  // Shown again only when there are more groups than when it was dismissed:
+  // merging some leaves the rest (some may never merge, their files differ).
+  const mergeBannerDismissed = mergeBannerDismissedAt !== null && duplicateGroups <= mergeBannerDismissedAt;
+  const dismissMergeBanner = () => {
+    setMergeBannerDismissedAt(duplicateGroups);
+    try {
+      localStorage.setItem('archiveMergeBannerDismissed', String(duplicateGroups));
+    } catch {
+      // Private window: dismissed for this visit only.
+    }
+  };
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   // An archive navigated to from elsewhere, and the warning shown when the
   // An archive navigated to from elsewhere, and the warning shown when the
   // current filters hide it.
   // current filters hide it.
@@ -3243,6 +3270,12 @@ export function ArchivesPage() {
     setHighlightRequest({ archiveId, requestedAt: Date.now() });
     setHighlightRequest({ archiveId, requestedAt: Date.now() });
     refetchArchives();
     refetchArchives();
   }, [highlightParam, refetchArchives]);
   }, [highlightParam, refetchArchives]);
+  // Archives deleted, uploaded or printed change the duplicates the banner counts.
+  useEffect(() => {
+    if (canMergeDuplicates && archivesUpdatedAt) {
+      queryClient.invalidateQueries({ queryKey: ['archive-merge-count'] });
+    }
+  }, [archivesUpdatedAt, canMergeDuplicates, queryClient]);
   useEffect(() => {
   useEffect(() => {
     if (!highlightRequest || !archives || archivesUpdatedAt < highlightRequest.requestedAt) return;
     if (!highlightRequest || !archives || archivesUpdatedAt < highlightRequest.requestedAt) return;
     setHighlightRequest(null);
     setHighlightRequest(null);
@@ -3987,6 +4020,28 @@ export function ArchivesPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {collection !== 'duplicates' && duplicateGroups > 0 && !mergeBannerDismissed && (
+        <div
+          className="mb-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary px-4 py-3 flex items-center gap-3"
+          data-testid="merge-banner"
+        >
+          <Combine className="w-5 h-5 text-bambu-green flex-shrink-0" />
+          <div className="flex-1 min-w-0 text-sm text-bambu-gray-light">
+            {t('archiveMerge.bannerText', { count: duplicateGroups })}
+          </div>
+          <Button variant="secondary" size="sm" onClick={() => setShowMergeModal(true)}>
+            {t('archiveMerge.headerButton')}
+          </Button>
+          <button
+            onClick={dismissMergeBanner}
+            className="text-bambu-gray hover:text-white"
+            aria-label={t('archiveMerge.dismiss')}
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+      )}
+
       {collection === 'duplicates' && canMergeDuplicates && (
       {collection === 'duplicates' && canMergeDuplicates && (
         <div className="mb-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary px-4 py-3 flex items-center gap-3">
         <div className="mb-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary px-4 py-3 flex items-center gap-3">
           <Combine className="w-5 h-5 text-bambu-green flex-shrink-0" />
           <Combine className="w-5 h-5 text-bambu-green flex-shrink-0" />

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-D2zuRt4q.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-BqY1UE0E.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-ChHPHRBy.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-DMDhrgSq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AreaChart-C5g103Jy.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-dmPqKY22.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AuthContext-BlnL2gGQ.js


+ 1 - 1
static/assets/Button-CzgwmHYz.js → static/assets/Button-DPTAdE-r.js

@@ -1 +1 @@
-import{w as e}from"./client-BF7bXNK1.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-CWBrdRQ8.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CamWallPage-CZA3lFGY.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-ltpwLHpN.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-D8ti0i0s.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-BzJbS8u_.js


+ 1 - 1
static/assets/Card-Dt47We-H.js → static/assets/Card-DSfTTQ2S.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-BF7bXNK1.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CartesianChart-CySNwb-8.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-CLgHP2AU.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-C8sLW7UM.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfirmModal-BZ6PdVis.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfirmOutcomeDialog-aLxq9HvU.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-D8681GnQ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectionDiagnostic-Bf84BtBl.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-BI8kiND5.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CopyButton-DCJgXGkS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/EmbeddedCameraViewer-C4YKaz1m.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ErrorBarContext-Csenv7Eu.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-BXhJTdsc.js


+ 1 - 1
static/assets/FilamentSwatch-DzSz890J.js → static/assets/FilamentSwatch-Dz5HrDb8.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-BF7bXNK1.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-TOl55G1u.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/FileManagerPage-moXFsMm1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-as3zaDnV.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-BEiOAIm9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-C0jz3Lsh.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GcodeToolpathViewer-D6U0qj5b.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-BycJVdWV.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/IconPicker-BimhUo0f.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-Ch4Dt0Df.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-D7vdaHWJ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/Layout-ruwL5Qle.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-CwHirNI9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-DbKZyYlZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-CazkonbW.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MergeDuplicatesModal-Cx2alSPc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-DL_xEZF4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-BJyNTiF-.js


+ 1 - 1
static/assets/NumberInput-BE_bIm1Y.js → static/assets/NumberInput-DmWSadae.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-BF7bXNK1.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-CD1ErASP.js → static/assets/OverlayFrame-BURM4Vsq.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-BF7bXNK1.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-CWBrdRQ8.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PdfPreviewModal-DRt2cAGe.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-9zaPoTRT.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-sLE4KKad.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintModal-DgAKJq3q.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-Jdh5nP9S.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-A7rvUJCH.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-DldeW-O3.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage-TpYGQy9E.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-tC9IhYtI.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueryClientProvider-q3IMwOW_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-DKq6AP14.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ReferenceLine-CmaP3ZnZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-BnSjGMVp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-1p3Qk4WD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-BJ7Ybvr4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-DwxALWbn.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SkipObjectsModal-DFEHNDAQ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-BMyHp8bG.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-Cqe3-5CL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-CFDp0yue.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CYTdes0j.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BYexExQD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-D5b_6s8x.js


+ 1 - 1
static/assets/SpoolIcon-D0RZd795.js → static/assets/SpoolIcon-DT-vc2hk.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-BF7bXNK1.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DOudyikg.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-B4XND1LE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage-CzeaKGa2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemHealthPanel-B_XI5cp1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-Bwp1tobE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ThemeContext-B-OKeSi9.js


Некоторые файлы не были показаны из-за большого количества измененных файлов