Procházet zdrojové kódy

fix(library): show the filename, not the embedded 3MF Title (#1489)

  File Manager cards, search and sort keyed off file_metadata.print_name,
  which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That
  title is the in-app project title — generic "Exported 3D Model" for any
  Bambu Studio "Save As", a marketing title for a MakerWorld download —
  and almost never the filename the user saved as. A card for
  Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename
  round-trip, since the Rename dialog disables Save while the name is
  unchanged.

  The slicer-output write path already dropped print_name for this exact
  reason; the four other paths that store parsed 3MF metadata onto a
  LibraryFile did not — external-folder scan, managed multipart upload,
  the multi-file ZIP-upload branch, and MakerWorld import.

  Add a shared _without_print_name() helper and apply it at all four
  import paths; switch the slicer path to it so there is one rule. A
  LibraryFile's display name is its filename — only PrintArchive carries
  a real print_name, which is untouched. Remove the now-redundant
  filename->print_name mirroring in the rename route.

  Add a one-time idempotent data migration (_migrate_drop_library_print_name,
  SQLite json_remove / PostgreSQL jsonb key-removal branched on
  is_sqlite()) so libraries imported before the fix correct themselves
  without the rename workaround. No frontend change: print_name || filename
  yields the filename once print_name is gone.

  Tests: 6 new in test_library_print_name.py cover _without_print_name and
  the migration (incl. idempotency, siblings preserved, null metadata).
  SQLite migration branch verified by test; PostgreSQL branch verified
  against a real Postgres instance.
maziggy před 5 dny
rodič
revize
71e58e6cf1

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 27 - 15
backend/app/api/routes/library.py

@@ -364,6 +364,23 @@ def _clean_3mf_metadata(obj):
     return obj
     return obj
 
 
 
 
+def _without_print_name(metadata: dict | None) -> dict | None:
+    """Drop the embedded 3MF Title (``print_name``) from library-file metadata.
+
+    The 3MF ``<metadata name="Title">`` holds the in-app project title — the
+    generic ``"Exported 3D Model"`` for a Bambu Studio "Save As", a marketing
+    title for a MakerWorld download — never the filename the user saved as.
+    The FileManager keys its display name, search and sort off ``print_name``,
+    so storing it makes every card show the wrong name (#1489). A library
+    file's display name is its filename; only ``PrintArchive`` carries a real
+    ``print_name``. Returns the input unchanged when there's nothing to strip;
+    otherwise a new dict (never mutates the argument).
+    """
+    if not metadata or "print_name" not in metadata:
+        return metadata
+    return {k: v for k, v in metadata.items() if k != "print_name"}
+
+
 async def save_3mf_bytes_to_library(
 async def save_3mf_bytes_to_library(
     db: AsyncSession,
     db: AsyncSession,
     *,
     *,
@@ -435,7 +452,7 @@ async def save_3mf_bytes_to_library(
         file_size=len(file_bytes),
         file_size=len(file_bytes),
         file_hash=file_hash,
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
-        file_metadata=metadata,
+        file_metadata=_without_print_name(metadata),
         source_type=source_type,
         source_type=source_type,
         source_url=source_url,
         source_url=source_url,
         created_by_id=owner_id,
         created_by_id=owner_id,
@@ -1378,7 +1395,7 @@ async def scan_external_folder(
                 file_size=stat.st_size,
                 file_size=stat.st_size,
                 file_hash=None,  # Skip hashing external files for performance
                 file_hash=None,  # Skip hashing external files for performance
                 thumbnail_path=thumbnail_path,
                 thumbnail_path=thumbnail_path,
-                file_metadata=file_metadata,
+                file_metadata=_without_print_name(file_metadata),
             )
             )
             db.add(db_file)
             db.add(db_file)
             added += 1
             added += 1
@@ -1655,7 +1672,7 @@ async def upload_file(
             file_size=len(content),
             file_size=len(content),
             file_hash=file_hash,
             file_hash=file_hash,
             thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
             thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
-            file_metadata=metadata if metadata else None,
+            file_metadata=_without_print_name(metadata) if metadata else None,
             created_by_id=current_user.id if current_user else None,
             created_by_id=current_user.id if current_user else None,
         )
         )
         db.add(library_file)
         db.add(library_file)
@@ -1908,7 +1925,7 @@ async def extract_zip_file(
                         file_size=len(file_content),
                         file_size=len(file_content),
                         file_hash=file_hash,
                         file_hash=file_hash,
                         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
                         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
-                        file_metadata=metadata if metadata else None,
+                        file_metadata=_without_print_name(metadata) if metadata else None,
                         created_by_id=current_user.id if current_user else None,
                         created_by_id=current_user.id if current_user else None,
                     )
                     )
                     db.add(library_file)
                     db.add(library_file)
@@ -3152,14 +3169,10 @@ async def slice_and_persist(
     except Exception as exc:
     except Exception as exc:
         logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
         logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
 
 
-    # The parsed 3MF metadata carries a `print_name` lifted from the source
-    # file's embedded settings (BambuStudio always sets this; OrcaSlicer
-    # often leaves it blank). The FileManager listing prefers print_name
-    # over filename for display, which makes a sliced row indistinguishable
-    # from its source. Drop print_name so the listing falls back to the
-    # actual filename — which already ends in ".gcode.3mf" and self-describes
-    # as the sliced output.
-    metadata: dict = {k: v for k, v in parsed_metadata.items() if k != "print_name"}
+    # Drop the embedded `print_name` (see _without_print_name) so the sliced
+    # row's display falls back to its ".gcode.3mf" filename instead of the
+    # source file's project title, which would make the two indistinguishable.
+    metadata: dict = dict(_without_print_name(parsed_metadata) or {})
     metadata.update(
     metadata.update(
         {
         {
             "print_time_seconds": result.print_time_seconds,
             "print_time_seconds": result.print_time_seconds,
@@ -3637,9 +3650,8 @@ async def update_file(
         if "/" in data.filename or "\\" in data.filename:
         if "/" in data.filename or "\\" in data.filename:
             raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
             raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
         file.filename = data.filename
         file.filename = data.filename
-        # Also update print_name in file_metadata so the display name matches
-        if file.file_metadata and "print_name" in file.file_metadata:
-            file.file_metadata = {**file.file_metadata, "print_name": data.filename}
+        # No print_name to keep in sync — library files display by filename,
+        # and _without_print_name strips the embedded 3MF Title on import (#1489).
 
 
     if data.folder_id is not None:
     if data.folder_id is not None:
         if data.folder_id == 0:
         if data.folder_id == 0:

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

@@ -415,6 +415,39 @@ async def _migrate_normalize_printer_ids(conn) -> None:
             await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
             await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
 
 
 
 
+async def _migrate_drop_library_print_name(conn) -> None:
+    """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
+
+    Library files stored the 3MF's ``<metadata name="Title">`` as
+    ``file_metadata.print_name`` — generic ("Exported 3D Model") for Bambu
+    Studio exports, a marketing title for MakerWorld downloads — and the
+    FileManager wrongly preferred it over the filename for the card label,
+    search and sort. New imports no longer store it; this clears it from rows
+    imported before the fix so existing libraries don't need a rename
+    round-trip. Idempotent — rows without the key are untouched.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        if is_sqlite():
+            await conn.execute(
+                text(
+                    "UPDATE library_files SET file_metadata = json_remove(file_metadata, '$.print_name') "
+                    "WHERE json_extract(file_metadata, '$.print_name') IS NOT NULL"
+                )
+            )
+        else:
+            # file_metadata is a JSON (not JSONB) column — cast to jsonb for the
+            # key-exists test (jsonb_exists, avoiding the `?` operator which
+            # clashes with driver parameter syntax) and the `- key` removal.
+            await conn.execute(
+                text(
+                    "UPDATE library_files SET file_metadata = (file_metadata::jsonb - 'print_name')::json "
+                    "WHERE jsonb_exists(file_metadata::jsonb, 'print_name')"
+                )
+            )
+
+
 async def _migrate_update_auto_link_constraint(conn) -> None:
 async def _migrate_update_auto_link_constraint(conn) -> None:
     """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
     """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
 
 
@@ -2615,6 +2648,10 @@ async def run_migrations(conn):
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
         )
         )
 
 
+    # Data migration: drop the embedded 3MF Title (`print_name`) from library
+    # file metadata so the FileManager displays the filename, not the title (#1489).
+    await _migrate_drop_library_print_name(conn)
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 95 - 0
backend/tests/unit/test_library_print_name.py

@@ -0,0 +1,95 @@
+"""Tests for library files displaying the filename, not the embedded 3MF Title (#1489).
+
+The 3MF ``<metadata name="Title">`` is the in-app project title — generic
+("Exported 3D Model") for a Bambu Studio "Save As", a marketing title for a
+MakerWorld download — never the filename the user saved as. The FileManager
+keyed its display name / search / sort off ``file_metadata.print_name``, so
+storing the Title made every card show the wrong name. ``_without_print_name``
+strips it on import; ``_migrate_drop_library_print_name`` clears it from rows
+imported before the fix.
+"""
+
+from sqlalchemy import select
+
+from backend.app.api.routes.library import _without_print_name
+from backend.app.core.database import _migrate_drop_library_print_name
+from backend.app.models.library import LibraryFile
+
+# --- _without_print_name ---------------------------------------------------
+
+
+def test_strips_print_name_keeps_siblings():
+    cleaned = _without_print_name({"print_name": "Exported 3D Model", "print_time_seconds": 100})
+    assert cleaned == {"print_time_seconds": 100}
+
+
+def test_none_passes_through():
+    assert _without_print_name(None) is None
+
+
+def test_dict_without_print_name_returned_unchanged():
+    meta = {"print_time_seconds": 50}
+    # No copy needed when there's nothing to strip — same object back.
+    assert _without_print_name(meta) is meta
+
+
+def test_does_not_mutate_input():
+    original = {"print_name": "Whatever", "filament_used_grams": 12}
+    cleaned = _without_print_name(original)
+    assert original == {"print_name": "Whatever", "filament_used_grams": 12}  # untouched
+    assert cleaned == {"filament_used_grams": 12}
+
+
+def test_print_name_only_collapses_to_empty_dict():
+    assert _without_print_name({"print_name": "Exported 3D Model"}) == {}
+
+
+# --- _migrate_drop_library_print_name --------------------------------------
+
+
+async def test_migration_strips_print_name_from_existing_rows(db_session, monkeypatch):
+    """Rows imported before the fix get print_name cleared; siblings and rows
+    that never had it are untouched. Idempotent on a second run.
+
+    The test DB is SQLite; is_sqlite() reads settings.database_url (not the
+    test engine), so pin it to exercise the SQLite branch deterministically.
+    The PostgreSQL branch is verified against a real PG instance separately."""
+    monkeypatch.setattr("backend.app.core.database.is_sqlite", lambda: True)
+    db_session.add_all(
+        [
+            LibraryFile(
+                filename="halloween.3mf",
+                file_path="/a",
+                file_type="3mf",
+                file_size=1,
+                file_metadata={"print_name": "Haunted House", "print_time_seconds": 100},
+            ),
+            LibraryFile(
+                filename="no_meta.3mf",
+                file_path="/b",
+                file_type="3mf",
+                file_size=1,
+                file_metadata={"print_time_seconds": 50},
+            ),
+            LibraryFile(
+                filename="null_meta.3mf",
+                file_path="/c",
+                file_type="3mf",
+                file_size=1,
+                file_metadata=None,
+            ),
+        ]
+    )
+    await db_session.commit()
+
+    conn = await db_session.connection()
+    await _migrate_drop_library_print_name(conn)
+    await _migrate_drop_library_print_name(conn)  # idempotent
+
+    db_session.expire_all()
+    rows = (await db_session.execute(select(LibraryFile).order_by(LibraryFile.filename))).scalars().all()
+    by_name = {r.filename: r for r in rows}
+
+    assert by_name["halloween.3mf"].file_metadata == {"print_time_seconds": 100}
+    assert by_name["no_meta.3mf"].file_metadata == {"print_time_seconds": 50}
+    assert by_name["null_meta.3mf"].file_metadata is None

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů