ソースを参照

Archives: merge duplicates from the archives page (archive redesign P2)

- "Merge duplicates" in the archives header and the Duplicates
  collection lists each group with the archive that stays and the ones
  merging into it, left-out groups with their reason, and merges the
  ticked groups after a confirm
- Groups the merge itself leaves out are listed with why before the
  dialog closes; the preview also flags a hand-attached video beside
  another
- Links to a merged archive open the one it went into: the outcome
  dialog answers that archive, and ?highlight finds it
- Shown to users who may edit and delete all archives; all locales
maziggy 1 日 前
親
コミット
aa8d906719
100 ファイル変更、1055 行追加、20 行削除
  1. 23 7
      backend/app/services/archive_merge.py
  2. 1 1
      backend/tests/integration/test_archive_merge.py
  3. 21 0
      frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx
  4. 117 0
      frontend/src/__tests__/components/MergeDuplicatesModal.test.tsx
  5. 9 0
      frontend/src/__tests__/pages/ArchivesHighlightParam.test.tsx
  6. 47 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  7. 33 0
      frontend/src/api/client.ts
  8. 6 2
      frontend/src/components/ConfirmOutcomeDialog.tsx
  9. 251 0
      frontend/src/components/MergeDuplicatesModal.tsx
  10. 33 0
      frontend/src/i18n/locales/de.ts
  11. 33 0
      frontend/src/i18n/locales/en.ts
  12. 33 0
      frontend/src/i18n/locales/es.ts
  13. 33 0
      frontend/src/i18n/locales/fr.ts
  14. 33 0
      frontend/src/i18n/locales/it.ts
  15. 33 0
      frontend/src/i18n/locales/ja.ts
  16. 33 0
      frontend/src/i18n/locales/ko.ts
  17. 33 0
      frontend/src/i18n/locales/nl.ts
  18. 33 0
      frontend/src/i18n/locales/pt-BR.ts
  19. 33 0
      frontend/src/i18n/locales/ru.ts
  20. 33 0
      frontend/src/i18n/locales/sv.ts
  21. 33 0
      frontend/src/i18n/locales/tr.ts
  22. 33 0
      frontend/src/i18n/locales/uk.ts
  23. 33 0
      frontend/src/i18n/locales/zh-CN.ts
  24. 33 0
      frontend/src/i18n/locales/zh-TW.ts
  25. 44 2
      frontend/src/pages/ArchivesPage.tsx
  26. 0 0
      static/assets/AdditionalSection-VjQDYOHY.js
  27. 0 0
      static/assets/AmsUnitCard-BSNvg5MS.js
  28. 0 0
      static/assets/ArchivesPage-ChHPHRBy.js
  29. 0 0
      static/assets/ArchivesPage-JEEGcZqh.js
  30. 0 0
      static/assets/AreaChart-CTVkRrvz.js
  31. 0 0
      static/assets/AssignToAmsModal-Vsy_XyOt.js
  32. 0 0
      static/assets/AuthContext-B9gIpMLz.js
  33. 1 1
      static/assets/Button-CzgwmHYz.js
  34. 0 0
      static/assets/CamWallPage-HT2Znls3.js
  35. 0 0
      static/assets/CameraDiagnoseModal-DY7NsQmL.js
  36. 0 0
      static/assets/CameraPage-BG6LzkMI.js
  37. 0 0
      static/assets/CameraWall-C5FXel4C.js
  38. 1 1
      static/assets/Card-Dt47We-H.js
  39. 0 0
      static/assets/CartesianChart-JN-2ZiTi.js
  40. 0 0
      static/assets/ColumnConfigModal-COFA-DuM.js
  41. 0 0
      static/assets/ConfigureAmsSlotModal-B6x63T4j.js
  42. 0 0
      static/assets/ConfirmModal-DcNL3bIh.js
  43. 0 0
      static/assets/ConfirmOutcomeDialog-Dal-cSao.js
  44. 0 0
      static/assets/ConfirmOutcomeDialog-DumG9Oh_.js
  45. 0 0
      static/assets/ConnectAuthorizePage-C-hAVL26.js
  46. 0 0
      static/assets/ConnectionDiagnostic-CcAm2RqN.js
  47. 0 0
      static/assets/ContextMenu-BPSqHnaA.js
  48. 0 0
      static/assets/CopyButton-CtKgxPKf.js
  49. 0 0
      static/assets/EmbeddedCameraViewer-OHMATbGZ.js
  50. 0 0
      static/assets/ErrorBarContext-T5o6inW7.js
  51. 0 0
      static/assets/ExternalLinkPage-IluEAP5R.js
  52. 1 1
      static/assets/FilamentSwatch-DzSz890J.js
  53. 0 1
      static/assets/FileManagerPage-CCg1jTJR.js
  54. 1 0
      static/assets/FileManagerPage-TOl55G1u.js
  55. 0 0
      static/assets/FileUploadModal-t1v7X6X_.js
  56. 0 0
      static/assets/FinancePage-Df33Oafw.js
  57. 0 0
      static/assets/GCodeViewerPage-CGqcGLCv.js
  58. 0 0
      static/assets/GcodeToolpathViewer-BHLBh0s3.js
  59. 0 0
      static/assets/GroupEditPage-CSD--6vr.js
  60. 0 0
      static/assets/IconPicker-1RTyVRhj.js
  61. 0 0
      static/assets/ImagePreviewModal-D_1NEFuH.js
  62. 0 0
      static/assets/InventoryPage-Bym1M5dB.js
  63. 0 0
      static/assets/Layout-C89cPPwl.js
  64. 0 0
      static/assets/LibraryTrashPage-D5EytIeX.js
  65. 0 0
      static/assets/LoginPage-loARb3A7.js
  66. 0 0
      static/assets/MaintenancePage-CBjeu3nF.js
  67. 0 0
      static/assets/ModelSourcesPage-A5Th6ak2.js
  68. 0 0
      static/assets/NotificationsPage-awEIDrxq.js
  69. 1 1
      static/assets/NumberInput-BE_bIm1Y.js
  70. 1 1
      static/assets/OverlayFrame-CD1ErASP.js
  71. 0 0
      static/assets/PdfPreviewModal-BelEs6iK.js
  72. 0 0
      static/assets/PhotoGalleryModal-D50nEPW2.js
  73. 0 0
      static/assets/PhotoGalleryModal-sLE4KKad.js
  74. 0 0
      static/assets/PrintModal-BT2KtjZt.js
  75. 0 0
      static/assets/PrinterLocationsPage-Ew_IiK4h.js
  76. 0 0
      static/assets/PrintersPage-Dy9ZRyrC.js
  77. 0 0
      static/assets/ProfilesPage-BKW84csD.js
  78. 0 0
      static/assets/ProjectDetailPage-c_B5ABQm.js
  79. 0 0
      static/assets/ProjectsPage-Ds1hxt43.js
  80. 0 0
      static/assets/QueryClientProvider-9JNFZ3bI.js
  81. 0 0
      static/assets/QueuePage-CzYdYJ5X.js
  82. 0 0
      static/assets/ReferenceLine-BRhRYYwU.js
  83. 0 0
      static/assets/RichTextEditor-CXjZogRK.js
  84. 0 0
      static/assets/SettingsPage-1p3Qk4WD.js
  85. 0 0
      static/assets/SetupPage-DXOREGl3.js
  86. 0 0
      static/assets/SkipObjectsModal-BuhX5VOW.js
  87. 0 0
      static/assets/SliceModal-BIzXRH-g.js
  88. 0 0
      static/assets/SpoolBuddyAmsPage-BNJzfaVS.js
  89. 0 0
      static/assets/SpoolBuddyDashboard-CLLeE5eU.js
  90. 0 0
      static/assets/SpoolBuddyInventoryPage-x0eAcUaE.js
  91. 1 1
      static/assets/SpoolBuddySettingsPage-6VlOZmQI.js
  92. 0 0
      static/assets/SpoolBuddyWriteTagPage-DqWJRBO1.js
  93. 1 1
      static/assets/SpoolIcon-D0RZd795.js
  94. 0 0
      static/assets/SpreadsheetPreviewModal-GYjYUlDp.js
  95. 0 0
      static/assets/StatsPage--8qxkK14.js
  96. 0 0
      static/assets/StreamOverlayPage-D9-t4yUR.js
  97. 0 0
      static/assets/SystemHealthPanel-Cple2Hvu.js
  98. 0 0
      static/assets/SystemInfoPage-D6QrxazL.js
  99. 0 0
      static/assets/ThemeContext-CxS_afow.js
  100. 0 0
      static/assets/ToastContext-DZTRNQaL.js

+ 23 - 7
backend/app/services/archive_merge.py

@@ -112,6 +112,14 @@ async def find_groups(db: AsyncSession, visible: list) -> list[MergeGroup]:
             )
         ).all()
     )
+    member_runs: dict[int, list[PrintLogEntry]] = {}
+    for run in await db.scalars(
+        select(PrintLogEntry)
+        .where(PrintLogEntry.archive_id.in_([a.id for a in members]))
+        .execution_options(**{INCLUDE_OPEN_RUNS: True})
+    ):
+        if run.archive_id is not None:
+            member_runs.setdefault(run.archive_id, []).append(run)
     groups = []
     for group in by_key.values():
         if len(group) < 2:
@@ -119,7 +127,7 @@ async def find_groups(db: AsyncSession, visible: list) -> list[MergeGroup]:
         skipped = None
         if any(a.id in busy for a in group):
             skipped = SKIPPED_BUSY
