Ver Fonte

Fix: unquote compatible_printers from Orca Cloud profiles (#3381)

Paul Traina há 22 horas atrás
pai
commit
c4c5c70422

+ 2 - 4
backend/app/api/routes/library.py

@@ -118,6 +118,7 @@ from backend.app.utils.filename import (
 from backend.app.utils.library_paths import library_photos_dir, remove_library_photos_dir
 from backend.app.utils.printer_models import is_gcode_compatible
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
+from backend.app.utils.slicer_strings import first_slicer_string
 from backend.app.utils.threemf_tools import (
     carries_gcode,
     default_plate_gcode_name,
@@ -4092,10 +4093,7 @@ def _preset_default_colour(profile: dict) -> str:
     ``filament_colour: ["#00AE42"]``. Bambu Studio consumes the default in the
     GUI when a project is created, not in ``--load-filaments``.
     """
-    raw = profile.get("default_filament_colour")
-    if isinstance(raw, list):
-        raw = raw[0] if raw else None
-    return raw.strip() if isinstance(raw, str) else ""
+    return first_slicer_string(profile.get("default_filament_colour")) or ""
 
 
 def _patch_filament_colours(

+ 12 - 26
backend/app/api/routes/slicer_presets.py

@@ -75,6 +75,7 @@ from backend.app.utils.printer_models import (
     normalize_printer_model,
     normalize_printer_model_id,
 )
+from backend.app.utils.slicer_strings import first_slicer_string, slicer_strings
 
 logger = logging.getLogger(__name__)
 
@@ -277,18 +278,10 @@ async def _fetch_orca_cloud_presets(
             filament_colour: str | None = None
             if slot == "filament":
                 # Bambu/Orca filament profiles store these as single-element
-                # arrays (the historical multi-extruder shape). Extract the
-                # first non-empty element for both.
-                ft = content.get("filament_type")
-                if isinstance(ft, list) and ft and isinstance(ft[0], str):
-                    filament_type = ft[0]
-                elif isinstance(ft, str):
-                    filament_type = ft
-                fc = content.get("default_filament_colour")
-                if isinstance(fc, list) and fc and isinstance(fc[0], str):
-                    filament_colour = fc[0]
-                elif isinstance(fc, str):
-                    filament_colour = fc
+                # lists (the historical multi-extruder shape) — a JSON array or
+                # an INI-style string. Take the first non-empty element.
+                filament_type = first_slicer_string(content.get("filament_type"))
+                filament_colour = first_slicer_string(content.get("default_filament_colour"))
             preset = UnifiedPreset(
                 id=str(preset_id),
                 name=str(name),
@@ -357,19 +350,13 @@ def _content_compatible_printers(content: dict) -> list[str] | None:
     ``content``, and an entry of the sidecar's bundled listing.
 
     Orca profiles carry it as a list of printer-preset names (the same shape
-    ``orca_profiles.py`` stores on import); a single-printer profile may store
-    a bare string. Returns ``None`` for missing / empty / malformed values so
-    the caller leaves the field unset and the SliceModal falls back to the
-    name-based matcher, rather than treating "no data" as "compatible with
-    nothing".
+    ``orca_profiles.py`` stores on import) or as one INI-style string — see
+    :func:`slicer_strings`. Returns ``None`` for missing / empty / malformed
+    values so the caller leaves the field unset and the SliceModal falls back
+    to the name-based matcher, rather than treating "no data" as "compatible
+    with nothing".
     """
-    raw = content.get("compatible_printers")
-    if isinstance(raw, str):
-        raw = [raw]
-    if not isinstance(raw, list):
-        return None
-    names = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
-    return names or None
+    return slicer_strings(content.get("compatible_printers")) or None
 
 
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
@@ -384,8 +371,7 @@ def _parse_compatible_printers(raw: str | None) -> list[str] | None:
         return None
     if not isinstance(data, list):
         return None
-    names = [s for s in data if isinstance(s, str) and s.strip()]
-    return names or None
+    return slicer_strings(data) or None
 
 
 def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:

+ 15 - 0
backend/app/services/preset_resolver.py

@@ -42,6 +42,7 @@ from backend.app.services.bambu_cloud import (
 )
 from backend.app.services.bambu_cloud_credentials import get_stored_token
 from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
+from backend.app.utils.slicer_strings import unescape_strings_cstyle
 
 logger = logging.getLogger(__name__)
 
@@ -68,6 +69,12 @@ _SLOT_TO_PROFILE_TYPE = {
     "filament": "filament",
 }
 
+# String-list options Orca Cloud can return INI-encoded (#3380). The sidecar
+# only fixes up `compatible_printers` when it is an array (#3250), so a
+# process made for a user printer preset passes the Slice dialog's check and
+# is then refused by the CLI.
+_ORCA_STRING_LIST_FIELDS = ("compatible_printers", "filament_type", "default_filament_colour")
+
 
 async def resolve_preset_ref(
     db: AsyncSession,
@@ -259,6 +266,14 @@ async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRe
         # same forced pin to `"system"` for the same reason — see the
         # Bambu Cloud branch above.
         content = {**content, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
+        for key in _ORCA_STRING_LIST_FIELDS:
+            raw = content.get(key)
+            # Parsed the way the slicer would, empty items included, so
+            # per-extruder positions survive. A string it can't parse is
+            # forwarded as is.
+            items = unescape_strings_cstyle(raw) if isinstance(raw, str) else None
+            if items is not None:
+                content[key] = items
     return json.dumps(content)
 
 

+ 4 - 6
backend/app/services/slicer_filament_resolver.py

@@ -51,6 +51,7 @@ from backend.app.utils.filament_ids import (
     setting_id_to_filament_id,
 )
 from backend.app.utils.filament_types import is_material_name
+from backend.app.utils.slicer_strings import first_slicer_string
 
 logger = logging.getLogger(__name__)
 
@@ -237,7 +238,8 @@ def _preset_filament_type(raw: object) -> str | None:
     """Read a slicer preset's ``filament_type`` field.
 
     Bambu Studio and OrcaSlicer both store it as a one-element array
-    (``["PLA"]``); some hand-written and older profiles store a bare string.
+    (``["PLA"]``); some hand-written and older profiles store a bare string,
+    and Orca Cloud can return it INI-quoted (``'"PLA"'``, #3380).
     ``orca_profiles._extract_filament_fields`` accepts both and this has to
     agree with it, since that is what fills ``LocalPreset.filament_type``.
 
@@ -248,11 +250,7 @@ def _preset_filament_type(raw: object) -> str | None:
     ``filament_type`` is a product line, which is the case this whole module
     exists to keep out of an AMS slot.
     """
-    if isinstance(raw, list):
-        raw = raw[0] if raw else None
-    if isinstance(raw, str) and raw.strip():
-        return raw.strip()
-    return None
+    return first_slicer_string(raw)
 
 
 async def resolve_slicer_filament(

+ 83 - 0
backend/app/utils/slicer_strings.py

@@ -0,0 +1,83 @@
+"""Slicer string-list options as plain lists (#3380)."""
+
+
+def slicer_strings(raw: object) -> list[str]:
+    """A slicer string-list option as its non-empty strings.
+
+    Orca Cloud returns a list the way the slicer writes it to its INI files —
+    one string of quoted, ``\\``-escaped items joined by ``;``
+    (``"Bambu Lab X1 Carbon 0.4 nozzle";"Bambu Lab H2C 0.4 nozzle"``). Left
+    quoted, a ``compatible_printers`` name never equals the selected printer
+    preset and the SliceModal hides the profile for every printer. A JSON
+    array is never INI-encoded, so its items are kept as they are. A string
+    the slicer itself can't read counts as no data rather than failing the
+    caller.
+    """
+    if isinstance(raw, list):
+        items = [s for s in raw if isinstance(s, str)]
+    elif isinstance(raw, str):
+        items = unescape_strings_cstyle(raw) or []
+    else:
+        return []
+    return [s for s in items if s.strip()]
+
+
+def first_slicer_string(raw: object) -> str | None:
+    """The first non-empty item of a slicer string-list option, stripped.
+
+    Filament profiles store per-extruder options such as ``filament_type`` as
+    one-element lists; pre-pick matching only needs the first slot.
+    """
+    return next((s.strip() for s in slicer_strings(raw)), None)
+
+
+def unescape_strings_cstyle(text: str) -> list[str] | None:
+    """Port of OrcaSlicer's ``unescape_strings_cstyle`` (libslic3r/Config.cpp).
+
+    Unquoted items are read up to ``;`` as is; quoted items decode ``\\n``,
+    ``\\r`` and ``\\<char>``. Empty items are kept, so per-extruder positions
+    survive. Returns ``None`` where the slicer reports a parse failure: an
+    unterminated quote, a trailing ``\\`` inside quotes, or anything but
+    spaces, tabs and ``;`` after a closing quote.
+    """
+    out: list[str] = []
+    i, n = 0, len(text)
+    if n == 0:
+        return out
+    while True:
+        while text[i] in " \t":
+            i += 1
+            if i == n:
+                return out
+        buf: list[str] = []
+        if text[i] == '"':
+            i += 1
+            while i < n and text[i] != '"':
+                c = text[i]
+                if c == "\\":
+                    i += 1
+                    if i == n:
+                        return None
+                    c = {"r": "\r", "n": "\n"}.get(text[i], text[i])
+                buf.append(c)
+                i += 1
+            if i == n:
+                return None
+            i += 1
+        else:
+            while i < n and text[i] != ";":
+                buf.append(text[i])
+                i += 1
+        out.append("".join(buf))
+        if i == n:
+            return out
+        while text[i] in " \t":
+            i += 1
+            if i == n:
+                return out
+        if text[i] != ";":
+            return None
+        i += 1
+        if i == n:
+            out.append("")
+            return out

+ 78 - 0
backend/tests/unit/services/test_preset_resolver.py

@@ -376,6 +376,84 @@ async def test_orca_cloud_unwraps_content():
     svc_mock.close.assert_awaited_once()
 
 
+@pytest.mark.asyncio
+async def test_orca_cloud_string_lists_become_arrays():
+    """#3380: Orca Cloud returns list options INI-encoded. The sidecar only
+    adds a user printer preset's system parent to ``compatible_printers``
+    when it is an array, so they're forwarded as arrays, parsed the way the
+    slicer would: empty items keep their per-extruder positions. Other
+    options are left alone."""
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    svc_mock = MagicMock()
+    svc_mock.get_profile = AsyncMock(
+        return_value={
+            "content": {
+                "compatible_printers": '"Bambu Lab H2D 0.4 nozzle - Skew";"Bambu Lab X1 Carbon 0.4 nozzle"',
+                "compatible_prints": "",
+                "filament_type": "PLA",
+                "default_filament_colour": '"";"#FF0000"',
+                "compatible_printers_condition": '"bad" quote',
+            }
+        }
+    )
+    svc_mock.close = AsyncMock()
+    with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
+        out = await preset_resolver._resolve_orca_cloud(
+            db, user, PresetRef(source="orca_cloud", id="abc"), slot="filament"
+        )
+    payload = json.loads(out)
+    assert payload["compatible_printers"] == ["Bambu Lab H2D 0.4 nozzle - Skew", "Bambu Lab X1 Carbon 0.4 nozzle"]
+    assert payload["compatible_prints"] == ""
+    assert payload["filament_type"] == ["PLA"]
+    assert payload["default_filament_colour"] == ["", "#FF0000"]
+    assert payload["compatible_printers_condition"] == '"bad" quote'
+
+
+@pytest.mark.asyncio
+async def test_orca_cloud_unparseable_string_list_is_forwarded_as_is():
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    svc_mock = MagicMock()
+    svc_mock.get_profile = AsyncMock(return_value={"content": {"compatible_printers": '"X1C" junk'}})
+    svc_mock.close = AsyncMock()
+    with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
+        out = await preset_resolver._resolve_orca_cloud(
+            db, user, PresetRef(source="orca_cloud", id="abc"), slot="process"
+        )
+    assert json.loads(out)["compatible_printers"] == '"X1C" junk'
+
+
+@pytest.mark.asyncio
+async def test_orca_cloud_json_array_lists_are_forwarded_unchanged():
+    """Only a string is INI-parsed: an array that already is one must not be
+    re-read, or its names would merge into one and the slice be refused."""
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    svc_mock = MagicMock()
+    svc_mock.get_profile = AsyncMock(
+        return_value={
+            "content": {
+                "compatible_printers": ["X1C 0.4", "H2C 0.4"],
+                "filament_type": ["PLA", "PETG"],
+                "default_filament_colour": ["", "#FF0000"],
+            }
+        }
+    )
+    svc_mock.close = AsyncMock()
+    with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
+        out = await preset_resolver._resolve_orca_cloud(
+            db, user, PresetRef(source="orca_cloud", id="abc"), slot="filament"
+        )
+    payload = json.loads(out)
+    assert payload["compatible_printers"] == ["X1C 0.4", "H2C 0.4"]
+    assert payload["filament_type"] == ["PLA", "PETG"]
+    assert payload["default_filament_colour"] == ["", "#FF0000"]
+
+
 @pytest.mark.asyncio
 async def test_orca_cloud_auth_error_returns_401():
     db = MagicMock()

+ 20 - 0
backend/tests/unit/services/test_slicer_filament_resolver.py

@@ -410,6 +410,26 @@ class TestOrcaCloudIsTheFirstSource:
         assert type_override == "PLA"
         svc.close.assert_awaited()
 
+    @pytest.mark.asyncio
+    async def test_an_ini_quoted_filament_type_is_read_like_the_slicer(self):
+        """#3380: Orca Cloud can return ``filament_type`` INI-quoted."""
+        db = MagicMock()
+        svc = self._svc(
+            {"id": self.ORCA_ID, "name": "My PETG", "content": {"filament_id": "P56e1be0", "filament_type": '"PETG"'}}
+        )
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(return_value=svc),
+        ):
+            _idx, _sid, _brand, type_override = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament=self.ORCA_ID,
+                slicer_filament_name=None,
+                material="PETG",
+            )
+        assert type_override == "PETG"
+
     @pytest.mark.asyncio
     async def test_a_profile_with_no_filament_id_leaves_the_caller_its_fallback(self):
         db = MagicMock()

+ 13 - 1
backend/tests/unit/test_filament_colour_2977.py

@@ -111,9 +111,21 @@ class TestThePresetDefaultReader:
         # store a one-element array.
         assert _preset_default_colour({"default_filament_colour": "#123456"}) == "#123456"
 
+    def test_reads_the_ini_string_form(self):
+        # Orca Cloud returns list options the way the slicer writes its INI
+        # files (#3380).
+        assert _preset_default_colour({"default_filament_colour": '"#123456";"#654321"'}) == "#123456"
+
     @pytest.mark.parametrize(
         "profile",
-        [{}, {"default_filament_colour": []}, {"default_filament_colour": None}, {"default_filament_colour": "  "}],
+        [
+            {},
+            {"default_filament_colour": []},
+            {"default_filament_colour": None},
+            {"default_filament_colour": "  "},
+            {"default_filament_colour": '""'},
+            {"default_filament_colour": [""]},
+        ],
     )
     def test_absent_or_empty_reads_as_no_colour(self, profile):
         assert _preset_default_colour(profile) == ""

+ 56 - 0
backend/tests/unit/test_slicer_presets.py

@@ -533,6 +533,62 @@ class TestFetchOrcaCloudPresets:
 
         assert [p.inherits for p in slots["printer"]] == ["Bambu Lab H2D 0.4 nozzle", None, None]
 
+    @pytest.mark.asyncio
+    async def test_quoted_compatible_printers_are_unquoted(self):
+        """#3380: Orca Cloud returns list settings INI-style — one string of
+        quoted items joined by ``;``. Left as-is, the names never equal the
+        selected printer preset and the SliceModal hides the profile."""
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {
+                    "id": "f1",
+                    "name": "SUNLU PLA Meta @Bambu Lab X1 Carbon 0.4 nozzle",
+                    "content": {
+                        "type": "filament",
+                        "filament_type": "PLA",
+                        "default_filament_colour": '""',
+                        "compatible_printers": '"Bambu Lab X1 Carbon 0.4 nozzle";"Bambu Lab H2C 0.4 nozzle"',
+                    },
+                },
+                {"id": "f2", "name": "Only quotes", "content": {"type": "filament", "compatible_printers": '""'}},
+                {
+                    "id": "f3",
+                    "name": "Escaped",
+                    "content": {
+                        "type": "filament",
+                        "default_filament_colour": '"#FF0000"',
+                        "compatible_printers": '"Printer \\"A\\"";"Bambu Lab A1 0.4 nozzle"',
+                    },
+                },
+                {
+                    "id": "f4",
+                    "name": "Text after a closing quote",
+                    "content": {"type": "filament", "filament_type": "PLA", "compatible_printers": '"X1C" P1S'},
+                },
+            ]
+        )
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+
+        meta, only_quotes, escaped, bad = slots["filament"]
+        assert meta.compatible_printers == ["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab H2C 0.4 nozzle"]
+        assert meta.filament_type == "PLA"
+        assert meta.filament_colour is None
+        assert only_quotes.compatible_printers is None
+        assert escaped.compatible_printers == ['Printer "A"', "Bambu Lab A1 0.4 nozzle"]
+        assert escaped.filament_colour == "#FF0000"
+        # One unparseable value reads as "no data" and the rest of the listing survives.
+        assert bad.compatible_printers is None
+        assert bad.filament_type == "PLA"
+
     @pytest.mark.asyncio
     async def test_filament_preset_carries_the_preset_it_was_saved_from(self):
         """#3403: a filament saved with only the keys the user changed states no

+ 55 - 0
backend/tests/unit/test_slicer_strings.py

@@ -0,0 +1,55 @@
+"""slicer_strings: slicer string-list options as a JSON array or an INI-style string (#3380)."""
+
+import pytest
+
+from backend.app.utils.slicer_strings import first_slicer_string, slicer_strings, unescape_strings_cstyle
+
+
+def test_ini_string():
+    assert slicer_strings('"Bambu Lab X1 Carbon 0.4 nozzle";"Bambu Lab H2C 0.4 nozzle"') == [
+        "Bambu Lab X1 Carbon 0.4 nozzle",
+        "Bambu Lab H2C 0.4 nozzle",
+    ]
+    assert slicer_strings("PLA;PETG") == ["PLA", "PETG"]
+    assert slicer_strings('"Printer \\"A\\""') == ['Printer "A"']
+
+
+def test_quoted_items_decode_like_orca():
+    assert slicer_strings('"a\\nb";"c\\rd";"e\\\\f"') == ["a\nb", "c\rd", "e\\f"]
+    assert slicer_strings(' \t"X1C" \t; "P1S"') == ["X1C", "P1S"]
+
+
+def test_unquoted_items_are_read_as_is():
+    assert slicer_strings("C:\\x") == ["C:\\x"]
+    assert slicer_strings("Bambu Lab P2S 0.4 nozzle") == ["Bambu Lab P2S 0.4 nozzle"]
+
+
+def test_json_array_items_are_kept_as_they_are():
+    """A JSON array is never INI-encoded: a ``;``, ``\\`` or ``"`` in a name stays."""
+    assert slicer_strings(["X1C", "", 5, "A1"]) == ["X1C", "A1"]
+    assert slicer_strings(["My X1C; tuned", '"C:\\profiles\\X1C"']) == ["My X1C; tuned", '"C:\\profiles\\X1C"']
+
+
+def test_the_slicer_parse_keeps_empty_items():
+    """Per-extruder lists keep their positions."""
+    assert unescape_strings_cstyle('"";"#FF0000"') == ["", "#FF0000"]
+    assert unescape_strings_cstyle('""') == [""]
+    assert unescape_strings_cstyle('"#FF0000";') == ["#FF0000", ""]
+    assert unescape_strings_cstyle("") == []
+    assert unescape_strings_cstyle('"X1C" P1S') is None
+
+
+def test_first_item():
+    assert first_slicer_string('"";" #FF0000 "') == "#FF0000"
+    assert first_slicer_string(["PLA"]) == "PLA"
+    assert first_slicer_string('""') is None
+
+
+@pytest.mark.parametrize("raw", ['"X1C" P1S', '"X1C', '"X1C\\', '"X1C";"P1S'])
+def test_string_orca_cannot_parse_is_no_data(raw):
+    assert slicer_strings(raw) == []
+
+
+@pytest.mark.parametrize("raw", [None, "", '""', "  ", ";", [], ["", "  "], {"a": 1}, 5])
+def test_empty_and_malformed(raw):
+    assert slicer_strings(raw) == []