From fdd6ec416f72ea0cecc8144232902faa5c8e6da5 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 22 Jul 2026 10:44:05 +0200 Subject: [PATCH] fix(slicer): classify filament profiles by their real printer scope, not just their name (#2628 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slicing for a P2S failed with "filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle" — naming a profile shown nowhere in the dialog. The picked profile was "Overture PLA Matte @0.2", whose inheritance chain roots in that X1C profile. The dialog classifies a profile by its compatible_printers list and falls back to reading the printer out of its name. That name carries no model, and the list — present on the imported copy — is not shipped by every source: Bambu Cloud omits it deliberately (rate limits), and Orca Cloud shipped it but Bambuddy only mined filament type and colour from the same content. Orca Cloud entries now carry their own compatible_printers, and the existing same-name enrichment bridge carries the list onto entries that lack one, in both directions between the cloud tiers. A bare "@" name tag is read as a nozzle size as a last resort: it can rule a printer out but never rules one in, and implausible values are ignored rather than guessed at. --- CHANGELOG.md | 2 + backend/app/api/routes/library.py | 8 +- backend/app/api/routes/slicer_presets.py | 71 ++++- backend/app/services/slicer_3mf_convert.py | 45 ++- .../unit/services/test_slicer_3mf_convert.py | 80 ++++- backend/tests/unit/test_slicer_presets.py | 147 +++++++++ .../__tests__/components/SliceModal.test.tsx | 80 +++++ .../utils/slicerPrinterMatch.test.ts | 124 ++++++++ frontend/src/utils/slicerPrinterMatch.ts | 97 +++++- .../{index-D0xVmIdo.js => index-DVKbiORm.js} | 298 +++++++++--------- static/index.html | 2 +- 11 files changed, 767 insertions(+), 187 deletions(-) rename static/assets/{index-D0xVmIdo.js => index-DVKbiORm.js} (86%) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc71bc2eb..7c42c6c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file. - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing. ### Fixed +- **Slicing a single plate failed on a filament slot the plate doesn't even use, with no way to fix it from the UI (#2628, reporter @michaelklos)** — Slicing plate 2 of a local multi-plate 3MF for an A1 failed with **"filament preset (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle"**. Slot 1 was labelled "Filament 1 (PLA) — not used by this plate", held `SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle`, and its dropdown was greyed out — so the slice was blocked by a slot the plate never touches and the user couldn't correct. Slicing **all** plates worked. **Root cause, two independent defects.** (1) The unused-slot substitution — which replaces the profile in every slot the plate doesn't paint with, so the slicer's validators don't judge the slice on slots its G-code never touches — always copied from **slot 1**. When slot 1 is itself the unused one, that's a no-op (the reporter's log even shows it: `Substituted slot-1 filament for unused slot(s) [1]`), and with several unused slots it actively spread slot 1's foreign profile across all of them — the same poisoning #1851 removed from the picker. (2) The dialog's printer-compatibility matcher only understood BambuStudio's short `@BBL ` tag. Profiles you save yourself carry the **full** printer name instead (`… @Bambu Lab H2D 0.4 nozzle`), which the matcher classified as "can't tell" — indistinguishable from compatible — so an H2D-scoped filament was offered in the main dropdown list, auto-picked for the slot on metadata score, and handed to the slicer. **Fix.** The substitution now copies from the plate's lowest **used** slot, so an unused slot can no longer block a slice regardless of what was baked into the source file; if a plate's used slots fall outside the submitted list, the picks are left untouched rather than substituted from a slot the plate doesn't use. And the matcher now reads both tag shapes — the same two forms the AMS slot dialog has parsed since #1623 — including a trailing `(Custom)` suffix and a stray earlier `@` in the name, so a profile scoped to another printer is never auto-picked and is grouped under **Other printers** where you can still choose it deliberately. A profile whose tag names no recognisable Bambu printer stays unclassified and keeps its place in the list, exactly as before. Covered by matcher tests (long-form mismatch and self-match, display-name-vs-short-code models, the nozzle filter, `(Custom)` suffix, the A1/A1 Mini alias, unrecognisable tags, stray `@`), picker tests (the reporter's registry: the H2D profile loses to the A1 one despite a better colour and tier score, but still wins for its own printer), and substitution tests (anchors on the first used slot, doesn't poison sibling unused slots, deterministic lowest-used anchor, support slots as anchor, and the out-of-range no-op). +- **A filament profile could be auto-picked for a printer it doesn't belong to when its name doesn't say which printer that is (#2628 follow-up)** — Slicing for a P2S failed with **"filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle"** — naming a profile that appeared nowhere in the slice dialog. The dialog showed `Overture PLA Matte @0.2` in every slot, including the one the plate actually uses; the slicer resolves that profile's inheritance chain and validates the X1C system profile at its root. **Root cause.** The dialog decides whether a profile fits the selected printer from the profile's own `compatible_printers` list, and falls back to reading the printer out of its NAME. This profile's name carries a nozzle size but no model, so the name fallback couldn't classify it — and the list, though present on the imported copy, is not shipped by every source: Bambu Cloud omits it from its listing on purpose (the per-profile endpoint is rate-limited), and Orca Cloud's listing carried it but Bambuddy only read the filament type and colour out of it. "Can't tell" is treated as usable, so the profile scored its way into the auto-pick for a printer it was never built for. **Fix.** Orca Cloud profiles now surface their own `compatible_printers` (it was already in the data Orca returns — no extra request), and the existing same-name bridge that lends Bambu Cloud entries their filament type and colour from another source now lends the compatible-printer list too, in both directions between the cloud sources. So whichever copy of a profile knows its printers teaches the ones that don't. As a last resort for profiles no source can classify, a bare `@` tag in the name is now read as a nozzle size: it can rule a printer out (0.2 profile, 0.4 printer) but never rules one in, since a size says nothing about the model — and a number that can't be a nozzle (`@2026`) is ignored rather than guessed at. Profiles that still can't be classified keep their place in the list exactly as before; ones that can are grouped under **Other printers**, where you can still pick them deliberately. Covered by preset-listing tests (Orca list extraction incl. the bare-string form, malformed/empty lists staying unclassified, the bridge in both directions and for both process and filament, never overwriting a list an entry already has, no-donor staying unclassified, and the borrowed list being copied rather than shared across the per-user caches) and matcher tests (0.2-vs-0.4 rejection, matching size staying unclassified, the `0.2 nozzle` / `0.2mm` spellings, numeric `0.20` vs `0.2`, implausible sizes ignored, and model-bearing tags still taking the model path). - **Switching off an accessory smart plug at the end of a print knocked the printer into "Unknown" and stalled the queue (#2629)** — With an end-of-print auto-off on a plug that powers a *filter fan* (not the printer), the printer flipped to **Unknown** the moment the plug switched off and the queue stopped dispatching to it until a manual **Force Refresh**. The printer itself never went anywhere — MQTT traffic continued a second later. **Root cause, two parts.** (1) Bambuddy treats *any* plug linked to a printer as that printer's power supply: every auto-off — time delay, temperature delay, the time-of-day schedule, a resumed-after-restart off, and a manual off from the plug card — immediately marked the printer offline, whether or not the plug feeds it. (2) That mark was **unrecoverable**. It forces `connected=False` and the state to `unknown`; `connected` heals on the very next MQTT message, but the state does not — the printer state is only rewritten when a status frame carries `gcode_state`, and the steady-state frames a P1S sends are partial. So `unknown` stuck until the next full status push, and the scheduler (which dispatches only to `IDLE`/`FINISH`/`FAILED`) treated the printer as permanently unavailable. **Fix.** The offline mark is now an explicitly *presumed* power cut: the state it overwrites is remembered, and the presumption is undone as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut (a frame that does carry `gcode_state` still wins, and the recovery re-broadcasts so the UI un-greys). A printer whose power really was cut sends nothing, so it correctly stays offline — this also repairs the same stuck-`unknown` for a genuine printer plug whose MQTT resumes without a full push. On top of that, each plug now carries a **Powers the printer** toggle (shown when a printer is linked, in both the plug card and the add/edit dialog): leave it on for the plug that feeds the printer, turn it off for accessories — filter fan, chamber light, enclosure heater — and switching those off no longer touches the printer's state at all. Existing plugs are migrated as power plugs, so nothing changes until you say otherwise. The same flag also fixes the queue's power-on step, which used to pick whichever linked plug came first and could spend the whole power-on timeout waiting for a filter fan to boot a printer; it now picks the plug flagged as the power source. Covered by an end-to-end regression test that drives the real MQTT client, printer manager and scheduler through the reported sequence (accessory off → printer keeps talking → queue dispatches again; real power cut → stays offline), MQTT-client tests (presumed off remembers and restores the state, a partial frame recovers it, a real `gcode_state` overrides it, request-topic traffic does not count as proof of life, a reconnect discards the saved state, and a genuine second power cut is not undone), smart-plug tests (accessory plugs switch off without marking the printer offline on all four off-paths, power plugs keep the old behaviour), scheduler tests (power-plug selection), and migration tests on both SQLite and PostgreSQL. - **A2L "AMS Lite" slots showed as empty and never deducted filament** — On an A2L with the 4-slot AMS Lite attached, physically loaded slots could display as empty, filament usage was never deducted from the loaded spool, and linking an AMS-Lite slot to a Spoolman spool failed outright. **Root cause.** The A2L reports its AMS Lite as unit **id 16**, but the firmware is internally inconsistent about it: its slot-presence bitmasks sit at the bit positions for unit **6** (bit base 24), and it reports the actively-feeding slot (`tray_now`) as a **local** 0-3 index rather than a global tray id. Bambuddy's `ams_id*4+slot` convention, fed the raw id 16, probed bit positions 64-67 (always zero) and marked every loaded slot empty; the local `tray_now` was read as a global id, so usage was attributed to the wrong spool (or dropped); and the `ams_id <= 7` database constraint rejected id-16 Spoolman links. **Fix.** The AMS Lite is now normalised from unit **16 → 6** at the MQTT ingest boundary, so its global tray ids land at **24-27** — matching the firmware's own bit base, working with every existing `ams_id*4+slot` consumer (slot presence, usage tracking, deficit warnings, the scheduler, Load/Unload), colliding with nothing, and passing the database constraint. The local `tray_now` is globalised to `24+slot` so deduction hits the right spool, the valid-tray guards accept the 24-27 range, and the printer card labels the unit "AMS Lite". Prints dispatched to the Lite build the correct `ams_mapping2` (`{ams_id:16, slot_id:0-3}`) and flat mapping (local 0-3), both confirmed against the firmware's own mapping. Outbound slot commands (set filament, reset, load/unload, calibration, RFID refresh) translate the normalised id 6 back to the physical 16 on the wire, centralised in one helper. The whole normalisation is self-scoping — only unit id 16 is ever touched, so every other printer and AMS type is byte-for-byte unaffected. Verified against the reporter's live captures (slot presence via `tray_exist_bits`, and `tray_now` while printing a known physical slot); mixed setups (a regular AMS attached alongside the Lite) are out of scope and log a warning, and one uncaptured wire encoding (the physical global tray field on `load`/`calibration` commands) is extrapolated and isolated to the single translation helper. - **Bambu Cloud kept dropping to "sign-in expired" and forcing constant re-logins, even while cloud features still worked** — Since the #2562 status rework, the Bambu Cloud sign-in would flip to "expired" shortly after logging in, over and over, with nothing in the logs to explain it. **Root cause.** The rework made a genuine 401 from Bambu durably record the stored token as dead (`cloud_token_invalid_at`) — correct in principle, but it treated **any** HTTP 401 from **any** cloud or MakerWorld call as a dead token. Bambu returns 401 for plenty of non-fatal reasons (an endpoint-, region- or scope-specific refusal; a Cloudflare-edge blip; a brief backend hiccup), so a single stray 401 from any one call — including a background poll — durably signed the whole cloud integration out until the next manual re-login. Because the flag lives in the database, a setup running more than one Bambuddy instance against the same database made it worse: a stray 401 seen by either instance signed the user out in both. **Fix.** Invalidation now fires only for Bambu's documented token-expiry response — `{"code":4,"error":"Please login."}` — and never for a plain or unparseable 401, which is treated as transient (the request fails, but the session is left signed in). The same signature gate is applied to the MakerWorld path, which shares the token. A genuinely expired token is still detected and surfaced exactly as before; what stops is the false "expired" on a working session. Covered by service tests for both engines: `code:4` and the "Please login." text invalidate, while a benign `code:1`/`forbidden` 401, an unparseable 401, and (for MakerWorld) a signature-less 401 with a token do not. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 62b141273..6459a1858 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -3590,9 +3590,11 @@ async def _run_slicer_with_fallback( # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes # BambuStudio reject the slice with "the temperature difference of # the filaments used is too large" (exit 194) even though the G-code - # never touches the unused slot. Replace unused-slot entries with the - # slot-1 selection before the real slice so the loaded-filament set - # is materially homogeneous. + # never touches the unused slot; a default scoped to another printer + # gets it rejected with "filament preset (slot N) is not compatible + # with printer …" (#2628). Replace unused-slot entries with the + # plate's lowest used slot before the real slice so the loaded set is + # materially homogeneous and printer-correct. if is_3mf and request.plate is not None: from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index dd5b707e3..f11e8c2dd 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/backend/app/api/routes/slicer_presets.py @@ -259,15 +259,23 @@ async def _fetch_orca_cloud_presets( filament_colour = fc[0] elif isinstance(fc, str): filament_colour = fc - slots[slot].append( - UnifiedPreset( - id=str(preset_id), - name=str(name), - source="orca_cloud", - filament_type=filament_type, - filament_colour=filament_colour, - ) + preset = UnifiedPreset( + id=str(preset_id), + name=str(name), + source="orca_cloud", + filament_type=filament_type, + filament_colour=filament_colour, ) + if slot in ("process", "filament"): + # The profile's own compatible-printer list, straight out of + # the content Orca already hands us (#2628). Without it the + # SliceModal falls back to reading the printer out of the + # profile NAME — and a profile whose name carries no model + # ("Overture PLA Matte @0.2") then reads as "can't tell", + # which the picker treats as usable and auto-picks for a + # printer the profile was never built for. + preset.compatible_printers = _content_compatible_printers(content) + slots[slot].append(preset) _orca_cloud_cache[cache_key] = (now, slots) return slots, "ok" finally: @@ -297,6 +305,25 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset return slots +def _content_compatible_printers(content: dict) -> list[str] | None: + """Pull ``compatible_printers`` out of an inline profile content dict. + + 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". + """ + 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 + + def _parse_compatible_printers(raw: str | None) -> list[str] | None: """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset names. Return the parsed list, or ``None`` on missing / malformed data so @@ -442,6 +469,16 @@ def _enrich_cloud_metadata( in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason this function exists post-#1712 — without the enrich the Bambu Cloud tier can't score in ``pickFilamentForSlot``. + + Compatibility merge (#2628): the same name bridge carries + ``compatible_printers`` onto any process / filament entry that lacks it. + Bambu Cloud never ships the list, so a profile whose NAME carries no + printer model reads as "compatibility unknown" — which the SliceModal + treats as usable and auto-picks for whatever printer is selected. When + the very same profile is also present as a local import or an Orca Cloud + profile, that copy states the truth; borrowing it turns the auto-pick + into a correctly-rejected mismatch. Only ever fills a gap: an entry that + carries its own list keeps it. """ # Build a name → metadata lookup from the tiers that carry it (local, # orca_cloud, standard). Bambu cloud is intentionally skipped — it @@ -464,6 +501,24 @@ def _enrich_cloud_metadata( if p.filament_colour is None and c is not None: p.filament_colour = c + # Compatibility bridge (#2628). Runs over both slots that carry the + # list, and in both directions between the cloud tiers — whichever copy + # of a profile knows its printers teaches the ones that don't. + for slot in ("process", "filament"): + compat_by_name: dict[str, list[str]] = {} + for tier in (local, orca_cloud, cloud, standard): + for p in tier[slot]: + if p.compatible_printers and p.name not in compat_by_name: + compat_by_name[p.name] = p.compatible_printers + if not compat_by_name: + continue + for tier in (orca_cloud, cloud): + for p in tier[slot]: + if not p.compatible_printers: + borrowed = compat_by_name.get(p.name) + if borrowed: + p.compatible_printers = list(borrowed) + return orca_cloud, cloud, local, standard diff --git a/backend/app/services/slicer_3mf_convert.py b/backend/app/services/slicer_3mf_convert.py index 3bcb3a6fe..89baad0f9 100644 --- a/backend/app/services/slicer_3mf_convert.py +++ b/backend/app/services/slicer_3mf_convert.py @@ -237,20 +237,31 @@ def merge_plate_3mfs( def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]: """Replace any filament-list entry whose 1-indexed slot isn't used by - ``plate_id`` with the entry at slot 1 (index 0). + ``plate_id`` with the entry from the plate's lowest *used* slot. Why: the slice modal lets the user pick a filament profile per slot, but each plate in a multi-plate project only uses a subset of those slots. The modal labels the unused rows "not used by this plate" yet still submits their dropdown values. BambuStudio then validates every - loaded filament for material compatibility — PLA in a used slot + - ABS defaulted into an unused slot trips - "the temperature difference of the filaments used is too large" - (exit 194), even though the plate's G-code never touches the ABS - slot. Substituting unused entries with slot 1's filament keeps the - per-filament array length intact (so the source 3MF's per-slot - references stay valid) while making the loaded-filament set - materially homogeneous, so the validator passes. + loaded filament — for material compatibility (PLA in a used slot + + ABS defaulted into an unused slot trips "the temperature difference + of the filaments used is too large", exit 194) and for printer + compatibility ("filament preset X (slot N) is not compatible with + printer Y", exit -5) — even though the plate's G-code never touches + the unused slot. Substituting unused entries with a used slot's + filament keeps the per-filament array length intact (so the source + 3MF's per-slot references stay valid) while making the loaded set + both materially homogeneous and printer-correct, so both validators + pass. + + The anchor is the lowest used slot, NOT slot 1 (#2628). Slot 1 is + itself unused on plenty of plates, and anchoring there did the two + things this function exists to prevent: the substitution became a + no-op for the slot that needed it most, and — with more than one + unused slot — it propagated slot 1's own preset (in the reported + case an ``@Bambu Lab H2D`` profile baked into the source 3MF) into + every other unused slot, blocking an A1 slice on slots the plate + doesn't even use. The substitution is a no-op when: - ``plate_id`` is None (we can't determine which slots are unused), @@ -288,16 +299,26 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N # than to silently rewrite them. return items out = list(items) + # Anchor on the lowest used slot that actually exists in the list. A + # plate can reference a slot beyond the submitted list (a truncated or + # mismatched pick set) — those can't be an anchor, and if none of the + # used slots is in range there is nothing trustworthy to copy from, so + # leave the user's picks alone rather than inventing a substitution. + in_range_used = sorted(s for s in used if 1 <= s <= len(out)) + if not in_range_used: + return items + anchor_slot = in_range_used[0] substituted = [] for idx in range(len(out)): slot = idx + 1 if slot not in used: substituted.append(slot) - out[idx] = out[0] + out[idx] = out[anchor_slot - 1] if substituted: logger.info( - "Substituted slot-1 filament for unused slot(s) %s on plate %s " - "(avoids loaded-filament temp-spread validator)", + "Substituted slot-%s filament for unused slot(s) %s on plate %s " + "(avoids loaded-filament temp-spread and printer-compatibility validators)", + anchor_slot, substituted, plate_id, ) diff --git a/backend/tests/unit/services/test_slicer_3mf_convert.py b/backend/tests/unit/services/test_slicer_3mf_convert.py index 4bfda15e3..770c759eb 100644 --- a/backend/tests/unit/services/test_slicer_3mf_convert.py +++ b/backend/tests/unit/services/test_slicer_3mf_convert.py @@ -253,10 +253,10 @@ def p1_project(zip_bytes: bytes) -> bytes: class TestSubstituteUnusedPlateFilaments: - """Slot 1 carries the used filament; unused-slot entries are - overwritten with slot 1 so BambuStudio's filament-temp validator - doesn't trip on heterogeneous loaded filaments that the plate's - G-code never actually touches.""" + """Unused-slot entries are overwritten with the plate's lowest *used* + slot so BambuStudio's validators don't trip on loaded filaments the + plate's G-code never actually touches — neither the filament-temp + spread nor the preset-vs-printer compatibility check.""" @staticmethod def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes: @@ -371,3 +371,75 @@ class TestSubstituteUnusedPlateFilaments: items = ["pla.json", "abs_never_used.json"] result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) assert result == ["pla.json", "pla.json"] + + # ---- #2628: the anchor is the lowest USED slot, not slot 1 ---------- + + def test_substitutes_from_first_used_slot_when_slot_1_is_unused(self): + """michaelklos's report: plate 2 of a multi-plate project uses only + slot 2, while slot 1 carries an ``@Bambu Lab H2D`` preset baked into + the source 3MF. Anchoring on slot 1 made the substitution a no-op for + the one slot that needed it, and the CLI rejected the A1 slice with + "filament preset (slot 1) is not compatible with printer + Bambu Lab A1 0.4 nozzle".""" + zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])}) + items = ["tpu_at_h2d.json", "pla_at_a1.json"] + + result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items) + + assert result == ["pla_at_a1.json", "pla_at_a1.json"] + + def test_unused_slot_1_does_not_poison_the_other_unused_slots(self): + """With more than one unused slot, the old anchor propagated slot 1's + own (foreign-printer) preset into every other unused slot — the exact + poisoning #1851 removed from the picker.""" + zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [3])])}) + items = ["tpu_at_h2d.json", "abs.json", "pla_at_a1.json"] + + result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items) + + assert result == ["pla_at_a1.json", "pla_at_a1.json", "pla_at_a1.json"] + + def test_anchor_is_the_lowest_used_slot_not_merely_a_used_one(self): + """Deterministic pick so the same project always slices identically.""" + zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2, 4])])}) + items = ["slot1.json", "slot2.json", "slot3.json", "slot4.json"] + + result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items) + + assert result == ["slot2.json", "slot2.json", "slot2.json", "slot4.json"] + + def test_no_op_when_every_used_slot_is_outside_the_submitted_list(self): + """A plate referencing only slots beyond the picked list leaves nothing + trustworthy to copy from — keep the user's picks rather than invent a + substitution from a slot the plate doesn't use.""" + zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [5])])}) + items = ["a.json", "b.json"] + + result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items) + + assert result == ["a.json", "b.json"] + + def test_support_slot_can_be_the_anchor(self): + """The support-filament union (#1881) feeds the same used-slot set, so + a plate whose only geometry slot is 2 with PVA supports in 3 anchors on + 2 — never on the unused slot 1.""" + model_settings = self._model_settings_xml([(1, [1]), (2, [2])]) + project_settings = json.dumps( + { + "enable_support": "1", + "support_filament": "3", + "support_interface_filament": "3", + "filament_type": ["PLA", "PLA", "PVA"], + } + ).encode() + zip_bytes = _make_3mf( + { + "Metadata/model_settings.config": model_settings, + "Metadata/project_settings.config": project_settings, + } + ) + items = ["tpu_at_h2d.json", "pla.json", "pva.json"] + + result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items) + + assert result == ["pla.json", "pla.json", "pva.json"] diff --git a/backend/tests/unit/test_slicer_presets.py b/backend/tests/unit/test_slicer_presets.py index 0cebdb4da..dd9195107 100644 --- a/backend/tests/unit/test_slicer_presets.py +++ b/backend/tests/unit/test_slicer_presets.py @@ -155,6 +155,83 @@ class TestEnrichCloudMetadata: assert c["filament"][0].filament_colour == "#FFFFFF" +class TestEnrichCompatiblePrinters: + """#2628: the same name bridge carries ``compatible_printers`` onto the + tiers that don't ship one. Bambu Cloud never does — so a profile whose + name carries no printer model reads as "compatibility unknown", which the + SliceModal treats as usable and auto-picks for the wrong printer.""" + + COMPAT = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"] + + def _tier(self, source: str, slot: str, compat: list[str] | None) -> dict[str, list[UnifiedPreset]]: + empty: dict[str, list[UnifiedPreset]] = {"printer": [], "process": [], "filament": []} + empty[slot] = [ + UnifiedPreset(id=f"{source}1", name="Overture PLA Matte @0.2", source=source, compatible_printers=compat) + ] + return empty + + def test_bambu_cloud_borrows_the_list_from_a_same_named_local_import(self): + local = self._tier("local", "filament", self.COMPAT) + cloud = self._tier("cloud", "filament", None) + + _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([])) + + assert c["filament"][0].compatible_printers == self.COMPAT + + def test_bambu_cloud_borrows_from_orca_cloud_too(self): + orca = self._tier("orca_cloud", "filament", self.COMPAT) + cloud = self._tier("cloud", "filament", None) + + _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([])) + + assert c["filament"][0].compatible_printers == self.COMPAT + + def test_orca_cloud_borrows_when_its_own_content_had_no_list(self): + orca = self._tier("orca_cloud", "filament", None) + local = self._tier("local", "filament", self.COMPAT) + + oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _slot([]), local, _slot([])) + + assert oc["filament"][0].compatible_printers == self.COMPAT + + def test_process_slot_is_bridged_as_well(self): + local = self._tier("local", "process", self.COMPAT) + cloud = self._tier("cloud", "process", None) + + _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([])) + + assert c["process"][0].compatible_printers == self.COMPAT + + def test_never_overwrites_a_list_the_entry_already_has(self): + own = ["Bambu Lab P2S 0.4 nozzle"] + local = self._tier("local", "filament", self.COMPAT) + cloud = self._tier("cloud", "filament", own) + + _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([])) + + assert c["filament"][0].compatible_printers == own + + def test_no_donor_leaves_the_entry_unclassified(self): + """Absent evidence the entry must stay None — the SliceModal then + falls back to the name matcher instead of hiding the profile.""" + cloud = self._tier("cloud", "filament", None) + + _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([])) + + assert c["filament"][0].compatible_printers is None + + def test_borrowed_list_is_copied_not_shared(self): + """A later mutation of one tier's list must not reach through to the + other — these objects are cached per user between requests.""" + local = self._tier("local", "filament", list(self.COMPAT)) + cloud = self._tier("cloud", "filament", None) + + _oc, c, l_, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([])) + l_["filament"][0].compatible_printers.append("Bambu Lab H2D 0.4 nozzle") + + assert c["filament"][0].compatible_printers == self.COMPAT + + def _user_with_cloud_auth(user_id: int = 1) -> MagicMock: """Construct a mock User that passes the CLOUD_AUTH permission check. @@ -273,6 +350,76 @@ class TestFetchOrcaCloudPresets: assert filament[0].filament_type == "PLA" assert filament[0].filament_colour == "#000000" + @pytest.mark.asyncio + async def test_extracts_compatible_printers_from_content(self): + """#2628: Orca's sync_pull already carries the profile's own + compatible-printer list. Surfacing it lets the SliceModal reject a + profile built for another printer instead of falling back to reading + the model out of the NAME — which fails outright for names that carry + no model ("Overture PLA Matte @0.2").""" + sp._orca_cloud_cache.clear() + compat = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"] + svc_mock = MagicMock() + svc_mock.list_profiles = AsyncMock( + return_value=[ + { + "id": "f1", + "name": "Overture PLA Matte @0.2", + "content": { + "type": "filament", + "filament_type": ["PLA"], + "compatible_printers": compat, + }, + }, + { + "id": "p1", + "name": "Orca 0.20mm", + "content": {"type": "print", "compatible_printers": "Bambu Lab P2S 0.4 nozzle"}, + }, + {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}}, + ] + ) + 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) + + assert status == "ok" + assert slots["filament"][0].compatible_printers == compat + # A single-printer profile may store a bare string — normalised to a list. + assert slots["process"][0].compatible_printers == ["Bambu Lab P2S 0.4 nozzle"] + # Printer presets have nothing to be compatible with. + assert slots["printer"][0].compatible_printers is None + + @pytest.mark.asyncio + async def test_missing_or_malformed_compatible_printers_stays_none(self): + """No data must read as "unknown", never as "compatible with nothing" — + the SliceModal falls back to the name matcher for those.""" + sp._orca_cloud_cache.clear() + svc_mock = MagicMock() + svc_mock.list_profiles = AsyncMock( + return_value=[ + {"id": "f1", "name": "No list", "content": {"type": "filament"}}, + {"id": "f2", "name": "Empty list", "content": {"type": "filament", "compatible_printers": []}}, + {"id": "f3", "name": "Blanks", "content": {"type": "filament", "compatible_printers": ["", " "]}}, + {"id": "f4", "name": "Wrong type", "content": {"type": "filament", "compatible_printers": {"a": 1}}}, + ] + ) + 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) + + assert [p.compatible_printers for p in slots["filament"]] == [None, None, None, None] + @pytest.mark.asyncio async def test_cache_hit_skips_orca_call(self): """A second call within TTL must reuse the cached slots and NOT diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index b68b57c21..bab506d06 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -1348,3 +1348,83 @@ describe('pickFilamentForSlot — printer-compat contract (#1851)', () => { expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' }); }); }); + +describe('pickFilamentForSlot — long-form printer tag (#2628)', () => { + const index = buildCompatibilityIndex({ + 'Bambu Lab A1': 'A1', + 'Bambu Lab H2D': 'H2D', + }); + + it('never auto-picks a user-saved preset scoped to another printer', () => { + // michaelklos's registry: a cloud-tier user preset carrying the full + // "@Bambu Lab H2D 0.4 nozzle" tag outscores the A1 preset on tier bonus + // alone. Until the matcher learned the long form it classified 'unknown' + // — indistinguishable from compatible — so it won the slot, landed in a + // dropdown the modal disables (slot not used by the plate), and the CLI + // rejected the whole slice. + const presets = makeUnified({ + cloud: { + printer: [], + process: [], + filament: [ + { + id: 'sunlu-tpu-h2d', + name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle', + source: 'cloud', + filament_type: 'PLA', + filament_colour: '#FF0000', + }, + ], + }, + standard: { + printer: [], + process: [], + filament: [ + { + id: 'Bambu PLA Basic @BBL A1', + name: 'Bambu PLA Basic @BBL A1', + source: 'standard', + filament_type: 'PLA', + filament_colour: '#FFFFFF', + }, + ], + }, + }); + + const pick = pickFilamentForSlot( + presets, + { type: 'PLA', color: '#FF0000' }, + 'Bambu Lab A1 0.4 nozzle', + index, + ); + + expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' }); + }); + + it('still picks a long-form preset for its own printer', () => { + const presets = makeUnified({ + cloud: { + printer: [], + process: [], + filament: [ + { + id: 'sunlu-tpu-h2d', + name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle', + source: 'cloud', + filament_type: 'TPU', + filament_colour: '#FF0000', + }, + ], + }, + }); + + const pick = pickFilamentForSlot( + presets, + { type: 'TPU', color: '#FF0000' }, + 'Bambu Lab H2D 0.4 nozzle', + index, + ); + + expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' }); + }); +}); diff --git a/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts b/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts index 11c471623..cb0f7e769 100644 --- a/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts +++ b/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts @@ -361,3 +361,127 @@ describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => { ).toBe('mismatch'); }); }); + +describe('presetCompatibility — long-form @Bambu Lab printer tag (#2628)', () => { + const A1 = 'Bambu Lab A1 0.4 nozzle'; + const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle'; + const H2D = 'Bambu Lab H2D 0.4 nozzle'; + const idx = buildCompatibilityIndex(PRINTER_MODELS); + + it('flags a user-saved H2D filament preset as a mismatch on an A1', () => { + // michaelklos's report: this exact preset sat in an unused filament slot + // of a multi-plate 3MF. Classified 'unknown' it was treated as usable, + // auto-picked, and the CLI rejected the slice with "filament preset + // (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle". + expect( + presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx), + ).toBe('mismatch'); + }); + + it('matches the same preset against its own printer', () => { + expect( + presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx), + ).toBe('match'); + }); + + it('resolves a long-form model whose display name differs from its short code', () => { + const name = 'My PETG @Bambu Lab X1 Carbon 0.4 nozzle'; + expect(presetCompatibility({ name }, 'filament', X1C, idx)).toBe('match'); + expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch'); + }); + + it('applies the nozzle filter to the long form too', () => { + expect( + presetCompatibility({ name: 'My PETG @Bambu Lab A1 0.6 nozzle' }, 'filament', A1, idx), + ).toBe('mismatch'); + }); + + it('ignores a trailing "(Custom)" suffix rather than mangling the model token', () => { + // The slicer appends this to user-saved presets. Parsed naively the tag + // becomes "H2D 0.4 nozzle (Custom)" — which would brand the preset a + // mismatch against its own printer and hide it from the dropdown. + const name = 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)'; + expect(presetCompatibility({ name }, 'filament', H2D, idx)).toBe('match'); + expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch'); + }); + + it('keeps the A1M alias working through the long form', () => { + expect( + presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1_MINI, idx), + ).toBe('match'); + expect( + presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1, idx), + ).toBe('mismatch'); + }); + + it('stays unknown for an @-tag that names no recognisable printer', () => { + // "@Voron 0.4 nozzle" is not a Bambu printer preset — the matcher must + // not guess, so the preset keeps its place in the main dropdown list. + expect( + presetCompatibility({ name: 'My PLA @Voron 0.4 nozzle' }, 'filament', A1, idx), + ).toBe('unknown'); + }); + + it('reads the tag from the last @ so a stray earlier one cannot swallow it', () => { + expect( + presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx), + ).toBe('mismatch'); + expect( + presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx), + ).toBe('match'); + }); +}); + +describe('presetCompatibility — nozzle-only @ tag (#2628 follow-up)', () => { + const P2S_04 = 'Bambu Lab P2S 0.4 nozzle'; + const X1C_02 = 'Bambu Lab X1 Carbon 0.2 nozzle'; + const idx = buildCompatibilityIndex(PRINTER_MODELS); + + it('rules out a 0.2-nozzle profile on a 0.4-nozzle printer', () => { + // The live case: "Overture PLA Matte @0.2" inherits from the X1C 0.2 + // nozzle system profile, so the slicer rejected a P2S 0.4 slice with + // "not compatible with printer Bambu Lab P2S 0.4 nozzle". The name + // carries no model, so this size is the only signal available. + expect( + presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', P2S_04, idx), + ).toBe('mismatch'); + }); + + it('stays unknown when the size agrees — a size is not a model', () => { + // The profile might belong to a different printer with the same nozzle; + // promoting this to 'match' would claim knowledge we don't have. + expect( + presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', X1C_02, idx), + ).toBe('unknown'); + }); + + it('accepts the "0.2 nozzle" and "0.2mm" spellings of the same tag', () => { + for (const name of ['My PLA @0.2 nozzle', 'My PLA @0.2mm']) { + expect(presetCompatibility({ name }, 'filament', P2S_04, idx)).toBe('mismatch'); + } + }); + + it('compares sizes numerically so 0.20 and 0.2 are one size', () => { + expect( + presetCompatibility({ name: 'My PLA @0.20' }, 'filament', X1C_02, idx), + ).toBe('unknown'); + }); + + it('ignores a numeric tag that cannot be a nozzle', () => { + // "@2026" is a year, not a 2026 mm nozzle — guessing here would brand + // the profile incompatible with every printer that exists. + expect(presetCompatibility({ name: 'My PLA @2026' }, 'filament', P2S_04, idx)).toBe('unknown'); + expect(presetCompatibility({ name: 'My PLA @0.05' }, 'filament', P2S_04, idx)).toBe('unknown'); + }); + + it('leaves a model-bearing tag on the model path', () => { + // Regression guard: the nozzle-only branch must not swallow the forms + // that carry a model — those still resolve to match/mismatch. + expect( + presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S_04, idx), + ).toBe('match'); + expect( + presetCompatibility({ name: 'My PLA @Bambu Lab X1 Carbon 0.4 nozzle' }, 'filament', P2S_04, idx), + ).toBe('mismatch'); + }); +}); diff --git a/frontend/src/utils/slicerPrinterMatch.ts b/frontend/src/utils/slicerPrinterMatch.ts index 690b5486e..5a4abd46a 100644 --- a/frontend/src/utils/slicerPrinterMatch.ts +++ b/frontend/src/utils/slicerPrinterMatch.ts @@ -6,9 +6,11 @@ // // 1. Imported (local-tier) presets carry the slicer's own // `compatible_printers` list — an exact list of printer-preset names. -// 2. BambuStudio's own `@BBL ` naming convention on shipped cloud -// / standard presets. The token → printer-fragment table is derived -// from the backend's canonical PRINTER_MODEL_MAP (fetched via +// 2. The `@` naming convention, in both shapes the slicer +// writes: `@BBL ` on shipped cloud / standard presets, and +// `@Bambu Lab nozzle` on presets a user saved for a +// specific printer (#2628). The token → printer-fragment table is +// derived from the backend's canonical PRINTER_MODEL_MAP (fetched via // /slicer/printer-models), not duplicated here. // // The result drives grouping, not hard hiding: a preset no rule covers @@ -136,10 +138,72 @@ function extractPrinterPresetModel(printerPresetName: string): { model: string; return stripped ? { model: stripped, nozzle } : null; } +// Trailing parenthetical the slicer appends to user-saved presets — +// "… @Bambu Lab H2D 0.4 nozzle (Custom)". Dropped before the nozzle suffix +// is parsed, or the tag would resolve to a nonsense model token and the +// preset would be branded a mismatch against its OWN printer. +function stripTrailingParenthetical(s: string): string { + return s.replace(/\s*\([^)]*\)\s*$/, '').trim(); +} + +// Nozzle sizes Bambu ships run 0.2 – 0.8. The range guard keeps a tag that +// merely looks numeric ("PLA @2026") from being read as a nozzle and branded +// incompatible with every printer. +const MIN_NOZZLE_MM = 0.1; +const MAX_NOZZLE_MM = 2.0; + +// Compare two nozzle strings numerically, so "0.20" and "0.2" are the same +// size. Unparseable values never match — a size we can't read is not evidence. +function sameNozzle(a: string, b: string): boolean { + const x = Number.parseFloat(a); + const y = Number.parseFloat(b); + if (Number.isNaN(x) || Number.isNaN(y)) return false; + return x === y; +} + +// Pull the model token and nozzle out of a preset name's printer tag. +// Three shapes exist in the wild (#2628): +// +// "0.20mm Standard @BBL X1C" — short code, the form +// Bambu ships its own cloud / standard presets under. +// "SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle" — the full printer-preset +// name, the form the slicer writes when a user saves their own preset +// for a printer. Handling only the short form left these classified +// 'unknown', so an H2D-scoped filament was offered (and auto-picked) +// for an A1 slice, which the CLI then rejected. +// "Overture PLA Matte @0.2" — nozzle only, no model. +// Returned with a null token: the size can rule a printer OUT, but +// says nothing about which models the profile belongs to. +// +// The first two shapes are also parsed in ConfigureAmsSlotModal (#1623). +function extractPrinterTag(presetName: string): { token: string | null; nozzle: string | null } | null { + const cleaned = stripTrailingParenthetical(presetName); + const bbl = extractBblToken(cleaned); + if (bbl) return bbl; + // The printer tag is a suffix by convention, so read from the LAST '@' — + // a stray earlier one ("My @work PLA @Bambu Lab H2D 0.4 nozzle") must not + // swallow it. Anything that doesn't parse as a Bambu printer preset name + // falls through to 'unknown', never to a guessed mismatch. + const at = cleaned.lastIndexOf('@'); + if (at < 0) return null; + const suffix = cleaned.slice(at + 1).trim(); + const longForm = extractPrinterPresetModel(suffix); + if (longForm) return { token: longForm.model, nozzle: longForm.nozzle }; + const nozzleOnly = suffix.match(/^([\d.]+)\s*(?:mm)?\s*(?:nozzle)?$/i); + if (nozzleOnly) { + const size = Number.parseFloat(nozzleOnly[1]); + if (!Number.isNaN(size) && size >= MIN_NOZZLE_MM && size <= MAX_NOZZLE_MM) { + return { token: null, nozzle: nozzleOnly[1] }; + } + } + return null; +} + /** - * Name-based fallback for presets BambuStudio ships with a `@BBL ` - * tag (#1325 follow-up). Used only after `compatible_printers` has returned - * `'unknown'`. + * Name-based fallback for presets carrying a printer tag — BambuStudio's own + * `@BBL ` (#1325 follow-up), the full `@Bambu Lab + * nozzle` form user-saved presets get, or a bare `@` (#2628). + * Used only after `compatible_printers` has returned `'unknown'`. * * Compares BOTH model AND nozzle. The nozzle filter is required because * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 / @@ -152,8 +216,23 @@ function classifyByBambuName( selectedPrinterName: string, bambuModelByShortCode: Record, ): PrinterCompatibility { - const parsed = extractBblToken(presetName); + const parsed = extractPrinterTag(presetName); if (!parsed) return 'unknown'; + const selectedParts = extractPrinterPresetModel(selectedPrinterName); + if (!selectedParts) return 'unknown'; + if (parsed.token === null) { + // Nozzle-only tag ("Overture PLA Matte @0.2"). The size can rule a + // printer OUT, but a matching size proves nothing about the model, so + // the best this can ever return is 'unknown' — never 'match'. + if ( + selectedParts.nozzle !== null + && parsed.nozzle !== null + && !sameNozzle(parsed.nozzle, selectedParts.nozzle) + ) { + return 'mismatch'; + } + return 'unknown'; + } // If the token isn't in the table (a brand-new Bambu model whose short // code the backend registry hasn't added yet, or the model map hasn't // loaded yet), fall back to comparing the raw token. That keeps the @@ -162,8 +241,6 @@ function classifyByBambuName( // without us having to ship a code update. When they differ in form // (X1C vs "X1 Carbon"), the registry is what makes the match work. const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token; - const selectedParts = extractPrinterPresetModel(selectedPrinterName); - if (!selectedParts) return 'unknown'; // The raw inferred model and the printer-preset fragment may differ only by // the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini"). // ``matchesPrinterModelSuffix`` consults the alias table before declaring a @@ -180,7 +257,7 @@ function classifyByBambuName( // or non-Bambu printer names that happened to match the model. if (selectedParts.nozzle !== null) { const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE; - if (presetNozzle !== selectedParts.nozzle) return 'mismatch'; + if (!sameNozzle(presetNozzle, selectedParts.nozzle)) return 'mismatch'; } return 'match'; } diff --git a/static/assets/index-D0xVmIdo.js b/static/assets/index-DVKbiORm.js similarity index 86% rename from static/assets/index-D0xVmIdo.js rename to static/assets/index-DVKbiORm.js index 658776e5f..6c4d992ae 100644 --- a/static/assets/index-D0xVmIdo.js +++ b/static/assets/index-DVKbiORm.js @@ -44,11 +44,11 @@ ON değeri: "ON" anlamına gelen tam string. Otomatik algılama için boş bıra Для преобразования Вт·ч→кВт·ч используйте 0.001, для МВт·ч→кВт·ч — 1000.`,mqttStateHint:`Путь JSON извлекает значение из полезной нагрузки JSON. Оставьте пустым для значения напрямую. Значение «включено» — точная строка, означающая включённое состояние. Оставьте пустым для автоматического распознавания ON, true и 1.`,restControl:`Управление`,restOnUrl:`URL включения`,restOffUrl:`URL выключения`,restOnBody:`Тело запроса включения`,restOffBody:`Тело запроса выключения`,restMethod:`Метод HTTP`,restHeaders:`Пользовательские заголовки (JSON)`,restStatusUrl:`URL состояния`,restStatusPath:`Путь JSON состояния`,restStatusOnValue:`Значение «включено»`,restPowerUrl:`URL мощности`,restPowerPath:`Путь JSON мощности`,restPowerMultiplier:`Множитель мощности`,restEnergyUrl:`URL энергии`,restEnergyPath:`Путь JSON энергии`,restEnergyTotalPath:`Путь JSON полной энергии`,restEnergyTotalMultiplier:`Множитель полной энергии`,restEnergyTotalPathHint:`например, aenergy.total`,restEnergyTotalHint:`Многие розетки, включая все устройства Shelly, передают только накопительный счётчик энергии, который никогда не сбрасывается. Его следует указывать здесь, а не в поле выше: если считать его суточным расходом, значение не сбросится в полночь, а данные за вчера и общий расход останутся пустыми. Bambuddy вычисляет расход за сегодня и вчера по этому счётчику; для накопления данных потребуется один-два дня. Shelly передаёт ватт-часы, поэтому используйте множитель 0.001.`,restEnergyMultiplier:`Множитель энергии`,restUrlRequired:`Для REST-розетки необходимо указать хотя бы один URL: включения или выключения`,restHeadersHint:`например, {"Authorization": "Bearer your-token"}`,restBodyHint:`например, ON или {"state": "on"}`,restStatusHint:`URL для опроса текущего состояния`,restPathHint:`например, state или data.power.status`,restPowerUrlHint:`Отдельный URL данных мощности; если пусто, используется URL состояния`,restEnergyUrlHint:`Отдельный URL данных энергии; если пусто, используется URL состояния`,restEnergyHint:`Для каждого значения можно использовать отдельный URL либо URL состояния. Множители используются для преобразования единиц, например 0.001 для Вт·ч→кВт·ч.`,testConnection:`Проверить подключение`,connectionSuccess:`Подключение успешно`,noSwitchesInSwitchbar:`На панели переключателей нет устройств`,enableSwitchbarHint:`Включите «Показывать на панели переключателей» в разделе «Настройки → Умные розетки»`},notifications:{providerTypes:{callmebot:`CallMeBot/WhatsApp`,ntfy:`ntfy`,pushover:`Pushover`,telegram:`Telegram`,email:`Электронная почта`,discord:`Discord`,webhook:`Вебхук`,homeassistant:`Home Assistant`},providerDescriptions:{email:`Уведомления по электронной почте через SMTP`,telegram:`Уведомления через Telegram-бота`,discord:`Отправка в канал Discord через вебхук`,ntfy:`Бесплатные push-уведомления с возможностью самостоятельного размещения сервера`,pushover:`Простые и надёжные push-уведомления`,callmebot:`Бесплатные уведомления WhatsApp через CallMeBot`,webhook:`Универсальный HTTP POST-запрос на любой URL`,homeassistant:`Постоянные уведомления на панели Home Assistant`},lastSuccess:`Последняя отправка: {{date}}`,error:`Ошибка`,printer:`Принтер:`,allPrinters:`Все принтеры`,sendTestNotification:`Отправить тестовое уведомление`,eventSettings:`Настройки событий`,enabled:`Включено`,sendFromProvider:`Отправлять уведомления через этого провайдера`,printEvents:`События печати`,printerStatus:`Состояние принтера`,amsAlarms:`Предупреждения AMS`,amsHtAlarms:`Предупреждения AMS-HT`,printQueue:`Очередь печати`,start:`Запуск`,plateCheck:`Проверка пластины`,complete:`Завершено`,failed:`Ошибка`,stopped:`Остановлено`,progress:`Ход печати`,offline:`Не в сети`,lowFilament:`Мало филамента`,maintenance:`Обслуживание`,amsHumidity:`Влажность AMS`,amsTemp:`Температура AMS`,amsHtHumidity:`Влажность AMS-HT`,amsHtTemp:`Температура AMS-HT`,bedCooled:`Стол остыл`,firstLayer:`Первый слой`,quiet:`Тихий режим`,digest:`Сводка в {{time}}`,printStarted:`Печать началась`,plateNotEmpty:`Пластина не пуста`,plateNotEmptyDescription:`Перед печатью на пластине обнаружены объекты`,printCompleted:`Печать завершена`,bedCooledLabel:`Стол остыл`,bedCooledDescription:`После печати температура стола опустилась ниже заданного порога`,firstLayerCompleteLabel:`Первый слой завершён`,firstLayerCompleteDescription:`Уведомить со снимком после завершения первого слоя`,missingSpoolAssignmentLabel:`Катушка не назначена`,missingSpoolAssignmentDescription:`Уведомить при запуске печати, если для необходимых слотов не назначены катушки`,printFailed:`Ошибка печати`,printStopped:`Печать остановлена`,progressMilestones:`Этапы выполнения`,progressMilestonesDescription:`Уведомлять при 25%, 50% и 75%`,printerOffline:`Принтер не в сети`,printerError:`Ошибка принтера`,aiFailureDetection:`Обнаружение сбоев с помощью ИИ`,aiFailureDetectionDescription:`Уведомить, если Obico AI обнаружит возможный сбой печати`,lowFilamentLabel:`Заканчивается филамент`,maintenanceDue:`Требуется обслуживание`,maintenanceDueDescription:`Уведомить, когда принтеру потребуется обслуживание`,amsHumidityHigh:`Высокая влажность в AMS`,amsHumidityHighDescription:`Влажность в обычной AMS превысила заданный порог`,amsTemperatureHigh:`Высокая температура в AMS`,amsTemperatureHighDescription:`Температура в обычной AMS превысила заданный порог`,amsHtHumidityHigh:`Высокая влажность в AMS-HT`,amsHtHumidityHighDescription:`Влажность в AMS-HT превысила заданный порог`,amsHtTemperatureHigh:`Высокая температура в AMS-HT`,amsHtTemperatureHighDescription:`Температура в AMS-HT превысила заданный порог`,inventoryAlerts:`Уведомления о запасах`,stockReorderAlert:`Пора заказывать`,stockReorderAlertDescription:`Запас по SKU достиг точки повторного заказа`,stockBreakAlert:`Риск исчерпания запаса`,stockBreakAlertDescription:`Запас закончится раньше, чем поступит пополнение`,jobAdded:`Задание добавлено`,jobAddedDescription:`Задание добавлено в очередь`,jobAssigned:`Задание назначено`,jobAssignedDescription:`Задание по модели назначено принтеру`,jobStarted:`Задание запущено`,jobStartedDescription:`Задание из очереди отправлено на печать`,jobWaiting:`Задание ожидает`,jobWaitingDescription:`Задание ожидает филамент или свободный принтер`,jobSkipped:`Задание пропущено`,jobSkippedDescription:`Задание пропущено из-за ошибки предыдущего`,jobFailed:`Не удалось запустить задание`,jobFailedDescription:`Задание не удалось отправить на печать`,queueComplete:`Очередь завершена`,queueCompleteDescription:`Все задания в очереди завершены`,quietHours:`Тихие часы`,noNotificationsDuring:`Не отправлять уведомления в это время`,editProviderToChangeQuietHours:`Измените провайдера, чтобы настроить тихие часы`,dailyDigest:`Ежедневная сводка`,batchNotifications:`Объединять уведомления в одну ежедневную сводку`,sendAt:`Отправлять в {{time}}`,editProviderToChangeDigestTime:`Измените провайдера, чтобы настроить время сводки`,edit:`Изменить`,deleteProvider:`Удалить провайдера уведомлений`,deleteConfirm:`Удалить «{{name}}»? Это действие нельзя отменить.`,delete:`Удалить`,addTitle:`Добавить провайдера уведомлений`,editTitle:`Изменить провайдера уведомлений`,nameLabel:`Название *`,namePlaceholder:`Мои уведомления`,providerTypeLabel:`Тип провайдера *`,configuration:`Настройка`,testConfiguration:`Проверить настройку`,printerFilter:`Фильтр принтеров`,onlyFromPrinter:`Отправлять уведомления только о событиях этого принтера`,quietHoursDnd:`Тихие часы (не беспокоить)`,quietStart:`Начало`,quietEnd:`Окончание`,dailyDigestLabel:`Ежедневная сводка`,sendDigestAt:`Отправлять сводку в`,digestCollected:`События будут собраны и отправлены одной сводкой в указанное время`,notificationEvents:`События для уведомлений`,progressPercent:`(25%, 50%, 75%)`,bedCooledAfterPrint:`(после завершения печати)`,eventPriority:{sectionTitle:`Приоритет ntfy`,helpNtfy:`Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.`,min:`Минимальный`,low:`Низкий`,default:`Обычный`,high:`Высокий`,urgent:`Срочный`},cancel:`Отмена`,save:`Сохранить`,add:`Добавить`,nameRequired:`Укажите название`,fieldRequired:`Поле «{{field}}» обязательно`,phoneNumber:`Номер телефона`,apiKey:`Ключ API`,serverUrl:`URL сервера`,topic:`Тема`,authToken:`Токен авторизации`,userKey:`Ключ пользователя`,appToken:`Токен приложения`,priority:`Приоритет`,pushoverRetry:`Повтор экстренного уведомления (с)`,pushoverExpire:`Срок действия экстренного уведомления (с)`,botToken:`Токен бота`,chatId:`ID чата`,smtpServer:`SMTP-сервер`,smtpPort:`Порт SMTP`,security:`Защита соединения`,authentication:`Аутентификация`,username:`Имя пользователя`,password:`Пароль`,fromEmail:`Адрес отправителя`,toEmail:`Адрес получателя`,webhookUrl:`URL вебхука`,payloadFormat:`Формат данных`,authorization:`Авторизация`,titleFieldName:`Имя поля заголовка`,messageFieldName:`Имя поля сообщения`,editTemplate:`Изменить шаблон: {{name}}`,titleLabel:`Заголовок`,bodyLabel:`Текст`,titlePlaceholder:`Заголовок уведомления...`,bodyPlaceholder:`Текст уведомления...`,availableVariables:`Доступные переменные`,clickToInsert:`Нажмите, чтобы вставить в позицию курсора в тексте`,livePreview:`Предварительный просмотр`,hide:`Скрыть`,show:`Показать`,loadingPreview:`Загрузка предварительного просмотра...`,enterTemplateContent:`Введите содержимое шаблона для предварительного просмотра`,titlePreview:`Заголовок:`,bodyPreview:`Текст:`,resetToDefault:`Восстановить стандартный шаблон`,titleRequired:`Укажите заголовок`,bodyRequired:`Введите текст уведомления`,notificationLog:`Журнал уведомлений`,showFailedOnly:`Только неудачные`,last24Hours:`Последние 24 часа`,last7Days:`Последние 7 дней`,last30Days:`Последние 30 дней`,last90Days:`Последние 90 дней`,justNow:`Только что`,noFailedNotifications:`Неудачных уведомлений нет`,noNotificationsLogged:`В журнале пока нет уведомлений`,unknownProvider:`Неизвестный провайдер`,logTitle:`Заголовок`,logMessage:`Сообщение`,logError:`Ошибка`,logProvider:`Провайдер: {{type}}`,logTime:`Время: {{time}}`,refresh:`Обновить`,clearOld:`Удалить старые`,statsSummary:`За последние {{days}} дней:`,statsNotifications:`уведомлений`,statsSent:`отправлено: {{count}}`,statsFailed:`ошибок: {{count}}`,eventTypes:{print_start:`Печать началась`,print_complete:`Печать завершена`,print_failed:`Ошибка печати`,print_stopped:`Печать остановлена`,print_progress:`Ход печати`,printer_offline:`Принтер не в сети`,printer_error:`Ошибка принтера`,filament_low:`Заканчивается филамент`,maintenance_due:`Требуется обслуживание`,test:`Тест`},userEmail:{title:`Уведомления`,emailNotifications:`Уведомления по электронной почте`,emailNotificationsDesc:`Получать письма о собственных заданиях печати. Для отправки используются системные параметры SMTP из раздела расширенной аутентификации.`,sendingTo:`Уведомления будут отправляться на адрес`,noEmailWarning:`Для вашей учётной записи не указан адрес электронной почты. Обратитесь к администратору.`,printJobNotifications:`Уведомления о заданиях печати`,printJobNotificationsDesc:`Выберите события, при которых будут отправляться письма о запущенных вами заданиях.`,printJobStarts:`Запуск задания печати`,printJobStartsDesc:`Уведомлять о начале вашего задания печати.`,printJobFinishes:`Завершение задания печати`,printJobFinishesDesc:`Уведомлять об успешном завершении вашего задания печати.`,printErrors:`Ошибки печати`,printErrorsDesc:`Уведомлять, если задание завершилось ошибкой или при печати возникла проблема.`,printJobStops:`Остановка задания печати`,printJobStopsDesc:`Уведомлять об отмене или остановке вашего задания печати.`,saveSuccess:`Настройки уведомлений сохранены.`,saveError:`Не удалось сохранить настройки уведомлений.`}},richTextEditor:{bold:`Полужирный`,italic:`Курсив`,underline:`Подчёркивание`,bulletList:`Маркированный список`,numberedList:`Нумерованный список`,alignLeft:`По левому краю`,alignCenter:`По центру`,alignRight:`По правому краю`,addLink:`Добавить ссылку`,removeLink:`Удалить ссылку`},externalLinks:{title:`Ссылки на боковой панели`,sidebarLayout:`Боковая панель`,sidebarLayoutDescription:`Показывайте или скрывайте встроенные страницы, добавляйте внешние ссылки и перетаскивайте пункты, меняя порядок навигации.`,systemPages:`Страницы Bambuddy`,externalLinks:`Внешние ссылки`,visibleInSidebar:`Показывается на боковой панели`,hiddenFromSidebar:`Скрыто с боковой панели`,requiredInSidebar:`Обязательный пункт боковой панели`,hidePage:`Скрыть страницу`,showPage:`Показать страницу`,settingsCannotBeHidden:`Настройки нельзя скрыть`,noLinksConfigured:`Внешние ссылки не настроены`,deleteLink:`Удалить ссылку`,removeCustomIcon:`Удалить пользовательский значок`,openInNewTab:`Открывать в новой вкладке`,placeholders:{linkName:`Моя ссылка`}},keyboardShortcuts:{title:`Горячие клавиши`,navigation:`Навигация`,archivesSection:`Архив`,kProfilesSection:`K-профили`,generalSection:`Общие`,shortcuts:{goToPrinters:`Перейти к принтерам`,goToArchives:`Перейти в архив`,goToQueue:`Перейти к очереди`,goToStats:`Перейти к статистике`,goToProfiles:`Перейти к облачным профилям`,goToSettings:`Перейти к настройкам`,focusSearch:`Перейти в строку поиска`,openUploadModal:`Открыть окно загрузки`,clearSelection:`Снять выделение / убрать фокус с поля`,contextMenu:`Контекстное меню карточки`,refreshProfiles:`Обновить профили`,newProfile:`Новый профиль`,exitSelectionMode:`Выйти из режима выделения`,showHelp:`Показать эту справку`},footer:`Нажмите Esc или щёлкните снаружи, чтобы закрыть`},notificationLog:{title:`Журнал уведомлений`,events:{printStarted:`Печать началась`,printComplete:`Печать завершена`,printFailed:`Ошибка печати`,printStopped:`Печать остановлена`,progress:`Ход выполнения`,printerOffline:`Принтер не в сети`,printerError:`Ошибка принтера`,lowFilament:`Заканчивается филамент`,maintenanceDue:`Требуется обслуживание`,test:`Тест`},timeAgo:{justNow:`Только что`,minutesAgo:`{{minutes}} мин назад`,hoursAgo:`{{hours}} ч назад`}},restoreBackup:{title:`Восстановление резервной копии`,restoring:`Восстановление...`,restoreComplete:`Восстановление завершено`,restoreFailed:`Ошибка восстановления`,importSettings:`Импортировать настройки из файла резервной копии`,pleaseWait:`Подождите, пока данные восстанавливаются`,clickToSelect:`Нажмите, чтобы выбрать файл резервной копии (.json или .zip)`,howDuplicateHandling:`Как обрабатываются совпадения:`,categories:{printers:`Принтеры`,smartPlugs:`Умные розетки`,notificationProviders:`Провайдеры уведомлений`,filaments:`Филаменты`,archives:`Архив`,pendingUploads:`Ожидающие загрузки`,settingsTemplates:`Настройки и шаблоны`},matchingInfo:{printers:`сопоставляются по серийному номеру`,smartPlugs:`сопоставляются по IP-адресу`,notificationProviders:`сопоставляются по названию`,filaments:`сопоставляются по названию, типу и бренду`,archives:`сопоставляются по хешу содержимого`,pendingUploads:`сопоставляются по имени файла`,settingsTemplates:`всегда перезаписываются`},replaceExisting:`Заменить существующие данные`,keepExisting:`Сохранить существующие данные`,replaceDescription:`Перезаписать уже существующие элементы данными из резервной копии`,keepDescription:`Восстановить только отсутствующие элементы`,caution:`Внимание:`,cautionText:`При перезаписи текущая конфигурация будет заменена данными из резервной копии. В целях безопасности коды доступа к принтерам никогда не перезаписываются.`,itemsRestored:`Восстановлено элементов`,itemsSkipped:`Пропущено элементов`,restored:`Восстановлено`,skipped:`Пропущено (уже существует)`,filesLabel:`Файлы (3MF, миниатюры и т. п.)`,newApiKeysGenerated:`Созданы новые ключи API`,newApiKeysWarning:`Эти ключи показываются только один раз. Скопируйте их сейчас!`,processingBackup:`Обработка резервной копии...`,noDataFound:`В резервной копии не найдено данных для восстановления.`,failedToRestore:`Не удалось восстановить резервную копию. Проверьте формат файла.`},backupExport:{title:`Экспорт резервной копии`,selectData:`Выберите данные для включения`,selectAll:`Выбрать всё`,selectNone:`Снять выделение`,categoryDescriptions:{settings:`Язык, тема и настройки обновлений`,notifications:`ntfy, Pushover, Discord и другие`,templates:`Пользовательские шаблоны сообщений`,smartPlugs:`Конфигурация розеток Tasmota`,externalLinks:`Ссылки на внешние сервисы в боковой панели`,printers:`Сведения о принтерах (без кодов доступа)`,plateDetection:`Эталонные изображения пустых пластин`,filaments:`Типы и стоимость филамента`,maintenance:`Пользовательские графики обслуживания`,archives:`Все данные печати и файлы (3MF, миниатюры, фотографии)`,projects:`Проекты, позиции спецификаций и вложения`,pendingUploads:`Загрузки виртуальных принтеров, ожидающие проверки`,apiKeys:`Ключи API вебхуков (при импорте создаются новые)`},requiresPrinters:`Необходимо выбрать раздел «Принтеры»`,zipFileWarning:`Будет создан ZIP-файл.`,zipFileDescription:`В него войдут все файлы 3MF, миниатюры, таймлапсы и фотографии. Операция может занять некоторое время, а файл — получиться большим.`,includeAccessCodes:`Включить коды доступа`,includeAccessCodesDescription:`Для переноса на другой компьютер`,includeAccessCodesWarning:`Коды доступа попадут в файл открытым текстом. Храните резервную копию в безопасном месте!`,categoriesSelected:`Выбрано категорий: {{selectedCount}}`},pendingUploads:{placeholders:{notes:`Добавьте примечания об этой печати...`},discardUpload:`Отклонить загрузку`,archiveAllUploads:`Добавить все загрузки в архив`,discardAllUploads:`Отклонить все загрузки`,archive:`В архив`,timeAgo:{justNow:`Только что`,minutesAgo:`{{minutes}} мин назад`,hoursAgo:`{{hours}} ч назад`,daysAgo:`{{days}} дн. назад`}},apiBrowser:{placeholders:{requestBody:`Тело запроса JSON...`,searchEndpoints:`Поиск конечных точек...`}},configureAmsSlot:{title:`Настройка слота AMS`,slotConfigured:`Слот настроен!`,configuringSlot:`Настраивается слот:`,slotLabel:`{{ams}}, слот {{slot}}`,searchPresets:`Поиск профилей...`,colorPlaceholder:`Название или HEX-код цвета (например: brown, FF8800)`,clearCustomColor:`Очистить пользовательский цвет`,noCloudPresets:`Облачных профилей нет. Войдите в Bambu Cloud для синхронизации.`,noPresetsAvailable:`Нет доступных профилей. Войдите в Bambu Cloud или импортируйте локальные профили.`,noMatchingPresets:`Подходящие профили не найдены.`,custom:`Пользовательский`,builtin:`Встроенный`,orcaCloud:`Orca Cloud`,bambuCloud:`Bambu Cloud`,settingsSentToPrinter:`Настройки отправлены на принтер`,filamentProfile:`Профиль филамента`,kProfileLabel:`K-профиль (Pressure Advance)`,filteringFor:`Фильтр по материалу: {{material}}`,noKProfile:`Без K-профиля (стандартное значение 0,020)`,noMatchingKProfiles:`Подходящие K-профили не найдены. Будет использовано стандартное значение K=0,020.`,selectFilamentFirst:`Сначала выберите профиль филамента`,kFromCalibration:`K={{value}} из калибровки принтера`,customColorLabel:`Пользовательский цвет (необязательно)`,presetColors:`Цвета {{name}}:`,showLessColors:`Показать меньше цветов`,showMoreColors:`Показать больше цветов`,clear:`Очистить`,hexLabel:`HEX: #{{hex}}`,resetting:`Сброс...`,resetSlot:`Сбросить слот`,cancel:`Отмена`,configuring:`Настройка...`,configureSlot:`Настроить слот`},githubBackup:{title:`Резервное копирование в Git`,history:`История`,downloadBackup:`Скачать резервную копию`,restoreBackup:`Восстановить резервную копию`,noBackupsYet:`Резервных копий пока нет`},emailSettings:{placeholders:{fromName:`BamBuddy`}},tagManagement:{searchTags:`Поиск меток...`,renameTag:`Переименовать метку`,deleteTag:`Удалить метку`},notificationTemplates:{placeholders:{title:`Заголовок уведомления...`,body:`Текст уведомления...`}},batchTag:{placeholders:{newTag:`Введите новую метку...`}},photoGallery:{deletePhoto:`Удалить фотографию`},filamentHoverCard:{copySpoolUuid:`Скопировать UUID катушки`},kProfilesView:{hasNote:`Есть примечание`,copyProfile:`Копировать профиль`},layout:{openMenu:`Открыть меню`,noPermissionSystemInfo:`У вас нет разрешения на просмотр системной информации`},dashboard:{dragToReorder:`Перетащите, чтобы изменить порядок`,hideWidget:`Скрыть виджет`},notificationProviderCard:{deleteNotificationProvider:`Удалить провайдера уведомлений`},fileManagerModal:{closeFileManager:`Закрыть файловый менеджер`,sortFiles:`Сортировать файлы`,goToParentFolder:`Перейти в родительскую папку`,threeView:`Просмотр 3D`},embeddedCameraViewer:{refreshStream:`Обновить поток`,close:`Закрыть`,zoomOut:`Уменьшить`,resetZoom:`Сбросить масштаб`,zoomIn:`Увеличить`,dragToResize:`Перетащите, чтобы изменить размер`},timelapseViewer:{skipBack5s:`Назад на 5 с`,skipForward5s:`Вперёд на 5 с`},notificationProviders:{descriptions:{email:`Уведомления по электронной почте через SMTP`,telegram:`Уведомления через Telegram-бота`,discord:`Отправка в канал Discord через вебхук`,ntfy:`Бесплатные push-уведомления с возможностью самостоятельного размещения сервера`,pushover:`Простые и надёжные push-уведомления`,callmebot:`Бесплатные уведомления WhatsApp через CallMeBot`,webhook:`Обычный HTTP POST на любой URL`}},logViewer:{searchPlaceholder:`Поиск по сообщению или имени логгера...`,noLogEntries:`Записи журнала не найдены`},switchbarPopover:{noSwitchesInSwitchbar:`На панели переключателей нет устройств`},projectPageModal:{placeholders:{title:`Название`,designer:`Автор`,license:`Лицензия`,description:`Введите описание...`,profileTitle:`Название профиля`,profileDescription:`Описание профиля...`}},spoolmanSettings:{},time:{unknown:`—`,waiting:`Ожидание`,justNow:`Только что`,now:`Сейчас`,minsAgo:`{{count}} мин назад`,inMins:`через {{count}} мин`,hoursAgo:`{{count}} ч назад`,inHours:`через {{count}} ч`,daysAgo:`{{count}} дн. назад`,inDays:`через {{count}} дн.`},spoolbuddy:{nav:{dashboard:`Панель`,ams:`AMS`,inventory:`Катушки`,writeTag:`Запись метки`,settings:`Настройки`},status:{nfcReady:`NFC готов`,nfcOff:`NFC выключен`,offline:`Не в сети`,online:`В сети`,noPrinters:`Нет принтеров`,deviceOffline:`Устройство не в сети`,waitingConnection:`Ожидание подключения устройства...`,systemReady:`Система готова`,status:`Состояние`},dashboard:{readyToScan:`Готово к сканированию`,idleMessage:`Поставьте катушку на весы для идентификации`,nfcHint:`Метка NFC будет считана автоматически`,device:`Устройство`,syncWeight:`Синхронизировать массу`,weightSynced:`Синхронизировано!`,unknownTag:`Неизвестная метка`,newTag:`Обнаружена новая метка`,onScale:`на весах`,linkSpool:`Связать с катушкой`,linkTagTitle:`Связать метку с катушкой`,linkTag:`Связать метку`,selectSpool:`Выберите катушку, с которой нужно связать эту метку:`,noUntagged:`Катушки без меток не найдены`,tagDetected:`Метка обнаружена`,noTag:`Метка отсутствует`,tagId:`Метка`,grossWeight:`Масса брутто`,spoolSize:`Размер катушки`,close:`Закрыть`,currentSpool:`Текущая катушка`,plateReady:`Пластина готова: {{name}}`,plateReadyLabel:`Пластины, готовые к очистке`,plateClearAction:`Очищено`,plateClearedToast:`Пластина отмечена как очищенная`,plateClearFailed:`Не удалось отметить пластину как очищенную`},modal:{spoolDetected:`Катушка обнаружена`,assignToAms:`Назначить в AMS`,syncWeight:`Синхронизировать массу`,weightSynced:`Синхронизировано!`,syncing:`Синхронизация...`,newTagDetected:`Обнаружена новая метка`,addToInventory:`Добавить в учёт`,assignToAmsTitle:`Назначить в AMS`,selectSlot:`Выберите слот`,assign:`Назначить`,assigning:`Назначение...`,assignSuccess:`Назначено!`,assignPendingInsert:`Назначено. Слот будет настроен после установки катушки.`,assignError:`Не удалось назначить катушку. Повторите попытку.`,noPrinterSelected:`Выберите принтер...`,noAmsDetected:`На этом принтере AMS не обнаружена`,slot:`Слот`},weight:{noReading:`Нет показаний`,stable:`Стабильно`,measuring:`Измерение...`,tare:`Тарировать`,calibrate:`Калибровать`},spool:{remaining:`Остаток`,material:`Материал`,brand:`Бренд`,color:`Цвет`,coreWeight:`Сердечник`,labelWeight:`По этикетке`,scaleWeight:`По весам`,netWeight:`Нетто`,lastUsed:`Последнее использование`},ams:{noData:`AMS не обнаружена`,connectAms:`Подключите AMS, чтобы увидеть слоты филамента`,noPrinter:`Принтер не выбран`,selectPrinter:`Выберите принтер на верхней панели`,printerDisconnected:`Принтер отключён`,humidity:`Влажность`,level:`Уровень`,active:`Активен`,slot:`Слот`,empty:`Пусто`},inventory:{search:`Поиск катушек...`,empty:`В учёте нет катушек`,noResults:`Подходящие катушки не найдены`,spools:`катушек`,addSpool:`Добавить катушку`},settings:{tabDevice:`Устройство`,tabDisplay:`Экран`,tabScale:`Весы`,tabUpdates:`Обновления`,nfcReader:`Считыватель NFC`,type:`Тип`,connection:`Подключение`,notConnected:`Н/Д`,deviceInfo:`Сведения об устройстве`,hostname:`Хост`,uptime:`Время работы`,systemConfig:`Бэкенд и авторизация`,backendUrl:`URL бэкенда Bambuddy`,apiToken:`Токен API`,apiTokenPlaceholder:`Введите токен API`,saveConfig:`Сохранить конфигурацию`,systemQueued:`Конфигурация поставлена в очередь.`,nfcDiagnostic:`Диагностика NFC`,scaleDiagnostic:`Диагностика весов`,readTagDiagnostic:`Диагностика чтения метки`,testNfc:`Проверить считыватель`,testScale:`Проверить точность`,testReadTag:`Считать метку`,systemFieldsRequired:`Необходимо указать URL бэкенда.`,brightness:`Яркость`,saved:`Сохранено`,noBacklight:`Подсветка DSI не обнаружена. Для управления яркостью требуется дисплей DSI.`,screenBlank:`Тайм-аут отключения экрана`,screenBlankDesc:`Экран выключается при бездействии. Коснитесь его для пробуждения.`,displayNote:`Яркость регулируется программным фильтром.`,scaleCalibration:`Калибровка весов`,currentWeight:`Текущая масса`,tareOffset:`Тара`,calFactor:`Коэффициент`,knownWeight:`Эталонная масса`,calStep1:`Уберите всё с весов и нажмите «Установить ноль».`,calStep2:`Положите на весы груз известной массы.`,setZero:`Установить ноль`,calibrateNow:`Калибровать`,calibrated:`Откалибровано`,tareSet:`Команда тарирования отправлена. Ожидание устройства...`,tareComplete:`Тарирование завершено!`,tareTimedOut:`Истекло время ожидания тарирования — демон SpoolBuddy запущен?`,tareFailed:`Не удалось отправить команду тарирования`,zeroSet:`Нулевая точка установлена. Положите на весы груз известной массы.`,calibrationDone:`Калибровка завершена!`,calibrationFailed:`Ошибка калибровки`,lastCalibrated:`Последняя калибровка`,stable:`Стабильно`,settling:`Стабилизация...`,firmware:`Прошивка`,scale:`Весы`,noDevice:`Устройство SpoolBuddy не найдено`,daemonVersion:`Версия демона`,currentVersion:`Текущая`,versionPending:`Ожидание демона...`,checking:`Проверка...`,checkUpdates:`Проверить обновления`,updateAvailable:`Доступно обновление`,updateInstructions:`Обновление по SSH: запустите установочный скрипт SpoolBuddy.`,upToDate:`Установлена актуальная версия`,includeBeta:`Включать бета-версии`},writeTag:{tabExisting:`Существующая катушка`,tabNew:`Новая катушка`,tabReplace:`Заменить метку`,searchPlaceholder:`Поиск по материалу, цвету или бренду...`,noUntaggedSpools:`Нет катушек без меток`,noTaggedSpools:`Нет катушек с метками`,selectSpool:`Выберите катушку, затем положите чистую метку NTAG на считыватель`,placeTag:`Положите метку NTAG на считыватель`,tagReady:`Метка обнаружена — можно записывать`,writeTag:`Записать метку`,replaceTag:`Заменить метку`,writing:`Запись метки...`,waiting:`Ожидание SpoolBuddy...`,writeSuccess:`Метка успешно записана!`,writeFailed:`Ошибка записи`,queueFailed:`Не удалось поставить команду записи в очередь`,tryAgain:`Повторить`,cancel:`Отмена`,replaceWarning:`Старая метка будет отвязана и заменена новой.`,deviceOffline:`SpoolBuddy не в сети`,material:`Материал`,colorName:`Название цвета`,color:`Цвет`,brand:`Бренд`,weight:`Масса (г)`,createSpool:`Создать катушку`,creating:`Создание...`,spoolCreated:`Катушка создана! Можно записывать метку.`,createFailed:`Не удалось создать катушку`,incompleteDataWarning:`Метка записана с неполными данными Spoolman`},quickMenu:{printerPower:`Питание принтера`,systemControls:`Система`,restartDaemon:`Перезапустить демон`,restartBrowser:`Перезапустить браузер`,reboot:`Перезагрузить`,shutdown:`Выключить`,swipeToClose:`Проведите вниз, чтобы закрыть`,confirmTitle:`Подтверждение`,confirmShutdown:`Выключить SpoolBuddy? Для повторного включения потребуется физический доступ.`,confirmReboot:`Перезагрузить SpoolBuddy?`,confirmRestartDaemon:`Перезапустить демон SpoolBuddy? NFC и весы временно будут недоступны.`,confirmRestartBrowser:`Перезапустить браузер киоска? Экран ненадолго погаснет.`,confirm:`Подтвердить`,confirmPlugOn:`Включить {{name}}?`,confirmPlugOff:`Выключить {{name}}?`,turnOn:`Включить`,turnOff:`Выключить`}},diagnostic:{modalTitle:`Диагностика подключения — {{name}}`,running:`Выполняется диагностика...`,runningElapsed:`Выполняется диагностика... ({{elapsed}} с)`,waitingForReportHint:`Ожидание публикации отчёта о состоянии принтера — это может занять до {{max}} секунд.`,runFailed:`Не удалось выполнить диагностику: {{error}}`,retry:`Запустить снова`,runButton:`Запустить диагностику`,sectionTitle:`Диагностика подключения`,sectionDescription:`Проверьте, почему принтер не подключается или не печатает: доступность портов, режим разработчика LAN, сетевой режим Docker и учётные данные.`,noPrinters:`Принтеры не настроены.`,overall:{ok:`Проблем не обнаружено — подключение к принтеру исправно.`,warnings:`Принтер должен работать, но некоторые пункты требуют внимания.`,problems:`Обнаружены проблемы, объясняющие, почему принтер не подключается или не печатает.`},check:{port_mqtt:{title:`Порт управления (MQTT 8883)`,pass:`Доступен — принтер принимает управляющие подключения.`,fail:`Порт 8883 недоступен. Принтер выключен, имеет другой IP-адрес либо подключение блокирует межсетевой экран. Проверьте IP-адрес принтера и доступность порта 8883.`},port_ftps:{title:`Порт передачи файлов (FTPS 990)`,pass:`Доступен — отправка файлов печати будет работать.`,warn:`Порт 990 недоступен. Мониторинг может работать, но отправка заданий на принтер завершится ошибкой. Убедитесь, что порт 990 не заблокирован.`},external_storage:{title:`Сохранять отправленные файлы во внешнем хранилище (шаг 4 установки)`,pass:`Принтер сообщает, что параметр включён: отправленные файлы будут сохраняться на SD-карте, а в архиве появятся миниатюры и метаданные слайсера.`,fail:`Принтер сообщает, что параметр выключен. Включите «Сохранять отправленные файлы во внешнем хранилище»: в новых прошивках (P2S 01.02 / Bambu Studio 2.6+) переключатель находится в настройках печати принтера, в старых — на вкладке «Устройство» Bambu Studio / OrcaSlicer. Без этого у всех архивных заданий не будет миниатюр и метаданных слайсера.`,skip:`Не проверено — требуется активное подключение MQTT. В старых версиях слайсеров этот параметр существует только в слайсере и принтер его не сообщает, поэтому проверка может пройти даже при выключенном параметре. Проверьте шаг 4 установки вручную.`,skip_unsupported_model:`В этой модели есть слот SD-карты, но включить соответствующую функцию невозможно: текущая прошивка серии P1 не предоставляет переключатель в Bambu Studio, а у принтера нет экрана. Исправлять здесь нечего. Пока Bambu Lab не добавит поддержку в прошивку, архивные задания могут сохраняться без миниатюр и метаданных слайсера.`},port_rtsps:{title:`Порт камеры ({{protocol}} {{port}})`,pass:`Доступен — видеопоток камеры будет работать.`,warn:`Порт {{port}} недоступен. Просмотр камеры в реальном времени работать не будет. На печать это не влияет.`},network_mode:{title:`Сетевой режим Docker`,pass:`Используется сетевой режим host.`,warn:`Bambuddy запущен в Docker с сетью bridge. Для обнаружения принтеров и виртуального принтера требуется режим host — пересоздайте контейнер с параметром «network_mode: host».`,skip:`Bambuddy запущен не в Docker — проверка неприменима.`},subnet:{title:`Подсеть`,pass:`Принтер и Bambuddy находятся в одной подсети.`,warn:`Принтер ({{printer_ip}}) и Bambuddy ({{host_ip}}) находятся в разных подсетях. Без настроенной маршрутизации между подсетями они могут быть недоступны друг для друга.`,skip:`Не удалось определить подсеть — проверка пропущена.`},mqtt_auth:{title:`Учётные данные принтера`,pass:`Принтер принял подключение.`,fail:`Принтер доступен, но отклонил подключение. Скорее всего, неверны код доступа или серийный номер. Код доступа изменяется при каждом переключении режима разработчика — снова скопируйте его с экрана принтера.`,skip:`Не проверено — принтер недоступен.`},developer_mode:{title:`Режим разработчика LAN`,pass:`Режим разработчика включён.`,fail:`Режим разработчика на принтере ВЫКЛЮЧЕН. Включите его в настройках LAN принтера и подтвердите кнопкой OK. Без него задания печати не запускаются.`,skip:`Не удалось проверить — требуется активное подключение к принтеру.`},printer_publishing:{title:`Принтер публикует состояние`,pass:`Принтер публикует обновления состояния — AMS, филаменты и K-профили будут корректно передаваться в слайсер.`,fail:`MQTT-брокер принял подключение, но принтер не опубликовал ни одного отчёта о состоянии. Почти всегда причина в неверном регистре или ошибке в серийном номере: тема device//report чувствительна к регистру. Сверьте серийный номер в настройках принтера с номером на его экране.`,skip:`Не удалось проверить — требуется активное подключение к принтеру.`}}},systemHealth:{sectionTitle:`Состояние системы`,sectionDescription:`Проверяет последние журналы на известные проблемы, которые обычно можно исправить самостоятельно до обращения в поддержку.`,rescan:`Проверить снова`,clean:`В последних {{times}} записях журнала известные проблемы не найдены.`,logUnavailable:`Запись журналов в файл отключена, поэтому выполнить проверку невозможно. Включите файловое журналирование.`,learnMore:`Как исправить`,fixLabel:`Исправление:`,occurrences:`Обнаружено {{times}} раз; последний раз — {{lastSeen}}`,category:{layer8:`Можно исправить самостоятельно`,environment:`Окружение`,bug:`Сообщите об ошибке`},signature:{"ftp-auth-rejected":{name:`Принтер отклонил код доступа`,cause:`Принтер отклонил вход в службу передачи файлов. Код доступа неверен либо изменился после переключения режима разработчика.`,fix:`Снова скопируйте код доступа с экрана принтера в настройках LAN и обновите его в настройках принтера Bambuddy.`},"ftp-connection-timeout":{name:`Истекло время подключения к службе передачи файлов`,cause:`Bambuddy не смог подключиться к порту передачи файлов принтера (FTPS 990). Порт заблокирован, принтер выключен либо находится в другой подсети.`,fix:`Убедитесь, что порт 990 между Bambuddy и принтером не блокируется и оба устройства находятся в доступных друг другу сетях.`},"ftp-ssl-error":{name:`Ошибка защищённого соединения с файловой службой`,cause:`Не удалось выполнить TLS-рукопожатие с сервером передачи файлов принтера. Частые причины — межсетевой экран или устаревшая прошивка принтера.`,fix:`Обновите прошивку принтера и убедитесь, что межсетевой экран или прокси не перехватывает соединение на порту 990.`},"mqtt-connection-flapping":{name:`Подключение к принтеру постоянно обрывается`,cause:`Управляющее соединение MQTT 8883 постоянно разрывается и восстанавливается. Обычно причина в нестабильной сети или частично заблокированном порте.`,fix:`Проверьте уровень Wi‑Fi у принтера, по возможности используйте проводное подключение и убедитесь в стабильной доступности порта 8883.`},"camera-connection-refused":{name:`Видеопоток камеры недоступен`,cause:`Камера недоступна по порту RTSPS 322. Порт заблокирован либо на принтере выключены камера или просмотр по LAN.`,fix:`Включите камеру и просмотр по LAN на принтере и убедитесь, что порт 322 не заблокирован. На печать это не влияет.`},"database-locked":{name:`Конфликт записи в базу данных`,cause:`Под нагрузкой SQLite выдаёт ошибки «database is locked». Это часто происходит при одновременной работе с несколькими принтерами.`,fix:`Переведите Bambuddy на внешнюю базу PostgreSQL. См. руководство по PostgreSQL в документации.`}}},vpDiagnostic:{title:`Проверка настройки — {{name}}`,runButton:`Запустить проверку`,running:`Выполняется проверка настройки...`,runFailed:`Не удалось выполнить проверку: {{error}}`,retry:`Запустить снова`,overall:{ok:`Все проверки пройдены — виртуальный принтер настроен правильно.`,warnings:`Виртуальный принтер должен работать, но некоторые пункты требуют внимания.`,problems:`Обнаружены проблемы, из-за которых слайсер не видит или не может использовать виртуальный принтер.`},check:{enabled:{title:`Виртуальный принтер включён`,fail:`Виртуальный принтер выключен. Включите его, чтобы он обнаруживался в сети.`},running:{title:`Службы запущены`,fail:`Виртуальный принтер включён, но его службы не работают. Проверьте журнал Bambuddy: обычно запуску мешает конфликт IP-адреса привязки или ошибка прав доступа.`},bind_interface:{title:`Сетевой интерфейс привязки`,fail:`Интерфейс привязки не задан или больше не существует на этом хосте. Выберите актуальный интерфейс в списке «Интерфейс привязки».`},access_code:{title:`Код доступа задан`,fail:`Код доступа не задан. В слайсере необходимо указать тот же восьмизначный код, который задан здесь.`},target_printer:{title:`Целевой принтер`,fail:`Целевой принтер не выбран. Для режима прокси необходимо указать реальный принтер.`,warn:`Целевой принтер сейчас не в сети. Перенаправление возобновится после его подключения.`},port_ftps:{title:`Служба загрузки файлов (порт {{port}})`,fail:`На порту {{port}} выбранного IP-адреса никто не слушает, поэтому слайсер не сможет загружать файлы. Обычно причина в конфликте порта на этом интерфейсе.`},port_mqtt:{title:`Служба управления (порт {{port}})`,fail:`На порту {{port}} выбранного IP-адреса никто не слушает, поэтому слайсер не сможет подключиться и отображать состояние.`},port_bind:{title:`Служба обнаружения (порт {{port}})`,fail:`На порту {{port}} выбранного IP-адреса никто не слушает, поэтому сетевое обнаружение слайсером не работает.`},certificate:{title:`Сертификат TLS`,pass:`Сертификат готов. Убедитесь, что сертификат центра сертификации Bambuddy, указанный выше, импортирован в доверенное хранилище слайсера.`,fail:`Сертификат TLS для этого виртуального принтера отсутствует. Проверьте права записи в каталог данных Bambuddy.`}}},bugReport:{title:`Сообщить об ошибке`,description:`Описание`,descriptionPlaceholder:`Что пошло не так? Опишите проблему...`,email:`Электронная почта (необязательно)`,emailPlaceholder:`your@email.com`,emailPrivacy:`Указанный адрес будет добавлен в сворачиваемый раздел задачи GitHub, чтобы сопровождающий мог с вами связаться.`,screenshot:`Снимок экрана`,uploadOrPaste:`Загрузите, вставьте или перетащите изображение`,dataCollectedSummary:`Какие данные включаются в отчёт?`,dataIncluded:`Включаются:`,dataIncludedList:`Версия приложения, ОС, архитектура, версия Python, статистика базы данных (только количества), модели принтеров, количество сопел, версии прошивок, состояние подключений, состояние интеграций (Spoolman, MQTT, HA), неконфиденциальные настройки, количество сетевых интерфейсов, сведения о Docker и версии зависимостей.`,dataNeverIncluded:`Никогда не включаются:`,dataNeverIncludedList:`Имена принтеров, серийные номера, коды доступа, пароли, IP-адреса, адреса электронной почты, ключи API, токены, URL вебхуков, имена хостов и пользователей.`,submit:`Отправить`,startLogging:`Начать отладочную запись`,stepEnableLogging:`Отладочная запись включена`,stepReproduce:`Теперь воспроизведите проблему`,stepStopLogging:`Остановить и отправить отчёт`,stopAndSubmit:`Остановить и отправить`,maxDuration:`Автоматическая остановка через {{minutes}} мин`,stoppingLogs:`Сбор журналов и отправка...`,submitting:`Отправка отчёта об ошибке...`,submittingStepConnection:`Проверка подключений к принтерам`,submittingStepVirtualPrinters:`Проверка настройки виртуальных принтеров`,submittingStepLogScan:`Поиск известных проблем в последних журналах`,submittingStepSubmit:`Отправка отчёта в GitHub`,submitSuccess:`Отчёт об ошибке успешно отправлен!`,submitFailed:`Не удалось отправить отчёт об ошибке`,diagnosticChecking:`Проверка подключений к принтерам...`,diagnosticHealthy:`Проверка подключения пройдена — проблем с принтерами не обнаружено.`,diagnosticSummary:`Проблемы с подключением обнаружены у {{problems}} из {{total}} принтеров`,diagnosticIntro:`У одного или нескольких принтеров есть проблема подключения, которая может быть причиной сбоя. Разверните сведения о принтере ниже и посмотрите способ исправления — возможно, отчёт об ошибке не потребуется. При необходимости его всё равно можно отправить ниже.`,logHealthSummary:`В журналах найдены известные проблемы`,logHealthIntro:`Последние записи журнала совпадают с известными проблемами. Посмотрите способы исправления ниже — возможно, отчёт об ошибке не потребуется. При необходимости его всё равно можно отправить.`,thankYou:`Спасибо!`,submitted:`Отчёт об ошибке отправлен.`,viewIssue:`Открыть задачу`,unexpectedError:`Произошла непредвиденная ошибка`},failureDetection:{title:`Обнаружение сбоев с помощью ИИ`,description:`Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.`,mlUrl:`URL Obico ML API`,mlUrlHint:`Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.`,test:`Проверить`,testSuccess:`ML API доступен и работает.`,testFailed:`Не удалось подключиться к ML API.`,sensitivity:`Чувствительность`,sensitivityLow:`Низкая (меньше ложных срабатываний)`,sensitivityMedium:`Средняя (сбалансированная)`,sensitivityHigh:`Высокая (раннее обнаружение, больше ложных срабатываний)`,sensitivityHint:`Определяет пороги уверенности, при которых создаются предупреждения и фиксируются сбои.`,action:`Действие при обнаружении сбоя`,actionNotify:`Только уведомить`,actionPause:`Приостановить печать`,actionPauseOff:`Приостановить печать и отключить питание`,pollInterval:`Интервал проверки (секунды)`,pollIntervalHint:`Как часто проверять каждый принтер во время печати. Минимум 5 с, максимум 120 с.`,externalUrlMissing:`Внешний URL не задан.`,externalUrlHint:`ML API получает снимок камеры по URL. Укажите внешний URL в общих настройках, чтобы контейнер ML API мог обращаться к Bambuddy.`,perPrinterTitle:`Контролируемые принтеры`,perPrinterHint:`Выберите принтеры, за которыми будет следить служба обнаружения.`,monitorAll:`Контролировать все подключённые принтеры`,statusTitle:`Состояние`,serviceRunning:`Служба работает`,thresholds:`Низкий / высокий порог`,activePrinters:`Активные задания`,noActivePrints:`Сейчас ничего не печатается.`,historyTitle:`Последние обнаружения`,noHistory:`Обнаружений пока нет.`},makerworld:{title:`MakerWorld`,description:`Вставьте URL модели MakerWorld, чтобы импортировать и напечатать её прямо из Bambuddy — без перехода в приложение Bambu Handy.`,pasteUrlHeader:`Импорт из MakerWorld`,pasteUrlPlaceholder:`https://makerworld.com/en/models/… или любая ссылка MakerWorld`,resolveButton:`Получить данные`,signInRequiredTitle:`Для загрузки требуется вход в Bambu Cloud`,signInRequiredBody:`Просматривать сведения о модели можно анонимно, но для загрузки файлов 3MF MakerWorld требует учётную запись Bambu Cloud.`,signInExpiredTitle:`Срок авторизации в Bambu Cloud истёк`,signInExpiredBody:`Вы по-прежнему авторизованы в Bambuddy, но Bambu Lab перестал принимать сохранённый токен, поэтому загрузки завершатся ошибкой. Выполните вход в Bambu Cloud снова.`,openCloudSettings:`Открыть настройки облака`,untitledModel:`Модель без названия`,byCreator:`Автор: {{name}}`,downloadsCount:`Загрузок: {{count}}`,licensePrefix:`Лицензия`,alreadyImported:`Уже в библиотеке`,openOnMakerworld:`Открыть на MakerWorld`,alreadyInLibrary:`Эта модель уже есть в библиотеке — найдите её в разделе «Файлы → MakerWorld».`,importSuccess:`Импортирован файл {{filename}} — сохранён в «Файлы → MakerWorld».`,platesHeader:`Пластины ({{count}})`,plateDefaultName:`Пластина {{n}}`,materialCount:`Филаментов: {{count}}`,amsRequired:`Требуется AMS`,slicedFor:`Нарезано для {{printer}}`,alsoCompatible:`Также отмечено как совместимое: {{printers}}`,importToLibrary:`Сохранить`,sliceIn:`Сохранить и нарезать в {{slicer}}`,disclaimer:`Интеграция с MakerWorld использует документированные сообществом конечные точки API. Bambuddy не связан с MakerWorld или Bambu Lab и не одобрен ими.`,lastImportSuccess:`Импортировано в библиотеку`,lastImportAlreadyInLibrary:`Уже в библиотеке`,viewInLibrary:`Открыть в файловом менеджере`,openInBambuStudio:`Открыть в Bambu Studio`,openInOrcaSlicer:`Открыть в OrcaSlicer`,importTo:`Импортировать в файловый менеджер`,recentImportsHeader:`Недавние импорты`,phaseResolving:`Получение данных`,phaseDownloading:`Загрузка`,folderAuto:`MakerWorld (по умолчанию)`,importAll:`Импортировать всё`,importAllProgress:`Импорт {{current}} из {{total}}`,openGallery:`Открыть галерею изображений`,galleryPrev:`Предыдущее изображение`,galleryNext:`Следующее изображение`,deleteImport:`Удалить из библиотеки`,importDeleting:`Удаление…`,importDeleted:`Удалено из библиотеки`,confirmDelete:`Удалить {{filename}} из библиотеки? Локальный файл будет удалён, но пластину можно снова импортировать из MakerWorld.`,errors:{resolveFailed:`Не удалось обработать эту ссылку MakerWorld.`,downloadFailed:`Ошибка загрузки. Повторите попытку.`,deleteFailed:`Не удалось удалить файл из библиотеки.`}},gcodeViewer:{back:`Назад`,backToArchives:`Вернуться в архив печати`,backToFiles:`Вернуться в файловый менеджер`},libraryTrash:{title:`Корзина`,headerButton:`Корзина`,headerTooltip:`Просмотреть файлы, перемещённые в корзину`,backToFiles:`Вернуться к файлам`,subtitleAdmin:`Удалённые файлы хранятся здесь {{days}} дней, затем удаляются автоматически. Показаны файлы всех пользователей.`,subtitleUser:`Удалённые файлы хранятся здесь {{days}} дней, затем удаляются автоматически.`,loading:`Загрузка корзины…`,loadError:`Не удалось загрузить корзину.`,empty:`Корзина пуста.`,summary:`{{count}} файлов · {{size}}`,emptyTrash:`Очистить корзину`,restore:`Восстановить`,purgeNow:`Удалить сейчас`,autoPurgeIn:`Автоудаление через {{when}}`,days:`дней`,retentionLabel:`Автоудаление через`,selectAll:`Выбрать всё`,selectOne:`Выбрать {{filename}}`,selectionCount:`Выбрано: {{count}}`,bulkRestore:`Восстановить выбранные`,bulkPurge:`Удалить выбранные`,col:{filename:`Файл`,folder:`Папка`,size:`Размер`,deleted:`Перемещён в корзину`,autoPurge:`Автоудаление`,owner:`Владелец`,actions:`Действия`},confirm:{purgeTitle:`Удалить навсегда?`,purgeBody:`Файл {{filename}} будет удалён с диска без возможности восстановления.`,emptyTitle:`Очистить корзину?`,emptyBody:`Все файлы ({{count}}) будут удалены с диска. Отменить это действие нельзя.`,bulkPurgeTitle:`Удалить выбранные файлы навсегда?`,bulkPurgeBody:`Выбранные файлы ({{count}}) будут удалены с диска без возможности восстановления.`,cta:`Удалить навсегда`},toast:{restored:`Файл восстановлен.`,restoreFailed:`Не удалось восстановить файл.`,purged:`Файл удалён навсегда.`,purgeFailed:`Не удалось удалить файл.`,emptied:`Из корзины удалено файлов: {{count}}.`,emptyFailed:`Не удалось очистить корзину.`,retentionSaved:`Срок автоудаления: {{days}} дней.`,retentionFailed:`Не удалось сохранить срок хранения.`,bulkRestored:`Восстановлено файлов: {{count}}.`,bulkPurged:`Удалено файлов: {{count}}.`}},libraryPurge:{title:`Очистить старые файлы`,headerButton:`Очистить старые`,headerTooltip:`Массово переместить старые файлы в корзину`,description:`За один проход убирает старые файлы из библиотеки. Для печатавшихся файлов возраст считается от даты последней печати, для ни разу не печатавшихся — от даты загрузки.`,ageLabel:`Переместить файлы старше`,days:`дней`,includeNeverPrinted:`Включить файлы, которые ни разу не печатались`,effectsTitle:`Что произойдёт после нажатия «Очистить»`,effect1:`Подходящие файлы будут перемещены в корзину, но пока не удалены с диска.`,effect2:`Их можно восстановить из корзины до окончания срока хранения.`,effect3:`После окончания срока хранения очистка корзины окончательно удалит их с диска.`,effect4:`Файлы во внешних подключённых папках пропускаются: Bambuddy не удаляет чужие данные.`,previewLoading:`Подсчёт подходящих файлов…`,previewFailed:`Не удалось выполнить предварительный расчёт.`,previewSummary:`В корзину будет перемещено: {{count}} файлов · {{size}}`,andMore:`…и ещё {{count}}`,warning:`Файлы в корзине продолжают занимать место до окончания срока хранения. Чтобы освободить диск сразу, после операции очистите корзину.`,confirmCta:`Переместить в корзину: {{count}}`,purging:`Перемещение в корзину…`,toast:{success:`Перемещено в корзину файлов: {{count}}.`,failed:`Не удалось очистить файлы.`}},libraryAutoPurge:{enableLabel:`Автоматически очищать старые файлы`,enableDescription:`Раз в сутки запускает административную очистку. Сначала файлы попадают в корзину и не удаляются немедленно.`,ageLabel:`Автоматически очищать файлы старше`,ageDescription:`Минимум 7 дней, максимум 10 лет. Используется тот же способ расчёта возраста, что и при ручной очистке.`,days:`дней`,includeNeverPrinted:`Включить файлы, которые ни разу не печатались`,saveFailed:`Не удалось сохранить настройки автоочистки.`},archivePurge:{headerButton:`Очистить старые`,headerTooltip:`Массово удалить старые записи архива`,title:`Очистить старые записи архива`,description:`Удаляет старую историю печати. Возраст записи считается от последнего завершения печати; повторная печать обновляет дату, поэтому используемые записи не удаляются.`,ageLabel:`Удалить записи, которые не печатались последние`,days:`дней`,effectsTitle:`Что произойдёт после нажатия «Очистить»`,effect1:`Каждая подходящая запись будет скрыта из списков, а связанные файлы удалены с диска: 3MF, миниатюра, таймлапс, исходный 3MF, проект F3D и фотографии.`,effect2:`Строка архива останется в базе, поэтому быстрая статистика сохранит вклад по филаменту, времени, стоимости и энергии — как при обычном удалении отдельной записи.`,effect3:`Установите флажок «Также удалить из статистики», чтобы исключить вклад из быстрой статистики. Это действие необратимо.`,effect4:`Повторная печать обновляет дату записи, поэтому используемые архивы не удаляются.`,purgeStatsLabel:`Также удалить из статистики`,purgeStatsHint:`Исключает соответствующие записи из быстрой статистики по филаменту, времени, стоимости и энергии. Без этого удаляются только файлы, а статистика сохраняется.`,previewLoading:`Подсчёт подходящих записей…`,previewFailed:`Не удалось выполнить предварительный расчёт.`,previewSummary:`Будет удалено: {{count}} записей · {{size}}`,andMore:`…и ещё {{count}}`,warning:`Файлы будут удалены с диска без возможности восстановления. Перед продолжением скачайте или добавьте в избранное всё, что нужно сохранить.`,confirmCta:`Удалить записей: {{count}}`,purging:`Удаление…`,toast:{success:`Удалено записей архива: {{count}}.`,failed:`Не удалось очистить архив.`}},archiveAutoPurge:{enableLabel:`Автоматически очищать старые записи архива`,enableDescription:`Раз в сутки скрывает записи из списков и удаляет их файлы с диска, если они не печатались в течение заданного срока. Повторная печать сбрасывает отсчёт.`,ageLabel:`Автоматически удалять записи, которые не печатались последние`,ageDescription:`Минимум 7 дней, максимум 10 лет. Срок считается от последнего завершения печати; повторная печать обновляет дату. Удаляются 3MF, миниатюра, таймлапс, исходный 3MF, F3D и фотографии.`,days:`дней`,purgeStatsLabel:`Также удалить из статистики`,purgeStatsDescription:`Если включено, ежедневная очистка исключает удалённые записи из быстрой статистики по филаменту, времени, стоимости и энергии. По умолчанию выключено: статистика сохраняется, удаляются только файлы.`,runNow:`Очистить архив сейчас`,saveFailed:`Не удалось сохранить настройки автоочистки.`},cameraTokens:{scope:{camera_stream:`Поток камеры`,camwall:`Стена камер`,overlay:`Оверлей для стрима`},title:`Токены API камеры`,navTitle:`Токены API камеры`,description:`Долгоживущие токены для встраивания видеопотока в Home Assistant, Frigate, киоски и другие системы, которым нужен постоянный URL. Каждый токен даёт доступ только к потоку камеры и может быть отозван в любой момент.`,loading:`Загрузка...`,confirmRevoke:{title:`Отозвать этот токен?`,body:`Все устройства, использующие «{{name}}», немедленно потеряют доступ. Отменить это действие нельзя.`,cancel:`Отмена`,confirm:`Отозвать`},create:{scopeLabel:`Область действия`,hintCameraStream:`Токен потока камеры позволяет получать только видеопотоки и снимки. Используйте его для Home Assistant, Frigate и других систем, встраивающих изображение с одной камеры.`,hintCamWall:`Токен стены камер открывает страницу /camwall на экране без авторизации. Он позволяет видеть названия и состояния всех принтеров, а также видеопотоки с их камер. Имена файлов, адреса и коды доступа недоступны.`,hintOverlay:`Токен оверлея для стрима открывает страницу /overlay/{printerId} на экране без авторизации — для OBS или любой прямой трансляции. Он даёт доступ к видеопотоку одного принтера и его текущему статусу печати, включая имя файла, отображаемое на экране. Адреса и коды доступа недоступны.`,title:`Создать токен`,nameLabel:`Название токена`,namePlaceholder:`Например, Home Assistant`,daysLabel:`Срок действия, дней`,submit:`Создать`,hint:`Максимальный срок действия — 365 дней. Значение токена показывается только один раз при создании — скопируйте его сейчас.`},created:{camWallUrlTitle:`URL стены камер для этого экрана`,camWallUrlHint:`Откройте этот адрес на экране. Любой, кто получит URL, сможет наблюдать за стеной камер, поэтому обращайтесь с ним как с ключом. Отзовите токен, чтобы отключить экран.`,overlayUrlTitle:`URL оверлея для OBS`,overlayUrlHint:`Добавьте этот адрес как источник браузера (Browser Source) в OBS. Замените число в /overlay/1 на номер вашего принтера (его можно узнать из URL на странице «Принтеры»). Любой, кто получит URL, сможет смотреть трансляцию, поэтому обращайтесь с ним как с ключом — отзовите токен, чтобы прекратить доступ.`,title:`Токен создан — скопируйте его сейчас`,warning:`Это единственный раз, когда токен отображается. После закрытия окна посмотреть его снова будет невозможно.`,copy:`Копировать`,dismiss:`Я сохранил токен`},list:{scope:`Область действия`,myTitle:`Мои токены`,allTitle:`Все пользователи (режим администратора)`,empty:`Токенов пока нет.`,name:`Название`,owner:`Владелец`,prefix:`Префикс`,created:`Создан`,expires:`Истекает`,lastUsed:`Последнее использование`,revoke:`Отозвать`,expired:`Истёк`},toast:{created:`Токен создан`,createFailed:`Не удалось создать токен`,revoked:`Токен отозван`,revokeFailed:`Не удалось отозвать токен`,loadFailed:`Не удалось загрузить токены`,copied:`Скопировано в буфер обмена`,copyFailed:`Не удалось скопировать — выделите и скопируйте вручную`}},forecast:{title:`Прогноз`,noSpools:`Активные катушки не найдены. Добавьте катушки в учёт, чтобы увидеть прогноз.`,noUsageData:`Нет данных о расходе — невозможно спрогнозировать остатки.`,sku:`Артикул`,material:`Материал`,stock:`Запас`,dailyRate:`Расход`,daysLeft:`Осталось дней`,emptyBy:`Закончится к`,reorderBy:`Заказать до`,actions:`Действия`,trend:`Тренд`,estimated:`Оценка`,noData:`Нет данных`,timeframe:`Период`,chartTitle:`Прогноз запаса — 5 основных материалов`,dashedLinesROP:`Пунктирные линии — точки заказа`,stockLevel:`Уровень запаса`,reorderPoint:`Точка заказа`,safetyMargin:`Страховой запас`,trendLegend:`Тренд (по истории, уровень обслуживания 95%)`,estimatedLegend:`Оценка (по изменению массы)`,noDataLegend:`Нет данных`,ropLabel:`ТЗ`,ssLabel:`СЗ`,safetyStockLegend:`Страховой запас`,stockArrivalLegend:`Поступление`,stockoutLegend:`Исчерпание запаса`,alertCount_one:`{{count}} предупреждение`,alertCount_other:`{{count}} предупреждений`,order:`Заказать`,save:`Сохранить`,cancel:`Отмена`,settingsSaved:`Настройки сохранены`,failedSaveSettings:`Не удалось сохранить настройки`,globalLeadTimeSaved:`Общий срок поставки сохранён`,globalLeadTime:`Общий срок поставки`,globalLeadTimeHint:`Минимальный общий срок поставки, используемый при расчёте точки заказа для всех артикулов`,skuLeadTimeOverride:`Переопределение срока поставки`,skuLeadTimeHint:`0 — использовать общий срок поставки. Значение больше 0 переопределяет его для этого артикула.`,safetyMarginLabel:`Страховой запас`,effectiveLeadTime:`Фактический срок поставки`,effectiveLeadTimeHint:`макс. (общий {{global}} дн., для артикула {{sku}} дн.)`,reorderPointHint:`средний расход × срок поставки + страховой запас — заказывайте, когда остаток достигнет этого уровня`,safetyMarginHint:`Статистический страховой запас (z=1,65 × σ × √LT) плюс заданный пользователем резерв`,safetyMarginHintDays:`Резерв поверх статистического страхового запаса.{{approx}}`,safetyMarginHintDaysApprox:` ≈ {{g}} г при текущем расходе.`,safetyMarginHintG:`Фиксированный весовой резерв поверх статистического страхового запаса.{{approx}}`,safetyMarginHintGApprox:` ≈ {{days}} дн. при текущем расходе.`,individualSpools:`Отдельные катушки`,labelWeight:`Метка`,spoolCount_one:`{{count}} катушка`,spoolCount_other:`{{count}} катушек`,stockBreakRisk:`Риск исчерпания запаса`,stockBreakDetail:`Осталось {{days}} дн., срок поставки {{lt}} дн.`,stockBreakBefore:`Запас закончится до пополнения`,reorderNow:`Заказать сейчас`,reorderTriggerPassed:`Дата заказа {{date}} уже прошла.`,shoppingList:`Список покупок`,shoppingListItems_one:`({{count}} позиция)`,shoppingListItems_other:`({{count}} позиций)`,shoppingListEmpty:`Список покупок пуст. Нажмите значок корзины в нужной строке, чтобы добавить позицию.`,addToCart:`Добавить в список покупок`,alertsSnoozed:`Отключить предупреждения для артикула`,alertsEnabled:`Включить предупреждения для артикула`,addedToCart:`Добавлено в список покупок`,failedAddItem:`Не удалось добавить позицию`,listView:`Список`,logisticsView:`Логистика`,qty:`Кол-во`,weight:`Масса`,leadTime:`Срок поставки`,expectedRestock:`Ожидаемое пополнение`,status:`Состояние`,note:`Примечание`,pending:`Ожидается`,purchased:`Куплено`,received:`Получено`,markPurchased:`Отметить как купленное`,markReceived:`Отметить как полученное — катушки будут добавлены в запас`,resetToPending:`Вернуть в ожидание`,remove:`Удалить`,clearAll:`Очистить всё`,downloadCsv:`CSV`,addToCartTitle:`Добавить в список покупок`,byQuantity:`По количеству`,byDuration:`По сроку`,numberOfSpools:`Количество катушек`,lastHowManyDays:`На сколько дней должно хватить?`,noUsageQty:`Нет данных о расходе — количество установлено равным 1.`,noteOptional:`Примечание (необязательно)`,notePlaceholder:`например, для проекта X, срочно…`,addNSpools_one:`Добавить {{count}} катушку`,addNSpools_other:`Добавить {{count}} катушек`,onArrival:`После поступления`,stockBreakIn:`Запас закончится через {{days}} дн.`,stockRunsOutBefore:`Запас закончится раньше, чем истекут {{lt}} дн. срока поставки.`,atRate:`При расходе {{rate}} г/день требуется`,moreSpools_one:`ещё {{count}} катушка`,moreSpools_other:`ещё {{count}} катушек`,bridgeGap:`чтобы перекрыть дефицит.`,noReadAccess:`У вас нет прав на просмотр прогноза запасов.`,noWriteAccess:`У вас нет прав на изменение настроек прогноза.`}}}},Tt=[`en`,`de`,`es`,`fr`,`ja`,`it`,`ko`,`pt-BR`,`ru`,`tr`,`zh-CN`,`zh-TW`],Et=`bambuddy_appliance_locale_consumed`;De.use(Ct).use(Ke).init({resources:wt,fallbackLng:`en`,supportedLngs:Tt,detection:{order:[`localStorage`,`navigator`,`htmlTag`],lookupLocalStorage:`bambutrack_language`,caches:[`localStorage`]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});function Dt(){if(typeof window>`u`||!window.localStorage)return;let e=window.localStorage;typeof e.getItem!=`function`||typeof e.setItem!=`function`||e.getItem(Et)||fetch(`/api/v1/system/appliance`).then(e=>e.ok?e.json():null).then(t=>{!t||typeof t.locale!=`string`||Tt.includes(t.locale)&&(De.changeLanguage(t.locale),e.setItem(Et,`1`))}).catch(()=>{})}Dt();var Ot=[{code:`en`,name:`English`,nativeName:`English`},{code:`de`,name:`German`,nativeName:`Deutsch`},{code:`es`,name:`Spanish`,nativeName:`Español`},{code:`fr`,name:`French`,nativeName:`Français`},{code:`ja`,name:`Japanese`,nativeName:`日本語`},{code:`it`,name:`Italian`,nativeName:`Italiano`},{code:`ko`,name:`Korean`,nativeName:`한국어`},{code:`pt-BR`,name:`Portuguese (Brazil)`,nativeName:`Português (Brasil)`},{code:`zh-CN`,name:`Chinese (Simplified)`,nativeName:`简体中文`},{code:`zh-TW`,name:`Chinese (Traditional)`,nativeName:`繁體中文`},{code:`tr`,name:`Turkish`,nativeName:`Türkçe`},{code:`ru`,name:`Russian`,nativeName:`Русский`}],kt=`modulepreload`,At=function(e){return`/`+e},jt={},Mt=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=At(t,n),t in jt)return;jt[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:kt,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},G=`popstate`;function Nt(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function Pt(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return zt(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:Bt(t)}return Ht(t,n,null,e)}function Ft(e,t){if(e===!1||e==null)throw Error(t)}function It(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function Lt(){return Math.random().toString(36).substring(2,10)}function Rt(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function zt(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?Vt(t):t,state:n,key:t&&t.key||r||Lt(),mask:i}}function Bt({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function Vt(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function Ht(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=Nt(e)?e:zt(h.location,e,t);n&&n(r,e),l=u()+1;let d=Rt(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=Nt(e)?e:zt(h.location,e,t);n&&n(r,e),l=u();let i=Rt(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return Ut(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(G,d),c=e,()=>{i.removeEventListener(G,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function Ut(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),Ft(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:Bt(t);return i=i.replace(/ $/,`%20`),!n&&i.startsWith(`//`)&&(i=r+i),new URL(i,r)}function Wt(e,t,n=`/`){return Gt(e,t,n,!1)}function Gt(e,t,n,r,i){let a=dn((typeof t==`string`?Vt(t):t).pathname||`/`,n);if(a==null)return null;let o=i??qt(e),s=null,c=un(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;Ft(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=bn([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(Ft(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),Jt(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:an(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of Yt(e.path))a(e,t,!0,n)}),t}function Yt(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=Yt(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function Xt(e){e.sort((e,t)=>e.score===t.score?on(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var Zt=/^:[\w-]+$/,Qt=3,$t=2,en=1,tn=10,nn=-2,rn=e=>e===`*`;function an(e,t){let n=e.split(`/`),r=n.length;return n.some(rn)&&(r+=nn),t&&(r+=$t),n.filter(e=>!rn(e)).reduce((e,t)=>e+(Zt.test(t)?Qt:t===``?en:tn),r)}function on(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function sn(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function ln(e,t=!1,n=!0){It(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function un(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return It(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function dn(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var fn=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function pn(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?Vt(e):e,a;return n?(n=yn(n),a=n.startsWith(`/`)?mn(n.substring(1),`/`):mn(n,t)):a=t,{pathname:a,search:Cn(r),hash:wn(i)}}function mn(e,t){let n=xn(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function hn(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function gn(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function _n(e){let t=gn(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function vn(e,t,n,r=!1){let i;typeof e==`string`?i=Vt(e):(i={...e},Ft(!i.pathname||!i.pathname.includes(`?`),hn(`?`,`pathname`,`search`,i)),Ft(!i.pathname||!i.pathname.includes(`#`),hn(`#`,`pathname`,`hash`,i)),Ft(!i.search||!i.search.includes(`#`),hn(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=pn(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var yn=e=>e.replace(/\/\/+/g,`/`),bn=e=>yn(e.join(`/`)),xn=e=>e.replace(/\/+$/,``),Sn=e=>xn(e).replace(/^\/*/,`/`),Cn=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,wn=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Tn=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function En(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Dn(e){return bn(e.map(e=>e.route.path).filter(Boolean))||`/`}var On=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function kn(e,t){let n=e;if(typeof n!=`string`||!fn.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(On)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=dn(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{It(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var An=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(An);var jn=[`GET`,...An];new Set(jn);var Mn=y.createContext(null);Mn.displayName=`DataRouter`;var Nn=y.createContext(null);Nn.displayName=`DataRouterState`;var Pn=y.createContext(!1);function Fn(){return y.useContext(Pn)}var In=y.createContext({isTransitioning:!1});In.displayName=`ViewTransition`;var Ln=y.createContext(new Map);Ln.displayName=`Fetchers`;var Rn=y.createContext(null);Rn.displayName=`Await`;var zn=y.createContext(null);zn.displayName=`Navigation`;var Bn=y.createContext(null);Bn.displayName=`Location`;var Vn=y.createContext({outlet:null,matches:[],isDataRoute:!1});Vn.displayName=`Route`;var Hn=y.createContext(null);Hn.displayName=`RouteError`;var Un=`REACT_ROUTER_ERROR`,Wn=`REDIRECT`,Gn=`ROUTE_ERROR_RESPONSE`;function Kn(e){if(e.startsWith(`${Un}:${Wn}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function qn(e){if(e.startsWith(`${Un}:${Gn}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new Tn(t.status,t.statusText,t.data)}catch{}}function Jn(e,{relative:t}={}){Ft(Yn(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=y.useContext(zn),{hash:i,pathname:a,search:o}=ar(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:bn([n,a])),r.createHref({pathname:s,search:o,hash:i})}function Yn(){return y.useContext(Bn)!=null}function Xn(){return Ft(Yn(),`useLocation() may be used only in the context of a component.`),y.useContext(Bn).location}var Zn=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function Qn(e){y.useContext(zn).static||y.useLayoutEffect(e)}function $n(){let{isDataRoute:e}=y.useContext(Vn);return e?wr():er()}function er(){Ft(Yn(),`useNavigate() may be used only in the context of a component.`);let e=y.useContext(Mn),{basename:t,navigator:n}=y.useContext(zn),{matches:r}=y.useContext(Vn),{pathname:i}=Xn(),a=JSON.stringify(_n(r)),o=y.useRef(!1);return Qn(()=>{o.current=!0}),y.useCallback((r,s={})=>{if(It(o.current,Zn),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=vn(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:bn([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var tr=y.createContext(null);function nr(){return y.useContext(tr)}function rr(e){let t=y.useContext(Vn).outlet;return y.useMemo(()=>t&&y.createElement(tr.Provider,{value:e},t),[t,e])}function ir(){let{matches:e}=y.useContext(Vn);return e[e.length-1]?.params??{}}function ar(e,{relative:t}={}){let{matches:n}=y.useContext(Vn),{pathname:r}=Xn(),i=JSON.stringify(_n(n));return y.useMemo(()=>vn(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function or(e,t){return sr(e,t)}function sr(e,t,n){Ft(Yn(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=y.useContext(zn),{matches:i}=y.useContext(Vn),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Er(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let u=Xn(),d;if(t){let e=typeof t==`string`?Vt(t):t;Ft(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):Wt(e,{pathname:p});It(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),It(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=mr(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:bn([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:bn([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?y.createElement(Bn.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function cr(){let e=Cr(),t=En(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=y.createElement(y.Fragment,null,y.createElement(`p`,null,`💿 Hey developer 👋`),y.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,y.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,y.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),y.createElement(y.Fragment,null,y.createElement(`h2`,null,`Unexpected Application Error!`),y.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?y.createElement(`pre`,{style:i},n):null,o)}var lr=y.createElement(cr,null),ur=class extends y.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=qn(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:y.createElement(Vn.Provider,{value:this.props.routeContext},y.createElement(Hn.Provider,{value:e,children:this.props.component}));return this.context?y.createElement(fr,{error:e},t):t}};ur.contextType=Pn;var dr=new WeakMap;function fr({children:e,error:t}){let{basename:n}=y.useContext(zn);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=Kn(t.digest);if(e){let r=dr.get(t);if(r)throw r;let i=kn(e.location,n);if(On&&!dr.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw dr.set(t,n),n}return y.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function pr({routeContext:e,match:t,children:n}){let r=y.useContext(Mn);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(Vn.Provider,{value:e},n)}function mr(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);Ft(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Dn(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||lr,o&&(s<0&&c===0?(Er(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?y.createElement(n.route.Component,null):n.route.element?n.route.element:e,y.createElement(pr,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?y.createElement(ur,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function hr(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function gr(e){let t=y.useContext(Mn);return Ft(t,hr(e)),t}function _r(e){let t=y.useContext(Nn);return Ft(t,hr(e)),t}function vr(e){let t=y.useContext(Vn);return Ft(t,hr(e)),t}function yr(e){let t=vr(e),n=t.matches[t.matches.length-1];return Ft(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function br(){return yr(`useRouteId`)}function xr(){let e=_r(`useNavigation`);return y.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function Sr(){let{matches:e,loaderData:t}=_r(`useMatches`);return y.useMemo(()=>e.map(e=>Kt(e,t)),[e,t])}function Cr(){let e=y.useContext(Hn),t=_r(`useRouteError`),n=yr(`useRouteError`);return e===void 0?t.errors?.[n]:e}function wr(){let{router:e}=gr(`useNavigate`),t=yr(`useNavigate`),n=y.useRef(!1);return Qn(()=>{n.current=!0}),y.useCallback(async(r,i={})=>{It(n.current,Zn),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Tr={};function Er(e,t,n){!t&&!Tr[e]&&(Tr[e]=!0,It(!1,n))}y.memo(Dr);function Dr({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return sr(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Or({to:e,replace:t,state:n,relative:r}){Ft(Yn(),` may be used only in the context of a component.`);let{static:i}=y.useContext(zn);It(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=y.useContext(Vn),{pathname:o}=Xn(),s=$n(),c=vn(e,_n(a),o,r===`path`),l=JSON.stringify(c);return y.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function kr(e){return rr(e.context)}function Ar(e){Ft(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function jr({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){Ft(!Yn(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=y.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=Vt(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=y.useMemo(()=>{let e=dn(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return It(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:y.createElement(zn.Provider,{value:c},y.createElement(Bn.Provider,{children:t,value:h}))}function Mr({children:e,location:t}){return or(Nr(e),t)}y.Component;function Nr(e,t=[]){let n=[];return y.Children.forEach(e,(e,r)=>{if(!y.isValidElement(e))return;let i=[...t,r];if(e.type===y.Fragment){n.push.apply(n,Nr(e.props.children,i));return}Ft(e.type===Ar,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),Ft(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Nr(e.props.children,i)),n.push(a)}),n}var Pr=`get`,Fr=`application/x-www-form-urlencoded`;function Ir(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Lr(e){return Ir(e)&&e.tagName.toLowerCase()===`button`}function Rr(e){return Ir(e)&&e.tagName.toLowerCase()===`form`}function zr(e){return Ir(e)&&e.tagName.toLowerCase()===`input`}function Br(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Vr(e,t){return e.button===0&&(!t||t===`_self`)&&!Br(e)}function Hr(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Ur(e,t){let n=Hr(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Wr=null;function Gr(){if(Wr===null)try{new FormData(document.createElement(`form`),0),Wr=!1}catch{Wr=!0}return Wr}var Kr=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function qr(e){return e!=null&&!Kr.has(e)?(It(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Fr}"`),null):e}function Jr(e,t){let n,r,i,a,o;if(Rr(e)){let o=e.getAttribute(`action`);r=o?dn(o,t):null,n=e.getAttribute(`method`)||Pr,i=qr(e.getAttribute(`enctype`))||Fr,a=new FormData(e)}else if(Lr(e)||zr(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a