-        elif await asyncio.to_thread(_conflicting_extras, group):
+        elif await asyncio.to_thread(_conflicting_extras, group) or _videos_conflict(group, member_runs):
             skipped = SKIPPED_CONFLICTING_FILES
         groups.append(
             MergeGroup(
@@ -291,6 +299,18 @@ def _orphan_video(archive: PrintArchive, runs: list[PrintLogEntry]) -> bool:
     return not any(r.timelapse_path is None and not owns_media(r) for r in runs)
 
 
+def _videos_conflict(archives: list[PrintArchive], runs: dict[int, list[PrintLogEntry]]) -> bool:
+    """Whether a video an archive alone holds (``_orphan_video``) is not the
+    group's one video: the archive shows one, so the other would be lost."""
+    orphans = {a.timelapse_path for a in archives if _orphan_video(a, runs.get(a.id, []))}
+    if not orphans:
+        return False
+    videos = {r.timelapse_path for own in runs.values() for r in own if r.timelapse_path} | {
+        a.timelapse_path for a in archives if a.timelapse_path
+    }
+    return len(orphans) > 1 or bool(videos - orphans)
+
+
 def _latest(runs, *, where) -> PrintLogEntry | None:
     """The latest by start of ``runs`` that ``where`` admits."""
     return max(
@@ -424,12 +444,8 @@ async def _merge(
     # hand to one never printed): the archive keeps it only if it is the one
     # video of the group, or it would be lost or hide another.
     orphans = {a.id: a.timelapse_path for a in rows.values() if _orphan_video(a, runs[a.id])}
-    if orphans:
-        videos = {r.timelapse_path for own in runs.values() for r in own if r.timelapse_path} | {
-            a.timelapse_path for a in rows.values() if a.timelapse_path
-        }
-        if len(orphans) > 1 or videos - set(orphans.values()):
-            return SKIPPED_CONFLICTING_FILES, []
+    if _videos_conflict(list(rows.values()), runs):
+        return SKIPPED_CONFLICTING_FILES, []
 
     # Plan, reading only: the files to place in the survivor's folder. The
     # survivor's own files where it lost them, from the first archive that

+ 1 - 1
backend/tests/integration/test_archive_merge.py

@@ -554,7 +554,7 @@ async def test_a_video_attached_by_hand_beside_another_waits(async_client, db_se
     [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
     response = await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
 
-    assert group["skipped"] is None
+    assert group["skipped"] == "conflicting_files"
     assert response.json()["skipped"] == [{"survivor_id": dupes.x, "reason": "conflicting_files"}]
 
 

+ 21 - 0
frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx

@@ -101,6 +101,27 @@ describe('ConfirmOutcomeDialog', () => {
     });
   });
 
+  it('answers the archive a merged one went into (archive redesign P2)', async () => {
+    const user = userEvent.setup();
+    let merged: Record<string, unknown> | null = null;
+    server.use(
+      // The merged id's lookup returns the archive it went into.
+      http.get('/api/v1/archives/31', () => HttpResponse.json(baseArchive)),
+      http.patch('/api/v1/archives/31', async ({ request }) => {
+        merged = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json(baseArchive);
+      }),
+    );
+    render(<ConfirmOutcomeDialog archiveId={31} onClose={vi.fn()} />);
+
+    await screen.findByText('Bracket');
+    expect(screen.getByAltText('Bracket')).toHaveAttribute('src', expect.stringContaining('/archives/42/photos/'));
+    await user.click(screen.getByText('Good'));
+
+    await waitFor(() => expect(patchPayload).toEqual({ user_verdict: 'good', user_verdict_source: 'dialog' }));
+    expect(merged).toBeNull();
+  });
+
   it('records a reject with an optional reason', async () => {
     const user = userEvent.setup();
     const onClose = vi.fn();

+ 117 - 0
frontend/src/__tests__/components/MergeDuplicatesModal.test.tsx

@@ -0,0 +1,117 @@
+/**
+ * MergeDuplicatesModal (archive redesign P2): the groups the server found,
+ * the ones it leaves out and why, the choice of groups, the confirm step,
+ * and what is sent and reported.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { MergeDuplicatesModal } from '../../components/MergeDuplicatesModal';
+
+const member = (id: number, name: string, runs: number) => ({
+  id,
+  print_name: name,
+  filename: `${name}.3mf`,
+  created_at: '2026-09-01T09:00:00',
+  run_count: runs,
+});
+
+let mergeBodies: Array<Record<string, unknown>>;
+let mergeResult: Record<string, unknown>;
+let groups: unknown[];
+
+beforeEach(() => {
+  mergeBodies = [];
+  groups = [
+    { survivor: member(1, 'Planter', 2), merged: [member(4, 'Planter', 1), member(7, 'Planter', 0)], skipped: null },
+    { survivor: member(2, 'Bracket', 1), merged: [member(5, 'Bracket', 1)], skipped: null },
+    { survivor: member(3, 'Hook', 1), merged: [member(6, 'Hook', 1)], skipped: 'busy' },
+    { survivor: member(8, 'Lid', 1), merged: [member(9, 'Lid', 1)], skipped: 'conflicting_files' },
+  ];
+  mergeResult = { merged: [{ survivor_id: 1, merged_ids: [4, 7] }, { survivor_id: 2, merged_ids: [5] }], skipped: [] };
+  server.use(
+    http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups })),
+    http.post('*/api/v1/archives/merge', async ({ request }) => {
+      mergeBodies.push((await request.json()) as Record<string, unknown>);
+      return HttpResponse.json(mergeResult);
+    }),
+  );
+});
+
+describe('MergeDuplicatesModal', () => {
+  it('lists the groups, the archive that stays and the ones merging in', async () => {
+    render(<MergeDuplicatesModal onClose={vi.fn()} />);
+
+    const planter = await screen.findByTestId('merge-group-1');
+    expect(planter).toHaveTextContent('Planter');
+    expect(planter).toHaveTextContent('2 print(s)');
+    expect(planter.querySelectorAll('li')).toHaveLength(2);
+    expect(screen.getByRole('button', { name: 'Merge 2 group(s)' })).toBeEnabled();
+  });
+
+  it('shows the groups left out with their reason and no checkbox', async () => {
+    render(<MergeDuplicatesModal onClose={vi.fn()} />);
+
+    const hook = await screen.findByTestId('merge-group-3');
+    expect(hook).toHaveTextContent('In use: printing, queued or just printed');
+    expect(hook.querySelector('input[type="checkbox"]')).toBeNull();
+    expect(screen.getByTestId('merge-group-8')).toHaveTextContent('different source or design files');
+  });
+
+  it('merges only the ticked groups, after a confirm', async () => {
+    const user = userEvent.setup();
+    const onClose = vi.fn();
+    mergeResult = { merged: [{ survivor_id: 1, merged_ids: [4, 7] }], skipped: [] };
+    render(<MergeDuplicatesModal onClose={onClose} />);
+
+    await user.click(await screen.findByRole('checkbox', { name: 'Merge the archives of Bracket' }));
+    await user.click(screen.getByRole('button', { name: 'Merge 1 group(s)' }));
+    expect(mergeBodies).toEqual([]);
+    expect(screen.getByText(/1 group\(s\) will be merged/)).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'Yes, merge 1 group(s)' }));
+
+    await waitFor(() => expect(mergeBodies).toEqual([{ survivor_ids: [1] }]));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(await screen.findByText('Merged 1 group(s).')).toBeInTheDocument();
+  });
+
+  it('shows which groups the merge left out and why, and stays open', async () => {
+    const user = userEvent.setup();
+    const onClose = vi.fn();
+    mergeResult = { merged: [{ survivor_id: 1, merged_ids: [4, 7] }], skipped: [{ survivor_id: 2, reason: 'failed' }] };
+    render(<MergeDuplicatesModal onClose={onClose} />);
+
+    await user.click(await screen.findByRole('button', { name: 'Merge 2 group(s)' }));
+    await user.click(screen.getByRole('button', { name: 'Yes, merge 2 group(s)' }));
+
+    expect(await screen.findByText('Merged 1 group(s); 1 left out.')).toBeInTheDocument();
+    const report = await screen.findByTestId('merge-result');
+    expect(report).toHaveTextContent('Bracket');
+    expect(report).toHaveTextContent('The merge failed.');
+    expect(onClose).not.toHaveBeenCalled();
+    const closes = screen.getAllByRole('button', { name: 'Close' });
+    await user.click(closes[closes.length - 1]);
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('says so when there is nothing to merge', async () => {
+    groups = [];
+    render(<MergeDuplicatesModal onClose={vi.fn()} />);
+
+    expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Merge 0 group(s)' })).toBeDisabled();
+  });
+
+  it('cannot merge when every group is left out', async () => {
+    groups = [groups[2]];
+    render(<MergeDuplicatesModal onClose={vi.fn()} />);
+
+    await screen.findByTestId('merge-group-3');
+    expect(screen.getByRole('button', { name: 'Merge 0 group(s)' })).toBeDisabled();
+  });
+});

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

@@ -105,6 +105,15 @@ describe('ArchivesPage ?highlight=', () => {
     expect(await screen.findByText('Linked archive not visible - try clearing filters')).toBeInTheDocument();
   });
 
