Преглед изворни кода

Archives: the export has one row per print (archive redesign P3)

- One row per print of an archive: the file's columns, then that
  print's own status, printer, plate, times, duration, filament, cost
  and outcome; new columns Print ID, Printer, Plate, Outcome, Duration
- A print logged before runs keeps its archive's row; a print running
  now is a row of its own
- Printer, status and date filters pick prints, several statuses at
  once; the Failed collection exports failed and aborted prints
- Only the export's own columns can be asked for
maziggy пре 1 дан
родитељ
комит
ff5e1f9bf8

+ 2 - 1
CHANGELOG.md

@@ -175,7 +175,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 ### Changed
-- **A file printed again adds a run to its archive instead of a duplicate** — Every print of a File Manager or project file from the queue archived the file anew, so a two-plate file printed plate by plate showed two archives of the same file, marked as duplicates of each other. The print now joins the archive the file already has, as a run in its Print Log, when it is the same file, printed by the same user, in the same project, and the archive came from the same File Manager file or from none. The card shows the latest run, with that plate's picture, and the Print Log has a **Plate** column once a run printed a plate other than plate 1; older runs get their plate where the queue still knows it. An archive holds one print at a time, so a print of the file that runs while another is queued, printing, or still having its timelapse fetched gets its own archive. Prints started from the slicer or on the printer, uploads, and existing duplicates are unchanged.
+- **A file printed again adds a run to its archive instead of a duplicate** — Every print of a File Manager or project file from the queue archived the file anew, so a two-plate file printed plate by plate showed two archives of the same file, marked as duplicates of each other. The print now joins the archive the file already has, as a run in its Print Log, when it is the same file, printed by the same user, in the same project, and the archive came from the same File Manager file or from none. The card shows the latest run, with that plate's picture, and the Print Log has a **Plate** column once a run printed a plate other than plate 1; older runs get their plate where the queue still knows it. This also holds while the file prints elsewhere: the same file sent to two printers at once is one archive with two runs. Prints started from the slicer or on the printer, uploads, and existing duplicates are unchanged.
+- **The archive export has one row per print** — An archive is a file, and each print of it a run, so the CSV and Excel export now has a row per print: the file's columns (name, file, project, tags, notes, slicer settings) repeated, then that print's own status, printer, plate, times, duration, filament, cost and outcome. New columns: **Print ID**, **Printer**, **Plate**, **Outcome** and **Duration (s)**. An archive printed before prints were logged keeps its one row. The printer, status and date filters pick prints (dates by when the print started), the project and search filters pick archives, and a user limited to some printers gets only the prints on theirs. Spreadsheets built on the old layout need their columns checked.
 - **Unused API routes and settings removed** — For anyone calling the API from scripts:
   - **Removed routes:** `POST /printers/test` (it took the access code in the URL; adding a printer tests the connection itself), the two debug routes `POST /printers/{id}/debug/simulate-print-complete` and `GET /printers/{id}/runtime-debug`, and `POST /spoolbuddy/devices/{id}/update-status`, which SpoolBuddy stopped calling when updates moved to SSH.
   - **Old virtual printer settings:** `GET /settings/virtual-printer` now returns only `tailscale_disabled`, `archive_name_source` and `status`, and `GET /settings` no longer lists `virtual_printer_enabled`, `virtual_printer_access_code` and `virtual_printer_mode` (`PUT /settings` ignores them). These settings stopped doing anything when each virtual printer got its own settings; use `/virtual-printers` instead.

+ 3 - 1
backend/app/api/routes/archives.py

