Quellcode durchsuchen

Decide whether a 3MF is sliced by looking inside it (issue #2993)

An archive that showed the green GCODE badge could re-import into the
File Manager as a source-only project with no Print button, seemingly at
random.

Nothing was ever lost from the file. The download serves the stored
bytes verbatim and the G-code was still in the zip; the two sides simply
asked different questions. Archives looked inside the file. The library
looked at the filename. So a sliced 3MF stored as Foo.3mf rather than
Foo.gcode.3mf earned the badge and lost the Print button, and which one
you got depended on how the print had reached the printer -- a slicer's
LAN send names it .gcode.3mf, a per-plate export or a cloud-dispatched
print does not.

Both sides now ask one shared predicate about the zip itself, and every
route into the library classifies on content. Only the central directory
is read, and only when the name has not already settled it, so ingest
costs nothing extra -- the external scan opens each 3MF for its
thumbnail regardless. Rows already stored are re-checked once, internal
ones only: an external row points at a mount that may be slow or absent,
and startup is the worst place to discover that.

The Slice action moves with it. Its refusal to slice an output was as
name-bound as the Print gate, and without that a file that correctly
gained a Print button would have offered to re-slice its own G-code.
maziggy vor 1 Woche
Ursprung
Commit
2e405afcd1

+ 1 - 0
CHANGELOG.md

@@ -26,6 +26,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 
 ### Fixed
+- **Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993, reported via the in-app form)** — they never lost it. The download serves the stored file byte for byte, and the G-code was still in the zip; what differed was who was asked. On the archive side the answer came from the file itself — the green GCODE badge reads the layer count and print time that were parsed out of the plate G-code — while the library decided from the filename alone, so a sliced 3MF stored as `Foo.3mf` rather than `Foo.gcode.3mf` carried the badge and still came back as a source-only project with no Print button. That splits on how the print reached the printer, not on anything about the file — a slicer's LAN send names it `.gcode.3mf`, while a per-plate export or a cloud-dispatched print arrives as plain `.3mf` — which is why it looked random. Both sides now ask the same question of the zip itself, and every route into the library (upload, ZIP import, MakerWorld, external-folder scan) classifies on content rather than on the name. Files already in your library are re-checked once on the next start. The backend was always willing to print these, so this was only ever the interface refusing to offer something that would have worked; a genuine model file is unaffected, and one that now shows **Print** correctly stops offering **Slice**.
 - **Swapping a spool left the previous spool's preset name on the AMS slot card** — pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the card still read "Bambu ABS" against the new colour. The backend had it right all along: the RFID auto-assign rewrites the slot's stored preset the moment the tag is read. The browser simply never refetched it. The slot card reads that stored preset ahead of the filament id the printer is reporting, so one cached row outranked correct data arriving over the WebSocket — and because every other field on the card (colour, material, fill, K value) rides the status push and updated instantly, it surfaced as a single wrong line rather than an obviously stale card. The manual assign path already refreshed it; the RFID path did not. Spoolman mode was the worse half of the same bug: its AMS sync writes that same row but announced nothing at all, so there was no event to refresh on — it now reports each slot it changed or cleared. Two further changes make the card right without waiting on any of that: the slot's queries no longer sit behind the 3-second cascade debounce meant for print completion (a swap touches one slot, and any further event restarted that timer), and the card now ignores a stored preset whose filament id disagrees with what the printer reports in the slot, so the correct name is on screen from the status push alone. A hand-picked preset name still wins wherever the stored row and the slot agree, and a user or local preset — whose ids genuinely cannot be compared — is untouched.
 - **A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana)** — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's `Title` metadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently.
 - **A slow-but-healthy 3MF download was cut off at 30 seconds (#2957, reported by @doncaruana)** — `ftp_timeout` is handed to every download as *both* the socket inactivity timeout and the whole-transfer deadline, which makes its default a cap on how big a file a printer is allowed to serve. The reporter measured the same 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB 3MF at 105 s; an older 7.8 MB archive in his logs survived only because the transfer happened to finish inside the retry grace. None of those links were broken — they were slow, which is what the inactivity timeout exists to tell apart. The total deadline now follows the size the printer reports for the file, against the same pessimistic 25 KB/s floor the upload path has used since #2529. The extension is granted only once the printer has answered `SIZE`, so a printer that is not answering at all still fails on schedule and the executor queue wait #2572 bounded is unaffected, and it is capped at five minutes because the print-start handler holds a pooled database connection for the length of its 3MF hunt. A transfer that overruns even that stretched deadline is not retried, for the reason an overrunning upload has not been since #2529: the retry would spend another full deadline reaching the same conclusion.

+ 5 - 2
backend/app/api/routes/archives.py

@@ -53,6 +53,7 @@ from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    names_carry_gcode,
     select_plate_gcode_name,
 )
 
