Explorar el Código

fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)

  @smandon retested the original #1152 fix on the latest daily and surfaced
  two distinct holes:

  1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
     default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
     ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.

  2. The pending-uploads review card always showed the raw FTP filename,
     while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
     embedded title (or, with the toggle on ``filename``, the stripped stem).
     Net effect: same upload showed two different names depending on which
     view you were looking at, with no way for the toggle to flip both
     views in lockstep.

  Three changes:

  - ``resolve_display_stem`` helper in ``services/archive.py`` strips
    ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
    the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
    every flow that produces a ``PrintArchive`` row.

  - ``PendingUpload.metadata_print_name`` (new nullable column) is
    populated at FTP-receive time by peeking at the 3MF's embedded title
    via the existing ``ThreeMFParser``. Read happens once per upload —
    the list endpoint then doesn't have to reopen each 3MF on every
    render. Parser failures are swallowed and the column stays NULL;
    the response model gracefully falls back to the stripped filename.

  - ``PendingUploadResponse.display_name`` is a computed field that
    mirrors ``archive_print``'s exact precedence — ``filename`` toggle
    → stripped stem; ``metadata`` toggle (default) → cached title or
    stripped stem. The frontend's review card reads it (with
    ``upload.filename`` as a defensive fallback) and surfaces the raw
    FTP filename via tooltip so users can still inspect what arrived.

  Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
  metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
  rows have NULL and degrade to filename-stem behaviour without any
  operator action.

  Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
  canonical normalisation rules (Bambu Studio default name, mixed case,
  dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
  inputs); 6 integration tests in ``test_pending_upload_display_name.py``
  pinning the response contract (default toggle uses metadata title when
  present, falls back to stripped stem when absent, ``filename`` toggle
  overrides metadata, ``filename`` toggle still strips the double suffix,
  ``GET /{id}`` exposes the same field, whitespace-only metadata behaves
  like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
  pinning the review card's render path (resolved name shown, fallback
  to filename when display_name is empty, raw filename available via
  tooltip). Full backend suite: 3598 passed; frontend build clean; no
  regressions in any flow that previously processed ``.3mf`` /
  ``.gcode`` / non-3D filenames.
maziggy hace 3 semanas
padre
commit
01a7e6ee93

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 48 - 3
backend/app/api/routes/pending_uploads.py

@@ -13,7 +13,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.services.archive import ArchiveService
+from backend.app.services.archive import ArchiveService, resolve_display_stem
 
 
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
 
 
@@ -31,6 +31,7 @@ class PendingUploadResponse(BaseModel):
 
 
     id: int
     id: int
     filename: str
     filename: str
+    display_name: str  # Resolved name that mirrors the eventual archive's print_name (#1152 follow-up)
     file_size: int
     file_size: int
     source_ip: str | None
     source_ip: str | None
     status: str
     status: str
@@ -43,6 +44,50 @@ class PendingUploadResponse(BaseModel):
         from_attributes = True
         from_attributes = True
 
 
 
 