@@ -1467,7 +1467,9 @@ async def export_archives(
     # Parse fields
     field_list = None
     if fields:
-        field_list = [f.strip() for f in fields.split(",")]
+        field_list = [f.strip() for f in fields.split(",") if f.strip()]
+        if unknown := ExportService.unknown_fields(field_list):
+            raise HTTPException(400, f"Unknown export fields: {', '.join(unknown)}")
 
     # Parse dates
     date_from_dt = None

+ 133 - 42
backend/app/services/export.py

@@ -9,6 +9,30 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
+
+# What one print of an archive has of its own (archive redesign): in the
+# export, its run's. Everything else is the file's, the archive's.
+_PER_PRINT_FIELDS = frozenset(
+    {
+        "status",
+        "quantity",
+        "printer_id",
+        "printer_name",
+        "plate_id",
+        "filament_type",
+        "filament_used_grams",
+        "cost",
+        "energy_kwh",
+        "energy_cost",
+        "wear_cost",
+        "failure_reason",
+        "user_verdict",
+        "duration_seconds",
+        "started_at",
+        "completed_at",
+    }
+)
 
 
 class ExportService:
@@ -17,11 +41,14 @@ class ExportService:
     # Default fields to export
     DEFAULT_FIELDS = [
         "id",
+        "run_id",
         "print_name",
         "filename",
         "status",
         "quantity",
         "printer_id",
+        "printer_name",
+        "plate_id",
         "project_name",
         "filament_type",
         "filament_used_grams",
@@ -36,6 +63,8 @@ class ExportService:
         "tags",
         "notes",
         "failure_reason",
+        "user_verdict",
+        "duration_seconds",
         "started_at",
         "completed_at",
         "created_at",
@@ -44,6 +73,12 @@ class ExportService:
     # Field labels for headers
     FIELD_LABELS = {
         "id": "ID",
+        "run_id": "Print ID",
+        "printer_name": "Printer",
+        "plate_id": "Plate",
+        "user_verdict": "Outcome",
+        "duration_seconds": "Duration (s)",
+        "energy_kwh": "Energy (kWh)",
         "print_name": "Print Name",
         "filename": "Filename",
         "status": "Status",
@@ -70,6 +105,13 @@ class ExportService:
         "created_at": "Created At",
     }
 
+    @classmethod
+    def unknown_fields(cls, fields: list[str]) -> list[str]:
+        """The requested columns the export does not have: only its own
+        columns are exported, never other attributes of an archive (its
+        one-tap confirm token is a credential)."""
+        return [f for f in fields if f not in cls.FIELD_LABELS]
+
     def __init__(self, db: AsyncSession):
         self.db = db
 
@@ -86,45 +128,33 @@ class ExportService:
         visible_to_user_id: int | None = None,
         printer_scope: PrinterScope | None = None,
     ) -> tuple[bytes, str, str]:
-        """Export archives to CSV or Excel format.
+        """Export archives to CSV or Excel format, one row per print.
 
-        Args:
-            format: Export format ('csv' or 'xlsx')
-            fields: List of fields to include (None = all default fields)
-            printer_id: Filter by printer
-            project_id: Filter by project
-            status: Filter by status
-            date_from: Filter by start date
-            date_to: Filter by end date
-            search: Search filter
-            visible_to_user_id: Scope rows to those owned by this user (used
-                when the caller has ARCHIVES_READ_OWN but not _ALL).
+        An archive is a file, each print of it a run (archive redesign): a row
+        has the archive's file columns and one run's own (``_PER_PRINT_FIELDS``).
+        An archive printed before runs were logged is one row of its own
+        fields; one never printed, one row with empty print columns.
+
+        ``printer_id``, ``status`` and the dates pick prints (a run's start,
+        an archive without runs by when it was created); ``project_id``,
+        ``search`` and ``visible_to_user_id`` pick archives. A caller limited
+        to some printers gets only the prints on theirs.
 
         Returns:
             Tuple of (file_bytes, filename, content_type)
         """
-        # Build query. Soft-deleted archives (#1343) are excluded: this export
-        # is the list the user is looking at, saved to a file, and that list
-        # hides them — an export that silently contains rows the UI says are
-        # gone is worse than useless for reconciling anything (#2731).
+        # Soft-deleted archives (#1343) are excluded: this export is the list
+        # the user is looking at, saved to a file, and that list hides them —
+        # an export that silently contains rows the UI says are gone is worse
+        # than useless for reconciling anything (#2731).
         query = (
             select(PrintArchive)
-            .options(selectinload(PrintArchive.project))
+            .options(selectinload(PrintArchive.project), selectinload(PrintArchive.printer))
             .where(PrintArchive.deleted_at.is_(None))
             .order_by(PrintArchive.created_at.desc())
         )
-
-        # Apply filters
-        if printer_id:
-            query = query.where(PrintArchive.printer_id == printer_id)
         if project_id:
             query = query.where(PrintArchive.project_id == project_id)
-        if status:
-            query = query.where(PrintArchive.status == status)
-        if date_from:
-            query = query.where(PrintArchive.created_at >= date_from)
-        if date_to:
-            query = query.where(PrintArchive.created_at <= date_to)
         if visible_to_user_id is not None:
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
         from backend.app.core.row_visibility import archive_scope_clause
@@ -140,19 +170,55 @@ class ExportService:
                 | (PrintArchive.notes.ilike(like_pattern))
                 | (PrintArchive.designer.ilike(like_pattern))
             )
+        archives = list((await self.db.execute(query)).scalars().all())
+
+        # Every archive's runs, a print running now included; the caller's
+        # only, when limited. By the archive query itself, not a list of its
+        # ids: a farm's archives outnumber what one statement may bind.
+        printed: set[int] = set()
+        runs: dict[int, list[PrintLogEntry]] = {}
+        if archives:
+            archive_ids = query.with_only_columns(PrintArchive.id).order_by(None)
+            all_runs = (
+                await self.db.scalars(
+                    select(PrintLogEntry)
+                    .where(PrintLogEntry.archive_id.in_(archive_ids))
+                    .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
+                    .execution_options(**{INCLUDE_OPEN_RUNS: True})
+                )
+            ).all()
+            for run in all_runs:
+                if run.archive_id is None:
+                    continue
+                printed.add(run.archive_id)
+                if printer_scope is None or printer_scope.allows(run.printer_id):
+                    runs.setdefault(run.archive_id, []).append(run)
+
+        statuses = {s.strip() for s in status.split(",") if s.strip()} if status else set()
+
+        def picked(printer: int | None, row_status: str | None, when: datetime | None) -> bool:
+            if printer_id and printer != printer_id:
+                return False
+            if statuses and row_status not in statuses:
+                return False
+            if date_from and (when is None or when < date_from):
+                return False
+            return not (date_to and (when is None or when > date_to))
 
-        # Execute query
-        result = await self.db.execute(query)
-        archives = list(result.scalars().all())
-
-        # Determine fields to export
         export_fields = fields if fields else self.DEFAULT_FIELDS
-
-        # Convert to rows
         rows = []
         for archive in archives:
-            row = self._archive_to_row(archive, export_fields)
-            rows.append(row)
+            if archive.id in runs:
+                for run in runs[archive.id]:
+                    if picked(run.printer_id, run.status, run.started_at or run.created_at):
+                        rows.append(self._archive_to_row(archive, export_fields, run))
+            elif archive.id not in printed:
+                # Printed before runs were logged, or never: the archive's own.
+                if picked(archive.printer_id, archive.status, archive.created_at):
+                    rows.append(self._archive_to_row(archive, export_fields))
+            elif not (printer_id or status or date_from or date_to):
+                # Printed only on printers outside the caller's scope: the file.
+                rows.append(self._archive_to_row(archive, export_fields, hide_prints=True))
 
         # Generate headers
         headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
@@ -257,18 +323,43 @@ class ExportService:
 
         return file_bytes, filename, content_type
 
-    def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:
-        """Convert an archive to a row of values."""
+    def _archive_to_row(
+        self,
+        archive: PrintArchive,
+        fields: list[str],
+        run: PrintLogEntry | None = None,
+        *,
+        hide_prints: bool = False,
+    ) -> list[Any]:
+        """One row: the archive's file columns, and ``run``'s own (without a
+        run, the archive's, as it kept its print before runs; empty with
+        ``hide_prints``)."""
         row = []
         for field in fields:
             if field == "project_name":
                 value = archive.project.name if archive.project else None
-            elif field in ("started_at", "completed_at", "created_at"):
-                value = getattr(archive, field)
-                if value:
-                    value = value.isoformat()
+            elif field == "run_id":
+                value = run.id if run is not None else None
+            elif field in _PER_PRINT_FIELDS and hide_prints:
+                value = None
+            elif field in _PER_PRINT_FIELDS and run is not None:
+                value = getattr(run, field, None)
+                # Runs logged before they kept these share their archive's.
+                if field in ("filament_type", "quantity") and value is None:
+                    value = getattr(archive, field)
+            elif field == "printer_name":
+                value = archive.printer.name if archive.printer is not None else None
+            elif field == "duration_seconds":
+                started, completed = archive.started_at, archive.completed_at
+                value = (
+                    int((completed - started).total_seconds())
+                    if started and completed and completed > started
+                    else None
+                )
             else:
                 value = getattr(archive, field, None)
+            if isinstance(value, datetime):
+                value = value.isoformat()
             row.append(value)
         return row
 

+ 181 - 0
backend/tests/integration/test_export_by_run.py

@@ -0,0 +1,181 @@
+"""The archive export has one row per print (archive redesign P3, decision 5).
+
+An archive is a file and each print of it a run: a row holds the file's
+columns and one run's own. An archive printed before runs were logged keeps
+its one row; the print filters pick prints, the others archives.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+from datetime import datetime
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+T1 = datetime(2026, 10, 1, 9)
+T2 = datetime(2026, 10, 20, 9)
+
+
+async def _rows(async_client, headers=None, **params) -> list[dict]:
+    response = await async_client.get(
+        "/api/v1/archives/export", params={"format": "csv", **params}, headers=headers or {}
+    )
+    assert response.status_code == 200, response.text
+    return list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
+
+
+@pytest.fixture
+async def prints(db_session, printer_factory):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    planter = PrintArchive(
+        printer_id=b.id,
+        filename="planter.3mf",
+        file_path="archives/t/planter.3mf",
+        file_size=1,
+        print_name="Planter",
+        status="failed",
+        tags="garden",
+        notes="two plates",
+        created_at=T1,
+    )
+    legacy = PrintArchive(
+        printer_id=a.id,
+        filename="hook.3mf",
+        file_path="archives/t/hook.3mf",
+        file_size=1,
+        print_name="Hook",
+        status="completed",
+        filament_used_grams=5.0,
+        created_at=T1,
+    )
+    db_session.add_all([planter, legacy])
+    await db_session.flush()
+    first = PrintLogEntry(
+        archive_id=planter.id,
+        printer_id=a.id,
+        printer_name="A",
+        plate_id=1,
+        status="completed",
+        started_at=T1,
+        filament_used_grams=12.0,
+        user_verdict="good",
+    )
+    second = PrintLogEntry(
+        archive_id=planter.id,
+        printer_id=b.id,
+        printer_name="B",
+        plate_id=2,
+        status="failed",
+        started_at=T2,
+        filament_used_grams=3.0,
+        failure_reason="spaghetti",
+    )
+    db_session.add_all([first, second])
+    await db_session.commit()
+    return {"a": a.id, "b": b.id, "planter": planter.id, "legacy": legacy.id, "first": first.id, "second": second.id}
+
+
+async def test_one_row_per_print(async_client, prints):
+    rows = await _rows(async_client)
+
+    planter = [r for r in rows if r["Print Name"] == "Planter"]
+    assert [(r["Print ID"], r["Printer"], r["Plate"], r["Status"], r["Filament (g)"]) for r in planter] == [
+        (str(prints["second"]), "B", "2", "failed", "3.0"),
+        (str(prints["first"]), "A", "1", "completed", "12.0"),
+    ]
+    # The file's columns on each.
+    assert {(r["ID"], r["Tags"], r["Notes"]) for r in planter} == {(str(prints["planter"]), "garden", "two plates")}
+    assert [r["Outcome"] for r in planter] == ["", "good"]
+
+
+async def test_a_print_logged_before_runs_keeps_its_row(async_client, prints):
+    [hook] = [r for r in await _rows(async_client) if r["Print Name"] == "Hook"]
+
+    assert (hook["Print ID"], hook["Status"], hook["Filament (g)"]) == ("", "completed", "5.0")
+
+
+async def test_the_print_filters_pick_prints(async_client, prints):
+    on_a = await _rows(async_client, printer_id=prints["a"])
+    failed = await _rows(async_client, status="failed")
+    late = await _rows(async_client, date_from="2026-10-15")
+
+    assert sorted(r["Print ID"] for r in on_a) == sorted(["", str(prints["first"])])
+    assert [r["Print ID"] for r in failed] == [str(prints["second"])]
+    assert [r["Print ID"] for r in late] == [str(prints["second"])]
+
+
+async def test_a_user_limited_to_some_printers_gets_their_prints(async_client, prints):
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(async_client, admin, "of_a", ["archives:read_all", "printers:read"], printer_ids=[prints["a"]])
+
+    rows = await _rows(async_client, headers=_auth(jwt))
+
+    planter = [r for r in rows if r["Print Name"] == "Planter"]
+    assert [r["Print ID"] for r in planter] == [str(prints["first"])]
+
+
+async def test_a_file_printed_only_outside_their_printers_shows_no_print(async_client, db_session, prints):
+    """Visible by its own printer (the archive names A), every print on B."""
+    archive = await db_session.get(PrintArchive, prints["planter"])
+    archive.printer_id = prints["a"]
+    first = await db_session.get(PrintLogEntry, prints["first"])
+    first.printer_id = prints["b"]
+    await db_session.commit()
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(async_client, admin, "of_a", ["archives:read_all", "printers:read"], printer_ids=[prints["a"]])
+
+    [planter] = [r for r in await _rows(async_client, headers=_auth(jwt)) if r["Print Name"] == "Planter"]
+
+    assert (planter["Print ID"], planter["Status"], planter["Printer ID"], planter["Tags"]) == ("", "", "", "garden")
+
+
+async def test_a_print_running_now_is_a_row_of_its_own(async_client, db_session, prints):
+    """Not taken from the archive, whose fields may be another printer's."""
+    from backend.app.models.print_log import RUN_OPEN
+
+    hook = await db_session.get(PrintArchive, prints["legacy"])
+    hook.printer_id = prints["b"]
+    running = PrintLogEntry(archive_id=prints["legacy"], printer_id=prints["a"], status=RUN_OPEN, started_at=T2)
+    db_session.add(running)
+    await db_session.commit()
+    running_id = running.id
+
+    [row] = [r for r in await _rows(async_client) if r["Print Name"] == "Hook"]
+
+    assert (row["Print ID"], row["Status"], row["Printer ID"]) == (str(running_id), "printing", str(prints["a"]))
+
+
+async def test_only_the_exports_own_columns(async_client, prints):
+    response = await async_client.get("/api/v1/archives/export", params={"fields": "id,confirm_token"})
+
+    assert response.status_code == 400
+    assert "confirm_token" in response.text
+
+
+async def test_a_print_logged_before_it_kept_its_quantity_has_its_archives(async_client, db_session, prints):
+    archive = await db_session.get(PrintArchive, prints["planter"])
+    archive.quantity = 4
+    await db_session.commit()
+
+    planter = [r for r in await _rows(async_client) if r["Print Name"] == "Planter"]
+
+    assert {r["Items Printed"] for r in planter} == {"4"}
+
+
+async def test_a_print_logged_before_runs_names_its_printer(async_client, prints):
+    [hook] = [r for r in await _rows(async_client) if r["Print Name"] == "Hook"]
+
+    assert hook["Printer"] == "A"
+
+
+async def test_several_statuses_at_once(async_client, prints):
+    rows = await _rows(async_client, status="failed,completed")
+
+    assert sorted(r["Print ID"] for r in rows) == sorted(["", str(prints["first"]), str(prints["second"])])

+ 6 - 0
backend/tests/integration/test_export_date_to_and_speed_mode.py

@@ -14,6 +14,9 @@ from unittest.mock import MagicMock, patch
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.print_log import PrintLogEntry
 
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
@@ -36,6 +39,9 @@ async def three_days(archive_factory, printer_factory, db_session):
     ]:
         archive = await archive_factory(printer.id, print_name=name)
         archive.created_at = when