@@ -3522,8 +3523,10 @@ async def get_archive_capabilities(
         with zipfile.ZipFile(file_path, "r") as zf:
             names = zf.namelist()
 
-            # Check for G-code in the sliced file
-            has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
+            # Check for G-code in the sliced file. Shared with the library's
+            # file-type classification so the card's badge and what the File
+            # Manager makes of the same file cannot disagree (#2993).
+            has_gcode = names_carry_gcode(names)
 
             # Check for 3D model in sliced file (fallback if no source)
             if not has_model:

+ 33 - 10
backend/app/api/routes/library.py

@@ -88,6 +88,7 @@ from backend.app.utils.filename import (
 )
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
+    carries_gcode,
     default_plate_gcode_name,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
@@ -146,7 +147,7 @@ def get_library_files_dir() -> Path:
     return files_dir
 
 
-def classify_file_type(filename: str) -> str:
+def classify_file_type(filename: str, file_path: Path | str | None = None) -> str:
     """Return the canonical ``LibraryFile.file_type`` for *filename*.
 
     Compound extensions are preserved — a `.gcode.3mf` file (a sliced
@@ -158,12 +159,23 @@ def classify_file_type(filename: str) -> str:
     downstream gates (gcode download, file-type filter, thumbnail
     extraction) only need to handle one canonical name per file family.
     Files with no extension classify as ``unknown``.
+
+    Pass ``file_path`` and a ``.3mf`` is judged on what the zip actually holds
+    rather than on its name (#2993). The name is not evidence: a plate exported
+    from Studio or a print dispatched through the cloud reaches the archive as
+    ``Foo.3mf``, G-code and all, and downloading that and re-importing it used
+    to land a fully printable file in the library as a source-only project. The
+    file is not opened when the name already settles it, so the common case
+    still costs nothing.
     """
     lower = filename.lower()
     if lower.endswith(".gcode.3mf"):
         return "gcode.3mf"
     ext = os.path.splitext(lower)[1]
-    return ext[1:] if ext else "unknown"
+    file_type = ext[1:] if ext else "unknown"
+    if file_type == "3mf" and file_path is not None and carries_gcode(file_path):
+        return "gcode.3mf"
+    return file_type
 
 
 def get_library_thumbnails_dir() -> Path:
@@ -658,7 +670,7 @@ async def save_3mf_bytes_to_library(
         is_external=is_external,
         filename=filename,
         file_path=_stored_file_path(file_path, is_external),
-        file_type=classify_file_type(filename),
+        file_type=classify_file_type(filename, file_path),
         file_size=len(file_bytes),
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
@@ -1889,7 +1901,10 @@ async def scan_external_folder(
             except OSError:
                 continue
 
-            file_type = classify_file_type(filename)
+            # The zip is opened for the thumbnail immediately below either way,
+            # so judging a `.3mf` on its contents rather than its name (#2993)
+            # costs this scan nothing.
+            file_type = classify_file_type(filename, filepath)
 
             # Extract thumbnail for 3mf files (including .gcode.3mf sliced
             # outputs — those are 3MF zips on disk and carry the same
@@ -2234,12 +2249,12 @@ async def upload_file(
             validate_print_filename(filename)
         except InvalidFilenameError as e:
             raise HTTPException(status_code=400, detail=str(e)) from e
-        ext = os.path.splitext(filename)[1].lower()
-        # `file_type` is compound-aware (`gcode.3mf` for sliced outputs).
-        # `ext` stays the trailing extension because the on-disk filename
-        # uses it directly and the 3MF-parse branch below still gates on
+        # `ext` stays the trailing extension because the on-disk filename uses
+        # it directly and the 3MF-parse branch below still gates on
         # `ext == ".3mf"`, which is correct for both `.3mf` and `.gcode.3mf`.
-        file_type = classify_file_type(filename)
+        # `file_type` is compound-aware and is decided further down, once the
+        # bytes are on disk to be read.
+        ext = os.path.splitext(filename)[1].lower()
 
         # Verify folder exists if specified
         target_folder = None
@@ -2267,6 +2282,10 @@ async def upload_file(
         with open(file_path, "wb") as f:
             f.write(content)
 
+        # Now that the bytes are on disk the zip can settle what the name only
+        # guessed at: a sliced 3MF uploaded as `Foo.3mf` is a sliced 3MF (#2993).
+        file_type = classify_file_type(filename, file_path)
+
         # Calculate hash
         file_hash = calculate_file_hash(file_path)
 
@@ -2533,7 +2552,6 @@ async def extract_zip_file(
                     # Extract file
                     filename = os.path.basename(zip_path)
                     ext = os.path.splitext(filename)[1].lower()
-                    file_type = classify_file_type(filename)
 
                     # Generate unique filename for storage
                     unique_filename = f"{uuid.uuid4().hex}{ext}"
@@ -2546,6 +2564,11 @@ async def extract_zip_file(
                     with open(file_path, "wb") as f:
                         f.write(file_content)
 
+                    # Classified once the bytes are on disk so a sliced 3MF
+                    # named `Foo.3mf` inside the zip is recognised as sliced
+                    # (#2993) rather than trusted to say so in its name.
+                    file_type = classify_file_type(filename, file_path)
+
                     # Calculate hash
                     file_hash = calculate_file_hash(file_path)
 

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

@@ -887,6 +887,80 @@ async def _migrate_scope_run_filament_to_plate(conn) -> None:
         )
 
 
+async def _reclassify_sliced_3mf_library_files(conn) -> None:
+    """Re-type library rows holding a sliced 3MF that does not say so (#2993).
+
+    ``file_type`` was decided from the filename alone, so a sliced 3MF whose
+    name lacks the ``.gcode`` infix landed as a source-only project. That is
+    not a rare shape: a plate exported from Studio, or a print dispatched
+    through the cloud, reaches the archive as ``Foo.3mf`` with its G-code
+    intact, and downloading one and re-importing it produced a library file
+    Bambuddy refused to offer a Print button for. The forward fix classifies on
+    content; this pass reaches the rows already stored.
+
+    One-shot, for the same reason the #2614 backfill is: a genuine source 3MF
+    keeps matching ``file_type = '3mf'`` forever, so without the gate every
+    boot would re-open every model file in the library.
+
+    External rows are deliberately skipped. They point at a mount that may be
+    slow, unmounted, or enormous, and startup is the worst possible place to
+    find that out -- the folder's own scan re-types them with no such risk.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import carries_gcode
+
+    flag = "_backfill_2993_sliced_3mf_type_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT id, file_path FROM library_files "
+                    "WHERE file_type = '3mf' AND deleted_at IS NULL "
+                    "AND file_path IS NOT NULL AND file_path <> '' "
+                    "AND (is_external IS NULL OR is_external = :false_val)"
+                ),
+                {"false_val": False},
+            )
+        ).fetchall()
+
+        reclassified = 0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            # carries_gcode swallows a missing or unreadable file, so a library
+            # with holes in it still finishes the pass.
+            if not carries_gcode(path):
+                continue
+            await conn.execute(
+                text("UPDATE library_files SET file_type = 'gcode.3mf' WHERE id = :id"),
+                {"id": row.id},
+            )
+            reclassified += 1
+
+        if reclassified:
+            logger.info(
+                "[#2993] Re-typed %d library file(s) from source 3MF to sliced -- they carry G-code",
+                reclassified,
+            )
+
+        # Marked done even when nothing matched, so the scan never repeats.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _backfill_archive_bed_temperature(conn) -> None:
     """Fill in ``print_archives.bed_temperature`` for archives written before #2989.
 
@@ -4729,6 +4803,11 @@ async def run_migrations(conn):
     # already on disk. One-shot; see the function for why it is gated.
     await _backfill_archive_bed_temperature(conn)
 
+    # Backfill: library rows typed from the filename alone kept a sliced 3MF
+    # named `Foo.3mf` filed as a source-only project (#2993). Re-reads the zip
+    # already on disk. One-shot, internal rows only; see the function.
+    await _reclassify_sliced_3mf_library_files(conn)
+
     # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
     # whether a plug actually feeds the printer's own power — only then may an
     # auto-off mark the printer offline. Defaults to true so existing plugs

+ 33 - 0
backend/app/utils/threemf_tools.py

@@ -1210,6 +1210,39 @@ def default_plate_gcode_name(names: list[str]) -> str | None:
     return gcodes[0]
 
 
+def names_carry_gcode(names: list[str]) -> bool:
+    """Is this 3MF a sliced file — does it carry printer-executable G-code?
+
+    One definition, because several of them is the bug (#2993). The archive
+    side judged a file by what it holds -- the card's GCODE badge reads the
+    layer count and print time parsed out of the plate G-code, and
+    ``/archives/{id}/capabilities`` scanned the zip -- while the library judged
+    it by its filename. So a sliced 3MF stored as ``Foo.3mf`` rather than
+    ``Foo.gcode.3mf`` carried the badge and still re-imported as a source-only
+    project. This is the answer for anything asking the zip directly.
+
+    Defers to ``default_plate_gcode_name`` rather than testing for
+    ``Metadata/plate_<n>.gcode``, so a slicer that lays its output out some
+    other way is judged by the same rule everywhere.
+    """
+    return default_plate_gcode_name(names) is not None
+
+
+def carries_gcode(file_path: Path | str) -> bool:
+    """``names_carry_gcode`` for a file on disk. False for anything unreadable.
+
+    Only the zip's central directory is read — no member is decompressed — so
+    this is cheap enough to run on every ingested file.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            return names_carry_gcode(zf.namelist())
+    except (OSError, zipfile.BadZipFile):
+        # Not a zip, gone, or unreadable. Callers treat that as "no G-code
+        # visible", which is what they did before this check existed.
+        return False
+
+
 # The header block sits at the very top of the plate G-code. Read only that
 # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
 # would inflate all of it to reach ~40 lines.

+ 106 - 0
backend/tests/unit/test_sliced_3mf_named_as_source_2993.py

@@ -0,0 +1,106 @@
+"""A sliced 3MF is sliced whatever it is called (#2993).
+
+Downloading an archive that shows the green GCODE badge and re-importing it
+produced, for some archives, a source-only project with no Print button. The
+G-code was never lost -- the download serves the stored file byte for byte.
+What differed was who was asked: the Archives card looked inside the zip, while
+the library decided from the filename alone.
+
+That splits on how the print reached the printer, which is why it looked
+random. Bambu Studio's LAN send names a file ``Foo.gcode.3mf``; a per-plate
+export or a cloud-dispatched print arrives as ``Foo.3mf``, G-code and all. Both
+archive fine, both badge fine, and only the second one came back as a project.
+
+These tests pin the two halves to one answer, and pin the escape hatch that
+keeps ingest cheap: the file is opened only when the name has not already
+settled it.
+"""
+
+from __future__ import annotations
+
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from backend.app.api.routes.library import classify_file_type
+from backend.app.utils.threemf_tools import carries_gcode, names_carry_gcode
+
+SLICED = ["3D/3dmodel.model", "Metadata/plate_3.gcode", "Metadata/plate_3.gcode.md5"]
+SOURCE = ["3D/3dmodel.model", "Metadata/plate_1.png", "Metadata/project_settings.config"]
+
+
+def _write_3mf(path: Path, names: list[str]) -> Path:
+    with zipfile.ZipFile(path, "w") as zf:
+        for name in names:
+            zf.writestr(name, b"x")
+    return path
+
+
+class TestTheSharedAnswer:
+    def test_plate_gcode_makes_it_sliced(self):
+        assert names_carry_gcode(SLICED) is True
+
+    def test_a_project_export_is_not(self):
+        assert names_carry_gcode(SOURCE) is False
+
+    def test_a_slicer_that_does_not_use_plate_naming_still_counts(self):
+        """Deferring to default_plate_gcode_name rather than matching
+        ``Metadata/plate_<n>.gcode`` is the point: a file that is executable on
+        a printer must not be filed as a model because of where its G-code
+        member sits."""
+        assert names_carry_gcode(["3D/3dmodel.model", "output.gcode"]) is True
+
+    def test_an_unreadable_file_reads_as_not_sliced(self, tmp_path):
+        """The pre-existing behaviour for anything that cannot be opened. An
+        ingest path must not fail on a truncated upload."""
+        broken = tmp_path / "broken.3mf"
+        broken.write_bytes(b"PK\x03\x04not-a-zip")
+
+        assert carries_gcode(broken) is False
+        assert carries_gcode(tmp_path / "absent.3mf") is False
+
+
+class TestClassification:
+    def test_the_reported_file(self, tmp_path):
+        """The whole bug in one line: same bytes, name says project."""
+        sliced = _write_3mf(tmp_path / "Labyrinth.3mf", SLICED)
+
+        assert classify_file_type("Labyrinth.3mf") == "3mf"
+        assert classify_file_type("Labyrinth.3mf", sliced) == "gcode.3mf"
+
+    def test_a_genuine_project_stays_a_project(self, tmp_path):
+        """The guard that keeps this from swallowing the model library: an
+        unsliced 3MF must not gain a Print button."""
+        source = _write_3mf(tmp_path / "Labyrinth.3mf", SOURCE)
+
+        assert classify_file_type("Labyrinth.3mf", source) == "3mf"
+
+    def test_a_name_that_already_says_sliced_needs_no_file(self):
+        """Not a micro-optimisation: the upload path classifies before the
+        bytes are anywhere, and the external scan runs over a mount. Neither
+        may depend on the file being openable when the name is enough."""
+        assert classify_file_type("Labyrinth.gcode.3mf", Path("/does/not/exist.3mf")) == "gcode.3mf"
+
+    @pytest.mark.parametrize("filename", ["model.stl", "preview.png", "README", "model.gcode"])
+    def test_nothing_else_is_sniffed(self, filename, tmp_path):
+        """Only `.3mf` is ambiguous. Handing a path for anything else must not
+        change its type or open the file."""
+        before = classify_file_type(filename)
+
+        assert classify_file_type(filename, _write_3mf(tmp_path / "z.3mf", SLICED)) == before
+
+
+class TestTheTwoSidesAgree:
+    def test_the_archives_badge_and_the_library_now_say_the_same_thing(self, tmp_path):
+        """The card promised G-code and the File Manager denied it. Asserted
+        against the archive endpoint's own expression, so this fails if that
+        side is ever pointed back at a private copy of the rule."""
+        from backend.app.api.routes.archives import names_carry_gcode as archives_predicate
+
+        sliced = _write_3mf(tmp_path / "Labyrinth.3mf", SLICED)
+        with zipfile.ZipFile(sliced) as zf:
+            badge = archives_predicate(zf.namelist())
+
+        assert badge is True
+        assert classify_file_type("Labyrinth.3mf", sliced) == "gcode.3mf"

+ 182 - 0
backend/tests/unit/test_sliced_3mf_reclassify_migration_2993.py

@@ -0,0 +1,182 @@
+"""The one-shot pass that reaches library rows already stored (#2993).
+
+The forward fix classifies on content, which does nothing for the files a user
+has already imported -- the reporter's whole complaint was about files they had
+downloaded and imported before this existed. This backfill re-opens them.
+
+Two properties matter as much as the re-typing itself. It must run once: a
+genuine source 3MF keeps matching ``file_type = '3mf'`` forever, so an ungated
+pass would re-open every model in the library on every boot. And it must leave
+external rows alone: they point at a mount that can be slow, unmounted, or
+enormous, and startup is the worst place to discover that.
+"""
+
+from __future__ import annotations
+
+import zipfile
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture(autouse=True)
+def base_dir(tmp_path, monkeypatch):
+    """Relative file_path values resolve against settings.base_dir."""
+    from backend.app.core.database import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+def _write_3mf(path: Path, *, sliced: bool) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    names = ["3D/3dmodel.model"] + (["Metadata/plate_3.gcode"] if sliced else ["Metadata/plate_1.png"])
+    with zipfile.ZipFile(path, "w") as zf:
+        for name in names:
+            zf.writestr(name, b"x")
+
+
+async def _insert_file(conn, *, file_id: int, filename: str, path: str, external: bool = False) -> None:
+    await conn.execute(
+        text(
+            "INSERT INTO library_files "
+            "(id, filename, file_path, file_type, file_size, is_external, print_count) "
+            "VALUES (:id, :filename, :path, '3mf', 0, :ext, 0)"
+        ),
+        {"id": file_id, "filename": filename, "path": path, "ext": 1 if external else 0},
+    )
+
+
+async def _types(engine) -> dict[int, str]:
+    async with engine.connect() as conn:
+        return dict((await conn.execute(text("SELECT id, file_type FROM library_files ORDER BY id"))).fetchall())
+
+
+@pytest.mark.asyncio
+async def test_a_stored_sliced_3mf_is_re_typed(engine, base_dir):
+    _write_3mf(base_dir / "files/sliced.3mf", sliced=True)
+    _write_3mf(base_dir / "files/model.3mf", sliced=False)
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="files/sliced.3mf")
+        await _insert_file(conn, file_id=2, filename="Labyrinth.3mf", path="files/model.3mf")
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    types = await _types(engine)
+    assert types[1] == "gcode.3mf"
+    assert types[2] == "3mf", "a genuine project must not gain a Print button"
+
+
+@pytest.mark.asyncio
+async def test_a_missing_file_does_not_stop_the_pass(engine, base_dir):
+    """A library with holes in it still finishes -- the row after the gap is
+    the one that proves it."""
+    _write_3mf(base_dir / "files/sliced.3mf", sliced=True)
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="gone.3mf", path="files/gone.3mf")
+        await _insert_file(conn, file_id=2, filename="Labyrinth.3mf", path="files/sliced.3mf")
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    types = await _types(engine)
+    assert types[1] == "3mf"
+    assert types[2] == "gcode.3mf"
+
+
+@pytest.mark.asyncio
+async def test_external_rows_are_left_to_their_own_scan(engine, base_dir):
+    """Even a sliced one. The folder's scan re-types it without putting a
+    possibly-unreachable mount on the startup path."""
+    _write_3mf(base_dir / "mount/sliced.3mf", sliced=True)
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="mount/sliced.3mf", external=True)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    assert (await _types(engine))[1] == "3mf"
+
+
+@pytest.mark.asyncio
+async def test_it_runs_once(engine, base_dir):
+    """Every boot re-runs the migration set. A source 3MF matches the query
+    forever, so without the flag this re-opens the whole library each time."""
+    _write_3mf(base_dir / "files/model.3mf", sliced=False)
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="Labyrinth.3mf", path="files/model.3mf")
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    # Second boot: the file becomes readable-as-sliced, and must be ignored,
+    # which can only happen if the pass is genuinely gated rather than merely
+    # idempotent in its effect.
+    _write_3mf(base_dir / "files/model.3mf", sliced=True)
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    assert (await _types(engine))[1] == "3mf"

+ 120 - 0
frontend/src/__tests__/pages/FileManagerSlicedNamedAsSource.test.tsx

@@ -0,0 +1,120 @@
+/**
+ * A sliced 3MF named as a project must still offer Print (#2993).
+ *
+ * Download an archive that carries the green GCODE badge, re-import it, and the
+ * file came back without a Print button. Nothing was lost from the file -- the
+ * download serves the stored bytes verbatim -- but the library decided what it
+ * was from the filename, and a per-plate export or a cloud-dispatched print
+ * arrives as `Foo.3mf` with its G-code intact.
+ *
+ * The backend has always been willing to print these (`library.py` takes both
+ * `3mf` and `gcode.3mf` on the G-code path), so this was the UI refusing to
+ * offer something that would have worked.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const base = {
+  file_path: '/library/x',
+  file_size: 1048576,
+  folder_id: null,
+  thumbnail_path: null,
+  print_name: null,
+  print_time_seconds: null,
+  print_count: 0,
+  duplicate_count: 0,
+  created_at: '2024-01-01T00:00:00Z',
+};
+
+/** All three named `.3mf`, so only file_type can tell them apart. */
+const files = [
+  // The reported file: sliced, named as a project.
+  { ...base, id: 1, filename: 'Labyrinth - Plate 3.3mf', file_type: 'gcode.3mf' },
+  // A genuine project. Must NOT gain a Print button.
+  { ...base, id: 2, filename: 'Labyrinth source.3mf', file_type: '3mf' },
+];
+
+function serve(rows: unknown[]) {
+  server.use(
+    http.get('/api/v1/library/folders', () => HttpResponse.json([])),
+    http.get('/api/v1/library/files', () => HttpResponse.json(rows)),
+    http.get('/api/v1/library/stats', () =>
+      HttpResponse.json({
+        total_files: rows.length,
+        total_folders: 0,
+        total_size_bytes: 1,
+        disk_free_bytes: 1,
+        disk_total_bytes: 2,
+      }),
+    ),
+  );
+}
+
+/** The row's own action strip, so one file's buttons can't answer for another. */
+async function rowFor(filename: string): Promise<HTMLElement> {
+  const label = await screen.findByText(filename);
+  const row = label.closest('div[class*="grid-cols-"]');
+  if (!row) throw new Error(`no list row around ${filename}`);
+  return row as HTMLElement;
+}
+
+describe('FileManagerPage — a sliced 3MF that is not named like one (#2993)', () => {
+  beforeEach(() => {
+    // localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
+    // the view mode has to be programmed rather than written. List view is the
+    // one that puts the row actions on screen without opening a kebab menu.
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+      key === 'library-view-mode' ? 'list' : null,
+    );
+    serve(files);
+  });
+
+  afterEach(() => {
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
+  });
+
+  it('offers Print for the file whose type says it is sliced', async () => {
+    render(<FileManagerPage />);
+
+    await waitFor(() => expect(screen.getByText('Labyrinth - Plate 3.3mf')).toBeInTheDocument());
+    const row = await rowFor('Labyrinth - Plate 3.3mf');
+
+    await waitFor(() => {
+      expect(within(row).getByTitle('Print')).toBeInTheDocument();
+    });
+  });
+
+  it('does not also offer to slice it, which would re-slice its own G-code', async () => {
+    // The Slice gate refused a `.gcode.3mf` *name*, so without this the file
+    // would gain a Print button and a Slice button at the same time.
+    render(<FileManagerPage />);
+
+    await waitFor(() => expect(screen.getByText('Labyrinth - Plate 3.3mf')).toBeInTheDocument());
+    const row = await rowFor('Labyrinth - Plate 3.3mf');
+
+    expect(within(row).queryByTitle('Slice')).not.toBeInTheDocument();
+  });
+
+  it('still offers Slice on the genuine project', async () => {
+    render(<FileManagerPage />);
+
+    await waitFor(() => expect(screen.getByText('Labyrinth source.3mf')).toBeInTheDocument());
+    const row = await rowFor('Labyrinth source.3mf');
+
+    expect(within(row).getByTitle('Slice')).toBeInTheDocument();
+  });
+
+  it('does not offer Print for a genuine project of the same shape', async () => {
+    render(<FileManagerPage />);
+
+    await waitFor(() => expect(screen.getByText('Labyrinth source.3mf')).toBeInTheDocument());
+    const row = await rowFor('Labyrinth source.3mf');
+
+    expect(within(row).queryByTitle('Print')).not.toBeInTheDocument();
+  });
+});

+ 50 - 0
frontend/src/__tests__/utils/isSlicedLibraryFile.test.ts

@@ -0,0 +1,50 @@
+/**
+ * The Print button must follow what the file is, not what it is called (#2993).
+ *
+ * A plate exported from Studio, or a print dispatched through the cloud, is a
+ * fully sliced 3MF named `Foo.3mf`. Deciding from the extension hid the Print
+ * button on files the backend was perfectly willing to print -- `library.py`
+ * has always accepted either type on the G-code path.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { isSlicedLibraryFile } from '../../utils/libraryFiles';
+
+describe('isSlicedLibraryFile', () => {
+  it('trusts file_type over a filename that disagrees', () => {
+    // The reported file: sliced, named as a project.
+    expect(isSlicedLibraryFile({ filename: 'Labyrinth.3mf', file_type: 'gcode.3mf' })).toBe(true);
+  });
+
+  it('leaves a genuine project alone', () => {
+    expect(isSlicedLibraryFile({ filename: 'Labyrinth.3mf', file_type: '3mf' })).toBe(false);
+  });
+
+  it('accepts raw gcode', () => {
+    expect(isSlicedLibraryFile({ filename: 'plate.gcode', file_type: 'gcode' })).toBe(true);
+  });
+
+  it('does not treat a model as printable', () => {
+    expect(isSlicedLibraryFile({ filename: 'thing.stl', file_type: 'stl' })).toBe(false);
+  });
+
+  describe('the name answers where file_type does not', () => {
+    it('with no file_type at all', () => {
+      expect(isSlicedLibraryFile({ filename: 'Labyrinth.gcode.3mf' })).toBe(true);
+      expect(isSlicedLibraryFile({ filename: 'plate.GCODE' })).toBe(true);
+      expect(isSlicedLibraryFile({ filename: 'Labyrinth.3mf' })).toBe(false);
+    });
+
+    it('with an empty one', () => {
+      expect(isSlicedLibraryFile({ filename: 'Labyrinth.gcode.3mf', file_type: '' })).toBe(true);
+      expect(isSlicedLibraryFile({ filename: 'Labyrinth.gcode.3mf', file_type: null })).toBe(true);
+    });
+
+    it('and on a row stored before the backfill, where file_type is the stale half', () => {
+      // Mirrors classify_file_type, which returns `gcode.3mf` for this name
+      // without opening the file. Neither side may drop a Print button that
+      // the other would keep.
+      expect(isSlicedLibraryFile({ filename: 'Labyrinth.gcode.3mf', file_type: '3mf' })).toBe(true);
+    });
+  });
+});

+ 13 - 25
frontend/src/pages/FileManagerPage.tsx

@@ -75,7 +75,8 @@ import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
 import { assignableProjects } from '../utils/projectTree';
-import { isApiSliceableFilename, isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -742,12 +743,6 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
   );
 }
 
-// Helper to check if a file is sliced (printable)
-function isSlicedFilename(filename: string): boolean {
-  const lower = filename.toLowerCase();
-  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
-}
-
 // File Card
 interface FileCardProps {
   file: LibraryFileListItem;
@@ -786,7 +781,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
   const canDelete = canModify('library', 'delete', file.created_by_id);
 
   const menuItems: ContextMenuItem[] = [];
-  if (onPrint && isSlicedFilename(file.filename)) {
+  if (onPrint && isSlicedLibraryFile(file)) {
     menuItems.push({
       label: t('common.print'),
       // The action stays visually distinct now that the menu component styles
@@ -797,8 +792,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
     });
   }
-  if ((useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename))
-      && (useSlicerApi ? onSlice : onOpenInSlicer)) {
+  if (isSliceableLibraryFile(file, !!useSlicerApi) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
     menuItems.push({
       label: t('slice.action'),
       icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
@@ -807,7 +801,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
     });
   }
-  if (onRunPipeline && useSlicerApi && isApiSliceableFilename(file.filename)) {
+  if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true)) {
     menuItems.push({
       label: t('library.runWithPipeline.actionLabel'),
       icon: <Play className="w-4 h-4" />,
@@ -1574,17 +1568,11 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
-  // Helper to check if a file is sliced (printable)
-  const isSlicedFile = useCallback((filename: string) => {
-    const lower = filename.toLowerCase();
-    return lower.endsWith('.gcode') || lower.includes('.gcode.');
-  }, []);
-
   // Get sliced files from selection
   const selectedSlicedFiles = useMemo(() => {
     if (!files) return [];
-    return files.filter(f => selectedFiles.includes(f.id) && isSlicedFile(f.filename));
-  }, [files, selectedFiles, isSlicedFile]);
+    return files.filter(f => selectedFiles.includes(f.id) && isSlicedLibraryFile(f));
+  }, [files, selectedFiles]);
 
   // The clicked file's variant group, so printing one member offers the rest
   // without the user re-selecting them (#2570).
@@ -2448,7 +2436,7 @@ export function FileManagerPage() {
                       // full-page gcode viewer the archive card uses, so
                       // the two paths feel consistent. STL / source 3MF
                       // continue to use the in-app 3D model viewer modal.
-                      if (isSlicedFilename(f.filename)) {
+                      if (isSlicedLibraryFile(f)) {
                         navigate(`/gcode-viewer?library_file=${f.id}`);
                       } else {
                         setViewerFile(f);
@@ -2602,7 +2590,7 @@ export function FileManagerPage() {
                     </div>
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
-                      {isSlicedFilename(file.filename) && (
+                      {isSlicedLibraryFile(file) && (
                         <>
                           <button
                             onClick={() => hasPermission('queue:create') && setPrintFile(file)}
@@ -2618,7 +2606,7 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
-                      {(settings?.use_slicer_api ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) && (
+                      {isSliceableLibraryFile(file, !!settings?.use_slicer_api) && (
                         <button
                           onClick={() => {
                             if (!canSlice()) return;
@@ -2635,7 +2623,7 @@ export function FileManagerPage() {
                           {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                         </button>
                       )}
-                      {(settings?.use_slicer_api ?? false) && isApiSliceableFilename(file.filename) && (
+                      {(settings?.use_slicer_api ?? false) && isSliceableLibraryFile(file, true) && (
                         <button
                           onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
                           className={`p-1.5 rounded transition-colors ${
@@ -2653,7 +2641,7 @@ export function FileManagerPage() {
                         <button
                           onClick={() => {
                             if (!hasPermission('library:read')) return;
-                            if (isSlicedFilename(file.filename)) {
+                            if (isSlicedLibraryFile(file)) {
                               navigate(`/gcode-viewer?library_file=${file.id}`);
                             } else {
                               setViewerFile(file);
@@ -2881,7 +2869,7 @@ export function FileManagerPage() {
           onSliceWithBambuddy={
             // Only offer in-app slicing on files the SliceModal can actually
             // handle (matches the file-row Cog visibility check at :2127).
-            isApiSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
+            isSliceableLibraryFile(viewerFile, true) && hasPermission('library:upload')
               ? () => {
                   const f = viewerFile;
                   setViewerFile(null);

+ 3 - 8
frontend/src/pages/ProjectDetailPage.tsx

@@ -47,12 +47,7 @@ import { PrintModal } from '../components/PrintModal';
 // Project edit modal (reused from ProjectsPage)
 import { ProjectModal } from './ProjectsPage';
 import { getCurrencySymbol } from '../utils/currency';
-
-// Returns true for sliced (printable) files: .gcode and .gcode.3mf
-function isSlicedFilename(filename: string): boolean {
-  const lower = filename.toLowerCase();
-  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
-}
+import { isSlicedLibraryFile } from '../utils/libraryFiles';
 
 function formatFilament(grams: number): string {
   if (grams >= 1000) {
@@ -286,7 +281,7 @@ export function ProjectDetailPage() {
   const completeSets = useMemo(() => {
     const target = project?.target_sets;
     if (!target || !allProjectFiles) return null;
-    const printable = allProjectFiles.filter((f) => isSlicedFilename(f.filename));
+    const printable = allProjectFiles.filter((f) => isSlicedLibraryFile(f));
     if (printable.length === 0) return null;
     return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target)));
   }, [project?.target_sets, allProjectFiles, progressByFileId]);
@@ -1059,7 +1054,7 @@ export function ProjectDetailPage() {
                     ) : (
                       <div className="space-y-1 pl-3">
                         {files.map((file) => {
-                          const printable = isSlicedFilename(file.filename);
+                          const printable = isSlicedLibraryFile(file);
                           return (
                             <div
                               key={file.id}

+ 44 - 0
frontend/src/utils/libraryFiles.ts

@@ -0,0 +1,44 @@
+import { isApiSliceableFilename, isSliceableFilename } from './slicer';
+
+/**
+ * Is a library file sliced — does it carry printer-executable G-code?
+ *
+ * The name alone is not evidence (#2993). A plate exported from Studio, or a
+ * print dispatched through the cloud, is a fully sliced 3MF called `Foo.3mf`,
+ * and deciding from the extension filed those as source-only projects with no
+ * Print button — while the Archives card, which looks inside the zip, showed
+ * the same file's green GCODE badge. The backend now classifies on content, so
+ * `file_type` answers where the name cannot.
+ *
+ * Either signal is enough, which mirrors `classify_file_type` on the backend:
+ * it returns `gcode.3mf` for a `.gcode.3mf` name without opening the file at
+ * all, and only consults the zip when the name has not already settled it. A
+ * row stored before the backfill therefore keeps its Print button on the
+ * strength of its name.
+ */
+export function isSlicedLibraryFile(file: {
+  filename: string;
+  file_type?: string | null;
+}): boolean {
+  const fileType = (file.file_type || '').toLowerCase();
+  if (fileType === 'gcode' || fileType === 'gcode.3mf') return true;
+  const lower = (file.filename || '').toLowerCase();
+  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
+}
+
+/**
+ * Is a library file something a slicer can take as *input*?
+ *
+ * `isSliceableFilename` already refuses a `.gcode` / `.gcode.3mf` name — the
+ * point being that a sliced file is an output, not an input. That refusal was
+ * as name-bound as the Print gate it sits beside (#2993), so once a sliced
+ * `Foo.3mf` correctly gained a Print button it would have been offered a
+ * Slice one as well: re-slicing its own G-code.
+ */
+export function isSliceableLibraryFile(
+  file: { filename: string; file_type?: string | null },
+  useSlicerApi: boolean,
+): boolean {
+  if (isSlicedLibraryFile(file)) return false;
+  return useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename);
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-CQmjKcng.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-1thJVCSQ.js"></script>
+    <script type="module" crossorigin src="/assets/index-CQmjKcng.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DR-aOvsI.css">
   </head>
   <body>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.