+  it('marks the archive a merged one went into (archive redesign P2)', async () => {
+    server.use(http.get('/api/v1/archives/31', () => HttpResponse.json(archive(2, 'Bracket'))));
+    window.history.pushState({}, '', '/archives?highlight=31');
+    render(<ArchivesPage />);
+
+    await waitFor(() => expect(card('Bracket').style.outline).toContain('solid'));
+    expect(screen.queryByText(/not visible - try clearing filters/)).not.toBeInTheDocument();
+  });
+
   it('looks for the archive in a fresh list, not one cached before it existed', async () => {
     beforeTheJobStarted = true;
     window.history.pushState({}, '', '/archives');

+ 47 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -652,6 +652,53 @@ describe('ArchivesPage', () => {
     });
   });
 
+  // Archive redesign P2: merging changes and removes whole archives, so the
+  // button is there only for those the merge API lets through.
+  describe('merge duplicates button', () => {
+    const signInWith = (permissions: string[]) => {
+      setAuthToken('merge-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('/api/v1/auth/me', () => HttpResponse.json({
+          id: 7,
+          username: 'operator',
+          is_active: true,
+          is_admin: false,
+          groups: [],
+          permissions,
+          created_at: '2026-08-18T00:00:00Z',
+        })),
+        http.get('*/api/v1/archives/merge-preview', () => HttpResponse.json({ groups: [] })),
+      );
+    };
+
+    it('opens the merge dialog for a user who may update and delete all archives', async () => {
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      render(<ArchivesPage />);
+
+      fireEvent.click(await screen.findByRole('button', { name: 'Merge duplicates' }));
+      expect(await screen.findByText('No duplicates to merge.')).toBeInTheDocument();
+    });
+
+    it('is offered in the Duplicates collection', async () => {
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_all']);
+      render(<ArchivesPage />);
+
+      await screen.findByRole('button', { name: 'Merge duplicates' });
+      expect(screen.queryByText(/can be merged into one, with every print of them/)).toBeNull();
+      fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'duplicates' } });
+      expect(await screen.findByText(/can be merged into one, with every print of them/)).toBeInTheDocument();
+    });
+
+    it('is not there for a user who may only update all archives', async () => {
+      signInWith(['archives:read_all', 'archives:update_all', 'archives:delete_own']);
+      render(<ArchivesPage />);
+
+      await screen.findByText('Archives');
+      await waitFor(() => expect(screen.queryByRole('button', { name: 'Merge duplicates' })).toBeNull());
+    });
+  });
+
   // #1898: the "outcome?" badge on a print still waiting for its verdict opens
   // the dialog. The PATCH behind it checks archives:update against the owner,
   // like the menu item beside it, so for anyone else the badge is a status only.

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

@@ -5747,6 +5747,13 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ older_than_days: olderThanDays, purge_stats: purgeStats }),
     }),
+  // Archives of one file merge into the oldest (archive redesign P2).
+  previewArchiveMerge: () => request<ArchiveMergePreview>('/archives/merge-preview'),
+  mergeArchives: (survivorIds: number[]) =>
+    request<ArchiveMergeResult>('/archives/merge', {
+      method: 'POST',
+      body: JSON.stringify({ survivor_ids: survivorIds }),
+    }),
   getArchivePurgeSettings: () =>
     request<ArchivePurgeSettings>('/archives/purge/settings'),
   updateArchivePurgeSettings: (body: ArchivePurgeSettings) =>
@@ -8846,6 +8853,32 @@ export interface ArchivePurgePreview {
   older_than_days: number;
 }
 
+export interface ArchiveMergeMember {
+  id: number;
+  print_name: string | null;
+  filename: string;
+  created_at: string | null;
+  run_count: number;
+}
+
+/** Why a group is left out: at work now, or holding files only one can keep. */
+export type ArchiveMergeSkip = 'busy' | 'conflicting_files' | 'failed' | 'not_a_group';
+
+export interface ArchiveMergeGroup {
+  survivor: ArchiveMergeMember;
+  merged: ArchiveMergeMember[];
+  skipped: ArchiveMergeSkip | null;
+}
+
+export interface ArchiveMergePreview {
+  groups: ArchiveMergeGroup[];
+}
+
+export interface ArchiveMergeResult {
+  merged: { survivor_id: number; merged_ids: number[] }[];
+  skipped: { survivor_id: number; reason: ArchiveMergeSkip }[];
+}
+
 export interface ArchivePurgeSettings {
   enabled: boolean;
   days: number;

+ 6 - 2
frontend/src/components/ConfirmOutcomeDialog.tsx

@@ -35,10 +35,13 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
     queryKey: ['archive', archiveId, runId ?? null],
     queryFn: () => api.getArchive(archiveId, runId),
   });