+def _resolve_display_name(pending: PendingUpload, prefer_filename: bool) -> str:
+    """Compute the name the review card should show, matching what archive_print
+    will eventually write to ``PrintArchive.print_name`` so the user sees the
+    same name in both places (#1152 follow-up).
+
+    Mirrors ``ArchiveService.archive_print``:
+      - ``prefer_filename=True`` → stripped filename stem.
+      - ``prefer_filename=False`` → ``metadata_print_name`` if set, else stem.
+    """
+    stem = resolve_display_stem(pending.filename)
+    if prefer_filename:
+        return stem
+    return (pending.metadata_print_name or "").strip() or stem
+
+
+async def _augment_with_display_name(
+    db: AsyncSession,
+    pendings: list[PendingUpload],
+) -> list[PendingUploadResponse]:
+    """Build response objects with display_name resolved against the toggle.
+
+    Reads the ``virtual_printer_archive_name_source`` setting once per request
+    rather than per row.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
+    return [
+        PendingUploadResponse(
+            id=p.id,
+            filename=p.filename,
+            display_name=_resolve_display_name(p, prefer_filename),
+            file_size=p.file_size,
+            source_ip=p.source_ip,
+            status=p.status,
+            tags=p.tags,
+            notes=p.notes,
+            project_id=p.project_id,
+            uploaded_at=p.uploaded_at,
+        )
+        for p in pendings
+    ]
+
+
 @router.get("/", response_model=list[PendingUploadResponse])
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
 async def list_pending_uploads(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
@@ -53,7 +98,7 @@ async def list_pending_uploads(
         select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
         select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
     )
     )
 
 
-    return result.scalars().all()
+    return await _augment_with_display_name(db, list(result.scalars().all()))
 
 
 
 
 @router.get("/count")
 @router.get("/count")
@@ -172,7 +217,7 @@ async def get_pending_upload(
     if not pending:
     if not pending:
         raise HTTPException(status_code=404, detail="Upload not found")
         raise HTTPException(status_code=404, detail="Upload not found")
 
 
-    return pending
+    return (await _augment_with_display_name(db, [pending]))[0]
 
 
 
 
 @router.post("/{upload_id}/archive")
 @router.post("/{upload_id}/archive")

+ 10 - 0
backend/app/core/database.py

@@ -1760,6 +1760,16 @@ async def run_migrations(conn):
         "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
         "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
     )
     )
 
 
+    # Migration: Cache metadata title on pending uploads (#1152 follow-up).
+    # Without this column the review card always shows the FTP filename while
+    # the eventual archive's print_name comes from the 3MF metadata title,
+    # creating a confusing review→archive name mismatch. Captured at upload
+    # time so /pending-uploads/ list calls don't have to reopen each 3MF.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)",
+    )
+
     # Migration: Per-user API key ownership + cloud-access scope (#1182).
     # Migration: Per-user API key ownership + cloud-access scope (#1182).
     # user_id is nullable so legacy keys (created before #1182) survive the
     # user_id is nullable so legacy keys (created before #1182) survive the
     # migration; cloud routes reject calls from keys without an owner so the
     # migration; cloud routes reject calls from keys without an owner so the

+ 6 - 0
backend/app/models/pending_upload.py

@@ -20,6 +20,12 @@ class PendingUpload(Base):
     file_path: Mapped[str] = mapped_column(String(500))  # Temp storage path
     file_path: Mapped[str] = mapped_column(String(500))  # Temp storage path
     file_size: Mapped[int] = mapped_column(Integer)
     file_size: Mapped[int] = mapped_column(Integer)
 
 
+    # Embedded 3MF Title metadata, captured at FTP-receive time so the review
+    # card and the eventual archive's print_name agree on which name to show
+    # (#1152 follow-up). NULL when the 3MF has no title or the metadata read
+    # failed — the response model falls back to the filename stem in that case.
+    metadata_print_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
     # Source info
     # Source info
     source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
     source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
 
 

+ 26 - 1
backend/app/services/archive.py

@@ -41,6 +41,31 @@ def _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None
     shutil.copystat(src, dst)
     shutil.copystat(src, dst)
 
 
 
 
+def resolve_display_stem(filename: str) -> str:
+    """Return a clean human-readable stem from a 3MF/gcode filename.
+
+    Bambu Studio's "Send to printer" dialog typically writes files like
+    ``Plate_1.gcode.3mf`` (a sliced gcode payload wrapped in a 3MF container).
+    The naive ``Path(filename).stem`` only drops the last suffix, leaving
+    ``Plate_1.gcode`` — which then surfaces in the archive UI as a confusing
+    ``Plate_1.gcode`` rather than ``Plate_1`` (#1152 follow-up).
+
+    Strip the recognised print-format suffixes in order:
+
+    - ``.gcode.3mf`` → bare stem (Bambu Studio FTP send)
+    - ``.3mf``       → bare stem
+    - ``.gcode``     → bare stem (rare standalone gcode upload)
+
+    Anything else passes through unchanged.
+    """
+    name = Path(filename).name  # drop any path components
+    lower = name.lower()
+    for suffix in (".gcode.3mf", ".3mf", ".gcode"):
+        if lower.endswith(suffix):
+            return name[: -len(suffix)]
+    return Path(name).stem
+
+
 class ThreeMFParser:
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
     """Parser for Bambu Lab 3MF files."""
 
 
@@ -917,7 +942,7 @@ class ArchiveService:
 
 
         # Create archive directory structure
         # Create archive directory structure
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-        display_stem = Path(original_filename).stem if original_filename else source_file.stem
+        display_stem = resolve_display_stem(original_filename if original_filename else source_file.name)
         archive_name = f"{timestamp}_{display_stem}"
         archive_name = f"{timestamp}_{display_stem}"
         # Use "unassigned" folder for archives without a printer
         # Use "unassigned" folder for archives without a printer
         printer_folder = str(printer_id) if printer_id is not None else "unassigned"
         printer_folder = str(printer_id) if printer_id is not None else "unassigned"

+ 17 - 0
backend/app/services/virtual_printer/manager.py

@@ -279,6 +279,22 @@ class VirtualPrinterInstance:
                 pass
                 pass
             return
             return
 
 
+        # Peek at the 3MF for the embedded title BEFORE we hand it off to the
+        # DB. Storing it now means the /pending-uploads/ list doesn't have to
+        # reopen every 3MF on every render to keep the review card and the
+        # eventual archive name in sync (#1152 follow-up). Failure to parse is
+        # not fatal — the response model falls back to the filename stem.
+        metadata_print_name: str | None = None
+        try:
+            from backend.app.services.archive import ThreeMFParser
+
+            parsed = ThreeMFParser(file_path).parse()
+            raw_name = parsed.get("print_name")
+            if isinstance(raw_name, str) and raw_name.strip():
+                metadata_print_name = raw_name.strip()[:255]
+        except Exception as e:
+            logger.debug("[VP %s] Metadata title peek failed for %s: %s", self.name, file_path.name, e)
+
         try:
         try:
             from backend.app.models.pending_upload import PendingUpload
             from backend.app.models.pending_upload import PendingUpload
 
 
@@ -290,6 +306,7 @@ class VirtualPrinterInstance:
                     source_ip=source_ip,
                     source_ip=source_ip,
                     status="pending",
                     status="pending",
                     uploaded_at=datetime.now(timezone.utc),
                     uploaded_at=datetime.now(timezone.utc),
+                    metadata_print_name=metadata_print_name,
                 )
                 )
                 db.add(pending)
                 db.add(pending)
                 await db.commit()
                 await db.commit()

+ 159 - 0
backend/tests/integration/test_pending_upload_display_name.py

@@ -0,0 +1,159 @@
+"""Integration tests for #1152 follow-up — pending-upload review card name
+matches the eventual archive's print_name.
+
+Before this change the review card always showed the raw FTP filename while
+the archive's ``print_name`` was resolved from the 3MF metadata title (or, with
+the toggle on ``filename``, the stripped stem). That gave users two different
+names for the same item — they'd see *Plate_1.gcode* in review and *Some
+Creator's Title* in the archive grid.
+
+These tests pin the new contract:
+  - ``PendingUploadResponse.display_name`` mirrors what archive_print will
+    eventually write to ``PrintArchive.print_name``.
+  - The toggle (``virtual_printer_archive_name_source``) flips both views in
+    lockstep — never one without the other.
+  - Filename normalisation (``Plate_1.gcode.3mf`` → ``Plate_1``) is applied
+    consistently regardless of the toggle.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.settings import Settings
+
+
+async def _set_archive_name_source(db: AsyncSession, value: str) -> None:
+    """Write the virtual_printer_archive_name_source setting directly."""
+    db.add(Settings(key="virtual_printer_archive_name_source", value=value))
+    await db.commit()
+
+
+async def _seed_pending(
+    db: AsyncSession,
+    *,
+    filename: str,
+    metadata_print_name: str | None = None,
+) -> int:
+    pending = PendingUpload(
+        filename=filename,
+        file_path=f"/tmp/{filename}",
+        file_size=42,
+        source_ip="192.168.1.50",
+        status="pending",
+        metadata_print_name=metadata_print_name,
+    )
+    db.add(pending)
+    await db.commit()
+    await db.refresh(pending)
+    return pending.id
+
+
+class TestDisplayNameResolution:
+    """``GET /pending-uploads/`` resolves ``display_name`` to the same value
+    ``archive_print`` would store on the eventual ``PrintArchive``."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_toggle_uses_metadata_title_when_present(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Default toggle is ``metadata`` — review card shows the embedded
+        title rather than the FTP filename, matching what the archived
+        PrintArchive.print_name will end up being."""
+        await _seed_pending(
+            db_session,
+            filename="Plate_1.gcode.3mf",
+            metadata_print_name="Custom Cool Benchy",
+        )
+
+        resp = await async_client.get("/api/v1/pending-uploads/")
+        assert resp.status_code == 200
+        rows = resp.json()
+        assert len(rows) == 1
+        assert rows[0]["display_name"] == "Custom Cool Benchy"
+        # filename is still surfaced separately so the user can see what
+        # actually arrived over FTP if they want to (tooltip).
+        assert rows[0]["filename"] == "Plate_1.gcode.3mf"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_toggle_falls_back_to_stripped_stem_when_no_metadata(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """No embedded title (Bambu Studio's default plate export) — both
+        review and archive end up showing the stripped filename stem."""
+        await _seed_pending(
+            db_session,
+            filename="Plate_1.gcode.3mf",
+            metadata_print_name=None,
+        )
+
+        resp = await async_client.get("/api/v1/pending-uploads/")
+        assert resp.status_code == 200
+        # ``Plate_1`` — both ``.gcode`` and ``.3mf`` stripped (#1152
+        # follow-up: ``Path.stem`` only strips the last suffix and would
+        # leave ``Plate_1.gcode``).
+        assert resp.json()[0]["display_name"] == "Plate_1"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_filename_toggle_overrides_metadata_title(self, async_client: AsyncClient, db_session: AsyncSession):
+        """When the operator opts into ``filename`` (so a user-renamed
+        Bambu Studio job surfaces its renamed-at-send filename), the embedded
+        creator-baked title is ignored. Review must follow the same toggle
+        as the archive."""
+        await _set_archive_name_source(db_session, "filename")
+        await _seed_pending(
+            db_session,
+            filename="MyRenamedJob.3mf",
+            metadata_print_name="Original Creator Title",
+        )
+
+        resp = await async_client.get("/api/v1/pending-uploads/")
+        assert resp.status_code == 200
+        assert resp.json()[0]["display_name"] == "MyRenamedJob"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_filename_toggle_strips_double_suffix(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Filename mode also drops .gcode.3mf — same normalisation as the
+        archive's print_name, so the names line up exactly."""
+        await _set_archive_name_source(db_session, "filename")
+        await _seed_pending(db_session, filename="Plate_4.gcode.3mf", metadata_print_name=None)
+
+        resp = await async_client.get("/api/v1/pending-uploads/")
+        assert resp.status_code == 200
+        assert resp.json()[0]["display_name"] == "Plate_4"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_one_returns_display_name(self, async_client: AsyncClient, db_session: AsyncSession):
+        """The single-resource endpoint surfaces display_name too — UIs that
+        load a pending upload by id (e.g. detail modals) get the same name."""
+        upload_id = await _seed_pending(
+            db_session,
+            filename="X.gcode.3mf",
+            metadata_print_name="Deep Detail Bear",
+        )
+
+        resp = await async_client.get(f"/api/v1/pending-uploads/{upload_id}")
+        assert resp.status_code == 200
+        assert resp.json()["display_name"] == "Deep Detail Bear"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_blank_metadata_title_falls_back_to_stem(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A whitespace-only metadata title behaves like absent metadata —
+        guards against 3MFs with broken/empty Title fields surfacing as a
+        blank review card."""
+        await _seed_pending(
+            db_session,
+            filename="empty-title.gcode.3mf",
+            metadata_print_name="   ",
+        )
+
+        resp = await async_client.get("/api/v1/pending-uploads/")
+        assert resp.status_code == 200
+        assert resp.json()[0]["display_name"] == "empty-title"

+ 51 - 0
backend/tests/unit/services/test_archive_display_stem.py

@@ -0,0 +1,51 @@
+"""Tests for resolve_display_stem — Bambu Studio filename normalisation (#1152).
+
+Bambu Studio's "Send to printer" dialog typically writes ``Plate_1.gcode.3mf``
+(a sliced gcode payload wrapped in a 3MF container). ``Path(name).stem`` only
+strips the last suffix and leaves ``Plate_1.gcode``, which then surfaces in
+the archive UI as a confusing ``Plate_1.gcode`` rather than ``Plate_1``.
+
+Pin the canonicalisation rules so a future refactor can't silently regress
+this path. We don't need a dedicated test for ``archive_print``'s consumption
+of the helper — the existing test suite covers that flow end-to-end via the
+integration tests and a behaviour change there would surface as a different
+``archive.print_name`` value.
+"""
+
+import pytest
+
+from backend.app.services.archive import resolve_display_stem
+
+
+@pytest.mark.parametrize(
+    ("filename", "expected"),
+    [
+        # The headline case: Bambu Studio's default name for a sliced 3MF.
+        ("Plate_1.gcode.3mf", "Plate_1"),
+        # User-renamed file with the double-suffix pattern.
+        ("MyAwesomeBenchy.gcode.3mf", "MyAwesomeBenchy"),
+        # Plain .3mf (already-clean export from Bambu Studio's Save As).
+        ("Benchy.3mf", "Benchy"),
+        # Standalone gcode upload — rare but supported.
+        ("standalone.gcode", "standalone"),
+        # Mixed-case suffix — many slicers / OSes preserve user-typed case.
+        ("UPPERCASE.GCODE.3MF", "UPPERCASE"),
+        ("mixed.GCode.3mf", "mixed"),
+        # Names that contain dots in the middle should keep them.
+        ("my.cool.model.gcode.3mf", "my.cool.model"),
+        ("v1.2.3-prototype.3mf", "v1.2.3-prototype"),
+        # No recognised suffix → fall through to Path.stem.
+        ("Cura_export.zip", "Cura_export"),
+        ("README.md", "README"),
+        # Edge: just the suffix with nothing in front. Strip honestly — the
+        # caller is responsible for sanity-checking empty stems.
+        (".gcode.3mf", ""),
+        (".3mf", ""),
+        # Path components must not leak in. The helper takes a filename, but
+        # callers occasionally pass a full path string.
+        ("/some/dir/Plate_1.gcode.3mf", "Plate_1"),
+        ("subdir/MyModel.3mf", "MyModel"),
+    ],
+)
+def test_resolve_display_stem(filename: str, expected: str) -> None:
+    assert resolve_display_stem(filename) == expected

+ 78 - 0
frontend/src/__tests__/components/PendingUploadsPanel.test.tsx

@@ -0,0 +1,78 @@
+/**
+ * Tests for PendingUploadsPanel — review-card name resolution (#1152 follow-up).
+ *
+ * The panel renders ``upload.display_name`` (the resolved name that mirrors
+ * what the eventual archive's ``print_name`` will be) and falls back to
+ * ``upload.filename`` when the display_name is missing — guards against a
+ * pending row created before the column landed (or a serialiser bug) showing
+ * a blank card.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { render } from '../utils';
+import { PendingUploadsPanel } from '../../components/PendingUploadsPanel';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+interface MockUpload {
+  id: number;
+  filename: string;
+  display_name: string;
+  file_size: number;
+  source_ip: string | null;
+  status: string;
+  tags: string | null;
+  notes: string | null;
+  project_id: number | null;
+  uploaded_at: string;
+}
+
+const baseUpload: MockUpload = {
+  id: 1,
+  filename: 'Plate_1.gcode.3mf',
+  display_name: 'My Resolved Name',
+  file_size: 12345,
+  source_ip: '192.168.1.50',
+  status: 'pending',
+  tags: null,
+  notes: null,
+  project_id: null,
+  uploaded_at: '2026-05-01T10:00:00Z',
+};
+
+describe('PendingUploadsPanel — display_name', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('renders the resolved display_name on the review card', async () => {
+    server.use(http.get('/api/v1/pending-uploads/', () => HttpResponse.json([baseUpload])));
+
+    const { findByText } = render(<PendingUploadsPanel />);
+    expect(await findByText('My Resolved Name')).toBeInTheDocument();
+  });
+
+  it('falls back to filename when display_name is an empty string', async () => {
+    // Defensive: a bug in the resolver, a pre-migration row that was somehow
+    // re-fetched, or a partial JSON deserialisation must not produce a blank
+    // review card. The frontend keeps showing _something_ the user can click.
+    server.use(
+      http.get('/api/v1/pending-uploads/', () =>
+        HttpResponse.json([{ ...baseUpload, display_name: '' }]),
+      ),
+    );
+
+    const { findByText } = render(<PendingUploadsPanel />);
+    expect(await findByText('Plate_1.gcode.3mf')).toBeInTheDocument();
+  });
+
+  it('exposes the raw filename via tooltip so the user can see what arrived over FTP', async () => {
+    server.use(http.get('/api/v1/pending-uploads/', () => HttpResponse.json([baseUpload])));
+
+    const { findByText } = render(<PendingUploadsPanel />);
+    const nameEl = await findByText('My Resolved Name');
+    expect(nameEl.getAttribute('title')).toBe('Plate_1.gcode.3mf');
+  });
+});

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

@@ -5652,6 +5652,11 @@ export interface VirtualPrinterModels {
 export interface PendingUpload {
 export interface PendingUpload {
   id: number;
   id: number;
   filename: string;
   filename: string;
+  // Resolved name the review card should show — mirrors what archive_print
+  // will eventually write to PrintArchive.print_name (#1152 follow-up). Falls
+  // back to the stripped filename stem when the 3MF has no embedded title or
+  // the operator has chosen the "filename" archive-name source.
+  display_name: string;
   file_size: number;
   file_size: number;
   source_ip: string | null;
   source_ip: string | null;
   status: string;
   status: string;

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

@@ -54,7 +54,7 @@ function PendingUploadItem({
           <div className="flex items-center gap-3">
           <div className="flex items-center gap-3">
             <FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
             <FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
             <div>
             <div>
-              <p className="text-white font-medium">{upload.filename}</p>
+              <p className="text-white font-medium" title={upload.filename}>{upload.display_name || upload.filename}</p>
               <div className="flex items-center gap-2 text-xs text-bambu-gray">
               <div className="flex items-center gap-2 text-xs text-bambu-gray">
                 <span>{formatFileSize(upload.file_size)}</span>
                 <span>{formatFileSize(upload.file_size)}</span>
                 <span>·</span>
                 <span>·</span>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-BlyoHMPZ.js


+ 1 - 1
static/index.html

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio