diff --git a/CHANGELOG.md b/CHANGELOG.md index 54057d634..887816d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased ### Added +- **Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files** — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by `(filament_type, filament_colour)` match, and (3) submits the user's picks as an ordered `filament_presets: PresetRef[]` array which is forwarded as repeated `filamentProfile` multipart parts to the slicer sidecar (the CLI joins them with `;` for `--load-filaments`). **Per-plate filament list source-of-truth chain**: for a sliced archive the modal reads `Metadata/slice_info.config` directly (existing path); for an unsliced project file (where `slice_info.config` is empty until Bambu Studio actually slices), the new `slice_preview` service runs a fast preview-slice via the sidecar's `slice_without_profiles` (the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by `(kind, source_id, plate_id, content_hash)` with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that reads `Metadata/project_settings.config` for the AMS slot config and intersects it with the plate's painted-face data (`paint_color` quadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). **SliceModal-only tier priority is now `local → cloud → standard`** (was `cloud → local → standard`): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local's `filament_type` / `filament_colour` so the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of `/slicer/presets` (Profiles page, etc.) retain the existing cloud-first dedup. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): `/slice` now accepts up to 16 repeated `filamentProfile` parts (was hard-capped at 1), the slicing service materializes each as `filament_N.json` and joins paths into a single `--load-filaments "a.json;b.json;c.json"` invocation; `/profiles/bundled` listing was extended with `filament_type` and `filament_colour` per leaf so the bundled tier carries metadata into the modal. **Backwards compatibility**: `SliceRequest` schema accepts three shapes — legacy `filament_preset_id: int`, source-aware singular `filament_preset: PresetRef`, multi-color array `filament_presets: list[PresetRef]` — the validator promotes any of them into a populated `filament_presets` list before the route handler runs, and stale browser tabs from before this change keep working unchanged. **Permissions**: no new endpoint paths added; the preview-slice runs inside `/filament-requirements` (gated on `LIBRARY_READ` / `ARCHIVES_READ`) and the multi-filament dispatch runs inside `POST /slice` (gated on `LIBRARY_UPLOAD`) — no auth surface widened. **Tests**: 6 schema tests for `SliceRequest` covering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests for `slice_preview` covering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-key `asyncio.Lock`, and LRU eviction-with-lock-cleanup; 15 unit tests for `extract_project_filaments_from_3mf` (5 cases) and `extract_plate_extruder_set_from_3mf` (10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test on `slice_with_profiles` pinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow). + - **Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end** — Initial slicer integration only saw DB-backed local imports, so a user without imported profiles got an empty Slice modal even when their Bambu Cloud account or the slicer sidecar carried perfectly usable presets. The Slice modal now pulls from three tiers in priority order — **cloud** (the user's own Bambu Cloud presets), **local** (DB-backed imports), **standard** (slicer-bundled stock profiles) — with name-based dedup so a preset that exists in multiple tiers only renders in the highest-priority one (cloud > local > standard) and within-tier order is preserved exactly. **Listing** (`GET /api/v1/slicer/presets`): cloud branch is per-user with a 5-minute cache keyed on `(user_id, sha256(token)[:16])` so a logout/login or token rotation auto-invalidates without callback wiring from the cloud-auth routes. Bundled branch is global with a 1-hour cache (sidecar's read-only filesystem only changes across image rebuilds). `cloud_status` (`ok` / `not_authenticated` / `expired` / `unreachable`) drives a precise modal banner instead of an unexplained empty list. **Slicing** (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`): request body now accepts source-aware `{source, id}` triplets per slot (cloud / local / standard) alongside the legacy `*_preset_id` fields for full backwards-compatibility — the schema validator normalises bare integer ids into `PresetRef(source='local', id=str(int))` so the dispatcher only deals with one shape. New `preset_resolver` service fetches the preset content per source: cloud via `BambuCloudService.get_setting_detail` (unwraps the `setting` envelope, falls back to top-level on minor shape variants), local from the DB (existing path), standard via a minimal `{inherits: , from: "system"}` stub that the sidecar's `bambuddy/profile-resolver` branch flattens against `BUNDLED_PROFILES_PATH//.json` — no preset-content round-trip needed for the standard tier. **Permissions**: the listing route gate matches the slice action itself (`LIBRARY_UPLOAD`) so any user who can slice can populate the dropdowns; the cloud branch has an independent `CLOUD_AUTH` check inside the fetch helper — a user holding `LIBRARY_UPLOAD` but not `CLOUD_AUTH` doesn't see the cloud tier (and can't slice with a cloud preset, returns 403) even if a leftover `User.cloud_token` survived a permission revocation. **SliceModal** (frontend): grouped `` per tier with localised section headers, default-selection follows the cloud > local > standard priority on first load, cloud-status banner with three variants (sign-in / expired / unreachable) only when the status isn't `ok`. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): new `GET /profiles/bundled` walks `BUNDLED_PROFILES_PATH/{machine,process,filament}` and returns instantiable presets only (`instantiation: "true"`), filtering out abstract bases like `fdm_filament_pla` so the dropdowns only offer things a user can actually pick. **Tests**: 17 unit tests for the listing endpoint helpers (dedup priority + per-slot scoping + order preservation, all four `cloud_status` states, `CLOUD_AUTH` defence-in-depth with token lookup short-circuit, per-user cache isolation, token-change cache invalidation, sidecar-unreachable fallback), 11 unit tests for the source-aware resolver (standard inherits-stub shape, local DB lookup with `preset_type` validation, cloud envelope unwrapping with both standard and top-level shapes, cloud auth-error → 401, cloud `CLOUD_AUTH` defence, slot dispatch routing), 6 schema tests for `SliceRequest` covering legacy bare-int normalisation and new source-aware refs and explicit-ref-wins-over-legacy precedence, 12 frontend tests for SliceModal covering tier-priority auto-selection, `` grouping, fallback when higher tiers are empty, source-aware payload on submit, manual override across tiers, archive-vs-library routing, error display, and all three banner variants. All 3391 backend + 1531 frontend tests pass. - **Server-side slicing via OrcaSlicer / Bambu Studio sidecar** — Bambuddy can now slice models without a desktop slicer installed. New optional `slicer-api/` Compose stack runs HTTP wrappers around the OrcaSlicer and/or Bambu Studio CLI; Bambuddy's File Manager and Archives pages get a **Slice** button that picks a printer / process / filament preset and dispatches a background slice job whose result lands as a new `.gcode.3mf` in the same library folder (or as a new archive when the source was an archive). Settings → Workflow gets a new **Slicer** card: pick the preferred slicer, toggle "Use Slicer API" on, and paste the sidecar URL — Slice buttons across File Manager, Archives, and MakerWorld then route through the API instead of the OS slicer URI scheme. Status updates come from a global `SliceJobTrackerProvider` that polls `/api/v1/slice-jobs/{id}` and surfaces a single toast per job (queued → running → completed / failed) plus auto-refreshes the file or archive list on success — slicing one file no longer pins the modal. Server side, a fresh in-memory dispatcher (`backend/app/services/slice_dispatch.py`) runs jobs as `asyncio.create_task`s with a 30-minute retention sweep, and the routes (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`) return 202 immediately with `{job_id, status, status_url}` instead of holding the request open through a multi-minute slice. The CLI bridge (`backend/app/services/slicer_api.py`) distinguishes 4xx (`SlicerInputError`), 5xx (`SlicerApiServerError`), and connection failures (`SlicerApiUnavailableError`) so 3MF inputs can transparently retry with embedded settings when the sidecar's `--load-settings` path segfaults on the input — empirically required for OrcaSlicer 2.3.x + H2D and signalled to the UI via `used_embedded_settings: true`. Sliced output is forced to `.gcode.3mf` so File Manager picks up the embedded thumbnail, the `print_name` is dropped from saved metadata so the displayed filename matches what the user picked, and `file_type="gcode"` paints the badge blue. The polling endpoint `GET /api/v1/slice-jobs/{id}` is gated on `LIBRARY_READ` since job IDs are sequential and the body leaks source filenames + resulting library/archive IDs. The sidecar itself builds from a fork of [AFKFelix/orca-slicer-api](https://github.com/AFKFelix/orca-slicer-api) (`maziggy/orca-slicer-api@bambuddy/profile-resolver`) which adds the `inherits:` chain resolver, `from: "User"` → `"system"` rewrite, `# ` clone-prefix strip, and sentinel-value strip empirically required to slice real OrcaSlicer GUI exports without segfaulting the CLI; the Compose file uses Docker's git-build-context so users don't clone it manually. Default ports are 3003 (orca) and 3001 (bambu-studio) — 3000/3002 are skipped because Bambuddy's virtual-printer feature owns them. 10 backend integration tests cover sync validation (404/400), happy-path enqueue, preset-error → failed job, sidecar unreachable, the 3MF embedded-settings fallback, STL no-fallback, and the strip-before-forward path; 5 new frontend tests for the SliceModal cover preset gating, library + archive enqueue paths, error display, and preset-load failure. New i18n keys under `slicer.*` and `settings.slicer.*` across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Slicer integration is opt-in: if "Use Slicer API" stays off, the existing "open in desktop slicer via URI" flow is the default and unchanged. diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 363f16131..fceaa15f1 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -27,7 +27,11 @@ from backend.app.models.user import User from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest from backend.app.schemas.slicer import SliceRequest from backend.app.services.archive import ArchiveService -from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf +from backend.app.utils.threemf_tools import ( + extract_nozzle_mapping_from_3mf, + extract_plate_extruder_set_from_3mf, + extract_project_filaments_from_3mf, +) logger = logging.getLogger(__name__) @@ -3109,6 +3113,47 @@ async def get_plate_thumbnail( raise HTTPException(404, f"Thumbnail for plate {plate_index} not found") +async def _try_preview_slice_filaments( + db: AsyncSession, + *, + kind: str, + source_id: int, + plate_id: int, + file_path: Path, +) -> list[dict] | None: + """Run a preview slice via the user's configured sidecar so the filament + list endpoint can return real per-plate filaments for unsliced project + files. Returns ``None`` on any failure — the caller falls back to the + painted-face heuristic.""" + from backend.app.api.routes.settings import get_setting + from backend.app.services.slice_preview import get_preview_filaments + + preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio" + if preferred == "orcaslicer": + configured = await get_setting(db, "orcaslicer_api_url") + api_url = (configured or settings.slicer_api_url).strip() + elif preferred == "bambu_studio": + configured = await get_setting(db, "bambu_studio_api_url") + api_url = (configured or settings.bambu_studio_api_url).strip() + else: + return None + if not api_url: + return None + + try: + file_bytes = file_path.read_bytes() + except OSError: + return None + return await get_preview_filaments( + kind=kind, + source_id=source_id, + plate_id=plate_id, + file_bytes=file_bytes, + file_name=file_path.name, + api_url=api_url, + ) + + @router.get("/{archive_id}/filament-requirements") async def get_filament_requirements( archive_id: int, @@ -3216,6 +3261,39 @@ async def get_filament_requirements( } ) + # Unsliced project files: slice_info has nothing usable. Try a + # preview-slice via the sidecar — the slicer's own logic + # determines which filaments the plate actually consumes + # (Bambu Studio prunes painted regions whose extruder isn't + # used, etc.) — and parse the resulting slice_info. Cached so + # the modal opens fast on the second visit. Falls back to the + # painted-face heuristic if the sidecar isn't configured or + # the preview slice errors out. + if not filaments and plate_id is not None: + preview = await _try_preview_slice_filaments( + db, + kind="archive", + source_id=archive_id, + plate_id=plate_id, + file_path=file_path, + ) + if preview is not None: + filaments = preview + + # Last-resort fallback for unsliced files when preview slicing + # also can't help (no sidecar, slicer errored, etc.). See + # library.py for the matching block. + if not filaments: + project_filaments = extract_project_filaments_from_3mf(zf) + if plate_id is not None and project_filaments: + used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id) + if used_slots: + filaments = [f for f in project_filaments if f["slot_id"] in used_slots] + else: + filaments = project_filaments + elif project_filaments: + filaments = project_filaments + # Sort by slot ID filaments.sort(key=lambda x: x["slot_id"]) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 080f2e4ae..5079fd9e3 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -61,7 +61,11 @@ from backend.app.schemas.library import ( from backend.app.schemas.slicer import SliceRequest, SliceResponse from backend.app.services.archive import ThreeMFParser from backend.app.services.stl_thumbnail import generate_stl_thumbnail -from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf +from backend.app.utils.threemf_tools import ( + extract_nozzle_mapping_from_3mf, + extract_plate_extruder_set_from_3mf, + extract_project_filaments_from_3mf, +) logger = logging.getLogger(__name__) @@ -2338,6 +2342,45 @@ async def get_library_file_plate_thumbnail( raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found") +async def _try_preview_slice_filaments( + db: AsyncSession, + *, + kind: str, + source_id: int, + plate_id: int, + file_path: Path, +) -> list[dict] | None: + """Run a preview slice via the user's configured sidecar. Same shape as + the matching helper in archives.py — see that module for rationale.""" + from backend.app.api.routes.settings import get_setting + from backend.app.services.slice_preview import get_preview_filaments + + preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio" + if preferred == "orcaslicer": + configured = await get_setting(db, "orcaslicer_api_url") + api_url = (configured or app_settings.slicer_api_url).strip() + elif preferred == "bambu_studio": + configured = await get_setting(db, "bambu_studio_api_url") + api_url = (configured or app_settings.bambu_studio_api_url).strip() + else: + return None + if not api_url: + return None + + try: + file_bytes = file_path.read_bytes() + except OSError: + return None + return await get_preview_filaments( + kind=kind, + source_id=source_id, + plate_id=plate_id, + file_bytes=file_bytes, + file_name=file_path.name, + api_url=api_url, + ) + + @router.get("/files/{file_id}/filament-requirements") async def get_library_file_filament_requirements( file_id: int, @@ -2451,6 +2494,38 @@ async def get_library_file_filament_requirements( } ) + # Unsliced project files: slice_info has nothing usable. Try a + # preview-slice via the sidecar — see archives.py for full + # rationale. Cached per (kind, id, plate, content_hash). + if not filaments and plate_id is not None: + preview = await _try_preview_slice_filaments( + db, + kind="library_file", + source_id=file_id, + plate_id=plate_id, + file_path=file_path, + ) + if preview is not None: + filaments = preview + + # Last-resort fallback when preview slicing also can't help (no + # sidecar configured, slicer errored, etc.). project_settings + # gives us the full AMS slot config; the plate-extruder filter + # narrows it to the slots the painted faces reference. + if not filaments: + project_filaments = extract_project_filaments_from_3mf(zf) + if plate_id is not None and project_filaments: + used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id) + if used_slots: + filaments = [f for f in project_filaments if f["slot_id"] in used_slots] + else: + # No extruder metadata anywhere — return the full + # project list rather than zero so the user still + # gets to pick (over-rendering > under-rendering). + filaments = project_filaments + elif project_filaments: + filaments = project_filaments + # Sort by slot ID filaments.sort(key=lambda x: x["slot_id"]) @@ -2567,11 +2642,17 @@ async def _run_slicer_with_fallback( refs = { "printer": request.printer_preset, "process": request.process_preset, - "filament": request.filament_preset, } for slot, ref in refs.items(): assert ref is not None, "schema validator guarantees PresetRef is set" presets[slot] = await resolve_preset_ref(db, user, ref, slot) + # Multi-color: resolve each filament slot in plate order. The schema + # validator backfilled `filament_presets` from the legacy `filament_preset` + # field for single-color callers, so this list is always non-empty. + filament_jsons: list[str] = [] + for ref in request.filament_presets: + assert ref is not None, "schema validator guarantees filament list is non-None" + filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament")) # Slicer routing — pick the sidecar URL by preferred_slicer. # The per-install URL setting (Settings UI → Slicer card) wins; an @@ -2607,7 +2688,7 @@ async def _run_slicer_with_fallback( model_filename=model_filename, printer_profile_json=presets["printer"], process_profile_json=presets["process"], - filament_profile_json=presets["filament"], + filament_profile_jsons=filament_jsons, plate=request.plate, export_3mf=request.export_3mf, ) diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index a674a0a7b..ba34437bc 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/backend/app/api/routes/slicer_presets.py @@ -12,6 +12,7 @@ without faking an "ok with empty list" response. from __future__ import annotations import hashlib +import json import logging import time @@ -105,41 +106,54 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic cloud = BambuCloudService(region=region) cloud.set_token(token) try: - raw = await cloud.get_slicer_settings() - except BambuCloudAuthError: - # Don't clear the token here — the cloud-status endpoint owns that - # lifecycle. Just report expired so the UI can prompt re-auth. - return _empty_slots(), "expired" - except BambuCloudError as e: - logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e) - return _empty_slots(), "unreachable" - except Exception as e: # noqa: BLE001 — defensive: never crash the modal - logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e) - return _empty_slots(), "unreachable" + try: + raw = await cloud.get_slicer_settings() + except BambuCloudAuthError: + # Don't clear the token here — the cloud-status endpoint owns that + # lifecycle. Just report expired so the UI can prompt re-auth. + return _empty_slots(), "expired" + except BambuCloudError as e: + logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e) + return _empty_slots(), "unreachable" + except Exception as e: # noqa: BLE001 — defensive: never crash the modal + logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e) + return _empty_slots(), "unreachable" + + slots = _empty_slots() + for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items(): + type_data = raw.get(cloud_type, {}) + # The cloud splits presets into "private" (the user's own) and "public" + # (Bambu's stock cloud presets). Both are valid choices — surface them + # in the natural order private → public so a user's customisations + # appear above the stock entries with the same names. Stock entries + # that share names with private ones get deduped out within the cloud + # tier itself. + seen_names: set[str] = set() + for entry in type_data.get("private", []) + type_data.get("public", []): + name = entry.get("name") + setting_id = entry.get("setting_id") or entry.get("id") + if not name or not setting_id or name in seen_names: + continue + seen_names.add(name) + slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud")) + + # Cloud filament presets carry no metadata in this response on + # purpose: the per-preset detail endpoint + # (/v1/iot-service/api/slicer/setting/{id}) is rate-limited at roughly + # 10/sec per token, so fetching N filament presets to enrich them + # one-by-one trips Bambu's limiter and returns 429 on every request + # for users with large preset libraries (#1150 follow-up). + # + # The dedup pass (see _dedupe_by_name) compensates: when a cloud entry + # wins over a same-named local entry, the cloud entry inherits the + # local entry's filament_type / filament_colour. So cloud presets that + # also exist locally still get metadata-aware pre-pick in the + # SliceModal; cloud-only presets fall back to plain priority order. + _cloud_cache[cache_key] = (now, slots) + return slots, "ok" finally: await cloud.close() - slots = _empty_slots() - for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items(): - type_data = raw.get(cloud_type, {}) - # The cloud splits presets into "private" (the user's own) and "public" - # (Bambu's stock cloud presets). Both are valid choices — surface them - # in the natural order private → public so a user's customisations - # appear above the stock entries with the same names. Stock entries - # that share names with private ones get deduped out within the cloud - # tier itself. - seen_names: set[str] = set() - for entry in type_data.get("private", []) + type_data.get("public", []): - name = entry.get("name") - setting_id = entry.get("setting_id") or entry.get("id") - if not name or not setting_id or name in seen_names: - continue - seen_names.add(name) - slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud")) - - _cloud_cache[cache_key] = (now, slots) - return slots, "ok" - async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]: """Local imports — no caching needed, single indexed DB read.""" @@ -151,10 +165,40 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset slot = type_to_slot.get(p.preset_type) if slot is None: continue - slots[slot].append(UnifiedPreset(id=str(p.id), name=p.name, source="local")) + extra: dict[str, str | None] = {} + if slot == "filament": + extra["filament_type"], extra["filament_colour"] = _parse_filament_metadata(p.setting) + slots[slot].append( + UnifiedPreset(id=str(p.id), name=p.name, source="local", **extra), + ) return slots +def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]: + """Extract first-slot ``filament_type`` and ``filament_colour`` from a + stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we + take the first entry since pre-pick matching is one-slot-at-a-time. + Defensive parse: any error returns (None, None) so a corrupt row never + breaks the listing.""" + if not setting_json: + return None, None + try: + data = json.loads(setting_json) + except (ValueError, TypeError): + return None, None + if not isinstance(data, dict): + return None, None + return _first_scalar(data.get("filament_type")), _first_scalar(data.get("filament_colour")) + + +def _first_scalar(value: object) -> str | None: + if isinstance(value, list) and value: + return value[0] if isinstance(value[0], str) else None + if isinstance(value, str) and value: + return value + return None + + async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]: """Standard slicer-bundled profiles via the sidecar's /profiles/bundled.""" global _bundled_cache @@ -186,7 +230,13 @@ async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPres continue # Bundled presets are addressed by name (the slicer resolves them # by name during the `inherits:` walk), so name doubles as id. - slots[slot].append(UnifiedPreset(id=name, name=name, source="standard")) + extra: dict[str, str | None] = {} + if slot == "filament": + extra["filament_type"] = entry.get("filament_type") + extra["filament_colour"] = entry.get("filament_colour") + slots[slot].append( + UnifiedPreset(id=name, name=name, source="standard", **extra), + ) _bundled_cache = (now, slots) return slots @@ -236,7 +286,36 @@ def _dedupe_by_name( Order within each tier is preserved as-is — only "lower-priority duplicates" are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud public AND standard bundled) only renders once, in the cloud tier. + + Filament metadata is **merged across tiers** during dedup: when a cloud + entry wins over a same-named local entry, the cloud entry inherits the + local entry's ``filament_type`` and ``filament_colour`` (cloud entries + carry no metadata themselves because we deliberately don't fetch each + setting's content — see _fetch_cloud_presets). Without this merge, the + SliceModal's metadata-aware pre-pick would silently lose match data for + every preset the user has both cloud-synced and locally imported, and + fall back to plain priority selection. """ + # Build a lookup: filament name → metadata from the highest-quality tier + # that has it. Local + standard both expose parsed metadata; cloud + # doesn't. Take whichever non-empty entry shows up first. + metadata_by_name: dict[str, tuple[str | None, str | None]] = {} + for tier in (local, standard): + for p in tier["filament"]: + if p.name in metadata_by_name: + continue + if p.filament_type or p.filament_colour: + metadata_by_name[p.name] = (p.filament_type, p.filament_colour) + + # Backfill cloud entries that don't have their own metadata. + for p in cloud["filament"]: + if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name: + t, c = metadata_by_name[p.name] + if p.filament_type is None and t is not None: + p.filament_type = t + if p.filament_colour is None and c is not None: + p.filament_colour = c + deduped_local = _empty_slots() deduped_standard = _empty_slots() for slot in ("printer", "process", "filament"): diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py index e51f86c3c..15450dd0d 100644 --- a/backend/app/schemas/slicer.py +++ b/backend/app/schemas/slicer.py @@ -53,6 +53,13 @@ class SliceRequest(BaseModel): process_preset: PresetRef | None = None filament_preset: PresetRef | None = None + # Multi-color: one PresetRef per AMS slot the source plate uses. Order is + # significant — the slicer matches index-by-index against the plate's + # filament slots. Always preferred over the legacy singular field; the + # validator promotes a singular field into ``[singular]`` when the list + # is empty so older clients keep working. + filament_presets: list[PresetRef] = Field(default_factory=list) + plate: int | None = Field( default=None, ge=1, @@ -67,11 +74,13 @@ class SliceRequest(BaseModel): def normalise_preset_refs(self) -> "SliceRequest": """Each slot must end up with a `PresetRef` set. Legacy integer ids become `(source='local', id=str(int))` so the route handler only - deals with the canonical shape.""" + deals with the canonical shape. For filament: a non-empty + ``filament_presets`` list satisfies the requirement on its own; an + empty list falls back to the singular fields, which then promote + into a one-element list.""" for slot, ref_attr, legacy_attr in ( ("printer", "printer_preset", "printer_preset_id"), ("process", "process_preset", "process_preset_id"), - ("filament", "filament_preset", "filament_preset_id"), ): ref = getattr(self, ref_attr) legacy_id = getattr(self, legacy_attr) @@ -81,6 +90,28 @@ class SliceRequest(BaseModel): ) if ref is None: setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id))) + + # Filament accepts THREE shapes, in priority order: + # 1. filament_presets — multi-color array (new clients) + # 2. filament_preset — source-aware singular (single-color new clients) + # 3. filament_preset_id — legacy bare integer (old clients) + # The first non-empty shape wins; missing all three raises. + if not self.filament_presets: + if self.filament_preset is not None: + self.filament_presets = [self.filament_preset] + elif self.filament_preset_id is not None: + fallback = PresetRef(source="local", id=str(self.filament_preset_id)) + self.filament_preset = fallback + self.filament_presets = [fallback] + else: + raise ValueError( + "filament preset is required: provide 'filament_presets' (preferred), " + "'filament_preset', or legacy 'filament_preset_id'" + ) + elif self.filament_preset is None: + # Multi-color caller: backfill the singular from the first slot + # so callers that still read the legacy field see a stable value. + self.filament_preset = self.filament_presets[0] return self diff --git a/backend/app/schemas/slicer_presets.py b/backend/app/schemas/slicer_presets.py index 3f1f6f59c..bcd47404a 100644 --- a/backend/app/schemas/slicer_presets.py +++ b/backend/app/schemas/slicer_presets.py @@ -25,11 +25,19 @@ class UnifiedPreset(BaseModel): The frontend treats ``id`` as opaque; the slice dispatch path uses ``(source, id)`` to fetch / pass the preset content to the sidecar. + + ``filament_type`` and ``filament_colour`` are populated for the filament + slot only — they let the SliceModal pre-pick a preset per plate slot in + the multi-color flow by matching against the source 3MF's per-slot type + and color. Populated when the underlying preset JSON exposes them; left + as ``None`` on bundled profiles where colour is a runtime spool attribute. """ id: str name: str source: Literal["cloud", "local", "standard"] + filament_type: str | None = None + filament_colour: str | None = None class UnifiedPresetsBySlot(BaseModel): diff --git a/backend/app/services/slice_preview.py b/backend/app/services/slice_preview.py new file mode 100644 index 000000000..80ce67cc7 --- /dev/null +++ b/backend/app/services/slice_preview.py @@ -0,0 +1,180 @@ +"""Preview-slice cache for the SliceModal. + +The slice modal needs the per-plate filament list before the user picks +profiles. For sliced files this lives in ``Metadata/slice_info.config`` and +the ``/filament-requirements`` endpoint can read it directly. For unsliced +project files it doesn't exist yet — only the slicer can produce it, since +Bambu Studio applies its own pruning to painted-face data at slice time. + +This module wraps the sidecar's ``slice_without_profiles`` call so the +endpoint can run a preview slice with the project's embedded settings, +parse the result's slice_info, and return the actual filament list. Results +are cached by ``(kind, source_id, plate_id, content_hash)`` so repeat +opens of the modal on the same plate are instant; LRU eviction keeps the +cache bounded. Hash invalidation handles in-place file replacement; no TTL +is used because preview-slice output is deterministic for a given file +content. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import zipfile +from collections import OrderedDict +from io import BytesIO + +import defusedxml.ElementTree as ET + +from backend.app.services.slicer_api import ( + SlicerApiError, + SlicerApiService, +) + +logger = logging.getLogger(__name__) + +_PREVIEW_CACHE_MAX = 256 +# Cache values: list[dict] on success, [] on parsed-but-empty (slicer +# returned a 3MF without filament data for this plate — caching the negative +# avoids burning 30s+ per modal open on a known-bad input). +_preview_cache: OrderedDict[tuple[str, int, int, str], list[dict]] = OrderedDict() +# Per-key locks prevent N concurrent modal opens on the same (file, plate) +# from launching N redundant preview slices — only the first one runs, the +# rest wait and read from the cache. Locks are evicted alongside cache +# entries to keep the dict bounded; we do NOT cache transient sidecar +# failures (network errors etc.) so those retry naturally on next request. +_preview_locks: dict[tuple[str, int, int, str], asyncio.Lock] = {} + + +def _content_hash(file_bytes: bytes) -> str: + return hashlib.sha256(file_bytes).hexdigest()[:16] + + +async def get_preview_filaments( + *, + kind: str, + source_id: int, + plate_id: int, + file_bytes: bytes, + file_name: str, + api_url: str, +) -> list[dict] | None: + """Run a preview slice for ``plate_id`` using the file's embedded settings, + parse the resulting slice_info, and return the per-plate filament list. + + Returns ``None`` when the preview slice fails — the caller should fall + back to whatever heuristic it has (typically the project_filaments + + painted-face approach in ``threemf_tools``). + """ + h = _content_hash(file_bytes) + key = (kind, source_id, plate_id, h) + cached = _preview_cache.get(key) + if cached is not None: + _preview_cache.move_to_end(key) + return cached + + lock = _preview_locks.setdefault(key, asyncio.Lock()) + async with lock: + # Re-check after acquiring the lock — another coroutine may have + # populated the cache while we were waiting on it. + cached = _preview_cache.get(key) + if cached is not None: + _preview_cache.move_to_end(key) + return cached + + try: + async with SlicerApiService(base_url=api_url) as svc: + result = await svc.slice_without_profiles( + model_bytes=file_bytes, + model_filename=file_name, + plate=plate_id, + export_3mf=True, + ) + except SlicerApiError as e: + logger.warning( + "Preview slice failed for %s/%s plate %s: %s", + kind, + source_id, + plate_id, + e, + ) + return None + except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues + logger.warning("Preview slice unexpected error: %s", e) + return None + + filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id) + # Negative-cache the parse failure: a slice that succeeds but yields + # no parsable filament data for this plate is a deterministic + # property of the input. Re-running the slice produces the same + # result, just N seconds slower. Empty list signals "preview was + # tried, no usable data" so the caller can fall through. + cache_value: list[dict] = filaments if filaments is not None else [] + _preview_cache[key] = cache_value + if len(_preview_cache) > _PREVIEW_CACHE_MAX: + evicted_key, _ = _preview_cache.popitem(last=False) + # Drop the matching lock so the dict doesn't grow forever. + # Safe to discard: the lock isn't held here, and any later + # request for the same key will mint a fresh lock. + _preview_locks.pop(evicted_key, None) + return filaments + + +def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None: + """Extract ```` entries for ``plate_id`` from a sliced 3MF's + Metadata/slice_info.config. Returns ``None`` on any parse error so the + caller knows to fall back.""" + try: + with zipfile.ZipFile(BytesIO(content)) as zf: + if "Metadata/slice_info.config" not in zf.namelist(): + return None + data = zf.read("Metadata/slice_info.config").decode() + except (zipfile.BadZipFile, OSError): + return None + + try: + root = ET.fromstring(data) + except ET.ParseError: + return None + + for plate_elem in root.findall(".//plate"): + idx = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "index": + try: + idx = int(meta.get("value", "")) + except (ValueError, TypeError): + pass + break + if idx != plate_id: + continue + out: list[dict] = [] + for f in plate_elem.findall("filament"): + fid = f.get("id") + if not fid: + continue + try: + slot_id = int(fid) + except (ValueError, TypeError): + continue + try: + used_grams = float(f.get("used_g", "0")) + except (ValueError, TypeError): + used_grams = 0 + try: + used_meters = float(f.get("used_m", "0")) + except (ValueError, TypeError): + used_meters = 0 + out.append( + { + "slot_id": slot_id, + "type": f.get("type", ""), + "color": f.get("color", ""), + "used_grams": round(used_grams, 1), + "used_meters": used_meters, + "tray_info_idx": f.get("tray_info_idx", ""), + }, + ) + return sorted(out, key=lambda x: x["slot_id"]) + return None diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index 7428ae024..080055c63 100644 --- a/backend/app/services/slicer_api.py +++ b/backend/app/services/slicer_api.py @@ -160,22 +160,39 @@ class SlicerApiService: model_filename: str, printer_profile_json: str, process_profile_json: str, - filament_profile_json: str, + filament_profile_jsons: list[str], plate: int | None = None, export_3mf: bool = False, ) -> SliceResult: - """POST /slice with model + printer/process/filament profile triplet. + """POST /slice with model + printer/process/filament profiles. + + ``filament_profile_jsons`` is plate-slot-ordered: index 0 is the + profile for slot 1, etc. Single-color callers pass a one-element + list. Multiple ``filamentProfile`` parts are sent as a repeated form + field — the sidecar's route declares ``maxCount: 16`` and the + slicing service joins them as semicolon-separated + ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI. Raises: SlicerInputError: 4xx from sidecar (caller-supplied input is bad). SlicerApiUnavailableError: connection error or 5xx from sidecar. """ - files = { - "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)), - "printerProfile": ("printer.json", printer_profile_json.encode("utf-8"), "application/json"), - "presetProfile": ("preset.json", process_profile_json.encode("utf-8"), "application/json"), - "filamentProfile": ("filament.json", filament_profile_json.encode("utf-8"), "application/json"), - } + # httpx supports repeated multipart fields when files is a list of + # tuples — using the dict form would silently overwrite duplicate + # keys and ship only the last filament profile. + files: list[tuple[str, tuple[str, bytes, str]]] = [ + ("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))), + ("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")), + ("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")), + ] + for idx, fjson in enumerate(filament_profile_jsons): + files.append( + ( + "filamentProfile", + (f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"), + ) + ) + data: dict[str, str] = {} if plate is not None: data["plate"] = str(plate) diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index 8853e9c21..bcaf4e240 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -607,3 +607,224 @@ def inject_gcode_into_3mf( if "tmp_path" in locals() and tmp_path.exists(): tmp_path.unlink(missing_ok=True) return None + + +def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]: + """Project-wide AMS slot config from ``Metadata/project_settings.config``. + + Returns one dict per configured AMS slot in slot order (1-indexed), with + ``type`` and ``color`` populated from the project's ``filament_type`` and + ``filament_colour`` arrays. ``used_grams`` / ``used_meters`` are 0 because + project_settings carries the configuration, not per-print usage — the + fields exist for shape compatibility with the slice_info-derived list. + + The SliceModal needs this on **unsliced** project files: slice_info.config + is empty until Bambu Studio has actually sliced the project, but the user + can still pick filament profiles for a slice we're about to perform. + """ + if "Metadata/project_settings.config" not in zf.namelist(): + return [] + try: + proj = json.loads(zf.read("Metadata/project_settings.config").decode()) + except (ValueError, OSError): + return [] + if not isinstance(proj, dict): + return [] + types_arr = proj.get("filament_type") or [] + colors_arr = proj.get("filament_colour") or [] + slot_count = max( + len(types_arr) if isinstance(types_arr, list) else 0, len(colors_arr) if isinstance(colors_arr, list) else 0 + ) + out: list[dict] = [] + for i in range(slot_count): + out.append( + { + "slot_id": i + 1, + "type": types_arr[i] if i < len(types_arr) and isinstance(types_arr[i], str) else "", + "color": colors_arr[i] if i < len(colors_arr) and isinstance(colors_arr[i], str) else "", + "used_grams": 0, + "used_meters": 0, + } + ) + return out + + +_PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"') + +# Painted-face quadtree leaves include both real filament assignments and +# tiny edit artifacts (single-leaf accidents from "tried a colour, undid, +# repainted with a different one"). The threshold's only job is dropping +# accidents — anything the user spent meaningful effort on must survive. +# 5% of an object's painted triangles is well below any 60/40 / 70/30 / +# 33/33/33 split a real two- or three-colour print would hit, so all +# intentional colours are kept; one-off single-leaf paints (typically +# 0.1-1.5% in observed projects) are filtered. Note that this fallback +# path runs ONLY when the preview-slice path can't reach the sidecar; in +# the normal flow the slicer's own pruning produces the canonical list and +# this threshold isn't reached. +_PAINT_NOISE_THRESHOLD = 0.05 + + +def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> set[int]: + """Extruder/AMS slot indices (1-indexed) used by objects on ``plate_id``. + + Three sources are unioned because Bambu Studio splits per-object extruder + info across THREE places depending on how the user assigned colours: + + 1. ``model_settings.config`` — top-level ```` + on each ```` (the "default extruder" for the whole object). + 2. ``model_settings.config`` — per-```` ```` + overrides (used when the user split an object into multiple parts + with distinct filaments). + 3. ``3D/Objects/object_*.model`` — ``paint_color`` attributes on + individual ```` elements (used when the user "painted" a + face with a different filament). The encoding is a hex string where + each nibble is a TriangleSelector tree node: ``0`` = unpainted leaf, + ``F`` = branch (4 children follow), ``1``..``E`` = leaf painted with + extruder N. We don't decode the tree — every leaf-paint nibble in + the string IS the extruder number, so a flat scan over hex chars + yields the correct set without recursive parsing. + + Without (3) the painted-face data is invisible: model_settings says + every object on a multi-color plate uses extruder 1 by default but the + actual print uses 3, 4, 12 etc. via face paint, so the SliceModal would + render only one filament dropdown for what's clearly a multi-colour + print (#1150 follow-up). + """ + if "Metadata/model_settings.config" not in zf.namelist(): + return set() + try: + root = ET.fromstring(zf.read("Metadata/model_settings.config").decode()) + except (ET.ParseError, OSError): + return set() + + # Pass 1: object → set of extruders from XML metadata (sources 1 + 2) + # plus the per-object .model file path so we can later scan source 3. + object_extruders: dict[str, set[int]] = {} + object_model_paths: dict[str, list[str]] = {} + for obj_elem in root.findall(".//object"): + obj_id = obj_elem.get("id") + if not obj_id: + continue + extruders: set[int] = set() + top = obj_elem.find("metadata[@key='extruder']") + if top is not None: + try: + v = int(top.get("value", "0")) + if v > 0: + extruders.add(v) + except (ValueError, TypeError): + pass + for part_elem in obj_elem.findall(".//part"): + part_ext = part_elem.find("metadata[@key='extruder']") + if part_ext is None: + continue + try: + v = int(part_ext.get("value", "0")) + if v > 0: + extruders.add(v) + except (ValueError, TypeError): + pass + object_extruders[obj_id] = extruders + + # Pass 2: 3dmodel.model maps each to its component + # .model file path(s). Bambu wraps object IDs that match + # model_settings.config IDs around . + # Strip xmlns prefixes on attributes so ElementTree can find them + # without namespace gymnastics — `p:path` becomes `path` etc. + if "3D/3dmodel.model" in zf.namelist(): + try: + raw = zf.read("3D/3dmodel.model").decode() + stripped = re.sub(r'xmlns:?\w*="[^"]*"', "", raw) + stripped = re.sub(r"<(/?)\w+:", r"<\1", stripped) + stripped = re.sub(r" \w+:(\w+=)", r" \1", stripped) + model_root = ET.fromstring(stripped) + for obj_elem in model_root.findall(".//object"): + oid = obj_elem.get("id") + if not oid: + continue + comps = obj_elem.find("components") + if comps is None: + continue + paths = [] + for c in comps.findall("component"): + p = c.get("path") + if p: + paths.append(p.lstrip("/")) + if paths: + object_model_paths[oid] = paths + except (ET.ParseError, OSError): + pass # No 3dmodel — paint scan just won't apply + + # Pass 3: scan paint_color attrs in each per-object .model file. Cache + # by file path because two objects often share the same component tree. + paint_cache: dict[str, set[int]] = {} + + def _scan_paint(path: str) -> set[int]: + if path in paint_cache: + return paint_cache[path] + out: set[int] = set() + if path not in zf.namelist(): + paint_cache[path] = out + return out + try: + data = zf.read(path) + except OSError: + paint_cache[path] = out + return out + # Per-extruder triangle coverage. Each painted triangle may have + # multiple leaf nibbles (the quadtree subdivides the face into + # painted regions); we count one triangle per unique extruder per + # match so the resulting fraction is "what share of painted + # triangles include at least one leaf with extruder N". Noise from + # one-off edit artifacts is filtered out at the threshold below. + extruder_triangles: dict[int, int] = {} + total_painted = 0 + for match in _PAINT_COLOR_ATTR_RE.finditer(data): + total_painted += 1 + seen: set[int] = set() + for ch in match.group(1): + # Hex digit → 4-bit value. 0 = unpainted leaf, F = branch + # (decoded recursively but children are encoded inline, so + # we'll see them on later iterations). 1-E = leaf painted + # with extruder N. + if ch in b"123456789": + seen.add(ch - 0x30) + elif ch in b"ABCDEabcde": + seen.add((ch & 0x4F) - 0x37) + for e in seen: + extruder_triangles[e] = extruder_triangles.get(e, 0) + 1 + if total_painted > 0: + cutoff = max(1, int(total_painted * _PAINT_NOISE_THRESHOLD)) + for ext, count in extruder_triangles.items(): + if count >= cutoff: + out.add(ext) + paint_cache[path] = out + return out + + # Walk plates — collect extruders for objects on the requested plate. + used: set[int] = set() + for plate_elem in root.findall(".//plate"): + plater_id = None + for meta in plate_elem.findall("metadata"): + if meta.get("key") == "plater_id": + try: + plater_id = int(meta.get("value", "")) + except (ValueError, TypeError): + pass + break + if plater_id != plate_id: + continue + for inst in plate_elem.findall("model_instance"): + for inst_meta in inst.findall("metadata"): + if inst_meta.get("key") != "object_id": + continue + obj_id = inst_meta.get("value") + if not obj_id: + continue + used.update(object_extruders.get(obj_id, set())) + for path in object_model_paths.get(obj_id, []): + used.update(_scan_paint(path)) + break + return used diff --git a/backend/tests/unit/services/test_slice_preview.py b/backend/tests/unit/services/test_slice_preview.py new file mode 100644 index 000000000..6988d5afc --- /dev/null +++ b/backend/tests/unit/services/test_slice_preview.py @@ -0,0 +1,256 @@ +"""Unit tests for the preview-slice cache. + +The preview-slice runs the sidecar's `slice_without_profiles` on an unsliced +project file to extract the per-plate filament list. Results are cached by +``(kind, source_id, plate_id, content_hash)`` with LRU eviction so repeat +modal opens on the same plate are instant. +""" + +from __future__ import annotations + +import asyncio +import io +import zipfile +from typing import Any +from unittest.mock import patch + +import pytest + +from backend.app.services import slice_preview +from backend.app.services.slice_preview import ( + _PREVIEW_CACHE_MAX, + _parse_filaments_from_sliced_3mf, + get_preview_filaments, +) +from backend.app.services.slicer_api import ( + SlicerApiUnavailableError, + SliceResult, +) + + +def _make_sliced_3mf(plate_id: int, filaments: list[dict[str, str]]) -> bytes: + """Build a fake sliced-3MF zip whose Metadata/slice_info.config has one + plate matching ``plate_id`` with the given filament rows.""" + fil_xml = "".join( + f'' + for f in filaments + ) + slice_info = ( + f'{fil_xml}' + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("Metadata/slice_info.config", slice_info) + return buf.getvalue() + + +@pytest.fixture(autouse=True) +def _reset_cache(): + """Each test gets an empty cache + lock dict to keep them independent.""" + slice_preview._preview_cache.clear() + slice_preview._preview_locks.clear() + yield + slice_preview._preview_cache.clear() + slice_preview._preview_locks.clear() + + +class _StubService: + """Mimics SlicerApiService just enough for these tests. Records every + `slice_without_profiles` call so we can assert call counts.""" + + def __init__(self, response_bytes: bytes | None = None, raise_exc: BaseException | None = None) -> None: + self.response_bytes = response_bytes + self.raise_exc = raise_exc + self.calls: list[dict[str, Any]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def slice_without_profiles(self, **kw): + self.calls.append(kw) + if self.raise_exc is not None: + raise self.raise_exc + return SliceResult( + content=self.response_bytes or b"", + print_time_seconds=0, + filament_used_g=0.0, + filament_used_mm=0.0, + ) + + +# --------------------------------------------------------------------------- +# _parse_filaments_from_sliced_3mf — pure-function parsing tests. +# --------------------------------------------------------------------------- + + +class TestParseFilamentsFromSliced3mf: + def test_happy_path(self): + body = _make_sliced_3mf( + plate_id=22, + filaments=[ + {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "33.9"}, + {"id": "6", "type": "PLA", "color": "#FF0000", "used_g": "37.7"}, + ], + ) + result = _parse_filaments_from_sliced_3mf(body, 22) + assert result is not None + assert [(f["slot_id"], f["color"]) for f in result] == [(1, "#FFFFFF"), (6, "#FF0000")] + assert result[0]["used_grams"] == 33.9 + + def test_missing_slice_info_returns_none(self): + empty_zip = io.BytesIO() + with zipfile.ZipFile(empty_zip, "w") as zf: + zf.writestr("placeholder.txt", "x") + assert _parse_filaments_from_sliced_3mf(empty_zip.getvalue(), 1) is None + + def test_plate_not_in_slice_info_returns_none(self): + body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]) + assert _parse_filaments_from_sliced_3mf(body, plate_id=99) is None + + def test_corrupt_zip_returns_none(self): + assert _parse_filaments_from_sliced_3mf(b"not a zip file", 1) is None + + +# --------------------------------------------------------------------------- +# get_preview_filaments — cache + concurrency behaviour. +# --------------------------------------------------------------------------- + + +class TestGetPreviewFilaments: + @pytest.mark.asyncio + async def test_happy_path_caches_result(self): + body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]) + stub = _StubService(response_bytes=body) + with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub): + first = await get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"abc", + file_name="x.3mf", + api_url="http://sidecar", + ) + second = await get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"abc", + file_name="x.3mf", + api_url="http://sidecar", + ) + assert first is not None + assert first[0]["slot_id"] == 1 + assert second == first + # Cache hit — only one slice was actually run. + assert len(stub.calls) == 1 + + @pytest.mark.asyncio + async def test_different_content_hash_misses_cache(self): + body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]) + stub = _StubService(response_bytes=body) + with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub): + await get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"v1", + file_name="x.3mf", + api_url="http://sidecar", + ) + await get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"v2", # Same archive, but content changed + file_name="x.3mf", + api_url="http://sidecar", + ) + # Hash differs → cache miss → fresh slice. + assert len(stub.calls) == 2 + + @pytest.mark.asyncio + async def test_sidecar_unavailable_returns_none_no_cache(self): + # Transient sidecar failure must NOT poison the cache — the next + # request retries cleanly. + stub = _StubService(raise_exc=SlicerApiUnavailableError("boom")) + with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub): + first = await get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"abc", + file_name="x.3mf", + api_url="http://sidecar", + ) + assert first is None + # Second call hits the sidecar again (no cached failure). + await get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"abc", + file_name="x.3mf", + api_url="http://sidecar", + ) + assert len(stub.calls) == 2 + + @pytest.mark.asyncio + async def test_concurrent_calls_share_one_slice(self): + body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]) + + # Slow stub so we can observe N coroutines piling up on the lock. + class _SlowStub(_StubService): + async def slice_without_profiles(self, **kw): + self.calls.append(kw) + await asyncio.sleep(0.05) + return SliceResult( + content=self.response_bytes or b"", + print_time_seconds=0, + filament_used_g=0.0, + filament_used_mm=0.0, + ) + + stub = _SlowStub(response_bytes=body) + with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub): + results = await asyncio.gather( + *( + get_preview_filaments( + kind="archive", + source_id=1, + plate_id=1, + file_bytes=b"abc", + file_name="x.3mf", + api_url="http://sidecar", + ) + for _ in range(8) + ), + ) + # All 8 callers got the same result, but only ONE slice ran. + assert all(r == results[0] for r in results) + assert len(stub.calls) == 1 + + @pytest.mark.asyncio + async def test_lru_eviction_drops_lock(self): + # Fill cache past the bound; oldest should evict, including its lock. + body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]) + stub = _StubService(response_bytes=body) + with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub): + # Each call has a unique source_id → unique cache key. + for i in range(_PREVIEW_CACHE_MAX + 5): + await get_preview_filaments( + kind="archive", + source_id=i, + plate_id=1, + file_bytes=b"abc", + file_name="x.3mf", + api_url="http://sidecar", + ) + # Cache is bounded — older entries fell off. + assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX + # Lock dict is also pruned (no leak): same size as cache. + assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX diff --git a/backend/tests/unit/services/test_slicer_api.py b/backend/tests/unit/services/test_slicer_api.py index 2c50443eb..0564253bf 100644 --- a/backend/tests/unit/services/test_slicer_api.py +++ b/backend/tests/unit/services/test_slicer_api.py @@ -74,7 +74,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json='{"name": "p"}', process_profile_json='{"name": "pr"}', - filament_profile_json='{"name": "f"}', + filament_profile_jsons=['{"name": "f"}'], ) assert isinstance(result, SliceResult) @@ -103,7 +103,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) assert "Invalid file type" in str(exc_info.value) @@ -125,7 +125,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) assert "Failed to slice the model" in str(exc_info.value) @@ -153,7 +153,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) msg = str(exc_info.value) assert "Failed to slice the model" in msg @@ -178,7 +178,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) assert "SIGSEGV" in str(exc_info.value) @@ -198,7 +198,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) assert "Bad Gateway" in str(exc_info.value) @@ -214,7 +214,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) assert "unreachable" in str(exc_info.value).lower() @@ -236,7 +236,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], plate=2, export_3mf=True, ) @@ -249,6 +249,41 @@ class TestSliceWithProfiles: assert b'name="exportType"' in body assert b"3mf" in body + @pytest.mark.asyncio + async def test_multi_filament_sends_one_part_per_profile(self): + # Multi-color slicing requires N filament profiles, in plate-slot + # order, sent as N repeated multipart `filamentProfile` parts (NOT a + # single concatenated value). The CLI joins their resulting paths + # with `;` for --load-filaments. A future regression to a dict-shaped + # `files=` would silently keep prior tests green but ship only the + # last filament — pin the wire shape. + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = request.content + return httpx.Response( + status_code=200, + content=b"3MF-BYTES", + headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"}, + ) + + service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler)) + await service.slice_with_profiles( + model_bytes=b"x", + model_filename="Cube.3mf", + printer_profile_json="{}", + process_profile_json="{}", + filament_profile_jsons=['{"a":1}', '{"b":2}', '{"c":3}'], + ) + + body = captured["body"] + # Three repeated `filamentProfile` parts, in submission order. + assert body.count(b'name="filamentProfile"') == 3 + assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body + # Parts present in plate order — the 'a' bytes appear before 'b' + # which appear before 'c'. (httpx preserves the list order.) + assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}') + @pytest.mark.asyncio async def test_missing_metadata_headers_default_to_zero(self): # The /slice endpoint always sets these on success, but be defensive @@ -262,7 +297,7 @@ class TestSliceWithProfiles: model_filename="Cube.stl", printer_profile_json="{}", process_profile_json="{}", - filament_profile_json="{}", + filament_profile_jsons=["{}"], ) assert result.print_time_seconds == 0 assert result.filament_used_g == 0.0 diff --git a/backend/tests/unit/test_slice_request_schema.py b/backend/tests/unit/test_slice_request_schema.py index ac2c5b180..918eab970 100644 --- a/backend/tests/unit/test_slice_request_schema.py +++ b/backend/tests/unit/test_slice_request_schema.py @@ -84,3 +84,61 @@ class TestPriorityWhenBothSet: # Validator only fills the ref when it's None — the explicit cloud # ref stays untouched. assert req.printer_preset == PresetRef(source="cloud", id="PFU") + + +class TestFilamentPresetsList: + """Multi-color: the new array shape carries one filament profile per + plate slot in plate order. Backwards-compat: legacy clients still + submit a singular `filament_preset` and the validator promotes it into + a one-element list so the route handler only deals with one shape.""" + + def test_explicit_list_passes_through(self): + refs = [ + PresetRef(source="cloud", id="A"), + PresetRef(source="local", id="2"), + PresetRef(source="standard", id="Bambu PLA Basic"), + ] + req = SliceRequest( + printer_preset_id=1, + process_preset_id=2, + filament_preset_id=99, # explicit legacy id — should be ignored + filament_presets=refs, + ) + assert req.filament_presets == refs + # Precedence pin: when caller sends both shapes, the array wins and + # the singular gets backfilled from the array's first entry — NOT + # from the legacy id 99. Documents the migration ordering for a + # future change that might quietly mix them. + assert req.filament_preset == refs[0] + + def test_empty_list_is_backfilled_from_singular(self): + req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3) + # Legacy single-color path: validator promotes the singular into a + # one-element list so route handlers can iterate uniformly. + assert req.filament_presets == [PresetRef(source="local", id="3")] + + def test_explicit_empty_list_with_singular_set_uses_singular(self): + # User of the new schema can leave `filament_presets` as the empty + # default and rely on the legacy `filament_preset_id` — same path + # as `test_empty_list_is_backfilled_from_singular`. + req = SliceRequest( + printer_preset_id=1, + process_preset_id=2, + filament_preset=PresetRef(source="cloud", id="PFU"), + filament_presets=[], + ) + assert req.filament_presets == [PresetRef(source="cloud", id="PFU")] + + def test_list_preserves_order(self): + refs = [ + PresetRef(source="cloud", id="slot1"), + PresetRef(source="cloud", id="slot2"), + PresetRef(source="cloud", id="slot3"), + ] + req = SliceRequest( + printer_preset_id=1, + process_preset_id=2, + filament_preset_id=3, + filament_presets=refs, + ) + assert [r.id for r in req.filament_presets] == ["slot1", "slot2", "slot3"] diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index bacbed307..7ec4688d8 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/backend/tests/unit/test_threemf_tools.py @@ -5,11 +5,14 @@ and cumulative layer usage lookup. """ import io +import json import math import zipfile from backend.app.utils.threemf_tools import ( extract_filament_usage_from_3mf, + extract_plate_extruder_set_from_3mf, + extract_project_filaments_from_3mf, get_cumulative_usage_at_layer, mm_to_grams, parse_gcode_layer_filament_usage, @@ -408,3 +411,237 @@ class TestExtractFilamentUsageFrom3mf: assert len(result) == 1 assert result[0]["type"] == "" assert result[0]["color"] == "" + + +# --------------------------------------------------------------------------- +# Tests for extract_project_filaments_from_3mf — used by the slice modal as +# fallback when the sidecar can't run a preview slice. +# --------------------------------------------------------------------------- + + +def _make_3mf_with(files: dict[str, bytes | str]) -> zipfile.ZipFile: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content if isinstance(content, (bytes, str)) else str(content)) + buf.seek(0) + return zipfile.ZipFile(buf, "r") + + +class TestExtractProjectFilamentsFrom3mf: + """The helper backfills the slice modal when slice_info.config is empty + (raw project files) and the sidecar is unreachable.""" + + def test_returns_empty_when_project_settings_missing(self): + with _make_3mf_with({"placeholder.txt": "hi"}) as zf: + assert extract_project_filaments_from_3mf(zf) == [] + + def test_happy_path_returns_one_entry_per_slot(self): + proj = { + "filament_type": ["PLA", "PETG"], + "filament_colour": ["#000000", "#FFFFFF"], + } + with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf: + out = extract_project_filaments_from_3mf(zf) + assert [(f["slot_id"], f["type"], f["color"]) for f in out] == [ + (1, "PLA", "#000000"), + (2, "PETG", "#FFFFFF"), + ] + + def test_mismatched_array_lengths_use_max_with_blanks(self): + proj = { + "filament_type": ["PLA", "PETG", "ABS"], + "filament_colour": ["#000000"], + } + with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf: + out = extract_project_filaments_from_3mf(zf) + assert len(out) == 3 + assert out[0]["color"] == "#000000" + assert out[1]["color"] == "" + assert out[2]["color"] == "" + + def test_corrupt_json_returns_empty_no_exception(self): + with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf: + assert extract_project_filaments_from_3mf(zf) == [] + + def test_root_is_list_returns_empty(self): + # Defensive: spec says it's a dict, but a file shipping a top-level + # list (or anything non-dict) shouldn't crash the modal. + with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf: + assert extract_project_filaments_from_3mf(zf) == [] + + def test_empty_arrays_returns_empty(self): + proj = {"filament_type": [], "filament_colour": []} + with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf: + assert extract_project_filaments_from_3mf(zf) == [] + + +# --------------------------------------------------------------------------- +# Tests for extract_plate_extruder_set_from_3mf — three sources unioned: +# object top-level extruder, per-part extruder, painted-face quadtree leaves. +# --------------------------------------------------------------------------- + + +def _model_settings(plate_id: int, objects: list[dict]) -> str: + """Build a minimal model_settings.config XML for tests. Each object dict + can have: id, extruder (top-level), parts (list of {extruder}). + The plate references all object ids.""" + parts_xml = [] + for obj in objects: + oid = obj["id"] + ext = obj.get("extruder") + parts = obj.get("parts", []) + ext_meta = f'' if ext is not None else "" + part_blocks = "".join( + f'' + for i, p in enumerate(parts) + if p.get("extruder") is not None + ) + parts_xml.append(f'{ext_meta}{part_blocks}') + instances = "".join( + f'' for o in objects + ) + plate = f'{instances}' + return f'{"".join(parts_xml)}{plate}' + + +class TestExtractPlateExtruderSetFrom3mf: + def test_returns_empty_set_when_model_settings_missing(self): + with _make_3mf_with({"placeholder.txt": "hi"}) as zf: + assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set() + + def test_object_top_level_extruder_only(self): + xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}]) + with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf: + assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2} + + def test_per_part_extruder_unions_with_top_level(self): + # Object's default is 1; one of its parts overrides to 3 (multi-color + # via a sub-mesh). Union both — the slicer needs profiles for both. + xml = _model_settings( + plate_id=1, + objects=[{"id": "10", "extruder": 1, "parts": [{"extruder": 3}]}], + ) + with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf: + assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 3} + + def test_unknown_plate_id_returns_empty_set(self): + xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}]) + with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf: + assert extract_plate_extruder_set_from_3mf(zf, plate_id=99) == set() + + def test_corrupt_xml_returns_empty_set_no_exception(self): + with _make_3mf_with({"Metadata/model_settings.config": "') + for _ in range(40): + triangles.append('') + per_obj = ( + '' + '' + "" + "".join(triangles) + "" + "" + ) + ms = ( + '' + '' + '' + '' + "" + ) + threed = ( + '' + '' + "" + '' + '' + "" + "" + ) + with _make_3mf_with( + { + "Metadata/model_settings.config": ms, + "3D/3dmodel.model": threed, + "3D/Objects/o100.model": per_obj, + } + ) as zf: + result = extract_plate_extruder_set_from_3mf(zf, plate_id=1) + # Both real colours kept (60/40 well above 5% threshold); the dropped + # threshold case is the regression that motivates this test. + assert result == {1, 2} + + def test_painted_face_below_threshold_dropped_as_noise(self): + # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5% + # threshold). The 1% leaf is a single-leaf accident. + triangles = [] + for _ in range(99): + triangles.append('') + triangles.append('') + per_obj = ( + '' + '' + "" + "".join(triangles) + "" + "" + ) + ms = ( + '' + '' + '' + '' + "" + ) + threed = ( + '' + '' + '' + '' + "" + ) + with _make_3mf_with( + { + "Metadata/model_settings.config": ms, + "3D/3dmodel.model": threed, + "3D/Objects/o100.model": per_obj, + } + ) as zf: + result = extract_plate_extruder_set_from_3mf(zf, plate_id=1) + # Single-leaf accident at 1% filtered as noise; only the dominant + # extruder survives. + assert result == {1} + + def test_missing_per_object_model_file_silently_skipped(self): + ms = ( + '' + '' + '' + '' + "" + ) + threed = ( + '' + '' + '' + '' + "" + ) + with _make_3mf_with( + {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed}, + ) as zf: + # Top-level metadata still works; missing component model file + # is silently skipped without crashing. + assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2} diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 3831e18ea..479e73184 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -22,6 +22,10 @@ vi.mock('../../api/client', () => ({ sliceLibraryFile: vi.fn(), sliceArchive: vi.fn(), getSliceJob: vi.fn(), + getLibraryFilePlates: vi.fn(), + getArchivePlates: vi.fn(), + getLibraryFileFilamentRequirements: vi.fn(), + getArchiveFilamentRequirements: vi.fn(), getSettings: vi.fn().mockResolvedValue({}), updateSettings: vi.fn().mockResolvedValue({}), }, @@ -32,6 +36,10 @@ const mockApi = api as unknown as { sliceLibraryFile: ReturnType; sliceArchive: ReturnType; getSliceJob: ReturnType; + getLibraryFilePlates: ReturnType; + getArchivePlates: ReturnType; + getLibraryFileFilamentRequirements: ReturnType; + getArchiveFilamentRequirements: ReturnType; }; function makeUnified(overrides: Partial = {}): UnifiedPresetsResponse { @@ -84,6 +92,33 @@ describe('SliceModal', () => { started_at: null, completed_at: null, }); + // Default: single-plate (or non-3MF). Multi-plate tests override this. + mockApi.getLibraryFilePlates.mockResolvedValue({ + file_id: 100, + filename: 'Cube.stl', + plates: [], + is_multi_plate: false, + }); + mockApi.getArchivePlates.mockResolvedValue({ + archive_id: 100, + filename: 'Cube.3mf', + plates: [], + is_multi_plate: false, + }); + // Default: no per-plate filament metadata available (mirrors STL or + // unsliced source). Multi-color tests override this. + mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({ + file_id: 100, + filename: 'Cube.stl', + plate_id: 1, + filaments: [], + }); + mockApi.getArchiveFilamentRequirements.mockResolvedValue({ + archive_id: 100, + filename: 'Cube.3mf', + plate_id: 1, + filaments: [], + }); }); it('auto-selects the highest-priority tier per slot on first load', async () => { @@ -92,44 +127,45 @@ describe('SliceModal', () => { onClose: vi.fn(), }); - // The cloud tier wins — printer dropdown should land on the cloud entry. + // SliceModal-specific tier priority: imported (local) wins over cloud + // and standard so the user's curated picks come first. await waitFor(() => { expect(screen.getByText('My Custom X1C')).toBeDefined(); }); const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; expect(selects).toHaveLength(3); - expect(selects[0].value).toBe('cloud:PFUcloud-printer'); - expect(selects[1].value).toBe('cloud:PFUcloud-process'); - expect(selects[2].value).toBe('cloud:PFUcloud-filament'); + expect(selects[0].value).toBe('local:1'); + expect(selects[1].value).toBe('local:2'); + expect(selects[2].value).toBe('local:3'); // Slice button is enabled because all three slots auto-defaulted. const sliceBtn = screen.getByRole('button', { name: /^Slice$/ }); expect((sliceBtn as HTMLButtonElement).disabled).toBe(false); }); - it('renders Cloud / Imported / Standard sections via ', async () => { + it('renders Imported / Cloud / Standard sections via ', async () => { renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn(), }); - await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined()); const printerSelect = screen.getAllByRole('combobox')[0]; const groups = printerSelect.querySelectorAll('optgroup'); expect(Array.from(groups).map((g) => g.label)).toEqual([ - 'Cloud', 'Imported', + 'Cloud', 'Standard', ]); - // The cloud entry sits inside the Cloud group, the local entry inside - // Imported, the standard entry inside Standard — pin the assignment so - // a future render-shape change can't quietly mix them. - const cloudGroup = groups[0]; - expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined(); - const localGroup = groups[1]; + // Each entry sits inside its own tier's group — pin the assignment so + // a future render-shape change can't quietly mix them. Order matches + // SLICE_MODAL_TIER_ORDER (local → cloud → standard). + const localGroup = groups[0]; expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined(); + const cloudGroup = groups[1]; + expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined(); const standardGroup = groups[2]; expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(); }); @@ -184,10 +220,14 @@ describe('SliceModal', () => { await user.click(screen.getByRole('button', { name: /^Slice$/ })); await waitFor(() => { + // SliceModal-specific tier priority puts imported (local) above cloud, + // so the auto-pick lands on the local entries even when a cloud entry + // with the same slot is also available in the listing. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, { - printer_preset: { source: 'cloud', id: 'PFUcloud-printer' }, - process_preset: { source: 'cloud', id: 'PFUcloud-process' }, - filament_preset: { source: 'cloud', id: 'PFUcloud-filament' }, + printer_preset: { source: 'local', id: '1' }, + process_preset: { source: 'local', id: '2' }, + filament_preset: { source: 'local', id: '3' }, + filament_presets: [{ source: 'local', id: '3' }], }); }); await waitFor(() => expect(onClose).toHaveBeenCalled()); @@ -324,4 +364,331 @@ describe('SliceModal', () => { // No status-role banner should be rendered on the happy path. expect(screen.queryByRole('status')).toBeNull(); }); + + // ----- Multi-plate flow ----------------------------------------------- + + function makeMultiPlateLibraryResponse() { + return { + file_id: 100, + filename: 'Multi.3mf', + is_multi_plate: true, + plates: [ + { + index: 1, + name: 'Plate 1', + objects: ['Cube'], + object_count: 1, + has_thumbnail: false, + thumbnail_url: null, + print_time_seconds: 600, + filament_used_grams: 10, + filaments: [], + }, + { + index: 2, + name: 'Plate 2', + objects: ['Pyramid'], + object_count: 1, + has_thumbnail: false, + thumbnail_url: null, + print_time_seconds: 800, + filament_used_grams: 12, + filaments: [], + }, + ], + }; + } + + it('shows the plate picker first for multi-plate library files', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse()); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' }, + onClose: vi.fn(), + }); + + // Plate picker renders one button per plate — the accessible name + // joins the heading ("Plate N — name") with the object summary line. + await screen.findByRole('button', { name: /Plate 1.*Cube/ }); + expect(screen.getByRole('button', { name: /Plate 2.*Pyramid/ })).toBeDefined(); + // Profile dropdowns must NOT be visible yet — the user has to pick a + // plate first. + expect(screen.queryByRole('combobox')).toBeNull(); + }); + + it('skips the plate picker for single-plate sources', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue({ + file_id: 100, + filename: 'Single.3mf', + is_multi_plate: false, + plates: [ + { + index: 1, + name: 'Plate 1', + objects: [], + has_thumbnail: false, + thumbnail_url: null, + print_time_seconds: null, + filament_used_grams: null, + filaments: [], + }, + ], + }); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' }, + onClose: vi.fn(), + }); + + // Should jump straight to the profile dropdowns. + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + }); + + it('passes the picked plate to the slice request', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse()); + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' }, + onClose: vi.fn(), + }); + + const user = userEvent.setup(); + // Step 1: pick Plate 2. + const plate2Button = await screen.findByRole('button', { name: /Plate 2.*Pyramid/ }); + await user.click(plate2Button); + + // Step 2: profile dropdowns are now visible. + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + + // Step 3: submit and verify the plate index made it into the body. + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + await waitFor(() => { + expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith( + 100, + expect.objectContaining({ plate: 2 }), + ); + }); + }); + + it('routes the plate fetch through getArchivePlates for archive sources', async () => { + mockApi.getArchivePlates.mockResolvedValue({ + ...makeMultiPlateLibraryResponse(), + archive_id: 100, + filename: 'Multi.3mf', + }); + renderWithTracker({ + source: { kind: 'archive', id: 100, filename: 'Multi.3mf' }, + onClose: vi.fn(), + }); + + await screen.findByRole('button', { name: /Plate 1.*Cube/ }); + expect(mockApi.getArchivePlates).toHaveBeenCalledWith(100); + expect(mockApi.getLibraryFilePlates).not.toHaveBeenCalled(); + }); + + it('cancelling the plate picker closes the entire slice flow', async () => { + const onClose = vi.fn(); + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse()); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' }, + onClose, + }); + + await screen.findByRole('button', { name: /Plate 1.*Cube/ }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /^Close$/i })); + + expect(onClose).toHaveBeenCalled(); + }); + + it('omits the plate field when the source is single-plate', async () => { + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => { + const [, body] = mockApi.sliceLibraryFile.mock.calls[0]; + expect(body).not.toHaveProperty('plate'); + }); + }); + + // ----- Multi-color flow ------------------------------------------------ + + function makeMultiColorPlateResponse() { + // Single-plate 3MF that uses two filament slots — mirrors the realistic + // "I have a multi-color file with one plate" case. Multi-plate is a + // separate axis that's already covered above. + return { + file_id: 100, + filename: 'TwoColor.3mf', + is_multi_plate: false, + plates: [ + { + index: 1, + name: 'Plate 1', + objects: ['Logo'], + object_count: 1, + has_thumbnail: false, + thumbnail_url: null, + print_time_seconds: 600, + filament_used_grams: 20, + filaments: [], + }, + ], + }; + } + + function makeMultiColorRequirementsResponse() { + return { + file_id: 100, + filename: 'TwoColor.3mf', + plate_id: 1, + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3 }, + { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 10, used_meters: 3 }, + ], + }; + } + + function makeColorAwarePresets(): UnifiedPresetsResponse { + // Two filament presets in cloud: one black PLA, one white PLA. Pre-pick + // should match each plate slot to the same-colour preset so the user + // doesn't have to manually align them. + return { + cloud: { + printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }], + process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }], + filament: [ + { id: 'F-BLACK', name: 'Cloud PLA Black', source: 'cloud', filament_type: 'PLA', filament_colour: '#000000' }, + { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' }, + ], + }, + local: { printer: [], process: [], filament: [] }, + standard: { printer: [], process: [], filament: [] }, + cloud_status: 'ok', + }; + } + + it('renders one filament dropdown per plate slot when the source is multi-color', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse()); + mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse()); + mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets()); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('X1C')).toBeDefined()); + // 1 printer + 1 process + 2 filament = 4 dropdowns. + expect(screen.getAllByRole('combobox')).toHaveLength(4); + }); + + it('pre-picks each filament slot by matching colour metadata', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse()); + mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse()); + mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets()); + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('X1C')).toBeDefined()); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => { + const [, body] = mockApi.sliceLibraryFile.mock.calls[0]; + // Slot 1 was black plate → cloud black preset; slot 2 was white → + // cloud white preset. Pre-pick aligns them by metadata so the user + // doesn't have to swap them manually. + expect(body.filament_presets).toEqual([ + { source: 'cloud', id: 'F-BLACK' }, + { source: 'cloud', id: 'F-WHITE' }, + ]); + }); + }); + + it('still sends the legacy filament_preset for single-color flows', async () => { + // Backwards-compat with backends / proxies that read the singular field. + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => { + const [, body] = mockApi.sliceLibraryFile.mock.calls[0]; + // Single-color path mirrors the array's first entry into the legacy + // singular so older backend clients that only know about + // `filament_preset` still work. + expect(body.filament_preset).toEqual(body.filament_presets[0]); + expect(body.filament_presets).toHaveLength(1); + }); + }); + + it('lets the user override a pre-picked filament slot', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse()); + mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse()); + mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets()); + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('X1C')).toBeDefined()); + + const user = userEvent.setup(); + const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + // Slots 0 (printer) and 1 (process) are auto-picked. Slots 2 and 3 are + // the two filament dropdowns. Swap slot-2 (was black) to white. + await user.selectOptions(selects[2], 'cloud:F-WHITE'); + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => { + const [, body] = mockApi.sliceLibraryFile.mock.calls[0]; + expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' }); + // Slot 1 stayed at the auto-picked white. + expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-WHITE' }); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 59242b516..8b22985fe 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1129,6 +1129,11 @@ export interface SliceRequest { printer_preset?: PresetRef; process_preset?: PresetRef; filament_preset?: PresetRef; + // Multi-color: one PresetRef per plate slot, in plate order. Always + // preferred over the singular `filament_preset` when both are sent; the + // backend validator promotes a singular into a one-element list when this + // is omitted, so legacy single-color clients keep working unchanged. + filament_presets?: PresetRef[]; plate?: number; export_3mf?: boolean; } @@ -1139,6 +1144,13 @@ export interface UnifiedPreset { id: string; name: string; source: PresetSource; + // Populated for the filament slot only — used by the SliceModal multi-color + // pre-pick to score presets against each plate slot's required (type, + // colour). Optional because the bundled / standard tier rarely carries a + // colour (colour is a runtime spool attribute on Bambu) and older API + // responses pre-date these fields entirely. + filament_type?: string | null; + filament_colour?: string | null; } export interface UnifiedPresetsBySlot { printer: UnifiedPreset[]; diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index e026209a9..cc58f8e8f 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/frontend/src/components/SliceModal.tsx @@ -12,6 +12,9 @@ import { type UnifiedPresetsResponse, } from '../api/client'; import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext'; +import { PlatePickerModal } from './PlatePickerModal'; +import type { PlateFilament } from '../types/plates'; +import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers'; export type SliceSource = | { kind: 'libraryFile'; id: number; filename: string } @@ -24,10 +27,16 @@ interface SliceModalProps { type Slot = 'printer' | 'process' | 'filament'; +// SliceModal-specific tier priority: local (imported) → cloud → standard. +// Imported profiles are surfaced first because they're the user's curated +// picks (often colour/type-tagged), cloud is second since names alone can't +// drive metadata-aware match, standard is the bundled fallback. This is +// distinct from the listing endpoint's dedup order and only affects what +// the SliceModal renders / pre-picks. +const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const; + function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null { - // Cloud > local > standard. The endpoint already deduplicates by name, so - // no name-collision handling needed here — first non-empty tier wins. - for (const tier of ['cloud', 'local', 'standard'] as const) { + for (const tier of SLICE_MODAL_TIER_ORDER) { const list = by[tier][slot]; if (list.length > 0) { return { source: list[0].source, id: list[0].id }; @@ -36,6 +45,48 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null { return null; } +const TIER_BONUS: Record = { + local: 1.5, + cloud: 1.0, + standard: 0.5, +}; + +function pickFilamentForSlot( + by: UnifiedPresetsResponse, + required: { type: string; color: string }, +): PresetRef | null { + // Score every filament preset against the plate slot's required (type, + // colour) and pick the highest. Mirrors the AMS slot-mapping match in the + // print/schedule modal: type match dominates, exact-colour-match bumps over + // similar-colour-match, and a small per-tier bonus breaks ties so cloud + // user customisations win over standard bundled fallbacks of equal merit. + const reqType = required.type.trim().toUpperCase(); + const reqColor = normalizeColorForCompare(required.color); + + let best: { ref: PresetRef; score: number } | null = null; + for (const tier of SLICE_MODAL_TIER_ORDER) { + for (const p of by[tier].filament) { + let score = 0; + const presetType = (p.filament_type ?? '').trim().toUpperCase(); + const presetColor = normalizeColorForCompare(p.filament_colour ?? ''); + if (reqType && presetType && reqType === presetType) score += 10; + if (reqColor && presetColor) { + if (presetColor === reqColor) score += 5; + else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2; + } + score += TIER_BONUS[tier]; + if (best == null || score > best.score) { + best = { ref: { source: p.source, id: p.id }, score }; + } + } + } + // Fall back to plain priority pick if every preset scored 0+tier (i.e. no + // metadata matched). The fallback is exactly the single-color default — + // first preset in the highest-priority non-empty tier. + if (best == null) return pickDefault(by, 'filament'); + return best.ref; +} + function toRefValue(ref: PresetRef | null): string { // The HTML ` onChange(fromRefValue(e.target.value))} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index ca167db31..7e3b3f101 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -3250,15 +3250,28 @@ export default { printer: 'Drucker-Profil', process: 'Prozess-Profil', filament: 'Filament-Profil', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Profil auswählen —', loadingPresets: 'Profile werden geladen…', + noPresetsForSlot: 'Keine Profile verfügbar', presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.', + allPresetsRequired: 'Alle Profile müssen ausgewählt sein', enqueuing: 'Slice-Auftrag wird übermittelt…', queued: 'In Warteschlange…', failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.', startedToast: '{{name}} wird im Hintergrund gesliced…', completedToast: '{{name}} wurde gesliced', failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}', + tier: { + local: 'Importiert', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'In Bambu Cloud anmelden (Einstellungen → Profile → Cloud), um deine Cloud-Profile zu sehen.', + expired: 'Bambu-Cloud-Sitzung abgelaufen — erneut anmelden, um die Cloud-Profile zu aktualisieren.', + unreachable: 'Bambu Cloud ist gerade nicht erreichbar. Lokale und Standard-Profile funktionieren weiterhin.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 8e5fd479f..85bfbc7f0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3253,15 +3253,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 6f28cefc9..d889194eb 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -3172,15 +3172,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index dbb47ba9e..836acb79f 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -3171,15 +3171,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 85cc4ee65..fb610f9d5 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3210,15 +3210,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index a8a3c66fb..07d304a4c 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -3185,15 +3185,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 56730dd93..e9e7d289d 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3237,15 +3237,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a8c69a92d..dd9e29d91 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3237,15 +3237,28 @@ export default { printer: 'Printer profile', process: 'Process profile', filament: 'Filament profile', + filamentSlot: 'Filament {{index}} ({{type}})', selectPreset: '— Select a preset —', loadingPresets: 'Loading presets…', + noPresetsForSlot: 'No presets available', presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.', + allPresetsRequired: 'All presets must be selected', enqueuing: 'Submitting slice job…', queued: 'Queued…', failed: 'Slicing failed. Check the slicer sidecar logs.', startedToast: 'Slicing {{name}} in the background…', completedToast: 'Sliced {{name}}', failedToast: 'Slicing {{name}} failed: {{detail}}', + tier: { + local: 'Imported', + cloud: 'Cloud', + standard: 'Standard', + }, + cloud: { + notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.', + expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.', + unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.', + }, }, // Spoolman diff --git a/static/assets/index-BGNGgkMm.js b/static/assets/index-ByGZ61Vo.js similarity index 83% rename from static/assets/index-BGNGgkMm.js rename to static/assets/index-ByGZ61Vo.js index b3139462e..c3c754406 100644 --- a/static/assets/index-BGNGgkMm.js +++ b/static/assets/index-ByGZ61Vo.js @@ -1,42 +1,42 @@ -function Qde(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var Z_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function gc(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var SM={exports:{}},Ev={};var B8;function Jde(){if(B8)return Ev;B8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return Ev.Fragment=e,Ev.jsx=n,Ev.jsxs=n,Ev}var H8;function Zde(){return H8||(H8=1,SM.exports=Jde()),SM.exports}var a=Zde(),_M={exports:{}},Wn={};var q8;function eue(){if(q8)return Wn;q8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),u=Symbol.for("react.lazy"),m=Symbol.for("react.activity"),p=Symbol.iterator;function f(K){return K===null||typeof K!="object"?null:(K=p&&K[p]||K["@@iterator"],typeof K=="function"?K:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function g(K,ae,me){this.props=K,this.context=ae,this.refs=b,this.updater=me||x}g.prototype.isReactComponent={},g.prototype.setState=function(K,ae){if(typeof K!="object"&&typeof K!="function"&&K!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,K,ae,"setState")},g.prototype.forceUpdate=function(K){this.updater.enqueueForceUpdate(this,K,"forceUpdate")};function _(){}_.prototype=g.prototype;function N(K,ae,me){this.props=K,this.context=ae,this.refs=b,this.updater=me||x}var P=N.prototype=new _;P.constructor=N,v(P,g.prototype),P.isPureReactComponent=!0;var C=Array.isArray;function A(){}var T={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function k(K,ae,me){var xe=me.ref;return{$$typeof:t,type:K,key:ae,ref:xe!==void 0?xe:null,props:me}}function E(K,ae){return k(K.type,ae,K.props)}function $(K){return typeof K=="object"&&K!==null&&K.$$typeof===t}function z(K){var ae={"=":"=0",":":"=2"};return"$"+K.replace(/[=:]/g,function(me){return ae[me]})}var X=/\/+/g;function L(K,ae){return typeof K=="object"&&K!==null&&K.key!=null?z(""+K.key):ae.toString(36)}function ne(K){switch(K.status){case"fulfilled":return K.value;case"rejected":throw K.reason;default:switch(typeof K.status=="string"?K.then(A,A):(K.status="pending",K.then(function(ae){K.status==="pending"&&(K.status="fulfilled",K.value=ae)},function(ae){K.status==="pending"&&(K.status="rejected",K.reason=ae)})),K.status){case"fulfilled":return K.value;case"rejected":throw K.reason}}throw K}function re(K,ae,me,xe,be){var B=typeof K;(B==="undefined"||B==="boolean")&&(K=null);var Q=!1;if(K===null)Q=!0;else switch(B){case"bigint":case"string":case"number":Q=!0;break;case"object":switch(K.$$typeof){case t:case e:Q=!0;break;case u:return Q=K._init,re(Q(K._payload),ae,me,xe,be)}}if(Q)return be=be(K),Q=xe===""?"."+L(K,0):xe,C(be)?(me="",Q!=null&&(me=Q.replace(X,"$&/")+"/"),re(be,ae,me,"",function(U){return U})):be!=null&&($(be)&&(be=E(be,me+(be.key==null||K&&K.key===be.key?"":(""+be.key).replace(X,"$&/")+"/")+Q)),ae.push(be)),1;Q=0;var D=xe===""?".":xe+":";if(C(K))for(var M=0;M>>1,oe=re[se];if(0>>1;sei(me,ue))xei(be,me)?(re[se]=be,re[xe]=ue,se=xe):(re[se]=me,re[ae]=ue,se=ae);else if(xei(be,ue))re[se]=be,re[xe]=ue,se=xe;else break e}}return ee}function i(re,ee){var ue=re.sortIndex-ee.sortIndex;return ue!==0?ue:re.id-ee.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],d=[],u=1,m=null,p=3,f=!1,x=!1,v=!1,b=!1,g=typeof setTimeout=="function"?setTimeout:null,_=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function P(re){for(var ee=n(d);ee!==null;){if(ee.callback===null)r(d);else if(ee.startTime<=re)r(d),ee.sortIndex=ee.expirationTime,e(c,ee);else break;ee=n(d)}}function C(re){if(v=!1,P(re),!x)if(n(c)!==null)x=!0,A||(A=!0,z());else{var ee=n(d);ee!==null&&ne(C,ee.startTime-re)}}var A=!1,T=-1,F=5,k=-1;function E(){return b?!0:!(t.unstable_now()-kre&&E());){var se=m.callback;if(typeof se=="function"){m.callback=null,p=m.priorityLevel;var oe=se(m.expirationTime<=re);if(re=t.unstable_now(),typeof oe=="function"){m.callback=oe,P(re),ee=!0;break t}m===n(c)&&r(c),P(re)}else r(c);m=n(c)}if(m!==null)ee=!0;else{var K=n(d);K!==null&&ne(C,K.startTime-re),ee=!1}}break e}finally{m=null,p=ue,f=!1}ee=void 0}}finally{ee?z():A=!1}}}var z;if(typeof N=="function")z=function(){N($)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,L=X.port2;X.port1.onmessage=$,z=function(){L.postMessage(null)}}else z=function(){g($,0)};function ne(re,ee){T=g(function(){re(t.unstable_now())},ee)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(re){re.callback=null},t.unstable_forceFrameRate=function(re){0>re||125se?(re.sortIndex=ue,e(d,re),n(c)===null&&re===n(d)&&(v?(_(T),T=-1):v=!0,ne(C,ue-se))):(re.sortIndex=oe,e(c,re),x||f||(x=!0,A||(A=!0,z()))),re},t.unstable_shouldYield=E,t.unstable_wrapCallback=function(re){var ee=p;return function(){var ue=p;p=ee;try{return re.apply(this,arguments)}finally{p=ue}}}})(CM)),CM}var G8;function rue(){return G8||(G8=1,NM.exports=nue()),NM.exports}var PM={exports:{}},Es={};var W8;function aue(){if(W8)return Es;W8=1;var t=Ug();function e(c){var d="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),PM.exports=aue(),PM.exports}var X8;function iue(){if(X8)return Dv;X8=1;var t=rue(),e=Ug(),n=nQ();function r(h){var y="https://react.dev/errors/"+h;if(1oe||(h.current=se[oe],se[oe]=null,oe--)}function me(h,y){oe++,se[oe]=h.current,h.current=y}var xe=K(null),be=K(null),B=K(null),Q=K(null);function D(h,y){switch(me(B,y),me(be,h),me(xe,null),y.nodeType){case 9:case 11:h=(h=y.documentElement)&&(h=h.namespaceURI)?d8(h):0;break;default:if(h=y.tagName,y=y.namespaceURI)y=d8(y),h=u8(y,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}ae(xe),me(xe,h)}function M(){ae(xe),ae(be),ae(B)}function U(h){h.memoizedState!==null&&me(Q,h);var y=xe.current,S=u8(y,h.type);y!==S&&(me(be,h),me(xe,S))}function Y(h){be.current===h&&(ae(xe),ae(be)),Q.current===h&&(ae(Q),Tv._currentValue=ue)}var I,ce;function O(h){if(I===void 0)try{throw Error()}catch(S){var y=S.stack.trim().match(/\n( *(at )?)/);I=y&&y[1]||"",ce=-1)":-1r[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var Z_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function gc(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var SM={exports:{}},Ev={};var H8;function Zde(){if(H8)return Ev;H8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return Ev.Fragment=e,Ev.jsx=n,Ev.jsxs=n,Ev}var q8;function eue(){return q8||(q8=1,SM.exports=Zde()),SM.exports}var a=eue(),_M={exports:{}},Wn={};var $8;function tue(){if($8)return Wn;$8=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),u=Symbol.for("react.lazy"),m=Symbol.for("react.activity"),p=Symbol.iterator;function f(K){return K===null||typeof K!="object"?null:(K=p&&K[p]||K["@@iterator"],typeof K=="function"?K:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function g(K,ae,me){this.props=K,this.context=ae,this.refs=b,this.updater=me||x}g.prototype.isReactComponent={},g.prototype.setState=function(K,ae){if(typeof K!="object"&&typeof K!="function"&&K!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,K,ae,"setState")},g.prototype.forceUpdate=function(K){this.updater.enqueueForceUpdate(this,K,"forceUpdate")};function S(){}S.prototype=g.prototype;function N(K,ae,me){this.props=K,this.context=ae,this.refs=b,this.updater=me||x}var P=N.prototype=new S;P.constructor=N,v(P,g.prototype),P.isPureReactComponent=!0;var C=Array.isArray;function A(){}var T={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function k(K,ae,me){var xe=me.ref;return{$$typeof:t,type:K,key:ae,ref:xe!==void 0?xe:null,props:me}}function E(K,ae){return k(K.type,ae,K.props)}function $(K){return typeof K=="object"&&K!==null&&K.$$typeof===t}function z(K){var ae={"=":"=0",":":"=2"};return"$"+K.replace(/[=:]/g,function(me){return ae[me]})}var X=/\/+/g;function L(K,ae){return typeof K=="object"&&K!==null&&K.key!=null?z(""+K.key):ae.toString(36)}function ne(K){switch(K.status){case"fulfilled":return K.value;case"rejected":throw K.reason;default:switch(typeof K.status=="string"?K.then(A,A):(K.status="pending",K.then(function(ae){K.status==="pending"&&(K.status="fulfilled",K.value=ae)},function(ae){K.status==="pending"&&(K.status="rejected",K.reason=ae)})),K.status){case"fulfilled":return K.value;case"rejected":throw K.reason}}throw K}function re(K,ae,me,xe,be){var B=typeof K;(B==="undefined"||B==="boolean")&&(K=null);var Q=!1;if(K===null)Q=!0;else switch(B){case"bigint":case"string":case"number":Q=!0;break;case"object":switch(K.$$typeof){case t:case e:Q=!0;break;case u:return Q=K._init,re(Q(K._payload),ae,me,xe,be)}}if(Q)return be=be(K),Q=xe===""?"."+L(K,0):xe,C(be)?(me="",Q!=null&&(me=Q.replace(X,"$&/")+"/"),re(be,ae,me,"",function(U){return U})):be!=null&&($(be)&&(be=E(be,me+(be.key==null||K&&K.key===be.key?"":(""+be.key).replace(X,"$&/")+"/")+Q)),ae.push(be)),1;Q=0;var D=xe===""?".":xe+":";if(C(K))for(var M=0;M>>1,oe=re[se];if(0>>1;sei(me,ue))xei(be,me)?(re[se]=be,re[xe]=ue,se=xe):(re[se]=me,re[ae]=ue,se=ae);else if(xei(be,ue))re[se]=be,re[xe]=ue,se=xe;else break e}}return ee}function i(re,ee){var ue=re.sortIndex-ee.sortIndex;return ue!==0?ue:re.id-ee.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],d=[],u=1,m=null,p=3,f=!1,x=!1,v=!1,b=!1,g=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function P(re){for(var ee=n(d);ee!==null;){if(ee.callback===null)r(d);else if(ee.startTime<=re)r(d),ee.sortIndex=ee.expirationTime,e(c,ee);else break;ee=n(d)}}function C(re){if(v=!1,P(re),!x)if(n(c)!==null)x=!0,A||(A=!0,z());else{var ee=n(d);ee!==null&&ne(C,ee.startTime-re)}}var A=!1,T=-1,F=5,k=-1;function E(){return b?!0:!(t.unstable_now()-kre&&E());){var se=m.callback;if(typeof se=="function"){m.callback=null,p=m.priorityLevel;var oe=se(m.expirationTime<=re);if(re=t.unstable_now(),typeof oe=="function"){m.callback=oe,P(re),ee=!0;break t}m===n(c)&&r(c),P(re)}else r(c);m=n(c)}if(m!==null)ee=!0;else{var K=n(d);K!==null&&ne(C,K.startTime-re),ee=!1}}break e}finally{m=null,p=ue,f=!1}ee=void 0}}finally{ee?z():A=!1}}}var z;if(typeof N=="function")z=function(){N($)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,L=X.port2;X.port1.onmessage=$,z=function(){L.postMessage(null)}}else z=function(){g($,0)};function ne(re,ee){T=g(function(){re(t.unstable_now())},ee)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(re){re.callback=null},t.unstable_forceFrameRate=function(re){0>re||125se?(re.sortIndex=ue,e(d,re),n(c)===null&&re===n(d)&&(v?(S(T),T=-1):v=!0,ne(C,ue-se))):(re.sortIndex=oe,e(c,re),x||f||(x=!0,A||(A=!0,z()))),re},t.unstable_shouldYield=E,t.unstable_wrapCallback=function(re){var ee=p;return function(){var ue=p;p=ee;try{return re.apply(this,arguments)}finally{p=ue}}}})(CM)),CM}var W8;function aue(){return W8||(W8=1,NM.exports=rue()),NM.exports}var PM={exports:{}},Es={};var K8;function iue(){if(K8)return Es;K8=1;var t=Ug();function e(c){var d="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),PM.exports=iue(),PM.exports}var Y8;function sue(){if(Y8)return Dv;Y8=1;var t=aue(),e=Ug(),n=rQ();function r(h){var y="https://react.dev/errors/"+h;if(1oe||(h.current=se[oe],se[oe]=null,oe--)}function me(h,y){oe++,se[oe]=h.current,h.current=y}var xe=K(null),be=K(null),B=K(null),Q=K(null);function D(h,y){switch(me(B,y),me(be,h),me(xe,null),y.nodeType){case 9:case 11:h=(h=y.documentElement)&&(h=h.namespaceURI)?u8(h):0;break;default:if(h=y.tagName,y=y.namespaceURI)y=u8(y),h=m8(y,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}ae(xe),me(xe,h)}function M(){ae(xe),ae(be),ae(B)}function U(h){h.memoizedState!==null&&me(Q,h);var y=xe.current,_=m8(y,h.type);y!==_&&(me(be,h),me(xe,_))}function Y(h){be.current===h&&(ae(xe),ae(be)),Q.current===h&&(ae(Q),Tv._currentValue=ue)}var I,ce;function O(h){if(I===void 0)try{throw Error()}catch(_){var y=_.stack.trim().match(/\n( *(at )?)/);I=y&&y[1]||"",ce=-1<_.stack.indexOf(` + at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` +`+I+h+ce}var W=!1;function V(h,y){if(!h||W)return"";W=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var j={DetermineComponentFrameRoot:function(){try{if(y){var Ut=function(){throw Error()};if(Object.defineProperty(Ut.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(Ut,[])}catch(At){var St=At}Reflect.construct(h,[],Ut)}else{try{Ut.call()}catch(At){St=At}h.call(Ut.prototype)}}else{try{throw Error()}catch(At){St=At}(Ut=h())&&typeof Ut.catch=="function"&&Ut.catch(function(){})}}catch(At){if(At&&St&&typeof At.stack=="string")return[At.stack,St.stack]}return[null,null]}};j.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var G=Object.getOwnPropertyDescriptor(j.DetermineComponentFrameRoot,"name");G&&G.configurable&&Object.defineProperty(j.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var Z=j.DetermineComponentFrameRoot(),Ne=Z[0],qe=Z[1];if(Ne&&qe){var lt=Ne.split(` `),vt=qe.split(` `);for(G=j=0;jG||lt[j]!==vt[G]){var Rt=` -`+lt[j].replace(" at new "," at ");return h.displayName&&Rt.includes("")&&(Rt=Rt.replace("",h.displayName)),Rt}while(1<=j&&0<=G);break}}}finally{W=!1,Error.prepareStackTrace=S}return(S=h?h.displayName||h.name:"")?O(S):""}function H(h,y){switch(h.tag){case 26:case 27:case 5:return O(h.type);case 16:return O("Lazy");case 13:return h.child!==y&&y!==null?O("Suspense Fallback"):O("Suspense");case 19:return O("SuspenseList");case 0:case 15:return V(h.type,!1);case 11:return V(h.type.render,!1);case 1:return V(h.type,!0);case 31:return O("Activity");default:return""}}function J(h){try{var y="",S=null;do y+=H(h,S),S=h,h=h.return;while(h);return y}catch(j){return` +`+lt[j].replace(" at new "," at ");return h.displayName&&Rt.includes("")&&(Rt=Rt.replace("",h.displayName)),Rt}while(1<=j&&0<=G);break}}}finally{W=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?O(_):""}function H(h,y){switch(h.tag){case 26:case 27:case 5:return O(h.type);case 16:return O("Lazy");case 13:return h.child!==y&&y!==null?O("Suspense Fallback"):O("Suspense");case 19:return O("SuspenseList");case 0:case 15:return V(h.type,!1);case 11:return V(h.type.render,!1);case 1:return V(h.type,!0);case 31:return O("Activity");default:return""}}function J(h){try{var y="",_=null;do y+=H(h,_),_=h,h=h.return;while(h);return y}catch(j){return` Error generating stack: `+j.message+` -`+j.stack}}var ie=Object.prototype.hasOwnProperty,ge=t.unstable_scheduleCallback,pe=t.unstable_cancelCallback,he=t.unstable_shouldYield,q=t.unstable_requestPaint,R=t.unstable_now,te=t.unstable_getCurrentPriorityLevel,fe=t.unstable_ImmediatePriority,ke=t.unstable_UserBlockingPriority,Ae=t.unstable_NormalPriority,je=t.unstable_LowPriority,ye=t.unstable_IdlePriority,Le=t.log,Be=t.unstable_setDisableYieldValue,Ce=null,ze=null;function Me(h){if(typeof Le=="function"&&Be(h),ze&&typeof ze.setStrictMode=="function")try{ze.setStrictMode(Ce,h)}catch{}}var Ie=Math.clz32?Math.clz32:ve,Ge=Math.log,et=Math.LN2;function ve(h){return h>>>=0,h===0?32:31-(Ge(h)/et|0)|0}var Xe=256,Pe=262144,Ve=4194304;function we(h){var y=h&42;if(y!==0)return y;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function de(h,y,S){var j=h.pendingLanes;if(j===0)return 0;var G=0,Z=h.suspendedLanes,Ne=h.pingedLanes;h=h.warmLanes;var qe=j&134217727;return qe!==0?(j=qe&~Z,j!==0?G=we(j):(Ne&=qe,Ne!==0?G=we(Ne):S||(S=qe&~h,S!==0&&(G=we(S))))):(qe=j&~Z,qe!==0?G=we(qe):Ne!==0?G=we(Ne):S||(S=j&~h,S!==0&&(G=we(S)))),G===0?0:y!==0&&y!==G&&(y&Z)===0&&(Z=G&-G,S=y&-y,Z>=S||Z===32&&(S&4194048)!==0)?y:G}function Se(h,y){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&y)===0}function $e(h,y){switch(h){case 1:case 2:case 4:case 8:case 64:return y+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return y+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function st(){var h=Ve;return Ve<<=1,(Ve&62914560)===0&&(Ve=4194304),h}function He(h){for(var y=[],S=0;31>S;S++)y.push(h);return y}function rt(h,y){h.pendingLanes|=y,y!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function mt(h,y,S,j,G,Z){var Ne=h.pendingLanes;h.pendingLanes=S,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=S,h.entangledLanes&=S,h.errorRecoveryDisabledLanes&=S,h.shellSuspendCounter=0;var qe=h.entanglements,lt=h.expirationTimes,vt=h.hiddenUpdates;for(S=Ne&~S;0"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Pr=/[\n"\\]/g;function In(h){return h.replace(Pr,function(y){return"\\"+y.charCodeAt(0).toString(16)+" "})}function Ea(h,y,S,j,G,Z,Ne,qe){h.name="",Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"?h.type=Ne:h.removeAttribute("type"),y!=null?Ne==="number"?(y===0&&h.value===""||h.value!=y)&&(h.value=""+cn(y)):h.value!==""+cn(y)&&(h.value=""+cn(y)):Ne!=="submit"&&Ne!=="reset"||h.removeAttribute("value"),y!=null?Br(h,Ne,cn(y)):S!=null?Br(h,Ne,cn(S)):j!=null&&h.removeAttribute("value"),G==null&&Z!=null&&(h.defaultChecked=!!Z),G!=null&&(h.checked=G&&typeof G!="function"&&typeof G!="symbol"),qe!=null&&typeof qe!="function"&&typeof qe!="symbol"&&typeof qe!="boolean"?h.name=""+cn(qe):h.removeAttribute("name")}function $n(h,y,S,j,G,Z,Ne,qe){if(Z!=null&&typeof Z!="function"&&typeof Z!="symbol"&&typeof Z!="boolean"&&(h.type=Z),y!=null||S!=null){if(!(Z!=="submit"&&Z!=="reset"||y!=null)){Sn(h);return}S=S!=null?""+cn(S):"",y=y!=null?""+cn(y):S,qe||y===h.value||(h.value=y),h.defaultValue=y}j=j??G,j=typeof j!="function"&&typeof j!="symbol"&&!!j,h.checked=qe?h.checked:!!j,h.defaultChecked=!!j,Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"&&(h.name=Ne),Sn(h)}function Br(h,y,S){y==="number"&&br(h.ownerDocument)===h||h.defaultValue===""+S||(h.defaultValue=""+S)}function Kr(h,y,S,j){if(h=h.options,y){y={};for(var G=0;G"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),zi=!1;if($o)try{var wl={};Object.defineProperty(wl,"passive",{get:function(){zi=!0}}),window.addEventListener("test",wl,wl),window.removeEventListener("test",wl,wl)}catch{zi=!1}var wo=null,ha=null,vc=null;function Om(){if(vc)return vc;var h,y=ha,S=y.length,j,G="value"in wo?wo.value:wo.textContent,Z=G.length;for(h=0;h=it),ct=" ",Mn=!1;function xr(h,y){switch(h){case"keyup":return Fn.indexOf(y.keyCode)!==-1;case"keydown":return y.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function lr(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Ir=!1;function Da(h,y){switch(h){case"compositionend":return lr(y);case"keypress":return y.which!==32?null:(Mn=!0,ct);case"textInput":return h=y.data,h===ct&&Mn?null:h;default:return null}}function un(h,y){if(Ir)return h==="compositionend"||!_n&&xr(h,y)?(h=Om(),vc=ha=wo=null,Ir=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(y.ctrlKey||y.altKey||y.metaKey)||y.ctrlKey&&y.altKey){if(y.char&&1=y)return{node:S,offset:y-h};h=j}e:{for(;S;){if(S.nextSibling){S=S.nextSibling;break e}S=S.parentNode}S=void 0}S=Yp(S)}}function Jy(h,y){return h&&y?h===y?!0:h&&h.nodeType===3?!1:y&&y.nodeType===3?Jy(h,y.parentNode):"contains"in h?h.contains(y):h.compareDocumentPosition?!!(h.compareDocumentPosition(y)&16):!1:!1}function YS(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var y=br(h.document);y instanceof h.HTMLIFrameElement;){try{var S=typeof y.contentWindow.location.href=="string"}catch{S=!1}if(S)h=y.contentWindow;else break;y=br(h.document)}return y}function MA(h){var y=h&&h.nodeName&&h.nodeName.toLowerCase();return y&&(y==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||y==="textarea"||h.contentEditable==="true")}var jce=$o&&"documentMode"in document&&11>=document.documentMode,eb=null,EA=null,Zy=null,DA=!1;function E5(h,y,S){var j=S.window===S?S.document:S.nodeType===9?S:S.ownerDocument;DA||eb==null||eb!==br(j)||(j=eb,"selectionStart"in j&&MA(j)?j={start:j.selectionStart,end:j.selectionEnd}:(j=(j.ownerDocument&&j.ownerDocument.defaultView||window).getSelection(),j={anchorNode:j.anchorNode,anchorOffset:j.anchorOffset,focusNode:j.focusNode,focusOffset:j.focusOffset}),Zy&&Vm(Zy,j)||(Zy=j,j=I_(EA,"onSelect"),0>=Ne,G-=Ne,od=1<<32-Ie(y)+G|S<Qn?(vr=vn,vn=null):vr=vn.sibling;var Mr=St(gt,vn,yt[Qn],It);if(Mr===null){vn===null&&(vn=vr);break}h&&vn&&Mr.alternate===null&&y(gt,vn),pt=Z(Mr,pt,Qn),jr===null?An=Mr:jr.sibling=Mr,jr=Mr,vn=vr}if(Qn===yt.length)return S(gt,vn),Sr&&cu(gt,Qn),An;if(vn===null){for(;QnQn?(vr=vn,vn=null):vr=vn.sibling;var hh=St(gt,vn,Mr.value,It);if(hh===null){vn===null&&(vn=vr);break}h&&vn&&hh.alternate===null&&y(gt,vn),pt=Z(hh,pt,Qn),jr===null?An=hh:jr.sibling=hh,jr=hh,vn=vr}if(Mr.done)return S(gt,vn),Sr&&cu(gt,Qn),An;if(vn===null){for(;!Mr.done;Qn++,Mr=yt.next())Mr=Ut(gt,Mr.value,It),Mr!==null&&(pt=Z(Mr,pt,Qn),jr===null?An=Mr:jr.sibling=Mr,jr=Mr);return Sr&&cu(gt,Qn),An}for(vn=j(vn);!Mr.done;Qn++,Mr=yt.next())Mr=At(vn,gt,Qn,Mr.value,It),Mr!==null&&(h&&Mr.alternate!==null&&vn.delete(Mr.key===null?Qn:Mr.key),pt=Z(Mr,pt,Qn),jr===null?An=Mr:jr.sibling=Mr,jr=Mr);return h&&vn.forEach(function(Yde){return y(gt,Yde)}),Sr&&cu(gt,Qn),An}function Qr(gt,pt,yt,It){if(typeof yt=="object"&&yt!==null&&yt.type===v&&yt.key===null&&(yt=yt.props.children),typeof yt=="object"&&yt!==null){switch(yt.$$typeof){case f:e:{for(var An=yt.key;pt!==null;){if(pt.key===An){if(An=yt.type,An===v){if(pt.tag===7){S(gt,pt.sibling),It=G(pt,yt.props.children),It.return=gt,gt=It;break e}}else if(pt.elementType===An||typeof An=="object"&&An!==null&&An.$$typeof===F&&sf(An)===pt.type){S(gt,pt.sibling),It=G(pt,yt.props),iv(It,yt),It.return=gt,gt=It;break e}S(gt,pt);break}else y(gt,pt);pt=pt.sibling}yt.type===v?(It=ef(yt.props.children,gt.mode,It,yt.key),It.return=gt,gt=It):(It=t_(yt.type,yt.key,yt.props,null,gt.mode,It),iv(It,yt),It.return=gt,gt=It)}return Ne(gt);case x:e:{for(An=yt.key;pt!==null;){if(pt.key===An)if(pt.tag===4&&pt.stateNode.containerInfo===yt.containerInfo&&pt.stateNode.implementation===yt.implementation){S(gt,pt.sibling),It=G(pt,yt.children||[]),It.return=gt,gt=It;break e}else{S(gt,pt);break}else y(gt,pt);pt=pt.sibling}It=UA(yt,gt.mode,It),It.return=gt,gt=It}return Ne(gt);case F:return yt=sf(yt),Qr(gt,pt,yt,It)}if(ne(yt))return gn(gt,pt,yt,It);if(z(yt)){if(An=z(yt),typeof An!="function")throw Error(r(150));return yt=An.call(yt),Ln(gt,pt,yt,It)}if(typeof yt.then=="function")return Qr(gt,pt,l_(yt),It);if(yt.$$typeof===N)return Qr(gt,pt,a_(gt,yt),It);c_(gt,yt)}return typeof yt=="string"&&yt!==""||typeof yt=="number"||typeof yt=="bigint"?(yt=""+yt,pt!==null&&pt.tag===6?(S(gt,pt.sibling),It=G(pt,yt),It.return=gt,gt=It):(S(gt,pt),It=zA(yt,gt.mode,It),It.return=gt,gt=It),Ne(gt)):S(gt,pt)}return function(gt,pt,yt,It){try{av=0;var An=Qr(gt,pt,yt,It);return ub=null,An}catch(vn){if(vn===db||vn===s_)throw vn;var jr=Ko(29,vn,null,gt.mode);return jr.lanes=It,jr.return=gt,jr}}}var lf=tU(!0),nU=tU(!1),Ym=!1;function JA(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ZA(h,y){h=h.updateQueue,y.updateQueue===h&&(y.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function Qm(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Jm(h,y,S){var j=h.updateQueue;if(j===null)return null;if(j=j.shared,(Lr&2)!==0){var G=j.pending;return G===null?y.next=y:(y.next=G.next,G.next=y),j.pending=y,y=e_(h),z5(h,null,S),y}return ZS(h,j,y,S),e_(h)}function sv(h,y,S){if(y=y.updateQueue,y!==null&&(y=y.shared,(S&4194048)!==0)){var j=y.lanes;j&=h.pendingLanes,S|=j,y.lanes=S,qt(h,S)}}function ej(h,y){var S=h.updateQueue,j=h.alternate;if(j!==null&&(j=j.updateQueue,S===j)){var G=null,Z=null;if(S=S.firstBaseUpdate,S!==null){do{var Ne={lane:S.lane,tag:S.tag,payload:S.payload,callback:null,next:null};Z===null?G=Z=Ne:Z=Z.next=Ne,S=S.next}while(S!==null);Z===null?G=Z=y:Z=Z.next=y}else G=Z=y;S={baseState:j.baseState,firstBaseUpdate:G,lastBaseUpdate:Z,shared:j.shared,callbacks:j.callbacks},h.updateQueue=S;return}h=S.lastBaseUpdate,h===null?S.firstBaseUpdate=y:h.next=y,S.lastBaseUpdate=y}var tj=!1;function ov(){if(tj){var h=cb;if(h!==null)throw h}}function lv(h,y,S,j){tj=!1;var G=h.updateQueue;Ym=!1;var Z=G.firstBaseUpdate,Ne=G.lastBaseUpdate,qe=G.shared.pending;if(qe!==null){G.shared.pending=null;var lt=qe,vt=lt.next;lt.next=null,Ne===null?Z=vt:Ne.next=vt,Ne=lt;var Rt=h.alternate;Rt!==null&&(Rt=Rt.updateQueue,qe=Rt.lastBaseUpdate,qe!==Ne&&(qe===null?Rt.firstBaseUpdate=vt:qe.next=vt,Rt.lastBaseUpdate=lt))}if(Z!==null){var Ut=G.baseState;Ne=0,Rt=vt=lt=null,qe=Z;do{var St=qe.lane&-536870913,At=St!==qe.lane;if(At?(yr&St)===St:(j&St)===St){St!==0&&St===lb&&(tj=!0),Rt!==null&&(Rt=Rt.next={lane:0,tag:qe.tag,payload:qe.payload,callback:null,next:null});e:{var gn=h,Ln=qe;St=y;var Qr=S;switch(Ln.tag){case 1:if(gn=Ln.payload,typeof gn=="function"){Ut=gn.call(Qr,Ut,St);break e}Ut=gn;break e;case 3:gn.flags=gn.flags&-65537|128;case 0:if(gn=Ln.payload,St=typeof gn=="function"?gn.call(Qr,Ut,St):gn,St==null)break e;Ut=m({},Ut,St);break e;case 2:Ym=!0}}St=qe.callback,St!==null&&(h.flags|=64,At&&(h.flags|=8192),At=G.callbacks,At===null?G.callbacks=[St]:At.push(St))}else At={lane:St,tag:qe.tag,payload:qe.payload,callback:qe.callback,next:null},Rt===null?(vt=Rt=At,lt=Ut):Rt=Rt.next=At,Ne|=St;if(qe=qe.next,qe===null){if(qe=G.shared.pending,qe===null)break;At=qe,qe=At.next,At.next=null,G.lastBaseUpdate=At,G.shared.pending=null}}while(!0);Rt===null&&(lt=Ut),G.baseState=lt,G.firstBaseUpdate=vt,G.lastBaseUpdate=Rt,Z===null&&(G.shared.lanes=0),rh|=Ne,h.lanes=Ne,h.memoizedState=Ut}}function rU(h,y){if(typeof h!="function")throw Error(r(191,h));h.call(y)}function aU(h,y){var S=h.callbacks;if(S!==null)for(h.callbacks=null,h=0;hZ?Z:8;var Ne=re.T,qe={};re.T=qe,vj(h,!1,y,S);try{var lt=G(),vt=re.S;if(vt!==null&&vt(qe,lt),lt!==null&&typeof lt=="object"&&typeof lt.then=="function"){var Rt=zce(lt,j);uv(h,y,Rt,Zo(h))}else uv(h,y,j,Zo(h))}catch(Ut){uv(h,y,{then:function(){},status:"rejected",reason:Ut},Zo())}finally{ee.p=Z,Ne!==null&&qe.types!==null&&(Ne.types=qe.types),re.T=Ne}}function Vce(){}function xj(h,y,S,j){if(h.tag!==5)throw Error(r(476));var G=LU(h).queue;RU(h,G,y,ue,S===null?Vce:function(){return OU(h),S(j)})}function LU(h){var y=h.memoizedState;if(y!==null)return y;y={memoizedState:ue,baseState:ue,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:ue},next:null};var S={};return y.next={memoizedState:S,baseState:S,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:S},next:null},h.memoizedState=y,h=h.alternate,h!==null&&(h.memoizedState=y),y}function OU(h){var y=LU(h);y.next===null&&(y=h.alternate.memoizedState),uv(h,y.next.queue,{},Zo())}function yj(){return ds(Tv)}function IU(){return ii().memoizedState}function zU(){return ii().memoizedState}function Gce(h){for(var y=h.return;y!==null;){switch(y.tag){case 24:case 3:var S=Zo();h=Qm(S);var j=Jm(y,h,S);j!==null&&(To(j,y,S),sv(j,y,S)),y={cache:KA()},h.payload=y;return}y=y.return}}function Wce(h,y,S){var j=Zo();S={lane:j,revertLane:0,gesture:null,action:S,hasEagerState:!1,eagerState:null,next:null},y_(h)?BU(y,S):(S=OA(h,y,S,j),S!==null&&(To(S,h,j),HU(S,y,j)))}function UU(h,y,S){var j=Zo();uv(h,y,S,j)}function uv(h,y,S,j){var G={lane:j,revertLane:0,gesture:null,action:S,hasEagerState:!1,eagerState:null,next:null};if(y_(h))BU(y,G);else{var Z=h.alternate;if(h.lanes===0&&(Z===null||Z.lanes===0)&&(Z=y.lastRenderedReducer,Z!==null))try{var Ne=y.lastRenderedState,qe=Z(Ne,S);if(G.hasEagerState=!0,G.eagerState=qe,_o(qe,Ne))return ZS(h,y,G,0),oa===null&&JS(),!1}catch{}if(S=OA(h,y,G,j),S!==null)return To(S,h,j),HU(S,y,j),!0}return!1}function vj(h,y,S,j){if(j={lane:2,revertLane:Jj(),gesture:null,action:j,hasEagerState:!1,eagerState:null,next:null},y_(h)){if(y)throw Error(r(479))}else y=OA(h,S,j,2),y!==null&&To(y,h,2)}function y_(h){var y=h.alternate;return h===Xn||y!==null&&y===Xn}function BU(h,y){hb=m_=!0;var S=h.pending;S===null?y.next=y:(y.next=S.next,S.next=y),h.pending=y}function HU(h,y,S){if((S&4194048)!==0){var j=y.lanes;j&=h.pendingLanes,S|=j,y.lanes=S,qt(h,S)}}var mv={readContext:ds,use:f_,useCallback:qa,useContext:qa,useEffect:qa,useImperativeHandle:qa,useLayoutEffect:qa,useInsertionEffect:qa,useMemo:qa,useReducer:qa,useRef:qa,useState:qa,useDebugValue:qa,useDeferredValue:qa,useTransition:qa,useSyncExternalStore:qa,useId:qa,useHostTransitionStatus:qa,useFormState:qa,useActionState:qa,useOptimistic:qa,useMemoCache:qa,useCacheRefresh:qa};mv.useEffectEvent=qa;var qU={readContext:ds,use:f_,useCallback:function(h,y){return Zs().memoizedState=[h,y===void 0?null:y],h},useContext:ds,useEffect:CU,useImperativeHandle:function(h,y,S){S=S!=null?S.concat([h]):null,b_(4194308,4,jU.bind(null,y,h),S)},useLayoutEffect:function(h,y){return b_(4194308,4,h,y)},useInsertionEffect:function(h,y){b_(4,2,h,y)},useMemo:function(h,y){var S=Zs();y=y===void 0?null:y;var j=h();if(cf){Me(!0);try{h()}finally{Me(!1)}}return S.memoizedState=[j,y],j},useReducer:function(h,y,S){var j=Zs();if(S!==void 0){var G=S(y);if(cf){Me(!0);try{S(y)}finally{Me(!1)}}}else G=y;return j.memoizedState=j.baseState=G,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:G},j.queue=h,h=h.dispatch=Wce.bind(null,Xn,h),[j.memoizedState,h]},useRef:function(h){var y=Zs();return h={current:h},y.memoizedState=h},useState:function(h){h=hj(h);var y=h.queue,S=UU.bind(null,Xn,y);return y.dispatch=S,[h.memoizedState,S]},useDebugValue:gj,useDeferredValue:function(h,y){var S=Zs();return bj(S,h,y)},useTransition:function(){var h=hj(!1);return h=RU.bind(null,Xn,h.queue,!0,!1),Zs().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,y,S){var j=Xn,G=Zs();if(Sr){if(S===void 0)throw Error(r(407));S=S()}else{if(S=y(),oa===null)throw Error(r(349));(yr&127)!==0||dU(j,y,S)}G.memoizedState=S;var Z={value:S,getSnapshot:y};return G.queue=Z,CU(mU.bind(null,j,Z,h),[h]),j.flags|=2048,fb(9,{destroy:void 0},uU.bind(null,j,Z,S,y),null),S},useId:function(){var h=Zs(),y=oa.identifierPrefix;if(Sr){var S=ld,j=od;S=(j&~(1<<32-Ie(j)-1)).toString(32)+S,y="_"+y+"R_"+S,S=h_++,0<\/script>",Z=Z.removeChild(Z.firstChild);break;case"select":Z=typeof j.is=="string"?Ne.createElement("select",{is:j.is}):Ne.createElement("select"),j.multiple?Z.multiple=!0:j.size&&(Z.size=j.size);break;default:Z=typeof j.is=="string"?Ne.createElement(G,{is:j.is}):Ne.createElement(G)}}Z[jt]=y,Z[Jt]=j;e:for(Ne=y.child;Ne!==null;){if(Ne.tag===5||Ne.tag===6)Z.appendChild(Ne.stateNode);else if(Ne.tag!==4&&Ne.tag!==27&&Ne.child!==null){Ne.child.return=Ne,Ne=Ne.child;continue}if(Ne===y)break e;for(;Ne.sibling===null;){if(Ne.return===null||Ne.return===y)break e;Ne=Ne.return}Ne.sibling.return=Ne.return,Ne=Ne.sibling}y.stateNode=Z;e:switch(ms(Z,G,j),G){case"button":case"input":case"select":case"textarea":j=!!j.autoFocus;break e;case"img":j=!0;break e;default:j=!1}j&&fu(y)}}return ya(y),Fj(y,y.type,h===null?null:h.memoizedProps,y.pendingProps,S),null;case 6:if(h&&y.stateNode!=null)h.memoizedProps!==j&&fu(y);else{if(typeof j!="string"&&y.stateNode===null)throw Error(r(166));if(h=B.current,sb(y)){if(h=y.stateNode,S=y.memoizedProps,j=null,G=cs,G!==null)switch(G.tag){case 27:case 5:j=G.memoizedProps}h[jt]=y,h=!!(h.nodeValue===S||j!==null&&j.suppressHydrationWarning===!0||l8(h.nodeValue,S)),h||Km(y,!0)}else h=z_(h).createTextNode(j),h[jt]=y,y.stateNode=h}return ya(y),null;case 31:if(S=y.memoizedState,h===null||h.memoizedState!==null){if(j=sb(y),S!==null){if(h===null){if(!j)throw Error(r(318));if(h=y.memoizedState,h=h!==null?h.dehydrated:null,!h)throw Error(r(557));h[jt]=y}else tf(),(y.flags&128)===0&&(y.memoizedState=null),y.flags|=4;ya(y),h=!1}else S=$A(),h!==null&&h.memoizedState!==null&&(h.memoizedState.hydrationErrors=S),h=!0;if(!h)return y.flags&256?(Yo(y),y):(Yo(y),null);if((y.flags&128)!==0)throw Error(r(558))}return ya(y),null;case 13:if(j=y.memoizedState,h===null||h.memoizedState!==null&&h.memoizedState.dehydrated!==null){if(G=sb(y),j!==null&&j.dehydrated!==null){if(h===null){if(!G)throw Error(r(318));if(G=y.memoizedState,G=G!==null?G.dehydrated:null,!G)throw Error(r(317));G[jt]=y}else tf(),(y.flags&128)===0&&(y.memoizedState=null),y.flags|=4;ya(y),G=!1}else G=$A(),h!==null&&h.memoizedState!==null&&(h.memoizedState.hydrationErrors=G),G=!0;if(!G)return y.flags&256?(Yo(y),y):(Yo(y),null)}return Yo(y),(y.flags&128)!==0?(y.lanes=S,y):(S=j!==null,h=h!==null&&h.memoizedState!==null,S&&(j=y.child,G=null,j.alternate!==null&&j.alternate.memoizedState!==null&&j.alternate.memoizedState.cachePool!==null&&(G=j.alternate.memoizedState.cachePool.pool),Z=null,j.memoizedState!==null&&j.memoizedState.cachePool!==null&&(Z=j.memoizedState.cachePool.pool),Z!==G&&(j.flags|=2048)),S!==h&&S&&(y.child.flags|=8192),k_(y,y.updateQueue),ya(y),null);case 4:return M(),h===null&&nM(y.stateNode.containerInfo),ya(y),null;case 10:return uu(y.type),ya(y),null;case 19:if(ae(ai),j=y.memoizedState,j===null)return ya(y),null;if(G=(y.flags&128)!==0,Z=j.rendering,Z===null)if(G)pv(j,!1);else{if($a!==0||h!==null&&(h.flags&128)!==0)for(h=y.child;h!==null;){if(Z=u_(h),Z!==null){for(y.flags|=128,pv(j,!1),h=Z.updateQueue,y.updateQueue=h,k_(y,h),y.subtreeFlags=0,h=S,S=y.child;S!==null;)U5(S,h),S=S.sibling;return me(ai,ai.current&1|2),Sr&&cu(y,j.treeForkCount),y.child}h=h.sibling}j.tail!==null&&R()>A_&&(y.flags|=128,G=!0,pv(j,!1),y.lanes=4194304)}else{if(!G)if(h=u_(Z),h!==null){if(y.flags|=128,G=!0,h=h.updateQueue,y.updateQueue=h,k_(y,h),pv(j,!0),j.tail===null&&j.tailMode==="hidden"&&!Z.alternate&&!Sr)return ya(y),null}else 2*R()-j.renderingStartTime>A_&&S!==536870912&&(y.flags|=128,G=!0,pv(j,!1),y.lanes=4194304);j.isBackwards?(Z.sibling=y.child,y.child=Z):(h=j.last,h!==null?h.sibling=Z:y.child=Z,j.last=Z)}return j.tail!==null?(h=j.tail,j.rendering=h,j.tail=h.sibling,j.renderingStartTime=R(),h.sibling=null,S=ai.current,me(ai,G?S&1|2:S&1),Sr&&cu(y,j.treeForkCount),h):(ya(y),null);case 22:case 23:return Yo(y),rj(),j=y.memoizedState!==null,h!==null?h.memoizedState!==null!==j&&(y.flags|=8192):j&&(y.flags|=8192),j?(S&536870912)!==0&&(y.flags&128)===0&&(ya(y),y.subtreeFlags&6&&(y.flags|=8192)):ya(y),S=y.updateQueue,S!==null&&k_(y,S.retryQueue),S=null,h!==null&&h.memoizedState!==null&&h.memoizedState.cachePool!==null&&(S=h.memoizedState.cachePool.pool),j=null,y.memoizedState!==null&&y.memoizedState.cachePool!==null&&(j=y.memoizedState.cachePool.pool),j!==S&&(y.flags|=2048),h!==null&&ae(af),null;case 24:return S=null,h!==null&&(S=h.memoizedState.cache),y.memoizedState.cache!==S&&(y.flags|=2048),uu(ui),ya(y),null;case 25:return null;case 30:return null}throw Error(r(156,y.tag))}function Jce(h,y){switch(HA(y),y.tag){case 1:return h=y.flags,h&65536?(y.flags=h&-65537|128,y):null;case 3:return uu(ui),M(),h=y.flags,(h&65536)!==0&&(h&128)===0?(y.flags=h&-65537|128,y):null;case 26:case 27:case 5:return Y(y),null;case 31:if(y.memoizedState!==null){if(Yo(y),y.alternate===null)throw Error(r(340));tf()}return h=y.flags,h&65536?(y.flags=h&-65537|128,y):null;case 13:if(Yo(y),h=y.memoizedState,h!==null&&h.dehydrated!==null){if(y.alternate===null)throw Error(r(340));tf()}return h=y.flags,h&65536?(y.flags=h&-65537|128,y):null;case 19:return ae(ai),null;case 4:return M(),null;case 10:return uu(y.type),null;case 22:case 23:return Yo(y),rj(),h!==null&&ae(af),h=y.flags,h&65536?(y.flags=h&-65537|128,y):null;case 24:return uu(ui),null;case 25:return null;default:return null}}function hB(h,y){switch(HA(y),y.tag){case 3:uu(ui),M();break;case 26:case 27:case 5:Y(y);break;case 4:M();break;case 31:y.memoizedState!==null&&Yo(y);break;case 13:Yo(y);break;case 19:ae(ai);break;case 10:uu(y.type);break;case 22:case 23:Yo(y),rj(),h!==null&&ae(af);break;case 24:uu(ui)}}function fv(h,y){try{var S=y.updateQueue,j=S!==null?S.lastEffect:null;if(j!==null){var G=j.next;S=G;do{if((S.tag&h)===h){j=void 0;var Z=S.create,Ne=S.inst;j=Z(),Ne.destroy=j}S=S.next}while(S!==G)}}catch(qe){$r(y,y.return,qe)}}function th(h,y,S){try{var j=y.updateQueue,G=j!==null?j.lastEffect:null;if(G!==null){var Z=G.next;j=Z;do{if((j.tag&h)===h){var Ne=j.inst,qe=Ne.destroy;if(qe!==void 0){Ne.destroy=void 0,G=y;var lt=S,vt=qe;try{vt()}catch(Rt){$r(G,lt,Rt)}}}j=j.next}while(j!==Z)}}catch(Rt){$r(y,y.return,Rt)}}function pB(h){var y=h.updateQueue;if(y!==null){var S=h.stateNode;try{aU(y,S)}catch(j){$r(h,h.return,j)}}}function fB(h,y,S){S.props=df(h.type,h.memoizedProps),S.state=h.memoizedState;try{S.componentWillUnmount()}catch(j){$r(h,y,j)}}function gv(h,y){try{var S=h.ref;if(S!==null){switch(h.tag){case 26:case 27:case 5:var j=h.stateNode;break;case 30:j=h.stateNode;break;default:j=h.stateNode}typeof S=="function"?h.refCleanup=S(j):S.current=j}}catch(G){$r(h,y,G)}}function cd(h,y){var S=h.ref,j=h.refCleanup;if(S!==null)if(typeof j=="function")try{j()}catch(G){$r(h,y,G)}finally{h.refCleanup=null,h=h.alternate,h!=null&&(h.refCleanup=null)}else if(typeof S=="function")try{S(null)}catch(G){$r(h,y,G)}else S.current=null}function gB(h){var y=h.type,S=h.memoizedProps,j=h.stateNode;try{e:switch(y){case"button":case"input":case"select":case"textarea":S.autoFocus&&j.focus();break e;case"img":S.src?j.src=S.src:S.srcSet&&(j.srcset=S.srcSet)}}catch(G){$r(h,h.return,G)}}function Rj(h,y,S){try{var j=h.stateNode;vde(j,h.type,S,y),j[Jt]=y}catch(G){$r(h,h.return,G)}}function bB(h){return h.tag===5||h.tag===3||h.tag===26||h.tag===27&&lh(h.type)||h.tag===4}function Lj(h){e:for(;;){for(;h.sibling===null;){if(h.return===null||bB(h.return))return null;h=h.return}for(h.sibling.return=h.return,h=h.sibling;h.tag!==5&&h.tag!==6&&h.tag!==18;){if(h.tag===27&&lh(h.type)||h.flags&2||h.child===null||h.tag===4)continue e;h.child.return=h,h=h.child}if(!(h.flags&2))return h.stateNode}}function Oj(h,y,S){var j=h.tag;if(j===5||j===6)h=h.stateNode,y?(S.nodeType===9?S.body:S.nodeName==="HTML"?S.ownerDocument.body:S).insertBefore(h,y):(y=S.nodeType===9?S.body:S.nodeName==="HTML"?S.ownerDocument.body:S,y.appendChild(h),S=S._reactRootContainer,S!=null||y.onclick!==null||(y.onclick=Wt));else if(j!==4&&(j===27&&lh(h.type)&&(S=h.stateNode,y=null),h=h.child,h!==null))for(Oj(h,y,S),h=h.sibling;h!==null;)Oj(h,y,S),h=h.sibling}function N_(h,y,S){var j=h.tag;if(j===5||j===6)h=h.stateNode,y?S.insertBefore(h,y):S.appendChild(h);else if(j!==4&&(j===27&&lh(h.type)&&(S=h.stateNode),h=h.child,h!==null))for(N_(h,y,S),h=h.sibling;h!==null;)N_(h,y,S),h=h.sibling}function xB(h){var y=h.stateNode,S=h.memoizedProps;try{for(var j=h.type,G=y.attributes;G.length;)y.removeAttributeNode(G[0]);ms(y,j,S),y[jt]=h,y[Jt]=S}catch(Z){$r(h,h.return,Z)}}var gu=!1,pi=!1,Ij=!1,yB=typeof WeakSet=="function"?WeakSet:Set,Hi=null;function Zce(h,y){if(h=h.containerInfo,iM=G_,h=YS(h),MA(h)){if("selectionStart"in h)var S={start:h.selectionStart,end:h.selectionEnd};else e:{S=(S=h.ownerDocument)&&S.defaultView||window;var j=S.getSelection&&S.getSelection();if(j&&j.rangeCount!==0){S=j.anchorNode;var G=j.anchorOffset,Z=j.focusNode;j=j.focusOffset;try{S.nodeType,Z.nodeType}catch{S=null;break e}var Ne=0,qe=-1,lt=-1,vt=0,Rt=0,Ut=h,St=null;t:for(;;){for(var At;Ut!==S||G!==0&&Ut.nodeType!==3||(qe=Ne+G),Ut!==Z||j!==0&&Ut.nodeType!==3||(lt=Ne+j),Ut.nodeType===3&&(Ne+=Ut.nodeValue.length),(At=Ut.firstChild)!==null;)St=Ut,Ut=At;for(;;){if(Ut===h)break t;if(St===S&&++vt===G&&(qe=Ne),St===Z&&++Rt===j&&(lt=Ne),(At=Ut.nextSibling)!==null)break;Ut=St,St=Ut.parentNode}Ut=At}S=qe===-1||lt===-1?null:{start:qe,end:lt}}else S=null}S=S||{start:0,end:0}}else S=null;for(sM={focusedElem:h,selectionRange:S},G_=!1,Hi=y;Hi!==null;)if(y=Hi,h=y.child,(y.subtreeFlags&1028)!==0&&h!==null)h.return=y,Hi=h;else for(;Hi!==null;){switch(y=Hi,Z=y.alternate,h=y.flags,y.tag){case 0:if((h&4)!==0&&(h=y.updateQueue,h=h!==null?h.events:null,h!==null))for(S=0;S title"))),ms(Z,j,S),Z[jt]=h,nt(Z),j=Z;break e;case"link":var Ne=N8("link","href",G).get(j+(S.href||""));if(Ne){for(var qe=0;qeQr&&(Ne=Qr,Qr=Ln,Ln=Ne);var gt=Qy(qe,Ln),pt=Qy(qe,Qr);if(gt&&pt&&(At.rangeCount!==1||At.anchorNode!==gt.node||At.anchorOffset!==gt.offset||At.focusNode!==pt.node||At.focusOffset!==pt.offset)){var yt=Ut.createRange();yt.setStart(gt.node,gt.offset),At.removeAllRanges(),Ln>Qr?(At.addRange(yt),At.extend(pt.node,pt.offset)):(yt.setEnd(pt.node,pt.offset),At.addRange(yt))}}}}for(Ut=[],At=qe;At=At.parentNode;)At.nodeType===1&&Ut.push({element:At,left:At.scrollLeft,top:At.scrollTop});for(typeof qe.focus=="function"&&qe.focus(),qe=0;qeS?32:S,re.T=null,S=Vj,Vj=null;var Z=ih,Ne=wu;if(Ni=0,vb=ih=null,wu=0,(Lr&6)!==0)throw Error(r(331));var qe=Lr;if(Lr|=4,jB(Z.current),PB(Z,Z.current,Ne,S),Lr=qe,Sv(0,!1),ze&&typeof ze.onPostCommitFiberRoot=="function")try{ze.onPostCommitFiberRoot(Ce,Z)}catch{}return!0}finally{ee.p=G,re.T=j,KB(h,y)}}function YB(h,y,S){y=Al(S,y),y=kj(h.stateNode,y,2),h=Jm(h,y,2),h!==null&&(rt(h,2),dd(h))}function $r(h,y,S){if(h.tag===3)YB(h,h,S);else for(;y!==null;){if(y.tag===3){YB(y,h,S);break}else if(y.tag===1){var j=y.stateNode;if(typeof y.type.getDerivedStateFromError=="function"||typeof j.componentDidCatch=="function"&&(ah===null||!ah.has(j))){h=Al(S,h),S=QU(2),j=Jm(y,S,2),j!==null&&(JU(S,j,y,h),rt(j,2),dd(j));break}}y=y.return}}function Xj(h,y,S){var j=h.pingCache;if(j===null){j=h.pingCache=new nde;var G=new Set;j.set(y,G)}else G=j.get(y),G===void 0&&(G=new Set,j.set(y,G));G.has(S)||(Bj=!0,G.add(S),h=ode.bind(null,h,y,S),y.then(h,h))}function ode(h,y,S){var j=h.pingCache;j!==null&&j.delete(y),h.pingedLanes|=h.suspendedLanes&S,h.warmLanes&=~S,oa===h&&(yr&S)===S&&($a===4||$a===3&&(yr&62914560)===yr&&300>R()-T_?(Lr&2)===0&&wb(h,0):Hj|=S,yb===yr&&(yb=0)),dd(h)}function QB(h,y){y===0&&(y=st()),h=Zp(h,y),h!==null&&(rt(h,y),dd(h))}function lde(h){var y=h.memoizedState,S=0;y!==null&&(S=y.retryLane),QB(h,S)}function cde(h,y){var S=0;switch(h.tag){case 31:case 13:var j=h.stateNode,G=h.memoizedState;G!==null&&(S=G.retryLane);break;case 19:j=h.stateNode;break;case 22:j=h.stateNode._retryCache;break;default:throw Error(r(314))}j!==null&&j.delete(y),QB(h,S)}function dde(h,y){return ge(h,y)}var R_=null,_b=null,Yj=!1,L_=!1,Qj=!1,oh=0;function dd(h){h!==_b&&h.next===null&&(_b===null?R_=_b=h:_b=_b.next=h),L_=!0,Yj||(Yj=!0,mde())}function Sv(h,y){if(!Qj&&L_){Qj=!0;do for(var S=!1,j=R_;j!==null;){if(h!==0){var G=j.pendingLanes;if(G===0)var Z=0;else{var Ne=j.suspendedLanes,qe=j.pingedLanes;Z=(1<<31-Ie(42|h)+1)-1,Z&=G&~(Ne&~qe),Z=Z&201326741?Z&201326741|1:Z?Z|2:0}Z!==0&&(S=!0,t8(j,Z))}else Z=yr,Z=de(j,j===oa?Z:0,j.cancelPendingCommit!==null||j.timeoutHandle!==-1),(Z&3)===0||Se(j,Z)||(S=!0,t8(j,Z));j=j.next}while(S);Qj=!1}}function ude(){JB()}function JB(){L_=Yj=!1;var h=0;oh!==0&&Sde()&&(h=oh);for(var y=R(),S=null,j=R_;j!==null;){var G=j.next,Z=ZB(j,y);Z===0?(j.next=null,S===null?R_=G:S.next=G,G===null&&(_b=S)):(S=j,(h!==0||(Z&3)!==0)&&(L_=!0)),j=G}Ni!==0&&Ni!==5||Sv(h),oh!==0&&(oh=0)}function ZB(h,y){for(var S=h.suspendedLanes,j=h.pingedLanes,G=h.expirationTimes,Z=h.pendingLanes&-62914561;0qe)break;var Rt=lt.transferSize,Ut=lt.initiatorType;Rt&&c8(Ut)&&(lt=lt.responseEnd,Ne+=Rt*(lt"u"?null:document;function w8(h,y,S){var j=kb;if(j&&typeof y=="string"&&y){var G=In(y);G='link[rel="'+h+'"][href="'+G+'"]',typeof S=="string"&&(G+='[crossorigin="'+S+'"]'),v8.has(G)||(v8.add(G),h={rel:h,crossOrigin:S,href:y},j.querySelector(G)===null&&(y=j.createElement("link"),ms(y,"link",h),nt(y),j.head.appendChild(y)))}}function Mde(h){Su.D(h),w8("dns-prefetch",h,null)}function Ede(h,y){Su.C(h,y),w8("preconnect",h,y)}function Dde(h,y,S){Su.L(h,y,S);var j=kb;if(j&&h&&y){var G='link[rel="preload"][as="'+In(y)+'"]';y==="image"&&S&&S.imageSrcSet?(G+='[imagesrcset="'+In(S.imageSrcSet)+'"]',typeof S.imageSizes=="string"&&(G+='[imagesizes="'+In(S.imageSizes)+'"]')):G+='[href="'+In(h)+'"]';var Z=G;switch(y){case"style":Z=Nb(h);break;case"script":Z=Cb(h)}Rl.has(Z)||(h=m({rel:"preload",href:y==="image"&&S&&S.imageSrcSet?void 0:h,as:y},S),Rl.set(Z,h),j.querySelector(G)!==null||y==="style"&&j.querySelector(Cv(Z))||y==="script"&&j.querySelector(Pv(Z))||(y=j.createElement("link"),ms(y,"link",h),nt(y),j.head.appendChild(y)))}}function Fde(h,y){Su.m(h,y);var S=kb;if(S&&h){var j=y&&typeof y.as=="string"?y.as:"script",G='link[rel="modulepreload"][as="'+In(j)+'"][href="'+In(h)+'"]',Z=G;switch(j){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":Z=Cb(h)}if(!Rl.has(Z)&&(h=m({rel:"modulepreload",href:h},y),Rl.set(Z,h),S.querySelector(G)===null)){switch(j){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(S.querySelector(Pv(Z)))return}j=S.createElement("link"),ms(j,"link",h),nt(j),S.head.appendChild(j)}}}function Rde(h,y,S){Su.S(h,y,S);var j=kb;if(j&&h){var G=tt(j).hoistableStyles,Z=Nb(h);y=y||"default";var Ne=G.get(Z);if(!Ne){var qe={loading:0,preload:null};if(Ne=j.querySelector(Cv(Z)))qe.loading=5;else{h=m({rel:"stylesheet",href:h,"data-precedence":y},S),(S=Rl.get(Z))&&hM(h,S);var lt=Ne=j.createElement("link");nt(lt),ms(lt,"link",h),lt._p=new Promise(function(vt,Rt){lt.onload=vt,lt.onerror=Rt}),lt.addEventListener("load",function(){qe.loading|=1}),lt.addEventListener("error",function(){qe.loading|=2}),qe.loading|=4,B_(Ne,y,j)}Ne={type:"stylesheet",instance:Ne,count:1,state:qe},G.set(Z,Ne)}}}function Lde(h,y){Su.X(h,y);var S=kb;if(S&&h){var j=tt(S).hoistableScripts,G=Cb(h),Z=j.get(G);Z||(Z=S.querySelector(Pv(G)),Z||(h=m({src:h,async:!0},y),(y=Rl.get(G))&&pM(h,y),Z=S.createElement("script"),nt(Z),ms(Z,"link",h),S.head.appendChild(Z)),Z={type:"script",instance:Z,count:1,state:null},j.set(G,Z))}}function Ode(h,y){Su.M(h,y);var S=kb;if(S&&h){var j=tt(S).hoistableScripts,G=Cb(h),Z=j.get(G);Z||(Z=S.querySelector(Pv(G)),Z||(h=m({src:h,async:!0,type:"module"},y),(y=Rl.get(G))&&pM(h,y),Z=S.createElement("script"),nt(Z),ms(Z,"link",h),S.head.appendChild(Z)),Z={type:"script",instance:Z,count:1,state:null},j.set(G,Z))}}function S8(h,y,S,j){var G=(G=B.current)?U_(G):null;if(!G)throw Error(r(446));switch(h){case"meta":case"title":return null;case"style":return typeof S.precedence=="string"&&typeof S.href=="string"?(y=Nb(S.href),S=tt(G).hoistableStyles,j=S.get(y),j||(j={type:"style",instance:null,count:0,state:null},S.set(y,j)),j):{type:"void",instance:null,count:0,state:null};case"link":if(S.rel==="stylesheet"&&typeof S.href=="string"&&typeof S.precedence=="string"){h=Nb(S.href);var Z=tt(G).hoistableStyles,Ne=Z.get(h);if(Ne||(G=G.ownerDocument||G,Ne={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},Z.set(h,Ne),(Z=G.querySelector(Cv(h)))&&!Z._p&&(Ne.instance=Z,Ne.state.loading=5),Rl.has(h)||(S={rel:"preload",as:"style",href:S.href,crossOrigin:S.crossOrigin,integrity:S.integrity,media:S.media,hrefLang:S.hrefLang,referrerPolicy:S.referrerPolicy},Rl.set(h,S),Z||Ide(G,h,S,Ne.state))),y&&j===null)throw Error(r(528,""));return Ne}if(y&&j!==null)throw Error(r(529,""));return null;case"script":return y=S.async,S=S.src,typeof S=="string"&&y&&typeof y!="function"&&typeof y!="symbol"?(y=Cb(S),S=tt(G).hoistableScripts,j=S.get(y),j||(j={type:"script",instance:null,count:0,state:null},S.set(y,j)),j):{type:"void",instance:null,count:0,state:null};default:throw Error(r(444,h))}}function Nb(h){return'href="'+In(h)+'"'}function Cv(h){return'link[rel="stylesheet"]['+h+"]"}function _8(h){return m({},h,{"data-precedence":h.precedence,precedence:null})}function Ide(h,y,S,j){h.querySelector('link[rel="preload"][as="style"]['+y+"]")?j.loading=1:(y=h.createElement("link"),j.preload=y,y.addEventListener("load",function(){return j.loading|=1}),y.addEventListener("error",function(){return j.loading|=2}),ms(y,"link",S),nt(y),h.head.appendChild(y))}function Cb(h){return'[src="'+In(h)+'"]'}function Pv(h){return"script[async]"+h}function k8(h,y,S){if(y.count++,y.instance===null)switch(y.type){case"style":var j=h.querySelector('style[data-href~="'+In(S.href)+'"]');if(j)return y.instance=j,nt(j),j;var G=m({},S,{"data-href":S.href,"data-precedence":S.precedence,href:null,precedence:null});return j=(h.ownerDocument||h).createElement("style"),nt(j),ms(j,"style",G),B_(j,S.precedence,h),y.instance=j;case"stylesheet":G=Nb(S.href);var Z=h.querySelector(Cv(G));if(Z)return y.state.loading|=4,y.instance=Z,nt(Z),Z;j=_8(S),(G=Rl.get(G))&&hM(j,G),Z=(h.ownerDocument||h).createElement("link"),nt(Z);var Ne=Z;return Ne._p=new Promise(function(qe,lt){Ne.onload=qe,Ne.onerror=lt}),ms(Z,"link",j),y.state.loading|=4,B_(Z,S.precedence,h),y.instance=Z;case"script":return Z=Cb(S.src),(G=h.querySelector(Pv(Z)))?(y.instance=G,nt(G),G):(j=S,(G=Rl.get(Z))&&(j=m({},S),pM(j,G)),h=h.ownerDocument||h,G=h.createElement("script"),nt(G),ms(G,"link",j),h.head.appendChild(G),y.instance=G);case"void":return null;default:throw Error(r(443,y.type))}else y.type==="stylesheet"&&(y.state.loading&4)===0&&(j=y.instance,y.state.loading|=4,B_(j,S.precedence,h));return y.instance}function B_(h,y,S){for(var j=S.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),G=j.length?j[j.length-1]:null,Z=G,Ne=0;Ne title"):null)}function zde(h,y,S){if(S===1||y.itemProp!=null)return!1;switch(h){case"meta":case"title":return!0;case"style":if(typeof y.precedence!="string"||typeof y.href!="string"||y.href==="")break;return!0;case"link":if(typeof y.rel!="string"||typeof y.href!="string"||y.href===""||y.onLoad||y.onError)break;return y.rel==="stylesheet"?(h=y.disabled,typeof y.precedence=="string"&&h==null):!0;case"script":if(y.async&&typeof y.async!="function"&&typeof y.async!="symbol"&&!y.onLoad&&!y.onError&&y.src&&typeof y.src=="string")return!0}return!1}function P8(h){return!(h.type==="stylesheet"&&(h.state.loading&3)===0)}function Ude(h,y,S,j){if(S.type==="stylesheet"&&(typeof j.media!="string"||matchMedia(j.media).matches!==!1)&&(S.state.loading&4)===0){if(S.instance===null){var G=Nb(j.href),Z=y.querySelector(Cv(G));if(Z){y=Z._p,y!==null&&typeof y=="object"&&typeof y.then=="function"&&(h.count++,h=q_.bind(h),y.then(h,h)),S.state.loading|=4,S.instance=Z,nt(Z);return}Z=y.ownerDocument||y,j=_8(j),(G=Rl.get(G))&&hM(j,G),Z=Z.createElement("link"),nt(Z);var Ne=Z;Ne._p=new Promise(function(qe,lt){Ne.onload=qe,Ne.onerror=lt}),ms(Z,"link",j),S.instance=Z}h.stylesheets===null&&(h.stylesheets=new Map),h.stylesheets.set(S,y),(y=S.state.preload)&&(S.state.loading&3)===0&&(h.count++,S=q_.bind(h),y.addEventListener("load",S),y.addEventListener("error",S))}}var fM=0;function Bde(h,y){return h.stylesheets&&h.count===0&&V_(h,h.stylesheets),0fM?50:800)+y);return h.unsuspend=S,function(){h.unsuspend=null,clearTimeout(j),clearTimeout(G)}}:null}function q_(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)V_(this,this.stylesheets);else if(this.unsuspend){var h=this.unsuspend;this.unsuspend=null,h()}}}var $_=null;function V_(h,y){h.stylesheets=null,h.unsuspend!==null&&(h.count++,$_=new Map,y.forEach(Hde,h),$_=null,q_.call(h))}function Hde(h,y){if(!(y.state.loading&4)){var S=$_.get(h);if(S)var j=S.get(null);else{S=new Map,$_.set(h,S);for(var G=h.querySelectorAll("link[data-precedence],style[data-precedence]"),Z=0;Z"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),kM.exports=iue(),kM.exports}var oue=sue();const Bn=t=>typeof t=="string",Fv=()=>{let t,e;const n=new Promise((r,i)=>{t=r,e=i});return n.resolve=t,n.reject=e,n},Q8=t=>t==null?"":""+t,lue=(t,e,n)=>{t.forEach(r=>{e[r]&&(n[r]=e[r])})},cue=/###/g,J8=t=>t&&t.indexOf("###")>-1?t.replace(cue,"."):t,Z8=t=>!t||Bn(t),M0=(t,e,n)=>{const r=Bn(e)?e.split("."):e;let i=0;for(;i{const{obj:r,k:i}=M0(t,e,Object);if(r!==void 0||e.length===1){r[i]=n;return}let s=e[e.length-1],o=e.slice(0,e.length-1),l=M0(t,o,Object);for(;l.obj===void 0&&o.length;)s=`${o[o.length-1]}.${s}`,o=o.slice(0,o.length-1),l=M0(t,o,Object),l?.obj&&typeof l.obj[`${l.k}.${s}`]<"u"&&(l.obj=void 0);l.obj[`${l.k}.${s}`]=n},due=(t,e,n,r)=>{const{obj:i,k:s}=M0(t,e,Object);i[s]=i[s]||[],i[s].push(n)},xN=(t,e)=>{const{obj:n,k:r}=M0(t,e);if(n&&Object.prototype.hasOwnProperty.call(n,r))return n[r]},uue=(t,e,n)=>{const r=xN(t,n);return r!==void 0?r:xN(e,n)},rQ=(t,e,n)=>{for(const r in e)r!=="__proto__"&&r!=="constructor"&&(r in t?Bn(t[r])||t[r]instanceof String||Bn(e[r])||e[r]instanceof String?n&&(t[r]=e[r]):rQ(t[r],e[r],n):t[r]=e[r]);return t},Tb=t=>t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&");var mue={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};const hue=t=>Bn(t)?t.replace(/[&<>"'\/]/g,e=>mue[e]):t;class pue{constructor(e){this.capacity=e,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(e){const n=this.regExpMap.get(e);if(n!==void 0)return n;const r=new RegExp(e);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(e,r),this.regExpQueue.push(e),r}}const fue=[" ",",","?","!",";"],gue=new pue(20),bue=(t,e,n)=>{e=e||"",n=n||"";const r=fue.filter(o=>e.indexOf(o)<0&&n.indexOf(o)<0);if(r.length===0)return!0;const i=gue.getRegExp(`(${r.map(o=>o==="?"?"\\?":o).join("|")})`);let s=!i.test(t);if(!s){const o=t.indexOf(n);o>0&&!i.test(t.substring(0,o))&&(s=!0)}return s},eF=(t,e,n=".")=>{if(!t)return;if(t[e])return Object.prototype.hasOwnProperty.call(t,e)?t[e]:void 0;const r=e.split(n);let i=t;for(let s=0;s-1&&ct?.replace("_","-"),xue={type:"logger",log(t){this.output("log",t)},warn(t){this.output("warn",t)},error(t){this.output("error",t)},output(t,e){console?.[t]?.apply?.(console,e)}};class yN{constructor(e,n={}){this.init(e,n)}init(e,n={}){this.prefix=n.prefix||"i18next:",this.logger=e||xue,this.options=n,this.debug=n.debug}log(...e){return this.forward(e,"log","",!0)}warn(...e){return this.forward(e,"warn","",!0)}error(...e){return this.forward(e,"error","")}deprecate(...e){return this.forward(e,"warn","WARNING DEPRECATED: ",!0)}forward(e,n,r,i){return i&&!this.debug?null:(Bn(e[0])&&(e[0]=`${r}${this.prefix} ${e[0]}`),this.logger[n](e))}create(e){return new yN(this.logger,{prefix:`${this.prefix}:${e}:`,...this.options})}clone(e){return e=e||this.options,e.prefix=e.prefix||this.prefix,new yN(this.logger,e)}}var Ad=new yN;let GP=class{constructor(){this.observers={}}on(e,n){return e.split(" ").forEach(r=>{this.observers[r]||(this.observers[r]=new Map);const i=this.observers[r].get(n)||0;this.observers[r].set(n,i+1)}),this}off(e,n){if(this.observers[e]){if(!n){delete this.observers[e];return}this.observers[e].delete(n)}}emit(e,...n){this.observers[e]&&Array.from(this.observers[e].entries()).forEach(([i,s])=>{for(let o=0;o{for(let o=0;o-1&&this.options.ns.splice(n,1)}getResource(e,n,r,i={}){const s=i.keySeparator!==void 0?i.keySeparator:this.options.keySeparator,o=i.ignoreJSONStructure!==void 0?i.ignoreJSONStructure:this.options.ignoreJSONStructure;let l;e.indexOf(".")>-1?l=e.split("."):(l=[e,n],r&&(Array.isArray(r)?l.push(...r):Bn(r)&&s?l.push(...r.split(s)):l.push(r)));const c=xN(this.data,l);return!c&&!n&&!r&&e.indexOf(".")>-1&&(e=l[0],n=l[1],r=l.slice(2).join(".")),c||!o||!Bn(r)?c:eF(this.data?.[e]?.[n],r,s)}addResource(e,n,r,i,s={silent:!1}){const o=s.keySeparator!==void 0?s.keySeparator:this.options.keySeparator;let l=[e,n];r&&(l=l.concat(o?r.split(o):r)),e.indexOf(".")>-1&&(l=e.split("."),i=n,n=l[1]),this.addNamespaces(n),eH(this.data,l,i),s.silent||this.emit("added",e,n,r,i)}addResources(e,n,r,i={silent:!1}){for(const s in r)(Bn(r[s])||Array.isArray(r[s]))&&this.addResource(e,n,s,r[s],{silent:!0});i.silent||this.emit("added",e,n,r)}addResourceBundle(e,n,r,i,s,o={silent:!1,skipCopy:!1}){let l=[e,n];e.indexOf(".")>-1&&(l=e.split("."),i=r,r=n,n=l[1]),this.addNamespaces(n);let c=xN(this.data,l)||{};o.skipCopy||(r=JSON.parse(JSON.stringify(r))),i?rQ(c,r,s):c={...c,...r},eH(this.data,l,c),o.silent||this.emit("added",e,n,r)}removeResourceBundle(e,n){this.hasResourceBundle(e,n)&&delete this.data[e][n],this.removeNamespaces(n),this.emit("removed",e,n)}hasResourceBundle(e,n){return this.getResource(e,n)!==void 0}getResourceBundle(e,n){return n||(n=this.options.defaultNS),this.getResource(e,n)}getDataByLanguage(e){return this.data[e]}hasLanguageSomeTranslations(e){const n=this.getDataByLanguage(e);return!!(n&&Object.keys(n)||[]).find(i=>n[i]&&Object.keys(n[i]).length>0)}toJSON(){return this.data}}var aQ={processors:{},addPostProcessor(t){this.processors[t.name]=t},handle(t,e,n,r,i){return t.forEach(s=>{e=this.processors[s]?.process(e,n,r,i)??e}),e}};const iQ=Symbol("i18next/PATH_KEY");function yue(){const t=[],e=Object.create(null);let n;return e.get=(r,i)=>(n?.revoke?.(),i===iQ?t:(t.push(i),n=Proxy.revocable(r,e),n.proxy)),Proxy.revocable(Object.create(null),e).proxy}function tF(t,e){const{[iQ]:n}=t(yue());return n.join(e?.keySeparator??".")}const nH={},TM=t=>!Bn(t)&&typeof t!="boolean"&&typeof t!="number";class vN extends GP{constructor(e,n={}){super(),lue(["resourceStore","languageUtils","pluralResolver","interpolator","backendConnector","i18nFormat","utils"],e,this),this.options=n,this.options.keySeparator===void 0&&(this.options.keySeparator="."),this.logger=Ad.create("translator")}changeLanguage(e){e&&(this.language=e)}exists(e,n={interpolation:{}}){const r={...n};if(e==null)return!1;const i=this.resolve(e,r);if(i?.res===void 0)return!1;const s=TM(i.res);return!(r.returnObjects===!1&&s)}extractFromKey(e,n){let r=n.nsSeparator!==void 0?n.nsSeparator:this.options.nsSeparator;r===void 0&&(r=":");const i=n.keySeparator!==void 0?n.keySeparator:this.options.keySeparator;let s=n.ns||this.options.defaultNS||[];const o=r&&e.indexOf(r)>-1,l=!this.options.userDefinedKeySeparator&&!n.keySeparator&&!this.options.userDefinedNsSeparator&&!n.nsSeparator&&!bue(e,r,i);if(o&&!l){const c=e.match(this.interpolator.nestingRegexp);if(c&&c.length>0)return{key:e,namespaces:Bn(s)?[s]:s};const d=e.split(r);(r!==i||r===i&&this.options.ns.indexOf(d[0])>-1)&&(s=d.shift()),e=d.join(i)}return{key:e,namespaces:Bn(s)?[s]:s}}translate(e,n,r){let i=typeof n=="object"?{...n}:n;if(typeof i!="object"&&this.options.overloadTranslationOptionHandler&&(i=this.options.overloadTranslationOptionHandler(arguments)),typeof i=="object"&&(i={...i}),i||(i={}),e==null)return"";typeof e=="function"&&(e=tF(e,{...this.options,...i})),Array.isArray(e)||(e=[String(e)]);const s=i.returnDetails!==void 0?i.returnDetails:this.options.returnDetails,o=i.keySeparator!==void 0?i.keySeparator:this.options.keySeparator,{key:l,namespaces:c}=this.extractFromKey(e[e.length-1],i),d=c[c.length-1];let u=i.nsSeparator!==void 0?i.nsSeparator:this.options.nsSeparator;u===void 0&&(u=":");const m=i.lng||this.language,p=i.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if(m?.toLowerCase()==="cimode")return p?s?{res:`${d}${u}${l}`,usedKey:l,exactUsedKey:l,usedLng:m,usedNS:d,usedParams:this.getUsedParamsDetails(i)}:`${d}${u}${l}`:s?{res:l,usedKey:l,exactUsedKey:l,usedLng:m,usedNS:d,usedParams:this.getUsedParamsDetails(i)}:l;const f=this.resolve(e,i);let x=f?.res;const v=f?.usedKey||l,b=f?.exactUsedKey||l,g=["[object Number]","[object Function]","[object RegExp]"],_=i.joinArrays!==void 0?i.joinArrays:this.options.joinArrays,N=!this.i18nFormat||this.i18nFormat.handleAsObject,P=i.count!==void 0&&!Bn(i.count),C=vN.hasDefaultValue(i),A=P?this.pluralResolver.getSuffix(m,i.count,i):"",T=i.ordinal&&P?this.pluralResolver.getSuffix(m,i.count,{ordinal:!1}):"",F=P&&!i.ordinal&&i.count===0,k=F&&i[`defaultValue${this.options.pluralSeparator}zero`]||i[`defaultValue${A}`]||i[`defaultValue${T}`]||i.defaultValue;let E=x;N&&!x&&C&&(E=k);const $=TM(E),z=Object.prototype.toString.apply(E);if(N&&E&&$&&g.indexOf(z)<0&&!(Bn(_)&&Array.isArray(E))){if(!i.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn("accessing an object - but returnObjects options is not enabled!");const X=this.options.returnedObjectHandler?this.options.returnedObjectHandler(v,E,{...i,ns:c}):`key '${l} (${this.language})' returned an object instead of string.`;return s?(f.res=X,f.usedParams=this.getUsedParamsDetails(i),f):X}if(o){const X=Array.isArray(E),L=X?[]:{},ne=X?b:v;for(const re in E)if(Object.prototype.hasOwnProperty.call(E,re)){const ee=`${ne}${o}${re}`;C&&!x?L[re]=this.translate(ee,{...i,defaultValue:TM(k)?k[re]:void 0,joinArrays:!1,ns:c}):L[re]=this.translate(ee,{...i,joinArrays:!1,ns:c}),L[re]===ee&&(L[re]=E[re])}x=L}}else if(N&&Bn(_)&&Array.isArray(x))x=x.join(_),x&&(x=this.extendTranslation(x,e,i,r));else{let X=!1,L=!1;!this.isValidLookup(x)&&C&&(X=!0,x=k),this.isValidLookup(x)||(L=!0,x=l);const re=(i.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&L?void 0:x,ee=C&&k!==x&&this.options.updateMissing;if(L||X||ee){if(this.logger.log(ee?"updateKey":"missingKey",m,d,l,ee?k:x),o){const K=this.resolve(l,{...i,keySeparator:!1});K&&K.res&&this.logger.warn("Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.")}let ue=[];const se=this.languageUtils.getFallbackCodes(this.options.fallbackLng,i.lng||this.language);if(this.options.saveMissingTo==="fallback"&&se&&se[0])for(let K=0;K{const xe=C&&me!==x?me:re;this.options.missingKeyHandler?this.options.missingKeyHandler(K,d,ae,xe,ee,i):this.backendConnector?.saveMissing&&this.backendConnector.saveMissing(K,d,ae,xe,ee,i),this.emit("missingKey",K,d,ae,x)};this.options.saveMissing&&(this.options.saveMissingPlurals&&P?ue.forEach(K=>{const ae=this.pluralResolver.getSuffixes(K,i);F&&i[`defaultValue${this.options.pluralSeparator}zero`]&&ae.indexOf(`${this.options.pluralSeparator}zero`)<0&&ae.push(`${this.options.pluralSeparator}zero`),ae.forEach(me=>{oe([K],l+me,i[`defaultValue${me}`]||k)})}):oe(ue,l,k))}x=this.extendTranslation(x,e,i,f,r),L&&x===l&&this.options.appendNamespaceToMissingKey&&(x=`${d}${u}${l}`),(L||X)&&this.options.parseMissingKeyHandler&&(x=this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${d}${u}${l}`:l,X?x:void 0,i))}return s?(f.res=x,f.usedParams=this.getUsedParamsDetails(i),f):x}extendTranslation(e,n,r,i,s){if(this.i18nFormat?.parse)e=this.i18nFormat.parse(e,{...this.options.interpolation.defaultVariables,...r},r.lng||this.language||i.usedLng,i.usedNS,i.usedKey,{resolved:i});else if(!r.skipInterpolation){r.interpolation&&this.interpolator.init({...r,interpolation:{...this.options.interpolation,...r.interpolation}});const c=Bn(e)&&(r?.interpolation?.skipOnVariables!==void 0?r.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables);let d;if(c){const m=e.match(this.interpolator.nestingRegexp);d=m&&m.length}let u=r.replace&&!Bn(r.replace)?r.replace:r;if(this.options.interpolation.defaultVariables&&(u={...this.options.interpolation.defaultVariables,...u}),e=this.interpolator.interpolate(e,u,r.lng||this.language||i.usedLng,r),c){const m=e.match(this.interpolator.nestingRegexp),p=m&&m.length;ds?.[0]===m[0]&&!r.context?(this.logger.warn(`It seems you are nesting recursively key: ${m[0]} in key: ${n[0]}`),null):this.translate(...m,n),r)),r.interpolation&&this.interpolator.reset()}const o=r.postProcess||this.options.postProcess,l=Bn(o)?[o]:o;return e!=null&&l?.length&&r.applyPostProcessor!==!1&&(e=aQ.handle(l,e,n,this.options&&this.options.postProcessPassResolved?{i18nResolved:{...i,usedParams:this.getUsedParamsDetails(r)},...r}:r,this)),e}resolve(e,n={}){let r,i,s,o,l;return Bn(e)&&(e=[e]),e.forEach(c=>{if(this.isValidLookup(r))return;const d=this.extractFromKey(c,n),u=d.key;i=u;let m=d.namespaces;this.options.fallbackNS&&(m=m.concat(this.options.fallbackNS));const p=n.count!==void 0&&!Bn(n.count),f=p&&!n.ordinal&&n.count===0,x=n.context!==void 0&&(Bn(n.context)||typeof n.context=="number")&&n.context!=="",v=n.lngs?n.lngs:this.languageUtils.toResolveHierarchy(n.lng||this.language,n.fallbackLng);m.forEach(b=>{this.isValidLookup(r)||(l=b,!nH[`${v[0]}-${b}`]&&this.utils?.hasLoadedNamespace&&!this.utils?.hasLoadedNamespace(l)&&(nH[`${v[0]}-${b}`]=!0,this.logger.warn(`key "${i}" for languages "${v.join(", ")}" won't get resolved as namespace "${l}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!")),v.forEach(g=>{if(this.isValidLookup(r))return;o=g;const _=[u];if(this.i18nFormat?.addLookupKeys)this.i18nFormat.addLookupKeys(_,u,g,b,n);else{let P;p&&(P=this.pluralResolver.getSuffix(g,n.count,n));const C=`${this.options.pluralSeparator}zero`,A=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(p&&(n.ordinal&&P.indexOf(A)===0&&_.push(u+P.replace(A,this.options.pluralSeparator)),_.push(u+P),f&&_.push(u+C)),x){const T=`${u}${this.options.contextSeparator||"_"}${n.context}`;_.push(T),p&&(n.ordinal&&P.indexOf(A)===0&&_.push(T+P.replace(A,this.options.pluralSeparator)),_.push(T+P),f&&_.push(T+C))}}let N;for(;N=_.pop();)this.isValidLookup(r)||(s=N,r=this.getResource(g,b,N,n))}))})}),{res:r,usedKey:i,exactUsedKey:s,usedLng:o,usedNS:l}}isValidLookup(e){return e!==void 0&&!(!this.options.returnNull&&e===null)&&!(!this.options.returnEmptyString&&e==="")}getResource(e,n,r,i={}){return this.i18nFormat?.getResource?this.i18nFormat.getResource(e,n,r,i):this.resourceStore.getResource(e,n,r,i)}getUsedParamsDetails(e={}){const n=["defaultValue","ordinal","context","replace","lng","lngs","fallbackLng","ns","keySeparator","nsSeparator","returnObjects","returnDetails","joinArrays","postProcess","interpolation"],r=e.replace&&!Bn(e.replace);let i=r?e.replace:e;if(r&&typeof e.count<"u"&&(i.count=e.count),this.options.interpolation.defaultVariables&&(i={...this.options.interpolation.defaultVariables,...i}),!r){i={...i};for(const s of n)delete i[s]}return i}static hasDefaultValue(e){const n="defaultValue";for(const r in e)if(Object.prototype.hasOwnProperty.call(e,r)&&n===r.substring(0,n.length)&&e[r]!==void 0)return!0;return!1}}class rH{constructor(e){this.options=e,this.supportedLngs=this.options.supportedLngs||!1,this.logger=Ad.create("languageUtils")}getScriptPartFromCode(e){if(e=iw(e),!e||e.indexOf("-")<0)return null;const n=e.split("-");return n.length===2||(n.pop(),n[n.length-1].toLowerCase()==="x")?null:this.formatLanguageCode(n.join("-"))}getLanguagePartFromCode(e){if(e=iw(e),!e||e.indexOf("-")<0)return e;const n=e.split("-");return this.formatLanguageCode(n[0])}formatLanguageCode(e){if(Bn(e)&&e.indexOf("-")>-1){let n;try{n=Intl.getCanonicalLocales(e)[0]}catch{}return n&&this.options.lowerCaseLng&&(n=n.toLowerCase()),n||(this.options.lowerCaseLng?e.toLowerCase():e)}return this.options.cleanCode||this.options.lowerCaseLng?e.toLowerCase():e}isSupportedCode(e){return(this.options.load==="languageOnly"||this.options.nonExplicitSupportedLngs)&&(e=this.getLanguagePartFromCode(e)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.indexOf(e)>-1}getBestMatchFromCodes(e){if(!e)return null;let n;return e.forEach(r=>{if(n)return;const i=this.formatLanguageCode(r);(!this.options.supportedLngs||this.isSupportedCode(i))&&(n=i)}),!n&&this.options.supportedLngs&&e.forEach(r=>{if(n)return;const i=this.getScriptPartFromCode(r);if(this.isSupportedCode(i))return n=i;const s=this.getLanguagePartFromCode(r);if(this.isSupportedCode(s))return n=s;n=this.options.supportedLngs.find(o=>{if(o===s)return o;if(!(o.indexOf("-")<0&&s.indexOf("-")<0)&&(o.indexOf("-")>0&&s.indexOf("-")<0&&o.substring(0,o.indexOf("-"))===s||o.indexOf(s)===0&&s.length>1))return o})}),n||(n=this.getFallbackCodes(this.options.fallbackLng)[0]),n}getFallbackCodes(e,n){if(!e)return[];if(typeof e=="function"&&(e=e(n)),Bn(e)&&(e=[e]),Array.isArray(e))return e;if(!n)return e.default||[];let r=e[n];return r||(r=e[this.getScriptPartFromCode(n)]),r||(r=e[this.formatLanguageCode(n)]),r||(r=e[this.getLanguagePartFromCode(n)]),r||(r=e.default),r||[]}toResolveHierarchy(e,n){const r=this.getFallbackCodes((n===!1?[]:n)||this.options.fallbackLng||[],e),i=[],s=o=>{o&&(this.isSupportedCode(o)?i.push(o):this.logger.warn(`rejecting language code not found in supportedLngs: ${o}`))};return Bn(e)&&(e.indexOf("-")>-1||e.indexOf("_")>-1)?(this.options.load!=="languageOnly"&&s(this.formatLanguageCode(e)),this.options.load!=="languageOnly"&&this.options.load!=="currentOnly"&&s(this.getScriptPartFromCode(e)),this.options.load!=="currentOnly"&&s(this.getLanguagePartFromCode(e))):Bn(e)&&s(this.formatLanguageCode(e)),r.forEach(o=>{i.indexOf(o)<0&&s(this.formatLanguageCode(o))}),i}}const aH={zero:0,one:1,two:2,few:3,many:4,other:5},iH={select:t=>t===1?"one":"other",resolvedOptions:()=>({pluralCategories:["one","other"]})};class vue{constructor(e,n={}){this.languageUtils=e,this.options=n,this.logger=Ad.create("pluralResolver"),this.pluralRulesCache={}}addRule(e,n){this.rules[e]=n}clearCache(){this.pluralRulesCache={}}getRule(e,n={}){const r=iw(e==="dev"?"en":e),i=n.ordinal?"ordinal":"cardinal",s=JSON.stringify({cleanedCode:r,type:i});if(s in this.pluralRulesCache)return this.pluralRulesCache[s];let o;try{o=new Intl.PluralRules(r,{type:i})}catch{if(!Intl)return this.logger.error("No Intl support, please use an Intl polyfill!"),iH;if(!e.match(/-|_/))return iH;const c=this.languageUtils.getLanguagePartFromCode(e);o=this.getRule(c,n)}return this.pluralRulesCache[s]=o,o}needsPlural(e,n={}){let r=this.getRule(e,n);return r||(r=this.getRule("dev",n)),r?.resolvedOptions().pluralCategories.length>1}getPluralFormsOfKey(e,n,r={}){return this.getSuffixes(e,r).map(i=>`${n}${i}`)}getSuffixes(e,n={}){let r=this.getRule(e,n);return r||(r=this.getRule("dev",n)),r?r.resolvedOptions().pluralCategories.sort((i,s)=>aH[i]-aH[s]).map(i=>`${this.options.prepend}${n.ordinal?`ordinal${this.options.prepend}`:""}${i}`):[]}getSuffix(e,n,r={}){const i=this.getRule(e,r);return i?`${this.options.prepend}${r.ordinal?`ordinal${this.options.prepend}`:""}${i.select(n)}`:(this.logger.warn(`no plural rule found for: ${e}`),this.getSuffix("dev",n,r))}}const sH=(t,e,n,r=".",i=!0)=>{let s=uue(t,e,n);return!s&&i&&Bn(n)&&(s=eF(t,n,r),s===void 0&&(s=eF(e,n,r))),s},AM=t=>t.replace(/\$/g,"$$$$");class wue{constructor(e={}){this.logger=Ad.create("interpolator"),this.options=e,this.format=e?.interpolation?.format||(n=>n),this.init(e)}init(e={}){e.interpolation||(e.interpolation={escapeValue:!0});const{escape:n,escapeValue:r,useRawValueToEscape:i,prefix:s,prefixEscaped:o,suffix:l,suffixEscaped:c,formatSeparator:d,unescapeSuffix:u,unescapePrefix:m,nestingPrefix:p,nestingPrefixEscaped:f,nestingSuffix:x,nestingSuffixEscaped:v,nestingOptionsSeparator:b,maxReplaces:g,alwaysFormat:_}=e.interpolation;this.escape=n!==void 0?n:hue,this.escapeValue=r!==void 0?r:!0,this.useRawValueToEscape=i!==void 0?i:!1,this.prefix=s?Tb(s):o||"{{",this.suffix=l?Tb(l):c||"}}",this.formatSeparator=d||",",this.unescapePrefix=u?"":m||"-",this.unescapeSuffix=this.unescapePrefix?"":u||"",this.nestingPrefix=p?Tb(p):f||Tb("$t("),this.nestingSuffix=x?Tb(x):v||Tb(")"),this.nestingOptionsSeparator=b||",",this.maxReplaces=g||1e3,this.alwaysFormat=_!==void 0?_:!1,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){const e=(n,r)=>n?.source===r?(n.lastIndex=0,n):new RegExp(r,"g");this.regexp=e(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=e(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=e(this.nestingRegexp,`${this.nestingPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${this.nestingSuffix}`)}interpolate(e,n,r,i){let s,o,l;const c=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},d=f=>{if(f.indexOf(this.formatSeparator)<0){const g=sH(n,c,f,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(g,void 0,r,{...i,...n,interpolationkey:f}):g}const x=f.split(this.formatSeparator),v=x.shift().trim(),b=x.join(this.formatSeparator).trim();return this.format(sH(n,c,v,this.options.keySeparator,this.options.ignoreJSONStructure),b,r,{...i,...n,interpolationkey:v})};this.resetRegExp();const u=i?.missingInterpolationHandler||this.options.missingInterpolationHandler,m=i?.interpolation?.skipOnVariables!==void 0?i.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:f=>AM(f)},{regex:this.regexp,safeValue:f=>this.escapeValue?AM(this.escape(f)):AM(f)}].forEach(f=>{for(l=0;s=f.regex.exec(e);){const x=s[1].trim();if(o=d(x),o===void 0)if(typeof u=="function"){const b=u(e,s,i);o=Bn(b)?b:""}else if(i&&Object.prototype.hasOwnProperty.call(i,x))o="";else if(m){o=s[0];continue}else this.logger.warn(`missed to pass in variable ${x} for interpolating ${e}`),o="";else!Bn(o)&&!this.useRawValueToEscape&&(o=Q8(o));const v=f.safeValue(o);if(e=e.replace(s[0],v),m?(f.regex.lastIndex+=o.length,f.regex.lastIndex-=s[0].length):f.regex.lastIndex=0,l++,l>=this.maxReplaces)break}}),e}nest(e,n,r={}){let i,s,o;const l=(c,d)=>{const u=this.nestingOptionsSeparator;if(c.indexOf(u)<0)return c;const m=c.split(new RegExp(`${u}[ ]*{`));let p=`{${m[1]}`;c=m[0],p=this.interpolate(p,o);const f=p.match(/'/g),x=p.match(/"/g);((f?.length??0)%2===0&&!x||x.length%2!==0)&&(p=p.replace(/'/g,'"'));try{o=JSON.parse(p),d&&(o={...d,...o})}catch(v){return this.logger.warn(`failed parsing options string in nesting for key ${c}`,v),`${c}${u}${p}`}return o.defaultValue&&o.defaultValue.indexOf(this.prefix)>-1&&delete o.defaultValue,c};for(;i=this.nestingRegexp.exec(e);){let c=[];o={...r},o=o.replace&&!Bn(o.replace)?o.replace:o,o.applyPostProcessor=!1,delete o.defaultValue;const d=/{.*}/.test(i[1])?i[1].lastIndexOf("}")+1:i[1].indexOf(this.formatSeparator);if(d!==-1&&(c=i[1].slice(d).split(this.formatSeparator).map(u=>u.trim()).filter(Boolean),i[1]=i[1].slice(0,d)),s=n(l.call(this,i[1].trim(),o),o),s&&i[0]===e&&!Bn(s))return s;Bn(s)||(s=Q8(s)),s||(this.logger.warn(`missed to resolve ${i[1]} for nesting ${e}`),s=""),c.length&&(s=c.reduce((u,m)=>this.format(u,m,r.lng,{...r,interpolationkey:i[1].trim()}),s.trim())),e=e.replace(i[0],s),this.regexp.lastIndex=0}return e}}const Sue=t=>{let e=t.toLowerCase().trim();const n={};if(t.indexOf("(")>-1){const r=t.split("(");e=r[0].toLowerCase().trim();const i=r[1].substring(0,r[1].length-1);e==="currency"&&i.indexOf(":")<0?n.currency||(n.currency=i.trim()):e==="relativetime"&&i.indexOf(":")<0?n.range||(n.range=i.trim()):i.split(";").forEach(o=>{if(o){const[l,...c]=o.split(":"),d=c.join(":").trim().replace(/^'+|'+$/g,""),u=l.trim();n[u]||(n[u]=d),d==="false"&&(n[u]=!1),d==="true"&&(n[u]=!0),isNaN(d)||(n[u]=parseInt(d,10))}})}return{formatName:e,formatOptions:n}},oH=t=>{const e={};return(n,r,i)=>{let s=i;i&&i.interpolationkey&&i.formatParams&&i.formatParams[i.interpolationkey]&&i[i.interpolationkey]&&(s={...s,[i.interpolationkey]:void 0});const o=r+JSON.stringify(s);let l=e[o];return l||(l=t(iw(r),i),e[o]=l),l(n)}},_ue=t=>(e,n,r)=>t(iw(n),r)(e);class kue{constructor(e={}){this.logger=Ad.create("formatter"),this.options=e,this.init(e)}init(e,n={interpolation:{}}){this.formatSeparator=n.interpolation.formatSeparator||",";const r=n.cacheInBuiltFormats?oH:_ue;this.formats={number:r((i,s)=>{const o=new Intl.NumberFormat(i,{...s});return l=>o.format(l)}),currency:r((i,s)=>{const o=new Intl.NumberFormat(i,{...s,style:"currency"});return l=>o.format(l)}),datetime:r((i,s)=>{const o=new Intl.DateTimeFormat(i,{...s});return l=>o.format(l)}),relativetime:r((i,s)=>{const o=new Intl.RelativeTimeFormat(i,{...s});return l=>o.format(l,s.range||"day")}),list:r((i,s)=>{const o=new Intl.ListFormat(i,{...s});return l=>o.format(l)})}}add(e,n){this.formats[e.toLowerCase().trim()]=n}addCached(e,n){this.formats[e.toLowerCase().trim()]=oH(n)}format(e,n,r,i={}){const s=n.split(this.formatSeparator);if(s.length>1&&s[0].indexOf("(")>1&&s[0].indexOf(")")<0&&s.find(l=>l.indexOf(")")>-1)){const l=s.findIndex(c=>c.indexOf(")")>-1);s[0]=[s[0],...s.splice(1,l)].join(this.formatSeparator)}return s.reduce((l,c)=>{const{formatName:d,formatOptions:u}=Sue(c);if(this.formats[d]){let m=l;try{const p=i?.formatParams?.[i.interpolationkey]||{},f=p.locale||p.lng||i.locale||i.lng||r;m=this.formats[d](l,f,{...u,...i,...p})}catch(p){this.logger.warn(p)}return m}else this.logger.warn(`there was no format function for ${d}`);return l},e)}}const Nue=(t,e)=>{t.pending[e]!==void 0&&(delete t.pending[e],t.pendingCount--)};class Cue extends GP{constructor(e,n,r,i={}){super(),this.backend=e,this.store=n,this.services=r,this.languageUtils=r.languageUtils,this.options=i,this.logger=Ad.create("backendConnector"),this.waitingReads=[],this.maxParallelReads=i.maxParallelReads||10,this.readingCalls=0,this.maxRetries=i.maxRetries>=0?i.maxRetries:5,this.retryTimeout=i.retryTimeout>=1?i.retryTimeout:350,this.state={},this.queue=[],this.backend?.init?.(r,i.backend,i)}queueLoad(e,n,r,i){const s={},o={},l={},c={};return e.forEach(d=>{let u=!0;n.forEach(m=>{const p=`${d}|${m}`;!r.reload&&this.store.hasResourceBundle(d,m)?this.state[p]=2:this.state[p]<0||(this.state[p]===1?o[p]===void 0&&(o[p]=!0):(this.state[p]=1,u=!1,o[p]===void 0&&(o[p]=!0),s[p]===void 0&&(s[p]=!0),c[m]===void 0&&(c[m]=!0)))}),u||(l[d]=!0)}),(Object.keys(s).length||Object.keys(o).length)&&this.queue.push({pending:o,pendingCount:Object.keys(o).length,loaded:{},errors:[],callback:i}),{toLoad:Object.keys(s),pending:Object.keys(o),toLoadLanguages:Object.keys(l),toLoadNamespaces:Object.keys(c)}}loaded(e,n,r){const i=e.split("|"),s=i[0],o=i[1];n&&this.emit("failedLoading",s,o,n),!n&&r&&this.store.addResourceBundle(s,o,r,void 0,void 0,{skipCopy:!0}),this.state[e]=n?-1:2,n&&r&&(this.state[e]=0);const l={};this.queue.forEach(c=>{due(c.loaded,[s],o),Nue(c,e),n&&c.errors.push(n),c.pendingCount===0&&!c.done&&(Object.keys(c.loaded).forEach(d=>{l[d]||(l[d]={});const u=c.loaded[d];u.length&&u.forEach(m=>{l[d][m]===void 0&&(l[d][m]=!0)})}),c.done=!0,c.errors.length?c.callback(c.errors):c.callback())}),this.emit("loaded",l),this.queue=this.queue.filter(c=>!c.done)}read(e,n,r,i=0,s=this.retryTimeout,o){if(!e.length)return o(null,{});if(this.readingCalls>=this.maxParallelReads){this.waitingReads.push({lng:e,ns:n,fcName:r,tried:i,wait:s,callback:o});return}this.readingCalls++;const l=(d,u)=>{if(this.readingCalls--,this.waitingReads.length>0){const m=this.waitingReads.shift();this.read(m.lng,m.ns,m.fcName,m.tried,m.wait,m.callback)}if(d&&u&&i{this.read.call(this,e,n,r,i+1,s*2,o)},s);return}o(d,u)},c=this.backend[r].bind(this.backend);if(c.length===2){try{const d=c(e,n);d&&typeof d.then=="function"?d.then(u=>l(null,u)).catch(l):l(null,d)}catch(d){l(d)}return}return c(e,n,l)}prepareLoading(e,n,r={},i){if(!this.backend)return this.logger.warn("No backend was added via i18next.use. Will not load resources."),i&&i();Bn(e)&&(e=this.languageUtils.toResolveHierarchy(e)),Bn(n)&&(n=[n]);const s=this.queueLoad(e,n,r,i);if(!s.toLoad.length)return s.pending.length||i(),null;s.toLoad.forEach(o=>{this.loadOne(o)})}load(e,n,r){this.prepareLoading(e,n,{},r)}reload(e,n,r){this.prepareLoading(e,n,{reload:!0},r)}loadOne(e,n=""){const r=e.split("|"),i=r[0],s=r[1];this.read(i,s,"read",void 0,void 0,(o,l)=>{o&&this.logger.warn(`${n}loading namespace ${s} for language ${i} failed`,o),!o&&l&&this.logger.log(`${n}loaded namespace ${s} for language ${i}`,l),this.loaded(e,o,l)})}saveMissing(e,n,r,i,s,o={},l=()=>{}){if(this.services?.utils?.hasLoadedNamespace&&!this.services?.utils?.hasLoadedNamespace(n)){this.logger.warn(`did not save key "${r}" as the namespace "${n}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!");return}if(!(r==null||r==="")){if(this.backend?.create){const c={...o,isUpdate:s},d=this.backend.create.bind(this.backend);if(d.length<6)try{let u;d.length===5?u=d(e,n,r,i,c):u=d(e,n,r,i),u&&typeof u.then=="function"?u.then(m=>l(null,m)).catch(l):l(null,u)}catch(u){l(u)}else d(e,n,r,i,l,c)}!e||!e[0]||this.store.addResource(e[0],n,r,i)}}}const lH=()=>({debug:!1,initAsync:!0,ns:["translation"],defaultNS:["translation"],fallbackLng:["dev"],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:"all",preload:!1,simplifyPluralSuffix:!0,keySeparator:".",nsSeparator:":",pluralSeparator:"_",contextSeparator:"_",partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:"fallback",saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:t=>{let e={};if(typeof t[1]=="object"&&(e=t[1]),Bn(t[1])&&(e.defaultValue=t[1]),Bn(t[2])&&(e.tDescription=t[2]),typeof t[2]=="object"||typeof t[3]=="object"){const n=t[3]||t[2];Object.keys(n).forEach(r=>{e[r]=n[r]})}return e},interpolation:{escapeValue:!0,format:t=>t,prefix:"{{",suffix:"}}",formatSeparator:",",unescapePrefix:"-",nestingPrefix:"$t(",nestingSuffix:")",nestingOptionsSeparator:",",maxReplaces:1e3,skipOnVariables:!0},cacheInBuiltFormats:!0}),cH=t=>(Bn(t.ns)&&(t.ns=[t.ns]),Bn(t.fallbackLng)&&(t.fallbackLng=[t.fallbackLng]),Bn(t.fallbackNS)&&(t.fallbackNS=[t.fallbackNS]),t.supportedLngs?.indexOf?.("cimode")<0&&(t.supportedLngs=t.supportedLngs.concat(["cimode"])),typeof t.initImmediate=="boolean"&&(t.initAsync=t.initImmediate),t),e1=()=>{},Pue=t=>{Object.getOwnPropertyNames(Object.getPrototypeOf(t)).forEach(n=>{typeof t[n]=="function"&&(t[n]=t[n].bind(t))})};class E0 extends GP{constructor(e={},n){if(super(),this.options=cH(e),this.services={},this.logger=Ad,this.modules={external:[]},Pue(this),n&&!this.isInitialized&&!e.isClone){if(!this.options.initAsync)return this.init(e,n),this;setTimeout(()=>{this.init(e,n)},0)}}init(e={},n){this.isInitializing=!0,typeof e=="function"&&(n=e,e={}),e.defaultNS==null&&e.ns&&(Bn(e.ns)?e.defaultNS=e.ns:e.ns.indexOf("translation")<0&&(e.defaultNS=e.ns[0]));const r=lH();this.options={...r,...this.options,...cH(e)},this.options.interpolation={...r.interpolation,...this.options.interpolation},e.keySeparator!==void 0&&(this.options.userDefinedKeySeparator=e.keySeparator),e.nsSeparator!==void 0&&(this.options.userDefinedNsSeparator=e.nsSeparator);const i=d=>d?typeof d=="function"?new d:d:null;if(!this.options.isClone){this.modules.logger?Ad.init(i(this.modules.logger),this.options):Ad.init(null,this.options);let d;this.modules.formatter?d=this.modules.formatter:d=kue;const u=new rH(this.options);this.store=new tH(this.options.resources,this.options);const m=this.services;m.logger=Ad,m.resourceStore=this.store,m.languageUtils=u,m.pluralResolver=new vue(u,{prepend:this.options.pluralSeparator,simplifyPluralSuffix:this.options.simplifyPluralSuffix}),this.options.interpolation.format&&this.options.interpolation.format!==r.interpolation.format&&this.logger.deprecate("init: you are still using the legacy format function, please use the new approach: https://www.i18next.com/translation-function/formatting"),d&&(!this.options.interpolation.format||this.options.interpolation.format===r.interpolation.format)&&(m.formatter=i(d),m.formatter.init&&m.formatter.init(m,this.options),this.options.interpolation.format=m.formatter.format.bind(m.formatter)),m.interpolator=new wue(this.options),m.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},m.backendConnector=new Cue(i(this.modules.backend),m.resourceStore,m,this.options),m.backendConnector.on("*",(f,...x)=>{this.emit(f,...x)}),this.modules.languageDetector&&(m.languageDetector=i(this.modules.languageDetector),m.languageDetector.init&&m.languageDetector.init(m,this.options.detection,this.options)),this.modules.i18nFormat&&(m.i18nFormat=i(this.modules.i18nFormat),m.i18nFormat.init&&m.i18nFormat.init(this)),this.translator=new vN(this.services,this.options),this.translator.on("*",(f,...x)=>{this.emit(f,...x)}),this.modules.external.forEach(f=>{f.init&&f.init(this)})}if(this.format=this.options.interpolation.format,n||(n=e1),this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){const d=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);d.length>0&&d[0]!=="dev"&&(this.options.lng=d[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn("init: no languageDetector is used and no lng is defined"),["getResource","hasResourceBundle","getResourceBundle","getDataByLanguage"].forEach(d=>{this[d]=(...u)=>this.store[d](...u)}),["addResource","addResources","addResourceBundle","removeResourceBundle"].forEach(d=>{this[d]=(...u)=>(this.store[d](...u),this)});const l=Fv(),c=()=>{const d=(u,m)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn("init: i18next is already initialized. You should call init just once!"),this.isInitialized=!0,this.options.isClone||this.logger.log("initialized",this.options),this.emit("initialized",this.options),l.resolve(m),n(u,m)};if(this.languages&&!this.isInitialized)return d(null,this.t.bind(this));this.changeLanguage(this.options.lng,d)};return this.options.resources||!this.options.initAsync?c():setTimeout(c,0),l}loadResources(e,n=e1){let r=n;const i=Bn(e)?e:this.language;if(typeof e=="function"&&(r=e),!this.options.resources||this.options.partialBundledLanguages){if(i?.toLowerCase()==="cimode"&&(!this.options.preload||this.options.preload.length===0))return r();const s=[],o=l=>{if(!l||l==="cimode")return;this.services.languageUtils.toResolveHierarchy(l).forEach(d=>{d!=="cimode"&&s.indexOf(d)<0&&s.push(d)})};i?o(i):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach(c=>o(c)),this.options.preload?.forEach?.(l=>o(l)),this.services.backendConnector.load(s,this.options.ns,l=>{!l&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),r(l)})}else r(null)}reloadResources(e,n,r){const i=Fv();return typeof e=="function"&&(r=e,e=void 0),typeof n=="function"&&(r=n,n=void 0),e||(e=this.languages),n||(n=this.options.ns),r||(r=e1),this.services.backendConnector.reload(e,n,s=>{i.resolve(),r(s)}),i}use(e){if(!e)throw new Error("You are passing an undefined module! Please check the object you are passing to i18next.use()");if(!e.type)throw new Error("You are passing a wrong module! Please check the object you are passing to i18next.use()");return e.type==="backend"&&(this.modules.backend=e),(e.type==="logger"||e.log&&e.warn&&e.error)&&(this.modules.logger=e),e.type==="languageDetector"&&(this.modules.languageDetector=e),e.type==="i18nFormat"&&(this.modules.i18nFormat=e),e.type==="postProcessor"&&aQ.addPostProcessor(e),e.type==="formatter"&&(this.modules.formatter=e),e.type==="3rdParty"&&this.modules.external.push(e),this}setResolvedLanguage(e){if(!(!e||!this.languages)&&!(["cimode","dev"].indexOf(e)>-1)){for(let n=0;n-1)&&this.store.hasLanguageSomeTranslations(r)){this.resolvedLanguage=r;break}}!this.resolvedLanguage&&this.languages.indexOf(e)<0&&this.store.hasLanguageSomeTranslations(e)&&(this.resolvedLanguage=e,this.languages.unshift(e))}}changeLanguage(e,n){this.isLanguageChangingTo=e;const r=Fv();this.emit("languageChanging",e);const i=l=>{this.language=l,this.languages=this.services.languageUtils.toResolveHierarchy(l),this.resolvedLanguage=void 0,this.setResolvedLanguage(l)},s=(l,c)=>{c?this.isLanguageChangingTo===e&&(i(c),this.translator.changeLanguage(c),this.isLanguageChangingTo=void 0,this.emit("languageChanged",c),this.logger.log("languageChanged",c)):this.isLanguageChangingTo=void 0,r.resolve((...d)=>this.t(...d)),n&&n(l,(...d)=>this.t(...d))},o=l=>{!e&&!l&&this.services.languageDetector&&(l=[]);const c=Bn(l)?l:l&&l[0],d=this.store.hasLanguageSomeTranslations(c)?c:this.services.languageUtils.getBestMatchFromCodes(Bn(l)?[l]:l);d&&(this.language||i(d),this.translator.language||this.translator.changeLanguage(d),this.services.languageDetector?.cacheUserLanguage?.(d)),this.loadResources(d,u=>{s(u,d)})};return!e&&this.services.languageDetector&&!this.services.languageDetector.async?o(this.services.languageDetector.detect()):!e&&this.services.languageDetector&&this.services.languageDetector.async?this.services.languageDetector.detect.length===0?this.services.languageDetector.detect().then(o):this.services.languageDetector.detect(o):o(e),r}getFixedT(e,n,r){const i=(s,o,...l)=>{let c;typeof o!="object"?c=this.options.overloadTranslationOptionHandler([s,o].concat(l)):c={...o},c.lng=c.lng||i.lng,c.lngs=c.lngs||i.lngs,c.ns=c.ns||i.ns,c.keyPrefix!==""&&(c.keyPrefix=c.keyPrefix||r||i.keyPrefix);const d=this.options.keySeparator||".";let u;return c.keyPrefix&&Array.isArray(s)?u=s.map(m=>(typeof m=="function"&&(m=tF(m,{...this.options,...o})),`${c.keyPrefix}${d}${m}`)):(typeof s=="function"&&(s=tF(s,{...this.options,...o})),u=c.keyPrefix?`${c.keyPrefix}${d}${s}`:s),this.t(u,c)};return Bn(e)?i.lng=e:i.lngs=e,i.ns=n,i.keyPrefix=r,i}t(...e){return this.translator?.translate(...e)}exists(...e){return this.translator?.exists(...e)}setDefaultNamespace(e){this.options.defaultNS=e}hasLoadedNamespace(e,n={}){if(!this.isInitialized)return this.logger.warn("hasLoadedNamespace: i18next was not initialized",this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn("hasLoadedNamespace: i18n.languages were undefined or empty",this.languages),!1;const r=n.lng||this.resolvedLanguage||this.languages[0],i=this.options?this.options.fallbackLng:!1,s=this.languages[this.languages.length-1];if(r.toLowerCase()==="cimode")return!0;const o=(l,c)=>{const d=this.services.backendConnector.state[`${l}|${c}`];return d===-1||d===0||d===2};if(n.precheck){const l=n.precheck(this,o);if(l!==void 0)return l}return!!(this.hasResourceBundle(r,e)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||o(r,e)&&(!i||o(s,e)))}loadNamespaces(e,n){const r=Fv();return this.options.ns?(Bn(e)&&(e=[e]),e.forEach(i=>{this.options.ns.indexOf(i)<0&&this.options.ns.push(i)}),this.loadResources(i=>{r.resolve(),n&&n(i)}),r):(n&&n(),Promise.resolve())}loadLanguages(e,n){const r=Fv();Bn(e)&&(e=[e]);const i=this.options.preload||[],s=e.filter(o=>i.indexOf(o)<0&&this.services.languageUtils.isSupportedCode(o));return s.length?(this.options.preload=i.concat(s),this.loadResources(o=>{r.resolve(),n&&n(o)}),r):(n&&n(),Promise.resolve())}dir(e){if(e||(e=this.resolvedLanguage||(this.languages?.length>0?this.languages[0]:this.language)),!e)return"rtl";try{const i=new Intl.Locale(e);if(i&&i.getTextInfo){const s=i.getTextInfo();if(s&&s.direction)return s.direction}}catch{}const n=["ar","shu","sqr","ssh","xaa","yhd","yud","aao","abh","abv","acm","acq","acw","acx","acy","adf","ads","aeb","aec","afb","ajp","apc","apd","arb","arq","ars","ary","arz","auz","avl","ayh","ayl","ayn","ayp","bbz","pga","he","iw","ps","pbt","pbu","pst","prp","prd","ug","ur","ydd","yds","yih","ji","yi","hbo","men","xmn","fa","jpr","peo","pes","prs","dv","sam","ckb"],r=this.services?.languageUtils||new rH(lH());return e.toLowerCase().indexOf("-latn")>1?"ltr":n.indexOf(r.getLanguagePartFromCode(e))>-1||e.toLowerCase().indexOf("-arab")>1?"rtl":"ltr"}static createInstance(e={},n){const r=new E0(e,n);return r.createInstance=E0.createInstance,r}cloneInstance(e={},n=e1){const r=e.forkResourceStore;r&&delete e.forkResourceStore;const i={...this.options,...e,isClone:!0},s=new E0(i);if((e.debug!==void 0||e.prefix!==void 0)&&(s.logger=s.logger.clone(e)),["store","services","language"].forEach(l=>{s[l]=this[l]}),s.services={...this.services},s.services.utils={hasLoadedNamespace:s.hasLoadedNamespace.bind(s)},r){const l=Object.keys(this.store.data).reduce((c,d)=>(c[d]={...this.store.data[d]},c[d]=Object.keys(c[d]).reduce((u,m)=>(u[m]={...c[d][m]},u),c[d]),c),{});s.store=new tH(l,i),s.services.resourceStore=s.store}return s.translator=new vN(s.services,i),s.translator.on("*",(l,...c)=>{s.emit(l,...c)}),s.init(i,n),s.translator.options=i,s.translator.backendConnector.services.utils={hasLoadedNamespace:s.hasLoadedNamespace.bind(s)},s}toJSON(){return{options:this.options,store:this.store,language:this.language,languages:this.languages,resolvedLanguage:this.resolvedLanguage}}}const yo=E0.createInstance();yo.createInstance;yo.dir;yo.init;yo.loadResources;yo.reloadResources;yo.use;yo.changeLanguage;yo.getFixedT;yo.t;yo.exists;yo.setDefaultNamespace;yo.hasLoadedNamespace;yo.loadNamespaces;yo.loadLanguages;const Tue=(t,e,n,r)=>{const i=[n,{code:e,...r||{}}];if(t?.services?.logger?.forward)return t.services.logger.forward(i,"warn","react-i18next::",!0);ug(i[0])&&(i[0]=`react-i18next:: ${i[0]}`),t?.services?.logger?.warn?t.services.logger.warn(...i):console?.warn&&console.warn(...i)},dH={},sQ=(t,e,n,r)=>{ug(n)&&dH[n]||(ug(n)&&(dH[n]=new Date),Tue(t,e,n,r))},oQ=(t,e)=>()=>{if(t.isInitialized)e();else{const n=()=>{setTimeout(()=>{t.off("initialized",n)},0),e()};t.on("initialized",n)}},nF=(t,e,n)=>{t.loadNamespaces(e,oQ(t,n))},uH=(t,e,n,r)=>{if(ug(n)&&(n=[n]),t.options.preload&&t.options.preload.indexOf(e)>-1)return nF(t,n,r);n.forEach(i=>{t.options.ns.indexOf(i)<0&&t.options.ns.push(i)}),t.loadLanguages(e,oQ(t,r))},Aue=(t,e,n={})=>!e.languages||!e.languages.length?(sQ(e,"NO_LANGUAGES","i18n.languages were undefined or empty",{languages:e.languages}),!0):e.hasLoadedNamespace(t,{lng:n.lng,precheck:(r,i)=>{if(n.bindI18n&&n.bindI18n.indexOf("languageChanging")>-1&&r.services.backendConnector.backend&&r.isLanguageChangingTo&&!i(r.isLanguageChangingTo,t))return!1}}),ug=t=>typeof t=="string",jue=t=>typeof t=="object"&&t!==null,Mue=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,Eue={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},Due=t=>Eue[t],Fue=t=>t.replace(Mue,Due);let rF={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:Fue,transDefaultProps:void 0};const Rue=(t={})=>{rF={...rF,...t}},Lue=()=>rF;let lQ;const Oue=t=>{lQ=t},Iue=()=>lQ,zue={type:"3rdParty",init(t){Rue(t.options.react),Oue(t)}},Uue=w.createContext();class Bue{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach(n=>{this.usedNamespaces[n]||(this.usedNamespaces[n]=!0)})}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}var jM={exports:{}},MM={};var mH;function Hue(){if(mH)return MM;mH=1;var t=Ug();function e(m,p){return m===p&&(m!==0||1/m===1/p)||m!==m&&p!==p}var n=typeof Object.is=="function"?Object.is:e,r=t.useState,i=t.useEffect,s=t.useLayoutEffect,o=t.useDebugValue;function l(m,p){var f=p(),x=r({inst:{value:f,getSnapshot:p}}),v=x[0].inst,b=x[1];return s(function(){v.value=f,v.getSnapshot=p,c(v)&&b({inst:v})},[m,f,p]),i(function(){return c(v)&&b({inst:v}),m(function(){c(v)&&b({inst:v})})},[m]),o(f),f}function c(m){var p=m.getSnapshot;m=m.value;try{var f=p();return!n(m,f)}catch{return!0}}function d(m,p){return p()}var u=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?d:l;return MM.useSyncExternalStore=t.useSyncExternalStore!==void 0?t.useSyncExternalStore:u,MM}var hH;function cQ(){return hH||(hH=1,jM.exports=Hue()),jM.exports}var CO=cQ();const que=(t,e)=>ug(e)?e:jue(e)&&ug(e.defaultValue)?e.defaultValue:Array.isArray(t)?t[t.length-1]:t,$ue={t:que,ready:!1},Vue=()=>()=>{},Nt=(t,e={})=>{const{i18n:n}=e,{i18n:r,defaultNS:i}=w.useContext(Uue)||{},s=n||r||Iue();s&&!s.reportNamespaces&&(s.reportNamespaces=new Bue),s||sQ(s,"NO_I18NEXT_INSTANCE","useTranslation: You will need to pass in an i18next instance by using initReactI18next");const o=w.useMemo(()=>({...Lue(),...s?.options?.react,...e}),[s,e]),{useSuspense:l,keyPrefix:c}=o,d=i||s?.options?.defaultNS,u=ug(d)?[d]:d||["translation"],m=w.useMemo(()=>u,u);s?.reportNamespaces?.addUsedNamespaces?.(m);const p=w.useRef(0),f=w.useCallback(k=>{if(!s)return Vue;const{bindI18n:E,bindI18nStore:$}=o,z=()=>{p.current+=1,k()};return E&&s.on(E,z),$&&s.store.on($,z),()=>{E&&E.split(" ").forEach(X=>s.off(X,z)),$&&$.split(" ").forEach(X=>s.store.off(X,z))}},[s,o]),x=w.useRef(),v=w.useCallback(()=>{if(!s)return $ue;const k=!!(s.isInitialized||s.initializedStoreOnce)&&m.every(ne=>Aue(ne,s,o)),E=e.lng||s.language,$=p.current,z=x.current;if(z&&z.ready===k&&z.lng===E&&z.keyPrefix===c&&z.revision===$)return z;const L={t:s.getFixedT(E,o.nsMode==="fallback"?m:m[0],c),ready:k,lng:E,keyPrefix:c,revision:$};return x.current=L,L},[s,m,c,o,e.lng]),[b,g]=w.useState(0),{t:_,ready:N}=CO.useSyncExternalStore(f,v,v);w.useEffect(()=>{if(s&&!N&&!l){const k=()=>g(E=>E+1);e.lng?uH(s,e.lng,m,k):nF(s,m,k)}},[s,e.lng,m,N,l,b]);const P=s||{},C=w.useRef(null),A=w.useRef(),T=k=>{const E=Object.getOwnPropertyDescriptors(k);E.__original&&delete E.__original;const $=Object.create(Object.getPrototypeOf(k),E);if(!Object.prototype.hasOwnProperty.call($,"__original"))try{Object.defineProperty($,"__original",{value:k,writable:!1,enumerable:!1,configurable:!1})}catch{}return $},F=w.useMemo(()=>{const k=P,E=k?.language;let $=k;k&&(C.current&&C.current.__original===k?A.current!==E?($=T(k),C.current=$,A.current=E):$=C.current:($=T(k),C.current=$,A.current=E));const z=[_,$,N];return z.t=_,z.i18n=$,z.ready=N,z},[_,P,N,P.resolvedLanguage,P.language,P.languages]);if(s&&l&&!N)throw new Promise(k=>{const E=()=>k();e.lng?uH(s,e.lng,m,E):nF(s,m,E)});return F},{slice:Gue,forEach:Wue}=[];function Kue(t){return Wue.call(Gue.call(arguments,1),e=>{if(e)for(const n in e)t[n]===void 0&&(t[n]=e[n])}),t}function Xue(t){return typeof t!="string"?!1:[/<\s*script.*?>/i,/<\s*\/\s*script\s*>/i,/<\s*img.*?on\w+\s*=/i,/<\s*\w+\s*on\w+\s*=.*?>/i,/javascript\s*:/i,/vbscript\s*:/i,/expression\s*\(/i,/eval\s*\(/i,/alert\s*\(/i,/document\.cookie/i,/document\.write\s*\(/i,/window\.location/i,/innerHTML/i].some(n=>n.test(t))}const pH=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/,Yue=function(t,e){const r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{path:"/"},i=encodeURIComponent(e);let s=`${t}=${i}`;if(r.maxAge>0){const o=r.maxAge-0;if(Number.isNaN(o))throw new Error("maxAge should be a Number");s+=`; Max-Age=${Math.floor(o)}`}if(r.domain){if(!pH.test(r.domain))throw new TypeError("option domain is invalid");s+=`; Domain=${r.domain}`}if(r.path){if(!pH.test(r.path))throw new TypeError("option path is invalid");s+=`; Path=${r.path}`}if(r.expires){if(typeof r.expires.toUTCString!="function")throw new TypeError("option expires is invalid");s+=`; Expires=${r.expires.toUTCString()}`}if(r.httpOnly&&(s+="; HttpOnly"),r.secure&&(s+="; Secure"),r.sameSite)switch(typeof r.sameSite=="string"?r.sameSite.toLowerCase():r.sameSite){case!0:s+="; SameSite=Strict";break;case"lax":s+="; SameSite=Lax";break;case"strict":s+="; SameSite=Strict";break;case"none":s+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}return r.partitioned&&(s+="; Partitioned"),s},fH={create(t,e,n,r){let i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:{path:"/",sameSite:"strict"};n&&(i.expires=new Date,i.expires.setTime(i.expires.getTime()+n*60*1e3)),r&&(i.domain=r),document.cookie=Yue(t,e,i)},read(t){const e=`${t}=`,n=document.cookie.split(";");for(let r=0;r-1&&(r=window.location.hash.substring(window.location.hash.indexOf("?")));const s=r.substring(1).split("&");for(let o=0;o0&&s[o].substring(0,l)===e&&(n=s[o].substring(l+1))}}return n}},Zue={name:"hash",lookup(t){let{lookupHash:e,lookupFromHashIndex:n}=t,r;if(typeof window<"u"){const{hash:i}=window.location;if(i&&i.length>2){const s=i.substring(1);if(e){const o=s.split("&");for(let l=0;l0&&o[l].substring(0,c)===e&&(r=o[l].substring(c+1))}}if(r)return r;if(!r&&n>-1){const o=i.match(/\/([a-zA-Z-]*)/g);return Array.isArray(o)?o[typeof n=="number"?n:0]?.replace("/",""):void 0}}}return r}};let Ab=null;const gH=()=>{if(Ab!==null)return Ab;try{if(Ab=typeof window<"u"&&window.localStorage!==null,!Ab)return!1;const t="i18next.translate.boo";window.localStorage.setItem(t,"foo"),window.localStorage.removeItem(t)}catch{Ab=!1}return Ab};var eme={name:"localStorage",lookup(t){let{lookupLocalStorage:e}=t;if(e&&gH())return window.localStorage.getItem(e)||void 0},cacheUserLanguage(t,e){let{lookupLocalStorage:n}=e;n&&gH()&&window.localStorage.setItem(n,t)}};let jb=null;const bH=()=>{if(jb!==null)return jb;try{if(jb=typeof window<"u"&&window.sessionStorage!==null,!jb)return!1;const t="i18next.translate.boo";window.sessionStorage.setItem(t,"foo"),window.sessionStorage.removeItem(t)}catch{jb=!1}return jb};var tme={name:"sessionStorage",lookup(t){let{lookupSessionStorage:e}=t;if(e&&bH())return window.sessionStorage.getItem(e)||void 0},cacheUserLanguage(t,e){let{lookupSessionStorage:n}=e;n&&bH()&&window.sessionStorage.setItem(n,t)}},nme={name:"navigator",lookup(t){const e=[];if(typeof navigator<"u"){const{languages:n,userLanguage:r,language:i}=navigator;if(n)for(let s=0;s0?e:void 0}},rme={name:"htmlTag",lookup(t){let{htmlTag:e}=t,n;const r=e||(typeof document<"u"?document.documentElement:null);return r&&typeof r.getAttribute=="function"&&(n=r.getAttribute("lang")),n}},ame={name:"path",lookup(t){let{lookupFromPathIndex:e}=t;if(typeof window>"u")return;const n=window.location.pathname.match(/\/([a-zA-Z-]*)/g);return Array.isArray(n)?n[typeof e=="number"?e:0]?.replace("/",""):void 0}},ime={name:"subdomain",lookup(t){let{lookupFromSubdomainIndex:e}=t;const n=typeof e=="number"?e+1:1,r=typeof window<"u"&&window.location?.hostname?.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i);if(r)return r[n]}};let dQ=!1;try{document.cookie,dQ=!0}catch{}const uQ=["querystring","cookie","localStorage","sessionStorage","navigator","htmlTag"];dQ||uQ.splice(1,1);const sme=()=>({order:uQ,lookupQuerystring:"lng",lookupCookie:"i18next",lookupLocalStorage:"i18nextLng",lookupSessionStorage:"i18nextLng",caches:["localStorage"],excludeCacheFor:["cimode"],convertDetectedLanguage:t=>t});class mQ{constructor(e){let n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};this.type="languageDetector",this.detectors={},this.init(e,n)}init(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{languageUtils:{}},n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};this.services=e,this.options=Kue(n,this.options||{},sme()),typeof this.options.convertDetectedLanguage=="string"&&this.options.convertDetectedLanguage.indexOf("15897")>-1&&(this.options.convertDetectedLanguage=i=>i.replace("-","_")),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=r,this.addDetector(Que),this.addDetector(Jue),this.addDetector(eme),this.addDetector(tme),this.addDetector(nme),this.addDetector(rme),this.addDetector(ame),this.addDetector(ime),this.addDetector(Zue)}addDetector(e){return this.detectors[e.name]=e,this}detect(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.order,n=[];return e.forEach(r=>{if(this.detectors[r]){let i=this.detectors[r].lookup(this.options);i&&typeof i=="string"&&(i=[i]),i&&(n=n.concat(i))}}),n=n.filter(r=>r!=null&&!Xue(r)).map(r=>this.options.convertDetectedLanguage(r)),this.services&&this.services.languageUtils&&this.services.languageUtils.getBestMatchFromCodes?n:n.length>0?n[0]:null}cacheUserLanguage(e){let n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.options.caches;n&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(e)>-1||n.forEach(r=>{this.detectors[r]&&this.detectors[r].cacheUserLanguage(e,this.options)}))}}mQ.type="languageDetector";const ome={nav:{printers:"Printers",archives:"Archives",queue:"Queue",stats:"Statistics",profiles:"Profiles",maintenance:"Maintenance",projects:"Projects",inventory:"Filament",files:"File Manager",makerworld:"MakerWorld",notifications:"Notifications",settings:"Settings",system:"System",collapseSidebar:"Collapse sidebar",expandSidebar:"Expand sidebar",update:"Update",updateAvailable:"Update available: v{{version}}",updateAvailableBanner:"Version {{version}} is available!",viewUpdate:"View update",viewOnGithub:"View on GitHub",keyboardShortcuts:"Keyboard shortcuts (?)",switchToLight:"Switch to light mode",switchToDark:"Switch to dark mode",smartSwitches:"Smart Switches",logout:"Logout"},common:{save:"Save",saving:"Saving...",cancel:"Cancel",delete:"Delete",edit:"Edit",add:"Add",close:"Close",confirm:"Confirm",loading:"Loading...",error:"Error",success:"Success",warning:"Warning",enabled:"Enabled",disabled:"Disabled",yes:"Yes",no:"No",on:"On",off:"Off",all:"All",none:"None",search:"Search",filter:"Filter",sort:"Sort",refresh:"Refresh",download:"Download",upload:"Upload",uploading:"Uploading...",uploadFailed:"Upload failed",actions:"Actions",status:"Status",name:"Name",description:"Description",date:"Date",time:"Time",hours:"hours",minutes:"minutes",seconds:"seconds",days:"days",enable:"Enable",disable:"Disable",permissions:"Permissions",noPrinters:"No printers configured",noData:"No data available",linkNotFound:"Link not found",required:"Required",optional:"Optional",dismiss:"Dismiss",apply:"Apply",reset:"Reset",export:"Export",import:"Import",clear:"Clear",selectAll:"Select All",deselectAll:"Deselect All",noChange:"— No change —",unchanged:"Unchanged",unassigned:"Unassigned",unknown:"Unknown",unknownError:"Unknown error",today:"Today",tomorrow:"Tomorrow",asap:"ASAP",overdue:"Overdue",now:"Now",collapse:"Collapse",expand:"Expand",viewArchive:"View archive",viewInFileManager:"View in File Manager",addedBy:"Added by {{username}}",prints:"prints",more:"+{{count}} more",ascending:"Ascending",descending:"Descending",back:"Back",copy:"Copy",copied:"Copied!",printer:"Printer",remove:"Remove",type:"Type",print:"Print",rename:"Rename",move:"Move",create:"Create",duplicate:"Duplicate",left:"Left",right:"Right"},printers:{title:"Printers",addPrinter:"Add Printer",editPrinter:"Edit Printer",deletePrinter:"Delete Printer",printerName:"Printer Name",serialNumber:"Serial Number",ipAddress:"IP Address / Hostname",accessCode:"Access Code",model:"Model",nozzleCount:"Nozzle Count",autoArchive:"Auto Archive",status:{available:"Available",idle:"Idle",printing:"Printing",paused:"Paused",offline:"Offline",problem:"Problem",error:"Error",finished:"Finished",unknown:"Unknown"},temperatures:{nozzle:"Nozzle",bed:"Bed",chamber:"Chamber"},progress:"{{percent}}% complete",timeRemaining:"{{time}} remaining",deleteConfirm:'Are you sure you want to delete "{{name}}"?',maintenanceOk:"Maintenance OK",maintenanceWarning:"{{count}} warning",maintenanceWarning_plural:"{{count}} warnings",maintenanceDue:"{{count}} due",maintenanceDue_plural:"{{count}} due",sort:{name:"Name",status:"Status",model:"Model",location:"Location",ascending:"Sort ascending",descending:"Sort descending"},cardSize:{small:"Small cards",medium:"Medium cards",large:"Large cards",extraLarge:"Extra large cards"},hideOffline:"Hide offline",nextAvailable:"Next available",powerOn:"Power On",offlinePrintersWithPlugs:"Offline printers with smart plugs",noPrintersConfigured:"No printers configured yet",search:"Search printers...",noSearchResults:"No printers match your search or filters",filter:{allStatuses:"All statuses",allLocations:"All locations"},readyToPrint:"Ready to print",external:"External",extL:"Ext-L",extR:"Ext-R",deleteArchives:"Delete print archives",noLabel:"No label",printPreview:"Print preview",width:"Width",height:"Height",noObjectsFound:"No objects found",objectsLoadedOnPrintStart:"Objects are loaded when a print starts",willBeSkipped:"Will be skipped",name:"Name",serialCannotBeChanged:"Serial number cannot be changed",locationHelp:"Used to group printers and filter queue jobs",wifiSignal:{veryWeak:"Very weak",weak:"Weak",fair:"Fair",good:"Good",excellent:"Excellent"},maintenanceUpToDate:"All maintenance up to date - Click to view",chamberLightOn:"Turn on chamber light",chamberLightOff:"Turn off chamber light",files:"Files",browseFiles:"Browse printer files",autoOffAfterPrint:"Auto power-off after print",autoOffExecuted:"Auto-off was executed - turn printer on to reset",hmsErrors:"HMS Errors",viewHmsErrors:"View {{count}} HMS error(s)",resume:"Resume",pause:"Pause",stop:"Stop",camera:"Camera",skipObject:"Skip Object",reconnect:"Reconnect",forceRefresh:"Force Refresh",forceRefreshSuccess:"Refresh requested",mqttDebug:"MQTT Debug",printerInformation:"Printer Information",copyToClipboard:"Copy",copied:"Copied!",state:"State",wifiSignalLabel:"WiFi Signal",developerMode:"Developer Mode",enabled:"Enabled",disabled:"Disabled",addedOn:"Added",sdCard:"SD Card",inserted:"Inserted",notInserted:"Not inserted",totalPrintHours:"Print Hours",activeNozzle:"Active: {{nozzle}} nozzle",nozzleRack:"Nozzle Rack",nozzleDocked:"Docked",nozzleMounted:"Mounted",nozzleActive:"Active",nozzleIdle:"Idle",nozzleDiameter:"Diameter",nozzleType:"Type",nozzleStatus:"Status",nozzleFilament:"Filament",nozzleWear:"Wear",nozzleMaxTemp:"Max Temp",nozzleSerial:"Serial",nozzleHardenedSteel:"Hardened Steel",nozzleStainlessSteel:"Stainless Steel",nozzleTungstenCarbide:"Tungsten Carbide",nozzleFlow:"Flow",nozzleHighFlow:"High Flow",nozzleStandardFlow:"Standard",firmwareUpdate:"Firmware Update",firmwareInstructions:"On the printer's touchscreen, go to",firmwareNav:"Navigate to",settings:"Settings",firmware:"Firmware",discoverPrinters:"Discover Printers",searching:"Searching...",manualEntry:"Manual Entry",addFromCloud:"Add from Cloud",toast:{printerDeleted:"Printer deleted",missingSpoolAssignment:"Print started on {{printer}}. Missing spool assignment for: {{slots}}",printerAdded:"Printer added",printerUpdated:"Printer updated",failedToDelete:"Failed to delete printer",failedToAdd:"Failed to add printer",failedToUpdate:"Failed to update printer",commandSent:"Command sent",failedToSendCommand:"Failed to send command",turnedOn:"{{name}} turned on",failedToPowerOn:"Failed to power on {{name}}",scriptTriggered:"Script triggered",printStopped:"Print stopped",printPaused:"Print paused",printResumed:"Print resumed",referenceDeleted:"Reference deleted",detectionAreaSaved:"Detection area saved",failedToRunScript:"Failed to run script",failedToStopPrint:"Failed to stop print",failedToPausePrint:"Failed to pause print",failedToResumePrint:"Failed to resume print",failedToControlChamberLight:"Failed to control chamber light",failedToSetSpeed:"Failed to set print speed",failedToUpdateSetting:"Failed to update setting",failedToSkipObjects:"Failed to skip objects",failedToRereadRfid:"Failed to re-read RFID",failedToCheckPlate:"Failed to check plate",failedToUpdateLabel:"Failed to update label",failedToDeleteReference:"Failed to delete reference",failedToSaveDetectionArea:"Failed to save detection area",plateCheckEnabled:"Plate check enabled",plateCheckDisabled:"Plate check disabled",calibrationSaved:"Calibration saved!",calibrationFailed:"Calibration failed",rfidRereadInitiated:"RFID re-read initiated"},connection:{connected:"Connected",offline:"Offline"},plateStatus:{markCleared:"Mark plate as cleared",cleared:"Plate Clear",notCleared:"Plate not Clear",inUse:"Plate in Use"},queue:{inQueue:"{{count}} print in queue",inQueue_plural:"{{count}} prints in queue"},controls:"Controls",rfid:{reread:"Re-read RFID"},bedJog:{title:"Move build plate",bed:"Bed",step:"Step (mm)",up:"Move plate up",down:"Move plate down",disabledWhilePrinting:"Disabled while printing",notHomedTitle:"Printer is not homed",notHomedMessage:"The printer has not been homed since the last print. Run auto-home first for safe positioning (parks the toolhead, then homes X, Y, and Z), or move anyway — soft endstops will be bypassed.",homeZ:"Auto Home",moveAnyway:"Move anyway",homingStarted:"Auto-homing printer…"},permission:{noAdd:"You do not have permission to add printers",noEdit:"You do not have permission to edit printers",noDelete:"You do not have permission to delete printers",noControl:"You do not have permission to control printers",noFiles:"You do not have permission to access printer files",noAmsRfid:"You do not have permission to re-read AMS RFID",noSmartPlugControl:"You do not have permission to control smart plugs",noCamera:"You do not have permission to view cameras"},modal:{addTitle:"Add Printer",editTitle:"Edit Printer",myPrinter:"My Printer",selectModel:"Select model...",locationGroup:"Location / Group (optional)",locationPlaceholder:"e.g., Workshop, Office, Basement",autoArchiveLabel:"Auto-archive completed prints",fromPrinterSettings:"From printer settings",modelOptional:"Model (optional)",saveChanges:"Save Changes"},skipObjects:{tooltip:"Skip objects",onlyWhilePrinting:"Skip objects (only while printing)",requiresMultiple:"Skip objects (requires 2+ objects)",title:"Skip Objects",matchIdsInfo:"Match IDs with your printer display",printerShowsIds:"The printer screen shows object IDs on the build plate",skipSelected:"Skip Selected",skipping:"Skipping...",noObjectsSelected:"No objects selected",selectObjectsToSkip:"Select objects you want to skip from the current print",skipped:"skipped",objectsSkipped:"Objects skipped",activeCount:"{{count}} active",waitForLayer:"Wait for layer 2+ to skip objects (currently layer {{layer}})",skip:"Skip",confirmTitle:"Skip Object?",confirmMessage:'Are you sure you want to skip "{{name}}"? This cannot be undone.'},confirm:{deleteTitle:"Delete Printer",deleteMessage:'Are you sure you want to delete "{{name}}"? This will remove all connection settings.',deleteArchivesNote:"All print history for this printer will be permanently deleted.",keepArchivesNote:"Print history will be kept but no longer associated with this printer.",stopTitle:"Stop Print",stopMessage:'Are you sure you want to stop the current print on "{{name}}"? This will cancel the print job.',stopButton:"Stop Print",pauseTitle:"Pause Print",pauseMessage:'Are you sure you want to pause the current print on "{{name}}"?',pauseButton:"Pause Print",resumeTitle:"Resume Print",resumeMessage:'Are you sure you want to resume the print on "{{name}}"?',resumeButton:"Resume Print",powerOnTitle:"Power On Printer",powerOnMessage:'Are you sure you want to turn ON the power for "{{name}}"?',powerOnButton:"Power On",powerOffTitle:"Power Off Printer",powerOffMessage:'Are you sure you want to turn OFF the power for "{{name}}"?',powerOffWarning:'WARNING: "{{name}}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.',powerOffButton:"Power Off"},bulk:{select:"Select",selectAll:"Select All",selectByLocation:"Select by Location",selected:"{{count}} selected",actions:{stop:"Stop",pause:"Pause",resume:"Resume",clearPlate:"Clear Bed",clearHMS:"Clear Notifications"},confirm:{stopTitle:"Stop {{count}} Prints",stopMessage:"This will cancel active prints on {{count}} printer(s). This action cannot be undone.",stopButton:"Stop All",pauseTitle:"Pause {{count}} Prints",pauseMessage:"This will pause active prints on {{count}} printer(s).",pauseButton:"Pause All",clearPlateTitle:"Clear {{count}} Print Beds",clearPlateMessage:"This will clear the print bed on {{count}} printer(s) and may trigger queued jobs.",clearPlateButton:"Clear All"},success:"{{action}} completed on {{count}} printer(s)",partial:"{{succeeded}} succeeded, {{failed}} failed",noneApplicable:"No selected printers are in the right state for this action",selectByState:"Select by State"},discovery:{title:"Discover Printers",searching:"Searching...",scanning:"Scanning...",scanProgress:"Scanning... {{scanned}}/{{total}}",foundPrinters:"Found {{count}} printer(s)",noPrintersFound:"No printers found",noPrintersFoundSubnet:"No printers found in the specified subnet.",noPrintersFoundNetwork:"No printers found on the network.",allConfigured:"All discovered printers are already configured.",alreadyAdded:"Already added",select:"Select",manualEntry:"Manual Entry",addFromCloud:"Add from Cloud",subnetToScan:"Subnet to scan",dockerNote:"Docker detected. Enter your printer's subnet in CIDR notation. Requires network_mode: host in docker-compose.yml.",scanSubnet:"Scan Subnet for Printers",discoverNetwork:"Discover Printers on Network",scanningSubnet:"Scanning subnet for Bambu printers...",scanningNetwork:"Scanning network...",serialRequired:"Serial required",unknown:"Unknown",failedToStart:"Failed to start discovery"},drying:{start:"Start Drying",stop:"Stop Drying",temperature:"Temperature",duration:"Duration",hours:"hours",timeRemaining:"{{time}} left",active:"Drying",notSupported:"Drying not supported",powerRequired:"Connect AMS power adapter to enable drying",startingDrying:"Starting drying...",stoppingDrying:"Stopping drying...",rotateTray:"Rotate spool during drying"},filaments:"Filaments",openCameraOverlay:"Open camera overlay",openCameraWindow:"Open camera in new window",firmwareUpdateAvailable:"Firmware update available: {{current}} → {{latest}}",firmwareUpToDate:"Firmware {{version}} — Up to date",firmwareUpdateButton:"Update",plateDetection:{noPermission:"You do not have permission to update printers",enabledClick:"Plate check enabled - Click to disable",disabledClick:"Plate check disabled - Click to enable",manageCalibration:"Manage plate detection calibration",calibrationRequired:"Calibration Required",calibrationInstructions:"Please ensure the build plate is completely empty, then click Calibrate.",calibrationDescription:"Calibration captures a reference image of the empty plate. Future checks will compare against this reference to detect objects.",calibrationTip:"Tip: You can store up to 5 calibrations for different plates. The system automatically uses the best match when checking.",plateEmpty:"Plate appears empty",objectsDetected:"Objects detected on plate",confidence:"Confidence",difference:"Difference",analysisPreview:"Analysis preview:",analysisLegend:"Green box = detection area, Red overlay = differences from calibration",savedReferences:"Saved References ({{count}}/{{max}})",deleteReference:"Delete reference",labelPlaceholder:"Label...",clickToEdit:"{{label}} - Click to edit",clickToAddLabel:"Click to add label"},speed:{title:"Print Speed",silent:"Silent (50%)",standard:"Standard (100%)",sport:"Sport (124%)",ludicrous:"Ludicrous (166%)"},airduct:{title:"Airduct Mode",cooling:"Cooling",heating:"Heating"},noSdCard:"No SD",door:{open:"Open",closed:"Closed"},fans:{partCooling:"Part Cooling Fan",auxiliary:"Auxiliary Fan",chamber:"Chamber Fan"},clickToViewHmsErrors:"Click to view HMS errors",estimatedCompletion:"Estimated completion time",plateNumber:"Plate {{number}}",slotOptions:"Slot options",amsPopup:{friendlyName:"AMS Name",friendlyNamePlaceholder:"e.g. AMS Friendly Name",serialNumber:"Serial Number",firmwareVersion:"Firmware",save:"Save",clear:"Clear",noEditPermission:"You do not have permission to rename AMS units"},firmwareModal:{title:"Firmware Update",titleUpToDate:"Firmware Info",currentVersion:"Current:",latestVersion:"Latest:",releaseNotes:"Release Notes",checkingPrereqs:"Checking prerequisites...",sdCardReady:"SD card ready. Click below to upload firmware.",uploadedSuccess:"Firmware uploaded to SD card!",applyInstructions:"To apply the update on your printer:",step1:"On the printer's touchscreen, go to Settings",step2:"Navigate to Firmware",step3:"Select Update from SD card",step4:"The update will take 10-20 minutes",done:"Done",starting:"Starting...",uploadFirmware:"Upload Firmware",uploadFailed:"Failed to start upload: {{error}}",uploadedToast:"Firmware uploaded! Trigger update from printer screen.",availableVersions:"Available versions",usable:"Usable",unavailable:"Unavailable",installed:"Installed",newerBadge:"newer",olderBadge:"older",currentBadge:"current"},accessCodePlaceholder:"Leave empty to keep current",roi:{title:"Detection Area (ROI)",xStart:"X Start",yStart:"Y Start",width:"Width",height:"Height",instruction:"Adjust the detection area to focus on the build plate. The green box in the preview shows the current area."},developerModeWarning:"Developer LAN mode is not enabled on: {{names}}. Some features may not work.",howToEnable:"How to enable",incompatibleFile:"This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}",dropNotPrintable:"Only .gcode and .gcode.3mf files can be printed",dropToPrint:"Drop to print",cannotPrint:"Printer busy"},archives:{title:"Print Archives",searchPlaceholder:"Search archives...",filterByPrinter:"Filter by printer",filterByStatus:"Filter by status",sortBy:"Sort by",sortNewest:"Newest first",sortOldest:"Oldest first",sortName:"Name",sortDuration:"Duration",sortLargest:"Largest first",sortSmallest:"Smallest first",sortSize:"Size",noArchives:"No archives found",noArchivesSearch:"No archives match your search",originalPrintNotVisible:"Original print not visible - try clearing filters",noArchivesYet:"No archives yet",prints:"prints",pagination:{showing:"Showing",to:"to",of:"of",show:"Show",page:"Page",all:"All"},loadingArchives:"Loading archives...",releaseToUpload:"Release to upload",showAll:"Show all",showFavoritesOnly:"Show favorites only",gridView:"Grid view",listView:"List view",calendarView:"Calendar view",logView:"Print Log",manageTags:"Manage Tags",showFailedPrints:"Show failed prints",hideFailedPrints:"Hide failed prints",hideDuplicates:"Hide Duplicates",viewOriginalPrint:"Click to view original print (#{{id}})",printTime:"Print Time",filamentUsed:"Filament Used",cost:"Cost",reprint:"Reprint",preview:"Preview",deleteArchive:"Delete Archive",deleteConfirm:"Are you sure you want to delete this archive?",favorite:"Favorite",unfavorite:"Remove from favorites",viewDetails:"View Details",status:{completed:"Completed",failed:"Failed",stopped:"Stopped"},toast:{source3mfAttached:"Source 3MF attached: {{filename}}",failedUploadSource3mf:"Failed to upload source 3MF",source3mfRemoved:"Source 3MF removed",failedRemoveSource3mf:"Failed to remove source 3MF",f3dAttached:"F3D attached: {{filename}}",failedUploadF3d:"Failed to upload F3D",f3dRemoved:"F3D removed",failedRemoveF3d:"Failed to remove F3D",timelapseAttached:"Timelapse attached: {{filename}}",timelapseAlreadyAttached:"Timelapse already attached",noMatchingTimelapse:"No matching timelapse found",failedScanTimelapse:"Failed to scan for timelapse",failedAttachTimelapse:"Failed to attach timelapse",timelapseRemoved:"Timelapse removed",failedRemoveTimelapse:"Failed to remove timelapse",timelapseUploaded:"Timelapse uploaded: {{filename}}",failedUploadTimelapse:"Failed to upload timelapse",archiveDeleted:"Archive deleted",failedDeleteArchive:"Failed to delete archive",addedToFavorites:"Added to favorites",removedFromFavorites:"Removed from favorites",projectUpdated:"Project updated",failedUpdateProject:"Failed to update project",linkCopied:"Link copied to clipboard",failedCopyLink:"Failed to copy link",photoDeleted:"Photo deleted",failedDeletePhoto:"Failed to delete photo",failedDeleteArchives:"Failed to delete archives",failedUpdateFavorites:"Failed to update favorites",exportDownloaded:"Export downloaded",exportFailed:"Export failed"},menu:{print:"Print",schedule:"Schedule",openInBambuStudio:"Open in Slicer",slice:"Slice",externalLink:"External Link",viewOnMakerWorld:"View on MakerWorld",preview3d:"3D Preview",viewTimelapse:"View Timelapse",scanForTimelapse:"Scan for Timelapse",uploadTimelapse:"Upload Timelapse",removeTimelapse:"Remove Timelapse",downloadSource3mf:"Download Source 3MF",uploadSource3mf:"Upload Source 3MF",replaceSource3mf:"Replace Source 3MF",removeSource3mf:"Remove Source 3MF",uploadF3d:"Upload F3D",replaceF3d:"Replace F3D",downloadF3d:"Download F3D",removeF3d:"Remove F3D",download:"Download",copyDownloadLink:"Copy Download Link",qrCode:"QR Code",viewPhotos:"View Photos",viewPhotosCount:"View Photos ({{count}})",projectPage:"Project Page",addToFavorites:"Add to Favorites",removeFromFavorites:"Remove from Favorites",edit:"Edit",goToProject:"Go to Project: {{name}}",addToProject:"Add to Project",removeFromProject:"Remove from Project",loading:"Loading...",noProjectsAvailable:"No projects available",select:"Select",deselect:"Deselect",delete:"Delete"},permission:{noReprint:"You do not have permission to reprint this archive",noAddToQueue:"You do not have permission to add to queue",noUpdateArchives:"You do not have permission to update archives",noUploadFiles:"You do not have permission to upload files",noDownload:"You do not have permission to download archives",noCopyLink:"You do not have permission to copy download links",noDelete:"You do not have permission to delete this archive",noCreate:"You do not have permission to create archives"},platePicker:{title:"Select plate to preview",hint:"This archive has multiple plates. Pick one to open in the GCode viewer.",plateLabel:"Plate {{index}}",objectCount:"{{count}} object",objectCount_plural:"{{count}} objects",noGcode:"This archive has no sliced G-code to preview. Open it in Bambu Studio to slice first."},card:{previousPlate:"Previous plate",nextPlate:"Next plate",plateNumber:"Plate {{index}}",moreOptions:"Right-click for more options",addToFavorites:"Add to favorites",removeFromFavorites:"Remove from favorites",cancelled:"cancelled",failed:"failed",duplicate:"duplicate",duplicateTitle:"This model has been printed before",openSource3mf:"Open source 3MF in Bambu Studio (right-click for more options)",downloadF3d:"Download Fusion 360 design file",viewTimelapse:"View timelapse",viewPhoto:"View 1 photo",viewPhotos:"View {{count}} photos",openFolder:"Open folder: {{name}}",slicedFile:"Sliced file - ready to print",sourceFile:"Source file only - no AMS mapping available",gcode:"GCODE",source:"SOURCE",project:"Project: {{name}}",estimated:"Estimated: {{time}}",actual:"Actual: {{time}}",accuracy:"Accuracy: {{percent}}%",filament:"{{weight}}g",layer:"{{count}} layer",layers:"{{count}} layers",object:"{{count}} object",objects:"{{count}} objects",slicedFor:"Sliced for {{model}}",uploadedBy:"Uploaded By",noPermissionReprint:"You do not have permission to reprint",noFileForReprint:"No 3MF file available — the file could not be downloaded from the printer when the print was recorded",noPermissionEdit:"You do not have permission to edit archives",noPermissionDelete:"You do not have permission to delete archives",reprint:"Reprint",schedulePrint:"Schedule Print",schedule:"Schedule",openInBambuStudio:"Open in Slicer",openInBambuStudioToSlice:"Open in Slicer to slice",slice:"Slice",externalLink:"External Link",makerWorld:"MakerWorld: {{designer}}",viewProject:"View project",noExternalLink:"No external link",preview3d:"3D Preview",download:"Download",edit:"Edit",delete:"Delete"},modal:{deleteArchive:"Delete Archive",deleteConfirm:'Are you sure you want to delete "{{name}}"? This action cannot be undone.',deleteButton:"Delete",removeSource3mf:"Remove Source 3MF",removeSource3mfConfirm:'Are you sure you want to remove the source 3MF file from "{{name}}"? This will delete the original slicer project file.',removeButton:"Remove",removeF3d:"Remove F3D",removeF3dConfirm:'Are you sure you want to remove the Fusion 360 design file from "{{name}}"?',removeTimelapse:"Remove Timelapse",removeTimelapseConfirm:'Are you sure you want to remove the timelapse video from "{{name}}"?',timelapse:"{{name}} - Timelapse",selectTimelapse:"Select Timelapse",selectTimelapseDesc:"No auto-match found. Select the timelapse for this print:",deleteArchives:"Delete Archives",deleteArchivesConfirm:"Are you sure you want to delete {{count}} archive(s)? This action cannot be undone.",deleteCount:"Delete {{count}}"},page:{title:"Archives",printsCount:"{{filtered}} of {{total}} prints",dropFilesHere:"Drop .3mf files here",releaseToUpload:"Release to upload",only3mfSupported:"Only .3mf files are supported",close:"Close",selected:"{{count}} selected",selectAll:"Select All",tags:"Tags",project:"Project",favorite:"Favorite",delete:"Delete",toggledFavorites:"Toggled favorites for {{count}} archive(s)",failedUpdateFavorites:"Failed to update favorites",archivesDeleted:"{{count}} archive(s) deleted",failedDeleteArchives:"Failed to delete archives",photoDeleted:"Photo deleted",failedDeletePhoto:"Failed to delete photo"},list:{name:"Name",printer:"Printer",date:"Date",size:"Size",actions:"Actions",hasTimelapse:"Has timelapse"},log:{date:"Date",printName:"Print Name",printer:"Printer",user:"User",status:"Status",duration:"Duration",filament:"Filament",allPrinters:"All Printers",allUsers:"All Users",allStatuses:"All Statuses",cancelled:"Cancelled",skipped:"Skipped",dateFrom:"From",dateTo:"To",noEntries:"No print log entries found",showing:"Showing {{count}} of {{total}} entries",rowsPerPage:"Rows",page:"Page",prev:"Prev",next:"Next",clearLog:"Clear Log",clearLogTitle:"Clear Print Log",clearLogConfirm:"All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?",clearLogButton:"Clear All",cleared:"{{count}} log entries cleared",clearFailed:"Failed to clear print log"}},queue:{title:"Print Queue",subtitle:"Schedule and manage your print jobs",addToQueue:"Add to Queue",print:"Print",reprint:"Re-print",schedulePrint:"Schedule Print",editQueueItem:"Edit Queue Item",printToPrinters:"Print to {{count}} Printers",queueToPrinters:"Queue to {{count}} Printers",queueSelectedPlates:"Queue {{count}} Plates",selectAllPlates:"Select All {{count}} Plates",deselectAll:"Deselect All",printQueued:"Print queued",itemsQueued:"{{count}} items queued",sending:"Sending...",sendingProgress:"Sending {{current}}/{{total}}...",adding:"Adding...",addingProgress:"Adding {{current}}/{{total}}...",savingProgress:"Saving {{current}}/{{total}}...",clearQueue:"Clear Queue",clearHistory:"Clear History",emptyQueue:"Queue is empty",position:"Position",scheduledTime:"Scheduled Time",moveUp:"Move Up",moveDown:"Move Down",startNow:"Start Now",printingInProgress:"Printing in progress...",viewArchive:"View archive",viewInFileManager:"View in File Manager",itemCount:"{{count}} item",itemCount_plural:"{{count}} items",dragToReorder:"Drag to reorder (ASAP only)",reorderHint:"Position only affects ASAP items. Scheduled items run at their set time.",sjf:{label:"SJF",tooltip:"Shortest Job First — scheduler prioritizes shorter prints"},addedBy:"Added by {{name}}",nextInQueue:"Next in queue",clearPlateSuccess:"Plate cleared — ready for next print",plateNumber:"Plate {{index}}",quantity:"Quantity",quantityHint:"Creates {{count}} queue items",activeBatches:"Active Batches",batchProgress:"{{completed}} of {{total}} completed",cancelBatch:"Cancel Remaining",batchCancelled:"Remaining batch items cancelled",cancelBatchConfirmTitle:"Cancel Batch",cancelBatchConfirmMessage:"Cancel all remaining pending items in this batch?",batch:"Batch",sections:{currentlyPrinting:"Currently Printing",queued:"Queued",history:"History"},status:{pending:"Pending",waiting:"Waiting",printing:"Printing",paused:"Paused",completed:"Completed",failed:"Failed",skipped:"Skipped",cancelled:"Cancelled"},summary:{printing:"Printing",queued:"Queued",totalTime:"Total Queue Time",totalWeight:"Total Queue Weight",history:"History"},filter:{allPrinters:"All Printers",unassigned:"Unassigned",allStatus:"All Status",allLocations:"All Locations",any:"Any"},sort:{byPosition:"Sort by Position",byName:"Sort by Name",byPrinter:"Sort by Printer",bySchedule:"Sort by Schedule",byDate:"Sort by Date",ascendingOldest:"Ascending (oldest first)",descendingNewest:"Descending (newest first)"},badges:{staged:"Staged",requiresPrevious:"Requires previous success",autoPowerOff:"Auto power off",gcodeInjection:"G-code"},empty:{title:"No prints scheduled",description:'Schedule a print from the Archives page using the "Schedule" option in the context menu, or drag and drop files to get started.'},time:{asap:"ASAP",overdue:"Overdue",now:"Now",lessThanMinute:"In less than a minute",inMinutes:"In {{count}} min",inHours:"In {{count}} hours"},actions:{stopPrint:"Stop Print",startPrint:"Start Print",requeue:"Re-queue"},bulkEdit:{title:"Edit {{count}} Item",title_plural:"Edit {{count}} Items",description:"Only changed settings will be applied to selected items.",printer:"Printer",noChange:"— No change —",queueOptions:"Queue Options",staged:"Staged (manual start)",autoPowerOff:"Auto power off after print",requirePrevious:"Require previous success",printOptions:"Print Options",bedLevelling:"Bed levelling",flowCalibration:"Flow calibration",vibrationCalibration:"Vibration calibration",layerInspection:"First layer inspection",timelapse:"Timelapse",useAms:"Use AMS",applyChanges:"Apply Changes",selectAll:"Select All",deselectAll:"Deselect All",selected:"{{count}} selected",editSelected:"Edit Selected",cancelSelected:"Cancel Selected"},confirm:{cancelTitle:"Cancel Scheduled Print",cancelMessage:'Are you sure you want to cancel "{{name}}"?',stopTitle:"Stop Print",stopMessage:'Are you sure you want to stop the current print "{{name}}"? This will cancel the print job on the printer.',removeTitle:"Remove from History",removeMessage:'Are you sure you want to remove "{{name}}" from the queue history?',clearHistoryTitle:"Clear History",clearHistoryMessage:"Are you sure you want to remove all {{count}} item(s) from the history?",cancelButton:"Cancel Print",stopButton:"Stop Print",thisPrint:"this print",thisItem:"this item"},toast:{cancelled:"Queue item cancelled",cancelFailed:"Failed to cancel item",removed:"Queue item removed",removeFailed:"Failed to remove item",stopped:"Print stopped",stopFailed:"Failed to stop print",released:"Print released to queue",startFailed:"Failed to start print",reorderFailed:"Failed to reorder queue",historyCleared:"Cleared {{count}} history item(s)",clearHistoryFailed:"Failed to clear history",updateFailed:"Failed to update items",bulkCancelled:"Cancelled {{count}} item(s)",bulkCancelFailed:"Failed to cancel items"},timeline:{listView:"List",timelineView:"Timeline",unassigned:"Unassigned",noData:"No scheduled prints for this day",allDoneBy:"All prints estimated done by {{time}}",staged:"Staged",filterAll:"Show All",filterPrinting:"Printing",filterQueued:"Queued",time:{anyMoment:"any moment",minutesLeft:"{{minutes}}m left",hoursLeft:"{{hours}}h left",hoursMinutesLeft:"{{hours}}h {{minutes}}m left"},day:{previous:"Previous day",next:"Next day",today:"Today"}},permissions:{noStopPrint:"You do not have permission to stop prints",noStartPrint:"You do not have permission to start prints",noEdit:"You do not have permission to edit this queue item",noCancel:"You do not have permission to cancel this queue item",noRequeue:"You do not have permission to re-queue items",noRemove:"You do not have permission to remove this queue item",noClearHistory:"You do not have permission to clear all history",noEditItems:"You do not have permission to edit queue items",noCancelItems:"You do not have permission to cancel queue items"}},backgroundDispatch:{unknownFile:"Unknown file",unknownPrinter:"Unknown printer",startingPrints:"Starting prints",progressSummary:"{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}",expandDetails:"Expand dispatch details",collapseDetails:"Collapse dispatch details",dismissToast:"Dismiss dispatch toast",cancelDispatchJob:"Cancel dispatch job",cancel:"Cancel",cancelling:"Cancelling…",awaitingPrinter:"Awaiting printer…",status:{dispatched:"Dispatched",processing:"Processing",completed:"Completed",failed:"Failed",cancelled:"Cancelled"},toast:{cancellingUpload:"Cancelling upload...",cancelled:"Dispatch cancelled",cancelFailed:"Failed to cancel dispatch",completeWithFailures:"Background dispatch complete: {{completed}} succeeded, {{failed}} failed",completeSuccess:"Background dispatch complete: {{completed}} succeeded",printStartedRemaining:"{{completed}} print(s) started, {{remaining}} more sending..."}},stats:{title:"Dashboard",subtitle:"Drag widgets to rearrange. Click the eye icon to hide.",overview:"Overview",totalPrints:"Total Prints",successRate:"Success Rate",totalPrintTime:"Total Print Time",printTime:"Print Time",totalFilament:"Total Filament Used",filamentUsed:"Filament Used",filamentCost:"Filament Cost",totalCost:"Total Cost",energyUsed:"Energy Used",energyCost:"Energy Cost",energyWarmingUpTooltip:"Energy tracking is still collecting hourly snapshots. Date-range totals will become accurate once at least one snapshot exists before the selected range. Early values may undercount.",averagePrintTime:"Average Print Time",printsPerDay:"Prints per Day",byPrinter:"By Printer",printsByPrinter:"Prints by Printer",byMaterial:"By Material",byMonth:"By Month",last7Days:"Last 7 Days",last30Days:"Last 30 Days",last90Days:"Last 90 Days",allTime:"All Time",quickStats:"Quick Stats",printActivity:"Print Activity",filamentTypes:"Filament Types",filamentTrends:"Filament Trends",failureAnalysis:"Failure Analysis",timeAccuracy:"Time Accuracy",successful:"Successful:",failed:"Failed:",perfectEstimate:"100% = perfect estimate",noTimeAccuracyData:"No time accuracy data yet",noFilamentData:"No filament data available",noPrinterData:"No printer data available",noPrintData:"No print data available",noPrintDataLast30Days:"No print data in the last 30 days",failureReasons:"Failure Reasons",topFailureReasons:"Top Failure Reasons",failedPrintsCount:"{{failed}} / {{total}} prints failed",lastWeekRate:"Last week: {{rate}}%",resetLayout:"Reset Layout",recalculateCosts:"Recalculate Costs",recalculateCostsHint:"Recalculate all archive costs using current filament prices",exportStats:"Export Stats",exportAsCsv:"Export as CSV",exportAsExcel:"Export as Excel",hiddenCount:"{{count}} Hidden",exportDownloaded:"Export downloaded",exportFailed:"Export failed",layoutReset:"Layout reset",recalculatedCosts:"Recalculated costs for {{count}} archives",recalculateFailed:"Failed to recalculate costs",loadingStats:"Loading statistics...",noPermissionResetLayout:"You do not have permission to reset layout",noPermissionRecalculate:"You do not have permission to recalculate costs",noPrintDataInRange:"No print data in selected range",periodFilament:"Period Filament",periodCost:"Period Cost",avgPerPrint:"Avg per Print",usageOverTime:"Usage Over Time",filamentByWeight:"Weight",printDuration:"Print Duration",printerUtilization:"Printer Utilization",filamentSuccess:"Success by Material",printHabits:"Print Habits",printTimeOfDay:"Print Time of Day",colorDistribution:"Color Distribution",noColorData:"No color data available",records:"Records",longestPrint:"Longest Print",heaviestPrint:"Heaviest Print",mostExpensivePrint:"Most Expensive",busiestDay:"Busiest Day",successStreak:"Success Streak",streakPrint:"consecutive print",streakPrints:"{{count}} consecutive prints",printerStats:"Printer Stats",hours:"hours",avgPrints:"Avg. prints",noArchiveData:"No print data available",filamentByTime:"Time",avgWeight:"Avg. weight",avgTime:"Avg. time",filamentByPrints:"Prints",timeframe:{today:"Today","this-week":"This Week","this-month":"This Month","last-7":"Last 7 Days","last-30":"Last 30 Days","last-90":"Last 90 Days","this-year":"This Year","all-time":"All Time",custom:"Custom Range",from:"From",to:"To"},allUsers:"All Users",noUser:"No User (System)",filterByUser:"Filter by User"},maintenance:{title:"Maintenance",overview:"Overview",allOk:"All maintenance up to date",dueCount:"{{count}} item due",dueCount_plural:"{{count}} items due",warningCount:"{{count}} warning",warningCount_plural:"{{count}} warnings",totalPrintTime:"Total Print Time",nextMaintenance:"Next Maintenance",nothingDue:"Nothing due",tasks:"Tasks",lastPerformed:"Last performed",interval:"Interval",hoursRemaining:"{{hours}}h remaining",hoursOverdue:"{{hours}}h overdue",markDone:"Mark as Done",performMaintenance:"Perform Maintenance",history:"History",noHistory:"No maintenance history",editPrintHours:"Edit Print Hours",currentHours:"Current Hours",statusTab:"Status",settingsTab:"Settings",overdueCount:"{{count}} overdue",dueSoonCount:"{{count}} due soon",dueSoon:"Due soon",allGood:"All good",overdueBy:"Overdue by {{duration}}",dueIn:"Due in {{duration}}",timeLeft:"{{duration}} left",day:"1 day",days:"{{count}} days",week:"1 week",weeks:"{{count}} weeks",month:"1 month",months:"{{count}} months",year:"1 year",maintenanceTypes:"Maintenance Types",maintenanceTypesDescription:"System types and your custom maintenance tasks",addCustomType:"Add Custom Type",restoreDefaults:"Restore Default Tasks",intervalType:"Interval Type",intervalValue:"Interval ({{type}})",icon:"Icon",documentationLink:"Documentation Link (optional)",assignToPrinters:"Assign to Printers",selectAtLeastOnePrinter:"Select at least one printer",addType:"Add Type",custom:"Custom",printHours:"Print Hours",calendarDays:"Calendar Days",exampleName:"e.g., Replace HEPA Filter",viewDocumentation:"View documentation",timeBasedInterval:"Time-based interval",intervalOverrides:"Interval Overrides",intervalOverridesDescription:"Customize intervals for specific printers",assignedToPrinters:"Assigned to printers:",noPrintersAssigned:"No printers assigned",addPrinterShort:"Add:",printersAssignedClick:"{{count}} printer(s) assigned - click to manage",removeFromPrinter:"Remove from this printer",types:{lubricateCarbonRods:"Lubricate Carbon Rods",lubricateRails:"Lubricate Linear Rails",cleanNozzle:"Clean Nozzle/Hotend",checkBelts:"Check Belt Tension",cleanBuildPlate:"Clean Build Plate",checkExtruder:"Check Extruder Gears",checkCooling:"Check Cooling Fans",generalInspection:"General Inspection",cleanCarbonRods:"Clean Carbon Rods",lubricateSteelRods:"Lubricate Steel Rods",cleanSteelRods:"Clean Steel Rods",cleanLinearRails:"Clean Linear Rails",checkPtfeTube:"Check PTFE Tube",replaceHepaFilter:"Replace HEPA Filter",replaceCarbonFilter:"Replace Carbon Filter",lubricateLeftNozzleRail:"Lubricate Left Nozzle Rail"},maintenanceComplete:"Maintenance marked as complete",typeUpdated:"Maintenance type updated",typeDeleted:"Maintenance type deleted",defaultsRestored:"Restored {{count}} default task(s)",printHoursUpdated:"Print hours updated",printerAssigned:"Printer assigned",printerRemoved:"Printer removed",deleteTypeConfirm:'Delete "{{name}}"?',deleteSystemTypeTitle:"Delete default maintenance task?",deleteSystemTypeMessage:'Are you sure you want to delete the default maintenance task "{{name}}"?',noPermissionUpdate:"You do not have permission to update maintenance items",noPermissionPerform:"You do not have permission to perform maintenance",noPermissionEditTypes:"You do not have permission to edit maintenance types",noPermissionDeleteTypes:"You do not have permission to delete maintenance types",noPermissionEditHours:"You do not have permission to edit print hours",noPermissionRemovePrinter:"You do not have permission to remove printer assignments",noPermissionAssignPrinter:"You do not have permission to assign printers",noPermissionEditIntervals:"You do not have permission to edit intervals",configureSettings:"Configure maintenance types and intervals"},settings:{title:"Settings",general:"General",tabs:{general:"General",smartPlugs:"Smart Plugs",notifications:"Notifications",queue:"Workflow",filament:"Filament",network:"Network",apiKeys:"API Keys",virtualPrinter:"Virtual Printer",spoolbuddy:"SpoolBuddy",failureDetection:"Failure Detection",users:"Authentication",backup:"Backup",emailAuth:"Email Authentication",ldap:"LDAP",twoFa:"Two-Factor Auth",oidc:"SSO / OIDC"},spoolbuddy:{infoTitle:"SpoolBuddy devices",infoBody:"SpoolBuddy kiosks register themselves automatically via heartbeat. Unregister a device here if it is no longer in use or if a stale duplicate was left behind by a daemon crash.",duplicatesTitle:"{{count}} devices registered",duplicatesBody:"Only the first registered device is used by the kiosk UI. If one of these is a stale duplicate from a crash, unregister it — an online device will re-register itself on its next heartbeat.",empty:"No SpoolBuddy devices registered yet.",online:"Online",offline:"Offline",unregister:"Unregister",unregisterSuccess:"Device unregistered",unregisterError:"Failed to unregister device",confirmTitle:"Unregister SpoolBuddy device?",confirmBody:'This will remove "{{hostname}}" ({{deviceId}}) from the database. If the device is online, it will re-register itself on its next heartbeat.',ipAddress:"IP address",firmware:"Firmware",lastSeen:"Last seen",daemonUptime:"Daemon uptime",systemUptime:"System uptime",never:"never",nfc:"NFC",scale:"Scale",cpuTemp:"CPU temp",memory:"Memory",disk:"Disk",update:"Update",updateConfirmTitle:"Update Spoolbuddy daemon?",updateConfirmBody:'Trigger a software update on "{{hostname}}"? The daemon will restart once the update is applied.',restartBrowser:"Restart Browser",restartBrowserConfirmTitle:"Restart kiosk browser?",restartBrowserConfirmBody:'Restart the kiosk browser on "{{hostname}}"? The display will blank briefly.',restartDaemon:"Restart Daemon",restartDaemonConfirmTitle:"Restart Spoolbuddy daemon?",restartDaemonConfirmBody:'Restart the Spoolbuddy daemon on "{{hostname}}"? The device will go offline for a few seconds.',reboot:"Reboot",rebootConfirmTitle:"Reboot device?",rebootConfirmBody:'Reboot "{{hostname}}"? The device will be offline for around a minute.',shutdown:"Shutdown",shutdownConfirmTitle:"Shutdown device?",shutdownConfirmBody:'Shutdown "{{hostname}}"? You will need physical access to power it back on.',commandConfirm:"Confirm",commandQueued:"Command queued",commandError:"Failed to send command"},ldap:{title:"LDAP Authentication",enabledDesc:"LDAP authentication is enabled",disabledDesc:"LDAP authentication is disabled",disabledHint:"Configure and save LDAP settings below, then enable.",enabled:"LDAP authentication enabled",disabled:"LDAP authentication disabled",feature1:"Users can login with LDAP credentials",feature2:"Local admin account remains as fallback",feature3:"LDAP groups are mapped to BamBuddy groups on login",serverConfig:"LDAP Server Configuration",serverUrl:"Server URL",serverUrlHint:"Use ldaps:// for SSL or ldap:// with StartTLS",security:"Security",securityHint:"StartTLS upgrades a plain connection to TLS. LDAPS uses TLS from the start.",bindDn:"Bind DN (Service Account)",bindPassword:"Bind Password",searchBase:"Search Base DN",userFilter:"User Search Filter",userFilterHint:"{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.",autoProvision:"Auto-provision users",autoProvisionHint:"Automatically create a BamBuddy account on first LDAP login",defaultGroup:"Default group",defaultGroupNone:"— None (no fallback) —",defaultGroupHint:"Fallback group assigned when an LDAP user authenticates but is not listed in any mapped LDAP group. Leave empty to leave unmapped users without permissions.",groupMapping:"Group Mapping (JSON)",groupMappingHint:"Map LDAP group DNs to BamBuddy groups. Available groups: ",testConnection:"Test Connection",settingsSaved:"LDAP settings saved",errors:{serverRequired:"LDAP server URL is required",searchBaseRequired:"Search base DN is required",enableAuthFirst:"Enable authentication first",configureLdapFirst:"Save LDAP settings first"}},email:{smtpSettings:"SMTP Configuration",smtpHost:"SMTP Server",smtpPort:"SMTP Port",security:"Security",authentication:"Authentication",username:"Username",password:"Password",fromEmail:"From Email",fromName:"From Name",testConnection:"Test SMTP Connection",testRecipient:"Test Recipient Email",sendTest:"Send Test Email",sending:"Sending...",save:"Save Settings",saving:"Saving...",advancedAuth:"Advanced Authentication",advancedAuthEnabled:"Advanced Authentication is enabled",advancedAuthEnabledDesc:"Email-based user management features are active. New users will receive auto-generated passwords via email, and users can reset their passwords through the forgot password feature.",advancedAuthDisabled:"Advanced Authentication is disabled",advancedAuthDisabledDesc:"Enable advanced authentication to activate email-based features for user management.",enable:"Enable",disable:"Disable",feature1:"Passwords are auto-generated and emailed to new users",feature2:"Users can login with username or email",feature3:"Forgot password feature is available",feature4:"Admins can reset user passwords via email",errors:{requiredFields:"Please fill in all required fields",usernameRequired:"Username is required when authentication is enabled",enterTestEmail:"Please enter a test email address",smtpServerAndEmail:"Please fill in SMTP Server and From Email before testing",usernamePasswordRequired:"Username and Password are required when authentication is enabled",configureSmtpFirst:"Please configure and test SMTP settings first",enableAuthFirst:"Please enable authentication first to use email-based features."},success:{settingsSaved:"SMTP settings saved successfully"},securityOptions:{starttls:"STARTTLS (Port 587)",ssl:"SSL/TLS (Port 465)",none:"None (Port 25)"},authOptions:{enabled:"Enabled",disabled:"Disabled"}},appearance:"Appearance",notifications:"Notifications",smartPlugs:"Smart Plugs",spoolman:"Spoolman",updates:"Updates",language:"Language",languageDescription:"Select your preferred language",theme:"Theme",themeLight:"Light",themeDark:"Dark",themeSystem:"System",defaultView:"Default View",defaultViewDescription:"Page to show when opening the app",checkForUpdates:"Check for Updates",autoUpdate:"Auto Update",currentVersion:"Current Version",latestVersion:"Latest Version",upToDate:"You are up to date",updateAvailable:"Update available",notificationLanguage:"Notification Language",notificationLanguageDescription:"Language for push notifications",bedCooledThreshold:"Bed Cooled Threshold",bedCooledThresholdDescription:"Temperature below which the bed is considered cooled after a print",userNotificationsEnabled:"User Notifications",userNotificationsEnabledDescription:"Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.",userNotificationsDisabledHint:"Enable Advanced Authentication to use user notifications.",notificationProviders:"Notification Providers",addProvider:"Add Provider",editProvider:"Edit Provider",providerType:"Provider Type",testNotification:"Test Notification",testSuccess:"Test notification sent successfully",testFailed:"Failed to send test notification",quietHours:"Quiet Hours",quietHoursDescription:"Do not disturb during these hours",quietHoursStart:"Start",quietHoursEnd:"End",events:{title:"Notification Events",printStart:"Print Started",printComplete:"Print Completed",printFailed:"Print Failed",printStopped:"Print Stopped",printProgress:"Progress Milestones",printProgressDescription:"Notify at 25%, 50%, 75%",printerOffline:"Printer Offline",printerError:"Printer Error",filamentLow:"Low Filament",maintenanceDue:"Maintenance Due",maintenanceDueDescription:"Notify when maintenance is needed"},smartPlug:{title:"Smart Plugs",add:"Add Smart Plug",edit:"Edit Smart Plug",name:"Name",ipAddress:"IP Address",linkedPrinter:"Linked Printer",autoOn:"Auto Power On",autoOnDescription:"Turn on when print starts",autoOff:"Auto Power Off",autoOffDescription:"Turn off after print completes",offDelay:"Off Delay",offDelayMinutes:"Minutes after print",offDelayTemp:"When nozzle below temperature",currentState:"Current State",turnOn:"Turn On",turnOff:"Turn Off"},filamentTracking:"Filament Tracking",filamentTrackingDesc:"Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.",filamentChecks:"Filament checks",disableFilamentWarnings:"Disable filament warnings",disableFilamentWarningsDesc:"Don't show warnings about insufficient filament when printing or queueing",preferLowestFilament:"Prefer lowest remaining filament",preferLowestFilamentDesc:"When multiple spools match, use the one with the least filament remaining",trackingModeBuiltIn:"Built-in Inventory",trackingModeBuiltInDesc:"RFID auto-matching and usage tracking included",trackingModeSpoolmanDesc:"External filament management server",builtInFeatureRfid:"Automatically detects Bambu Lab RFID spools in AMS",builtInFeatureUsage:"Tracks filament consumption per print",builtInFeatureCatalog:"Manage spools, colors, and K-factor profiles",builtInFeatureThirdParty:"Third-party spools can be assigned to inventory spools",amsSyncButton:"Sync Weights from AMS",amsSyncTitle:"Sync Spool Weights from AMS",amsSyncMessage:"This will overwrite all inventory spool weights with the current AMS remain% values from connected printers. Use this to recover from corrupted weight data. Printers must be online.",amsSyncing:"Syncing...",amsSyncSuccess:"{{synced}} spool(s) synced, {{skipped}} skipped",amsSyncError:"Failed to sync weights from AMS",spoolmanUrl:"Spoolman URL",spoolmanUrlHint:"URL of your Spoolman server (e.g., http://localhost:7912)",spoolmanConnected:"Connected",spoolmanDisconnected:"Disconnected",status:"Status",connect:"Connect",disconnect:"Disconnect",howSyncWorks:"How Sync Works",syncInfoRfidOnly:"Only official Bambu Lab spools with RFID are synced",syncInfoAutoCreate:"New spools are auto-created in Spoolman on first sync",syncInfoThirdPartySkipped:"Non-Bambu Lab spools (third-party, refilled) are skipped",linkingExistingSpools:"Linking Existing Spools",linkingExistingSpoolsDesc:'To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".',syncMode:"Sync Mode",syncModeAuto:"Automatic",syncModeManual:"Manual Only",syncModeAutoDesc:"AMS data syncs automatically when changes are detected",syncModeManualDesc:"Only sync when manually triggered",syncAmsData:"Sync AMS Data",syncAmsDataDesc:"Manually sync printer AMS data to Spoolman",allPrinters:"All Printers",noDefaultPrinter:"No default (ask each time)",sidebarOrder:"Sidebar order",saveThumbnails:"Save thumbnails",captureFinishPhoto:"Capture finish photo",noPrintersConfigured:"No printers configured",archiveMode:{always:"Always create archive entry",never:"Never create archive entry",ask:"Ask each time"},checkForUpdatesLabel:"Check for updates",checkPrinterFirmware:"Check printer firmware",includeBetaUpdates:"Include beta versions",includeBetaUpdatesDesc:"Notify about beta and prerelease versions when checking for updates",enableRetry:"Enable retry",homeAssistantDescription:"Control smart plugs via Home Assistant",environmentManagedLabel:"(Environment Managed)",autoEnabledViaEnv:"Automatically enabled via environment variables",urlFromEnvReadOnly:"Value set by HA_URL environment variable (read-only)",tokenFromEnvReadOnly:"Value set by HA_TOKEN environment variable (read-only)",mqttConnectedTo:"Connected to",prometheusDescription:"Expose printer data in Prometheus format",noSmartPlugsTitle:"No smart plugs configured",noSmartPlugsDescription:"Add a Tasmota-based smart plug to track energy usage and automate power control.",noProvidersTitle:"No providers configured",noProvidersDescription:"Add a provider to receive alerts.",noTemplatesAvailable:"No templates available. Restart the backend to seed default templates.",apiPermissionView:"View printer status and queue",apiPermissionEdit:"Add and remove items from print queue",apiKeysEmptyTitle:"No API keys",apiKeysEmptyDescription:"Create an API key to integrate with external services.",noUsersFound:"No users found",noGroupsFound:"No groups found",noGroupsAvailable:"No groups available",passwordsDoNotMatch:"Passwords do not match",systemGroupWarning:"System group names cannot be changed",authDisabledTitle:"Authentication is Disabled",authDisabledFeature1:"Require login to access the system",authDisabledFeature2:"Create multiple users with group-based permissions",authDisabledFeature3:"Control access with 50+ granular permissions",userHasCreated:"This user has created:",userItemsQuestion:"What would you like to do with these items?",deleteUserConfirm:"Are you sure you want to delete this user?",actionCannotBeUndone:"This action cannot be undone.",addFirstSmartPlug:"Add Your First Smart Plug",providers:"Providers",log:"Log",testAll:"Test All",testResults:"Test Results",testPassedCount:"{{count}} passed",testFailedCount:"{{count}} failed",messageTemplates:"Message Templates",messageTemplatesDescription:"Customize notification messages for each event.",apiKeys:"API Keys",apiKeysDescription:"Create API keys for external integrations and webhooks.",createKey:"Create Key",apiKeyCreated:"API Key Created Successfully",apiKeyCopyWarning:"Copy this key now - it won't be shown again!",useInApiBrowser:"Use in API Browser",createNewApiKey:"Create New API Key",keyName:"Key Name",keyNamePlaceholder:"e.g., Home Assistant, OctoPrint",readStatus:"Read Status",readStatusDescription:"View printer status and queue",manageQueue:"Manage Queue",manageQueueDescription:"Add and remove items from print queue",controlPrinter:"Control Printer",controlPrinterDescription:"Pause, resume, and stop prints",unnamedKey:"Unnamed Key",lastUsed:"Last used",read:"Read",control:"Control",createFirstKey:"Create Your First Key",webhookEndpoints:"Webhook Endpoints",webhookApiKeyHint:"Use your API key in the X-API-Key header.",webhook:{getAllStatus:"Get all printer status",getSpecificStatus:"Get specific printer status",addToQueue:"Add to print queue",pausePrint:"Pause print",resumePrint:"Resume print",stopPrint:"Stop print"},apiBrowser:"API Browser",apiBrowserDescription:"Explore and test all available API endpoints.",apiKeyForTesting:"API Key for Testing",apiKeyPlaceholder:"Paste your API key here to test authenticated endpoints...",apiKeyHint:"This key will be sent as X-API-Key header with requests.",deleteApiKeyTitle:"Delete API Key",deleteApiKeyMessage:"Are you sure you want to delete this API key? Any integrations using this key will stop working.",deleteKey:"Delete Key",amsDisplayThresholds:"AMS Display Thresholds",amsThresholdsDescription:"Configure color thresholds for AMS humidity and temperature indicators.",humidity:"Humidity",goodGreen:"Good (green)",fairOrange:"Fair (orange)",aboveFairBad:"Above fair threshold shows as red (bad)",fairAlsoDryingThreshold:"This threshold is also used to trigger auto-drying when enabled",temperature:"Temperature",goodBlue:"Good (blue)",aboveFairHot:"Above fair threshold shows as red (hot)",historyRetention:"History Retention",keepSensorHistory:"Keep sensor history for",historyRetentionDescription:"Older humidity and temperature data will be automatically deleted",defaultPrintOptions:"Default Print Options",defaultPrintOptionsDescription:"Set default values for print options when starting new prints. These can be overridden per print in the print dialog.",defaultBedLevelling:"Bed Levelling",defaultBedLevellingDesc:"Auto-level bed before print",defaultFlowCali:"Flow Calibration",defaultFlowCaliDesc:"Calibrate extrusion flow",defaultVibrationCali:"Vibration Calibration",defaultVibrationCaliDesc:"Reduce ringing artifacts",defaultLayerInspect:"First Layer Inspection",defaultLayerInspectDesc:"AI inspection of first layer",defaultTimelapse:"Timelapse",defaultTimelapseDesc:"Record timelapse video",staggeredStart:"Staggered Start",staggeredStartDescription:"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.",plateClear:"Plate-Clear Confirmation",requirePlateClear:"Require plate-clear confirmation",requirePlateClearDescription:'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.',gcodeInjection:"G-code Injection",gcodeInjectionDescription:'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.',gcodeInjectionNoPrinters:"No printers found. Add printers to configure G-code snippets.",gcodeStartLabel:"Start G-code",gcodeEndLabel:"End G-code",gcodeStartPlaceholder:"G-code prepended before the print starts...",gcodeEndPlaceholder:"G-code appended after the print ends...",staggerGroupSize:"Group size",staggerGroupSizeHelp:"Printers to start simultaneously per group",staggerInterval:"Interval (minutes)",staggerIntervalHelp:"Delay between each group starting",queueDrying:"Queue Auto-Drying",queueDryingDescription:"Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.",queueDryingEnabled:"Enable auto-drying",queueDryingEnabledDescription:"Start AMS drying automatically when printer is idle and humidity is above threshold",queueDryingBlock:"Wait for drying to complete",queueDryingBlockDescription:"Block the print queue until drying finishes. When off, prints take priority over drying.",ambientDryingEnabled:"Ambient drying",ambientDryingEnabledDescription:"Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.",dryingPresets:"Drying Presets",dryingPresetsDescription:"Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.",dryingFilament:"Filament",printModal:"Print Modal",expandCustomMapping:"Expand custom mapping by default",expandCustomMappingDescription:"When printing to multiple printers, show per-printer AMS mapping expanded",authentication:"Authentication",authEnabledDescription:"Your instance is secured with user authentication",authDisabledDescription:"Enable to require login and manage user access",authDisabledMessage:"Enable authentication to create user accounts, manage permissions, and secure your Bambuddy instance.",enableAuthentication:"Enable Authentication",currentUser:"Current User",changePassword:"Change Password",admin:"Admin",users:"Users",addUser:"Add User",groups:"Groups",addGroup:"Add Group",system:"System",noDescription:"No description",userCount:"{{count}} users",permissionCount:"{{count}} permissions",createUser:"Create User",username:"Username",enterUsername:"Enter username",password:"Password",enterPassword:"Enter password (min 6 characters)",confirmPassword:"Confirm Password",confirmPasswordPlaceholder:"Confirm password",viewReleaseOnGitHub:"View release on GitHub",turnAllPlugsOn:"Turn all plugs on",turnAllPlugsOff:"Turn all plugs off",clearNotificationLogs:"Clear Notification Logs",clearLogsMessage:"This will permanently delete all notification logs older than 30 days. This action cannot be undone.",clearLogs:"Clear Logs",resetUiPreferences:"Reset UI Preferences",resetUiPreferencesMessage:"This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing.",resetPreferences:"Reset Preferences",deleteGroupTitle:"Delete Group",deleteGroupMessage:"Are you sure you want to delete this group? Users in this group will lose these permissions.",deleteGroup:"Delete Group",disableAuthenticationTitle:"Disable Authentication",disableAuthenticationMessage:"Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled.",disableAuthentication:"Disable Authentication",configureBambuddy:"Configure Bambuddy",systemDefault:"System Default",archiveSettings:"Archive Settings",newWindow:"New Window",embeddedOverlay:"Embedded Overlay",preferredSlicer:"Preferred Slicer",preferredSlicerDescription:"Choose which slicer application to open files with",useSlicerApi:"Use Slicer API",useSlicerApiDescription:'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',slicerCard:"Slicer",orcaslicerApiUrl:"OrcaSlicer sidecar URL",bambuStudioApiUrl:"Bambu Studio sidecar URL",slicerApiUrlDescription:"URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.",externalCameras:"External Cameras",costTracking:"Cost Tracking",printsOnly:"Prints Only",totalConsumption:"Total Consumption",dataManagement:"Data Management",storageUsage:"Storage Usage",storageUsageDescription:"Breakdown of data usage by category",storageUsageTotal:"Total",storageUsageErrors:"Errors",storageUsageOtherBreakdown:"Other (includes static assets, scripts, and configuration files)",storageUsageSystem:"System",storageUsageData:"Data",storageUsageUnavailable:"Storage usage information unavailable",clearNotificationLogsDescription:"Delete notification logs older than 30 days",resetUiPreferencesDescription:"Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.",enableHomeAssistant:"Enable Home Assistant",enableMqtt:"Enable MQTT",useTls:"Use TLS",enableMetricsEndpoint:"Enable Metrics Endpoint",availableMetrics:"Available Metrics",editUser:"Edit User",deleteUserTitle:"Delete User",groupName:"Group Name",leaveEmptyForAnonymous:"Leave empty for anonymous",leaveEmptyForNoAuth:"Leave empty for no authentication",enterNewPassword:"Enter new password",confirmNewPassword:"Confirm new password",enterGroupName:"Enter group name",enterDescriptionOptional:"Enter description (optional)",enterCurrentPassword:"Enter current password",enterNewPasswordMin6:"Enter new password (min 6 characters)",toast:{keyCopied:"Key copied to clipboard",copyFailed:"Failed to copy key",keyAddedToBrowser:"Key added to API Browser",clearLogsFailed:"Failed to clear logs",uiPreferencesReset:"UI preferences reset. Refreshing...",authDisabled:"Authentication disabled successfully",authDisableFailed:"Failed to disable authentication",apiKeyCreated:"API key created",apiKeyDeleted:"API key deleted",userCreated:"User created successfully",userUpdated:"User updated successfully",userDeleted:"User deleted successfully",groupCreated:"Group created successfully",groupUpdated:"Group updated successfully",groupDeleted:"Group deleted successfully",fillRequiredFields:"Please fill in all required fields",passwordsDoNotMatch:"Passwords do not match",passwordTooShort:"Password must be at least 6 characters",enterGroupName:"Please enter a group name",settingsSaved:"Settings saved",noPermissionUpdate:"You do not have permission to change settings",cameraSettingsSaved:"Camera settings saved",enterCameraUrl:"Please enter a camera URL",passwordChanged:"Password changed successfully",connectionFailed:"Connection failed",testFailed:"Test failed",cameraConnected:"Camera connected{{resolution}}"},testConnection:"Test Connection",catalog:{spoolCatalog:"Spool Catalog",spoolCatalogDescription:"Empty spool weights by brand/type. Used for automatic weight lookup when adding spools.",searchCatalog:"Search catalog...",addNewEntry:"Add New Entry",namePlaceholder:"Name (e.g., Bambu Lab - Plastic)",weight:"Weight",type:"Type",default:"Default",custom:"Custom",noMatch:"No entries match your search",empty:"No entries in catalog",deleteEntry:"Delete Entry",deleteConfirm:'Are you sure you want to delete "{{name}}"?',resetCatalog:"Reset Catalog",resetConfirm:"Reset catalog to defaults? This will remove all custom entries.",loadFailed:"Failed to load spool catalog",nameWeightRequired:"Name and weight are required",entryAdded:"Entry added",addFailed:"Failed to add entry",entryUpdated:"Entry updated",updateFailed:"Failed to update entry",entryDeleted:"Entry deleted",deleteFailed:"Failed to delete entry",resetSuccess:"Catalog reset to defaults",resetFailed:"Failed to reset catalog",exported:"Exported {{count}} entries",imported:"Imported {{added}} entries ({{skipped}} skipped)",importFailed:"Failed to import: invalid JSON format",exportTooltip:"Export catalog to JSON",importTooltip:"Import catalog from JSON",resetTooltip:"Reset to defaults",selectedCount:"{{count}} selected",deleteSelected:"Delete Selected",bulkDeleteConfirm:"Are you sure you want to delete {{count}} entries?",bulkDeleted:"Deleted {{count}} entries",bulkDeleteFailed:"Failed to delete entries"},colorCatalog:{title:"Color Catalog",description:"Filament colors by manufacturer/material. Used for automatic color lookup when adding spools.",searchColors:"Search colors...",allManufacturers:"All manufacturers",addNewColor:"Add New Color",manufacturer:"Manufacturer",colorName:"Color Name",hex:"Hex",materialOptional:"Material (optional)",showing:"Showing {{filtered}} of {{total}} colors",noMatch:"No colors match your search",empty:"No colors in catalog",deleteColor:"Delete Color",deleteConfirm:'Are you sure you want to delete "{{name}}"?',resetCatalog:"Reset Color Catalog",resetConfirm:"Reset catalog to defaults? This will remove all custom colors.",sync:"Sync",starting:"Starting...",syncTooltip:"Sync from FilamentColors.xyz (2000+ colors, may take a minute)",loadFailed:"Failed to load color catalog",fieldsRequired:"Manufacturer, color name, and hex color are required",colorAdded:"Color added",addFailed:"Failed to add color",colorUpdated:"Color updated",updateFailed:"Failed to update color",colorDeleted:"Color deleted",deleteFailed:"Failed to delete color",resetSuccess:"Color catalog reset to defaults",resetFailed:"Failed to reset catalog",syncUpToDate:"Already up to date ({{count}} colors checked)",syncComplete:"Added {{added}} new colors ({{skipped}} already existed)",syncError:"Sync error",syncFailed:"Failed to sync from FilamentColors.xyz",exported:"Exported {{count}} colors",imported:"Imported {{added}} colors ({{skipped}} skipped)",importFailed:"Failed to import: invalid JSON format",selectedCount:"{{count}} selected",deleteSelected:"Delete Selected",bulkDeleteConfirm:"Are you sure you want to delete {{count}} colors?",bulkDeleted:"Deleted {{count}} colors",bulkDeleteFailed:"Failed to delete colors"},dateFormat:"Date Format",dateFormatUs:"US (MM/DD/YYYY)",dateFormatEu:"EU (DD/MM/YYYY)",dateFormatIso:"ISO (YYYY-MM-DD)",timeFormat:"Time Format",timeFormat12:"12-hour (3:30 PM)",timeFormat24:"24-hour (15:30)",defaultPrinter:"Default Printer",defaultPrinterDescription:"Pre-select this printer for uploads, reprints, and other operations.",slicerBambuStudio:"Bambu Studio",slicerOrcaSlicer:"OrcaSlicer",sidebarOrderDescription:"Drag items in the sidebar to reorder. Reset to default order here.",setDefault:"Set Default",sidebarOrderSetDefaultHint:"Set default applies the current menu order to users who haven't customized theirs.",sidebarDefaultSet:"Default menu order has been set.",sidebarDefaultCleared:"Default menu order cleared.",sidebarDefaultFailed:"Failed to set default menu order.",reset:"Reset",darkMode:"Dark Mode",lightMode:"Light Mode",active:"(active)",background:"Background",accent:"Accent",style:"Style",bgNeutral:"Neutral",bgWarm:"Warm",bgCool:"Cool",bgOled:"OLED Black",bgSlate:"Slate Blue",bgForest:"Forest Green",accentGreen:"Green",accentTeal:"Teal",accentBlue:"Blue",accentOrange:"Orange",accentPurple:"Purple",accentRed:"Red",styleClassic:"Classic",styleGlow:"Glow",styleVibrant:"Vibrant",themeToggleHint:"Toggle between dark and light mode using the sun/moon icon in the sidebar.",autoArchivePrints:"Auto-archive prints",autoArchiveDescription:"Automatically save 3MF files when prints complete",saveThumbnailsDescription:"Extract and save preview images from 3MF files",captureFinishPhotoDescription:"Take a photo from printer camera when print completes",ffmpegNotInstalled:"ffmpeg not installed",ffmpegRequired:"Camera capture requires ffmpeg. Install it via brew install ffmpeg (macOS) or apt install ffmpeg (Linux).",camera:"Camera",cameraViewMode:"Camera View Mode",cameraOverlayDescription:"Camera opens in a resizable overlay on the main screen",cameraWindowDescription:"Camera opens in a separate browser window",externalCamerasDescription:"Configure external cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, HTTP snapshots, and USB cameras (V4L2). When enabled, the external camera is used for live view and finish photos.",cameraPlaceholderUsb:"Device path (/dev/video0)",cameraPlaceholderUrl:"Camera URL (rtsp://... or http://...)",cameraTypeMjpeg:"MJPEG Stream",cameraTypeRtsp:"RTSP Stream",cameraTypeSnapshot:"HTTP Snapshot",cameraTypeUsb:"USB Camera (V4L2)",cameraRotation:"Rotation",test:"Test",connected:"Connected",disconnected:"Disconnected",currency:"Currency",defaultFilamentCost:"Default filament cost (per kg)",electricityCost:"Electricity cost per kWh",energyDisplayMode:"Energy display mode",energyModePrintDescription:"Dashboard shows sum of energy used during prints",energyModeTotalDescription:"Dashboard shows lifetime energy from smart plugs",fileManager:"File Manager",createArchiveEntry:"Create Archive Entry When Printing",createArchiveEntryDescription:"When printing from File Manager, optionally create an archive entry",lowDiskSpaceWarning:"Low Disk Space Warning",lowDiskSpaceDescription:"Show warning when free disk space falls below this threshold",printerFirmware:"Printer Firmware",checkFirmwareDescription:"Check for printer firmware updates from Bambu Lab",bambuddySoftware:"Bambuddy Software",autoCheckDescription:"Automatically check for new versions on startup",checkNow:"Check now",updateAvailableVersion:"Update available: v{{version}}",releaseNotes:"Release Notes",updateViaDocker:"Update via Docker Compose:",installUpdate:"Install Update",latestVersionRunning:"You're running the latest version",failedToCheckUpdates:"Failed to check for updates: {{error}}",backupRestore:"Backup & Restore",backupRestoreDescription:"Export/import settings and configure GitHub backup",goToBackup:"Go to Backup",externalUrl:"External URL",externalUrlDescription:"The external URL where Bambuddy is accessible. Used for notification images and external integrations.",bambuddyUrl:"Bambuddy URL",externalUrlHint:"Include protocol and port (e.g., http://192.168.1.100:8000)",ftpRetry:"FTP Retry",ftpRetryDescription:"Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.",autoRetryDescription:"Automatically retry failed FTP operations",retryAttempts:"Retry attempts",retryDelay:"Retry delay",connectionTimeout:"Connection timeout",time_one:"{{count}} time",time_other:"{{count}} times",second_one:"{{count}} second",second_other:"{{count}} seconds",nSeconds:"{{count}} seconds",increaseForWeakWifi:"Increase for printers with weak WiFi",homeAssistant:"Home Assistant",homeAssistantFullDescription:"Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.",homeAssistantUrl:"Home Assistant URL",longLivedAccessToken:"Long-Lived Access Token",haTokenHint:"Create a token in HA: Profile → Long-Lived Access Tokens → Create Token",connectionSuccessful:"Connection Successful",connectionFailed:"Connection Failed",haConnectionSuccess:"Successfully connected to Home Assistant.",haConnectionFailed:"Failed to connect to Home Assistant.",mqttPublishing:"MQTT Publishing",mqttDescription:"Publish BamBuddy events to an external MQTT broker for integration with Node-RED, Home Assistant, and other automation systems.",mqttEnableDescription:"Publish events to external MQTT broker",brokerHostname:"Broker hostname",port:"Port",usernameOptional:"Username (optional)",passwordOptional:"Password (optional)",topicPrefix:"Topic prefix",topicPrefixHint:"Topics will be: {{prefix}}/printers//status, etc.",prometheusMetrics:"Prometheus Metrics",prometheusEndpointDescription:"Expose printer metrics at /api/v1/metrics for Prometheus/Grafana monitoring.",bearerTokenOptional:"Bearer Token (optional)",bearerTokenHint:"If set, requests must include Authorization: Bearer ",metricsConnectionStatus:"Connection status",metricsPrinterState:"Printer state (idle/printing/etc)",metricsPrintProgress:"Print progress 0-100%",metricsBedTemp:"Bed temperature",metricsNozzleTemp:"Nozzle temperature",metricsPrintsTotal:"Total prints by result",metricsMore:"...and more (layers, fans, queue, filament usage)",smartPlugsDescription:"Connect smart plugs (Tasmota or Home Assistant) to automate power control and track energy usage for your printers.",allOn:"All On",allOff:"All Off",addSmartPlug:"Add Smart Plug",energySummary:"Energy Summary",currentPower:"Current Power",plugsOnline:"{{reachable}}/{{total}} plugs online",today:"Today",yesterday:"Yesterday",total:"Total",enablePlugsForSummary:"Enable plugs to see energy summary",addNotificationProvider:"Add",systemBadge:"(System)",creating:"Creating...",changing:"Changing...",deleteUserAndItems:"Delete user AND their items",deleteUserKeepItems:"Delete user, keep items (become ownerless)",ok:"OK",twoFa:{totpTitle:"Authenticator App (TOTP)",totpDesc:"Use an authenticator app like Google Authenticator, Aegis or Authy.",emailOtpTitle:"Email OTP",emailOtpDesc:"Send a one-time code to {{email}} when you log in.",emailOtpNoEmail:"Add an email address to your account to enable this method.",addEmailFirst:"Your account has no email address. Ask an admin to add one before enabling Email OTP.",setupTotp:"Set up Authenticator App",setupAuthApp:"Set up Authenticator App",setupInstructions:"Scan the QR code below with your authenticator app, then confirm with a code.",manualEntry:"Can't scan? Enter this secret manually:",scannedContinue:"I've scanned the code — continue",enterCodeToConfirm:"Enter the 6-digit code from your authenticator app to confirm setup.",activate:"Activate",disableTotp:"Disable Authenticator",disableConfirmHint:"Enter a valid TOTP code or a backup code to disable the authenticator.",totpDisabled:"Authenticator app disabled.",emailOtpEnabled:"Email OTP enabled.",emailOtpDisabled:"Email OTP disabled.",smtpRequired:"Please configure and test SMTP settings first.",invalidCode:"Invalid code. Please try again.",enableEmailOtp:"Enable Email OTP",disableEmailOtp:"Disable Email OTP",emailSetupEnterCode:"A verification code has been sent to your email address. Enter it below to confirm you own this inbox.",verifyAndEnable:"Verify & Enable",emailDisablePasswordHint:"Enter your account password to confirm disabling email OTP.",passwordPlaceholder:"Enter your password",backupCodesTitle:"Save your backup codes",backupCodesWarning:"Save these codes somewhere safe. Each code can only be used once and they will not be shown again.",backupCodesRemaining:"{{count}} backup codes remaining",savedCodes:"I've saved my codes",regenBackup:"Regenerate Backup Codes",regenBackupHint:"Enter your current TOTP code to generate 10 new backup codes. All existing backup codes will be invalidated.",newBackupCodes:"New backup codes",linkedAccounts:"Linked SSO Accounts",linkedAccountsDesc:"These external identity providers are linked to your account.",oidcUnlinked:"Account unlinked."},oidc:{title:"SSO / OIDC Providers",desc:"Configure OpenID Connect providers to allow single sign-on via external identity providers.",addProvider:"Add Provider",newProvider:"New Provider",empty:"No OIDC providers configured yet.",created:"Provider created.",updated:"Provider updated.",deleted:"Provider deleted.",deleteTitle:"Delete Provider",deleteMessage:'Delete "{{name}}"? All linked user accounts will be disconnected.',form:{name:"Display Name",issuerUrl:"Issuer URL",clientId:"Client ID",clientSecret:"Client Secret",scopes:"Scopes",iconUrl:"Icon URL (optional)",enabled:"Enabled",autoCreate:"Auto-create users",autoCreateDesc:"Automatically create a local account on first login.",autoLink:"Auto-link existing accounts",autoLinkDesc:"Link existing local accounts by matching email on first login.",secretHint:"leave blank to keep current",secretPlaceholder:"new secret",emailClaim:"Email Claim",emailClaimDesc:"JWT claim used as email identity. Use 'preferred_username' or 'upn' for Azure Entra ID (which does not send email_verified). Only use trusted claim names.",emailClaimPlaceholder:"email",requireEmailVerified:"Require email verified",requireEmailVerifiedDesc:"Only accept the email claim when the provider marks it as verified.",requireEmailVerifiedWarning:"Warning: email will be accepted even without verification. Use only with trusted providers.",requireEmailVerifiedAutoLink:"Disable auto-link first to change this setting."}}},notification:{printStarted:{title:"Print Started",body:"{{printer}}: {{filename}} has started printing"},printCompleted:{title:"Print Completed",body:"{{printer}}: {{filename}} completed successfully"},printFailed:{title:"Print Failed",body:"{{printer}}: {{filename}} has failed"},printStopped:{title:"Print Stopped",body:"{{printer}}: {{filename}} was stopped"},printProgress:{title:"Print Progress",body:"{{printer}}: {{filename}} is {{percent}}% complete"},printerOffline:{title:"Printer Offline",body:"{{printer}} is offline"},printerError:{title:"Printer Error",body:"{{printer}}: {{error}}"},filamentLow:{title:"Low Filament",body:"{{printer}}: Filament is running low"},maintenanceDue:{title:"Maintenance Due",body:"{{printer}}: {{items}} need attention"}},errors:{generic:"Something went wrong",networkError:"Network error. Please check your connection.",notFound:"Not found",unauthorized:"Unauthorized",serverError:"Server error",validationError:"Please check your input",printerConnectionFailed:"Failed to connect to printer",saveFailed:"Failed to save changes",deleteFailed:"Failed to delete",loadFailed:"Failed to load data"},hmsErrors:{title:"Errors - {{name}}",noErrors:"No errors",viewOnWiki:"View on Bambu Lab Wiki",clearInstructions:"Clear errors on the printer to dismiss them here.",clearErrors:"Clear Errors",clearSuccess:"HMS errors cleared",clearFailed:"Failed to clear HMS errors"},mqttDebug:{title:"MQTT Debug Log",searchPlaceholder:"Search topic or payload...",noMessages:"No messages logged yet",startLoggingHint:'Click "Start Logging" to begin capturing MQTT messages',noMessagesMatch:"No messages match your filter",adjustFilterHint:"Try adjusting your search or filter criteria",incoming:"Incoming",outgoing:"Outgoing",loggingStopped:"Logging stopped",loggingActive:"Logging active - messages will auto-refresh",startLogging:"Start Logging",stopLogging:"Stop Logging",clearLog:"Clear Log",topic:"Topic",timestamp:"Timestamp",direction:"Direction",all:"All"},printerFiles:{title:"File Manager",storageUsed:"Used:",storageFree:"Free:",filterPlaceholder:"Filter files...",deleteButton:"Delete",deleteFiles:"Delete {{count}} Files",deleteFileConfirm:'Delete "{{name}}"? This cannot be undone.',deleteFilesConfirm:"Delete {{count}} selected files? This cannot be undone.",noFiles:"No files on printer",loadingFiles:"Loading files...",failedToLoad:"Failed to load files",toast:{filesDeleted:"Deleted {{count}} file(s)",deleteFailed:"Delete failed: {{error}}"}},confirm:{delete:"Are you sure you want to delete this?",unsavedChanges:"You have unsaved changes. Are you sure you want to leave?",clearQueue:"Are you sure you want to clear the queue?"},login:{title:"Bambuddy Login",subtitle:"Sign in to your account",username:"Username",usernamePlaceholder:"Enter your username",usernameOrEmail:"Username or Email",usernameOrEmailPlaceholder:"Username or @ Email",password:"Password",passwordPlaceholder:"Enter your password",signIn:"Sign in",signingIn:"Logging in...",rememberMe:"Remember Me",forgotPassword:"Forgot your password?",loginSuccess:"Logged in successfully",loginFailed:"Login failed",enterCredentials:"Please enter username and password",enterEmail:"Please enter your email address",oidcLoginFailed:"OIDC login failed",oidcErrors:{providerError:"The identity provider returned an error",missingParameters:"OIDC callback is missing required parameters",invalidState:"OIDC state is invalid or has already been used",stateExpired:"OIDC login session expired — please try again",providerNotFound:"OIDC provider not found",discoveryFailed:"Failed to fetch OIDC discovery document",invalidDiscovery:"OIDC discovery document is invalid",networkError:"Network error during OIDC token exchange",badResponse:"Unexpected response during OIDC token exchange",noIdToken:"OIDC provider did not return an ID token",validationFailed:"OIDC token validation failed",nonceMismatch:"OIDC nonce mismatch — possible replay attack",missingSubClaim:"OIDC token is missing the sub claim",noLinkedAccount:"No local account is linked to this OIDC identity",accountInactive:"Your account is inactive",userResolutionFailed:"Failed to resolve your account",internalError:"An internal error occurred during OIDC login",tokenExchangeFailed:"OIDC token exchange failed"},forgotPasswordTitle:"Forgot Password",forgotPasswordMessage:"If you've forgotten your password, please contact your system administrator to reset it.",forgotPasswordEmailMessage:"Enter your email address and we'll send you a new password.",emailAddress:"Email Address",emailPlaceholder:"your.email@example.com",cancel:"Cancel",sending:"Sending...",sendResetEmail:"Send Reset Email",howToReset:"How to reset your password:",resetStep1:"Contact your Bambuddy administrator",resetStep2:"Ask them to reset your password in User Management",resetStep3:"They can set a new temporary password for you",resetStep4:"Log in with the new password and change it in Settings",gotIt:"Got it",resetPassword:{title:"Set New Password",subtitle:"Enter and confirm your new password below.",newPassword:"New Password",newPasswordPlaceholder:"At least 8 characters",confirmPassword:"Confirm Password",confirmPasswordPlaceholder:"Repeat new password",saving:"Saving…",submit:"Set New Password",backToLogin:"Back to login",passwordsDoNotMatch:"Passwords do not match",passwordTooShort:"Password must be at least 8 characters",resetFailed:"Password reset failed. The link may have expired."},twoFA:{title:"Two-Factor Authentication",subtitle:"Your account is protected with 2FA. Enter the verification code below.",methodAuthenticator:"Authenticator App",methodEmail:"Email Code",methodBackup:"Backup Code",instructionsTotp:"Open your authenticator app and enter the 6-digit code for Bambuddy.",instructionsEmail:"A 6-digit code has been sent to your email address. It expires in 10 minutes.",instructionsEmailNotSent:"Click the button below to receive a verification code via email.",instructionsBackup:"Enter one of your 8-character backup recovery codes. Each code can only be used once.",sendCodeButton:"Send Code via Email",sendingCode:"Sending...",resendCode:"Resend code",codeLabel:"Verification Code",backupCodeLabel:"Backup Code",codePlaceholder:"000000",backupCodePlaceholder:"XXXXXXXX",verifyButton:"Verify",verifyingButton:"Verifying...",backToLogin:"← Back to login",orContinueWith:"or continue with",signInWith:"Sign in with {{provider}}",enterCode:"Please enter the verification code",sendCodeFailed:"Failed to send verification code",invalidCode:"Invalid code. Please try again."}},setup:{title:"Bambuddy Setup",subtitle:"Configure authentication for your Bambuddy instance",enableAuth:"Enable Authentication",adminAccount:"Admin Account",adminAccountDesc:"If admin users already exist, authentication will be enabled using the existing admin accounts. Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.",adminUsername:"Admin Username",adminPassword:"Admin Password",optionalIfAdminExists:"(optional if admin users exist)",adminUsernamePlaceholder:"Enter admin username (optional)",adminPasswordPlaceholder:"Enter admin password (optional)",confirmPassword:"Confirm Password",confirmPasswordPlaceholder:"Confirm admin password",settingUp:"Setting up...",completeSetup:"Complete Setup",toast:{authEnabledAdminCreated:"Authentication enabled and admin user created",authEnabledExistingAdmins:"Authentication enabled using existing admin users",setupCompleted:"Setup completed",enterBothCredentials:"Please enter both admin username and password, or leave both empty to use existing admin users",passwordsDoNotMatch:"Passwords do not match",passwordTooShort:"Password must be at least 6 characters"}},changePassword:{title:"Change Password",currentPassword:"Current Password",currentPasswordPlaceholder:"Enter current password",newPassword:"New Password",newPasswordPlaceholder:"Enter new password (min 6 characters)",confirmPassword:"Confirm New Password",confirmPasswordPlaceholder:"Confirm new password",passwordsDoNotMatch:"Passwords do not match",passwordTooShort:"Password must be at least 6 characters",changing:"Changing...",success:"Password changed successfully",failed:"Failed to change password"},plateAlert:{title:"Print Paused!",message:"Objects detected on build plate. The print has been automatically paused. Please clear the plate and resume the print.",understand:"I Understand"},camera:{title:"Camera View",invalidPrinterId:"Invalid printer ID",live:"Live",snapshot:"Snapshot",restartStream:"Restart stream",refreshSnapshot:"Refresh snapshot",fullscreen:"Fullscreen",exitFullscreen:"Exit fullscreen",connectingToCamera:"Connecting to camera...",capturingSnapshot:"Capturing snapshot...",connectionLost:"Connection lost",connectionFailed:"Camera connection failed",reconnecting:"Reconnecting in {{countdown}}s... (attempt {{attempt}}/{{max}})",reconnectNow:"Reconnect now",cameraUnavailable:"Camera unavailable",cameraUnavailableDesc:"Make sure the printer is powered on and connected.",noCamera:"No camera available",retry:"Retry",cameraStream:"Camera stream",zoomOut:"Zoom out",zoomIn:"Zoom in",resetZoom:"Reset zoom",recording:"Recording",startRecording:"Start Recording",stopRecording:"Stop Recording",chamberLight:"Toggle chamber light"},groups:{title:"Group Management",subtitle:"Manage permission groups for access control",backToSettings:"Back to Settings",createGroup:"Create Group",noPermission:"You do not have permission to access this page.",system:"System",noDescription:"No description",usersCount:"{{count}} users",permissionsCount:"{{count}} permissions",edit:"Edit",delete:"Delete",toast:{created:"Group created successfully",updated:"Group updated successfully",deleted:"Group deleted successfully",enterGroupName:"Please enter a group name"},modal:{editGroup:"Edit Group",createGroup:"Create Group",cancel:"Cancel",saving:"Saving...",creating:"Creating...",saveChanges:"Save Changes"},form:{groupName:"Group Name",groupNamePlaceholder:"Enter group name",systemGroupWarning:"System group names cannot be changed",description:"Description",descriptionPlaceholder:"Enter description (optional)",permissions:"Permissions ({{count}} selected)"},deleteModal:{title:"Delete Group",message:"Are you sure you want to delete this group? Users in this group will lose these permissions.",confirm:"Delete Group"},editor:{title:"Edit Group",createTitle:"Create Group",search:"Search permissions...",selectAll:"Select All",clearAll:"Clear All",permissionsSelected:"{{count}} selected",noResults:"No permissions match your search"}},users:{title:"User Management",subtitle:"Manage users and their access to your Bambuddy instance",backToSettings:"Back to Settings",createUser:"Create User",noPermission:"You do not have permission to access this page.",admin:"Admin",noGroups:"No groups",active:"Active",inactive:"Inactive",edit:"Edit",delete:"Delete",system:"System",noGroupsAvailable:"No groups available",table:{username:"Username",groups:"Groups",status:"Status",actions:"Actions"},toast:{created:"User created successfully",updated:"User updated successfully",deleted:"User deleted successfully",fillRequired:"Please fill in all required fields",passwordsDoNotMatch:"Passwords do not match",passwordTooShort:"Password must be at least 6 characters"},modal:{createUser:"Create User",editUser:"Edit User",cancel:"Cancel",creating:"Creating...",saving:"Saving...",saveChanges:"Save Changes",advancedAuthSubtitle:"with Advanced Authentication"},form:{username:"Username",usernamePlaceholder:"Enter username",email:"Email",emailPlaceholder:"user@example.com",password:"Password",passwordPlaceholder:"Enter password",confirmPassword:"Confirm Password",confirmPasswordPlaceholder:"Confirm password",newPasswordPlaceholder:"Enter new password",confirmNewPasswordPlaceholder:"Confirm new password",leaveBlankToKeep:"leave blank to keep current",groups:"Groups",optional:"optional",autoGeneratedPassword:"A secure password will be automatically generated and emailed to the user.",passwordManagedByAdvancedAuth:'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.',resetPassword:"Reset Password",resettingPassword:"Resetting Password..."},deleteModal:{title:"Delete User",message:"Are you sure you want to delete this user? This action cannot be undone.",confirm:"Delete User"}},streamOverlay:{title:"Stream Overlay",invalidPrinterId:"Invalid printer ID",cameraStream:"Camera stream",progress:"Progress",eta:"ETA",printerIdle:"Printer is idle",printerOffline:"Printer offline",status:{printing:"Printing",paused:"Paused",finished:"Finished",failed:"Failed",idle:"Idle",unknown:"Unknown"}},profiles:{title:"Profiles",subtitle:"Manage your slicer presets and pressure advance calibrations",tabs:{cloud:"Cloud Profiles",local:"Local Profiles",kprofiles:"K-Profiles"},localProfiles:{title:"Local Profiles",subtitle:"Import and manage slicer presets from OrcaSlicer",import:"Import Profiles",importDesc:"Drop .bbscfg, .bbsflmt, .orca_filament, .zip, or .json files here",importing:"Importing...",search:"Search local presets...",noPresets:"No local presets yet",badge:"Local",edit:"Edit",delete:"Delete",cancel:"Cancel",deleteConfirmTitle:"Delete Preset",deleteConfirm:"Are you sure you want to delete this preset? This cannot be undone.",source:"Source",inheritsFrom:"Inherits",filamentType:"Type",vendor:"Vendor",compatiblePrinters:"Printers",nozzleTemp:"Nozzle Temp",cost:"Cost",density:"Density",pressureAdvance:"Pressure Advance",filament:"Filament",process:"Process",printer:"Printer",toast:{importSuccess:"{{count}} preset(s) imported",importSkipped:"{{count}} preset(s) skipped (duplicates)",importError:"{{count}} error(s) during import",deleted:"Preset deleted",updated:"Preset updated"}},connectedAs:"Connected as",logout:"Logout",noLogoutPermission:"You do not have permission to logout",failedToLoad:"Failed to load profiles",retry:"Retry",time:{justNow:"Just now",minsAgo:"{{count}}m ago",hoursAgo:"{{count}}h ago",daysAgo:"{{count}}d ago"},toast:{loggedOut:"Logged out"},login:{title:"Connect to Bambu Cloud",subtitle:"Sync your slicer presets across devices",email:"Email",password:"Password",region:"Region",regionGlobal:"Global",regionChina:"China",verificationCode:"Verification Code",totpCode:"Authenticator Code",checkEmail:"Check your email ({{email}}) for a 6-digit code",enterTotpHint:"Enter the 6-digit code from your authenticator app",accessToken:"Access Token",accessTokenHint:"Paste your Bambu Lab access token (from Bambu Studio)",back:"Back",loginButton:"Login",verifyButton:"Verify",setTokenButton:"Set Token",useToken:"Use access token instead",useEmail:"Login with email instead",toast:{loggedIn:"Logged in successfully",codeSent:"Verification code sent to your email",enterTotp:"Enter code from your authenticator app",tokenSet:"Token set successfully"}},presets:{myPreset:"My preset (editable)",duplicate:"Duplicate",editable:"Editable",failedToLoadDetails:"Failed to load preset details",deleteConfirm:"Delete this preset?",deleteWarning:'This will permanently delete "{{name}}" from Bambu Cloud. This cannot be undone.',noDuplicatePermission:"You do not have permission to duplicate presets",noEditPermission:"You do not have permission to edit presets",noDeletePermission:"You do not have permission to delete presets",types:{filament:"Filament preset",printer:"Printer preset",process:"Process preset"},toast:{deleted:"Preset deleted",created:"Preset created",updated:"Preset updated",duplicated:"Preset duplicated",fieldAdded:'Field "{{key}}" added',exported:"Preset exported"},baseLabel:"Base: {{name}}",currentLabel:"Current: {{name}}",newPreset:"New Preset",editPreset:"Edit Preset",duplicatePreset:"Duplicate Preset",createNewPreset:"Create New Preset",customizeSettings:"Customize settings for your new preset",compareWithBase:"Compare with base preset",compare:"Compare",basePreset:"Base Preset",selectBasePreset:"Select base preset...",presetName:"Preset Name",myCustomPreset:"My custom preset",inheritsFrom:"Inherits from",dropJsonToImport:"Drop JSON to import",tabs:{common:"Common",allFields:"All Fields"},availableFields:"Available Fields",searchFieldsPlaceholder:"Search fields...",noMatchingFields:"No matching fields",allFieldsAdded:"All fields added",addCustomField:"Add custom field",yourOverrides:"Your Overrides",noOverridesYet:"No overrides yet",clickFieldsToAdd:"Click fields on the left to add them",saveAsTemplate:"Save as template",jsonTip:"Tip: Drag & drop a .json file anywhere on this modal to import settings"},cloudView:{searchPlaceholder:"Search presets...",templates:"Templates",refresh:"Refresh",newPreset:"New Preset",clearFilters:"Clear filters",compareMode:"Compare Mode",selectAnotherPreset:"Select another {{type}} preset",clickTwoPresets:"Click two presets of the same type to compare",selectFirst:"1. Select first",selectSecond:"2. Select second",compareNow:"Compare Now",lastSynced:"Last synced:",showingCount:"Showing {{showing}} of {{total}} presets",noPresetsFound:"No presets found",columns:{filament:"Filament",process:"Process",printer:"Printer"},noFilamentPresets:"No filament presets",noProcessPresets:"No process presets",noPrinterPresets:"No printer presets",filters:{type:"Type",owner:"Owner",printer:"Printer",nozzle:"Nozzle",filament:"Filament",layer:"Layer",all:"All",myPresets:"My Presets",builtIn:"Built-in",process:"Process"},noTemplatesPermission:"You do not have permission to manage templates",noRefreshPermission:"You do not have permission to refresh profiles",noCreatePermission:"You do not have permission to create presets"},templates:{title:"Quick Templates",noTemplates:"No templates yet",createFirst:"Create templates from the preset editor",typeFilter:"Type:",deleteTitle:"Delete Template",deleteWarning:"This action cannot be undone",deleteConfirm:'Are you sure you want to delete "{{name}}"?',namePlaceholder:"Template name",descriptionPlaceholder:"Description",settingsJson:"Settings (JSON)",fieldsCount:"{{count}} fields",shownInModals:"Shown in modals",hiddenInModals:"Hidden in modals",apply:"Apply",toast:{deleted:"Template deleted",updated:"Template updated",created:"Template created",applied:"Template applied"}}},support:{debugLoggingActive:"Debug logging is active",manageLogs:"Manage",collectItem7:"Printer connectivity and firmware versions",collectItem8:"Integration status (Spoolman, MQTT, HA)",collectItem9:"Network interfaces (subnets only)",collectItem10:"Python package versions",collectItem11:"Database health checks",collectItem12:"Docker environment details"},fileManager:{title:"File Manager",subtitle:"Organize and manage your print files",uploadFiles:"Upload Files",newFolder:"New Folder",folderName:"Folder Name",folderNamePlaceholder:"e.g., Functional Parts",renameFile:"Rename File",renameFolder:"Rename Folder",moveFiles:"Move {{count}} File(s)",rootNoFolder:"Root (No Folder)",current:"current",linkFolder:"Link Folder",linkFolderDescription:'Link "{{name}}" to a project or archive for quick access.',project:"Project",archive:"Archive",noProjectsFound:"No projects found",noArchivesFound:"No archives found",unlink:"Unlink",link:"Link",dragDropFiles:"Drag & drop files here",dropFilesHere:"Drop files here",orClickToBrowse:"or click to browse",allFileTypesSupported:"All file types supported. ZIP files will be extracted.",zipFilesDetected:"ZIP files detected",zipExtractOptions:"ZIP files will be extracted. Choose how to handle folder structure:",preserveZipStructure:"Preserve folder structure from ZIP",createFolderFromZip:"Create folder from ZIP filename",stlThumbnailGeneration:"STL thumbnail generation",zipMayContainStl:"ZIP files may contain STL files. Thumbnails can be generated during extraction.",thumbnailsCanBeGenerated:"Thumbnails can be generated for STL files. Large models may take longer to process.",generateThumbnailsForStl:"Generate thumbnails for STL files",threemfDetected:"3MF files detected",threemfExtractionInfo:"Printer model, material, color, and print settings will be automatically extracted from 3MF files.",willBeExtracted:"Will be extracted",filesExtracted:"{{count}} files extracted",uploadComplete:"Upload complete: {{succeeded}} succeeded",uploadFailed:"Upload failed",zipFilesFailed:"{{count}} files failed",uploading:"Uploading...",changeLink:"Change Link...",linkTo:"Link to...",linkToProjectOrArchive:"Link to project or archive",addToQueue:"Add to Queue",schedulePrint:"Schedule",generateThumbnail:"Generate Thumbnail",generateThumbnails:"Generate Thumbnails",generateThumbnailsForMissing:"Generate thumbnails for STL files missing them",gridView:"Grid view",listView:"List view",lowDiskSpaceWarning:"Low disk space warning",lowDiskSpaceDetails:"Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.",files:"Files",folders:"Folders",size:"Size",free:"Free",allFiles:"All Files",wrap:"Wrap",enableTextWrapping:"Enable text wrapping",disableTextWrapping:"Disable text wrapping",collapse:"Collapse",collapseFoldersByDefault:"Collapse folders by default",expandFoldersByDefault:"Expand folders by default",dragToResizeTooltip:"Drag to resize, double-click to reset",searchFiles:"Search files...",allTypes:"All types",prints:"Prints",ascending:"Ascending",descending:"Descending",resultsCount:"{{showing}} of {{total}} files",selectAll:"Select All",deselectAll:"Deselect All",selected:"{{count}} selected",adding:"Adding...",loadingFiles:"Loading files...",folderIsEmpty:"Folder is empty",noFilesYet:"No files yet",folderEmptyDescription:"Upload files or move files into this folder to get started.",noFilesDescription:"Upload files to start organizing your print-related files.",noMatchingFiles:"No matching files",noMatchingFilesDescription:"No files match your current search or filter criteria.",clearFilters:"Clear filters",printedCount:"Printed {{count}}x",uploadedBy:"Uploaded By",deleteFolder:"Delete Folder",deleteFile:"Delete File",deleteFilesCount:"Delete {{count}} Files",deleteFolderConfirm:"Are you sure you want to delete this folder? All files inside will also be deleted.",deleteFileConfirm:"Are you sure you want to delete this file?",deleteFilesConfirm:"Are you sure you want to delete {{count}} selected files? This action cannot be undone.",deleting:"Deleting...",noPermissionRenameFolder:"You do not have permission to rename folders",noPermissionLinkFolder:"You do not have permission to link folders",noPermissionDeleteFolder:"You do not have permission to delete folders",noPermissionPrint:"You do not have permission to print",noPermissionAddToQueue:"You do not have permission to add to queue",noPermissionSlice:"You do not have permission to slice files",noPermissionDownload:"You do not have permission to download files",noPermissionRenameFile:"You do not have permission to rename this file",noPermissionGenerateThumbnail:"You do not have permission to generate thumbnails",noPermissionDeleteFile:"You do not have permission to delete this file",noPermissionCreateFolder:"You do not have permission to create folders",noPermissionUpload:"You do not have permission to upload files",noPermissionMoveFiles:"You do not have permission to move files",noPermissionDeleteFiles:"You do not have permission to delete files",linkExternal:"Link External",linkExternalFolder:"Link External Folder",linkExternalFolderDescription:"Mount a host directory (NAS, USB, network share) into the File Manager. Files are not copied — they are accessed directly from the original path.",externalFolderNamePlaceholder:"e.g., NAS Prints",externalPath:"Host Path",externalPathHelp:"Absolute path to the directory on the Docker host. Must be bind-mounted into the container.",readOnly:"Read Only",readOnlyHelp:"prevents uploads and deletions",showHiddenFiles:"Show hidden files (dotfiles)",externalFolder:"External Folder",scanFolder:"Scan",toast:{folderCreated:"Folder created",folderDeleted:"Folder deleted",fileDeleted:"File deleted",filesDeleted:"Deleted {{count}} files",filesMoved:"Files moved",folderLinked:"Folder linked",folderUnlinked:"Folder unlinked",externalFolderLinked:"External folder linked and scanned",folderScanned:"Scan complete: {{added}} added, {{removed}} removed",addedToQueue:"Added {{count}} file(s) to queue",addedToQueuePartial:"Added {{added}} file(s), {{failed}} failed",failedToAddToQueue:"Failed to add files: {{error}}",fileRenamed:"File renamed",folderRenamed:"Folder renamed",thumbnailsGenerated:"Generated {{count}} thumbnail(s)",thumbnailsGeneratedPartial:"Generated {{succeeded}} thumbnail(s), {{failed}} failed",noStlMissingThumbnails:"No STL files missing thumbnails",failedToGenerateThumbnails:"Failed to generate thumbnails: {{error}}",thumbnailGenerated:"Thumbnail generated",failedToGenerateThumbnail:"Failed to generate thumbnail: {{error}}"}},projects:{title:"Projects",subtitle:"Organize and track your 3D printing projects",newProject:"New Project",editProject:"Edit Project",deleteProject:"Delete Project",projectName:"Project Name",description:"Description",noProjects:"No projects yet",noProjectsFiltered:"No {{status}} projects",noProjectsFilteredHelp:"You don't have any {{status}} projects. Projects will appear here when their status changes.",createFirst:"Create your first project to start organizing related prints, tracking progress, and managing your builds.",createFirstButton:"Create Your First Project",create:"Create",files:"Files",prints:"Prints",plates:"plates",parts:"parts",lastModified:"Last Modified",deleteConfirm:"Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted.",addFiles:"Add Files",removeFile:"Remove File",viewDetails:"View Details",namePlaceholder:"e.g., Voron 2.4 Build",descriptionPlaceholder:"Optional description...",color:"Color",targetPlates:"Target Plates",targetPlatesPlaceholder:"e.g., 25",targetPlatesHelp:"Number of print jobs",targetParts:"Target Parts",targetPartsPlaceholder:"e.g., 150",targetPartsHelp:"Total objects needed",tagsLabel:"Tags (comma-separated)",tagsPlaceholder:"e.g., voron, functional, gift",dueDate:"Due Date",priority:"Priority",priorityLow:"Low",priorityNormal:"Normal",priorityHigh:"High",priorityUrgent:"Urgent",statusActive:"Active",statusCompleted:"Completed",statusArchived:"Archived",done:"Done",completed:"completed",failed:"failed",inQueue:"in queue",noPrintsYet:"No prints yet",printJobs:"Print jobs (plates)",partsPrinted:"Parts printed",failedParts:"Failed parts",import:"Import",export:"Export",importProject:"Import project",exportAll:"Export all projects",loading:"Loading projects...",noEditPermission:"You do not have permission to edit projects",noDeletePermission:"You do not have permission to delete projects",noCreatePermission:"You do not have permission to create projects",noImportPermission:"You do not have permission to import projects",noExportPermission:"You do not have permission to export projects",toast:{created:"Project created",updated:"Project updated",deleted:"Project deleted",imported:"Project imported",multipleImported:"{{count}} projects imported",importFailed:"Import failed",exported:"Projects exported (metadata only)"}},projectDetail:{notFound:"Project not found",backToProjects:"Back to Projects",export:"Export",exportProject:"Export project",noExportPermission:"You do not have permission to export projects",noEditPermission:"You do not have permission to edit projects",partOf:"Part of:",priorityLabel:"Priority:",noPrints:"No prints in this project yet",status:{active:"Active",completed:"Completed",archived:"Archived"},priority:{low:"Low",normal:"Normal",high:"High",urgent:"Urgent"},dueDate:{overdue:"Overdue",today:"Due today",daysLeft:"{{count}} days left"},progress:{platesProgress:"Plates Progress",partsProgress:"Parts Progress",printJobs:"print jobs",parts:"parts",percentComplete:"{{percent}}% complete",remaining:"{{count}} remaining"},stats:{printJobs:"Print Jobs",total:"total",failed:"{{count}} failed",partsPrinted:"{{count}} parts printed",printTime:"Print Time",filamentUsed:"Filament Used"},cost:{title:"Cost Tracking",filamentCost:"Filament Cost",energy:"Energy",totalCost:"Total Cost",total:"Total",includesBom:"incl. BOM",budget:"Budget",remaining:"Remaining"},subProjects:{title:"Sub-projects ({{count}})"},notes:{title:"Notes",noEditPermission:"You do not have permission to edit notes",placeholder:"Add notes about this project...",empty:"No notes yet. Click Edit to add notes."},files:{title:"Files",linkFolders:"Link folders from the File Manager",forQuickAccess:"to this project for quick access.",fileCount:"{{count}} file(s)",empty:"No folders linked. Go to File Manager and link a folder to this project.",noFiles:"No files in this folder.",print:"Print Now",addToQueue:"Add to Queue"},bom:{title:"Bill of Materials",acquired:"{{completed}}/{{total}} acquired",showAll:"Show all",hideDone:"Hide done",addPart:"Add Part",noAddPermission:"You do not have permission to add parts",partNamePlaceholder:"Part name (e.g., M3x8 screws)",partName:"Part name",qty:"Qty",price:"Price ({{currency}})",sourcingUrlPlaceholder:"Sourcing URL (optional)",remarksPlaceholder:"Remarks (optional)",deletePart:"Delete Part",deleteConfirm:'Are you sure you want to delete "{{name}}"?',noUpdatePermission:"You do not have permission to update parts",noEditPermission:"You do not have permission to edit parts",noDeletePermission:"You do not have permission to delete parts",totalCost:"Total cost:",empty:"No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced."},timeline:{title:"Activity Timeline",empty:"No activity yet."},template:{saveAsTemplate:"Save as Template",noCreatePermission:"You do not have permission to create templates"},queue:{title:"Queue",viewAll:"View all",printing:"{{count}} printing",queued:"{{count}} queued"},prints:{title:"Prints ({{count}})"},toast:{projectUpdated:"Project updated",partAdded:"Part added",partRemoved:"Part removed",exportFailed:"Export failed",projectExported:"Project exported",templateCreated:"Template created"}},system:{title:"System Information",version:"Version",uptime:"Uptime",cpuUsage:"CPU Usage",memoryUsage:"Memory Usage",diskUsage:"Disk Usage",networkInfo:"Network Info",logs:"Logs",debugMode:"Debug Mode",enableDebug:"Enable Debug Logging",disableDebug:"Disable Debug Logging",downloadLogs:"Download Logs",clearLogs:"Clear Logs",dockerInfo:"Docker Info",containerName:"Container Name",imageName:"Image Name",platform:"Platform",architecture:"Architecture"},library:{title:"Filament Library",addFilament:"Add Filament",editFilament:"Edit Filament",deleteFilament:"Delete Filament",vendor:"Vendor",material:"Material",color:"Color",kFactor:"K Factor",temperature:"Temperature",noFilaments:"No filaments in library",deleteConfirm:"Are you sure you want to delete this filament?",importFromPrinter:"Import from Printer",exportToFile:"Export to File"},slice:{title:"Slice model",action:"Slice",slicing:"Slicing…",printer:"Printer profile",process:"Process profile",filament:"Filament profile",selectPreset:"— Select a preset —",loadingPresets:"Loading presets…",presetsLoadFailed:"Failed to load presets. Open Settings → Profiles to import them first.",enqueuing:"Submitting slice job…",queued:"Queued…",failed:"Slicing failed. Check the slicer sidecar logs.",startedToast:"Slicing {{name}} in the background…",completedToast:"Sliced {{name}}",failedToast:"Slicing {{name}} failed: {{detail}}"},spoolman:{title:"Spoolman Integration",enabled:"Spoolman Enabled",url:"Spoolman URL",connected:"Connected",disconnected:"Not Connected",testConnection:"Test Connection",sync:"Sync",syncing:"Syncing...",lastSync:"Last Sync",linkToSpoolman:"Link to Spoolman",openInSpoolman:"Open in Spoolman",unlinkSpool:"Unlink Spool",unlinkConfirmTitle:"Unlink Spool?",unlinkConfirmMessage:"This will disconnect the spool from Spoolman. The spool data in Spoolman will remain unchanged.",selectSpool:"Select Spool",noUnlinkedSpools:"No unlinked spools available",linkSuccess:"Spool linked to Spoolman successfully",linkFailed:"Failed to link spool",unlinkSuccess:"Spool unlinked from Spoolman successfully",unlinkFailed:"Failed to unlink spool",spoolId:"Spool ID",fillSourceLabel:"(Spoolman)",weight:"Weight",remaining:"Remaining",disableWeightSync:"Disable AMS Estimated Weight Sync",disableWeightSyncDesc:"Don't update remaining capacity from AMS estimates. Use this if you prefer Spoolman's usage tracking over AMS percentage-based estimates. New spools will still use the AMS estimate as their initial weight.",reportPartialUsage:"Report Partial Usage for Failed Prints",reportPartialUsageDesc:"When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress."},inventory:{title:"Spool Inventory",spoolmanMixedContentTitle:"Spoolman can't load over HTTPS — mixed-content blocked by your browser",spoolmanMixedContentBody:"Bambuddy is served over HTTPS (via your reverse proxy), but your Spoolman URL is still plain HTTP. Browsers block mixed content for security, so the embedded Spoolman UI can't render. Spoolman needs to be reachable over HTTPS for this to work.",spoolmanMixedContentFixReverseProxy:"Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.",spoolmanMixedContentFixOpenNewTab:"As a workaround, open Spoolman in a new browser tab over HTTP — mixed-content rules only apply to embedded frames, so a standalone tab still works.",spoolmanOpenInNewTab:"Open Spoolman in a new tab",addSpool:"Add Spool",editSpool:"Edit Spool",material:"Material",selectMaterial:"Select material...",subtype:"Subtype",brand:"Brand",searchBrand:"Search brand...",useCustomBrand:'Use "{{brand}}"',useCustomMaterial:"Use custom material: {{material}}",colorName:"Color Name",colorNamePlaceholder:"Jade White, Fire Red...",color:"Color",hexColor:"Hex Color",pickColor:"Pick custom color",labelWeight:"Label Weight",coreWeight:"Empty Spool Weight",searchSpoolWeight:"Search spool weight...",weightUsed:"Used",currentWeight:"Remaining Weight",measuredWeight:"Measured Weight",spoolName:"Spool",costPerKg:"Cost per kg",measuredWeightError:"Measured weight must be between {{min}}g and {{max}}g.",slicerFilament:"Slicer Filament",slicerFilamentName:"Slicer Preset Name",slicerPreset:"Slicer Preset",searchPresets:"Search filament presets...",selectedPreset:"Selected",noPresetsFound:"No presets found",tempOverrides:"Temperature Overrides",note:"Note",notePlaceholder:"Any additional notes about this spool...",category:"Category",categoryPlaceholder:"e.g. Production, Prototype, Client A",categoryNone:"Uncategorized",lowStockThresholdOverride:"Low-stock threshold (this spool)",lowStockThresholdOverrideHelp:"Leave blank to use the global threshold ({{global}}%).",clearRfid:"Clear RFID Tag",rfidCleared:"RFID tag cleared",archive:"Archive",restore:"Restore",noSpools:"No spools yet. Add your first spool to get started.",noAvailableSpools:"No spools available. Add a spool to your inventory or unassign one from another slot first.",kProfiles:"K-Profiles",addKProfile:"Add K-Profile",assignSpool:"Assign Spool",unassignSpool:"Unassign",assignSuccess:"Spool assigned and AMS slot configured",assignFailed:"Failed to assign spool",selectSpool:"Select a spool to assign to this slot",assigned:"Assigned",assigning:"Assigning...",searchSpools:"Search spools...",showAllSpools:"Show all spools",allMaterials:"All Materials",filterByBrand:"Filter by brand...",showArchived:"Show archived",quickAdd:"Quick Add (Stock)",quantity:"Quantity",stock:"Stock",configured:"Configured",spoolsCreated:"{{count}} spools created",spoolCreated:"Spool created",spoolUpdated:"Spool updated",spoolDeleted:"Spool deleted",spoolArchived:"Spool archived",spoolRestored:"Spool restored",deleteConfirm:"Are you sure you want to delete this spool? This cannot be undone.",archiveConfirm:"Are you sure you want to archive this spool?",advancedSettings:"Advanced Settings",filamentInfoTab:"Filament Info",paProfileTab:"PA Profile",filamentInfo:"Filament",additional:"Additional",loadingPresets:"Loading cloud presets...",cloudConnected:"Cloud connected",cloudNotConnected:"Cloud not connected (using defaults)",recentColors:"Recent",searchColors:"Search colors...",searchResults:"Search results",allColors:"All colors",commonColors:"Common colors",showLess:"Show less",showAll:"Show all",noColorsFound:"No colors match your search",noResults:"No matches found",selectMaterialFirst:"Please select a material first in the Filament Info tab.",noPrintersConfigured:"No printers configured. Add printers to use PA profiles.",matchingFilter:"Matching",anyBrand:"Any brand",anyVariant:"Any variant",autoSelect:"Auto-select",matches:"matches",match:"match",noMatches:"No matches",connected:"Connected",offline:"Offline",printerOffline:"Printer is offline. Connect to view calibration profiles.",noKProfilesMatch:"No K-profiles match the selected filament.",leftNozzle:"Left Nozzle",rightNozzle:"Right Nozzle",profilesSelected:"calibration profile(s) selected",totalInventory:"Total Inventory",totalConsumed:"Total Consumed",byMaterial:"By Material",inPrinter:"In Printer",lowStock:"Low Stock",sinceTracking:"Since tracking started",loadedInAms:"Loaded in AMS/Ext",remaining:"Remaining",weightCheck:"Weight Check",lastWeighed:"Last weighed",neverWeighed:"Never weighed",search:"Search spools...",showing:"Showing",to:"to",of:"of",show:"Show",spools:"spools",spool:"spool",page:"Page",noSpoolsMatch:"No results found",noSpoolsMatchDesc:"Try adjusting your search or filters to find what you're looking for.",active:"Active",archived:"Archived",all:"All",used:"Used",new:"New",clearFilters:"Clear filters",table:"Table",cards:"Cards",net:"Net",groupSimilar:"Group",groupedSpools:"{{count}} identical spools",groupedRows:"rows",columns:"Columns",configureColumns:"Configure Columns",configureColumnsDesc:"Drag to reorder columns or use arrows. Toggle visibility with the eye icon.",visible:"visible",reset:"Reset",cancel:"Cancel",applyChanges:"Apply Changes",moveUp:"Move up",moveDown:"Move down",hideColumn:"Hide column",showColumn:"Show column",linkToSpool:"Link to Spool",tagLinked:"Tag linked to spool",tagLinkFailed:"Failed to link tag",tagAlreadyLinked:"Tag already linked to another spool",unknownTag:"Unknown RFID tag detected",usageHistory:"Usage History",noUsageHistory:"No usage recorded yet",printName:"Print Name",weightConsumed:"Weight Consumed",clearHistory:"Clear",historyCleared:"Usage history cleared",fillSourceLabel:"(Inv)",lowStockThresholdError:"Threshold must be between 0.1 and 99.9",assignMismatchTitle:"Material mismatch",assignMismatchMessage:'The selected spool material "{{spoolMaterial}}" does not match the tray material "{{trayMaterial}}" for {{location}}. Assign anyway?',assignMismatchConfirm:"Assign Anyway",assignPartialMismatchMessage:'The spool material "{{spoolMaterial}}" is similar to but not exactly matching "{{trayMaterial}}" in {{location}}. Do you want to proceed?',assignProfileMismatchMessage:'The spool profile "{{spoolProfile}}" does not match the tray profile "{{trayProfile}}" in {{location}}. Do you want to proceed?'},timelapse:{title:"Timelapse",create:"Create Timelapse",download:"Download",delete:"Delete",preview:"Preview",frameRate:"Frame Rate",quality:"Quality",processing:"Processing...",noTimelapses:"No timelapses available"},ams:{title:"AMS",slot:"Slot",empty:"Empty",emptySlot:"Empty slot",unknown:"Unknown",humidity:"Humidity",temperature:"Temperature",filamentType:"Filament Type",filamentColor:"Color",remaining:"Remaining",history:"AMS History",noHistory:"No history available",configureSlot:"Configure Slot",externalSpool:"External Spool",profile:"Profile",kFactor:"K Factor",fill:"Fill",configure:"Configure",used:"used",remainingUnit:"remaining"},printModal:{title:"Start Print",selectPrinter:"Select Printer",selectPlate:"Select Plate",filamentMapping:"Filament Mapping",totalCost:"Total cost:",slotRemainingShort:" - {{grams}}g left",printSettings:"Print Settings",bedLeveling:"Bed Leveling",flowCalibration:"Flow Calibration",vibrationCalibration:"Vibration Calibration",layerInspection:"First Layer Inspection",timelapse:"Timelapse",startPrint:"Start Print",addToQueue:"Add to Queue",cancel:"Cancel",noPrintersAvailable:"No printers available",printerBusy:"Printer is busy",printerOffline:"Printer is offline",sameTypeDifferentColor:"Same type, different color",filamentTypeNotLoaded:"Filament type not loaded",openCalendar:"Open calendar",leftNozzle:"L",rightNozzle:"R",leftNozzleTooltip:"Left nozzle",rightNozzleTooltip:"Right nozzle",filamentOverride:"Filament Override",filamentOverrideHint:"Optionally override filaments for model-based assignment. The scheduler will match against your selected filaments instead of the original 3MF values.",originalFilament:"Original",overrideWith:"Override with",resetToOriginal:"Reset to original",insufficientFilamentTitle:"Not enough filament",insufficientFilamentMessage:"Some assigned spools have less filament remaining than this print needs:",insufficientFilamentLine:"{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g",printAnyway:"Print anyway",forceColorMatch:"Force color match",staggerPrinterStarts:"Stagger printer starts",staggerGroupSize:"Group size",staggerInterval:"Interval (min)",staggerPreview:"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min",staggerLastGroup:"last group: {{count}}",staggerTotal:"total: {{minutes}} min",staggerToPrinters:"Stagger to {{count}} printers",gcodeInjection:"Inject auto-print G-code"},backup:{title:"Backup & Restore",createBackup:"Create Backup",restoreBackup:"Restore Backup",restoreDescription:"Replace all data from a backup file",downloadBackup:"Download Backup",uploadBackup:"Upload Backup",lastBackup:"Last Backup",autoBackup:"Auto Backup",backupNow:"Backup Now",restoreWarning:"Warning: Restoring a backup will overwrite all current data.",includeArchives:"Include Archives",includeSettings:"Include Settings",includeProfiles:"Include Profiles",backupSuccess:"Backup created successfully",restoreSuccess:"Backup restored successfully",backupFailed:"Backup failed",restoreFailed:"Restore failed",restoreNote:"Virtual Printer will be stopped during restore",githubBackup:"GitHub Backup",enabled:"Enabled",cloudLoginRequired:"Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.",cloudLoginRequiredShort:"Cloud login required",githubDescription:"Automatically sync your profiles to a private GitHub repository for backup and version history.",repositoryUrl:"Repository URL",personalAccessToken:"Personal Access Token",tokenSaved:"(saved)",enterNewToken:"Enter new token to update",tokenHint:"Fine-grained token with Contents read/write permission",branch:"Branch",manualOnly:"Manual only",hourly:"Hourly",daily:"Daily",weekly:"Weekly",includeInBackup:"Include in backup",kProfiles:"K-Profiles",kProfilesDescription:"Pressure advance calibration from connected printers",noPrintersConnected:"No printers connected",printersConnected:"{{connected}}/{{total}} connected",cloudProfiles:"Cloud Profiles",cloudProfilesDescription:"Filament, printer, and process presets from Bambu Cloud",appSettings:"App Settings",appSettingsDescription:"Bambuddy configuration (complete database)",spoolInventory:"Spool Inventory",spoolInventoryDescription:"Filament spools, usage history, and cost tracking",printArchives:"Print Archives",printArchivesDescription:"Print history metadata (no gcode/3MF files)",lastBackupAt:"Last backup:",noBackupsYet:"No backups yet",next:"Next:",startingBackup:"Starting backup...",test:"Test",enableBackup:"Enable Backup",testConnection:"Test Connection",enterRepoUrl:"Enter repository URL",enterRepoAndToken:"Enter repository URL and access token",repoRequired:"Repository URL is required",tokenRequired:"Access token is required",githubBackupEnabled:"GitHub backup enabled",tokenUpdated:"Token updated",settingsSaved:"Settings saved",failedToSave:"Failed to save: {{message}}",backupCompleteFiles:"Backup complete - {{count}} files updated",backupSkippedNoChanges:"Backup skipped - no changes",backupFailed2:"Backup failed: {{message}}",clearedLogs:"Cleared {{count}} logs",failedToClearLogs:"Failed to clear logs: {{message}}",history:"History",clear:"Clear",date:"Date",status:"Status",commit:"Commit",localBackup:"Local Backup",localBackupDescription:"Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.",downloadBackupLabel:"Download Backup",completeBackupZip:"Complete backup: database + all files (ZIP)",download:"Download",preparingBackup:"Preparing backup...",creatingArchive:"Creating backup archive... This may take a while for large archives.",downloadingFile:"Downloading backup file...",backupDownloaded:"Backup downloaded successfully",failedToCreateBackup:"Failed to create backup: {{message}}",restore:"Restore",restoreReplacesAll:"Restore replaces all data.",restoreReplacesAllDetail:"Your current database and files will be completely replaced. A restart is required after restore.",restoreConfirmTitle:"Restore Backup",restoreConfirmMessage:'Are you sure you want to restore from "{{filename}}"? This will completely replace your current database and all files. The application will need to be restarted after restore.',restoreConfirmButton:"Restore Backup",uploadingFile:"Uploading backup file...",backupRestoredRestart:"Backup restored. Please restart Bambuddy.",failedToRestore:"Failed to restore backup. Please check the file format.",reloadNow:"Reload Now",creatingBackup:"Creating Backup",restoringBackup:"Restoring Backup",preparing:"Preparing...",processing:"Processing...",doNotClosePage:"Please do not close this page or navigate away. This operation may take several minutes for large backups.",restoring:"Restoring...",restoreComplete:"Restore Complete",restoreFailed2:"Restore Failed",importSettings:"Import settings from a backup file",pleaseWaitRestoring:"Please wait while your data is being restored",selectBackupFile:"Click to select backup file (.json or .zip)",duplicateHandling:"How duplicate handling works:",matchPrinters:"Printers",matchPrintersBy:"matched by serial number",matchSmartPlugs:"Smart Plugs",matchSmartPlugsBy:"matched by IP address",matchNotificationProviders:"Notification Providers",matchNotificationProvidersBy:"matched by name",matchFilaments:"Filaments",matchFilamentsBy:"matched by name + type + brand",matchArchives:"Archives",matchArchivesBy:"matched by content hash (always skipped)",matchPendingUploads:"Pending Uploads",matchPendingUploadsBy:"matched by filename",matchSettingsTemplates:"Settings & Templates",matchSettingsTemplatesBy:"always overwritten",replaceExisting:"Replace existing data",keepExisting:"Keep existing data",overwriteDescription:"Overwrite items that already exist with backup data",keepDescription:"Only restore items that don't already exist",overwriteCaution:"Caution:",overwriteWarning:"Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.",cancel:"Cancel",processingBackup:"Processing backup file...",itemsRestored:"Items Restored",itemsSkipped:"Items Skipped",restored:"Restored",skippedAlreadyExist:"Skipped (already exist)",filesCategory:"Files (3MF, thumbnails, etc.)",andMore:"...and {{count}} more",newApiKeysGenerated:"New API Keys Generated",keysShownOnce:"These keys are only shown once. Copy them now!",copy:"Copy",noDataFound:"No data was found to restore in the backup file.",close:"Close",scheduledBackup:"Scheduled Backups",scheduledBackupDescription:"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.",frequency:"Frequency",backupTime:"Time",retention:"Retention",retentionDescription:"Number of backups to keep",outputPath:"Output Path",outputPathPlaceholder:"Default: {{path}}",outputPathDescription:"Leave empty for default location",runNow:"Run Now",backupFiles:"Backup Files",noScheduledBackups:"No backups yet",deleteBackup:"Delete",deleteBackupConfirm:"Delete this backup file?",backupRunning:"Backup in progress...",scheduledBackupComplete:"Backup completed successfully",scheduledBackupFailed:"Backup failed",nextBackup:"Next backup",backupSize:"Size",utc:"UTC",defaultPathLabel:"Default:",categories:{settings:"Settings",notification_providers:"Notification Providers",notification_templates:"Notification Templates",smart_plugs:"Smart Plugs",printers:"Printers",filaments:"Filaments",maintenance_types:"Maintenance Types",archives:"Archives",projects:"Projects",pending_uploads:"Pending Uploads",external_links:"External Links",api_keys:"API Keys"}},tags:{title:"Tags",addTag:"Add Tag",editTag:"Edit Tag",deleteTag:"Delete Tag",tagName:"Tag Name",tagColor:"Tag Color",noTags:"No tags",deleteConfirm:"Are you sure you want to delete this tag?",manageTags:"Manage Tags"},uploadModal:{title:"Upload 3MF Files",dragDrop:"Drag & drop .3mf files here",or:"or",browseFiles:"Browse Files",extractionInfo:"The printer model will be automatically extracted from the 3MF file metadata.",uploaded:"uploaded",failed:"failed",uploading:"Uploading...",upload:"Upload",uploadFailed:"Upload failed"},editArchive:{title:"Edit Archive",name:"Name",namePlaceholder:"Print name",printer:"Printer",noPrinter:"No printer",project:"Project",noProject:"No project",itemsPrinted:"Items Printed",itemsPrintedHelp:"Number of items produced in this print job",notes:"Notes",notesPlaceholder:"Add notes about this print...",externalLink:"External Link",externalLinkPlaceholder:"https://printables.com/model/...",externalLinkHelp:"Link to Printables, Thingiverse, or other source",tags:"Tags",tagsPlaceholder:"Add tags...",addMoreTags:"Add more tags...",matchingTags:'Matching "{{query}}"',existingTags:"Existing tags",clickToAdd:"(click to add)",status:"Status",failureReason:"Failure Reason",selectReason:"Select reason...",photos:"Photos of Printed Result",photosHelp:"Click + to add photos of your printed result",printResult:"Print result",saving:"Saving...",failureReasons:{adhesionFailure:"Adhesion failure",spaghettiDetached:"Spaghetti / Detached",layerShift:"Layer shift",cloggedNozzle:"Clogged nozzle",filamentRunout:"Filament runout",warping:"Warping",stringing:"Stringing",underExtrusion:"Under-extrusion",powerFailure:"Power failure",userCancelled:"User cancelled",other:"Other"},statuses:{completed:"Completed",failed:"Failed",aborted:"Cancelled",printing:"Printing"}},kProfiles:{title:"K-Profiles",noPrintersConfigured:"No Printers Configured",addPrinterInSettings:"Add a printer in Settings to manage K-profiles",noActivePrinters:"No Active Printers",enablePrinterConnection:"Enable a printer connection to view its K-profiles",loadingProfiles:"Loading K-Profiles...",printerOffline:"Printer Offline",printerOfflineDesc:"The selected printer is not connected. Power it on to view K-profiles.",noMatchingProfiles:"No Matching Profiles",noMatchingProfilesDesc:"No profiles match your search criteria",noKProfiles:"No K-Profiles",noKProfilesDesc:"No pressure advance profiles found for {{diameter}}mm nozzle",createFirstProfile:"Create First Profile",printer:"Printer",nozzle:"Nozzle",refresh:"Refresh",addProfile:"Add Profile",export:"Export",import:"Import",select:"Select",selectAll:"Select All",delete:"Delete",searchPlaceholder:"Search by name or filament...",allExtruders:"All Extruders",leftOnly:"Left Only",rightOnly:"Right Only",allFlow:"All Flow",hfOnly:"HF Only",sOnly:"S Only",sortName:"Sort: Name",sortKValue:"Sort: K-Value",sortFilament:"Sort: Filament",leftExtruder:"Left Extruder",rightExtruder:"Right Extruder",modal:{addTitle:"Add K-Profile",editTitle:"Edit K-Profile",profileName:"Profile Name",profileNamePlaceholder:"My PLA Profile",kValue:"K-Value",kValuePlaceholder:"0.020",kValueHelp:"Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG",filament:"Filament",selectFilament:"Select filament...",noFilamentsHelp:"No filaments found. Create a K-profile in Bambu Studio first.",flowType:"Flow Type",highFlow:"High Flow",standard:"Standard",nozzleSize:"Nozzle Size",extruder:"Extruder",extruders:"Extruders",left:"Left",right:"Right",notes:"Notes (stored locally)",notesPlaceholder:"Add notes about this profile...",notesHelp:"Notes are saved in Bambuddy, not on the printer",syncing:"Syncing with printer...",savingExtruder:"Saving to extruder {{current}}/{{total}}...",pleaseWait:"Please wait"},deleteConfirm:{title:"Delete Profile",cannotUndo:"This cannot be undone",message:'Are you sure you want to delete "{{name}}" from the printer?'},bulkDelete:{title:"Delete Profiles",cannotUndo:"This cannot be undone",message:"Are you sure you want to delete {{count}} selected profiles from the printer?"},toast:{profileSaved:"K-profile saved",profilesSaved:"K-profile saved to {{count}} extruders",selectAtLeastOneExtruder:"Please select at least one extruder",profileDeleted:"K-profile deleted",profilesDeleted:"Deleted {{count}} profiles",exportedProfiles:"Exported {{count}} profiles",importedProfiles:"Imported {{count}} of {{total}} profiles",noProfilesToExport:"No profiles to export",invalidFileFormat:"Invalid file format",failedToParseImport:"Failed to parse import file",failedToSaveBatch:"Failed to save K-profiles",noteSaved:"Note saved",failedToSaveNote:"Failed to save note"},permission:{noRead:"You do not have permission to refresh profiles",noCreate:"You do not have permission to add profiles",noUpdate:"You do not have permission to update K-profiles",noDelete:"You do not have permission to delete K-profiles",noExport:"You do not have permission to export profiles",noImport:"You do not have permission to import profiles"}},virtualPrinter:{title:"Virtual Printer",running:"Running",stopped:"Stopped",description:{default:"Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer will be archived directly without printing.",proxy:"Enable a proxy that relays slicer traffic to a real printer, allowing remote printing over any network."},enable:{title:"Enable Virtual Printer",visibleInSlicer:'Visible as "Bambuddy" in slicer discovery',proxyingTo:"Proxying to {{name}}",notActive:"Not active"},model:{title:"Printer Model",description:"Select which printer model to emulate.",restartWarning:"Changing the model will restart the virtual printer"},accessCode:{title:"Access Code",isSet:"Access code is set",notSet:"No access code set - required to enable",placeholder:"Enter 8-char code",placeholderChange:"Enter new code to change",hint:"Must be exactly 8 characters. Used by slicers to authenticate.",charCount:"({{count}}/8)"},targetPrinter:{title:"Target Printer",configured:"Proxy target configured",notConfigured:"No target printer selected - required for proxy mode",placeholder:"Select a printer...",hint:"Select the printer to proxy slicer traffic to. The printer must be in LAN mode.",noPrinters:"No printers configured. Add a printer first to use proxy mode."},remoteInterface:{title:"Network Interface Override",configured:"Interface override active",optional:"Optional - use if auto-detected IP is wrong (e.g. multiple NICs, Docker, VPN)",placeholder:"Auto-detect (default)...",hint:"Override the IP address advertised via SSDP and used in the TLS certificate. Useful when Bambuddy has multiple network interfaces."},mode:{title:"Mode",archive:"Archive",archiveDesc:"Archive files immediately",review:"Review",reviewDesc:"Review before archiving",queue:"Queue",queueDesc:"Archive and add to queue",proxy:"Proxy",proxyDesc:"Relay to real printer"},autoDispatch:{title:"Auto-dispatch",description:"Automatically start prints when added to queue. When off, prints wait for manual dispatch."},tailscaleDisabled:{title:"Tailscale integration",description:"When enabled, uses Tailscale for trusted TLS certs. Disable to use self-signed cert only."},setupRequired:{title:"Setup Required",description:"The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.",readGuide:"Read the setup guide before enabling"},howItWorks:{title:"How it works",step1:"On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.",step2:'In Archive, Review, and Queue modes, use the "Send" button in your slicer to upload 3MF files to Bambuddy. The slicer will show "Print success" — the file is stored, not printed.',step3:"In Proxy mode, the virtual printer relays all traffic to a real printer — prints start immediately as if connected directly."},status:{title:"Status Details",printerName:"Printer Name",model:"Model",serialNumber:"Serial Number",mode:"Mode",pendingFiles:"Pending Files",targetPrinter:"Target Printer",ftpPort:"FTP Port",mqttPort:"MQTT Port",ftpConnections:"FTP Connections",mqttConnections:"MQTT Connections"},toast:{updated:"Virtual printer settings updated",failedToUpdate:"Failed to update settings",tailscaleNotAvailable:"Tailscale is not installed on this host. Install Tailscale first, then try again.",copyFailed:"Failed to copy — try selecting the text manually",accessCodeRequired:"Please set an access code first",targetPrinterRequired:"Please select a target printer first",bindIpRequired:"Please set a bind IP first",accessCodeEmpty:"Access code cannot be empty",accessCodeLength:"Access code must be exactly 8 characters",created:"Virtual printer created",failedToCreate:"Failed to create virtual printer",deleted:"Virtual printer deleted",failedToDelete:"Failed to delete virtual printer"},list:{title:"Virtual Printers",add:"Add",addFirst:"Add Virtual Printer",empty:"No virtual printers configured. Add one to get started."},bindIp:{title:"Bind Interface",placeholder:"Select interface...",hint:"Network interface for this virtual printer to bind to. Must be unique per printer."},proxy:{accessCodeHint:"In proxy mode, use your target printer's access code in the slicer. The connection is forwarded transparently to the real printer."},addDialog:{title:"Add Virtual Printer",name:"Name",hint:"You can configure access code, target printer, and other settings after creating.",create:"Create"},deleteConfirm:{title:"Delete Virtual Printer",message:'Are you sure you want to delete "{{name}}"? This will stop all services for this printer.'}},modelViewer:{openInSlicer:"Open in Slicer",tabs:{model:"3D Model",gcode:"G-code Preview"},notAvailable:"not available",notSliced:"not sliced",plates:"Plates",allPlates:"All Plates",plateNumber:"Plate {{number}}",plateCount:"{{count}} plate",plateCount_other:"{{count}} plates",objectCount:"{{count}} object",objectCount_other:"{{count}} objects",filamentCount:"{{count}} filament",filamentCount_other:"{{count}} filaments",eta:"ETA {{minutes}} min",noPreview:"No preview available for this file",pagination:{pageOf:"Page {{current}} of {{total}}",prev:"Prev",next:"Next"},errors:{failedToLoad:"Failed to load file",noMeshes:"No meshes found in 3MF file",unsupportedFormat:"Unsupported file format"}},maintenanceDescriptions:{lubricateCarbonRods:"Apply lubricant to carbon rods for smooth motion",lubricateRails:"Apply lubricant to linear rails for smooth motion",cleanNozzle:"Clean hotend and nozzle to prevent clogs",checkBelts:"Verify belt tension for accurate prints",cleanBuildPlate:"Clean build plate for better adhesion",checkExtruder:"Inspect extruder gears for wear",checkCooling:"Ensure cooling fans are working properly",generalInspection:"General printer inspection",cleanCarbonRods:"Clean carbon rods to reduce friction",lubricateSteelRods:"Apply lubricant to steel rods for smooth motion",cleanSteelRods:"Clean steel rods to reduce friction",cleanLinearRails:"Wipe linear rails to remove dust and debris",checkPtfeTube:"Inspect PTFE tube for wear or damage",replaceHepaFilter:"Replace HEPA filter for air quality",replaceCarbonFilter:"Replace activated carbon filter",lubricateLeftNozzleRail:"Lubricate left nozzle rail (H2 series)"},smartPlugs:{offline:"Offline",admin:"Admin",openPlugAdminPage:"Open plug admin page",deleteSmartPlug:"Delete Smart Plug",turnOnSmartPlug:"Turn On Smart Plug",turnOffSmartPlug:"Turn Off Smart Plug",turnOn:"Turn On",turnOff:"Turn Off",addSmartPlug:{scanningNetwork:"Scanning network...",chooseEntity:"Choose an entity...",connectionFailed:"Connection failed",searchEntities:"Search entities...",searchPowerSensors:"Search power sensors...",searchEnergySensors:"Search energy sensors...",placeholders:{plugName:"Living Room Plug",mqttStateOnValue:"ON, true, 1",mqttSameAsPower:"Same as power topic, or different"}},linkedTo:"Linked to:",monitorOnly:"Monitor Only",alerts:"Alerts",scheduleOn:"On {{time}}",scheduleOff:"Off {{time}}",on:"On",off:"Off",power:"Power",kwhToday:"kWh Today",settings:"Settings",automationSettings:"Automation Settings",showInSwitchbar:"Show in Switchbar",quickAccessSidebar:"Quick access from sidebar",enabled:"Enabled",enableAutomation:"Enable automation for this plug",autoOn:"Auto On",autoOnDescription:"Turn on when print starts",autoOff:"Auto Off",autoOffDescription:"Turn off when print completes (one-shot)",autoOffPersistent:"Keep Enabled",autoOffPersistentDescription:"Stay enabled between prints instead of one-shot",turnOffDelayMode:"Turn Off Delay Mode",time:"Time",temp:"Temp",delayMinutes:"Delay (minutes)",tempThreshold:"Temperature threshold (°C)",tempThresholdDescription:"Turns off when nozzle cools below this temperature",edit:"Edit",deleteConfirm:'Are you sure you want to delete "{{name}}"? This cannot be undone.',turnOnConfirm:'Are you sure you want to turn on "{{name}}"?',turnOffConfirm:'Are you sure you want to turn off "{{name}}"? This will cut power to the connected device.',failedToTurn:'Failed to turn {{action}} "{{name}}"',unknown:"Unknown",addTitle:"Add Smart Plug",editTitle:"Edit Smart Plug",stopScanning:"Stop Scanning",discoverTasmota:"Discover Tasmota Devices",foundDevices:"Found {{count}} device(s) - click to select:",noDevicesFound:"No Tasmota devices found on your network",haNotConfigured:"Home Assistant is not configured. Set it up in",haSettingsPath:"Settings → Network → Home Assistant",selectEntity:"Select Entity *",ipAddress:"IP Address *",nameLabel:"Name *",username:"Username",password:"Password",authHint:"Leave empty if your Tasmota device doesn't require authentication",linkToPrinter:"Link to Printer",noPrinter:"No printer (manual control only)",linkingDescription:"Linking enables automatic on/off when prints start/complete",powerAlerts:"Power Alerts",alertAbove:"Alert if above (W)",alertBelow:"Alert if below (W)",alertDescription:"Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.",dailySchedule:"Daily Schedule",turnOnAt:"Turn On at",turnOffAt:"Turn Off at",scheduleDescription:"Automatically turn the plug on/off at these times daily. Leave empty to skip that action.",showOnPrinterCard:"Show on Printer Card",displayOnPrinterCard:"Display button on printer card",connectedResult:"Connected!",deviceLabel:"Device: {{name}} - ",stateLabel:"State: {{state}}",test:"Test",delete:"Delete",save:"Save",add:"Add",cancel:"Cancel",failedToStartScan:"Failed to start scan",nameRequired:"Name is required",entityRequired:"Entity is required for Home Assistant plugs",mqttTopicRequired:"At least one MQTT topic must be configured for power, energy, or state monitoring",loadingEntities:"Loading entities...",loading:"Loading...",failedToLoadEntities:"Failed to load entities: {{error}}",noEntitiesMatching:'No entities found matching "{{search}}"',noEntitiesAvailable:"No entities available",searchingEntities:"Searching all entities ({{count}} found)",showingEntities:"Showing switch, light, input_boolean ({{count}} available)",energyMonitoringOptional:"Energy Monitoring (Optional)",energyMonitoringHint:"Search and select sensors that provide power/energy data.",powerSensorW:"Power Sensor (W)",energyTodayKwh:"Energy Today (kWh)",totalEnergyKwh:"Total Energy (kWh)",noMatchingSensors:"No matching sensors",none:"None",mqttNotConfigured:"MQTT broker not configured. Set broker address in",mqttSettingsPath:"Settings → Network → MQTT Publishing",mqttNotConfiguredSuffix:"(you don't need to enable publishing, just fill in the broker details).",mqttMonitorOnlyDescription:"MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.",powerMonitoring:"Power Monitoring",energyMonitoring:"Energy Monitoring",stateMonitoring:"State Monitoring",optional:"optional",topic:"Topic",jsonPath:"JSON Path",multiplier:"Multiplier",onValue:"ON Value",mqttPowerHint:`JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values. +`+j.stack}}var ie=Object.prototype.hasOwnProperty,ge=t.unstable_scheduleCallback,pe=t.unstable_cancelCallback,he=t.unstable_shouldYield,q=t.unstable_requestPaint,R=t.unstable_now,te=t.unstable_getCurrentPriorityLevel,fe=t.unstable_ImmediatePriority,ke=t.unstable_UserBlockingPriority,Ae=t.unstable_NormalPriority,je=t.unstable_LowPriority,ye=t.unstable_IdlePriority,Le=t.log,Be=t.unstable_setDisableYieldValue,Ce=null,ze=null;function Me(h){if(typeof Le=="function"&&Be(h),ze&&typeof ze.setStrictMode=="function")try{ze.setStrictMode(Ce,h)}catch{}}var Ie=Math.clz32?Math.clz32:ve,Ge=Math.log,et=Math.LN2;function ve(h){return h>>>=0,h===0?32:31-(Ge(h)/et|0)|0}var Xe=256,Pe=262144,Ve=4194304;function we(h){var y=h&42;if(y!==0)return y;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function de(h,y,_){var j=h.pendingLanes;if(j===0)return 0;var G=0,Z=h.suspendedLanes,Ne=h.pingedLanes;h=h.warmLanes;var qe=j&134217727;return qe!==0?(j=qe&~Z,j!==0?G=we(j):(Ne&=qe,Ne!==0?G=we(Ne):_||(_=qe&~h,_!==0&&(G=we(_))))):(qe=j&~Z,qe!==0?G=we(qe):Ne!==0?G=we(Ne):_||(_=j&~h,_!==0&&(G=we(_)))),G===0?0:y!==0&&y!==G&&(y&Z)===0&&(Z=G&-G,_=y&-y,Z>=_||Z===32&&(_&4194048)!==0)?y:G}function Se(h,y){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&y)===0}function $e(h,y){switch(h){case 1:case 2:case 4:case 8:case 64:return y+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return y+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function st(){var h=Ve;return Ve<<=1,(Ve&62914560)===0&&(Ve=4194304),h}function He(h){for(var y=[],_=0;31>_;_++)y.push(h);return y}function rt(h,y){h.pendingLanes|=y,y!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function mt(h,y,_,j,G,Z){var Ne=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var qe=h.entanglements,lt=h.expirationTimes,vt=h.hiddenUpdates;for(_=Ne&~_;0<_;){var Rt=31-Ie(_),Ut=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Pr=/[\n"\\]/g;function In(h){return h.replace(Pr,function(y){return"\\"+y.charCodeAt(0).toString(16)+" "})}function Da(h,y,_,j,G,Z,Ne,qe){h.name="",Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"?h.type=Ne:h.removeAttribute("type"),y!=null?Ne==="number"?(y===0&&h.value===""||h.value!=y)&&(h.value=""+cn(y)):h.value!==""+cn(y)&&(h.value=""+cn(y)):Ne!=="submit"&&Ne!=="reset"||h.removeAttribute("value"),y!=null?Br(h,Ne,cn(y)):_!=null?Br(h,Ne,cn(_)):j!=null&&h.removeAttribute("value"),G==null&&Z!=null&&(h.defaultChecked=!!Z),G!=null&&(h.checked=G&&typeof G!="function"&&typeof G!="symbol"),qe!=null&&typeof qe!="function"&&typeof qe!="symbol"&&typeof qe!="boolean"?h.name=""+cn(qe):h.removeAttribute("name")}function $n(h,y,_,j,G,Z,Ne,qe){if(Z!=null&&typeof Z!="function"&&typeof Z!="symbol"&&typeof Z!="boolean"&&(h.type=Z),y!=null||_!=null){if(!(Z!=="submit"&&Z!=="reset"||y!=null)){Sn(h);return}_=_!=null?""+cn(_):"",y=y!=null?""+cn(y):_,qe||y===h.value||(h.value=y),h.defaultValue=y}j=j??G,j=typeof j!="function"&&typeof j!="symbol"&&!!j,h.checked=qe?h.checked:!!j,h.defaultChecked=!!j,Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"&&(h.name=Ne),Sn(h)}function Br(h,y,_){y==="number"&&br(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function Kr(h,y,_,j){if(h=h.options,y){y={};for(var G=0;G<_.length;G++)y["$"+_[G]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),zi=!1;if($o)try{var wl={};Object.defineProperty(wl,"passive",{get:function(){zi=!0}}),window.addEventListener("test",wl,wl),window.removeEventListener("test",wl,wl)}catch{zi=!1}var wo=null,ha=null,vc=null;function Om(){if(vc)return vc;var h,y=ha,_=y.length,j,G="value"in wo?wo.value:wo.textContent,Z=G.length;for(h=0;h<_&&y[h]===G[h];h++);var Ne=_-h;for(j=1;j<=Ne&&y[_-j]===G[Z-j];j++);return vc=G.slice(h,1=it),ct=" ",Mn=!1;function xr(h,y){switch(h){case"keyup":return Fn.indexOf(y.keyCode)!==-1;case"keydown":return y.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function lr(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Ir=!1;function Fa(h,y){switch(h){case"compositionend":return lr(y);case"keypress":return y.which!==32?null:(Mn=!0,ct);case"textInput":return h=y.data,h===ct&&Mn?null:h;default:return null}}function un(h,y){if(Ir)return h==="compositionend"||!_n&&xr(h,y)?(h=Om(),vc=ha=wo=null,Ir=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(y.ctrlKey||y.altKey||y.metaKey)||y.ctrlKey&&y.altKey){if(y.char&&1=y)return{node:_,offset:y-h};h=j}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=Yp(_)}}function Jy(h,y){return h&&y?h===y?!0:h&&h.nodeType===3?!1:y&&y.nodeType===3?Jy(h,y.parentNode):"contains"in h?h.contains(y):h.compareDocumentPosition?!!(h.compareDocumentPosition(y)&16):!1:!1}function YS(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var y=br(h.document);y instanceof h.HTMLIFrameElement;){try{var _=typeof y.contentWindow.location.href=="string"}catch{_=!1}if(_)h=y.contentWindow;else break;y=br(h.document)}return y}function MA(h){var y=h&&h.nodeName&&h.nodeName.toLowerCase();return y&&(y==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||y==="textarea"||h.contentEditable==="true")}var Mce=$o&&"documentMode"in document&&11>=document.documentMode,eb=null,EA=null,Zy=null,DA=!1;function D5(h,y,_){var j=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;DA||eb==null||eb!==br(j)||(j=eb,"selectionStart"in j&&MA(j)?j={start:j.selectionStart,end:j.selectionEnd}:(j=(j.ownerDocument&&j.ownerDocument.defaultView||window).getSelection(),j={anchorNode:j.anchorNode,anchorOffset:j.anchorOffset,focusNode:j.focusNode,focusOffset:j.focusOffset}),Zy&&Vm(Zy,j)||(Zy=j,j=I_(EA,"onSelect"),0>=Ne,G-=Ne,ld=1<<32-Ie(y)+G|_<Qn?(vr=vn,vn=null):vr=vn.sibling;var Mr=St(gt,vn,yt[Qn],It);if(Mr===null){vn===null&&(vn=vr);break}h&&vn&&Mr.alternate===null&&y(gt,vn),pt=Z(Mr,pt,Qn),jr===null?An=Mr:jr.sibling=Mr,jr=Mr,vn=vr}if(Qn===yt.length)return _(gt,vn),Sr&&cu(gt,Qn),An;if(vn===null){for(;QnQn?(vr=vn,vn=null):vr=vn.sibling;var hh=St(gt,vn,Mr.value,It);if(hh===null){vn===null&&(vn=vr);break}h&&vn&&hh.alternate===null&&y(gt,vn),pt=Z(hh,pt,Qn),jr===null?An=hh:jr.sibling=hh,jr=hh,vn=vr}if(Mr.done)return _(gt,vn),Sr&&cu(gt,Qn),An;if(vn===null){for(;!Mr.done;Qn++,Mr=yt.next())Mr=Ut(gt,Mr.value,It),Mr!==null&&(pt=Z(Mr,pt,Qn),jr===null?An=Mr:jr.sibling=Mr,jr=Mr);return Sr&&cu(gt,Qn),An}for(vn=j(vn);!Mr.done;Qn++,Mr=yt.next())Mr=At(vn,gt,Qn,Mr.value,It),Mr!==null&&(h&&Mr.alternate!==null&&vn.delete(Mr.key===null?Qn:Mr.key),pt=Z(Mr,pt,Qn),jr===null?An=Mr:jr.sibling=Mr,jr=Mr);return h&&vn.forEach(function(Qde){return y(gt,Qde)}),Sr&&cu(gt,Qn),An}function Qr(gt,pt,yt,It){if(typeof yt=="object"&&yt!==null&&yt.type===v&&yt.key===null&&(yt=yt.props.children),typeof yt=="object"&&yt!==null){switch(yt.$$typeof){case f:e:{for(var An=yt.key;pt!==null;){if(pt.key===An){if(An=yt.type,An===v){if(pt.tag===7){_(gt,pt.sibling),It=G(pt,yt.props.children),It.return=gt,gt=It;break e}}else if(pt.elementType===An||typeof An=="object"&&An!==null&&An.$$typeof===F&&sf(An)===pt.type){_(gt,pt.sibling),It=G(pt,yt.props),iv(It,yt),It.return=gt,gt=It;break e}_(gt,pt);break}else y(gt,pt);pt=pt.sibling}yt.type===v?(It=ef(yt.props.children,gt.mode,It,yt.key),It.return=gt,gt=It):(It=t_(yt.type,yt.key,yt.props,null,gt.mode,It),iv(It,yt),It.return=gt,gt=It)}return Ne(gt);case x:e:{for(An=yt.key;pt!==null;){if(pt.key===An)if(pt.tag===4&&pt.stateNode.containerInfo===yt.containerInfo&&pt.stateNode.implementation===yt.implementation){_(gt,pt.sibling),It=G(pt,yt.children||[]),It.return=gt,gt=It;break e}else{_(gt,pt);break}else y(gt,pt);pt=pt.sibling}It=UA(yt,gt.mode,It),It.return=gt,gt=It}return Ne(gt);case F:return yt=sf(yt),Qr(gt,pt,yt,It)}if(ne(yt))return gn(gt,pt,yt,It);if(z(yt)){if(An=z(yt),typeof An!="function")throw Error(r(150));return yt=An.call(yt),Ln(gt,pt,yt,It)}if(typeof yt.then=="function")return Qr(gt,pt,l_(yt),It);if(yt.$$typeof===N)return Qr(gt,pt,a_(gt,yt),It);c_(gt,yt)}return typeof yt=="string"&&yt!==""||typeof yt=="number"||typeof yt=="bigint"?(yt=""+yt,pt!==null&&pt.tag===6?(_(gt,pt.sibling),It=G(pt,yt),It.return=gt,gt=It):(_(gt,pt),It=zA(yt,gt.mode,It),It.return=gt,gt=It),Ne(gt)):_(gt,pt)}return function(gt,pt,yt,It){try{av=0;var An=Qr(gt,pt,yt,It);return ub=null,An}catch(vn){if(vn===db||vn===s_)throw vn;var jr=Ko(29,vn,null,gt.mode);return jr.lanes=It,jr.return=gt,jr}}}var lf=nU(!0),rU=nU(!1),Ym=!1;function JA(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ZA(h,y){h=h.updateQueue,y.updateQueue===h&&(y.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function Qm(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Jm(h,y,_){var j=h.updateQueue;if(j===null)return null;if(j=j.shared,(Lr&2)!==0){var G=j.pending;return G===null?y.next=y:(y.next=G.next,G.next=y),j.pending=y,y=e_(h),U5(h,null,_),y}return ZS(h,j,y,_),e_(h)}function sv(h,y,_){if(y=y.updateQueue,y!==null&&(y=y.shared,(_&4194048)!==0)){var j=y.lanes;j&=h.pendingLanes,_|=j,y.lanes=_,qt(h,_)}}function ej(h,y){var _=h.updateQueue,j=h.alternate;if(j!==null&&(j=j.updateQueue,_===j)){var G=null,Z=null;if(_=_.firstBaseUpdate,_!==null){do{var Ne={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};Z===null?G=Z=Ne:Z=Z.next=Ne,_=_.next}while(_!==null);Z===null?G=Z=y:Z=Z.next=y}else G=Z=y;_={baseState:j.baseState,firstBaseUpdate:G,lastBaseUpdate:Z,shared:j.shared,callbacks:j.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=y:h.next=y,_.lastBaseUpdate=y}var tj=!1;function ov(){if(tj){var h=cb;if(h!==null)throw h}}function lv(h,y,_,j){tj=!1;var G=h.updateQueue;Ym=!1;var Z=G.firstBaseUpdate,Ne=G.lastBaseUpdate,qe=G.shared.pending;if(qe!==null){G.shared.pending=null;var lt=qe,vt=lt.next;lt.next=null,Ne===null?Z=vt:Ne.next=vt,Ne=lt;var Rt=h.alternate;Rt!==null&&(Rt=Rt.updateQueue,qe=Rt.lastBaseUpdate,qe!==Ne&&(qe===null?Rt.firstBaseUpdate=vt:qe.next=vt,Rt.lastBaseUpdate=lt))}if(Z!==null){var Ut=G.baseState;Ne=0,Rt=vt=lt=null,qe=Z;do{var St=qe.lane&-536870913,At=St!==qe.lane;if(At?(yr&St)===St:(j&St)===St){St!==0&&St===lb&&(tj=!0),Rt!==null&&(Rt=Rt.next={lane:0,tag:qe.tag,payload:qe.payload,callback:null,next:null});e:{var gn=h,Ln=qe;St=y;var Qr=_;switch(Ln.tag){case 1:if(gn=Ln.payload,typeof gn=="function"){Ut=gn.call(Qr,Ut,St);break e}Ut=gn;break e;case 3:gn.flags=gn.flags&-65537|128;case 0:if(gn=Ln.payload,St=typeof gn=="function"?gn.call(Qr,Ut,St):gn,St==null)break e;Ut=m({},Ut,St);break e;case 2:Ym=!0}}St=qe.callback,St!==null&&(h.flags|=64,At&&(h.flags|=8192),At=G.callbacks,At===null?G.callbacks=[St]:At.push(St))}else At={lane:St,tag:qe.tag,payload:qe.payload,callback:qe.callback,next:null},Rt===null?(vt=Rt=At,lt=Ut):Rt=Rt.next=At,Ne|=St;if(qe=qe.next,qe===null){if(qe=G.shared.pending,qe===null)break;At=qe,qe=At.next,At.next=null,G.lastBaseUpdate=At,G.shared.pending=null}}while(!0);Rt===null&&(lt=Ut),G.baseState=lt,G.firstBaseUpdate=vt,G.lastBaseUpdate=Rt,Z===null&&(G.shared.lanes=0),rh|=Ne,h.lanes=Ne,h.memoizedState=Ut}}function aU(h,y){if(typeof h!="function")throw Error(r(191,h));h.call(y)}function iU(h,y){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)aU(_[h],y)}var mb=K(null),d_=K(0);function sU(h,y){h=vu,me(d_,h),me(mb,y),vu=h|y.baseLanes}function nj(){me(d_,vu),me(mb,mb.current)}function rj(){vu=d_.current,ae(mb),ae(d_)}var Xo=K(null),Dl=null;function Zm(h){var y=h.alternate;me(ai,ai.current&1),me(Xo,h),Dl===null&&(y===null||mb.current!==null||y.memoizedState!==null)&&(Dl=h)}function aj(h){me(ai,ai.current),me(Xo,h),Dl===null&&(Dl=h)}function oU(h){h.tag===22?(me(ai,ai.current),me(Xo,h),Dl===null&&(Dl=h)):eh()}function eh(){me(ai,ai.current),me(Xo,Xo.current)}function Yo(h){ae(Xo),Dl===h&&(Dl=null),ae(ai)}var ai=K(0);function u_(h){for(var y=h;y!==null;){if(y.tag===13){var _=y.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||dM(_)||uM(_)))return y}else if(y.tag===19&&(y.memoizedProps.revealOrder==="forwards"||y.memoizedProps.revealOrder==="backwards"||y.memoizedProps.revealOrder==="unstable_legacy-backwards"||y.memoizedProps.revealOrder==="together")){if((y.flags&128)!==0)return y}else if(y.child!==null){y.child.return=y,y=y.child;continue}if(y===h)break;for(;y.sibling===null;){if(y.return===null||y.return===h)return null;y=y.return}y.sibling.return=y.return,y=y.sibling}return null}var mu=0,Xn=null,Xr=null,mi=null,m_=!1,hb=!1,cf=!1,h_=0,cv=0,pb=null,Bce=0;function $a(){throw Error(r(321))}function ij(h,y){if(y===null)return!1;for(var _=0;_Z?Z:8;var Ne=re.T,qe={};re.T=qe,vj(h,!1,y,_);try{var lt=G(),vt=re.S;if(vt!==null&&vt(qe,lt),lt!==null&&typeof lt=="object"&&typeof lt.then=="function"){var Rt=Uce(lt,j);uv(h,y,Rt,Zo(h))}else uv(h,y,j,Zo(h))}catch(Ut){uv(h,y,{then:function(){},status:"rejected",reason:Ut},Zo())}finally{ee.p=Z,Ne!==null&&qe.types!==null&&(Ne.types=qe.types),re.T=Ne}}function Gce(){}function xj(h,y,_,j){if(h.tag!==5)throw Error(r(476));var G=OU(h).queue;LU(h,G,y,ue,_===null?Gce:function(){return IU(h),_(j)})}function OU(h){var y=h.memoizedState;if(y!==null)return y;y={memoizedState:ue,baseState:ue,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:ue},next:null};var _={};return y.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:_},next:null},h.memoizedState=y,h=h.alternate,h!==null&&(h.memoizedState=y),y}function IU(h){var y=OU(h);y.next===null&&(y=h.alternate.memoizedState),uv(h,y.next.queue,{},Zo())}function yj(){return ds(Tv)}function zU(){return ii().memoizedState}function UU(){return ii().memoizedState}function Wce(h){for(var y=h.return;y!==null;){switch(y.tag){case 24:case 3:var _=Zo();h=Qm(_);var j=Jm(y,h,_);j!==null&&(To(j,y,_),sv(j,y,_)),y={cache:KA()},h.payload=y;return}y=y.return}}function Kce(h,y,_){var j=Zo();_={lane:j,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},y_(h)?HU(y,_):(_=OA(h,y,_,j),_!==null&&(To(_,h,j),qU(_,y,j)))}function BU(h,y,_){var j=Zo();uv(h,y,_,j)}function uv(h,y,_,j){var G={lane:j,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(y_(h))HU(y,G);else{var Z=h.alternate;if(h.lanes===0&&(Z===null||Z.lanes===0)&&(Z=y.lastRenderedReducer,Z!==null))try{var Ne=y.lastRenderedState,qe=Z(Ne,_);if(G.hasEagerState=!0,G.eagerState=qe,_o(qe,Ne))return ZS(h,y,G,0),oa===null&&JS(),!1}catch{}if(_=OA(h,y,G,j),_!==null)return To(_,h,j),qU(_,y,j),!0}return!1}function vj(h,y,_,j){if(j={lane:2,revertLane:Jj(),gesture:null,action:j,hasEagerState:!1,eagerState:null,next:null},y_(h)){if(y)throw Error(r(479))}else y=OA(h,_,j,2),y!==null&&To(y,h,2)}function y_(h){var y=h.alternate;return h===Xn||y!==null&&y===Xn}function HU(h,y){hb=m_=!0;var _=h.pending;_===null?y.next=y:(y.next=_.next,_.next=y),h.pending=y}function qU(h,y,_){if((_&4194048)!==0){var j=y.lanes;j&=h.pendingLanes,_|=j,y.lanes=_,qt(h,_)}}var mv={readContext:ds,use:f_,useCallback:$a,useContext:$a,useEffect:$a,useImperativeHandle:$a,useLayoutEffect:$a,useInsertionEffect:$a,useMemo:$a,useReducer:$a,useRef:$a,useState:$a,useDebugValue:$a,useDeferredValue:$a,useTransition:$a,useSyncExternalStore:$a,useId:$a,useHostTransitionStatus:$a,useFormState:$a,useActionState:$a,useOptimistic:$a,useMemoCache:$a,useCacheRefresh:$a};mv.useEffectEvent=$a;var $U={readContext:ds,use:f_,useCallback:function(h,y){return Zs().memoizedState=[h,y===void 0?null:y],h},useContext:ds,useEffect:PU,useImperativeHandle:function(h,y,_){_=_!=null?_.concat([h]):null,b_(4194308,4,MU.bind(null,y,h),_)},useLayoutEffect:function(h,y){return b_(4194308,4,h,y)},useInsertionEffect:function(h,y){b_(4,2,h,y)},useMemo:function(h,y){var _=Zs();y=y===void 0?null:y;var j=h();if(cf){Me(!0);try{h()}finally{Me(!1)}}return _.memoizedState=[j,y],j},useReducer:function(h,y,_){var j=Zs();if(_!==void 0){var G=_(y);if(cf){Me(!0);try{_(y)}finally{Me(!1)}}}else G=y;return j.memoizedState=j.baseState=G,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:G},j.queue=h,h=h.dispatch=Kce.bind(null,Xn,h),[j.memoizedState,h]},useRef:function(h){var y=Zs();return h={current:h},y.memoizedState=h},useState:function(h){h=hj(h);var y=h.queue,_=BU.bind(null,Xn,y);return y.dispatch=_,[h.memoizedState,_]},useDebugValue:gj,useDeferredValue:function(h,y){var _=Zs();return bj(_,h,y)},useTransition:function(){var h=hj(!1);return h=LU.bind(null,Xn,h.queue,!0,!1),Zs().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,y,_){var j=Xn,G=Zs();if(Sr){if(_===void 0)throw Error(r(407));_=_()}else{if(_=y(),oa===null)throw Error(r(349));(yr&127)!==0||uU(j,y,_)}G.memoizedState=_;var Z={value:_,getSnapshot:y};return G.queue=Z,PU(hU.bind(null,j,Z,h),[h]),j.flags|=2048,fb(9,{destroy:void 0},mU.bind(null,j,Z,_,y),null),_},useId:function(){var h=Zs(),y=oa.identifierPrefix;if(Sr){var _=cd,j=ld;_=(j&~(1<<32-Ie(j)-1)).toString(32)+_,y="_"+y+"R_"+_,_=h_++,0<_&&(y+="H"+_.toString(32)),y+="_"}else _=Bce++,y="_"+y+"r_"+_.toString(32)+"_";return h.memoizedState=y},useHostTransitionStatus:yj,useFormState:SU,useActionState:SU,useOptimistic:function(h){var y=Zs();y.memoizedState=y.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return y.queue=_,y=vj.bind(null,Xn,!0,_),_.dispatch=y,[h,y]},useMemoCache:dj,useCacheRefresh:function(){return Zs().memoizedState=Wce.bind(null,Xn)},useEffectEvent:function(h){var y=Zs(),_={impl:h};return y.memoizedState=_,function(){if((Lr&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},wj={readContext:ds,use:f_,useCallback:DU,useContext:ds,useEffect:fj,useImperativeHandle:EU,useInsertionEffect:AU,useLayoutEffect:jU,useMemo:FU,useReducer:g_,useRef:CU,useState:function(){return g_(hu)},useDebugValue:gj,useDeferredValue:function(h,y){var _=ii();return RU(_,Xr.memoizedState,h,y)},useTransition:function(){var h=g_(hu)[0],y=ii().memoizedState;return[typeof h=="boolean"?h:dv(h),y]},useSyncExternalStore:dU,useId:zU,useHostTransitionStatus:yj,useFormState:_U,useActionState:_U,useOptimistic:function(h,y){var _=ii();return gU(_,Xr,h,y)},useMemoCache:dj,useCacheRefresh:UU};wj.useEffectEvent=TU;var VU={readContext:ds,use:f_,useCallback:DU,useContext:ds,useEffect:fj,useImperativeHandle:EU,useInsertionEffect:AU,useLayoutEffect:jU,useMemo:FU,useReducer:mj,useRef:CU,useState:function(){return mj(hu)},useDebugValue:gj,useDeferredValue:function(h,y){var _=ii();return Xr===null?bj(_,h,y):RU(_,Xr.memoizedState,h,y)},useTransition:function(){var h=mj(hu)[0],y=ii().memoizedState;return[typeof h=="boolean"?h:dv(h),y]},useSyncExternalStore:dU,useId:zU,useHostTransitionStatus:yj,useFormState:NU,useActionState:NU,useOptimistic:function(h,y){var _=ii();return Xr!==null?gU(_,Xr,h,y):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:dj,useCacheRefresh:UU};VU.useEffectEvent=TU;function Sj(h,y,_,j){y=h.memoizedState,_=_(j,y),_=_==null?y:m({},y,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var _j={enqueueSetState:function(h,y,_){h=h._reactInternals;var j=Zo(),G=Qm(j);G.payload=y,_!=null&&(G.callback=_),y=Jm(h,G,j),y!==null&&(To(y,h,j),sv(y,h,j))},enqueueReplaceState:function(h,y,_){h=h._reactInternals;var j=Zo(),G=Qm(j);G.tag=1,G.payload=y,_!=null&&(G.callback=_),y=Jm(h,G,j),y!==null&&(To(y,h,j),sv(y,h,j))},enqueueForceUpdate:function(h,y){h=h._reactInternals;var _=Zo(),j=Qm(_);j.tag=2,y!=null&&(j.callback=y),y=Jm(h,j,_),y!==null&&(To(y,h,_),sv(y,h,_))}};function GU(h,y,_,j,G,Z,Ne){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(j,Z,Ne):y.prototype&&y.prototype.isPureReactComponent?!Vm(_,j)||!Vm(G,Z):!0}function WU(h,y,_,j){h=y.state,typeof y.componentWillReceiveProps=="function"&&y.componentWillReceiveProps(_,j),typeof y.UNSAFE_componentWillReceiveProps=="function"&&y.UNSAFE_componentWillReceiveProps(_,j),y.state!==h&&_j.enqueueReplaceState(y,y.state,null)}function df(h,y){var _=y;if("ref"in y){_={};for(var j in y)j!=="ref"&&(_[j]=y[j])}if(h=h.defaultProps){_===y&&(_=m({},_));for(var G in h)_[G]===void 0&&(_[G]=h[G])}return _}function KU(h){QS(h)}function XU(h){console.error(h)}function YU(h){QS(h)}function v_(h,y){try{var _=h.onUncaughtError;_(y.value,{componentStack:y.stack})}catch(j){setTimeout(function(){throw j})}}function QU(h,y,_){try{var j=h.onCaughtError;j(_.value,{componentStack:_.stack,errorBoundary:y.tag===1?y.stateNode:null})}catch(G){setTimeout(function(){throw G})}}function kj(h,y,_){return _=Qm(_),_.tag=3,_.payload={element:null},_.callback=function(){v_(h,y)},_}function JU(h){return h=Qm(h),h.tag=3,h}function ZU(h,y,_,j){var G=_.type.getDerivedStateFromError;if(typeof G=="function"){var Z=j.value;h.payload=function(){return G(Z)},h.callback=function(){QU(y,_,j)}}var Ne=_.stateNode;Ne!==null&&typeof Ne.componentDidCatch=="function"&&(h.callback=function(){QU(y,_,j),typeof G!="function"&&(ah===null?ah=new Set([this]):ah.add(this));var qe=j.stack;this.componentDidCatch(j.value,{componentStack:qe!==null?qe:""})})}function Xce(h,y,_,j,G){if(_.flags|=32768,j!==null&&typeof j=="object"&&typeof j.then=="function"){if(y=_.alternate,y!==null&&ob(y,_,G,!0),_=Xo.current,_!==null){switch(_.tag){case 31:case 13:return Dl===null?E_():_.alternate===null&&Va===0&&(Va=3),_.flags&=-257,_.flags|=65536,_.lanes=G,j===o_?_.flags|=16384:(y=_.updateQueue,y===null?_.updateQueue=new Set([j]):y.add(j),Xj(h,j,G)),!1;case 22:return _.flags|=65536,j===o_?_.flags|=16384:(y=_.updateQueue,y===null?(y={transitions:null,markerInstances:null,retryQueue:new Set([j])},_.updateQueue=y):(_=y.retryQueue,_===null?y.retryQueue=new Set([j]):_.add(j)),Xj(h,j,G)),!1}throw Error(r(435,_.tag))}return Xj(h,j,G),E_(),!1}if(Sr)return y=Xo.current,y!==null?((y.flags&65536)===0&&(y.flags|=256),y.flags|=65536,y.lanes=G,j!==qA&&(h=Error(r(422),{cause:j}),tv(Al(h,_)))):(j!==qA&&(y=Error(r(423),{cause:j}),tv(Al(y,_))),h=h.current.alternate,h.flags|=65536,G&=-G,h.lanes|=G,j=Al(j,_),G=kj(h.stateNode,j,G),ej(h,G),Va!==4&&(Va=2)),!1;var Z=Error(r(520),{cause:j});if(Z=Al(Z,_),vv===null?vv=[Z]:vv.push(Z),Va!==4&&(Va=2),y===null)return!0;j=Al(j,_),_=y;do{switch(_.tag){case 3:return _.flags|=65536,h=G&-G,_.lanes|=h,h=kj(_.stateNode,j,h),ej(_,h),!1;case 1:if(y=_.type,Z=_.stateNode,(_.flags&128)===0&&(typeof y.getDerivedStateFromError=="function"||Z!==null&&typeof Z.componentDidCatch=="function"&&(ah===null||!ah.has(Z))))return _.flags|=65536,G&=-G,_.lanes|=G,G=JU(G),ZU(G,h,_,j),ej(_,G),!1}_=_.return}while(_!==null);return!1}var Nj=Error(r(461)),hi=!1;function us(h,y,_,j){y.child=h===null?rU(y,null,_,j):lf(y,h.child,_,j)}function eB(h,y,_,j,G){_=_.render;var Z=y.ref;if("ref"in j){var Ne={};for(var qe in j)qe!=="ref"&&(Ne[qe]=j[qe])}else Ne=j;return rf(y),j=sj(h,y,_,Ne,Z,G),qe=oj(),h!==null&&!hi?(lj(h,y,G),pu(h,y,G)):(Sr&&qe&&BA(y),y.flags|=1,us(h,y,j,G),y.child)}function tB(h,y,_,j,G){if(h===null){var Z=_.type;return typeof Z=="function"&&!IA(Z)&&Z.defaultProps===void 0&&_.compare===null?(y.tag=15,y.type=Z,nB(h,y,Z,j,G)):(h=t_(_.type,null,j,y,y.mode,G),h.ref=y.ref,h.return=y,y.child=h)}if(Z=h.child,!Dj(h,G)){var Ne=Z.memoizedProps;if(_=_.compare,_=_!==null?_:Vm,_(Ne,j)&&h.ref===y.ref)return pu(h,y,G)}return y.flags|=1,h=lu(Z,j),h.ref=y.ref,h.return=y,y.child=h}function nB(h,y,_,j,G){if(h!==null){var Z=h.memoizedProps;if(Vm(Z,j)&&h.ref===y.ref)if(hi=!1,y.pendingProps=j=Z,Dj(h,G))(h.flags&131072)!==0&&(hi=!0);else return y.lanes=h.lanes,pu(h,y,G)}return Cj(h,y,_,j,G)}function rB(h,y,_,j){var G=j.children,Z=h!==null?h.memoizedState:null;if(h===null&&y.stateNode===null&&(y.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),j.mode==="hidden"){if((y.flags&128)!==0){if(Z=Z!==null?Z.baseLanes|_:_,h!==null){for(j=y.child=h.child,G=0;j!==null;)G=G|j.lanes|j.childLanes,j=j.sibling;j=G&~Z}else j=0,y.child=null;return aB(h,y,Z,_,j)}if((_&536870912)!==0)y.memoizedState={baseLanes:0,cachePool:null},h!==null&&i_(y,Z!==null?Z.cachePool:null),Z!==null?sU(y,Z):nj(),oU(y);else return j=y.lanes=536870912,aB(h,y,Z!==null?Z.baseLanes|_:_,_,j)}else Z!==null?(i_(y,Z.cachePool),sU(y,Z),eh(),y.memoizedState=null):(h!==null&&i_(y,null),nj(),eh());return us(h,y,G,_),y.child}function hv(h,y){return h!==null&&h.tag===22||y.stateNode!==null||(y.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),y.sibling}function aB(h,y,_,j,G){var Z=YA();return Z=Z===null?null:{parent:ui._currentValue,pool:Z},y.memoizedState={baseLanes:_,cachePool:Z},h!==null&&i_(y,null),nj(),oU(y),h!==null&&ob(h,y,j,!0),y.childLanes=G,null}function w_(h,y){return y=__({mode:y.mode,children:y.children},h.mode),y.ref=h.ref,h.child=y,y.return=h,y}function iB(h,y,_){return lf(y,h.child,null,_),h=w_(y,y.pendingProps),h.flags|=2,Yo(y),y.memoizedState=null,h}function Yce(h,y,_){var j=y.pendingProps,G=(y.flags&128)!==0;if(y.flags&=-129,h===null){if(Sr){if(j.mode==="hidden")return h=w_(y,j),y.lanes=536870912,hv(null,h);if(aj(y),(h=xa)?(h=b8(h,El),h=h!==null&&h.data==="&"?h:null,h!==null&&(y.memoizedState={dehydrated:h,treeContext:Gm!==null?{id:ld,overflow:cd}:null,retryLane:536870912,hydrationErrors:null},_=H5(h),_.return=y,y.child=_,cs=y,xa=null)):h=null,h===null)throw Km(y);return y.lanes=536870912,null}return w_(y,j)}var Z=h.memoizedState;if(Z!==null){var Ne=Z.dehydrated;if(aj(y),G)if(y.flags&256)y.flags&=-257,y=iB(h,y,_);else if(y.memoizedState!==null)y.child=h.child,y.flags|=128,y=null;else throw Error(r(558));else if(hi||ob(h,y,_,!1),G=(_&h.childLanes)!==0,hi||G){if(j=oa,j!==null&&(Ne=Vt(j,_),Ne!==0&&Ne!==Z.retryLane))throw Z.retryLane=Ne,Zp(h,Ne),To(j,h,Ne),Nj;E_(),y=iB(h,y,_)}else h=Z.treeContext,xa=Fl(Ne.nextSibling),cs=y,Sr=!0,Wm=null,El=!1,h!==null&&V5(y,h),y=w_(y,j),y.flags|=4096;return y}return h=lu(h.child,{mode:j.mode,children:j.children}),h.ref=y.ref,y.child=h,h.return=y,h}function S_(h,y){var _=y.ref;if(_===null)h!==null&&h.ref!==null&&(y.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(y.flags|=4194816)}}function Cj(h,y,_,j,G){return rf(y),_=sj(h,y,_,j,void 0,G),j=oj(),h!==null&&!hi?(lj(h,y,G),pu(h,y,G)):(Sr&&j&&BA(y),y.flags|=1,us(h,y,_,G),y.child)}function sB(h,y,_,j,G,Z){return rf(y),y.updateQueue=null,_=cU(y,j,_,G),lU(h),j=oj(),h!==null&&!hi?(lj(h,y,Z),pu(h,y,Z)):(Sr&&j&&BA(y),y.flags|=1,us(h,y,_,Z),y.child)}function oB(h,y,_,j,G){if(rf(y),y.stateNode===null){var Z=rb,Ne=_.contextType;typeof Ne=="object"&&Ne!==null&&(Z=ds(Ne)),Z=new _(j,Z),y.memoizedState=Z.state!==null&&Z.state!==void 0?Z.state:null,Z.updater=_j,y.stateNode=Z,Z._reactInternals=y,Z=y.stateNode,Z.props=j,Z.state=y.memoizedState,Z.refs={},JA(y),Ne=_.contextType,Z.context=typeof Ne=="object"&&Ne!==null?ds(Ne):rb,Z.state=y.memoizedState,Ne=_.getDerivedStateFromProps,typeof Ne=="function"&&(Sj(y,_,Ne,j),Z.state=y.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof Z.getSnapshotBeforeUpdate=="function"||typeof Z.UNSAFE_componentWillMount!="function"&&typeof Z.componentWillMount!="function"||(Ne=Z.state,typeof Z.componentWillMount=="function"&&Z.componentWillMount(),typeof Z.UNSAFE_componentWillMount=="function"&&Z.UNSAFE_componentWillMount(),Ne!==Z.state&&_j.enqueueReplaceState(Z,Z.state,null),lv(y,j,Z,G),ov(),Z.state=y.memoizedState),typeof Z.componentDidMount=="function"&&(y.flags|=4194308),j=!0}else if(h===null){Z=y.stateNode;var qe=y.memoizedProps,lt=df(_,qe);Z.props=lt;var vt=Z.context,Rt=_.contextType;Ne=rb,typeof Rt=="object"&&Rt!==null&&(Ne=ds(Rt));var Ut=_.getDerivedStateFromProps;Rt=typeof Ut=="function"||typeof Z.getSnapshotBeforeUpdate=="function",qe=y.pendingProps!==qe,Rt||typeof Z.UNSAFE_componentWillReceiveProps!="function"&&typeof Z.componentWillReceiveProps!="function"||(qe||vt!==Ne)&&WU(y,Z,j,Ne),Ym=!1;var St=y.memoizedState;Z.state=St,lv(y,j,Z,G),ov(),vt=y.memoizedState,qe||St!==vt||Ym?(typeof Ut=="function"&&(Sj(y,_,Ut,j),vt=y.memoizedState),(lt=Ym||GU(y,_,lt,j,St,vt,Ne))?(Rt||typeof Z.UNSAFE_componentWillMount!="function"&&typeof Z.componentWillMount!="function"||(typeof Z.componentWillMount=="function"&&Z.componentWillMount(),typeof Z.UNSAFE_componentWillMount=="function"&&Z.UNSAFE_componentWillMount()),typeof Z.componentDidMount=="function"&&(y.flags|=4194308)):(typeof Z.componentDidMount=="function"&&(y.flags|=4194308),y.memoizedProps=j,y.memoizedState=vt),Z.props=j,Z.state=vt,Z.context=Ne,j=lt):(typeof Z.componentDidMount=="function"&&(y.flags|=4194308),j=!1)}else{Z=y.stateNode,ZA(h,y),Ne=y.memoizedProps,Rt=df(_,Ne),Z.props=Rt,Ut=y.pendingProps,St=Z.context,vt=_.contextType,lt=rb,typeof vt=="object"&&vt!==null&&(lt=ds(vt)),qe=_.getDerivedStateFromProps,(vt=typeof qe=="function"||typeof Z.getSnapshotBeforeUpdate=="function")||typeof Z.UNSAFE_componentWillReceiveProps!="function"&&typeof Z.componentWillReceiveProps!="function"||(Ne!==Ut||St!==lt)&&WU(y,Z,j,lt),Ym=!1,St=y.memoizedState,Z.state=St,lv(y,j,Z,G),ov();var At=y.memoizedState;Ne!==Ut||St!==At||Ym||h!==null&&h.dependencies!==null&&r_(h.dependencies)?(typeof qe=="function"&&(Sj(y,_,qe,j),At=y.memoizedState),(Rt=Ym||GU(y,_,Rt,j,St,At,lt)||h!==null&&h.dependencies!==null&&r_(h.dependencies))?(vt||typeof Z.UNSAFE_componentWillUpdate!="function"&&typeof Z.componentWillUpdate!="function"||(typeof Z.componentWillUpdate=="function"&&Z.componentWillUpdate(j,At,lt),typeof Z.UNSAFE_componentWillUpdate=="function"&&Z.UNSAFE_componentWillUpdate(j,At,lt)),typeof Z.componentDidUpdate=="function"&&(y.flags|=4),typeof Z.getSnapshotBeforeUpdate=="function"&&(y.flags|=1024)):(typeof Z.componentDidUpdate!="function"||Ne===h.memoizedProps&&St===h.memoizedState||(y.flags|=4),typeof Z.getSnapshotBeforeUpdate!="function"||Ne===h.memoizedProps&&St===h.memoizedState||(y.flags|=1024),y.memoizedProps=j,y.memoizedState=At),Z.props=j,Z.state=At,Z.context=lt,j=Rt):(typeof Z.componentDidUpdate!="function"||Ne===h.memoizedProps&&St===h.memoizedState||(y.flags|=4),typeof Z.getSnapshotBeforeUpdate!="function"||Ne===h.memoizedProps&&St===h.memoizedState||(y.flags|=1024),j=!1)}return Z=j,S_(h,y),j=(y.flags&128)!==0,Z||j?(Z=y.stateNode,_=j&&typeof _.getDerivedStateFromError!="function"?null:Z.render(),y.flags|=1,h!==null&&j?(y.child=lf(y,h.child,null,G),y.child=lf(y,null,_,G)):us(h,y,_,G),y.memoizedState=Z.state,h=y.child):h=pu(h,y,G),h}function lB(h,y,_,j){return tf(),y.flags|=256,us(h,y,_,j),y.child}var Pj={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Tj(h){return{baseLanes:h,cachePool:Q5()}}function Aj(h,y,_){return h=h!==null?h.childLanes&~_:0,y&&(h|=Jo),h}function cB(h,y,_){var j=y.pendingProps,G=!1,Z=(y.flags&128)!==0,Ne;if((Ne=Z)||(Ne=h!==null&&h.memoizedState===null?!1:(ai.current&2)!==0),Ne&&(G=!0,y.flags&=-129),Ne=(y.flags&32)!==0,y.flags&=-33,h===null){if(Sr){if(G?Zm(y):eh(),(h=xa)?(h=b8(h,El),h=h!==null&&h.data!=="&"?h:null,h!==null&&(y.memoizedState={dehydrated:h,treeContext:Gm!==null?{id:ld,overflow:cd}:null,retryLane:536870912,hydrationErrors:null},_=H5(h),_.return=y,y.child=_,cs=y,xa=null)):h=null,h===null)throw Km(y);return uM(h)?y.lanes=32:y.lanes=536870912,null}var qe=j.children;return j=j.fallback,G?(eh(),G=y.mode,qe=__({mode:"hidden",children:qe},G),j=ef(j,G,_,null),qe.return=y,j.return=y,qe.sibling=j,y.child=qe,j=y.child,j.memoizedState=Tj(_),j.childLanes=Aj(h,Ne,_),y.memoizedState=Pj,hv(null,j)):(Zm(y),jj(y,qe))}var lt=h.memoizedState;if(lt!==null&&(qe=lt.dehydrated,qe!==null)){if(Z)y.flags&256?(Zm(y),y.flags&=-257,y=Mj(h,y,_)):y.memoizedState!==null?(eh(),y.child=h.child,y.flags|=128,y=null):(eh(),qe=j.fallback,G=y.mode,j=__({mode:"visible",children:j.children},G),qe=ef(qe,G,_,null),qe.flags|=2,j.return=y,qe.return=y,j.sibling=qe,y.child=j,lf(y,h.child,null,_),j=y.child,j.memoizedState=Tj(_),j.childLanes=Aj(h,Ne,_),y.memoizedState=Pj,y=hv(null,j));else if(Zm(y),uM(qe)){if(Ne=qe.nextSibling&&qe.nextSibling.dataset,Ne)var vt=Ne.dgst;Ne=vt,j=Error(r(419)),j.stack="",j.digest=Ne,tv({value:j,source:null,stack:null}),y=Mj(h,y,_)}else if(hi||ob(h,y,_,!1),Ne=(_&h.childLanes)!==0,hi||Ne){if(Ne=oa,Ne!==null&&(j=Vt(Ne,_),j!==0&&j!==lt.retryLane))throw lt.retryLane=j,Zp(h,j),To(Ne,h,j),Nj;dM(qe)||E_(),y=Mj(h,y,_)}else dM(qe)?(y.flags|=192,y.child=h.child,y=null):(h=lt.treeContext,xa=Fl(qe.nextSibling),cs=y,Sr=!0,Wm=null,El=!1,h!==null&&V5(y,h),y=jj(y,j.children),y.flags|=4096);return y}return G?(eh(),qe=j.fallback,G=y.mode,lt=h.child,vt=lt.sibling,j=lu(lt,{mode:"hidden",children:j.children}),j.subtreeFlags=lt.subtreeFlags&65011712,vt!==null?qe=lu(vt,qe):(qe=ef(qe,G,_,null),qe.flags|=2),qe.return=y,j.return=y,j.sibling=qe,y.child=j,hv(null,j),j=y.child,qe=h.child.memoizedState,qe===null?qe=Tj(_):(G=qe.cachePool,G!==null?(lt=ui._currentValue,G=G.parent!==lt?{parent:lt,pool:lt}:G):G=Q5(),qe={baseLanes:qe.baseLanes|_,cachePool:G}),j.memoizedState=qe,j.childLanes=Aj(h,Ne,_),y.memoizedState=Pj,hv(h.child,j)):(Zm(y),_=h.child,h=_.sibling,_=lu(_,{mode:"visible",children:j.children}),_.return=y,_.sibling=null,h!==null&&(Ne=y.deletions,Ne===null?(y.deletions=[h],y.flags|=16):Ne.push(h)),y.child=_,y.memoizedState=null,_)}function jj(h,y){return y=__({mode:"visible",children:y},h.mode),y.return=h,h.child=y}function __(h,y){return h=Ko(22,h,null,y),h.lanes=0,h}function Mj(h,y,_){return lf(y,h.child,null,_),h=jj(y,y.pendingProps.children),h.flags|=2,y.memoizedState=null,h}function dB(h,y,_){h.lanes|=y;var j=h.alternate;j!==null&&(j.lanes|=y),GA(h.return,y,_)}function Ej(h,y,_,j,G,Z){var Ne=h.memoizedState;Ne===null?h.memoizedState={isBackwards:y,rendering:null,renderingStartTime:0,last:j,tail:_,tailMode:G,treeForkCount:Z}:(Ne.isBackwards=y,Ne.rendering=null,Ne.renderingStartTime=0,Ne.last=j,Ne.tail=_,Ne.tailMode=G,Ne.treeForkCount=Z)}function uB(h,y,_){var j=y.pendingProps,G=j.revealOrder,Z=j.tail;j=j.children;var Ne=ai.current,qe=(Ne&2)!==0;if(qe?(Ne=Ne&1|2,y.flags|=128):Ne&=1,me(ai,Ne),us(h,y,j,_),j=Sr?ev:0,!qe&&h!==null&&(h.flags&128)!==0)e:for(h=y.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&dB(h,_,y);else if(h.tag===19)dB(h,_,y);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===y)break e;for(;h.sibling===null;){if(h.return===null||h.return===y)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(G){case"forwards":for(_=y.child,G=null;_!==null;)h=_.alternate,h!==null&&u_(h)===null&&(G=_),_=_.sibling;_=G,_===null?(G=y.child,y.child=null):(G=_.sibling,_.sibling=null),Ej(y,!1,G,_,Z,j);break;case"backwards":case"unstable_legacy-backwards":for(_=null,G=y.child,y.child=null;G!==null;){if(h=G.alternate,h!==null&&u_(h)===null){y.child=G;break}h=G.sibling,G.sibling=_,_=G,G=h}Ej(y,!0,_,null,Z,j);break;case"together":Ej(y,!1,null,null,void 0,j);break;default:y.memoizedState=null}return y.child}function pu(h,y,_){if(h!==null&&(y.dependencies=h.dependencies),rh|=y.lanes,(_&y.childLanes)===0)if(h!==null){if(ob(h,y,_,!1),(_&y.childLanes)===0)return null}else return null;if(h!==null&&y.child!==h.child)throw Error(r(153));if(y.child!==null){for(h=y.child,_=lu(h,h.pendingProps),y.child=_,_.return=y;h.sibling!==null;)h=h.sibling,_=_.sibling=lu(h,h.pendingProps),_.return=y;_.sibling=null}return y.child}function Dj(h,y){return(h.lanes&y)!==0?!0:(h=h.dependencies,!!(h!==null&&r_(h)))}function Qce(h,y,_){switch(y.tag){case 3:D(y,y.stateNode.containerInfo),Xm(y,ui,h.memoizedState.cache),tf();break;case 27:case 5:U(y);break;case 4:D(y,y.stateNode.containerInfo);break;case 10:Xm(y,y.type,y.memoizedProps.value);break;case 31:if(y.memoizedState!==null)return y.flags|=128,aj(y),null;break;case 13:var j=y.memoizedState;if(j!==null)return j.dehydrated!==null?(Zm(y),y.flags|=128,null):(_&y.child.childLanes)!==0?cB(h,y,_):(Zm(y),h=pu(h,y,_),h!==null?h.sibling:null);Zm(y);break;case 19:var G=(h.flags&128)!==0;if(j=(_&y.childLanes)!==0,j||(ob(h,y,_,!1),j=(_&y.childLanes)!==0),G){if(j)return uB(h,y,_);y.flags|=128}if(G=y.memoizedState,G!==null&&(G.rendering=null,G.tail=null,G.lastEffect=null),me(ai,ai.current),j)break;return null;case 22:return y.lanes=0,rB(h,y,_,y.pendingProps);case 24:Xm(y,ui,h.memoizedState.cache)}return pu(h,y,_)}function mB(h,y,_){if(h!==null)if(h.memoizedProps!==y.pendingProps)hi=!0;else{if(!Dj(h,_)&&(y.flags&128)===0)return hi=!1,Qce(h,y,_);hi=(h.flags&131072)!==0}else hi=!1,Sr&&(y.flags&1048576)!==0&&$5(y,ev,y.index);switch(y.lanes=0,y.tag){case 16:e:{var j=y.pendingProps;if(h=sf(y.elementType),y.type=h,typeof h=="function")IA(h)?(j=df(h,j),y.tag=1,y=oB(null,y,h,j,_)):(y.tag=0,y=Cj(null,y,h,j,_));else{if(h!=null){var G=h.$$typeof;if(G===P){y.tag=11,y=eB(null,y,h,j,_);break e}else if(G===T){y.tag=14,y=tB(null,y,h,j,_);break e}}throw y=L(h)||h,Error(r(306,y,""))}}return y;case 0:return Cj(h,y,y.type,y.pendingProps,_);case 1:return j=y.type,G=df(j,y.pendingProps),oB(h,y,j,G,_);case 3:e:{if(D(y,y.stateNode.containerInfo),h===null)throw Error(r(387));j=y.pendingProps;var Z=y.memoizedState;G=Z.element,ZA(h,y),lv(y,j,null,_);var Ne=y.memoizedState;if(j=Ne.cache,Xm(y,ui,j),j!==Z.cache&&WA(y,[ui],_,!0),ov(),j=Ne.element,Z.isDehydrated)if(Z={element:j,isDehydrated:!1,cache:Ne.cache},y.updateQueue.baseState=Z,y.memoizedState=Z,y.flags&256){y=lB(h,y,j,_);break e}else if(j!==G){G=Al(Error(r(424)),y),tv(G),y=lB(h,y,j,_);break e}else for(h=y.stateNode.containerInfo,h.nodeType===9?h=h.body:h=h.nodeName==="HTML"?h.ownerDocument.body:h,xa=Fl(h.firstChild),cs=y,Sr=!0,Wm=null,El=!0,_=rU(y,null,j,_),y.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling;else{if(tf(),j===G){y=pu(h,y,_);break e}us(h,y,j,_)}y=y.child}return y;case 26:return S_(h,y),h===null?(_=_8(y.type,null,y.pendingProps,null))?y.memoizedState=_:Sr||(_=y.type,h=y.pendingProps,j=z_(B.current).createElement(_),j[jt]=y,j[Jt]=h,ms(j,_,h),nt(j),y.stateNode=j):y.memoizedState=_8(y.type,h.memoizedProps,y.pendingProps,h.memoizedState),null;case 27:return U(y),h===null&&Sr&&(j=y.stateNode=v8(y.type,y.pendingProps,B.current),cs=y,El=!0,G=xa,lh(y.type)?(mM=G,xa=Fl(j.firstChild)):xa=G),us(h,y,y.pendingProps.children,_),S_(h,y),h===null&&(y.flags|=4194304),y.child;case 5:return h===null&&Sr&&((G=j=xa)&&(j=Pde(j,y.type,y.pendingProps,El),j!==null?(y.stateNode=j,cs=y,xa=Fl(j.firstChild),El=!1,G=!0):G=!1),G||Km(y)),U(y),G=y.type,Z=y.pendingProps,Ne=h!==null?h.memoizedProps:null,j=Z.children,oM(G,Z)?j=null:Ne!==null&&oM(G,Ne)&&(y.flags|=32),y.memoizedState!==null&&(G=sj(h,y,Hce,null,null,_),Tv._currentValue=G),S_(h,y),us(h,y,j,_),y.child;case 6:return h===null&&Sr&&((h=_=xa)&&(_=Tde(_,y.pendingProps,El),_!==null?(y.stateNode=_,cs=y,xa=null,h=!0):h=!1),h||Km(y)),null;case 13:return cB(h,y,_);case 4:return D(y,y.stateNode.containerInfo),j=y.pendingProps,h===null?y.child=lf(y,null,j,_):us(h,y,j,_),y.child;case 11:return eB(h,y,y.type,y.pendingProps,_);case 7:return us(h,y,y.pendingProps,_),y.child;case 8:return us(h,y,y.pendingProps.children,_),y.child;case 12:return us(h,y,y.pendingProps.children,_),y.child;case 10:return j=y.pendingProps,Xm(y,y.type,j.value),us(h,y,j.children,_),y.child;case 9:return G=y.type._context,j=y.pendingProps.children,rf(y),G=ds(G),j=j(G),y.flags|=1,us(h,y,j,_),y.child;case 14:return tB(h,y,y.type,y.pendingProps,_);case 15:return nB(h,y,y.type,y.pendingProps,_);case 19:return uB(h,y,_);case 31:return Yce(h,y,_);case 22:return rB(h,y,_,y.pendingProps);case 24:return rf(y),j=ds(ui),h===null?(G=YA(),G===null&&(G=oa,Z=KA(),G.pooledCache=Z,Z.refCount++,Z!==null&&(G.pooledCacheLanes|=_),G=Z),y.memoizedState={parent:j,cache:G},JA(y),Xm(y,ui,G)):((h.lanes&_)!==0&&(ZA(h,y),lv(y,null,null,_),ov()),G=h.memoizedState,Z=y.memoizedState,G.parent!==j?(G={parent:j,cache:j},y.memoizedState=G,y.lanes===0&&(y.memoizedState=y.updateQueue.baseState=G),Xm(y,ui,j)):(j=Z.cache,Xm(y,ui,j),j!==G.cache&&WA(y,[ui],_,!0))),us(h,y,y.pendingProps.children,_),y.child;case 29:throw y.pendingProps}throw Error(r(156,y.tag))}function fu(h){h.flags|=4}function Fj(h,y,_,j,G){if((y=(h.mode&32)!==0)&&(y=!1),y){if(h.flags|=16777216,(G&335544128)===G)if(h.stateNode.complete)h.flags|=8192;else if(zB())h.flags|=8192;else throw of=o_,QA}else h.flags&=-16777217}function hB(h,y){if(y.type!=="stylesheet"||(y.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!T8(y))if(zB())h.flags|=8192;else throw of=o_,QA}function k_(h,y){y!==null&&(h.flags|=4),h.flags&16384&&(y=h.tag!==22?st():536870912,h.lanes|=y,yb|=y)}function pv(h,y){if(!Sr)switch(h.tailMode){case"hidden":y=h.tail;for(var _=null;y!==null;)y.alternate!==null&&(_=y),y=y.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var j=null;_!==null;)_.alternate!==null&&(j=_),_=_.sibling;j===null?y||h.tail===null?h.tail=null:h.tail.sibling=null:j.sibling=null}}function ya(h){var y=h.alternate!==null&&h.alternate.child===h.child,_=0,j=0;if(y)for(var G=h.child;G!==null;)_|=G.lanes|G.childLanes,j|=G.subtreeFlags&65011712,j|=G.flags&65011712,G.return=h,G=G.sibling;else for(G=h.child;G!==null;)_|=G.lanes|G.childLanes,j|=G.subtreeFlags,j|=G.flags,G.return=h,G=G.sibling;return h.subtreeFlags|=j,h.childLanes=_,y}function Jce(h,y,_){var j=y.pendingProps;switch(HA(y),y.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return ya(y),null;case 1:return ya(y),null;case 3:return _=y.stateNode,j=null,h!==null&&(j=h.memoizedState.cache),y.memoizedState.cache!==j&&(y.flags|=2048),uu(ui),M(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(sb(y)?fu(y):h===null||h.memoizedState.isDehydrated&&(y.flags&256)===0||(y.flags|=1024,$A())),ya(y),null;case 26:var G=y.type,Z=y.memoizedState;return h===null?(fu(y),Z!==null?(ya(y),hB(y,Z)):(ya(y),Fj(y,G,null,j,_))):Z?Z!==h.memoizedState?(fu(y),ya(y),hB(y,Z)):(ya(y),y.flags&=-16777217):(h=h.memoizedProps,h!==j&&fu(y),ya(y),Fj(y,G,h,j,_)),null;case 27:if(Y(y),_=B.current,G=y.type,h!==null&&y.stateNode!=null)h.memoizedProps!==j&&fu(y);else{if(!j){if(y.stateNode===null)throw Error(r(166));return ya(y),null}h=xe.current,sb(y)?G5(y):(h=v8(G,j,_),y.stateNode=h,fu(y))}return ya(y),null;case 5:if(Y(y),G=y.type,h!==null&&y.stateNode!=null)h.memoizedProps!==j&&fu(y);else{if(!j){if(y.stateNode===null)throw Error(r(166));return ya(y),null}if(Z=xe.current,sb(y))G5(y);else{var Ne=z_(B.current);switch(Z){case 1:Z=Ne.createElementNS("http://www.w3.org/2000/svg",G);break;case 2:Z=Ne.createElementNS("http://www.w3.org/1998/Math/MathML",G);break;default:switch(G){case"svg":Z=Ne.createElementNS("http://www.w3.org/2000/svg",G);break;case"math":Z=Ne.createElementNS("http://www.w3.org/1998/Math/MathML",G);break;case"script":Z=Ne.createElement("div"),Z.innerHTML=" +