diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fd692b1..11e6226d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file. - **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean. ### Fixed +- **Plate-detection calibration captured the wrong camera when an external camera was configured** ([#1359](https://github.com/maziggy/bambuddy/issues/1359), reported by @Andlar94) — On the reporter's A1 with an external RTSP / go2rtc camera enabled, every print start raised "Build plate not empty" no matter how perfectly they calibrated. **Root cause:** the runtime auto-check at print start in [`backend/app/main.py:1819`](backend/app/main.py) called `check_plate_empty(..., use_external=printer.external_camera_enabled, ...)` — honouring the external camera setting. The manual UI check + calibration routes in [`backend/app/api/routes/camera.py`](backend/app/api/routes/camera.py) declared `use_external: bool = False`, and the frontend client at [`frontend/src/api/client.ts`](frontend/src/api/client.ts) always sent `use_external=false` explicitly (the UI call sites in [`PrintersPage.tsx`](frontend/src/pages/PrintersPage.tsx) never passed `useExternal`). So calibration captured a frame from the **built-in** chamber camera and saved it as the reference; the runtime auto-check captured a frame from the **external** camera and diffed it against that built-in reference — a permanent difference well above any sane threshold, hence "not empty" on every print. **Fix:** the two routes now use `use_external: bool | None = None`, and after the printer row is loaded they derive the default as `bool(printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type)` — identical to the runtime path's logic and the service-layer gate at [`plate_detection.py:605`](backend/app/services/plate_detection.py). Centralising the default on the backend means any current or future caller automatically gets the right camera without having to remember the flag. The frontend client now only forwards `use_external` when the caller explicitly sets it (default omitted → backend decides), so the existing UI buttons immediately benefit. Power-user override path stays open: passing `?use_external=false` on a printer with an external camera still wins, so anyone who deliberately wants a built-in-camera reference can still get one. **Regression tests** in [`backend/tests/integration/test_camera_api.py`](backend/tests/integration/test_camera_api.py): `test_check_plate_defaults_use_external_when_external_camera_enabled` and `test_calibrate_plate_defaults_use_external_when_external_camera_enabled` pin the new default for a printer with external camera + URL + type set; `test_check_plate_defaults_use_external_false_when_external_camera_disabled` pins the built-in default for the no-external-camera case (the common path stays untouched); `test_calibrate_plate_explicit_use_external_false_overrides_default` pins the explicit-override escape hatch. All 11 plate-tagged camera integration tests pass; ruff clean; frontend build clean. + - **API Keys page now exposes a narrowly-scoped "Update electricity price" toggle so the Home Assistant dynamic-tariff integration actually works** ([#1356](https://github.com/maziggy/bambuddy/issues/1356), reported by @maziggy) — The reporter followed the [Energy Tracking wiki](https://wiki.bambuddy.cool/features/api-keys/) page literally — "create a key with **Write Settings** permission, then PATCH `/api/v1/settings` with `{energy_cost_per_kwh: ...}`" — and hit `{"detail":"API keys cannot be used for administrative operations"}`. Triage showed three independent drifts: (1) the wiki listed nine fictional permissions ("Read Printers / Write Settings / Admin / …") but the actual UI in [`SettingsPage.tsx:3683-3744`](frontend/src/pages/SettingsPage.tsx) only ever exposed **four** toggles (Read Status, Manage Queue, Control Printer, Allow Cloud Access). There was no Write Settings toggle to tick. (2) Even if the UI had exposed it, the backend hard-denies `Permission.SETTINGS_UPDATE` for every API key via `_APIKEY_DENIED_PERMISSIONS` in [`backend/app/core/auth.py`](backend/app/core/auth.py) — intentional protection because `PATCH /settings` can rewrite SMTP/LDAP/MQTT credentials and the HA access token, which would silently widen attack surface beyond what any documented use case needs. (3) So the wiki had been promising a workflow that was never deliverable. **Fix:** introduce a narrowly-scoped door for exactly the documented use case rather than relaxing the deny list. New column `can_update_energy_cost BOOLEAN DEFAULT FALSE` on `api_keys` ([`backend/app/models/api_key.py`](backend/app/models/api_key.py)) with idempotent migration in [`backend/app/core/database.py`](backend/app/core/database.py) — defaults FALSE so existing keys never silently gain settings-write capability on upgrade. New endpoint `POST /api/v1/settings/electricity-price` in [`backend/app/api/routes/settings.py`](backend/app/api/routes/settings.py) accepts `{"energy_cost_per_kwh": }` — the field name matches what the wiki already documented so the HA `rest_command` example needs only a URL+method change, not a payload change. New custom dependency `require_energy_cost_update()` in [`backend/app/core/auth.py`](backend/app/core/auth.py) bypasses the `_APIKEY_DENIED_PERMISSIONS` check **for this one route** for API keys with `can_update_energy_cost=True`; JWT users still go through the standard `SETTINGS_UPDATE` permission check; auth-disabled deployments allow it (matches other settings routes). Crucially, the general `PATCH /settings` route remains denied for API keys — flipping the narrow flag does NOT widen general settings-write access (regression test pins this). Schema/route wiring in [`backend/app/schemas/api_key.py`](backend/app/schemas/api_key.py) + [`backend/app/api/routes/api_keys.py`](backend/app/api/routes/api_keys.py) accepts and returns the new field on create/update/list. **Frontend:** fifth toggle "Update electricity price" added to the create-API-key card in [`SettingsPage.tsx`](frontend/src/pages/SettingsPage.tsx) with an amber "Energy" badge on existing keys that have it set; `APIKey` / `APIKeyCreate` / `APIKeyUpdate` types in [`api/client.ts`](frontend/src/api/client.ts) gained the new field; 16 new i18n keys (`updateEnergyCost`, `updateEnergyCostDescription`, `energyCostBadge`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Wiki rewrites:** [`features/api-keys.md`](https://wiki.bambuddy.cool/features/api-keys/) — replaced the fictional 9-row permissions table with the actual 5 toggles plus an info box explaining why no general Write Settings / Admin exists. [`features/energy.md`](https://wiki.bambuddy.cool/features/energy/) — Home Assistant section now points at `POST /api/v1/settings/electricity-price`, instructs users to tick the new permission, and adds a deprecation warning for users who built the integration from the old (broken) `PATCH /settings` example. **Tests:** [`backend/tests/integration/test_settings_electricity_price.py`](backend/tests/integration/test_settings_electricity_price.py) — 8 tests covering create-with-flag, default-off, API-key-with-flag updates persist, API-key-without-flag → 403, JWT admin user with SETTINGS_UPDATE allowed, anon → 401, negative price → 422 (Pydantic `ge=0`), and the critical regression test `test_patch_settings_still_denied_with_energy_flag` that pins the narrow-flag-doesn't-widen-PATCH contract. [`frontend/src/__tests__/pages/SettingsPage.test.tsx`](frontend/src/__tests__/pages/SettingsPage.test.tsx) — 2 new tests: Energy badge renders for keys with the flag, the toggle's value flows through to the POST body when the box is ticked. All 8 new backend tests + 32/32 SettingsPage tests pass; ruff clean; i18n parity passes; frontend build clean. - **Layer timelapse now starts for queue/VP-dispatched prints** ([#1353](https://github.com/maziggy/bambuddy/issues/1353), reported by @Andlar94) — Reporter's external camera + go2rtc setup was configured correctly (Obico was happily polling the snapshot URL for ML plate detection) but no MP4 was ever produced. Logs showed `[LAYER-TL] Stitching layer timelapse for printer 1` after each print yet no frames were ever captured and no `[LAYER-TL] Attaching timelapse...` follow-up appeared. Root cause: `layer_timelapse.start_session()` was only called from the two **new-archive** paths in `on_print_start` (`backend/app/main.py:2510` fallback path and `2600` regular new-archive). The **expected-archive** branch at `main.py:1981-2052` — where every reprint and every queue/VP-dispatched print lands — updated the existing archive's status to `printing` but never started a timelapse session. So `_background_layer_timelapse` ran at print-complete time, called `tl_complete(printer_id)`, found no active session in `_active_sessions`, silently returned `None`, and the wrapper at `main.py:3917` produced no log message for the no-session case. Every print that came through the queue (or any reprint) silently lost its timelapse. **Fix:** mirror the same `if printer.external_camera_enabled and printer.external_camera_url: start_session(...)` call in the expected-archive branch right after `_active_prints` registration. The two pre-existing paths are untouched. **Help-text correction:** the snapshot URL field's tooltip previously read *"Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection"* — which is technically true but read as if filling in the URL was sufficient to enable those features. Reworded across all 8 locales to *"Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active"* so admins know they still need to enable plate detection per-printer (separate toggle) and that timelapse only fires while a print is running. **Regression tests** in [`backend/tests/unit/test_layer_timelapse_expected_archive.py`](backend/tests/unit/test_layer_timelapse_expected_archive.py): `test_expected_archive_path_starts_timelapse_when_external_camera_enabled` exercises the full `on_print_start` flow with a registered expected print + `external_camera_enabled=True` and asserts `start_session` is called with the expected-print archive_id (not a freshly created one); `test_expected_archive_path_skips_timelapse_when_external_camera_disabled` keeps the existing gate in place so we don't try to capture from a None URL. 2 new tests pass; ruff clean; frontend i18n parity passes; bundle builds. diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index b5bd637d1..8abe5b409 100644 --- a/backend/app/api/routes/camera.py +++ b/backend/app/api/routes/camera.py @@ -1001,7 +1001,7 @@ async def test_external_camera( async def check_plate_empty( printer_id: int, plate_type: str | None = None, - use_external: bool = False, + use_external: bool | None = None, include_debug_image: bool = False, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), @@ -1016,7 +1016,11 @@ async def check_plate_empty( Args: printer_id: Printer ID plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup - use_external: If True, prefer external camera over built-in + use_external: If True, prefer external camera over built-in. When omitted + (None), defaults to the printer's external_camera_enabled setting — + mirroring the runtime auto-check at print start (main.py). Without + this default the UI's manual check would always use the built-in + camera, mismatching the reference saved during calibration (#1359). include_debug_image: If True, return URL to annotated debug image Returns: @@ -1037,6 +1041,11 @@ async def check_plate_empty( # Check printer exists first (before OpenCV check) printer = await get_printer_or_404(printer_id, db) + if use_external is None: + use_external = bool( + printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type + ) + if not is_plate_detection_available(): raise HTTPException( status_code=503, @@ -1111,7 +1120,7 @@ async def check_plate_empty( async def calibrate_plate_detection( printer_id: int, label: str | None = None, - use_external: bool = False, + use_external: bool | None = None, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW), ): @@ -1128,7 +1137,10 @@ async def calibrate_plate_detection( Args: printer_id: Printer ID label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam") - use_external: If True, prefer external camera over built-in + use_external: If True, prefer external camera over built-in. When omitted + (None), defaults to the printer's external_camera_enabled setting so + calibration captures from the same source the runtime auto-check + uses at print start (#1359). Returns: Dict with: @@ -1145,6 +1157,11 @@ async def calibrate_plate_detection( # Check printer exists first (before OpenCV check) printer = await get_printer_or_404(printer_id, db) + if use_external is None: + use_external = bool( + printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type + ) + if not is_plate_detection_available(): raise HTTPException( status_code=503, diff --git a/backend/tests/integration/test_camera_api.py b/backend/tests/integration/test_camera_api.py index 756387dba..c2919d169 100644 --- a/backend/tests/integration/test_camera_api.py +++ b/backend/tests/integration/test_camera_api.py @@ -459,6 +459,138 @@ class TestCameraAPI: assert result["success"] is True assert "index" in result + # ------------------------------------------------------------------ + # Regression: #1359 — the manual UI check/calibrate routes must derive + # use_external from the printer's external_camera_enabled setting when + # the caller omits the flag. Otherwise the UI calibrates against the + # built-in camera while the runtime auto-check at print start uses the + # external one, producing a permanent "build plate not empty". + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_check_plate_defaults_use_external_when_external_camera_enabled( + self, async_client: AsyncClient, printer_factory + ): + """Omitting use_external on a printer with external camera enabled + must call the service with use_external=True.""" + printer = await printer_factory( + external_camera_enabled=True, + external_camera_url="http://192.168.1.50/mjpeg", + external_camera_type="mjpeg", + ) + + mock_result = MagicMock() + mock_result.to_dict.return_value = { + "is_empty": True, + "confidence": 0.95, + "difference_percent": 0.5, + "message": "Plate appears empty", + "has_debug_image": False, + "needs_calibration": False, + } + mock_result.debug_image = None + + mock_detector = MagicMock() + mock_detector.get_calibration_count.return_value = 0 + mock_detector.MAX_REFERENCES = 5 + + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check, + patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector), + ): + mock_check.return_value = mock_result + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate") + + assert response.status_code == 200 + assert mock_check.await_args.kwargs["use_external"] is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_check_plate_defaults_use_external_false_when_external_camera_disabled( + self, async_client: AsyncClient, printer_factory + ): + """Omitting use_external on a printer without an external camera + must call the service with use_external=False (built-in).""" + printer = await printer_factory() # external_camera_enabled defaults to False + + mock_result = MagicMock() + mock_result.to_dict.return_value = { + "is_empty": True, + "confidence": 0.95, + "difference_percent": 0.5, + "message": "Plate appears empty", + "has_debug_image": False, + "needs_calibration": False, + } + mock_result.debug_image = None + + mock_detector = MagicMock() + mock_detector.get_calibration_count.return_value = 0 + mock_detector.MAX_REFERENCES = 5 + + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check, + patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector), + ): + mock_check.return_value = mock_result + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate") + + assert response.status_code == 200 + assert mock_check.await_args.kwargs["use_external"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_calibrate_plate_defaults_use_external_when_external_camera_enabled( + self, async_client: AsyncClient, printer_factory + ): + """Calibrating with use_external omitted on an external-camera-enabled + printer captures the reference from the external camera — matching + what the runtime check at print start will compare against (#1359).""" + printer = await printer_factory( + external_camera_enabled=True, + external_camera_url="http://192.168.1.50/mjpeg", + external_camera_type="mjpeg", + ) + + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate, + ): + mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0) + response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate") + + assert response.status_code == 200 + assert mock_calibrate.await_args.kwargs["use_external"] is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_calibrate_plate_explicit_use_external_false_overrides_default( + self, async_client: AsyncClient, printer_factory + ): + """An explicit use_external=false from the caller still wins even + when the printer has an external camera configured, so power users + can force a built-in-camera reference if they ever need to.""" + printer = await printer_factory( + external_camera_enabled=True, + external_camera_url="http://192.168.1.50/mjpeg", + external_camera_type="mjpeg", + ) + + with ( + patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True), + patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate, + ): + mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0) + response = await async_client.post( + f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate?use_external=false" + ) + + assert response.status_code == 200 + assert mock_calibrate.await_args.kwargs["use_external"] is False + @pytest.mark.asyncio @pytest.mark.integration async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient): diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4b1c14956..6d1fdb400 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4914,7 +4914,12 @@ export const api = { // Plate Detection - Multi-reference calibration (stores up to 5 references per printer) checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => { const params = new URLSearchParams(); - params.set('use_external', String(options?.useExternal ?? false)); + // Only forward use_external when the caller explicitly sets it. Omitted → + // backend derives the default from the printer's external_camera_enabled + // setting so calibration and runtime checks use the same camera (#1359). + if (options?.useExternal !== undefined) { + params.set('use_external', String(options.useExternal)); + } params.set('include_debug_image', String(options?.includeDebugImage ?? false)); return request( `/printers/${printerId}/camera/check-plate?${params.toString()}` @@ -4928,7 +4933,9 @@ export const api = { calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => { const params = new URLSearchParams(); if (options?.label) params.set('label', options.label); - params.set('use_external', String(options?.useExternal ?? false)); + if (options?.useExternal !== undefined) { + params.set('use_external', String(options.useExternal)); + } return request( `/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`, { method: 'POST' } diff --git a/static/assets/index-BlvVqj3j.js b/static/assets/index-lshkWq_8.js similarity index 95% rename from static/assets/index-BlvVqj3j.js rename to static/assets/index-lshkWq_8.js index dccfdf592..2e9baa82b 100644 --- a/static/assets/index-BlvVqj3j.js +++ b/static/assets/index-lshkWq_8.js @@ -32,7 +32,7 @@ ON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1) 乘數:Wh→kWh使用0.001,MWh→kWh使用1000。`,mqttStateHint:`JSON路徑從JSON負載中提取值。原始值請留空。 ON值:表示"ON"的確切字串。留空以自動檢測(ON、true、1)。`,restControl:"控制",restOnUrl:"開啟 URL",restOffUrl:"關閉 URL",restOnBody:"開啟請求內容",restOffBody:"關閉請求內容",restMethod:"HTTP 方法",restHeaders:"自訂標頭(JSON)",restStatusUrl:"狀態 URL",restStatusPath:"狀態 JSON 路徑",restStatusOnValue:"ON 值",restPowerUrl:"功率URL",restPowerPath:"功率 JSON 路徑",restPowerMultiplier:"功率乘數",restEnergyUrl:"能耗URL",restEnergyPath:"能耗 JSON 路徑",restEnergyMultiplier:"能耗乘數",restUrlRequired:"REST 插座至少需要一個 URL(ON 或 OFF)",restHeadersHint:'例如:{"Authorization": "Bearer your-token"}',restBodyHint:'例如:ON、{"state": "on"}',restStatusHint:"用於輪詢目前狀態的 URL",restPathHint:"例如:state 或 data.power.status",restPowerUrlHint:"功率資料的獨立URL(留空則使用狀態URL)",restEnergyUrlHint:"能耗資料的獨立URL(留空則使用狀態URL)",restEnergyHint:"每個值可以使用獨立的URL,或回退到狀態 URL。使用乘數進行單位轉換(例如:0.001 將 Wh 轉換為 kWh)。",testConnection:"測試連線",connectionSuccess:"連線成功",noSwitchesInSwitchbar:"開關欄中沒有開關",enableSwitchbarHint:'在設定 > 智慧插座中啟用"在開關欄顯示"'},notifications:{providerTypes:{callmebot:"CallMeBot/WhatsApp",ntfy:"ntfy",pushover:"Pushover",telegram:"Telegram",email:"電子郵件",discord:"Discord",webhook:"Webhook",homeassistant:"Home Assistant"},providerDescriptions:{email:"SMTP 電子郵件通知",telegram:"透過 Telegram 機器人傳送通知",discord:"透過 Webhook 傳送到 Discord 頻道",ntfy:"免費、可自託管的推送通知",pushover:"簡單、可靠的推送通知",callmebot:"透過 CallMeBot 免費傳送 WhatsApp 通知",webhook:"通用 HTTP POST 到任意 URL",homeassistant:"Home Assistant 儀表板中的持久通知"},lastSuccess:"上次:{{date}}",error:"錯誤",printer:"印表機:",allPrinters:"所有印表機",sendTestNotification:"傳送測試通知",eventSettings:"事件設定",enabled:"已啟用",sendFromProvider:"從此提供者傳送通知",printEvents:"列印事件",printerStatus:"印表機狀態",amsAlarms:"AMS 警報",amsHtAlarms:"AMS-HT 警報",printQueue:"列印佇列",start:"開始",plateCheck:"熱床檢測",complete:"完成",failed:"失敗",stopped:"已停止",progress:"進度",offline:"離線",lowFilament:"耗材不足",maintenance:"維護",amsHumidity:"AMS 濕度",amsTemp:"AMS 溫度",amsHtHumidity:"AMS-HT 濕度",amsHtTemp:"AMS-HT 溫度",bedCooled:"熱床已冷卻",firstLayer:"首層完成",quiet:"免打擾",digest:"摘要 {{time}}",printStarted:"列印已開始",plateNotEmpty:"熱床非空",plateNotEmptyDescription:"列印前偵測到物體",printCompleted:"列印已完成",bedCooledLabel:"熱床已冷卻",bedCooledDescription:"列印後熱床溫度降至閾值以下",firstLayerCompleteLabel:"首層列印完成",firstLayerCompleteDescription:"首層完成時傳送帶照片的通知",missingSpoolAssignmentLabel:"缺少料卷分配",missingSpoolAssignmentDescription:"當列印開始且所需料盤沒有分配料卷時傳送通知",printFailed:"列印失敗",printStopped:"列印已停止",progressMilestones:"進度里程碑",progressMilestonesDescription:"在 25%、50%、75% 時通知",printerOffline:"印表機離線",printerError:"印表機錯誤",lowFilamentLabel:"耗材不足",maintenanceDue:"需要維護",maintenanceDueDescription:"需要維護時通知",amsHumidityHigh:"AMS 濕度過高",amsHumidityHighDescription:"普通 AMS 濕度超過閾值",amsTemperatureHigh:"AMS 溫度過高",amsTemperatureHighDescription:"普通 AMS 溫度超過閾值",amsHtHumidityHigh:"AMS-HT 濕度過高",amsHtHumidityHighDescription:"AMS-HT 濕度超過閾值",amsHtTemperatureHigh:"AMS-HT 溫度過高",amsHtTemperatureHighDescription:"AMS-HT 溫度超過閾值",inventoryAlerts:"庫存警報",stockReorderAlert:"補貨警報",stockReorderAlertDescription:"SKU 已達到補貨點",stockBreakAlert:"斷貨警報",stockBreakAlertDescription:"庫存將在補貨到達前耗盡",jobAdded:"任務已新增",jobAddedDescription:"任務已新增到佇列",jobAssigned:"任務已分配",jobAssignedDescription:"基於模型的任務已分配給印表機",jobStarted:"任務已開始",jobStartedDescription:"佇列任務已開始列印",jobWaiting:"任務等待中",jobWaitingDescription:"任務正在等待耗材或印表機",jobSkipped:"任務已跳過",jobSkippedDescription:"任務已跳過(上一個失敗)",jobFailed:"任務失敗",jobFailedDescription:"任務啟動失敗",queueComplete:"佇列已完成",queueCompleteDescription:"所有佇列任務已完成",quietHours:"免打擾時段",noNotificationsDuring:"在此時段內不傳送通知",editProviderToChangeQuietHours:"編輯提供者以更改免打擾時段",dailyDigest:"每日摘要",batchNotifications:"將通知彙總為每日摘要",sendAt:"傳送於 {{time}}",editProviderToChangeDigestTime:"編輯提供者以更改摘要時間",edit:"編輯",deleteProvider:"刪除通知提供者",deleteConfirm:'確定要刪除"{{name}}"嗎?此操作無法復原。',delete:"刪除",addTitle:"新增通知提供者",editTitle:"編輯通知提供者",nameLabel:"名稱 *",namePlaceholder:"我的通知",providerTypeLabel:"提供者類型 *",configuration:"設定",testConfiguration:"測試設定",printerFilter:"印表機篩選",onlyFromPrinter:"僅傳送來自此印表機的事件通知",quietHoursDnd:"免打擾時段",quietStart:"開始",quietEnd:"結束",dailyDigestLabel:"每日摘要",sendDigestAt:"傳送摘要於",digestCollected:"事件將被收集並在此時間作為單條摘要傳送",notificationEvents:"通知事件",progressPercent:"(25%、50%、75%)",bedCooledAfterPrint:"(列印完成後)",eventPriority:{sectionTitle:"ntfy 優先級",helpNtfy:"為每個已啟用的事件選擇優先級。ntfy 使用它來升級提醒(聲音、可見性、推播行為)。此處未設定的級別將使用 ntfy 伺服器預設值。",min:"最低",low:"低",default:"預設",high:"高",urgent:"緊急"},cancel:"取消",save:"儲存",add:"新增",nameRequired:"名稱為必填項",fieldRequired:"{{field}}為必填項",phoneNumber:"電話號碼",apiKey:"API 金鑰",serverUrl:"伺服器 URL",topic:"主題",authToken:"認證權杖",userKey:"使用者金鑰",appToken:"應用程式權杖",priority:"優先順序",botToken:"機器人權杖",chatId:"聊天 ID",smtpServer:"SMTP 伺服器",smtpPort:"SMTP 連接埠",security:"安全",authentication:"認證",username:"使用者名稱",password:"密碼",fromEmail:"寄件人信箱",toEmail:"收件人信箱",webhookUrl:"Webhook URL",payloadFormat:"負載格式",authorization:"授權",titleFieldName:"標題欄位名",messageFieldName:"訊息欄位名",editTemplate:"編輯範本:{{name}}",titleLabel:"標題",bodyLabel:"正文",titlePlaceholder:"通知標題...",bodyPlaceholder:"通知正文...",availableVariables:"可用變數",clickToInsert:"點選插入到正文游標位置",livePreview:"即時預覽",hide:"隱藏",show:"顯示",loadingPreview:"載入預覽中...",enterTemplateContent:"輸入範本內容以檢視預覽",titlePreview:"標題:",bodyPreview:"正文:",resetToDefault:"恢復預設",titleRequired:"標題為必填項",bodyRequired:"正文為必填項",notificationLog:"通知日誌",showFailedOnly:"僅顯示失敗",last24Hours:"最近 24 小時",last7Days:"最近 7 天",last30Days:"最近 30 天",last90Days:"最近 90 天",justNow:"剛剛",noFailedNotifications:"沒有失敗的通知",noNotificationsLogged:"沒有通知紀錄",unknownProvider:"未知提供者",logTitle:"標題",logMessage:"訊息",logError:"錯誤",logProvider:"提供者:{{type}}",logTime:"時間:{{time}}",refresh:"重新整理",clearOld:"清除舊紀錄",statsSummary:"最近 {{days}} 天:",statsNotifications:"條通知",statsSent:"{{count}} 條已傳送",statsFailed:"{{count}} 條失敗",eventTypes:{print_start:"列印已開始",print_complete:"列印完成",print_failed:"列印失敗",print_stopped:"列印已停止",print_progress:"進度",printer_offline:"印表機離線",printer_error:"印表機錯誤",filament_low:"耗材不足",maintenance_due:"需要維護",test:"測試"},userEmail:{title:"通知",emailNotifications:"郵件通知",emailNotificationsDesc:"接收您自己列印任務的郵件通知。郵件將透過進階身份驗證中設定的 SMTP 設定傳送。",sendingTo:"通知將傳送至",noEmailWarning:"您的帳戶沒有郵件地址。請聯絡管理員新增。",printJobNotifications:"列印任務通知",printJobNotificationsDesc:"選擇哪些事件會觸發您提交的列印任務的郵件通知。",printJobStarts:"列印任務開始",printJobStartsDesc:"當您的列印任務開始時收到通知。",printJobFinishes:"列印任務完成",printJobFinishesDesc:"當您的列印任務成功完成時收到通知。",printErrors:"列印錯誤",printErrorsDesc:"當您的列印任務失敗或遇到錯誤時收到通知。",printJobStops:"列印任務停止",printJobStopsDesc:"當您的列印任務被取消或停止時收到通知。",saveSuccess:"通知偏好設定已儲存。",saveError:"儲存通知偏好設定失敗。"}},richTextEditor:{bold:"粗體",italic:"斜體",underline:"底線",bulletList:"無序列表",numberedList:"有序列表",alignLeft:"左對齊",alignCenter:"居中對齊",alignRight:"右對齊",addLink:"新增連結",removeLink:"移除連結"},externalLinks:{noLinksConfigured:"未設定外部連結",deleteLink:"刪除連結",removeCustomIcon:"移除自訂圖示",openInNewTab:"在新標籤頁中開啟",placeholders:{linkName:"我的連結"}},keyboardShortcuts:{title:"鍵盤快捷鍵",navigation:"導航",archivesSection:"歸檔",kProfilesSection:"K 值設定",generalSection:"通用",shortcuts:{goToPrinters:"前往印表機",goToArchives:"前往歸檔",goToQueue:"前往佇列",goToStats:"前往統計",goToProfiles:"前往雲端設定",goToSettings:"前往設定",focusSearch:"聚焦搜尋",openUploadModal:"開啟上傳對話方塊",clearSelection:"清除選擇 / 取消焦點",contextMenu:"卡片右鍵選單",refreshProfiles:"重新整理設定",newProfile:"新增設定",exitSelectionMode:"結束選擇模式",showHelp:"顯示此協助"},footer:"按 Esc 或點選外部關閉"},notificationLog:{title:"通知日誌",events:{printStarted:"列印開始",printComplete:"列印完成",printFailed:"列印失敗",printStopped:"列印停止",progress:"進度",printerOffline:"印表機離線",printerError:"印表機錯誤",lowFilament:"耗材不足",maintenanceDue:"維護到期",test:"測試"},timeAgo:{justNow:"剛剛",minutesAgo:"{{minutes}} 分鐘前",hoursAgo:"{{hours}} 小時前"}},restoreBackup:{title:"恢復備份",restoring:"恢復中...",restoreComplete:"恢復完成",restoreFailed:"恢復失敗",importSettings:"從備份檔案匯入設定",pleaseWait:"請稍候,正在恢復您的資料",clickToSelect:"點選選擇備份檔案(.json 或 .zip)",howDuplicateHandling:"重複處理方式:",categories:{printers:"印表機",smartPlugs:"智慧插座",notificationProviders:"通知提供者",filaments:"耗材",archives:"歸檔",pendingUploads:"待處理上傳",settingsTemplates:"設定和範本"},matchingInfo:{printers:"按序列號匹配",smartPlugs:"按 IP 位址匹配",notificationProviders:"按名稱匹配",filaments:"按名稱 + 類型 + 品牌匹配",archives:"按內容雜湊匹配",pendingUploads:"按檔名匹配",settingsTemplates:"始終覆蓋"},replaceExisting:"替換現有資料",keepExisting:"保留現有資料",replaceDescription:"用備份資料覆蓋已存在的項目",keepDescription:"僅恢復不存在的項目",caution:"注意:",cautionText:"覆蓋將用備份資料替換您目前的設定。出於安全考慮,印表機存取碼永遠不會被覆蓋。",itemsRestored:"已恢復項目",itemsSkipped:"已跳過項目",restored:"已恢復",skipped:"已跳過(已存在)",filesLabel:"檔案(3MF、縮圖等)",newApiKeysGenerated:"已產生新 API 金鑰",newApiKeysWarning:"這些金鑰僅顯示一次。請立即複製!",processingBackup:"處理備份檔案中...",noDataFound:"備份檔案中未找到可恢復的資料。",failedToRestore:"恢復備份失敗。請檢查檔案格式。"},backupExport:{title:"匯出備份",selectData:"選擇要包含的資料",selectAll:"全選",selectNone:"全不選",categoryDescriptions:{settings:"語言、主題、更新偏好",notifications:"ntfy、Pushover、Discord 等",templates:"自訂訊息範本",smartPlugs:"Tasmota 插座設定",externalLinks:"側邊欄外部服務連結",printers:"印表機資訊(不含存取碼)",plateDetection:"空列印板參考影像",filaments:"耗材類型和成本",maintenance:"自訂維護計畫",archives:"所有列印資料 + 檔案(3MF、縮圖、照片)",projects:"專案、材料清單和附件",pendingUploads:"虛擬印表機待審核的上傳",apiKeys:"Webhook API 金鑰(匯入時產生新金鑰)"},requiresPrinters:"需要選擇印表機",zipFileWarning:"將建立 ZIP 檔案。",zipFileDescription:"包括所有 3MF 檔案、縮圖、縮時攝影和照片。這可能需要一些時間並產生較大的檔案。",includeAccessCodes:"包含存取碼",includeAccessCodesDescription:"用於轉移到另一臺機器",includeAccessCodesWarning:"存取碼將以明文形式包含。請妥善保管此備份檔案!",categoriesSelected:"已選擇 {{selectedCount}} 個類別"},pendingUploads:{placeholders:{notes:"新增關於此列印的備註..."},discardUpload:"丟棄上傳",archiveAllUploads:"歸檔所有上傳",discardAllUploads:"丟棄所有上傳",archive:"歸檔",timeAgo:{justNow:"剛剛",minutesAgo:"{{minutes}} 分鐘前",hoursAgo:"{{hours}} 小時前",daysAgo:"{{days}} 天前"}},apiBrowser:{placeholders:{requestBody:"JSON 請求體...",searchEndpoints:"搜尋端點..."}},configureAmsSlot:{title:"設定 AMS 槽位",slotConfigured:"槽位已設定!",configuringSlot:"正在設定槽位:",slotLabel:"{{ams}} 槽位 {{slot}}",searchPresets:"搜尋預設...",colorPlaceholder:"顏色名稱或十六進位(例如:棕色、FF8800)",clearCustomColor:"清除自訂顏色",noCloudPresets:"無雲端預設。登入拓竹雲以同步。",noPresetsAvailable:"無可用預設。登入拓竹雲或匯入本機設定。",noMatchingPresets:"未找到匹配的預設。",custom:"自訂",builtin:"內建",settingsSentToPrinter:"設定已傳送到印表機",filamentProfile:"耗材設定",kProfileLabel:"K 值設定(壓力推進)",filteringFor:"篩選:{{material}}",noKProfile:"無 K 值設定(使用預設值 0.020)",noMatchingKProfiles:"未找到匹配的 K 值設定。將使用預設 K=0.020。",selectFilamentFirst:"請先選擇耗材設定",kFromCalibration:"K={{value}}(來自印表機校準)",customColorLabel:"自訂顏色(可選)",presetColors:"{{name}} 顏色:",showLessColors:"顯示更少顏色",showMoreColors:"顯示更多顏色",clear:"清除",hexLabel:"十六進位:#{{hex}}",resetting:"重設中...",resetSlot:"重設槽位",cancel:"取消",configuring:"設定中...",configureSlot:"設定槽位"},githubBackup:{title:"Git 備份",history:"歷史",downloadBackup:"下載備份",restoreBackup:"恢復備份",noBackupsYet:"尚無備份"},emailSettings:{placeholders:{fromName:"BamBuddy"}},tagManagement:{searchTags:"搜尋標籤...",renameTag:"重新命名標籤",deleteTag:"刪除標籤"},notificationTemplates:{placeholders:{title:"通知標題...",body:"通知正文..."}},batchTag:{placeholders:{newTag:"輸入新標籤..."}},photoGallery:{deletePhoto:"刪除照片"},filamentHoverCard:{copySpoolUuid:"複製耗材 UUID"},kProfilesView:{hasNote:"有備註",copyProfile:"複製設定"},layout:{openMenu:"開啟選單",noPermissionSystemInfo:"您沒有檢視系統資訊的權限"},dashboard:{dragToReorder:"拖曳以重新排列",hideWidget:"隱藏小工具"},notificationProviderCard:{deleteNotificationProvider:"刪除通知提供者"},fileManagerModal:{closeFileManager:"關閉檔案管理器",sortFiles:"排序檔案",goToParentFolder:"返回上級資料夾",threeView:"3D 檢視"},embeddedCameraViewer:{refreshStream:"重新整理流",close:"關閉",zoomOut:"縮小",resetZoom:"重設縮放",zoomIn:"放大",dragToResize:"拖曳調整大小"},timelapseViewer:{skipBack5s:"後退 5 秒",skipForward5s:"前進 5 秒"},notificationProviders:{descriptions:{email:"SMTP 郵件通知",telegram:"透過 Telegram 機器人通知",discord:"透過 Webhook 傳送到 Discord 頻道",ntfy:"免費、可自託管的推送通知",pushover:"簡單、可靠的推送通知",callmebot:"透過 CallMeBot 的免費 WhatsApp 通知",webhook:"通用 HTTP POST 到任意 URL"}},logViewer:{searchPlaceholder:"搜尋訊息或日誌名稱...",noLogEntries:"未找到日誌條目"},switchbarPopover:{noSwitchesInSwitchbar:"切換欄中沒有開關"},projectPageModal:{placeholders:{title:"標題",designer:"設計師",license:"許可證",description:"輸入描述...",profileTitle:"設定標題",profileDescription:"設定描述..."}},spoolmanSettings:{},time:{unknown:"-",waiting:"等待中",justNow:"剛剛",now:"現在",minsAgo:"{{count}} 分鐘前",inMins:"{{count}} 分鐘後",hoursAgo:"{{count}} 小時前",inHours:"{{count}} 小時後",daysAgo:"{{count}} 天前",inDays:"{{count}} 天後"},spoolbuddy:{nav:{dashboard:"儀表板",ams:"AMS",inventory:"庫存",writeTag:"寫入",settings:"設定"},status:{nfcReady:"NFC 就緒",nfcOff:"NFC 關閉",offline:"離線",online:"線上",noPrinters:"無印表機",deviceOffline:"裝置離線",waitingConnection:"等待裝置連線...",systemReady:"系統就緒",status:"狀態"},dashboard:{readyToScan:"準備掃描",idleMessage:"將耗材放在磅秤上以識別",nfcHint:"NFC 標籤將自動讀取",device:"裝置",syncWeight:"同步重量",weightSynced:"已同步!",unknownTag:"未知標籤",newTag:"偵測到新標籤",onScale:"在磅秤上",linkSpool:"連結到耗材",linkTagTitle:"將標籤連結到耗材",linkTag:"連結標籤",selectSpool:"選擇要連結此標籤的耗材:",noUntagged:"未找到沒有標籤的耗材",tagDetected:"偵測到標籤",noTag:"無標籤",tagId:"標籤",grossWeight:"毛重",spoolSize:"耗材盤尺寸",close:"關閉",currentSpool:"目前耗材",plateReady:"熱床就緒: {{name}}",plateReadyLabel:"待清理的熱床",plateClearAction:"清理",plateClearedToast:"已將熱床標記為已清理",plateClearFailed:"無法將熱床標記為已清理"},modal:{spoolDetected:"偵測到耗材",assignToAms:"分配到 AMS",syncWeight:"同步重量",weightSynced:"已同步!",syncing:"同步中...",newTagDetected:"偵測到新標籤",addToInventory:"新增到庫存",assignToAmsTitle:"分配到 AMS",selectSlot:"選擇槽位",assign:"分配",assigning:"分配中...",assignSuccess:"已分配!",assignPendingInsert:"已分配。插入耗材後將設定槽位。",assignError:"分配耗材失敗。請重試。",noPrinterSelected:"選擇印表機...",noAmsDetected:"此印表機未偵測到 AMS",slot:"槽位"},weight:{noReading:"無讀數",stable:"穩定",measuring:"測量中...",tare:"去皮",calibrate:"校準"},spool:{remaining:"剩餘",material:"材料",brand:"品牌",color:"顏色",coreWeight:"軸心重",labelWeight:"標籤重",scaleWeight:"磅秤重",netWeight:"淨重",lastUsed:"上次使用"},ams:{noData:"未偵測到 AMS",connectAms:"連線 AMS 以檢視耗材槽位",noPrinter:"未選擇印表機",selectPrinter:"從頂部欄選擇印表機",printerDisconnected:"印表機已斷開",humidity:"濕度",level:"餘量",active:"活躍",slot:"槽位",empty:"空"},inventory:{search:"搜尋耗材...",empty:"庫存中沒有耗材",noResults:"沒有匹配的耗材",spools:"個耗材",addSpool:"新增耗材"},settings:{tabDevice:"裝置",tabDisplay:"顯示",tabScale:"磅秤",tabUpdates:"更新",nfcReader:"NFC 讀卡器",type:"類型",connection:"連線",notConnected:"不適用",deviceInfo:"裝置資訊",hostname:"主機",uptime:"執行時間",systemConfig:"後端與認證",backendUrl:"Bambuddy 後端 URL",apiToken:"API 權杖",apiTokenPlaceholder:"輸入 API 權杖",saveConfig:"儲存設定",systemQueued:"設定已加入佇列。",nfcDiagnostic:"NFC 診斷",scaleDiagnostic:"磅秤診斷",readTagDiagnostic:"讀取標籤診斷",testNfc:"測試讀卡器",testScale:"測試精度",testReadTag:"讀取標籤",systemFieldsRequired:"後端 URL 為必填項。",brightness:"亮度",saved:"已儲存",noBacklight:"未偵測到 DSI 背光。亮度控制需要 DSI 螢幕。",screenBlank:"螢幕熄滅超時",screenBlankDesc:"不活動後螢幕關閉。觸控喚醒。",displayNote:"亮度作為軟體濾鏡套用。",scaleCalibration:"磅秤校準",currentWeight:"目前重量",tareOffset:"去皮",calFactor:"係數",knownWeight:"已知重量",calStep1:"移除磅秤上所有物品並按設定零點。",calStep2:"將已知重量放在磅秤上。",setZero:"設定零點",calibrateNow:"校準",calibrated:"已校準",tareSet:"去皮命令已傳送。等待裝置回應...",tareFailed:"傳送去皮命令失敗",zeroSet:"零點已設定。將已知重量放在磅秤上。",calibrationDone:"校準完成!",calibrationFailed:"校準失敗",lastCalibrated:"上次校準",stable:"穩定",settling:"穩定中...",firmware:"韌體",scale:"磅秤",noDevice:"未找到 SpoolBuddy 裝置",daemonVersion:"守護程式版本",currentVersion:"目前",versionPending:"等待守護程式...",checking:"檢查中...",checkUpdates:"檢查更新",updateAvailable:"有可用更新",updateInstructions:"透過 SSH 更新:執行 SpoolBuddy 安裝腳本進行升級。",upToDate:"已是最新",includeBeta:"包含測試版本"},writeTag:{tabExisting:"現有耗材",tabNew:"新耗材",tabReplace:"替換標籤",searchPlaceholder:"按材料、顏色、品牌搜尋...",noUntaggedSpools:"沒有無標籤的耗材",noTaggedSpools:"沒有有標籤的耗材",selectSpool:"選擇一個耗材,然後將空白 NTAG 放在讀卡器上",placeTag:"將 NTAG 放在讀卡器上",tagReady:"偵測到標籤 — 準備寫入",writeTag:"寫入標籤",replaceTag:"替換標籤",writing:"寫入標籤中...",waiting:"等待 SpoolBuddy...",writeSuccess:"標籤寫入成功!",writeFailed:"寫入失敗",queueFailed:"佇列寫入命令失敗",tryAgain:"重試",cancel:"取消",replaceWarning:"舊標籤將被取消連結。新標籤將替換它。",deviceOffline:"SpoolBuddy 離線",material:"材料",colorName:"顏色名稱",color:"顏色",brand:"品牌",weight:"重量 (g)",createSpool:"建立耗材",creating:"建立中...",spoolCreated:"耗材已建立!準備寫入。",createFailed:"建立耗材失敗",incompleteDataWarning:"已使用不完整的Spoolman資料寫入標籤"},quickMenu:{printerPower:"印表機電源",systemControls:"系統",restartDaemon:"重新啟動守護程式",restartBrowser:"重新啟動瀏覽器",reboot:"重新開機",shutdown:"關機",swipeToClose:"向下滑動關閉",confirmTitle:"確認",confirmShutdown:"確定要關閉 SpoolBuddy 嗎?您需要實體存取才能重新開啟。",confirmReboot:"確定要重新開機 SpoolBuddy 嗎?",confirmRestartDaemon:"重新啟動 SpoolBuddy 守護程式?NFC 和磅秤將暫時不可用。",confirmRestartBrowser:"重新啟動kiosk瀏覽器?螢幕將短暫變黑。",confirm:"確認",confirmPlugOn:"開啟 {{name}}?",confirmPlugOff:"關閉 {{name}}?",turnOn:"開啟",turnOff:"關閉"}},bugReport:{title:"報告錯誤",description:"描述",descriptionPlaceholder:"出了什麼問題?請描述問題...",email:"信箱(可選)",emailPlaceholder:"your@email.com",emailPrivacy:"如果提供,您的信箱將包含在GitHub Issue的摺疊部分中,以便維護者後續跟進。",screenshot:"截圖",uploadOrPaste:"上傳、貼上或拖曳圖片",dataCollectedSummary:"報告中包含哪些資料?",dataIncluded:"包含:",dataIncludedList:"應用程式版本、作業系統、架構、Python版本、資料庫統計(僅計數)、印表機型號、噴嘴數量、韌體版本、連線狀態、整合狀態(Spoolman、MQTT、HA)、非敏感設定、網路介面數量、Docker詳情、依賴版本。",dataNeverIncluded:"絕不包含:",dataNeverIncludedList:"印表機名稱、序列號、存取碼、密碼、IP 位址、信箱地址、API金鑰、權杖、Webhook URL、主機名稱或使用者名稱。",submit:"提交",startLogging:"開始偵錯日誌",stepEnableLogging:"偵錯日誌已啟用",stepReproduce:"請現在重現問題",stepStopLogging:"停止並提交報告",stopAndSubmit:"停止並提交",maxDuration:"{{minutes}} 分鐘後自動停止",stoppingLogs:"正在收集日誌並提交...",submitting:"正在提交錯誤報告...",submitSuccess:"錯誤報告提交成功!",submitFailed:"提交錯誤報告失敗",thankYou:"謝謝!",submitted:"您的錯誤報告已提交。",viewIssue:"檢視 Issue",unexpectedError:"發生了意外錯誤"},failureDetection:{title:"AI 故障檢測",description:"透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。",mlUrl:"Obico ML API 地址",mlUrlHint:"您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。",test:"測試",testSuccess:"ML API 可存取且正常。",testFailed:"無法存取 ML API。",sensitivity:"靈敏度",sensitivityLow:"低(減少誤報)",sensitivityMedium:"中(平衡)",sensitivityHigh:"高(更早檢測,更多誤報)",sensitivityHint:"調整觸發警告和故障的置信度閾值。",action:"偵測到故障時的操作",actionNotify:"僅通知",actionPause:"暫停列印",actionPauseOff:"暫停並切斷電源",pollInterval:"檢查間隔(秒)",pollIntervalHint:"列印過程中每臺印表機的檢查頻率。最小 5 秒,最大 120 秒。",externalUrlMissing:"尚未設定外部 URL。",externalUrlHint:"ML API 透過 URL 擷取攝影機快照。請在一般設定中設定外部 URL,讓 ML API 容器可以連線到 Bambuddy。",perPrinterTitle:"監控的印表機",perPrinterHint:"選擇檢測服務要監視哪些印表機。",monitorAll:"監控所有已連線的印表機",statusTitle:"狀態",serviceRunning:"服務執行中",thresholds:"低 / 高閾值",activePrinters:"活動列印",noActivePrints:"目前沒有正在進行的列印。",historyTitle:"最近檢測",noHistory:"尚無檢測紀錄。"},makerworld:{title:"MakerWorld",description:"貼上 MakerWorld 模型連結,即可直接在 Bambuddy 中匯入並列印 —— 無需切換至 Bambu Handy 應用程式。",pasteUrlHeader:"從 MakerWorld 匯入",pasteUrlPlaceholder:"https://makerworld.com/en/models/… 或貼上任意 MakerWorld 連結",resolveButton:"解析",signInRequiredTitle:"下載需要登入 Bambu Cloud",signInRequiredBody:"您可以匿名瀏覽模型詳情,但下載 3MF 檔案需要 Bambu Cloud 帳戶。",openCloudSettings:"開啟雲端設定",untitledModel:"無標題模型",byCreator:"作者: {{name}}",downloadsCount:"{{count}} 次下載",licensePrefix:"授權條款",alreadyImported:"已在資料庫中",openOnMakerworld:"在 MakerWorld 中開啟",alreadyInLibrary:"此模型已在您的資料庫中 —— 可在檔案管理員 → MakerWorld 中找到",importSuccess:"已匯入 {{filename}} —— 已儲存至檔案管理員 → MakerWorld",platesHeader:"列印板 ({{count}})",plateDefaultName:"列印板 {{n}}",materialCount:"{{count}} 種耗材",amsRequired:"需要 AMS",slicedFor:"Sliced for {{printer}}",alsoCompatible:"Also marked compatible: {{printers}}",importToLibrary:"儲存",sliceIn:"儲存並在 {{slicer}} 中切片",disclaimer:"MakerWorld 整合使用由社群記錄的 API 介面。Bambuddy 與 MakerWorld 或 Bambu Lab 無從屬或認可關係。",lastImportSuccess:"已匯入您的資料庫",lastImportAlreadyInLibrary:"已存在於您的資料庫中",viewInLibrary:"在檔案管理員中查看",openInBambuStudio:"在 Bambu Studio 中開啟",openInOrcaSlicer:"在 OrcaSlicer 中開啟",importTo:"匯入至檔案管理員",recentImportsHeader:"最近匯入",phaseResolving:"解析中",phaseDownloading:"下載中",folderAuto:"MakerWorld (預設)",importAll:"全部匯入",importAllProgress:"正在匯入 {{current}}/{{total}}",openGallery:"開啟圖片庫",galleryPrev:"上一張",galleryNext:"下一張",deleteImport:"從資料庫中移除",importDeleting:"正在移除…",importDeleted:"已從資料庫中移除",confirmDelete:"從資料庫中移除 {{filename}}?本機檔案將被刪除,但可以從 MakerWorld 重新匯入。",errors:{resolveFailed:"無法解析該 MakerWorld 連結。",downloadFailed:"下載失敗。請重試。",deleteFailed:"無法從資料庫中移除檔案。"}},gcodeViewer:{back:"返回",backToArchives:"返回列印歸檔",backToFiles:"返回檔案管理器"},libraryTrash:{title:"資源回收筒",headerButton:"資源回收筒",headerTooltip:"檢視已移至資源回收筒的檔案",backToFiles:"返回檔案管理員",subtitleAdmin:"已刪除的檔案會在資源回收筒保留 {{days}} 天,之後自動刪除。此檢視顯示所有使用者的資源回收筒。",subtitleUser:"已刪除的檔案會在資源回收筒保留 {{days}} 天,之後自動刪除。",loading:"正在載入資源回收筒…",loadError:"無法載入資源回收筒。",empty:"資源回收筒是空的。",summary:"{{count}} 個檔案 · {{size}}",emptyTrash:"清空資源回收筒",restore:"還原",purgeNow:"立即刪除",autoPurgeIn:"將於 {{when}} 自動刪除",days:"天",retentionLabel:"自動刪除時間",selectAll:"全選",selectOne:"選擇 {{filename}}",selectionCount:"已選擇 {{count}} 項",bulkRestore:"還原所選",bulkPurge:"刪除所選",col:{filename:"檔案",folder:"資料夾",size:"大小",deleted:"移入資源回收筒",autoPurge:"自動刪除",owner:"擁有者",actions:"操作"},confirm:{purgeTitle:"永久刪除?",purgeBody:"{{filename}} 將從磁碟中刪除,無法還原。",emptyTitle:"清空資源回收筒?",emptyBody:"全部 {{count}} 個檔案將從磁碟中刪除。此操作無法復原。",bulkPurgeTitle:"永久刪除所選檔案?",bulkPurgeBody:"所選的 {{count}} 個檔案將從磁碟中刪除,無法還原。",cta:"永久刪除"},toast:{restored:"檔案已還原。",restoreFailed:"無法還原檔案。",purged:"檔案已永久刪除。",purgeFailed:"無法刪除檔案。",emptied:"已從資源回收筒刪除 {{count}} 個檔案。",emptyFailed:"無法清空資源回收筒。",retentionSaved:"自動刪除已設定為 {{days}} 天。",retentionFailed:"無法儲存保留設定。",bulkRestored:"已還原 {{count}} 個檔案。",bulkPurged:"已刪除 {{count}} 個檔案。"}},libraryPurge:{title:"清理舊檔案",headerButton:"清理舊檔案",headerTooltip:"批次將舊檔案移至資源回收筒",description:"早於所選時間的檔案將被移至資源回收筒。外部資料夾會被略過。在自動刪除之前,您可以從資源回收筒還原檔案。",ageLabel:"移動早於以下天數的檔案",days:"天",includeNeverPrinted:"包括從未列印過的檔案",effectsTitle:"What happens when you click Purge",effect1:"Matching files are moved to Trash — they are not deleted from disk yet.",effect2:"You can restore them from Trash at any time until the retention window expires.",effect3:"After retention, the trash sweeper permanently removes them from disk.",effect4:"Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.",previewLoading:"正在檢查符合的檔案數量…",previewFailed:"無法預覽清理結果。",previewSummary:"{{count}} 個檔案 · {{size}} 將被移至資源回收筒",andMore:"…還有 {{count}} 個",warning:"檔案將被軟刪除——在保留期結束前,您可以從資源回收筒還原它們。",confirmCta:"將 {{count}} 個移至資源回收筒",purging:"正在移至資源回收筒…",toast:{success:"已將 {{count}} 個檔案移至資源回收筒。",failed:"無法清理檔案。"}},libraryAutoPurge:{enableLabel:"自動清理舊檔案",enableDescription:"每天執行一次管理員清理。檔案會先進入資源回收筒——不會立即刪除。",ageLabel:"自動清理早於以下天數的檔案",ageDescription:"最少 7 天,最多 10 年。使用與手動「清理舊檔案」按鈕相同的時間規則。",days:"天",includeNeverPrinted:"包括從未列印過的檔案",saveFailed:"無法儲存自動清理設定。"},archivePurge:{headerButton:"Purge old",headerTooltip:"Bulk-delete old archives",title:"Purge old archives",description:"Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.",ageLabel:"Delete archives not printed in the last",days:"days",effectsTitle:"What happens when you click Purge",effect1:"Each matching archive is permanently removed from the database.",effect2:"The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.",effect3:"There is no trash bin for archives — deletion is immediate and cannot be undone.",effect4:"Reprinting an archive refreshes its age clock, so archives you still use are safe.",previewLoading:"Checking how many archives match…",previewFailed:"Could not preview the purge.",previewSummary:"{{count}} archives · {{size}} would be deleted",andMore:"…and {{count}} more",warning:"This is permanent. Download or favourite anything you want to keep before continuing.",confirmCta:"Delete {{count}} archive(s)",purging:"Deleting…",toast:{success:"Deleted {{count}} archive(s).",failed:"Could not purge archives."}},archiveAutoPurge:{enableLabel:"Auto-purge old archives",enableDescription:"Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.",ageLabel:"Auto-delete archives not printed in the last",ageDescription:"Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.",days:"days",runNow:"Purge archives now",saveFailed:"Could not save auto-purge settings."},cameraTokens:{title:"Camera API Tokens",navTitle:"Camera API tokens",description:"Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.",loading:"Loading…",confirmRevoke:{title:"Revoke this token?",body:'Any device using "{{name}}" will lose access immediately. This cannot be undone.',cancel:"Cancel",confirm:"Revoke"},create:{title:"Create new token",nameLabel:"Token name",namePlaceholder:"e.g. Home Assistant",daysLabel:"Days until expiry",submit:"Create",hint:"Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now."},created:{title:"Token created — copy it now",warning:"This is the only time this token will be visible. After you close this dialog you can never view it again.",copy:"Copy",dismiss:"I've saved it"},list:{myTitle:"My tokens",allTitle:"All users (admin view)",empty:"No tokens yet.",name:"Name",owner:"Owner",prefix:"Prefix",created:"Created",expires:"Expires",lastUsed:"Last used",revoke:"Revoke",expired:"Expired"},toast:{created:"Token created",createFailed:"Failed to create token",revoked:"Token revoked",revokeFailed:"Failed to revoke token",loadFailed:"Failed to load tokens",copied:"Copied to clipboard",copyFailed:"Copy failed — select and copy manually"}},forecast:{title:"庫存預測",noSpools:"未找到活躍的料卷。請將料卷新增至庫存以查看預測資料。",noUsageData:"無可用使用資料 — 無法預測庫存時間線。",sku:"SKU",material:"材料",stock:"庫存",dailyRate:"消耗率",daysLeft:"剩餘天數",emptyBy:"耗盡日期",reorderBy:"補貨截止日",actions:"操作",trend:"趨勢",estimated:"估算",noData:"無資料",timeframe:"時間範圍",chartTitle:"庫存預測 — 前5種材料",dashedLinesROP:"虛線 = 再訂貨點",stockLevel:"庫存量",reorderPoint:"再訂購點",safetyMargin:"安全餘量",trendLegend:"趨勢(基於歷史,95%服務水準)",estimatedLegend:"估算(重量差值)",noDataLegend:"無資料",ropLabel:"再訂貨點",ssLabel:"安全庫存",safetyStockLegend:"安全庫存",stockArrivalLegend:"到貨",stockoutLegend:"斷貨",alertCount_one:"{{count}}條警告",alertCount_other:"{{count}}條警告",order:"訂購",globalLeadTime:"全域交貨期",globalLeadTimeHint:"全域交貨期下限 — 用於所有 SKU 的再訂購點計算",save:"儲存",cancel:"取消",settingsSaved:"設定已儲存",failedSaveSettings:"儲存設定失敗",globalLeadTimeSaved:"全域提前期已儲存",skuLeadTimeOverride:"SKU 交貨期覆蓋",skuLeadTimeHint:"0 = 使用全域交貨期。設定 >0 以覆蓋此 SKU。",safetyMarginLabel:"安全餘量",effectiveLeadTime:"有效交貨期",effectiveLeadTimeHint:"max(全域 {{global}}天, SKU {{sku}}天)",reorderPointHint:"d̄ × LT + safety margin — 當庫存降至此水準時下單",safetyMarginHint:"統計安全庫存 (z=1.65 × σ × √LT) + 使用者自訂緩衝",safetyMarginHintDays:"在統計安全庫存基礎上額外增加的緩衝。{{approx}}",safetyMarginHintDaysApprox:" ≈ 按當前速率 {{g}}g。",safetyMarginHintG:"在統計安全庫存基礎上增加的固定重量緩衝。{{approx}}",safetyMarginHintGApprox:" ≈ 按當前速率 {{days}}天。",individualSpools:"單個料卷",labelWeight:"標示重量",spoolCount_one:"{{count}}個線軸",spoolCount_other:"{{count}}個線軸",stockBreakRisk:"斷貨風險",stockBreakBefore:"補貨前庫存斷檔",stockBreakDetail:"剩餘 {{days}} 天,交貨期 {{lt}} 天。",reorderNow:"立即補貨",reorderTriggerPassed:"觸發日期 {{date}} 已過。",shoppingList:"購物清單",shoppingListItems_one:"({{count}}項)",shoppingListItems_other:"({{count}}項)",shoppingListEmpty:"購物清單為空。點擊任意列的購物車圖示以新增商品。",addToCart:"新增至購物清單",alertsSnoozed:"靜音此SKU的提醒",alertsEnabled:"啟用此SKU的提醒",addedToCart:"已新增至購物清單",failedAddItem:"新增項目失敗",listView:"列表",logisticsView:"物流",qty:"數量",weight:"重量",leadTime:"交貨期",expectedRestock:"預計補貨日期",status:"狀態",note:"備註",pending:"待處理",purchased:"已購買",received:"已收貨",markPurchased:"標記為已購買",markReceived:"標記為已收貨 — 將料卷新增至庫存",resetToPending:"重置為待處理",remove:"移除",clearAll:"清空",downloadCsv:"CSV",addToCartTitle:"新增至購物清單",byQuantity:"依數量",byDuration:"依時長",numberOfSpools:"線軸數量",lastHowManyDays:"需要持續多少天?",noUsageQty:"無使用資料 — 數量已設為1。",noteOptional:"備註(選填)",notePlaceholder:"例如:用於專案X,緊急…",addNSpools_one:"新增{{count}}個線軸",addNSpools_other:"新增{{count}}個線軸",onArrival:"到貨時",stockBreakIn:"{{days}} 天後斷貨。",stockRunsOutBefore:"庫存在 {{lt}} 天交貨期結束前耗盡。",atRate:"按 {{rate}}g/天的速度,您需要",moreSpools_one:"再{{count}}個線軸",moreSpools_other:"再{{count}}個線軸",bridgeGap:"來彌補缺口。",noReadAccess:"您沒有查看庫存預測的權限。",noWriteAccess:"您沒有修改預測設定的權限。"}},Kme={en:{translation:Ume},de:{translation:Bme},fr:{translation:Hme},ja:{translation:qme},it:{translation:$me},"pt-BR":{translation:Vme},"zh-CN":{translation:Gme},"zh-TW":{translation:Wme}};Mo.use(qQ).use(xme).init({resources:Kme,fallbackLng:"en",supportedLngs:["en","de","fr","ja","it","pt-BR","zh-CN","zh-TW"],detection:{order:["localStorage","navigator","htmlTag"],lookupLocalStorage:"bambutrack_language",caches:["localStorage"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const VH=[{code:"en",name:"English",nativeName:"English"},{code:"de",name:"German",nativeName:"Deutsch"},{code:"fr",name:"French",nativeName:"Français"},{code:"ja",name:"Japanese",nativeName:"日本語"},{code:"it",name:"Italian",nativeName:"Italiano"},{code:"pt-BR",name:"Portuguese (Brazil)",nativeName:"Português (Brasil)"},{code:"zh-CN",name:"Chinese (Simplified)",nativeName:"简体中文"},{code:"zh-TW",name:"Chinese (Traditional)",nativeName:"繁體中文"}];var GH="popstate";function Yme(t={}){function e(a,i){let{pathname:s,search:o,hash:l}=a.location;return jF("",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||"default")}function n(a,i){return typeof i=="string"?i:Cw(i)}return Qme(e,n,null,t)}function za(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function vc(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function Xme(){return Math.random().toString(36).substring(2,10)}function WH(t,e){return{usr:t.state,key:t.key,idx:e}}function jF(t,e,n=null,a){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?Hy(e):e,state:n,key:e&&e.key||a||Xme()}}function Cw({pathname:t="/",search:e="",hash:n=""}){return e&&e!=="?"&&(t+=e.charAt(0)==="?"?e:"?"+e),n&&n!=="#"&&(t+=n.charAt(0)==="#"?n:"#"+n),t}function Hy(t){let e={};if(t){let n=t.indexOf("#");n>=0&&(e.hash=t.substring(n),t=t.substring(0,n));let a=t.indexOf("?");a>=0&&(e.search=t.substring(a),t=t.substring(0,a)),t&&(e.pathname=t)}return e}function Qme(t,e,n,a={}){let{window:i=document.defaultView,v5Compat:s=!1}=a,o=i.history,l="POP",c=null,d=u();d==null&&(d=0,o.replaceState({...o.state,idx:d},""));function u(){return(o.state||{idx:null}).idx}function m(){l="POP";let b=u(),x=b==null?null:b-d;d=b,c&&c({action:l,location:w.location,delta:x})}function p(b,x){l="PUSH";let S=jF(w.location,b,x);d=u()+1;let N=WH(S,d),C=w.createHref(S);try{o.pushState(N,"",C)}catch(P){if(P instanceof DOMException&&P.name==="DataCloneError")throw P;i.location.assign(C)}s&&c&&c({action:l,location:w.location,delta:1})}function f(b,x){l="REPLACE";let S=jF(w.location,b,x);d=u();let N=WH(S,d),C=w.createHref(S);o.replaceState(N,"",C),s&&c&&c({action:l,location:w.location,delta:0})}function g(b){return Zme(b)}let w={get action(){return l},get location(){return t(i,o)},listen(b){if(c)throw new Error("A history only accepts one active listener");return i.addEventListener(GH,m),c=b,()=>{i.removeEventListener(GH,m),c=null}},createHref(b){return e(i,b)},createURL:g,encodeLocation(b){let x=g(b);return{pathname:x.pathname,search:x.search,hash:x.hash}},push:p,replace:f,go(b){return o.go(b)}};return w}function Zme(t,e=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),za(n,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:Cw(t);return a=a.replace(/ $/,"%20"),!e&&a.startsWith("//")&&(a=n+a),new URL(a,n)}function $Q(t,e,n="/"){return Jme(t,e,n,!1)}function Jme(t,e,n,a){let i=typeof e=="string"?Hy(e):e,s=Um(i.pathname||"/",n);if(s==null)return null;let o=VQ(t);epe(o);let l=null;for(let c=0;l==null&&c{let u={relativePath:d===void 0?o.path||"":d,caseSensitive:o.caseSensitive===!0,childrenIndex:l,route:o};if(u.relativePath.startsWith("/")){if(!u.relativePath.startsWith(a)&&c)return;za(u.relativePath.startsWith(a),`Absolute route path "${u.relativePath}" nested under path "${a}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(a.length)}let m=jm([a,u.relativePath]),p=n.concat(u);o.children&&o.children.length>0&&(za(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),VQ(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:ope(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of GQ(o.path))s(o,l,!0,c)}),e}function GQ(t){let e=t.split("/");if(e.length===0)return[];let[n,...a]=e,i=n.endsWith("?"),s=n.replace(/\?$/,"");if(a.length===0)return i?[s,""]:[s];let o=GQ(a.join("/")),l=[];return l.push(...o.map(c=>c===""?s:[s,c].join("/"))),i&&l.push(...o),l.map(c=>t.startsWith("/")&&c===""?"/":c)}function epe(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:lpe(e.routesMeta.map(a=>a.childrenIndex),n.routesMeta.map(a=>a.childrenIndex)))}var tpe=/^:[\w-]+$/,npe=3,rpe=2,ape=1,ipe=10,spe=-2,KH=t=>t==="*";function ope(t,e){let n=t.split("/"),a=n.length;return n.some(KH)&&(a+=spe),e&&(a+=rpe),n.filter(i=>!KH(i)).reduce((i,s)=>i+(tpe.test(s)?npe:s===""?ape:ipe),a)}function lpe(t,e){return t.length===e.length&&t.slice(0,-1).every((a,i)=>a===e[i])?t[t.length-1]-e[e.length-1]:0}function cpe(t,e,n=!1){let{routesMeta:a}=t,i={},s="/",o=[];for(let l=0;l{if(u==="*"){let g=l[p]||"";o=s.slice(0,s.length-g.length).replace(/(.)\/+$/,"$1")}const f=l[p];return m&&!f?d[u]=void 0:d[u]=(f||"").replace(/%2F/g,"/"),d},{}),pathname:s,pathnameBase:o,pattern:t}}function dpe(t,e=!1,n=!0){vc(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let a=[],i="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(a.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(a.push({paramName:"*"}),i+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?i+="\\/*$":t!==""&&t!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,e?void 0:"i"),a]}function upe(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return vc(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${e}).`),t}}function Um(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let n=e.endsWith("/")?e.length-1:e.length,a=t.charAt(n);return a&&a!=="/"?null:t.slice(n)||"/"}var mpe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function ppe(t,e="/"){let{pathname:n,search:a="",hash:i=""}=typeof t=="string"?Hy(t):t,s;return n?(n=n.replace(/\/\/+/g,"/"),n.startsWith("/")?s=YH(n.substring(1),"/"):s=YH(n,e)):s=e,{pathname:s,search:gpe(a),hash:bpe(i)}}function YH(t,e){let n=e.replace(/\/+$/,"").split("/");return t.split("/").forEach(i=>{i===".."?n.length>1&&n.pop():i!=="."&&n.push(i)}),n.length>1?n.join("/"):"/"}function rE(t,e,n,a){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(a)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function hpe(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function tO(t){let e=hpe(t);return e.map((n,a)=>a===e.length-1?n.pathname:n.pathnameBase)}function nO(t,e,n,a=!1){let i;typeof t=="string"?i=Hy(t):(i={...t},za(!i.pathname||!i.pathname.includes("?"),rE("?","pathname","search",i)),za(!i.pathname||!i.pathname.includes("#"),rE("#","pathname","hash",i)),za(!i.search||!i.search.includes("#"),rE("#","search","hash",i)));let s=t===""||i.pathname==="",o=s?"/":i.pathname,l;if(o==null)l=n;else{let m=e.length-1;if(!a&&o.startsWith("..")){let p=o.split("/");for(;p[0]==="..";)p.shift(),m-=1;i.pathname=p.join("/")}l=m>=0?e[m]:"/"}let c=ppe(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var jm=t=>t.join("/").replace(/\/\/+/g,"/"),fpe=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),gpe=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,bpe=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,xpe=class{constructor(t,e,n,a=!1){this.status=t,this.statusText=e||"",this.internal=a,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function ype(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function vpe(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var WQ=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function KQ(t,e){let n=t;if(typeof n!="string"||!mpe.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let a=n,i=!1;if(WQ)try{let s=new URL(window.location.href),o=n.startsWith("//")?new URL(s.protocol+n):new URL(n),l=Um(o.pathname,e);o.origin===s.origin&&l!=null?n=l+o.search+o.hash:i=!0}catch{vc(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:a,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var YQ=["POST","PUT","PATCH","DELETE"];new Set(YQ);var wpe=["GET",...YQ];new Set(wpe);var qy=y.createContext(null);qy.displayName="DataRouter";var yT=y.createContext(null);yT.displayName="DataRouterState";var Spe=y.createContext(!1),XQ=y.createContext({isTransitioning:!1});XQ.displayName="ViewTransition";var _pe=y.createContext(new Map);_pe.displayName="Fetchers";var kpe=y.createContext(null);kpe.displayName="Await";var jl=y.createContext(null);jl.displayName="Navigation";var _S=y.createContext(null);_S.displayName="Location";var Ac=y.createContext({outlet:null,matches:[],isDataRoute:!1});Ac.displayName="Route";var rO=y.createContext(null);rO.displayName="RouteError";var QQ="REACT_ROUTER_ERROR",Npe="REDIRECT",Cpe="ROUTE_ERROR_RESPONSE";function Ppe(t){if(t.startsWith(`${QQ}:${Npe}:{`))try{let e=JSON.parse(t.slice(28));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.location=="string"&&typeof e.reloadDocument=="boolean"&&typeof e.replace=="boolean")return e}catch{}}function Tpe(t){if(t.startsWith(`${QQ}:${Cpe}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new xpe(e.status,e.statusText,e.data)}catch{}}function Ape(t,{relative:e}={}){za($y(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:a}=y.useContext(jl),{hash:i,pathname:s,search:o}=NS(t,{relative:e}),l=s;return n!=="/"&&(l=s==="/"?n:jm([n,s])),a.createHref({pathname:l,search:o,hash:i})}function $y(){return y.useContext(_S)!=null}function fd(){return za($y(),"useLocation() may be used only in the context of a component."),y.useContext(_S).location}var ZQ="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function JQ(t){y.useContext(jl).static||y.useLayoutEffect(t)}function Mi(){let{isDataRoute:t}=y.useContext(Ac);return t?qpe():jpe()}function jpe(){za($y(),"useNavigate() may be used only in the context of a component.");let t=y.useContext(qy),{basename:e,navigator:n}=y.useContext(jl),{matches:a}=y.useContext(Ac),{pathname:i}=fd(),s=JSON.stringify(tO(a)),o=y.useRef(!1);return JQ(()=>{o.current=!0}),y.useCallback((c,d={})=>{if(vc(o.current,ZQ),!o.current)return;if(typeof c=="number"){n.go(c);return}let u=nO(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:jm([e,u.pathname])),(d.replace?n.replace:n.push)(u,d.state,d)},[e,n,s,i,t])}var eZ=y.createContext(null);function Vy(){return y.useContext(eZ)}function Mpe(t){let e=y.useContext(Ac).outlet;return y.useMemo(()=>e&&y.createElement(eZ.Provider,{value:t},e),[e,t])}function kS(){let{matches:t}=y.useContext(Ac),e=t[t.length-1];return e?e.params:{}}function NS(t,{relative:e}={}){let{matches:n}=y.useContext(Ac),{pathname:a}=fd(),i=JSON.stringify(tO(n));return y.useMemo(()=>nO(t,JSON.parse(i),a,e==="path"),[t,i,a,e])}function Epe(t,e){return tZ(t,e)}function tZ(t,e,n,a,i){za($y(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=y.useContext(jl),{matches:o}=y.useContext(Ac),l=o[o.length-1],c=l?l.params:{},d=l?l.pathname:"/",u=l?l.pathnameBase:"/",m=l&&l.route;{let S=m&&m.path||"";rZ(d,!m||S.endsWith("*")||S.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${d}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let p=fd(),f;if(e){let S=typeof e=="string"?Hy(e):e;za(u==="/"||S.pathname?.startsWith(u),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${u}" but pathname "${S.pathname}" was given in the \`location\` prop.`),f=S}else f=p;let g=f.pathname||"/",w=g;if(u!=="/"){let S=u.replace(/^\//,"").split("/");w="/"+g.replace(/^\//,"").split("/").slice(S.length).join("/")}let b=$Q(t,{pathname:w});vc(m||b!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),vc(b==null||b[b.length-1].route.element!==void 0||b[b.length-1].route.Component!==void 0||b[b.length-1].route.lazy!==void 0,`Matched leaf route at location "${f.pathname}${f.search}${f.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let x=Ipe(b&&b.map(S=>Object.assign({},S,{params:Object.assign({},c,S.params),pathname:jm([u,s.encodeLocation?s.encodeLocation(S.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:S.pathname]),pathnameBase:S.pathnameBase==="/"?u:jm([u,s.encodeLocation?s.encodeLocation(S.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:S.pathnameBase])})),o,n,a,i);return e&&x?y.createElement(_S.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},x):x}function Dpe(){let t=Hpe(),e=ype(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),n=t instanceof Error?t.stack:null,a="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:a},s={padding:"2px 4px",backgroundColor:a},o=null;return console.error("Error handled by React Router default ErrorBoundary:",t),o=y.createElement(y.Fragment,null,y.createElement("p",null,"💿 Hey developer 👋"),y.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",y.createElement("code",{style:s},"ErrorBoundary")," or"," ",y.createElement("code",{style:s},"errorElement")," prop on your route.")),y.createElement(y.Fragment,null,y.createElement("h2",null,"Unexpected Application Error!"),y.createElement("h3",{style:{fontStyle:"italic"}},e),n?y.createElement("pre",{style:i},n):null,o)}var Fpe=y.createElement(Dpe,null),nZ=class extends y.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,e){return e.location!==t.location||e.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:e.error,location:e.location,revalidation:t.revalidation||e.revalidation}}componentDidCatch(t,e){this.props.onError?this.props.onError(t,e):console.error("React Router caught the following error during render",t)}render(){let t=this.state.error;if(this.context&&typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){const n=Tpe(t.digest);n&&(t=n)}let e=t!==void 0?y.createElement(Ac.Provider,{value:this.props.routeContext},y.createElement(rO.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?y.createElement(Lpe,{error:t},e):e}};nZ.contextType=Spe;var aE=new WeakMap;function Lpe({children:t,error:e}){let{basename:n}=y.useContext(jl);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let a=Ppe(e.digest);if(a){let i=aE.get(e);if(i)throw i;let s=KQ(a.location,n);if(WQ&&!aE.get(e))if(s.isExternal||a.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:a.replace}));throw aE.set(e,o),o}return y.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function Rpe({routeContext:t,match:e,children:n}){let a=y.useContext(qy);return a&&a.static&&a.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(a.staticContext._deepestRenderedBoundaryId=e.route.id),y.createElement(Ac.Provider,{value:t},n)}function Ipe(t,e=[],n=null,a=null,i=null){if(t==null){if(!n)return null;if(n.errors)t=n.matches;else if(e.length===0&&!n.initialized&&n.matches.length>0)t=n.matches;else return null}let s=t,o=n?.errors;if(o!=null){let u=s.findIndex(m=>m.route.id&&o?.[m.route.id]!==void 0);za(u>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(o).join(",")}`),s=s.slice(0,Math.min(s.length,u+1))}let l=!1,c=-1;if(n)for(let u=0;u=0?s=s.slice(0,c+1):s=[s[0]];break}}}let d=n&&a?(u,m)=>{a(u,{location:n.location,params:n.matches?.[0]?.params??{},unstable_pattern:vpe(n.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,g=!1,w=null,b=null;n&&(f=o&&m.route.id?o[m.route.id]:void 0,w=m.route.errorElement||Fpe,l&&(c<0&&p===0?(rZ("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),g=!0,b=null):c===p&&(g=!0,b=m.route.hydrateFallbackElement||null)));let x=e.concat(s.slice(0,p+1)),S=()=>{let N;return f?N=w:g?N=b:m.route.Component?N=y.createElement(m.route.Component,null):m.route.element?N=m.route.element:N=u,y.createElement(Rpe,{match:m,routeContext:{outlet:u,matches:x,isDataRoute:n!=null},children:N})};return n&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?y.createElement(nZ,{location:n.location,revalidation:n.revalidation,component:w,error:f,children:S(),routeContext:{outlet:null,matches:x,isDataRoute:!0},onError:d}):S()},null)}function aO(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Ope(t){let e=y.useContext(qy);return za(e,aO(t)),e}function zpe(t){let e=y.useContext(yT);return za(e,aO(t)),e}function Upe(t){let e=y.useContext(Ac);return za(e,aO(t)),e}function iO(t){let e=Upe(t),n=e.matches[e.matches.length-1];return za(n.route.id,`${t} can only be used on routes that contain a unique "id"`),n.route.id}function Bpe(){return iO("useRouteId")}function Hpe(){let t=y.useContext(rO),e=zpe("useRouteError"),n=iO("useRouteError");return t!==void 0?t:e.errors?.[n]}function qpe(){let{router:t}=Ope("useNavigate"),e=iO("useNavigate"),n=y.useRef(!1);return JQ(()=>{n.current=!0}),y.useCallback(async(i,s={})=>{vc(n.current,ZQ),n.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var XH={};function rZ(t,e,n){!e&&!XH[t]&&(XH[t]=!0,vc(!1,n))}y.memo($pe);function $pe({routes:t,future:e,state:n,onError:a}){return tZ(t,void 0,n,a,e)}function pg({to:t,replace:e,state:n,relative:a}){za($y()," may be used only in the context of a component.");let{static:i}=y.useContext(jl);vc(!i," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:s}=y.useContext(Ac),{pathname:o}=fd(),l=Mi(),c=nO(t,tO(s),o,a==="path"),d=JSON.stringify(c);return y.useEffect(()=>{l(JSON.parse(d),{replace:e,state:n,relative:a})},[l,d,a,e,n]),null}function aZ(t){return Mpe(t.context)}function Tr(t){za(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Vpe({basename:t="/",children:e=null,location:n,navigationType:a="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){za(!$y(),"You cannot render a inside another . You should never have more than one in your app.");let l=t.replace(/^\/*/,"/"),c=y.useMemo(()=>({basename:l,navigator:i,static:s,unstable_useTransitions:o,future:{}}),[l,i,s,o]);typeof n=="string"&&(n=Hy(n));let{pathname:d="/",search:u="",hash:m="",state:p=null,key:f="default"}=n,g=y.useMemo(()=>{let w=Um(d,l);return w==null?null:{location:{pathname:w,search:u,hash:m,state:p,key:f},navigationType:a}},[l,d,u,m,p,f,a]);return vc(g!=null,` is not able to match the URL "${d}${u}${m}" because it does not start with the basename, so the won't render anything.`),g==null?null:y.createElement(jl.Provider,{value:c},y.createElement(_S.Provider,{children:e,value:g}))}function Gpe({children:t,location:e}){return Epe(MF(t),e)}function MF(t,e=[]){let n=[];return y.Children.forEach(t,(a,i)=>{if(!y.isValidElement(a))return;let s=[...e,i];if(a.type===y.Fragment){n.push.apply(n,MF(a.props.children,s));return}za(a.type===Tr,`[${typeof a.type=="string"?a.type:a.type.name}] is not a component. All component children of must be a or `),za(!a.props.index||!a.props.children,"An index route cannot have child routes.");let o={id:a.props.id||s.join("-"),caseSensitive:a.props.caseSensitive,element:a.props.element,Component:a.props.Component,index:a.props.index,path:a.props.path,middleware:a.props.middleware,loader:a.props.loader,action:a.props.action,hydrateFallbackElement:a.props.hydrateFallbackElement,HydrateFallback:a.props.HydrateFallback,errorElement:a.props.errorElement,ErrorBoundary:a.props.ErrorBoundary,hasErrorBoundary:a.props.hasErrorBoundary===!0||a.props.ErrorBoundary!=null||a.props.errorElement!=null,shouldRevalidate:a.props.shouldRevalidate,handle:a.props.handle,lazy:a.props.lazy};a.props.children&&(o.children=MF(a.props.children,s)),n.push(o)}),n}var gN="get",bN="application/x-www-form-urlencoded";function vT(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Wpe(t){return vT(t)&&t.tagName.toLowerCase()==="button"}function Kpe(t){return vT(t)&&t.tagName.toLowerCase()==="form"}function Ype(t){return vT(t)&&t.tagName.toLowerCase()==="input"}function Xpe(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Qpe(t,e){return t.button===0&&(!e||e==="_self")&&!Xpe(t)}function EF(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,n)=>{let a=t[n];return e.concat(Array.isArray(a)?a.map(i=>[n,i]):[[n,a]])},[]))}function Zpe(t,e){let n=EF(t);return e&&e.forEach((a,i)=>{n.has(i)||e.getAll(i).forEach(s=>{n.append(i,s)})}),n}var kk=null;function Jpe(){if(kk===null)try{new FormData(document.createElement("form"),0),kk=!1}catch{kk=!0}return kk}var ehe=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function iE(t){return t!=null&&!ehe.has(t)?(vc(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${bN}"`),null):t}function the(t,e){let n,a,i,s,o;if(Kpe(t)){let l=t.getAttribute("action");a=l?Um(l,e):null,n=t.getAttribute("method")||gN,i=iE(t.getAttribute("enctype"))||bN,s=new FormData(t)}else if(Wpe(t)||Ype(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a