+        # The export picks prints by when they started (archive redesign).
+        run = await db_session.scalar(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        run.started_at = when
     await db_session.commit()
 
 

+ 28 - 11
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the ArchivesPage component.
  */
 
-import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
 import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { render } from '../utils';
 import { ArchivesPage } from '../../pages/ArchivesPage';
@@ -655,17 +655,34 @@ 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', () => {
-    // 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;
-    });
+  // Archive redesign P3: the Failed collection shows failed and aborted
+  // prints, and its export has both.
+  it('exports what the Failed collection shows', async () => {
+    let exported: string | null = null;
+    server.use(
+      http.get('*/api/v1/archives/export', ({ request }) => {
+        exported = new URL(request.url).searchParams.get('status');
+        return new HttpResponse('ID\n', { headers: { 'Content-Type': 'text/csv' } });
+      }),
+    );
+    const createObjectURL = URL.createObjectURL;
+    const revokeObjectURL = URL.revokeObjectURL;
+    URL.createObjectURL = () => 'blob:export';
+    URL.revokeObjectURL = () => {};
+    try {
+      render(<ArchivesPage />);
+      fireEvent.change(await screen.findByDisplayValue('All Archives'), { target: { value: 'failed' } });
+      fireEvent.click(screen.getByRole('button', { name: 'Export' }));
+      fireEvent.click(await screen.findByText('Export as CSV'));
+
+      await waitFor(() => expect(exported).toBe('failed,aborted'));
+    } finally {
+      URL.createObjectURL = createObjectURL;
+      URL.revokeObjectURL = revokeObjectURL;
+    }
+  });
 
