From 8a6fcf5cbdd60bb789fe7bdb9067138c6f2899af Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 12 May 2026 16:30:28 +0200 Subject: [PATCH] fix(settings): expose UI rendering fields without requiring SETTINGS_READ (#1293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clear Plate button (and 4 other features on the Printers page) read their state from /settings, which requires SETTINGS_READ. Granting that permission also adds the Settings nav item and leaks SMTP/LDAP/MQTT credentials — exactly what users were trying to avoid by giving an operator only printers:clear_plate. New /settings/ui-preferences endpoint returns a curated, opt-in subset of non-sensitive fields. Matches the existing /default-sidebar-order precedent. PrintersPage switched to the new endpoint; admin pages still use /settings for full access. --- CHANGELOG.md | 2 + backend/app/api/routes/settings.py | 38 ++++++ .../test_settings_ui_preferences.py | 119 ++++++++++++++++++ .../src/__tests__/pages/PrintersPage.test.tsx | 20 +++ frontend/src/api/client.ts | 18 +++ frontend/src/pages/PrintersPage.tsx | 8 +- .../{index-DPn2Wuq0.js => index-CcnHDYu5.js} | 4 +- static/index.html | 2 +- 8 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 backend/tests/integration/test_settings_ui_preferences.py rename static/assets/{index-DPn2Wuq0.js => index-CcnHDYu5.js} (93%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d146116..f183b7596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ All notable changes to Bambuddy will be documented in this file. - **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head). ### Fixed +- **Clear Plate button required granting Settings > Read Settings, leaking the entire Settings UI to non-admin users** ([#1293](https://github.com/maziggy/bambuddy/issues/1293), reported by @Tivonfeng) — On the Printers page, the "Clear Plate" button is gated on the global `require_plate_clear` setting being `true`. The page reads that value from `GET /api/v1/settings`, which requires `Permission.SETTINGS_READ`. A user with `printers:clear_plate` but no `settings:read` got a 403 on the settings fetch, the frontend's `settings` query stayed undefined, `requirePlateClear` evaluated to `false`, and the button never rendered. The reporter's workaround — also grant `settings:read` — works but also adds the Settings nav item to the sidebar and grants visibility of SMTP/LDAP/MQTT credentials and every other setting in the DB, which is exactly the leak they were trying to avoid. **Fix:** new `GET /api/v1/settings/ui-preferences` endpoint that returns a curated dict of UI rendering fields without requiring SETTINGS_READ — matches the existing `GET /settings/default-sidebar-order` precedent (intentionally unauthenticated for the same reason — UI rendering needs values that aren't admin-gated). Exposed fields are explicitly opt-in via a `_UI_PREFERENCE_FIELDS` tuple in `routes/settings.py`: `require_plate_clear`, `check_printer_firmware`, `camera_view_mode`, `time_format`, `date_format`, `drying_presets`, `ams_humidity_good`, `ams_humidity_fair`, `ams_temp_good`, `ams_temp_fair`, `bed_cooled_threshold`. Anything not on that list — including every sensitive field — is never returned, no matter what's in the DB. PrintersPage now fetches from `/settings/ui-preferences` via a new `api.getUiPreferences()` client method; the cache key changed from `['settings']` to `['ui-preferences']` so it doesn't collide with the admin-gated full settings query other admin pages still use. As a side-effect, the page's 4 other settings-driven UI features (drying presets, camera view mode, time format display, firmware-check banner) also stop silently degrading for non-admin users — they all live on the same fetch. Regression tests in `backend/tests/integration/test_settings_ui_preferences.py` pin: endpoint returns 200 without SETTINGS_READ, response includes `require_plate_clear` as a bool, field set exactly matches `_UI_PREFERENCE_FIELDS` (so accidentally adding a sensitive field there fails the test), and a "secret canary" test that seeds 23 sensitive keys with recognizable values and asserts none of them appear in either the response keys or the response body. Frontend types in `client.ts` tighten `camera_view_mode` and `time_format` to the same literal unions as `AppSettings` so the new endpoint slots into PrinterCard's prop types without casts. + - **LDAP user logins wiped manually-assigned BamBuddy groups** ([#1292](https://github.com/maziggy/bambuddy/issues/1292), reported by @Fuechslein) — When an admin assigned an LDAP-authenticated user a BamBuddy group that wasn't mapped from LDAP (e.g. "Administrators" while the LDAP mapping only covered "Users"), the assignment vanished on the user's next login. The reporter's observation matched the code exactly: assigning a group while the user was logged in held until the next login because `user.groups` was just mutated in memory; on next login, `_sync_ldap_user` in `backend/app/api/routes/auth.py:1187` rebuilt `user.groups` from LDAP state alone and blew away the manual assignment. The design intent (LDAP truth must propagate, including revocation) was correct, but the implementation was over-broad — every BamBuddy group got wiped, not just LDAP-mapped ones. **Fix:** `_sync_ldap_user` now computes the set of "LDAP-managed" BamBuddy group names = values of `ldap_group_mapping` ∪ `{ldap_default_group}`. Groups inside that set are still rebuilt from LDAP truth on each login (so revocation works). Groups outside that set are treated as manual admin assignments and preserved. The partition happens via a list comprehension over `user.groups`; no schema or DDL change. Edge case explicitly tested: a manual assignment to a group that IS in the LDAP mapping is still overridden by LDAP state — once an assignment is in the user_groups table you can't tell manual-but-mapped from LDAP-derived, so LDAP wins for any group it has authority over. Regression tests in `backend/tests/integration/test_ldap_group_sync.py` cover: manual group survives login (the reporter's exact scenario), revocation still propagates for LDAP-managed groups, default_group persists across empty-LDAP logins, manual assignment to a managed group is overridden, and the realistic mixed case where a user has multiple manual + multiple LDAP groups at once. - **Internal inventory: `storage_location` field was silently dropped on save and never shown in the table** ([#1291](https://github.com/maziggy/bambuddy/issues/1291), reported by @needo37) — The `storage_location` column existed on the Spool ORM model (`backend/app/models/spool.py:57`) but was missing from the Pydantic schemas in `backend/app/schemas/spool.py` (`SpoolBase`, `SpoolUpdate`, and by extension `SpoolResponse`). Pydantic silently strips unknown fields, so PATCH writes to `/inventory/spools/{id}` reached the update route's `model_dump(exclude_unset=True)` already missing the field, the `setattr` loop never touched the DB column, and GET responses left it out — the inventory table always showed "—" in the Storage Location column even when the user had typed and saved a value. Only the internal inventory was affected; Spoolman mode worked because it goes through a separate proxy backend with its own schema. Fix is two added fields in `schemas/spool.py`: one on `SpoolBase` (covers `SpoolCreate` + `SpoolResponse` via inheritance) and one on `SpoolUpdate` (standalone). Both constrained to `max_length=255` to match the DB column's `String(255)`. No route changes needed — the update handler at `inventory.py:961` already uses the generic dump-then-setattr pattern that picks up any new schema field automatically. Note on UX intent: `storage_location` is the user-defined free-text label ("Drybox #1", "Top shelf"), distinct from `location` which is the AMS slot assignment ("AMS-A slot 3") — keeping both is the right call. Regression tests in `test_spool_schemas_storage_location.py` lock in: create/update accept the field, the response surfaces it, explicit-null clears via `exclude_unset` round-trip, omitted-on-PATCH is left untouched (doesn't accidentally clear), and `max_length=255` is enforced (so the API returns a clean 422 instead of a SQLAlchemy column-length error). diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 6bf856d0a..cdde7d51a 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -261,6 +261,44 @@ async def get_default_sidebar_order( return {"default_sidebar_order": value or ""} +# Fields exposed via /ui-preferences without SETTINGS_READ. Each entry MUST be +# non-sensitive (no credentials, no PII, no secret tokens) — granting SETTINGS_READ +# also grants visibility of SMTP/LDAP/MQTT passwords and similar, so the goal of +# this endpoint is exactly to NOT require that permission for UI rendering hints. +# When adding a field here, confirm it doesn't carry anything sensitive. +_UI_PREFERENCE_FIELDS: tuple[str, ...] = ( + "require_plate_clear", + "check_printer_firmware", + "camera_view_mode", + "time_format", + "date_format", + "drying_presets", + "ams_humidity_good", + "ams_humidity_fair", + "ams_temp_good", + "ams_temp_fair", + "bed_cooled_threshold", +) + + +@router.get("/ui-preferences") +async def get_ui_preferences(db: AsyncSession = Depends(get_db)): + """Get the curated subset of settings that any page needs to render correctly. + + Intentionally not gated on SETTINGS_READ — every authenticated user (and + every page that loads for them) needs these fields, but granting SETTINGS_READ + would also grant visibility of secrets (SMTP/LDAP/MQTT credentials, etc.). + Same pattern as /default-sidebar-order (#1293). + + Reuses _build_settings_response so the typed values match what /settings + returns for fields with the same name — bool/int/float/str types stay in + sync without a separate type-coercion path. + """ + full = await _build_settings_response(db, is_api_key=False) + dumped = full.model_dump() + return {key: dumped[key] for key in _UI_PREFERENCE_FIELDS if key in dumped} + + @router.get("/check-ffmpeg") async def check_ffmpeg(): """Check if ffmpeg is installed and available.""" diff --git a/backend/tests/integration/test_settings_ui_preferences.py b/backend/tests/integration/test_settings_ui_preferences.py new file mode 100644 index 000000000..769c87a17 --- /dev/null +++ b/backend/tests/integration/test_settings_ui_preferences.py @@ -0,0 +1,119 @@ +"""Tests for the public /settings/ui-preferences endpoint (#1293). + +Reporter @Tivonfeng: granting `printers:clear_plate` alone wasn't enough to +make the Clear Plate button work — the frontend also needs `require_plate_clear` +from /settings, which requires SETTINGS_READ (and also surfaces SMTP/LDAP/MQTT +secrets). The fix is a public subset endpoint that returns only UI rendering +fields, so the frontend doesn't have to demand SETTINGS_READ for non-admin UX. + +The two guarantees pinned here: +1. The endpoint is accessible without SETTINGS_READ. +2. The endpoint NEVER returns sensitive fields (SMTP/LDAP/MQTT credentials, + API tokens, HA bearer token, etc.) — even if a future commit accidentally + adds one of those keys to _UI_PREFERENCE_FIELDS, this test fails loudly. +""" + +import pytest +from httpx import AsyncClient + +# Anything in this list MUST NOT appear in the /ui-preferences response. +# Mirror of _SENSITIVE_FIELDS_FOR_API_KEY in backend/app/api/routes/settings.py +# plus a wider net for any *_password / *_token / *_key suffix. +_SENSITIVE_KEYS = { + "smtp_password", + "smtp_username", + "smtp_from_email", + "smtp_host", + "smtp_port", + "mqtt_password", + "mqtt_username", + "mqtt_broker", + "ha_token", + "ha_url", + "prometheus_token", + "virtual_printer_access_code", + "ldap_bind_password", + "ldap_bind_dn", + "ldap_server_url", + "external_url", + "bambu_studio_api_url", + "orcaslicer_api_url", + "local_backup_path", + "github_token", + "gitea_token", + "obico_api_key", + "obico_endpoint_url", +} + + +class TestUiPreferencesEndpoint: + """The new public endpoint must work without SETTINGS_READ and must + never return sensitive fields.""" + + @pytest.mark.asyncio + async def test_endpoint_returns_200_without_auth(self, async_client: AsyncClient): + """No SETTINGS_READ required — that's the whole point of the endpoint.""" + response = await async_client.get("/api/v1/settings/ui-preferences") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + + @pytest.mark.asyncio + async def test_returns_require_plate_clear(self, async_client: AsyncClient): + """The field that drove #1293: PrintersPage gates the Clear Plate button + on this. Must be present in the response.""" + response = await async_client.get("/api/v1/settings/ui-preferences") + assert response.status_code == 200 + data = response.json() + assert "require_plate_clear" in data + # Type must be bool (frontend does === true checks) + assert isinstance(data["require_plate_clear"], bool) + + @pytest.mark.asyncio + async def test_returns_expected_field_set(self, async_client: AsyncClient): + """Pin the exact set of fields the endpoint exposes — adding a sensitive + field to _UI_PREFERENCE_FIELDS by accident should fail this assert and + force the author to reconsider.""" + response = await async_client.get("/api/v1/settings/ui-preferences") + data = response.json() + expected = { + "require_plate_clear", + "check_printer_firmware", + "camera_view_mode", + "time_format", + "date_format", + "drying_presets", + "ams_humidity_good", + "ams_humidity_fair", + "ams_temp_good", + "ams_temp_fair", + "bed_cooled_threshold", + } + assert set(data.keys()) == expected + + @pytest.mark.asyncio + async def test_response_excludes_sensitive_fields(self, async_client: AsyncClient, db_session): + """Even with sensitive fields seeded in the DB, none of them must + appear in the response — the endpoint is opt-in, not opt-out.""" + from backend.app.models.settings import Settings + + # Seed every sensitive field with a unique recognizable value so a leak + # would be obvious in failure output. + for i, key in enumerate(_SENSITIVE_KEYS): + db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK")) + await db_session.commit() + + response = await async_client.get("/api/v1/settings/ui-preferences") + assert response.status_code == 200 + data = response.json() + + # No sensitive key should appear in the response keys + leaked_keys = _SENSITIVE_KEYS & set(data.keys()) + assert leaked_keys == set(), f"Leaked sensitive fields: {leaked_keys}" + + # And the recognizable values shouldn't appear in any value either + response_text = response.text + for i in range(len(_SENSITIVE_KEYS)): + assert f"SECRET_VALUE_{i}_DO_NOT_LEAK" not in response_text, ( + f"Sensitive value index {i} leaked into response body" + ) diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index 71a8af3fe..254339674 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -96,6 +96,17 @@ describe('PrintersPage', () => { require_plate_clear: true, }); }), + // PrintersPage now reads UI rendering fields from the public ui-preferences + // endpoint instead of /settings (#1293) — admin pages still hit /settings. + http.get('/api/v1/settings/ui-preferences', () => { + return HttpResponse.json({ + ams_humidity_good: 40, + ams_humidity_fair: 60, + ams_temp_good: 30, + ams_temp_fair: 35, + require_plate_clear: true, + }); + }), http.get('/api/v1/queue/', () => { return HttpResponse.json([]); }) @@ -360,6 +371,15 @@ describe('PrintersPage', () => { require_plate_clear: false, }); }), + http.get('/api/v1/settings/ui-preferences', () => { + return HttpResponse.json({ + ams_humidity_good: 40, + ams_humidity_fair: 60, + ams_temp_good: 30, + ams_temp_fair: 35, + require_plate_clear: false, + }); + }), http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true }); }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cf6d56992..148b0eac4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3990,6 +3990,24 @@ export const api = { // Settings getSettings: () => request('/settings/'), getDefaultSidebarOrder: () => request<{ default_sidebar_order: string }>('/settings/default-sidebar-order'), + // Public subset of settings for UI rendering — no settings:read required. + // Used by pages whose users may not have SETTINGS_READ (e.g. operators with + // only printers:clear_plate). Keep in sync with _UI_PREFERENCE_FIELDS in + // backend/app/api/routes/settings.py. + getUiPreferences: () => + request<{ + require_plate_clear?: boolean; + check_printer_firmware?: boolean; + camera_view_mode?: 'window' | 'embedded'; + time_format?: 'system' | '12h' | '24h'; + date_format?: string; + drying_presets?: string; + ams_humidity_good?: number; + ams_humidity_fair?: number; + ams_temp_good?: number; + ams_temp_fair?: number; + bed_cooled_threshold?: number; + }>('/settings/ui-preferences'), updateSettings: (data: AppSettingsUpdate) => request('/settings/', { method: 'PUT', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index f637ec712..1afbc0e46 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -6455,10 +6455,12 @@ export function PrintersPage() { queryFn: api.getPrinters, }); - // Fetch app settings for AMS thresholds + // Fetch the UI-rendering subset of settings. Uses /ui-preferences (not /settings) + // so users with printers:read but no settings:read still get the values needed + // to render the clear-plate button, drying presets, AMS thresholds, etc. (#1293). const { data: settings } = useQuery({ - queryKey: ['settings'], - queryFn: api.getSettings, + queryKey: ['ui-preferences'], + queryFn: api.getUiPreferences, }); // Compute drying presets: user-configured (from settings) merged over built-in defaults diff --git a/static/assets/index-DPn2Wuq0.js b/static/assets/index-CcnHDYu5.js similarity index 93% rename from static/assets/index-DPn2Wuq0.js rename to static/assets/index-CcnHDYu5.js index fa6a2ae80..055bd645b 100644 --- a/static/assets/index-DPn2Wuq0.js +++ b/static/assets/index-CcnHDYu5.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:"您沒有修改預測設定的權限。"}},Vme={en:{translation:Ime},de:{translation:Ome},fr:{translation:zme},ja:{translation:Ume},it:{translation:Bme},"pt-BR":{translation:Hme},"zh-CN":{translation:qme},"zh-TW":{translation:$me}};Eo.use(BQ).use(fme).init({resources:Vme,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 qH=[{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 $H="popstate";function Gme(t={}){function e(a,i){let{pathname:s,search:o,hash:l}=a.location;return TF("",{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:Nw(i)}return Kme(e,n,null,t)}function Ua(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function kc(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function Wme(){return Math.random().toString(36).substring(2,10)}function VH(t,e){return{usr:t.state,key:t.key,idx:e}}function TF(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||Wme()}}function Nw({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 Kme(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 g=u(),x=g==null?null:g-d;d=g,c&&c({action:l,location:w.location,delta:x})}function p(g,x){l="PUSH";let _=TF(w.location,g,x);d=u()+1;let N=VH(_,d),C=w.createHref(_);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(g,x){l="REPLACE";let _=TF(w.location,g,x);d=u();let N=VH(_,d),C=w.createHref(_);o.replaceState(N,"",C),s&&c&&c({action:l,location:w.location,delta:0})}function b(g){return Yme(g)}let w={get action(){return l},get location(){return t(i,o)},listen(g){if(c)throw new Error("A history only accepts one active listener");return i.addEventListener($H,m),c=g,()=>{i.removeEventListener($H,m),c=null}},createHref(g){return e(i,g)},createURL:b,encodeLocation(g){let x=b(g);return{pathname:x.pathname,search:x.search,hash:x.hash}},push:p,replace:f,go(g){return o.go(g)}};return w}function Yme(t,e=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),Ua(n,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:Nw(t);return a=a.replace(/ $/,"%20"),!e&&a.startsWith("//")&&(a=n+a),new URL(a,n)}function HQ(t,e,n="/"){return Xme(t,e,n,!1)}function Xme(t,e,n,a){let i=typeof e=="string"?Hy(e):e,s=Bm(i.pathname||"/",n);if(s==null)return null;let o=qQ(t);Qme(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;Ua(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=Mm([a,u.relativePath]),p=n.concat(u);o.children&&o.children.length>0&&(Ua(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),qQ(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:ape(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of $Q(o.path))s(o,l,!0,c)}),e}function $Q(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=$Q(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 Qme(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:ipe(e.routesMeta.map(a=>a.childrenIndex),n.routesMeta.map(a=>a.childrenIndex)))}var Zme=/^:[\w-]+$/,Jme=3,epe=2,tpe=1,npe=10,rpe=-2,GH=t=>t==="*";function ape(t,e){let n=t.split("/"),a=n.length;return n.some(GH)&&(a+=rpe),e&&(a+=epe),n.filter(i=>!GH(i)).reduce((i,s)=>i+(Zme.test(s)?Jme:s===""?tpe:npe),a)}function ipe(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 spe(t,e,n=!1){let{routesMeta:a}=t,i={},s="/",o=[];for(let l=0;l{if(u==="*"){let b=l[p]||"";o=s.slice(0,s.length-b.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 ope(t,e=!1,n=!0){kc(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 lpe(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return kc(!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 Bm(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 cpe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function dpe(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=WH(n.substring(1),"/"):s=WH(n,e)):s=e,{pathname:s,search:ppe(a),hash:hpe(i)}}function WH(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 tE(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 upe(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function JI(t){let e=upe(t);return e.map((n,a)=>a===e.length-1?n.pathname:n.pathnameBase)}function eO(t,e,n,a=!1){let i;typeof t=="string"?i=Hy(t):(i={...t},Ua(!i.pathname||!i.pathname.includes("?"),tE("?","pathname","search",i)),Ua(!i.pathname||!i.pathname.includes("#"),tE("#","pathname","hash",i)),Ua(!i.search||!i.search.includes("#"),tE("#","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=dpe(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var Mm=t=>t.join("/").replace(/\/\/+/g,"/"),mpe=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),ppe=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,hpe=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,fpe=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 gpe(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function bpe(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var VQ=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function GQ(t,e){let n=t;if(typeof n!="string"||!cpe.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let a=n,i=!1;if(VQ)try{let s=new URL(window.location.href),o=n.startsWith("//")?new URL(s.protocol+n):new URL(n),l=Bm(o.pathname,e);o.origin===s.origin&&l!=null?n=l+o.search+o.hash:i=!0}catch{kc(!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 WQ=["POST","PUT","PATCH","DELETE"];new Set(WQ);var xpe=["GET",...WQ];new Set(xpe);var qy=v.createContext(null);qy.displayName="DataRouter";var bT=v.createContext(null);bT.displayName="DataRouterState";var ype=v.createContext(!1),KQ=v.createContext({isTransitioning:!1});KQ.displayName="ViewTransition";var vpe=v.createContext(new Map);vpe.displayName="Fetchers";var wpe=v.createContext(null);wpe.displayName="Await";var Il=v.createContext(null);Il.displayName="Navigation";var SS=v.createContext(null);SS.displayName="Location";var Dc=v.createContext({outlet:null,matches:[],isDataRoute:!1});Dc.displayName="Route";var tO=v.createContext(null);tO.displayName="RouteError";var YQ="REACT_ROUTER_ERROR",Spe="REDIRECT",_pe="ROUTE_ERROR_RESPONSE";function kpe(t){if(t.startsWith(`${YQ}:${Spe}:{`))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 Npe(t){if(t.startsWith(`${YQ}:${_pe}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new fpe(e.status,e.statusText,e.data)}catch{}}function Cpe(t,{relative:e}={}){Ua($y(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:a}=v.useContext(Il),{hash:i,pathname:s,search:o}=kS(t,{relative:e}),l=s;return n!=="/"&&(l=s==="/"?n:Mm([n,s])),a.createHref({pathname:l,search:o,hash:i})}function $y(){return v.useContext(SS)!=null}function pd(){return Ua($y(),"useLocation() may be used only in the context of a component."),v.useContext(SS).location}var XQ="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function QQ(t){v.useContext(Il).static||v.useLayoutEffect(t)}function Di(){let{isDataRoute:t}=v.useContext(Dc);return t?Upe():Ppe()}function Ppe(){Ua($y(),"useNavigate() may be used only in the context of a component.");let t=v.useContext(qy),{basename:e,navigator:n}=v.useContext(Il),{matches:a}=v.useContext(Dc),{pathname:i}=pd(),s=JSON.stringify(JI(a)),o=v.useRef(!1);return QQ(()=>{o.current=!0}),v.useCallback((c,d={})=>{if(kc(o.current,XQ),!o.current)return;if(typeof c=="number"){n.go(c);return}let u=eO(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:Mm([e,u.pathname])),(d.replace?n.replace:n.push)(u,d.state,d)},[e,n,s,i,t])}var ZQ=v.createContext(null);function Vy(){return v.useContext(ZQ)}function Tpe(t){let e=v.useContext(Dc).outlet;return v.useMemo(()=>e&&v.createElement(ZQ.Provider,{value:t},e),[e,t])}function _S(){let{matches:t}=v.useContext(Dc),e=t[t.length-1];return e?e.params:{}}function kS(t,{relative:e}={}){let{matches:n}=v.useContext(Dc),{pathname:a}=pd(),i=JSON.stringify(JI(n));return v.useMemo(()=>eO(t,JSON.parse(i),a,e==="path"),[t,i,a,e])}function Ape(t,e){return JQ(t,e)}function JQ(t,e,n,a,i){Ua($y(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=v.useContext(Il),{matches:o}=v.useContext(Dc),l=o[o.length-1],c=l?l.params:{},d=l?l.pathname:"/",u=l?l.pathnameBase:"/",m=l&&l.route;{let _=m&&m.path||"";tZ(d,!m||_.endsWith("*")||_.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=pd(),f;if(e){let _=typeof e=="string"?Hy(e):e;Ua(u==="/"||_.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 "${_.pathname}" was given in the \`location\` prop.`),f=_}else f=p;let b=f.pathname||"/",w=b;if(u!=="/"){let _=u.replace(/^\//,"").split("/");w="/"+b.replace(/^\//,"").split("/").slice(_.length).join("/")}let g=HQ(t,{pathname:w});kc(m||g!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),kc(g==null||g[g.length-1].route.element!==void 0||g[g.length-1].route.Component!==void 0||g[g.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=Fpe(g&&g.map(_=>Object.assign({},_,{params:Object.assign({},c,_.params),pathname:Mm([u,s.encodeLocation?s.encodeLocation(_.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathname]),pathnameBase:_.pathnameBase==="/"?u:Mm([u,s.encodeLocation?s.encodeLocation(_.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathnameBase])})),o,n,a,i);return e&&x?v.createElement(SS.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},x):x}function jpe(){let t=zpe(),e=gpe(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=v.createElement(v.Fragment,null,v.createElement("p",null,"💿 Hey developer 👋"),v.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",v.createElement("code",{style:s},"ErrorBoundary")," or"," ",v.createElement("code",{style:s},"errorElement")," prop on your route.")),v.createElement(v.Fragment,null,v.createElement("h2",null,"Unexpected Application Error!"),v.createElement("h3",{style:{fontStyle:"italic"}},e),n?v.createElement("pre",{style:i},n):null,o)}var Mpe=v.createElement(jpe,null),eZ=class extends v.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=Npe(t.digest);n&&(t=n)}let e=t!==void 0?v.createElement(Dc.Provider,{value:this.props.routeContext},v.createElement(tO.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?v.createElement(Epe,{error:t},e):e}};eZ.contextType=ype;var nE=new WeakMap;function Epe({children:t,error:e}){let{basename:n}=v.useContext(Il);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let a=kpe(e.digest);if(a){let i=nE.get(e);if(i)throw i;let s=GQ(a.location,n);if(VQ&&!nE.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 nE.set(e,o),o}return v.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function Dpe({routeContext:t,match:e,children:n}){let a=v.useContext(qy);return a&&a.static&&a.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(a.staticContext._deepestRenderedBoundaryId=e.route.id),v.createElement(Dc.Provider,{value:t},n)}function Fpe(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);Ua(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:bpe(n.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,b=!1,w=null,g=null;n&&(f=o&&m.route.id?o[m.route.id]:void 0,w=m.route.errorElement||Mpe,l&&(c<0&&p===0?(tZ("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),b=!0,g=null):c===p&&(b=!0,g=m.route.hydrateFallbackElement||null)));let x=e.concat(s.slice(0,p+1)),_=()=>{let N;return f?N=w:b?N=g:m.route.Component?N=v.createElement(m.route.Component,null):m.route.element?N=m.route.element:N=u,v.createElement(Dpe,{match:m,routeContext:{outlet:u,matches:x,isDataRoute:n!=null},children:N})};return n&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?v.createElement(eZ,{location:n.location,revalidation:n.revalidation,component:w,error:f,children:_(),routeContext:{outlet:null,matches:x,isDataRoute:!0},onError:d}):_()},null)}function nO(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Rpe(t){let e=v.useContext(qy);return Ua(e,nO(t)),e}function Lpe(t){let e=v.useContext(bT);return Ua(e,nO(t)),e}function Ipe(t){let e=v.useContext(Dc);return Ua(e,nO(t)),e}function rO(t){let e=Ipe(t),n=e.matches[e.matches.length-1];return Ua(n.route.id,`${t} can only be used on routes that contain a unique "id"`),n.route.id}function Ope(){return rO("useRouteId")}function zpe(){let t=v.useContext(tO),e=Lpe("useRouteError"),n=rO("useRouteError");return t!==void 0?t:e.errors?.[n]}function Upe(){let{router:t}=Rpe("useNavigate"),e=rO("useNavigate"),n=v.useRef(!1);return QQ(()=>{n.current=!0}),v.useCallback(async(i,s={})=>{kc(n.current,XQ),n.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var KH={};function tZ(t,e,n){!e&&!KH[t]&&(KH[t]=!0,kc(!1,n))}v.memo(Bpe);function Bpe({routes:t,future:e,state:n,onError:a}){return JQ(t,void 0,n,a,e)}function gg({to:t,replace:e,state:n,relative:a}){Ua($y()," may be used only in the context of a component.");let{static:i}=v.useContext(Il);kc(!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}=v.useContext(Dc),{pathname:o}=pd(),l=Di(),c=eO(t,JI(s),o,a==="path"),d=JSON.stringify(c);return v.useEffect(()=>{l(JSON.parse(d),{replace:e,state:n,relative:a})},[l,d,a,e,n]),null}function nZ(t){return Tpe(t.context)}function Cr(t){Ua(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Hpe({basename:t="/",children:e=null,location:n,navigationType:a="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){Ua(!$y(),"You cannot render a inside another . You should never have more than one in your app.");let l=t.replace(/^\/*/,"/"),c=v.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,b=v.useMemo(()=>{let w=Bm(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 kc(b!=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.`),b==null?null:v.createElement(Il.Provider,{value:c},v.createElement(SS.Provider,{children:e,value:b}))}function qpe({children:t,location:e}){return Ape(AF(t),e)}function AF(t,e=[]){let n=[];return v.Children.forEach(t,(a,i)=>{if(!v.isValidElement(a))return;let s=[...e,i];if(a.type===v.Fragment){n.push.apply(n,AF(a.props.children,s));return}Ua(a.type===Cr,`[${typeof a.type=="string"?a.type:a.type.name}] is not a component. All component children of must be a or `),Ua(!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=AF(a.props.children,s)),n.push(o)}),n}var hN="get",fN="application/x-www-form-urlencoded";function xT(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function $pe(t){return xT(t)&&t.tagName.toLowerCase()==="button"}function Vpe(t){return xT(t)&&t.tagName.toLowerCase()==="form"}function Gpe(t){return xT(t)&&t.tagName.toLowerCase()==="input"}function Wpe(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Kpe(t,e){return t.button===0&&(!e||e==="_self")&&!Wpe(t)}function jF(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 Ype(t,e){let n=jF(t);return e&&e.forEach((a,i)=>{n.has(i)||e.getAll(i).forEach(s=>{n.append(i,s)})}),n}var _k=null;function Xpe(){if(_k===null)try{new FormData(document.createElement("form"),0),_k=!1}catch{_k=!0}return _k}var Qpe=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function rE(t){return t!=null&&!Qpe.has(t)?(kc(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${fN}"`),null):t}function Zpe(t,e){let n,a,i,s,o;if(Vpe(t)){let l=t.getAttribute("action");a=l?Bm(l,e):null,n=t.getAttribute("method")||hN,i=rE(t.getAttribute("enctype"))||fN,s=new FormData(t)}else if($pe(t)||Gpe(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a