+  // The archive the answer goes to: a link to one merged into another
+  // (archive redesign P2) loads that one, and the merged id writes nothing.
+  const targetId = archive?.id ?? archiveId;
 
   const verdictMutation = useMutation({
     mutationFn: (data: { user_verdict: 'good' | 'reject'; failure_reason?: string }) =>
-      api.updateArchive(archiveId, {
+      api.updateArchive(targetId, {
         ...data,
         user_verdict_source: 'dialog',
         ...(runId != null ? { run_id: runId } : {}),
@@ -46,6 +49,7 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
     onSuccess: (_updated, variables) => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archive', archiveId] });
+      queryClient.invalidateQueries({ queryKey: ['archive', targetId] });
       showToast(
         variables.user_verdict === 'good'
           ? t('confirmOutcome.savedGood')
@@ -63,7 +67,7 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
   // The finish photo is prepended to archive.photos at print end (#1397);
   // fall back to the thumbnail when no camera shot exists.
   const photoFilename = archive?.photos?.[0] ?? null;
-  const photoUrl = photoFilename ? api.getArchivePhotoUrl(archiveId, photoFilename) : null;
+  const photoUrl = photoFilename ? api.getArchivePhotoUrl(targetId, photoFilename) : null;
   const alreadyDecided = archive?.user_verdict != null;
   // Why this print already has a verdict — the plate-clear default answers
   // prompts on its own, and without this the answer looks like it appeared

+ 251 - 0
frontend/src/components/MergeDuplicatesModal.tsx

@@ -0,0 +1,251 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, Combine, Loader2, X } from 'lucide-react';
+
+import { api } from '../api/client';
+import type { ArchiveMergeGroup, ArchiveMergeMember, ArchiveMergeResult } from '../api/client';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface MergeDuplicatesModalProps {
+  onClose: () => void;
+}
+
+function memberName(member: ArchiveMergeMember): string {
+  return member.print_name || member.filename;
+}
+
+/**
+ * "Merge duplicates" (archive redesign P2): the archives that hold the same
+ * file merge into the oldest. The server finds the groups and says which it
+ * leaves out for now; the user picks the groups, confirms, and gets a report.
+ */
+export function MergeDuplicatesModal({ onClose }: MergeDuplicatesModalProps) {
+  const { t, i18n } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const previewQuery = useQuery({
+    queryKey: ['archive-merge-preview'],
+    queryFn: () => api.previewArchiveMerge(),
+    // Each opening asks again: groups change as prints start and end. Not
+    // behind the user's back while it is open: what they confirm is what
+    // they saw.
+    staleTime: 0,
+    gcTime: 0,
+    refetchOnWindowFocus: false,
+  });
+  const groups = useMemo(() => previewQuery.data?.groups ?? [], [previewQuery.data]);
+  const mergeable = useMemo(() => groups.filter((g) => g.skipped === null), [groups]);
+  const waiting = useMemo(() => groups.filter((g) => g.skipped !== null), [groups]);
+
+  // Every mergeable group is ticked once the preview arrives.
+  const [unticked, setUnticked] = useState<Set<number>>(new Set());
+  const selected = mergeable.filter((g) => !unticked.has(g.survivor.id));
+  const [confirming, setConfirming] = useState(false);
+  // The groups the merge itself left out, and why, shown before closing.
+  const [result, setResult] = useState<ArchiveMergeResult | null>(null);
+
+  const mergeMutation = useMutation({
+    mutationFn: () => api.mergeArchives(selected.map((g) => g.survivor.id)),
+    onSuccess: (result: ArchiveMergeResult) => {
+      const merged = result.merged.length;
+      const left = result.skipped.length;
+      // Runs moved between archives: every list of archives and runs.
+      for (const key of ['archives', 'archive', 'archiveStats', 'archives-stats', 'print-log', 'archive-runs']) {
+        queryClient.invalidateQueries({ queryKey: [key] });
+      }
+      if (left === 0) {
+        showToast(t('archiveMerge.toast.merged', { count: merged }), 'success');
+        onClose();
+      } else {
+        showToast(t('archiveMerge.toast.mergedSome', { count: merged, left }), merged > 0 ? 'warning' : 'error');
+        setResult(result);
+      }
+    },
+    onError: (e: Error) => showToast(e.message || t('archiveMerge.toast.failed'), 'error'),
+  });
+
+  useEffect(() => {
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !mergeMutation.isPending) onClose();
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [onClose, mergeMutation.isPending]);
+
+  const toggle = (group: ArchiveMergeGroup) => {
+    setConfirming(false);
+    setUnticked((current) => {
+      const next = new Set(current);
+      if (next.has(group.survivor.id)) next.delete(group.survivor.id);
+      else next.add(group.survivor.id);
+      return next;
+    });
+  };
+
+  const formatDate = (iso: string | null) =>
+    iso ? new Date(iso.endsWith('Z') || iso.includes('+') ? iso : `${iso}Z`).toLocaleDateString(i18n.language) : '';
+
+  const renderGroup = (group: ArchiveMergeGroup) => {
+    const ticked = group.skipped === null && !unticked.has(group.survivor.id);
+    return (
+      <li
+        key={group.survivor.id}
+        className={`rounded border border-gray-200 dark:border-gray-700 p-3 ${group.skipped ? 'opacity-60' : ''}`}
+        data-testid={`merge-group-${group.survivor.id}`}
+      >
+        <label className={`flex gap-2 items-start ${group.skipped ? '' : 'cursor-pointer'}`}>
+          {group.skipped === null && (
+            <input
+              type="checkbox"
+              checked={ticked}
+              onChange={() => toggle(group)}
+              disabled={mergeMutation.isPending}
+              className="mt-1 shrink-0"
+              aria-label={t('archiveMerge.selectGroup', { name: memberName(group.survivor) })}
+            />
+          )}
+          <div className="min-w-0 flex-1">
+            <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
+              {memberName(group.survivor)}
+            </div>
+            <div className="text-xs text-gray-500 dark:text-gray-400">
+              {t('archiveMerge.keeps', {
+                date: formatDate(group.survivor.created_at),
+                count: group.survivor.run_count,
+              })}
+            </div>
+            <ul className="mt-1 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
+              {group.merged.map((member) => (
+                <li key={member.id} className="truncate">
+                  {t('archiveMerge.mergesIn', {
+                    name: memberName(member),
+                    date: formatDate(member.created_at),
+                    count: member.run_count,
+                  })}
+                </li>
+              ))}
+            </ul>
+            {group.skipped && (
+              <div className="mt-1 text-xs text-amber-700 dark:text-amber-400">
+                {t(`archiveMerge.skipped.${group.skipped}`)}
+              </div>
+            )}
+          </div>
+        </label>
+      </li>
+    );
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
+        <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
+            <Combine className="w-5 h-5" />
+            {t('archiveMerge.title')}
+          </h2>
+          <button
+            onClick={onClose}
+            className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+            aria-label={t('common.close')}
+            disabled={mergeMutation.isPending}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4 overflow-y-auto">
+          <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
+            <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
+              {t('archiveMerge.effectsTitle')}
+            </div>
+            <ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
+              <li>{t('archiveMerge.effect1')}</li>
+              <li>{t('archiveMerge.effect2')}</li>
+              <li>{t('archiveMerge.effect3')}</li>
+            </ul>
+          </div>
+
+          {result ? (
+            <div data-testid="merge-result">
+              <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
+                {t('archiveMerge.leftOutTitle')}
+              </div>
+              <ul className="space-y-2">
+                {result.skipped.map((skip) => {
+                  const group = groups.find((g) => g.survivor.id === skip.survivor_id);
+                  return (
+                    <li
+                      key={skip.survivor_id}
+                      className="rounded border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-900 dark:text-gray-100"
+                    >
+                      <div className="font-medium truncate">{group ? memberName(group.survivor) : `#${skip.survivor_id}`}</div>
+                      <div className="mt-1 text-xs text-amber-700 dark:text-amber-400">
+                        {t(`archiveMerge.skipped.${skip.reason}`)}
+                      </div>
+                    </li>
+                  );
+                })}
+              </ul>
+            </div>
+          ) : previewQuery.isLoading ? (
+            <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
+              <Loader2 className="w-4 h-4 animate-spin" /> {t('archiveMerge.previewLoading')}
+            </div>
+          ) : previewQuery.isError ? (
+            <div className="text-sm text-red-600 dark:text-red-400">
+              {(previewQuery.error as Error | null)?.message ?? t('archiveMerge.previewFailed')}
+            </div>
+          ) : groups.length === 0 ? (
+            <div className="text-sm text-gray-600 dark:text-gray-400">{t('archiveMerge.none')}</div>
+          ) : (
+            <>
+              {mergeable.length > 0 && <ul className="space-y-2">{mergeable.map(renderGroup)}</ul>}
+              {waiting.length > 0 && (
+                <div>
+                  <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
+                    {t('archiveMerge.waitingTitle')}
+                  </div>
+                  <ul className="space-y-2">{waiting.map(renderGroup)}</ul>
+                </div>
+              )}
+            </>
+          )}
+
+          {confirming && !result && (
+            <div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
+              <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
+              <span>{t('archiveMerge.confirmWarning', { count: selected.length })}</span>
+            </div>
+          )}
+        </div>
+
+        <div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
+          <Button variant="secondary" onClick={onClose} disabled={mergeMutation.isPending}>
+            {result ? t('common.close') : t('common.cancel')}
+          </Button>
+          {!result && (
+          <Button
+            disabled={selected.length === 0 || mergeMutation.isPending}
+            onClick={() => (confirming ? mergeMutation.mutate() : setConfirming(true))}
+          >
+            {mergeMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin mr-1" />
+                {t('archiveMerge.merging')}
+              </>
+            ) : confirming ? (
+              t('archiveMerge.confirmCta', { count: selected.length })
+            ) : (
+              t('archiveMerge.mergeCta', { count: selected.length })
+            )}
+          </Button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -8123,6 +8123,39 @@ export default {
     includeNeverPrinted: 'Dateien einbeziehen, die nie gedruckt wurden',
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
   },
+  archiveMerge: {
+    headerButton: 'Duplikate zusammenführen',
+    headerTooltip: 'Archive derselben Datei zu einem zusammenführen',
+    duplicatesHint: 'Archive derselben Datei, desselben Besitzers und Projekts lassen sich mit allen ihren Drucken zu einem zusammenführen.',
+    title: 'Doppelte Archive zusammenführen',
+    effectsTitle: 'Was beim Zusammenführen passiert',
+    effect1: 'Das älteste Archiv jeder Gruppe bleibt und übernimmt alle Drucke der anderen mit ihren Fotos, Videos und Dateien.',
+    effect2: 'Die anderen verschwinden aus den Archiven; ihre alten Links und Lesezeichen öffnen das Archiv, in das sie eingegangen sind.',
+    effect3: 'Wallet-Buchungen und Budget-Reservierungen bleiben, wo sie gebucht wurden.',
+    previewLoading: 'Suche nach Archiven derselben Datei…',
+    previewFailed: 'Duplikate konnten nicht gesucht werden.',
+    none: 'Keine Duplikate zum Zusammenführen.',
+    selectGroup: 'Archive von {{name}} zusammenführen',
+    keeps: 'Bleibt · erstellt {{date}} · {{count}} Druck(e)',
+    mergesIn: '{{name}} · erstellt {{date}} · {{count}} Druck(e)',
+    leftOutTitle: 'Beim Zusammenführen ausgelassen',
+    waitingTitle: 'Vorerst ausgelassen',
+    mergeCta: '{{count}} Gruppe(n) zusammenführen',
+    confirmCta: 'Ja, {{count}} Gruppe(n) zusammenführen',
+    confirmWarning: '{{count}} Gruppe(n) werden zusammengeführt. Das lässt sich hier nicht rückgängig machen.',
+    merging: 'Wird zusammengeführt…',
+    skipped: {
+      busy: 'In Verwendung: druckt, in der Warteschlange oder gerade gedruckt. Später erneut versuchen.',
+      conflicting_files: 'Die Archive enthalten unterschiedliche Quell- oder Konstruktionsdateien oder Videos. Zuerst von Hand eines behalten.',
+      failed: 'Das Zusammenführen ist fehlgeschlagen.',
+      not_a_group: 'Keine Gruppe von Duplikaten mehr.',
+    },
+    toast: {
+      merged: '{{count}} Gruppe(n) zusammengeführt.',
+      mergedSome: '{{count}} Gruppe(n) zusammengeführt; {{left}} ausgelassen.',
+      failed: 'Die Archive konnten nicht zusammengeführt werden.',
+    },
+  },
   archivePurge: {
     headerButton: 'Alte löschen',
     headerTooltip: 'Alte Archive in einem Rutsch löschen',

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

@@ -8176,6 +8176,39 @@ export default {
     includeNeverPrinted: 'Include files that have never been printed',
     saveFailed: 'Could not save auto-purge settings.',
   },
+  archiveMerge: {
+    headerButton: 'Merge duplicates',
+    headerTooltip: 'Merge the archives that hold the same file into one',
+    duplicatesHint: 'Archives of the same file, owner and project can be merged into one, with every print of them.',
+    title: 'Merge duplicate archives',
+    effectsTitle: 'What a merge does',
+    effect1: 'The oldest archive of each group stays and takes in every print of the others, with their photos, videos and files.',
+    effect2: 'The others disappear from the archives; their old links and bookmarks open the one they went into.',
+    effect3: 'Wallet charges and budget reservations stay where they were booked.',
+    previewLoading: 'Looking for archives of the same file…',
+    previewFailed: 'Could not look for duplicates.',
+    none: 'No duplicates to merge.',
+    selectGroup: 'Merge the archives of {{name}}',
+    keeps: 'Stays · created {{date}} · {{count}} print(s)',
+    mergesIn: '{{name}} · created {{date}} · {{count}} print(s)',
+    leftOutTitle: 'Left out by the merge',
+    waitingTitle: 'Left out for now',
+    mergeCta: 'Merge {{count}} group(s)',
+    confirmCta: 'Yes, merge {{count}} group(s)',
+    confirmWarning: '{{count}} group(s) will be merged. This cannot be undone from here.',
+    merging: 'Merging…',
+    skipped: {
+      busy: 'In use: printing, queued or just printed. Try again later.',
+      conflicting_files: 'The archives carry different source or design files, or videos. Keep one by hand first.',
+      failed: 'The merge failed.',
+      not_a_group: 'No longer a group of duplicates.',
+    },
+    toast: {
+      merged: 'Merged {{count}} group(s).',
+      mergedSome: 'Merged {{count}} group(s); {{left}} left out.',
+      failed: 'Could not merge the archives.',
+    },
+  },
   archivePurge: {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',

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

@@ -8131,6 +8131,39 @@ export default {
     includeNeverPrinted: 'Incluir los archivos que nunca se han impreso',
     saveFailed: 'No se pudieron guardar los ajustes de purga automática.',
   },
+  archiveMerge: {
+    headerButton: 'Fusionar duplicados',
+    headerTooltip: 'Fusionar en uno los archivos que contienen el mismo fichero',
+    duplicatesHint: 'Los archivos del mismo fichero, propietario y proyecto pueden fusionarse en uno, con todas sus impresiones.',
+    title: 'Fusionar archivos duplicados',
+    effectsTitle: 'Qué hace una fusión',
+    effect1: 'El archivo más antiguo de cada grupo se mantiene y recibe todas las impresiones de los demás, con sus fotos, vídeos y ficheros.',
+    effect2: 'Los demás desaparecen del archivo; sus enlaces y marcadores antiguos abren el archivo en el que se fusionaron.',
+    effect3: 'Los cargos del monedero y las reservas de presupuesto se quedan donde se registraron.',
+    previewLoading: 'Buscando archivos del mismo fichero…',
+    previewFailed: 'No se pudieron buscar duplicados.',
+    none: 'No hay duplicados que fusionar.',
+    selectGroup: 'Fusionar los archivos de {{name}}',
+    keeps: 'Se mantiene · creado {{date}} · {{count}} impresión(es)',
+    mergesIn: '{{name}} · creado {{date}} · {{count}} impresión(es)',
+    leftOutTitle: 'Omitidos al fusionar',
+    waitingTitle: 'Omitidos por ahora',
+    mergeCta: 'Fusionar {{count}} grupo(s)',
+    confirmCta: 'Sí, fusionar {{count}} grupo(s)',
+    confirmWarning: 'Se fusionarán {{count}} grupo(s). Esto no se puede deshacer desde aquí.',
+    merging: 'Fusionando…',
+    skipped: {
+      busy: 'En uso: imprimiendo, en cola o recién impreso. Inténtalo más tarde.',
+      conflicting_files: 'Los archivos tienen ficheros fuente o de diseño distintos, o vídeos. Conserva uno a mano primero.',
+      failed: 'La fusión falló.',
+      not_a_group: 'Ya no es un grupo de duplicados.',
+    },
+    toast: {
+      merged: '{{count}} grupo(s) fusionado(s).',
+      mergedSome: '{{count}} grupo(s) fusionado(s); {{left}} omitido(s).',
+      failed: 'No se pudieron fusionar los archivos.',
+    },
+  },
   archivePurge: {
     headerButton: 'Purgar antiguos',
     headerTooltip: 'Eliminar en bloque los archivos antiguos',

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

@@ -8109,6 +8109,39 @@ export default {
     includeNeverPrinted: 'Inclure les fichiers jamais imprimés',
     saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
   },
+  archiveMerge: {
+    headerButton: 'Fusionner les doublons',
+    headerTooltip: 'Fusionner en une seule les archives qui contiennent le même fichier',
+    duplicatesHint: "Les archives d'un même fichier, propriétaire et projet peuvent être fusionnées en une seule, avec toutes leurs impressions.",
+    title: 'Fusionner les archives en double',
+    effectsTitle: 'Ce que fait une fusion',
+    effect1: "L'archive la plus ancienne de chaque groupe est conservée et reprend toutes les impressions des autres, avec leurs photos, vidéos et fichiers.",
+    effect2: 'Les autres disparaissent des archives ; leurs anciens liens et favoris ouvrent celle dans laquelle elles ont été fusionnées.',
+    effect3: 'Les débits du portefeuille et les réservations de budget restent là où ils ont été enregistrés.',
+    previewLoading: 'Recherche des archives du même fichier…',
+    previewFailed: 'Impossible de rechercher les doublons.',
+    none: 'Aucun doublon à fusionner.',
+    selectGroup: 'Fusionner les archives de {{name}}',
+    keeps: 'Conservée · créée le {{date}} · {{count}} impression(s)',
+    mergesIn: '{{name}} · créée le {{date}} · {{count}} impression(s)',
+    leftOutTitle: 'Écartés lors de la fusion',
+    waitingTitle: "Écartés pour l'instant",
+    mergeCta: 'Fusionner {{count}} groupe(s)',
+    confirmCta: 'Oui, fusionner {{count}} groupe(s)',
+    confirmWarning: '{{count}} groupe(s) seront fusionnés. Cela ne peut pas être annulé ici.',
+    merging: 'Fusion en cours…',
+    skipped: {
+      busy: "En cours d'utilisation : impression, file d'attente ou impression récente. Réessayez plus tard.",
+      conflicting_files: "Les archives contiennent des fichiers source ou de conception différents, ou des vidéos. Gardez-en d'abord un à la main.",
+      failed: 'La fusion a échoué.',
+      not_a_group: "Ce n'est plus un groupe de doublons.",
+    },
+    toast: {
+      merged: '{{count}} groupe(s) fusionné(s).',
+      mergedSome: '{{count}} groupe(s) fusionné(s) ; {{left}} écarté(s).',
+      failed: 'Impossible de fusionner les archives.',
+    },
+  },
   archivePurge: {
     headerButton: 'Purger les anciens',
     headerTooltip: 'Suppression groupée d\'anciennes archives',

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

@@ -8108,6 +8108,39 @@ export default {
     includeNeverPrinted: 'Includi i file mai stampati',
     saveFailed: 'Impossibile salvare le impostazioni di eliminazione automatica.',
   },
+  archiveMerge: {
+    headerButton: 'Unisci duplicati',
+    headerTooltip: 'Unisci in uno gli archivi che contengono lo stesso file',
+    duplicatesHint: 'Gli archivi dello stesso file, proprietario e progetto possono essere uniti in uno, con tutte le loro stampe.',
+    title: 'Unisci archivi duplicati',
+    effectsTitle: "Cosa fa un'unione",
+    effect1: "L'archivio più vecchio di ogni gruppo resta e accoglie tutte le stampe degli altri, con foto, video e file.",
+    effect2: 'Gli altri scompaiono dagli archivi; i loro vecchi link e segnalibri aprono quello in cui sono confluiti.',
+    effect3: 'Gli addebiti del portafoglio e le prenotazioni di budget restano dove sono stati registrati.',
+    previewLoading: 'Ricerca degli archivi dello stesso file…',
+    previewFailed: 'Impossibile cercare i duplicati.',
+    none: 'Nessun duplicato da unire.',
+    selectGroup: 'Unisci gli archivi di {{name}}',
+    keeps: 'Resta · creato il {{date}} · {{count}} stampa/e',
+    mergesIn: '{{name}} · creato il {{date}} · {{count}} stampa/e',
+    leftOutTitle: "Esclusi dall'unione",
+    waitingTitle: 'Esclusi per ora',
+    mergeCta: 'Unisci {{count}} gruppo/i',
+    confirmCta: 'Sì, unisci {{count}} gruppo/i',
+    confirmWarning: 'Verranno uniti {{count}} gruppo/i. Non si può annullare da qui.',
+    merging: 'Unione in corso…',
+    skipped: {
+      busy: 'In uso: in stampa, in coda o appena stampato. Riprova più tardi.',
+      conflicting_files: 'Gli archivi contengono file sorgente o di progetto diversi, oppure video. Tienine prima uno a mano.',
+      failed: "L'unione non è riuscita.",
+      not_a_group: 'Non è più un gruppo di duplicati.',
+    },
+    toast: {
+      merged: '{{count}} gruppo/i uniti.',
+      mergedSome: '{{count}} gruppo/i uniti; {{left}} esclusi.',
+      failed: 'Impossibile unire gli archivi.',
+    },
+  },
   archivePurge: {
     headerButton: 'Elimina vecchi',
     headerTooltip: 'Eliminazione massiva vecchi archivi',

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

@@ -8122,6 +8122,39 @@ export default {
     includeNeverPrinted: '一度も印刷していないファイルも含める',
     saveFailed: '自動削除の設定を保存できませんでした。',
   },
+  archiveMerge: {
+    headerButton: '重複を統合',
+    headerTooltip: '同じファイルのアーカイブを1つに統合します',
+    duplicatesHint: '同じファイル・所有者・プロジェクトのアーカイブは、すべての印刷とともに1つに統合できます。',
+    title: '重複アーカイブの統合',
+    effectsTitle: '統合で行われること',
+    effect1: '各グループの最も古いアーカイブが残り、他のアーカイブのすべての印刷を写真・動画・ファイルとともに引き継ぎます。',
+    effect2: '他のアーカイブは一覧から消えます。古いリンクやブックマークは統合先のアーカイブを開きます。',
+    effect3: 'ウォレットの請求と予算の予約は記録された場所に残ります。',
+    previewLoading: '同じファイルのアーカイブを検索中…',
+    previewFailed: '重複を検索できませんでした。',
+    none: '統合する重複はありません。',
+    selectGroup: '{{name}} のアーカイブを統合',
+    keeps: '残す · 作成日 {{date}} · 印刷 {{count}} 回',
+    mergesIn: '{{name}} · 作成日 {{date}} · 印刷 {{count}} 回',
+    leftOutTitle: '統合で対象外になったもの',
+    waitingTitle: '今回は対象外',
+    mergeCta: '{{count}} グループを統合',
+    confirmCta: 'はい、{{count}} グループを統合',
+    confirmWarning: '{{count}} グループが統合されます。ここから元に戻すことはできません。',
+    merging: '統合中…',
+    skipped: {
+      busy: '使用中です(印刷中、キュー内、または印刷直後)。後でもう一度お試しください。',
+      conflicting_files: 'ソースファイル、設計ファイル、または動画が異なります。先に手動でどれかを残してください。',
+      failed: '統合に失敗しました。',
+      not_a_group: '重複のグループではなくなりました。',
+    },
+    toast: {
+      merged: '{{count}} グループを統合しました。',
+      mergedSome: '{{count}} グループを統合しました。{{left}} グループは対象外です。',
+      failed: 'アーカイブを統合できませんでした。',
+    },
+  },
   archivePurge: {
     headerButton: '古いものを削除',
     headerTooltip: '古いアーカイブを一括削除',

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

@@ -7565,6 +7565,39 @@ export default {
     includeNeverPrinted: '한 번도 인쇄되지 않은 파일 포함',
     saveFailed: '자동 삭제 설정을 저장할 수 없습니다.'
   },
+  archiveMerge: {
+    headerButton: '중복 병합',
+    headerTooltip: '같은 파일의 아카이브를 하나로 병합합니다',
+    duplicatesHint: '같은 파일, 소유자, 프로젝트의 아카이브는 모든 출력과 함께 하나로 병합할 수 있습니다.',
+    title: '중복 아카이브 병합',
+    effectsTitle: '병합 시 일어나는 일',
+    effect1: '각 그룹에서 가장 오래된 아카이브가 남고 다른 아카이브의 모든 출력을 사진, 동영상, 파일과 함께 넘겨받습니다.',
+    effect2: '나머지는 아카이브에서 사라지며, 이전 링크와 북마크는 병합된 아카이브를 엽니다.',
+    effect3: '지갑 청구와 예산 예약은 기록된 곳에 그대로 남습니다.',
+    previewLoading: '같은 파일의 아카이브를 찾는 중…',
+    previewFailed: '중복을 찾을 수 없습니다.',
+    none: '병합할 중복이 없습니다.',
+    selectGroup: '{{name}}의 아카이브 병합',
+    keeps: '유지 · {{date}} 생성 · 출력 {{count}}회',
+    mergesIn: '{{name}} · {{date}} 생성 · 출력 {{count}}회',
+    leftOutTitle: '병합에서 제외됨',
+    waitingTitle: '이번에는 제외됨',
+    mergeCta: '{{count}}개 그룹 병합',
+    confirmCta: '예, {{count}}개 그룹 병합',
+    confirmWarning: '{{count}}개 그룹이 병합됩니다. 여기서 되돌릴 수 없습니다.',
+    merging: '병합 중…',
+    skipped: {
+      busy: '사용 중: 출력 중, 대기열에 있음 또는 방금 출력됨. 나중에 다시 시도하세요.',
+      conflicting_files: '아카이브의 소스 또는 설계 파일, 또는 동영상이 서로 다릅니다. 먼저 하나를 직접 남기세요.',
+      failed: '병합에 실패했습니다.',
+      not_a_group: '더 이상 중복 그룹이 아닙니다.',
+    },
+    toast: {
+      merged: '{{count}}개 그룹을 병합했습니다.',
+      mergedSome: '{{count}}개 그룹을 병합했습니다. {{left}}개는 제외되었습니다.',
+      failed: '아카이브를 병합할 수 없습니다.',
+    },
+  },
   archivePurge: {
     headerButton: '오래된 것 삭제',
     headerTooltip: '오래된 아카이브 일괄 삭제',

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

@@ -8174,6 +8174,39 @@ export default {
     includeNeverPrinted: 'Bestanden opnemen die nooit zijn afgedrukt',
     saveFailed: 'Instellingen voor automatisch opschonen konden niet worden opgeslagen.',
   },
+  archiveMerge: {
+    headerButton: 'Duplicaten samenvoegen',
+    headerTooltip: 'Archieven met hetzelfde bestand samenvoegen tot één',
+    duplicatesHint: 'Archieven van hetzelfde bestand, dezelfde eigenaar en hetzelfde project kunnen met al hun prints tot één worden samengevoegd.',
+    title: 'Dubbele archieven samenvoegen',
+    effectsTitle: 'Wat samenvoegen doet',
+    effect1: "Het oudste archief van elke groep blijft en neemt alle prints van de andere over, met hun foto's, video's en bestanden.",
+    effect2: 'De andere verdwijnen uit de archieven; hun oude links en bladwijzers openen het archief waarin ze zijn opgegaan.',
+    effect3: 'Walletafschrijvingen en budgetreserveringen blijven waar ze geboekt zijn.',
+    previewLoading: 'Archieven van hetzelfde bestand zoeken…',
+    previewFailed: 'Kon niet naar duplicaten zoeken.',
+    none: 'Geen duplicaten om samen te voegen.',
+    selectGroup: 'De archieven van {{name}} samenvoegen',
+    keeps: 'Blijft · aangemaakt {{date}} · {{count}} print(s)',
+    mergesIn: '{{name}} · aangemaakt {{date}} · {{count}} print(s)',
+    leftOutTitle: 'Overgeslagen bij het samenvoegen',
+    waitingTitle: 'Voorlopig overgeslagen',
+    mergeCta: '{{count}} groep(en) samenvoegen',
+    confirmCta: 'Ja, {{count}} groep(en) samenvoegen',
+    confirmWarning: '{{count}} groep(en) worden samengevoegd. Dit kan hier niet ongedaan worden gemaakt.',
+    merging: 'Bezig met samenvoegen…',
+    skipped: {
+      busy: 'In gebruik: aan het printen, in de wachtrij of net geprint. Probeer het later opnieuw.',
+      conflicting_files: "De archieven bevatten verschillende bron- of ontwerpbestanden, of video's. Houd er eerst zelf één.",
+      failed: 'Samenvoegen is mislukt.',
+      not_a_group: 'Geen groep duplicaten meer.',
+    },
+    toast: {
+      merged: '{{count}} groep(en) samengevoegd.',
+      mergedSome: '{{count}} groep(en) samengevoegd; {{left}} overgeslagen.',
+      failed: 'Kon de archieven niet samenvoegen.',
+    },
+  },
   archivePurge: {
     headerButton: 'Oude opschonen',
     headerTooltip: 'Oude archieven in bulk verwijderen',

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

@@ -8108,6 +8108,39 @@ export default {
     includeNeverPrinted: 'Incluir arquivos que nunca foram impressos',
     saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
   },
+  archiveMerge: {
+    headerButton: 'Mesclar duplicados',
+    headerTooltip: 'Mesclar em um os arquivos que contêm o mesmo arquivo',
+    duplicatesHint: 'Arquivos do mesmo arquivo, dono e projeto podem ser mesclados em um, com todas as suas impressões.',
+    title: 'Mesclar arquivos duplicados',
+    effectsTitle: 'O que uma mesclagem faz',
+    effect1: 'O arquivo mais antigo de cada grupo fica e recebe todas as impressões dos outros, com fotos, vídeos e arquivos.',
+    effect2: 'Os outros somem dos arquivos; seus links e favoritos antigos abrem aquele em que foram mesclados.',
+    effect3: 'Cobranças da carteira e reservas de orçamento ficam onde foram lançadas.',
+    previewLoading: 'Procurando arquivos do mesmo arquivo…',
+    previewFailed: 'Não foi possível procurar duplicados.',
+    none: 'Nenhum duplicado para mesclar.',
+    selectGroup: 'Mesclar os arquivos de {{name}}',
+    keeps: 'Fica · criado em {{date}} · {{count}} impressão(ões)',
+    mergesIn: '{{name}} · criado em {{date}} · {{count}} impressão(ões)',
+    leftOutTitle: 'Deixados de fora na mesclagem',
+    waitingTitle: 'Deixados de fora por enquanto',
+    mergeCta: 'Mesclar {{count}} grupo(s)',
+    confirmCta: 'Sim, mesclar {{count}} grupo(s)',
+    confirmWarning: '{{count}} grupo(s) serão mesclados. Isso não pode ser desfeito aqui.',
+    merging: 'Mesclando…',
+    skipped: {
+      busy: 'Em uso: imprimindo, na fila ou recém-impresso. Tente novamente mais tarde.',
+      conflicting_files: 'Os arquivos têm arquivos-fonte ou de projeto diferentes, ou vídeos. Mantenha um manualmente primeiro.',
+      failed: 'A mesclagem falhou.',
+      not_a_group: 'Não é mais um grupo de duplicados.',
+    },
+    toast: {
+      merged: '{{count}} grupo(s) mesclado(s).',
+      mergedSome: '{{count}} grupo(s) mesclado(s); {{left}} deixado(s) de fora.',
+      failed: 'Não foi possível mesclar os arquivos.',
+    },
+  },
   archivePurge: {
     headerButton: 'Limpar antigos',
     headerTooltip: 'Excluir arquivos antigos em massa',

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

@@ -7749,6 +7749,39 @@ export default {
     includeNeverPrinted: "Включить файлы, которые ни разу не печатались",
     saveFailed: "Не удалось сохранить настройки автоочистки.",
   },
+  archiveMerge: {
+    headerButton: 'Объединить дубликаты',
+    headerTooltip: 'Объединить архивы с одним и тем же файлом в один',
+    duplicatesHint: 'Архивы одного файла, владельца и проекта можно объединить в один вместе со всеми их печатями.',
+    title: 'Объединение дубликатов архивов',
+    effectsTitle: 'Что делает объединение',
+    effect1: 'Самый старый архив каждой группы остаётся и принимает все печати остальных вместе с их фото, видео и файлами.',
+    effect2: 'Остальные исчезают из архивов; их старые ссылки и закладки открывают архив, в который они вошли.',
+    effect3: 'Списания кошелька и резервы бюджета остаются там, где были проведены.',
+    previewLoading: 'Поиск архивов того же файла…',
+    previewFailed: 'Не удалось найти дубликаты.',
+    none: 'Нет дубликатов для объединения.',
+    selectGroup: 'Объединить архивы {{name}}',
+    keeps: 'Остаётся · создан {{date}} · печатей: {{count}}',
+    mergesIn: '{{name}} · создан {{date}} · печатей: {{count}}',
+    leftOutTitle: 'Пропущено при объединении',
+    waitingTitle: 'Пока пропущены',
+    mergeCta: 'Объединить групп: {{count}}',
+    confirmCta: 'Да, объединить групп: {{count}}',
+    confirmWarning: 'Будет объединено групп: {{count}}. Здесь это нельзя отменить.',
+    merging: 'Объединение…',
+    skipped: {
+      busy: 'Используется: печатается, в очереди или только что напечатан. Попробуйте позже.',
+      conflicting_files: 'В архивах разные исходные или проектные файлы либо видео. Сначала оставьте один вручную.',
+      failed: 'Не удалось объединить.',
+      not_a_group: 'Больше не группа дубликатов.',
+    },
+    toast: {
+      merged: 'Объединено групп: {{count}}.',
+      mergedSome: 'Объединено групп: {{count}}; пропущено: {{left}}.',
+      failed: 'Не удалось объединить архивы.',
+    },
+  },
   archivePurge: {
     headerButton: "Очистить старые",
     headerTooltip: "Массово удалить старые записи архива",

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

@@ -8172,6 +8172,39 @@ errors: {
     includeNeverPrinted: 'Inkludera filer som aldrig skrivits ut',
     saveFailed: 'Kunde inte spara inställningar för auto-rensning.',
   },
+  archiveMerge: {
+    headerButton: 'Slå ihop dubbletter',
+    headerTooltip: 'Slå ihop arkiv med samma fil till ett',
+    duplicatesHint: 'Arkiv med samma fil, ägare och projekt kan slås ihop till ett, med alla deras utskrifter.',
+    title: 'Slå ihop dubblettarkiv',
+    effectsTitle: 'Vad en sammanslagning gör',
+    effect1: 'Det äldsta arkivet i varje grupp blir kvar och tar över alla utskrifter från de andra, med deras foton, videor och filer.',
+    effect2: 'De andra försvinner från arkiven; deras gamla länkar och bokmärken öppnar arkivet de gick in i.',
+    effect3: 'Plånboksdebiteringar och budgetreservationer stannar där de bokades.',
+    previewLoading: 'Letar efter arkiv med samma fil…',
+    previewFailed: 'Kunde inte leta efter dubbletter.',
+    none: 'Inga dubbletter att slå ihop.',
+    selectGroup: 'Slå ihop arkiven för {{name}}',
+    keeps: 'Blir kvar · skapad {{date}} · {{count}} utskrift(er)',
+    mergesIn: '{{name}} · skapad {{date}} · {{count}} utskrift(er)',
+    leftOutTitle: 'Utelämnade vid sammanslagningen',
+    waitingTitle: 'Utelämnade för tillfället',
+    mergeCta: 'Slå ihop {{count}} grupp(er)',
+    confirmCta: 'Ja, slå ihop {{count}} grupp(er)',
+    confirmWarning: '{{count}} grupp(er) slås ihop. Det går inte att ångra härifrån.',
+    merging: 'Slår ihop…',
+    skipped: {
+      busy: 'Används: skriver ut, i kö eller nyss utskriven. Försök igen senare.',
+      conflicting_files: 'Arkiven har olika käll- eller designfiler, eller videor. Behåll en för hand först.',
+      failed: 'Sammanslagningen misslyckades.',
+      not_a_group: 'Inte längre en grupp dubbletter.',
+    },
+    toast: {
+      merged: '{{count}} grupp(er) sammanslagna.',
+      mergedSome: '{{count}} grupp(er) sammanslagna; {{left}} utelämnade.',
+      failed: 'Kunde inte slå ihop arkiven.',
+    },
+  },
   archivePurge: {
     headerButton: 'Rensa gamla',
     headerTooltip: 'Massradera gamla arkiv',

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

@@ -8062,6 +8062,39 @@ export default {
     includeNeverPrinted: 'Hiç yazdırılmamış dosyaları dahil et',
     saveFailed: 'Otomatik temizleme ayarları kaydedilemedi.',
   },
+  archiveMerge: {
+    headerButton: 'Yinelenenleri birleştir',
+    headerTooltip: 'Aynı dosyayı içeren arşivleri tek arşivde birleştir',
+    duplicatesHint: 'Aynı dosya, sahip ve projeye ait arşivler tüm baskılarıyla birlikte tek arşivde birleştirilebilir.',
+    title: 'Yinelenen arşivleri birleştir',
+    effectsTitle: 'Birleştirme ne yapar',
+    effect1: 'Her gruptaki en eski arşiv kalır ve diğerlerinin tüm baskılarını fotoğraf, video ve dosyalarıyla birlikte devralır.',
+    effect2: 'Diğerleri arşivlerden kaybolur; eski bağlantıları ve yer imleri birleştirildikleri arşivi açar.',
+    effect3: 'Cüzdan tahsilatları ve bütçe rezervasyonları kaydedildikleri yerde kalır.',
+    previewLoading: 'Aynı dosyanın arşivleri aranıyor…',
+    previewFailed: 'Yinelenenler aranamadı.',
+    none: 'Birleştirilecek yinelenen yok.',
+    selectGroup: '{{name}} arşivlerini birleştir',
+    keeps: 'Kalır · oluşturma {{date}} · {{count}} baskı',
+    mergesIn: '{{name}} · oluşturma {{date}} · {{count}} baskı',
+    leftOutTitle: 'Birleştirmede dışarıda bırakılanlar',
+    waitingTitle: 'Şimdilik dışarıda bırakıldı',
+    mergeCta: '{{count}} grubu birleştir',
+    confirmCta: 'Evet, {{count}} grubu birleştir',
+    confirmWarning: '{{count}} grup birleştirilecek. Bu işlem buradan geri alınamaz.',
+    merging: 'Birleştiriliyor…',
+    skipped: {
+      busy: 'Kullanımda: yazdırılıyor, kuyrukta veya yeni yazdırıldı. Daha sonra tekrar deneyin.',
+      conflicting_files: 'Arşivlerde farklı kaynak veya tasarım dosyaları ya da videolar var. Önce birini elle bırakın.',
+      failed: 'Birleştirme başarısız oldu.',
+      not_a_group: 'Artık bir yinelenen grubu değil.',
+    },
+    toast: {
+      merged: '{{count}} grup birleştirildi.',
+      mergedSome: '{{count}} grup birleştirildi; {{left}} dışarıda bırakıldı.',
+      failed: 'Arşivler birleştirilemedi.',
+    },
+  },
   archivePurge: {
     headerButton: 'Eskileri temizle',
     headerTooltip: 'Eski arşivleri toplu olarak sil',

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

@@ -8164,6 +8164,39 @@ export default {
     includeNeverPrinted: "Включати файли, які ніколи не друкувались",
     saveFailed: "Не вдалося зберегти налаштування автоматичного очищення.",
   },
+  archiveMerge: {
+    headerButton: "Об'єднати дублікати",
+    headerTooltip: "Об'єднати архіви з тим самим файлом в один",
+    duplicatesHint: "Архіви одного файлу, власника й проєкту можна об'єднати в один разом з усіма їхніми друками.",
+    title: "Об'єднання дублікатів архівів",
+    effectsTitle: "Що робить об'єднання",
+    effect1: 'Найстаріший архів кожної групи залишається й приймає всі друки інших разом з їхніми фото, відео та файлами.',
+    effect2: 'Інші зникають з архівів; їхні старі посилання й закладки відкривають архів, до якого вони увійшли.',
+    effect3: 'Списання гаманця й резерви бюджету залишаються там, де їх проведено.',
+    previewLoading: 'Пошук архівів того самого файлу…',
+    previewFailed: 'Не вдалося знайти дублікати.',
+    none: "Немає дублікатів для об'єднання.",
+    selectGroup: "Об'єднати архіви {{name}}",
+    keeps: 'Залишається · створено {{date}} · друків: {{count}}',
+    mergesIn: '{{name}} · створено {{date}} · друків: {{count}}',
+    leftOutTitle: "Пропущено під час об'єднання",
+    waitingTitle: 'Поки пропущено',
+    mergeCta: "Об'єднати груп: {{count}}",
+    confirmCta: "Так, об'єднати груп: {{count}}",
+    confirmWarning: "Буде об'єднано груп: {{count}}. Тут це не можна скасувати.",
+    merging: "Об'єднання…",
+    skipped: {
+      busy: 'Використовується: друкується, у черзі або щойно надруковано. Спробуйте пізніше.',
+      conflicting_files: 'В архівах різні вихідні чи проєктні файли або відео. Спершу залиште один вручну.',
+      failed: "Не вдалося об'єднати.",
+      not_a_group: 'Більше не група дублікатів.',
+    },
+    toast: {
+      merged: "Об'єднано груп: {{count}}.",
+      mergedSome: "Об'єднано груп: {{count}}; пропущено: {{left}}.",
+      failed: "Не вдалося об'єднати архіви.",
+    },
+  },
   archivePurge: {
     headerButton: "Очищення старого",
     headerTooltip: "Масове видалення старих архівів",

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

@@ -8106,6 +8106,39 @@ export default {
     includeNeverPrinted: '包括从未打印过的文件',
     saveFailed: '无法保存自动清理设置。',
   },
+  archiveMerge: {
+    headerButton: '合并重复项',
+    headerTooltip: '将包含同一文件的归档合并为一个',
+    duplicatesHint: '同一文件、所有者和项目的归档可以连同其所有打印合并为一个。',
+    title: '合并重复归档',
+    effectsTitle: '合并会做什么',
+    effect1: '每组中最早的归档会保留,并接收其他归档的所有打印及其照片、视频和文件。',
+    effect2: '其他归档会从列表中消失;它们的旧链接和书签会打开合并后的归档。',
+    effect3: '钱包扣费和预算预留保留在原来记账的位置。',
+    previewLoading: '正在查找同一文件的归档…',
+    previewFailed: '无法查找重复项。',
+    none: '没有可合并的重复项。',
+    selectGroup: '合并 {{name}} 的归档',
+    keeps: '保留 · 创建于 {{date}} · 打印 {{count}} 次',
+    mergesIn: '{{name}} · 创建于 {{date}} · 打印 {{count}} 次',
+    leftOutTitle: '合并时未处理',
+    waitingTitle: '暂不处理',
+    mergeCta: '合并 {{count}} 组',
+    confirmCta: '是,合并 {{count}} 组',
+    confirmWarning: '将合并 {{count}} 组。此操作无法在此撤销。',
+    merging: '正在合并…',
+    skipped: {
+      busy: '正在使用:打印中、排队中或刚打印完。请稍后再试。',
+      conflicting_files: '这些归档的源文件、设计文件或视频不同。请先手动保留其中一个。',
+      failed: '合并失败。',
+      not_a_group: '已不再是一组重复项。',
+    },
+    toast: {
+      merged: '已合并 {{count}} 组。',
+      mergedSome: '已合并 {{count}} 组;{{left}} 组未处理。',
+      failed: '无法合并归档。',
+    },
+  },
   archivePurge: {
     headerButton: '清除旧条目',
     headerTooltip: '批量删除旧归档',

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

@@ -8106,6 +8106,39 @@ export default {
     includeNeverPrinted: '包括從未列印過的檔案',
     saveFailed: '無法儲存自動清理設定。',
   },
+  archiveMerge: {
+    headerButton: '合併重複項目',
+    headerTooltip: '將包含同一檔案的封存合併為一個',
+    duplicatesHint: '同一檔案、擁有者與專案的封存可以連同其所有列印合併為一個。',
+    title: '合併重複封存',
+    effectsTitle: '合併會做什麼',
+    effect1: '每組中最早的封存會保留,並接收其他封存的所有列印及其照片、影片與檔案。',
+    effect2: '其他封存會從列表中消失;它們的舊連結與書籤會開啟合併後的封存。',
+    effect3: '錢包扣款與預算保留會留在原本記帳的位置。',
+    previewLoading: '正在尋找同一檔案的封存…',
+    previewFailed: '無法尋找重複項目。',
+    none: '沒有可合併的重複項目。',
+    selectGroup: '合併 {{name}} 的封存',
+    keeps: '保留 · 建立於 {{date}} · 列印 {{count}} 次',
+    mergesIn: '{{name}} · 建立於 {{date}} · 列印 {{count}} 次',
+    leftOutTitle: '合併時未處理',
+    waitingTitle: '暫不處理',
+    mergeCta: '合併 {{count}} 組',
+    confirmCta: '是,合併 {{count}} 組',
+    confirmWarning: '將合併 {{count}} 組。此操作無法在此復原。',
+    merging: '正在合併…',
+    skipped: {
+      busy: '使用中:列印中、排隊中或剛列印完。請稍後再試。',
+      conflicting_files: '這些封存的原始檔、設計檔或影片不同。請先手動保留其中一個。',
+      failed: '合併失敗。',
+      not_a_group: '已不再是一組重複項目。',
+    },
+    toast: {
+      merged: '已合併 {{count}} 組。',
+      mergedSome: '已合併 {{count}} 組;{{left}} 組未處理。',
+      failed: '無法合併封存。',
+    },
+  },
   archivePurge: {
     headerButton: '清除舊條目',
     headerTooltip: '批次刪除舊歸檔',

+ 44 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -17,6 +17,7 @@ import {
   Upload,
   ExternalLink,
   CheckSquare,
+  Combine,
   Square,
   X,
   Globe,
@@ -84,6 +85,7 @@ import { PrintModal } from '../components/PrintModal';
 import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 import { UploadModal } from '../components/UploadModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
+import { MergeDuplicatesModal } from '../components/MergeDuplicatesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal, FAILURE_REASON_KEYS } from '../components/EditArchiveModal';
 import { PrintLogModal } from '../components/PrintLogModal';
@@ -3141,6 +3143,9 @@ export function ArchivesPage() {
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
+  const [showMergeModal, setShowMergeModal] = useState(false);
+  // What the merge API asks for: it changes and removes whole archives.
+  const canMergeDuplicates = hasPermission('archives:update_all') && hasPermission('archives:delete_all');
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   // An archive navigated to from elsewhere, and the warning shown when the
   // current filters hide it.
@@ -3242,8 +3247,22 @@ export function ArchivesPage() {
     if (!highlightRequest || !archives || archivesUpdatedAt < highlightRequest.requestedAt) return;
     setHighlightRequest(null);
     if (viewMode === 'log' || viewMode === 'calendar') setViewMode('grid');
-    setPendingNavigation({ archiveId: highlightRequest.archiveId, notVisibleKey: 'archives.linkedArchiveNotVisible' });
-    setHighlightedArchiveId(highlightRequest.archiveId);
+    const highlight = (archiveId: number) => {
+      setPendingNavigation({ archiveId, notVisibleKey: 'archives.linkedArchiveNotVisible' });
+      setHighlightedArchiveId(archiveId);
+    };
+    if (archives.some((a) => a.id === highlightRequest.archiveId)) {
+      highlight(highlightRequest.archiveId);
+      return;
+    }
+    // Not listed: maybe merged into another archive (archive redesign P2),
+    // which the archive's own lookup answers with. Not cancelled when this
+    // effect re-runs: clearing the request above re-runs it.
+    const requested = highlightRequest.archiveId;
+    api
+      .getArchive(requested)
+      .then((found) => highlight(found.id))
+      .catch(() => highlight(requested));
   }, [highlightRequest, archives, archivesUpdatedAt, viewMode]);
 
   const { data: printers } = useQuery({
@@ -3968,6 +3987,16 @@ export function ArchivesPage() {
         </div>
       )}
 
+      {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">
+          <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.duplicatesHint')}</div>
+          <Button variant="secondary" size="sm" onClick={() => setShowMergeModal(true)}>
+            {t('archiveMerge.headerButton')}
+          </Button>
+        </div>
+      )}
+
       {no3MFWarning?.has_fallback && !no3MFWarningDismissed && (
         <div className="mb-6 rounded-lg border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 flex items-start gap-3">
           <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
@@ -4138,6 +4167,16 @@ export function ArchivesPage() {
             <Upload className="w-4 h-4" />
             Upload 3MF
           </Button>
+          {canMergeDuplicates && (
+            <Button
+              variant="secondary"
+              onClick={() => setShowMergeModal(true)}
+              title={t('archiveMerge.headerTooltip')}
+            >
+              <Combine className="w-4 h-4 mr-2" />
+              {t('archiveMerge.headerButton')}
+            </Button>
+          )}
           {hasPermission('archives:purge') && (
             <Button
               variant="secondary"
@@ -4768,6 +4807,9 @@ export function ArchivesPage() {
         />
       )}
 
+      {/* Merge duplicate archives (archive redesign P2) */}
+      {showMergeModal && <MergeDuplicatesModal onClose={() => setShowMergeModal(false)} />}
+
       {/* Archive bulk-purge modal (#1008 follow-up) */}
       {showPurgeModal && (
         <PurgeArchivesModal onClose={() => setShowPurgeModal(false)} />

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


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


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


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


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


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


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


+ 1 - 1
static/assets/Button-DYYZvzLo.js → static/assets/Button-CzgwmHYz.js

@@ -1 +1 @@
-import{w as e}from"./client-uq_Z1wxL.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-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};

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


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


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


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


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

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.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-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};

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


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


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


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


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


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


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


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


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


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


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


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


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


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

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.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-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};

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.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-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};

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

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-uq_Z1wxL.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-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};

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


ファイルの差分が大きいため隠しています
+ 1 - 1
static/assets/SpoolBuddySettingsPage-6VlOZmQI.js


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


+ 1 - 1
static/assets/SpoolIcon-BBThOs9Y.js → static/assets/SpoolIcon-D0RZd795.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.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-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};

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


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


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


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


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


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


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


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