+  describe('merge duplicates button', () => {
     const signInWith = (permissions: string[]) => {
       setAuthToken('merge-token', 'session');
       server.use(

+ 7 - 0
frontend/src/__tests__/setup.ts

@@ -141,6 +141,13 @@ window.scrollTo = vi.fn();
   });
 }
 
+// jsdom has no scrolling: a page that scrolls to an element on a timer (an
+// archive link scrolls to its card) would throw after the test that caused it
+// has ended. Tests that check the scroll stub it themselves.
+if (!Element.prototype.scrollIntoView) {
+  Element.prototype.scrollIntoView = () => {};
+}
+
 // Mock localStorage
 const localStorageMock = {
   getItem: vi.fn(),

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

@@ -4146,7 +4146,8 @@ export function ArchivesPage() {
                       const { blob, filename } = await api.exportArchives({
                         format: 'csv',
                         printerId: filterPrinter || undefined,
-                        status: collection === 'failed' ? 'failed' : undefined,
+                        // As the Failed collection shows them.
+                        status: collection === 'failed' ? 'failed,aborted' : undefined,
                         search: search || undefined,
                       });
                       const url = URL.createObjectURL(blob);
@@ -4175,7 +4176,8 @@ export function ArchivesPage() {
                       const { blob, filename } = await api.exportArchives({
                         format: 'xlsx',
                         printerId: filterPrinter || undefined,
-                        status: collection === 'failed' ? 'failed' : undefined,
+                        // As the Failed collection shows them.
+                        status: collection === 'failed' ? 'failed,aborted' : undefined,
                         search: search || undefined,
                       });
                       const url = URL.createObjectURL(blob);

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ArchivesPage-YpORT6Zg.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CamWallPage-Da0oH_PZ.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraPage-6R1Ysyza.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FileManagerPage-BRy5r6YL.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/InventoryPage-DwNiNhmA.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ModelSourcesPage-tk_F4CAV.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PhotoGalleryModal-a8kKQYNh.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PrintersPage-Clb3XtNz.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SettingsPage-CckXSzSp.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SliceModal-BBYojTAH.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-H8q_YF7D.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Bq4LDqUn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-DXN5_K40.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/locationSensorDefaults-TJlMUuHn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/spoolCost-D7W6DhXc.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CTm17gIU.js"></script>
+    <script type="module" crossorigin src="/assets/index-DXN5_K40.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-CWBrdRQ8.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-B3DWHe9Z.js">

Неке датотеке нису приказане због велике количине промена