From 804fe470fa3758587d032a39c0dacc9a3a0b8fd6 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 16 Jun 2026 07:44:22 +0200 Subject: [PATCH] fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag. Default Operators group is seeded with *_own only (and the migration map flips legacy → _own on existing groups), so non-admin users never held the legacy permission and the sidebar hid Archives / Queue / Files even though the underlying API accepted their requests. Reporter only spotted Files; same bug shape applied to Archives and Queue. Fix: navPermissions accepts Permission | Permission[]; the three affected resources list all three tiers. isHidden checks .some(hasPermission) for arrays. Permission type extended with the matching *_own / *_all variants — backend already shipped them, the TS type just didn't declare them. --- CHANGELOG.md | 2 + .../src/__tests__/components/Layout.test.tsx | 87 +++++++++++++++++++ frontend/src/api/client.ts | 6 +- frontend/src/components/Layout.tsx | 23 +++-- .../{index-DiPrOrl_.js => index-BMUh7cW4.js} | 2 +- static/index.html | 2 +- 6 files changed, 111 insertions(+), 11 deletions(-) rename static/assets/{index-DiPrOrl_.js => index-BMUh7cW4.js} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b74efe03c..b1d7c9754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ All notable changes to Bambuddy will be documented in this file. - **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `/vp_wire/_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. ### Fixed +- **Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by @knifesk)** — The reporter noticed the **File Manager** sidebar entry was hidden for a default Operators user even though the same user could load `/files` directly and the backend API accepted their requests. Root cause is broader than reported: `frontend/src/components/Layout.tsx::navPermissions` mapped `files → 'library:read'`, `archives → 'archives:read'`, `queue → 'queue:read'` — the LEGACY permission flags — but the default Operators group at `backend/app/core/permissions.py:368-380` is seeded with the GRANULAR variants only (`ARCHIVES_READ_OWN.value`, `QUEUE_READ_OWN.value`, `LIBRARY_READ_OWN.value`). The migration path at `backend/app/core/database.py:3034-3041` also flips legacy `*:read` → `*:read_own` on existing non-admin groups. So a non-admin user never holds the legacy permission, `hasPermission('library:read')` returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins get `ALL_PERMISSIONS` which includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. **Fix:** `navPermissions` now accepts `Permission | Permission[]` and the three affected resources list all three tiers (`*:read`, `*:read_own`, `*:read_all`). The `isHidden` check switches on the array type — `some(hasPermission)` for arrays, current behavior for single values. Nothing else in the gate logic changed. `frontend/src/api/client.ts` Permission type extended with the missing granular variants (`archives:read_own`, `archives:read_all`, `queue:read_own`, `queue:read_all`, `library:read_own`, `library:read_all`) — these existed in the backend enum and were already being shipped to the frontend in `/auth/me`, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. **What this also fixes downstream:** any future feature that needs to gate UI on `*:read_own` / `*:read_all` can now do so without re-adding the same type entries. **Tests:** 5 new cases in `Layout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'` — Files visible with only `library:read_own`, Files visible with only `library:read_all`, Archives visible with only `archives:read_own`, Queue visible with only `queue:read_own`, and the negative case (`printers:read` only — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean; `npm run build` clean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access. + - **Push notification for "Printer offline" now actually fires (#1752, reported by @saint-hh)** — The notification provider's `on_printer_offline` toggle has shipped since the notifications feature landed: schema field, DB column, `notification_template.py` entry, and the dispatcher `NotificationService.on_printer_offline(printer_id, printer_name, db)` are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware's `gcode_state=FAILED` report on MQTT reconnect. **Why the toggle was orphan:** every other provider event (`on_print_start`, `on_print_complete`, `on_print_progress`, `on_printer_error`, etc.) has a clear call site under `main.py::on_printer_status_change` or alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (`smart_plug_manager` → `printer_manager.mark_printer_offline()` and `bambu_mqtt.py::check_staleness` after the 30s STALE_RECONNECT_COOLDOWN) route through `_on_status_change` already and reach `on_printer_status_change`; the handler just didn't act on the disconnect edge. **Fix:** edge detection in `on_printer_status_change` watches `state.connected` against the previous observation per printer (`_printer_last_connected: dict[int, bool]`). On the True → False transition it schedules `_maybe_notify_printer_offline(printer_id)` as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps `_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0` then re-checks `printer_manager.is_connected(printer_id)` — only fires the notification if the printer is still offline. **Why 60s debounce:** sized against `bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s` — a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. **Edge-case handling:** initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from `_printer_offline_notify_tasks` in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. **No symmetric `on_printer_online` event:** the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reporting `gcode_state=FAILED` for the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy's `bambu_mqtt.py:3039` explicitly suppresses `is_new_print` for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. **Tests:** 9 new cases in `test_printer_offline_notification.py` split across two classes. `TestMaybeNotifyPrinterOffline` pins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears `_printer_offline_notify_tasks[printer_id]` after run. `TestOfflineEdgeDetection` pins the edge logic inside `on_printer_status_change`: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean. - **Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate** — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR — `slice_info.config` inside the file carries N `` blocks (one per plate), each with its own `` and its own `Metadata/plate_N.gcode` payload. Previously the VP queue path only ever extracted the FIRST plate's index via `_extract_plate_id` and created exactly ONE PrintQueueItem with that single `plate_id`; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. **Confirmed against the wire** on the live H2D-1 Proxy VP: `Cube.gcode.3mf` carrying three `` blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of `` blocks inside the file. **Fix:** replaced `_extract_plate_id` (returning `int | None`) with `_extract_plate_ids` (returning `list[int]`). The list contains every `` block's `index` metadata, in order; falls back to `[1]` for files missing `slice_info.config` or with no parseable plates so the single-plate path is preserved. `_add_to_print_queue` now loops over the list — each iteration calls `extract_filament_requirements(file_path, plate_id)` per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position = `MAX(position) + iteration`. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). **What stays the same:** the single archive row per upload (the archive backs the queue items via `archive_id`); the `auto_dispatch=False` / `manual_start=true` posture inherited from the VP config (so multi-plate items still require manual start); the `queue_force_color_match` per-VP toggle (now applies per-plate). **What this also fixed downstream:** the `required_filament_types` / `filament_overrides` JSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. **Tests:** 1 new regression case in `test_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate` — builds a 3-plate 3MF (writes the per-plate `` blocks into `slice_info.config` and the per-plate gcode payloads), runs `_add_to_print_queue`, asserts 3 PrintQueueItems with `plate_id == [1, 2, 3]`, `position == [1, 2, 3]`, shared `archive_id`, all `manual_start=True`. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. **Live-verified** on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive. diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx index 8d23cb52c..f3c1905ec 100644 --- a/frontend/src/__tests__/components/Layout.test.tsx +++ b/frontend/src/__tests__/components/Layout.test.tsx @@ -420,4 +420,91 @@ describe('Layout', () => { }); }); }); + + describe('Sidebar gate accepts granular read tiers (#1755)', () => { + // Default Operators group is seeded with `*:read_own` only — never the + // legacy `*:read`. Previously the sidebar gate checked the legacy alone, + // so Archives / Queue / Files were hidden from every non-admin even + // though the underlying API endpoints accepted their requests. These + // tests pin that the gate accepts ANY of the three tiers (legacy / + // _own / _all) for the three resources that ship granular variants. + const enableAuthWithUser = (permissions: string[]) => { + server.use( + http.get('/api/v1/auth/status', () => + HttpResponse.json({ auth_enabled: true, requires_setup: false }), + ), + http.get('/api/v1/auth/me', () => + HttpResponse.json({ + id: 1, + username: 'tester', + role: 'user', + is_active: true, + is_admin: false, + groups: [{ id: 2, name: 'Operators' }], + permissions, + created_at: '2026-01-01T00:00:00Z', + }), + ), + ); + window.localStorage.setItem('auth_token', 'test-token'); + }; + + const sidebarLink = (href: string) => + document.querySelector(`aside a[href="${href}"]`); + + it('shows Files in the sidebar when the user only has library:read_own', async () => { + enableAuthWithUser(['library:read_own']); + + render(); + + await waitFor(() => { + expect(document.querySelector('aside')).toBeInTheDocument(); + expect(sidebarLink('/files')).toBeInTheDocument(); + }); + }); + + it('shows Files in the sidebar when the user only has library:read_all', async () => { + enableAuthWithUser(['library:read_all']); + + render(); + + await waitFor(() => { + expect(sidebarLink('/files')).toBeInTheDocument(); + }); + }); + + it('shows Archives in the sidebar when the user only has archives:read_own', async () => { + enableAuthWithUser(['archives:read_own']); + + render(); + + await waitFor(() => { + expect(sidebarLink('/archives')).toBeInTheDocument(); + }); + }); + + it('shows Queue in the sidebar when the user only has queue:read_own', async () => { + enableAuthWithUser(['queue:read_own']); + + render(); + + await waitFor(() => { + expect(sidebarLink('/queue')).toBeInTheDocument(); + }); + }); + + it('still hides Files when the user has none of the three read tiers', async () => { + enableAuthWithUser(['printers:read']); + + render(); + + await waitFor(() => { + expect(document.querySelector('aside')).toBeInTheDocument(); + }); + + expect(sidebarLink('/files')).toBeNull(); + expect(sidebarLink('/archives')).toBeNull(); + expect(sidebarLink('/queue')).toBeNull(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 89f6e1bd5..cdaa43e3f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2898,13 +2898,13 @@ export interface ExternalLinkUpdate { // Permission type - all available permissions export type Permission = | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate' - | 'archives:read' | 'archives:create' + | 'archives:read' | 'archives:read_own' | 'archives:read_all' | 'archives:create' | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all' | 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge' - | 'queue:read' | 'queue:create' + | 'queue:read' | 'queue:read_own' | 'queue:read_all' | 'queue:create' | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all' | 'queue:reorder' - | 'library:read' | 'library:upload' + | 'library:read' | 'library:read_own' | 'library:read_all' | 'library:upload' | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all' | 'library:purge' | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete' diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bde7a53c5..0a8be0dbc 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -279,23 +279,34 @@ export function Layout() { const result: string[] = []; const seen = new Set(); - // Map nav item IDs to the permission required to see them - const navPermissions: Record = { - archives: 'archives:read', - queue: 'queue:read', + // Map nav item IDs to the permission(s) required to see them. Resources + // that ship in three tiers (legacy `*:read` + granular `*:read_own` / + // `*:read_all`) list all three: the default Operators group is seeded + // with `_own` only, so gating on the legacy alone hides the entry from + // every non-admin user even though the underlying API accepts their + // request (#1755). + const navPermissions: Record = { + archives: ['archives:read', 'archives:read_own', 'archives:read_all'], + queue: ['queue:read', 'queue:read_own', 'queue:read_all'], stats: 'stats:read', profiles: 'kprofiles:read', maintenance: 'maintenance:read', projects: 'projects:read', inventory: 'inventory:read', - files: 'library:read', + files: ['library:read', 'library:read_own', 'library:read_all'], makerworld: 'makerworld:view', settings: 'settings:read', notifications: 'notifications:user_email', }; const isHidden = (id: string) => { - if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true; + if (authEnabled && id in navPermissions) { + const required = navPermissions[id]; + const granted = Array.isArray(required) + ? required.some((p) => hasPermission(p)) + : hasPermission(required); + if (!granted) return true; + } // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true; return false; diff --git a/static/assets/index-DiPrOrl_.js b/static/assets/index-BMUh7cW4.js similarity index 96% rename from static/assets/index-DiPrOrl_.js rename to static/assets/index-BMUh7cW4.js index 91043bf8b..9c0c908a1 100644 --- a/static/assets/index-DiPrOrl_.js +++ b/static/assets/index-BMUh7cW4.js @@ -41,7 +41,7 @@ mW→W için 0.001, kW→W için 1000 çarpanı kullanın.`,mqttEnergyHint:`JSON Wh→kWh için 0.001, MWh→kWh için 1000 çarpanı kullanın.`,mqttStateHint:`JSON yolu JSON yükten değer çıkarır. Ham değerler için boş bırakın. ON değeri: "ON" anlamına gelen tam string. Otomatik algılama için boş bırakın (ON, true, 1).`,restControl:"Kontrol",restOnUrl:"AÇ URL'si",restOffUrl:"KAPAT URL'si",restOnBody:"AÇ İstek Gövdesi",restOffBody:"KAPAT İstek Gövdesi",restMethod:"HTTP Yöntemi",restHeaders:"Özel Başlıklar (JSON)",restStatusUrl:"Durum URL'si",restStatusPath:"Durum JSON Yolu",restStatusOnValue:"ON Değeri",restPowerUrl:"Güç URL'si",restPowerPath:"Güç JSON Yolu",restPowerMultiplier:"Güç Çarpanı",restEnergyUrl:"Enerji URL'si",restEnergyPath:"Enerji JSON Yolu",restEnergyMultiplier:"Enerji Çarpanı",restUrlRequired:"REST prizleri için en az bir URL (ON veya OFF) gerekli",restHeadersHint:'örn. {"Authorization": "Bearer your-token"}',restBodyHint:'örn. ON, {"state": "on"}',restStatusHint:"Mevcut durumu sorgulamak için URL",restPathHint:"örn. state veya data.power.status",restPowerUrlHint:"Güç verisi için ayrı URL (boşsa Durum URL'sini kullanır)",restEnergyUrlHint:"Enerji verisi için ayrı URL (boşsa Durum URL'sini kullanır)",restEnergyHint:"Her değer kendi URL'sini kullanabilir veya Durum URL'sine geri dönebilir. Birim dönüşümü için çarpanları kullanın (örn. Wh'yi kWh'ye dönüştürmek için 0.001).",testConnection:"Bağlantıyı Test Et",connectionSuccess:"Bağlantı başarılı",noSwitchesInSwitchbar:"Anahtar çubuğunda anahtar yok",enableSwitchbarHint:`Ayarlar > Akıllı Prizler'de "Anahtar Çubuğunda Göster"i etkinleştirin`},notifications:{providerTypes:{callmebot:"CallMeBot/WhatsApp",ntfy:"ntfy",pushover:"Pushover",telegram:"Telegram",email:"E-posta",discord:"Discord",webhook:"Webhook",homeassistant:"Home Assistant"},providerDescriptions:{email:"SMTP e-posta bildirimleri",telegram:"Telegram botu üzerinden bildirimler",discord:"Webhook üzerinden Discord kanalına gönder",ntfy:"Ücretsiz, kendi barındırılabilir push bildirimleri",pushover:"Basit, güvenilir push bildirimleri",callmebot:"CallMeBot üzerinden ücretsiz WhatsApp bildirimleri",webhook:"Herhangi bir URL'ye genel HTTP POST",homeassistant:"Home Assistant gösterge panelinde kalıcı bildirimler"},lastSuccess:"Son: {{date}}",error:"Hata",printer:"Yazıcı:",allPrinters:"Tüm yazıcılar",sendTestNotification:"Test Bildirimi Gönder",eventSettings:"Olay Ayarları",enabled:"Etkin",sendFromProvider:"Bu sağlayıcıdan bildirim gönder",printEvents:"Baskı Olayları",printerStatus:"Yazıcı Durumu",amsAlarms:"AMS Alarmları",amsHtAlarms:"AMS-HT Alarmları",printQueue:"Baskı Kuyruğu",start:"Başlangıç",plateCheck:"Plaka Kontrolü",complete:"Tamamlandı",failed:"Başarısız",stopped:"Durduruldu",progress:"İlerleme",offline:"Çevrimdışı",lowFilament:"Az Filament",maintenance:"Bakım",amsHumidity:"AMS Nemi",amsTemp:"AMS Sıcaklığı",amsHtHumidity:"AMS-HT Nemi",amsHtTemp:"AMS-HT Sıcaklığı",bedCooled:"Tabla Soğudu",firstLayer:"İlk Katman",quiet:"Sessiz",digest:"Özet {{time}}",printStarted:"Baskı Başladı",plateNotEmpty:"Plaka Boş Değil",plateNotEmptyDescription:"Baskıdan önce nesneler algılandı",printCompleted:"Baskı Tamamlandı",bedCooledLabel:"Tabla Soğudu",bedCooledDescription:"Baskıdan sonra tabla eşiğin altına soğudu",firstLayerCompleteLabel:"İlk Katman Tamamlandı",firstLayerCompleteDescription:"İlk katman bittiğinde anlık görüntüyle bildir",missingSpoolAssignmentLabel:"Eksik Makara Ataması",missingSpoolAssignmentDescription:"Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir",printFailed:"Baskı Başarısız",printStopped:"Baskı Durduruldu",progressMilestones:"İlerleme Kilometre Taşları",progressMilestonesDescription:"%25, %50, %75'te bildir",printerOffline:"Yazıcı Çevrimdışı",printerError:"Yazıcı Hatası",lowFilamentLabel:"Az Filament",maintenanceDue:"Bakım Zamanı",maintenanceDueDescription:"Bakım gerektiğinde bildir",amsHumidityHigh:"AMS Nemi Yüksek",amsHumidityHighDescription:"Normal AMS nemi eşiği aşıyor",amsTemperatureHigh:"AMS Sıcaklığı Yüksek",amsTemperatureHighDescription:"Normal AMS sıcaklığı eşiği aşıyor",amsHtHumidityHigh:"AMS-HT Nemi Yüksek",amsHtHumidityHighDescription:"AMS-HT nemi eşiği aşıyor",amsHtTemperatureHigh:"AMS-HT Sıcaklığı Yüksek",amsHtTemperatureHighDescription:"AMS-HT sıcaklığı eşiği aşıyor",inventoryAlerts:"Envanter Uyarıları",stockReorderAlert:"Yeniden Sipariş Uyarısı",stockReorderAlertDescription:"SKU yeniden sipariş noktasına ulaştı",stockBreakAlert:"Stok Tükenme Uyarısı",stockBreakAlertDescription:"Yenileme gelmeden stok tükenecek",jobAdded:"İş Eklendi",jobAddedDescription:"İş kuyruğa eklendi",jobAssigned:"İş Atandı",jobAssignedDescription:"Model tabanlı iş yazıcıya atandı",jobStarted:"İş Başladı",jobStartedDescription:"Kuyruk işi yazdırmaya başladı",jobWaiting:"İş Bekliyor",jobWaitingDescription:"İş filament veya yazıcı için bekliyor",jobSkipped:"İş Atlandı",jobSkippedDescription:"İş atlandı (önceki başarısız oldu)",jobFailed:"İş Başarısız",jobFailedDescription:"İş başlatılamadı",queueComplete:"Kuyruk Tamamlandı",queueCompleteDescription:"Tüm kuyruk işleri bitti",quietHours:"Sessiz Saatler",noNotificationsDuring:"Bu saatler arasında bildirim yok",editProviderToChangeQuietHours:"Sessiz saatleri değiştirmek için sağlayıcıyı düzenleyin",dailyDigest:"Günlük Özet",batchNotifications:"Bildirimleri tek bir günlük özette topla",sendAt:"{{time}}'de gönder",editProviderToChangeDigestTime:"Özet saatini değiştirmek için sağlayıcıyı düzenleyin",edit:"Düzenle",deleteProvider:"Bildirim Sağlayıcısını Sil",deleteConfirm:'"{{name}}" silmek istediğinizden emin misiniz? Bu geri alınamaz.',delete:"Sil",addTitle:"Bildirim Sağlayıcısı Ekle",editTitle:"Bildirim Sağlayıcısını Düzenle",nameLabel:"Ad *",namePlaceholder:"Bildirimlerim",providerTypeLabel:"Sağlayıcı Türü *",configuration:"Yapılandırma",testConfiguration:"Yapılandırmayı Test Et",printerFilter:"Yazıcı Filtresi",onlyFromPrinter:"Yalnızca bu yazıcının olayları için bildirim gönder",quietHoursDnd:"Sessiz Saatler (Rahatsız Etme)",quietStart:"Başlangıç",quietEnd:"Bitiş",dailyDigestLabel:"Günlük Özet",sendDigestAt:"Özeti şu saatte gönder",digestCollected:"Olaylar toplanacak ve bu saatte tek bir özet olarak gönderilecek",notificationEvents:"Bildirim Olayları",progressPercent:"(%25, %50, %75)",bedCooledAfterPrint:"(baskı tamamlandıktan sonra)",eventPriority:{sectionTitle:"ntfy Önceliği",helpNtfy:"Her etkin olay için bir öncelik seçin. ntfy uyarıları kademelendirmek için bunları kullanır (ses, görünürlük, push davranışı). Burada ayarlanmamış seviyeler ntfy sunucu varsayılanını kullanır.",min:"Min",low:"Düşük",default:"Varsayılan",high:"Yüksek",urgent:"Acil"},cancel:"İptal",save:"Kaydet",add:"Ekle",nameRequired:"Ad gerekli",fieldRequired:"{{field}} gerekli",phoneNumber:"Telefon Numarası",apiKey:"API Anahtarı",serverUrl:"Sunucu URL",topic:"Konu",authToken:"Yetkilendirme Belirteci",userKey:"Kullanıcı Anahtarı",appToken:"Uygulama Belirteci",priority:"Öncelik",botToken:"Bot Belirteci",chatId:"Sohbet ID",smtpServer:"SMTP Sunucusu",smtpPort:"SMTP Portu",security:"Güvenlik",authentication:"Kimlik Doğrulama",username:"Kullanıcı Adı",password:"Parola",fromEmail:"Gönderen E-posta",toEmail:"Alıcı E-posta",webhookUrl:"Webhook URL",payloadFormat:"Yük Formatı",authorization:"Yetkilendirme",titleFieldName:"Başlık Alanı Adı",messageFieldName:"Mesaj Alanı Adı",editTemplate:"Şablonu Düzenle: {{name}}",titleLabel:"Başlık",bodyLabel:"Gövde",titlePlaceholder:"Bildirim başlığı...",bodyPlaceholder:"Bildirim gövdesi...",availableVariables:"Kullanılabilir Değişkenler",clickToInsert:"Gövdede imleç konumuna eklemek için tıklayın",livePreview:"Canlı Önizleme",hide:"Gizle",show:"Göster",loadingPreview:"Önizleme yükleniyor...",enterTemplateContent:"Önizlemeyi görmek için şablon içeriğini girin",titlePreview:"Başlık:",bodyPreview:"Gövde:",resetToDefault:"Varsayılana Sıfırla",titleRequired:"Başlık gerekli",bodyRequired:"Gövde gerekli",notificationLog:"Bildirim Günlüğü",showFailedOnly:"Yalnızca başarısız",last24Hours:"Son 24 saat",last7Days:"Son 7 gün",last30Days:"Son 30 gün",last90Days:"Son 90 gün",justNow:"Az önce",noFailedNotifications:"Başarısız bildirim yok",noNotificationsLogged:"Kaydedilen bildirim yok",unknownProvider:"Bilinmeyen Sağlayıcı",logTitle:"Başlık",logMessage:"Mesaj",logError:"Hata",logProvider:"Sağlayıcı: {{type}}",logTime:"Saat: {{time}}",refresh:"Yenile",clearOld:"Eskileri Temizle",statsSummary:"Son {{days}} gün:",statsNotifications:"bildirim",statsSent:"{{count}} gönderildi",statsFailed:"{{count}} başarısız",eventTypes:{print_start:"Baskı Başladı",print_complete:"Baskı Tamamlandı",print_failed:"Baskı Başarısız",print_stopped:"Baskı Durduruldu",print_progress:"İlerleme",printer_offline:"Yazıcı Çevrimdışı",printer_error:"Yazıcı Hatası",filament_low:"Az Filament",maintenance_due:"Bakım Zamanı",test:"Test"},userEmail:{title:"Bildirimler",emailNotifications:"E-posta Bildirimleri",emailNotificationsDesc:"Kendi baskı işleriniz için e-posta bildirimleri alın. E-postalar Gelişmiş Kimlik Doğrulamada yapılandırılan sistem SMTP ayarları kullanılarak gönderilir.",sendingTo:"Bildirimler şuraya gönderilecek",noEmailWarning:"Hesabınızın e-posta adresi yok. Eklemek için bir yöneticiyle iletişime geçin.",printJobNotifications:"Baskı İşi Bildirimleri",printJobNotificationsDesc:"Gönderdiğiniz baskı işleri için hangi olayların e-posta bildirimlerini tetikleyeceğini seçin.",printJobStarts:"Baskı İşi Başlar",printJobStartsDesc:"Baskı işiniz başladığında bildirim alın.",printJobFinishes:"Baskı İşi Biter",printJobFinishesDesc:"Baskı işiniz başarıyla tamamlandığında bildirim alın.",printErrors:"Baskı Hataları",printErrorsDesc:"Baskı işiniz başarısız olduğunda veya bir hatayla karşılaştığında bildirim alın.",printJobStops:"Baskı İşi Durur",printJobStopsDesc:"Baskı işiniz iptal edildiğinde veya durdurulduğunda bildirim alın.",saveSuccess:"Bildirim tercihleri kaydedildi.",saveError:"Bildirim tercihleri kaydedilemedi."}},richTextEditor:{bold:"Kalın",italic:"İtalik",underline:"Altı Çizili",bulletList:"Madde İşaretli Liste",numberedList:"Numaralı Liste",alignLeft:"Sola Hizala",alignCenter:"Ortaya Hizala",alignRight:"Sağa Hizala",addLink:"Bağlantı Ekle",removeLink:"Bağlantıyı Kaldır"},externalLinks:{noLinksConfigured:"Yapılandırılmış harici bağlantı yok",deleteLink:"Bağlantıyı Sil",removeCustomIcon:"Özel simgeyi kaldır",openInNewTab:"Yeni sekmede aç",placeholders:{linkName:"Bağlantım"}},keyboardShortcuts:{title:"Klavye Kısayolları",navigation:"Navigasyon",archivesSection:"Arşivler",kProfilesSection:"K-Profilleri",generalSection:"Genel",shortcuts:{goToPrinters:"Yazıcılara Git",goToArchives:"Arşivlere Git",goToQueue:"Kuyruğa Git",goToStats:"İstatistiklere Git",goToProfiles:"Bulut Profillerine Git",goToSettings:"Ayarlara Git",focusSearch:"Aramaya odaklan",openUploadModal:"Yükleme modalini aç",clearSelection:"Seçimi temizle / odağı kaldır",contextMenu:"Kartlarda bağlam menüsü",refreshProfiles:"Profilleri yenile",newProfile:"Yeni profil",exitSelectionMode:"Seçim modundan çık",showHelp:"Bu yardımı göster"},footer:"Kapatmak için Esc'ye basın veya dışarı tıklayın"},notificationLog:{title:"Bildirim Günlüğü",events:{printStarted:"Baskı Başladı",printComplete:"Baskı Tamamlandı",printFailed:"Baskı Başarısız",printStopped:"Baskı Durduruldu",progress:"İlerleme",printerOffline:"Yazıcı Çevrimdışı",printerError:"Yazıcı Hatası",lowFilament:"Az Filament",maintenanceDue:"Bakım Zamanı",test:"Test"},timeAgo:{justNow:"Az önce",minutesAgo:"{{minutes}}dk önce",hoursAgo:"{{hours}}sa önce"}},restoreBackup:{title:"Yedeği Geri Yükle",restoring:"Geri yükleniyor...",restoreComplete:"Geri Yükleme Tamamlandı",restoreFailed:"Geri Yükleme Başarısız",importSettings:"Ayarları bir yedek dosyasından içe aktar",pleaseWait:"Verileriniz geri yüklenirken lütfen bekleyin",clickToSelect:"Yedek dosyası seçmek için tıklayın (.json veya .zip)",howDuplicateHandling:"Yinelenen yönetiminin nasıl çalıştığı:",categories:{printers:"Yazıcılar",smartPlugs:"Akıllı Prizler",notificationProviders:"Bildirim Sağlayıcıları",filaments:"Filamentler",archives:"Arşivler",pendingUploads:"Bekleyen Yüklemeler",settingsTemplates:"Ayarlar ve Şablonlar"},matchingInfo:{printers:"seri numarasına göre eşleşti",smartPlugs:"IP adresine göre eşleşti",notificationProviders:"ada göre eşleşti",filaments:"ad + tür + markaya göre eşleşti",archives:"içerik hash'ine göre eşleşti",pendingUploads:"dosya adına göre eşleşti",settingsTemplates:"her zaman üzerine yazılır"},replaceExisting:"Mevcut veriyi değiştir",keepExisting:"Mevcut veriyi koru",replaceDescription:"Zaten mevcut olan öğeleri yedek verisiyle üzerine yaz",keepDescription:"Yalnızca zaten mevcut olmayan öğeleri geri yükle",caution:"Dikkat:",cautionText:"Üzerine yazma, mevcut yapılandırmalarınızı yedek verisiyle değiştirecek. Güvenlik için yazıcı erişim kodları asla üzerine yazılmaz.",itemsRestored:"Geri Yüklenen Öğeler",itemsSkipped:"Atlanan Öğeler",restored:"Geri yüklendi",skipped:"Atlandı (zaten mevcut)",filesLabel:"Dosyalar (3MF, küçük resimler, vb.)",newApiKeysGenerated:"Yeni API Anahtarları Oluşturuldu",newApiKeysWarning:"Bu anahtarlar yalnızca bir kez gösterilir. Şimdi kopyalayın!",processingBackup:"Yedek dosyası işleniyor...",noDataFound:"Yedek dosyasında geri yüklenecek veri bulunamadı.",failedToRestore:"Yedek geri yüklenemedi. Lütfen dosya formatını kontrol edin."},backupExport:{title:"Yedeği Dışa Aktar",selectData:"Dahil edilecek veriyi seçin",selectAll:"Tümünü Seç",selectNone:"Hiçbirini Seçme",categoryDescriptions:{settings:"Dil, tema, güncelleme tercihleri",notifications:"ntfy, Pushover, Discord, vb.",templates:"Özel mesaj şablonları",smartPlugs:"Tasmota priz yapılandırmaları",externalLinks:"Harici servislere kenar çubuğu bağlantıları",printers:"Yazıcı bilgisi (erişim kodları hariç)",plateDetection:"Boş plaka referans görüntüleri",filaments:"Filament türleri ve maliyetleri",maintenance:"Özel bakım programları",archives:"Tüm baskı verileri + dosyalar (3MF, küçük resimler, fotoğraflar)",projects:"Projeler, BOM öğeleri ve ekler",pendingUploads:"İnceleme bekleyen sanal yazıcı yüklemeleri",apiKeys:"Webhook API anahtarları (içe aktarımda yeni anahtarlar oluşturulur)"},requiresPrinters:"Yazıcıların seçili olması gerekir",zipFileWarning:"ZIP dosyası oluşturulacak.",zipFileDescription:"Tüm 3MF dosyalarını, küçük resimleri, zaman atlamalı videoları ve fotoğrafları içerir. Bu biraz zaman alabilir ve büyük bir dosya oluşturabilir.",includeAccessCodes:"Erişim Kodlarını Dahil Et",includeAccessCodesDescription:"Başka bir makineye aktarmak için",includeAccessCodesWarning:"Erişim kodları düz metin olarak dahil edilecek. Bu yedek dosyasını güvende tutun!",categoriesSelected:"{{selectedCount}} kategori seçildi"},pendingUploads:{placeholders:{notes:"Bu baskı hakkında notlar ekleyin..."},discardUpload:"Yüklemeyi At",archiveAllUploads:"Tüm Yüklemeleri Arşivle",discardAllUploads:"Tüm Yüklemeleri At",archive:"Arşivle",timeAgo:{justNow:"Az önce",minutesAgo:"{{minutes}}dk önce",hoursAgo:"{{hours}}sa önce",daysAgo:"{{days}}g önce"}},apiBrowser:{placeholders:{requestBody:"JSON istek gövdesi...",searchEndpoints:"Uç noktalarda ara..."}},configureAmsSlot:{title:"AMS Yuvasını Yapılandır",slotConfigured:"Yuva Yapılandırıldı!",configuringSlot:"Yuva yapılandırılıyor:",slotLabel:"{{ams}} Yuva {{slot}}",searchPresets:"Ön ayarlarda ara...",colorPlaceholder:"Renk adı veya hex (örn., kahverengi, FF8800)",clearCustomColor:"Özel rengi temizle",noCloudPresets:"Bulut ön ayarı yok. Senkronize etmek için Bambu Cloud'a giriş yapın.",noPresetsAvailable:"Kullanılabilir ön ayar yok. Bambu Cloud'a giriş yapın veya yerel profilleri içe aktarın.",noMatchingPresets:"Eşleşen ön ayar bulunamadı.",custom:"Özel",builtin:"Yerleşik",orcaCloud:"Orca Cloud",bambuCloud:"Bambu Cloud",settingsSentToPrinter:"Ayarlar yazıcıya gönderildi",filamentProfile:"Filament Profili",kProfileLabel:"K Profili (Basınç İlerlemesi)",filteringFor:"Şu için filtreleniyor: {{material}}",noKProfile:"K profili yok (varsayılan 0.020 kullan)",noMatchingKProfiles:"Eşleşen K profili bulunamadı. Varsayılan K=0.020 kullanılacak.",selectFilamentFirst:"Önce bir filament profili seçin",kFromCalibration:"Yazıcı kalibrasyonundan K={{value}}",customColorLabel:"Özel Renk (isteğe bağlı)",presetColors:"{{name}} renkleri:",showLessColors:"Daha az renk göster",showMoreColors:"Daha fazla renk göster",clear:"Temizle",hexLabel:"Hex: #{{hex}}",resetting:"Sıfırlanıyor...",resetSlot:"Yuvayı Sıfırla",cancel:"İptal",configuring:"Yapılandırılıyor...",configureSlot:"Yuvayı Yapılandır"},githubBackup:{title:"Git Yedekleme",history:"Geçmiş",downloadBackup:"Yedeği İndir",restoreBackup:"Yedeği Geri Yükle",noBackupsYet:"Henüz yedek yok"},emailSettings:{placeholders:{fromName:"BamBuddy"}},tagManagement:{searchTags:"Etiketlerde ara...",renameTag:"Etiketi yeniden adlandır",deleteTag:"Etiketi sil"},notificationTemplates:{placeholders:{title:"Bildirim başlığı...",body:"Bildirim gövdesi..."}},batchTag:{placeholders:{newTag:"Yeni etiket girin..."}},photoGallery:{deletePhoto:"Fotoğrafı Sil"},filamentHoverCard:{copySpoolUuid:"Makara UUID'sini kopyala"},kProfilesView:{hasNote:"Notu var",copyProfile:"Profili kopyala"},layout:{openMenu:"Menüyü aç",noPermissionSystemInfo:"Sistem bilgisini görüntüleme izniniz yok"},dashboard:{dragToReorder:"Yeniden sıralamak için sürükleyin",hideWidget:"Widget'ı gizle"},notificationProviderCard:{deleteNotificationProvider:"Bildirim Sağlayıcısını Sil"},fileManagerModal:{closeFileManager:"Dosya yöneticisini kapat",sortFiles:"Dosyaları sırala",goToParentFolder:"Üst klasöre git",threeView:"3B Görünüm"},embeddedCameraViewer:{refreshStream:"Akışı yenile",close:"Kapat",zoomOut:"Uzaklaştır",resetZoom:"Yakınlaştırmayı sıfırla",zoomIn:"Yakınlaştır",dragToResize:"Yeniden boyutlandırmak için sürükleyin"},timelapseViewer:{skipBack5s:"5 sn geri al",skipForward5s:"5 sn ileri al"},notificationProviders:{descriptions:{email:"SMTP e-posta bildirimleri",telegram:"Telegram botu üzerinden bildirimler",discord:"Webhook üzerinden Discord kanalına gönder",ntfy:"Ücretsiz, kendi barındırılabilir push bildirimleri",pushover:"Basit, güvenilir push bildirimleri",callmebot:"CallMeBot üzerinden ücretsiz WhatsApp bildirimleri",webhook:"Herhangi bir URL'ye genel HTTP POST"}},logViewer:{searchPlaceholder:"Mesaj veya günlükçü adı ara...",noLogEntries:"Günlük kaydı bulunamadı"},switchbarPopover:{noSwitchesInSwitchbar:"Anahtar çubuğunda anahtar yok"},projectPageModal:{placeholders:{title:"Başlık",designer:"Tasarımcı",license:"Lisans",description:"Açıklama girin...",profileTitle:"Profil Başlığı",profileDescription:"Profil açıklaması..."}},spoolmanSettings:{},time:{unknown:"-",waiting:"Bekliyor",justNow:"Az önce",now:"Şimdi",minsAgo:"{{count}}dk önce",inMins:"{{count}}dk sonra",hoursAgo:"{{count}}sa önce",inHours:"{{count}}sa sonra",daysAgo:"{{count}}g önce",inDays:"{{count}}g sonra"},spoolbuddy:{nav:{dashboard:"Gösterge Paneli",ams:"AMS",inventory:"Envanter",writeTag:"Yaz",settings:"Ayarlar"},status:{nfcReady:"NFC Hazır",nfcOff:"NFC Kapalı",offline:"Çevrimdışı",online:"Çevrimiçi",noPrinters:"Yazıcı yok",deviceOffline:"Cihaz Çevrimdışı",waitingConnection:"Cihaz bağlantısı bekleniyor...",systemReady:"Sistem Hazır",status:"Durum"},dashboard:{readyToScan:"Taramaya hazır",idleMessage:"Tanımlamak için bir makarayı tartıya yerleştirin",nfcHint:"NFC etiketi otomatik olarak okunacak",device:"Cihaz",syncWeight:"Ağırlığı Senkronize Et",weightSynced:"Senkronize edildi!",unknownTag:"Bilinmeyen Etiket",newTag:"Yeni Etiket Algılandı",onScale:"tartıda",linkSpool:"Makaraya Bağla",linkTagTitle:"Etiketi Makaraya Bağla",linkTag:"Etiketi Bağla",selectSpool:"Bu etiketi bağlamak için bir makara seçin:",noUntagged:"Etiketsiz makara bulunamadı",tagDetected:"Etiket algılandı",noTag:"Etiket yok",tagId:"Etiket",grossWeight:"Brüt ağırlık",spoolSize:"Makara boyutu",close:"Kapat",currentSpool:"Mevcut Makara",plateReady:"Plaka hazır: {{name}}",plateReadyLabel:"Temizlenmeye hazır plakalar",plateClearAction:"Temizle",plateClearedToast:"Plaka temizlendi olarak işaretlendi",plateClearFailed:"Plaka temizlendi olarak işaretlenemedi"},modal:{spoolDetected:"Makara Algılandı",assignToAms:"AMS'e Ata",syncWeight:"Ağırlığı Senkronize Et",weightSynced:"Senkronize edildi!",syncing:"Senkronize ediliyor...",newTagDetected:"Yeni Etiket Algılandı",addToInventory:"Envantere Ekle",assignToAmsTitle:"AMS'e Ata",selectSlot:"Bir yuva seçin",assign:"Ata",assigning:"Atanıyor...",assignSuccess:"Atandı!",assignPendingInsert:"Atandı. Makarayı yerleştirdiğinizde yuva yapılandırılacak.",assignError:"Makara atanamadı. Lütfen tekrar deneyin.",noPrinterSelected:"Bir yazıcı seçin...",noAmsDetected:"Bu yazıcıda AMS algılanmadı",slot:"Yuva"},weight:{noReading:"Okuma yok",stable:"Kararlı",measuring:"Ölçülüyor...",tare:"Dara",calibrate:"Kalibre Et"},spool:{remaining:"Kalan",material:"Malzeme",brand:"Marka",color:"Renk",coreWeight:"Çekirdek",labelWeight:"Etiket",scaleWeight:"Tartı",netWeight:"Net",lastUsed:"Son kullanım"},ams:{noData:"AMS algılanmadı",connectAms:"Filament yuvalarını görmek için bir AMS bağlayın",noPrinter:"Yazıcı seçilmedi",selectPrinter:"Üst çubuktan bir yazıcı seçin",printerDisconnected:"Yazıcı bağlantısı kesildi",humidity:"Nem",level:"Seviye",active:"Aktif",slot:"Yuva",empty:"Boş"},inventory:{search:"Makaralarda ara...",empty:"Envanterde makara yok",noResults:"Eşleşen makara yok",spools:"makara",addSpool:"Makara Ekle"},settings:{tabDevice:"Cihaz",tabDisplay:"Görüntü",tabScale:"Tartı",tabUpdates:"Güncellemeler",nfcReader:"NFC Okuyucu",type:"Tür",connection:"Bağlantı",notConnected:"N/A",deviceInfo:"Cihaz Bilgisi",hostname:"Ana Bilgisayar",uptime:"Çalışma Süresi",systemConfig:"Arka Uç ve Kimlik Doğrulama",backendUrl:"Bambuddy Arka Uç URL'si",apiToken:"API Belirteci",apiTokenPlaceholder:"API belirtecini girin",saveConfig:"Yapılandırmayı Kaydet",systemQueued:"Yapılandırma kuyruğa alındı.",nfcDiagnostic:"NFC Tanılaması",scaleDiagnostic:"Tartı Tanılaması",readTagDiagnostic:"Etiket Okuma Tanılaması",testNfc:"Okuyucuyu test et",testScale:"Doğruluğu test et",testReadTag:"Etiket oku",systemFieldsRequired:"Arka uç URL'si gerekli.",brightness:"Parlaklık",saved:"Kaydedildi",noBacklight:"DSI arka ışık algılanmadı. Parlaklık kontrolü DSI ekran gerektirir.",screenBlank:"Ekran Kapanma Zaman Aşımı",screenBlankDesc:"Hareketsizlik sonrası ekran kapanır. Uyandırmak için dokunun.",displayNote:"Parlaklık bir yazılım filtresi olarak uygulanır.",scaleCalibration:"Tartı Kalibrasyonu",currentWeight:"Mevcut ağırlık",tareOffset:"Dara",calFactor:"Faktör",knownWeight:"Bilinen ağırlık",calStep1:"Tartıdan tüm öğeleri kaldırın ve Sıfır Ayarla'ya basın.",calStep2:"Bilinen ağırlığı tartıya yerleştirin.",setZero:"Sıfır Ayarla",calibrateNow:"Kalibre Et",calibrated:"Kalibre edildi",tareSet:"Dara komutu gönderildi. Cihaz bekleniyor...",tareComplete:"Dara tamam!",tareTimedOut:"Dara zaman aşımına uğradı — SpoolBuddy daemon çalışıyor mu?",tareFailed:"Dara komutu gönderilemedi",zeroSet:"Sıfır noktası ayarlandı. Bilinen ağırlığı tartıya yerleştirin.",calibrationDone:"Kalibrasyon tamamlandı!",calibrationFailed:"Kalibrasyon başarısız",lastCalibrated:"Son kalibrasyon",stable:"Kararlı",settling:"Sabitleniyor...",firmware:"Firmware",scale:"Tartı",noDevice:"SpoolBuddy cihazı bulunamadı",daemonVersion:"Daemon Sürümü",currentVersion:"Mevcut",versionPending:"Daemon bekleniyor...",checking:"Kontrol ediliyor...",checkUpdates:"Güncellemeleri Kontrol Et",updateAvailable:"Güncelleme mevcut",updateInstructions:"SSH üzerinden güncelle: yükseltmek için SpoolBuddy yükleme betiğini çalıştırın.",upToDate:"Güncel",includeBeta:"Beta sürümleri dahil et"},writeTag:{tabExisting:"Mevcut Makara",tabNew:"Yeni Makara",tabReplace:"Etiketi Değiştir",searchPlaceholder:"Malzeme, renk, marka ile ara...",noUntaggedSpools:"Etiketsiz makara yok",noTaggedSpools:"Etiketli makara yok",selectSpool:"Bir makara seçin, ardından okuyucuya boş bir NTAG yerleştirin",placeTag:"Okuyucuya bir NTAG yerleştirin",tagReady:"Etiket algılandı — yazmaya hazır",writeTag:"Etiketi Yaz",replaceTag:"Etiketi Değiştir",writing:"Etiket yazılıyor...",waiting:"SpoolBuddy bekleniyor...",writeSuccess:"Etiket başarıyla yazıldı!",writeFailed:"Yazma başarısız",queueFailed:"Yazma komutu kuyruğa alınamadı",tryAgain:"Tekrar Dene",cancel:"İptal",replaceWarning:"Eski etiket bağlantısı kaldırılacak. Yeni etiket yerini alacak.",deviceOffline:"SpoolBuddy çevrimdışı",material:"Malzeme",colorName:"Renk Adı",color:"Renk",brand:"Marka",weight:"Ağırlık (g)",createSpool:"Makara Oluştur",creating:"Oluşturuluyor...",spoolCreated:"Makara oluşturuldu! Yazmaya hazır.",createFailed:"Makara oluşturulamadı",incompleteDataWarning:"Etiket eksik Spoolman verisiyle yazıldı"},quickMenu:{printerPower:"Yazıcı Gücü",systemControls:"Sistem",restartDaemon:"Daemon'u Yeniden Başlat",restartBrowser:"Tarayıcıyı Yeniden Başlat",reboot:"Yeniden Başlat",shutdown:"Kapat",swipeToClose:"Kapatmak için aşağı kaydır",confirmTitle:"Onayla",confirmShutdown:"SpoolBuddy'yi kapatmak istediğinizden emin misiniz? Tekrar açmak için fiziksel erişime ihtiyacınız olacak.",confirmReboot:"SpoolBuddy'yi yeniden başlatmak istediğinizden emin misiniz?",confirmRestartDaemon:"SpoolBuddy daemon yeniden başlatılsın mı? NFC ve tartı geçici olarak kullanılamayacak.",confirmRestartBrowser:"Kiosk tarayıcısı yeniden başlatılsın mı? Ekran kısa bir süre kararacak.",confirm:"Onayla",confirmPlugOn:"{{name}} açılsın mı?",confirmPlugOff:"{{name}} kapatılsın mı?",turnOn:"Aç",turnOff:"Kapat"}},diagnostic:{modalTitle:"Bağlantı tanılaması — {{name}}",running:"Tanılama çalışıyor...",runningElapsed:"Tanılama çalışıyor... ({{elapsed}}s)",waitingForReportHint:"Yazıcının durum raporu yayınlamasını bekliyor — bu işlem en fazla {{max}} saniye sürebilir.",runFailed:"Tanılama çalıştırılamadı: {{error}}",retry:"Tekrar çalıştır",runButton:"Tanılamayı çalıştır",sectionTitle:"Bağlantı Tanılaması",sectionDescription:"Bir yazıcının neden bağlanmadığını veya yazdırmadığını kontrol edin — port erişilebilirliği, LAN geliştirici modu, Docker ağ modu ve kimlik bilgileri.",noPrinters:"Yapılandırılmış yazıcı yok.",overall:{ok:"Sorun bulunamadı — yazıcı bağlantısı sağlıklı görünüyor.",warnings:"Yazıcı çalışmalı, ancak bazı şeyler dikkat gerektiriyor.",problems:"Yazıcının neden bağlanmadığını veya yazdırmadığını açıklayan sorunlar bulundu."},check:{port_mqtt:{title:"Kontrol portu (MQTT 8883)",pass:"Erişilebilir — yazıcı kontrol bağlantılarını kabul ediyor.",fail:"Port 8883 erişilemez. Yazıcı kapalı, farklı bir IP adresinde veya bir güvenlik duvarı engelliyor. Yazıcı IP'sini ve hiçbir şeyin port 8883'ü engellemediğini doğrulayın."},port_ftps:{title:"Dosya aktarım portu (FTPS 990)",pass:"Erişilebilir — baskı dosyaları gönderme çalışacak.",warn:"Port 990 erişilemez. İzleme yine çalışabilir, ancak yazıcıya baskı gönderme başarısız olacak. Port 990'ın engellenmediğinden emin olun."},external_storage:{title:"Gönderilen dosyaları harici depolamada sakla (kurulum adımı 4)",pass:"Yazıcı bu seçeneğin açık olduğunu bildiriyor — gönderilen dosyalar SD kartta saklanacak ve arşivler küçük resim ve dilimleyici meta verileri içerecek.",fail:`Yazıcı bu seçeneğin kapalı olduğunu bildiriyor. "Gönderilen dosyaları harici depolamada sakla" seçeneğini etkinleştirin — yeni donanım yazılımlarında (P2S 01.02 / Bambu Studio 2.6+) düğme yazıcının Baskı Ayarları'nda; eski sürümlerde Bambu Studio / OrcaSlicer'in Cihaz sekmesindedir. Bu seçenek olmadan, arşivlenen her baskıda küçük resim ve dilimleyici meta verisi olmayacak.`,skip:"Kontrol edilmedi — etkin bir MQTT bağlantısı gerekli. Bu ayarın yalnızca dilimleyicide bulunduğu eski dilimleyicilerde yazıcı bunu bildirmez, bu nedenle seçenek kapalı olsa bile bu kontrol geçer — kurulum adımı 4'ü manuel olarak doğrulayın."},port_rtsps:{title:"Kamera portu (RTSPS 322)",pass:"Erişilebilir — kamera akışı çalışacak.",warn:"Port 322 erişilemez. Canlı kamera görünümü çalışmayacak. Bu, baskıyı etkilemez."},network_mode:{title:"Docker ağ modu",pass:"Ana bilgisayar ağ modunda çalışıyor.",warn:'Bambuddy, Docker köprü ağı kullanılarak çalışıyor. Yazıcı keşfi ve Sanal Yazıcı, ana bilgisayar ağ modu gerektirir — konteyneri "network_mode: host" ile yeniden oluşturun.',skip:"Docker'da çalışmıyor — uygulanamaz."},subnet:{title:"Ağ alt ağı",pass:"Yazıcı ve Bambuddy aynı alt ağda.",warn:"Yazıcı ({{printer_ip}}) ve Bambuddy ({{host_ip}}) farklı alt ağlarda. Alt ağlar arasında yönlendirme yapılandırılmazsa birbirlerine erişemeyebilirler.",skip:"Alt ağ belirlenemedi — atlandı."},mqtt_auth:{title:"Yazıcı kimlik bilgileri",pass:"Yazıcı bağlantıyı kabul etti.",fail:"Yazıcı erişilebilir ancak bağlantıyı reddetti. Büyük olasılıkla erişim kodu veya seri numarası yanlış. Erişim kodu, Geliştirici Modu her açılıp kapatıldığında değişir — yazıcı ekranından yeniden kopyalayın.",skip:"Kontrol edilmedi — yazıcıya erişilemedi."},developer_mode:{title:"LAN Geliştirici Modu",pass:"Geliştirici Modu etkin.",fail:"Yazıcıda Geliştirici Modu KAPALI. Yazıcının LAN ayarlarında etkinleştirin — ve OK ile onaylayın. Bu olmadan baskılar başlamayacak.",skip:"Kontrol edilemedi — yazıcıya canlı bir bağlantı gerektirir."},printer_publishing:{title:"Yazıcı durum yayını yapıyor",pass:"Yazıcı durum güncellemelerini yayınlıyor — AMS, filamentler ve K profilleri dilimleyiciye doğru şekilde yansıtılacak.",fail:"MQTT aracısı bağlantıyı kabul etti, ancak yazıcı hiç durum raporu yayınlamadı. Bu neredeyse her zaman yanlış ya da büyük/küçük harf hatalı bir seri numarasından kaynaklanır — device//report konusu büyük/küçük harfe duyarlıdır. Yazıcı ayarlarındaki seri numarasını cihazın ekranındaki ile karşılaştırarak doğrulayın.",skip:"Kontrol edilemedi — yazıcıya canlı bir bağlantı gerektirir."}}},systemHealth:{sectionTitle:"Sistem Sağlığı",sectionDescription:"Bir destek talebine dönüşmeden önce genellikle kendi başınıza düzeltebileceğiniz bilinen sorunlar için son günlükleri tarar.",rescan:"Yeniden tara",clean:"Son {{times}} günlük kaydında bilinen sorun bulunamadı.",logUnavailable:"Dosya günlüğü devre dışı, bu nedenle günlükler taranamıyor. Bu kontrolü kullanmak için dosya günlüğünü etkinleştirin.",learnMore:"Nasıl düzeltilir",fixLabel:"Düzeltme:",occurrences:"{{times}}× görüldü — son {{lastSeen}}'de",category:{layer8:"Bunu düzeltebilirsiniz",environment:"Ortam",bug:"Lütfen bunu bildirin"},signature:{"ftp-auth-rejected":{name:"Yazıcı erişim kodunu reddetti",cause:"Yazıcı dosya aktarım girişini reddetti. Erişim kodu yanlış veya Geliştirici Modu açılıp kapatıldıktan sonra değişti.",fix:"Erişim kodunu yazıcı ekranından (LAN ayarları) yeniden kopyalayın ve Bambuddy'deki yazıcının ayarlarında güncelleyin."},"ftp-connection-timeout":{name:"Dosya aktarım bağlantısı zaman aşımına uğradı",cause:"Bambuddy yazıcının dosya aktarım portuna (FTPS 990) erişemedi. Port engellendi veya yazıcı kapalı ya da başka bir alt ağda.",fix:"Bambuddy ile yazıcı arasında hiçbir şeyin port 990'ı engellemediğinden ve her ikisinin de aynı ağda olduğundan emin olun."},"ftp-ssl-error":{name:"Güvenli dosya aktarım el sıkışması başarısız",cause:"Yazıcının dosya aktarım sunucusuyla TLS el sıkışması başarısız oldu. Bu genellikle bir güvenlik duvarı veya eski yazıcı firmware'idir.",fix:"Yazıcı firmware'ini güncelleyin ve port 990'daki bağlantıyı hiçbir güvenlik duvarı veya proxy'nin engellemediğini kontrol edin."},"mqtt-connection-flapping":{name:"Yazıcı bağlantısı sürekli düşüyor",cause:"Kontrol bağlantısı (MQTT 8883) tekrar tekrar bağlantısı kesiliyor ve yeniden bağlanıyor — genellikle zayıf bir ağ yolu veya kısmen engellenmiş bir port.",fix:"Yazıcıdaki Wi-Fi sinyalini kontrol edin, kablolu bağlantı tercih edin ve port 8883'ün güvenilir bir şekilde erişilebilir olduğundan emin olun."},"camera-connection-refused":{name:"Kamera akışına erişilemez",cause:"Canlı kameraya RTSPS 322 portunda erişilemedi. Port engellendi veya yazıcıda kamera veya LAN canlı görünümü kapalı.",fix:"Yazıcıda kamerayı ve LAN canlı görünümünü etkinleştirin ve port 322'nin engellenmediğinden emin olun. Bu, baskıyı etkilemez."},"database-locked":{name:"Veritabanı yazma çekişmesi",cause:'SQLite veritabanı yük altında "veritabanı kilitli" hatalarıyla karşılaşıyor — aynı anda birkaç yazıcı çalıştırırken yaygındır.',fix:"Bambuddy'yi harici bir PostgreSQL veritabanına geçirin. Dokümantasyondaki PostgreSQL kılavuzuna bakın."}}},vpDiagnostic:{title:"Kurulum kontrolü — {{name}}",runButton:"Kurulum kontrolünü çalıştır",running:"Kurulum kontrolü çalışıyor...",runFailed:"Kurulum kontrolü çalıştırılamadı: {{error}}",retry:"Tekrar çalıştır",overall:{ok:"Tüm kontroller geçti — bu sanal yazıcı doğru şekilde kurulmuş.",warnings:"Sanal yazıcı çalışmalı, ancak bazı şeyler dikkat gerektiriyor.",problems:"Dilimleyicinin bu sanal yazıcıyı neden göremediğini veya kullanamadığını açıklayan sorunlar bulundu."},check:{enabled:{title:"Sanal yazıcı etkin",fail:"Bu sanal yazıcı kapatılmış. Keşfedilebilir hale getirmek için açın."},running:{title:"Servisler çalışıyor",fail:"Sanal yazıcı etkin ancak servisleri çalışmıyor. Bambuddy günlüğünü kontrol edin — bir bind IP çakışması veya bir izin hatası genellikle onları durdurur."},bind_interface:{title:"Bind ağ arayüzü",fail:"Bind arayüzü ayarlanmamış veya artık bu ana bilgisayarda mevcut değil. Bind Arayüzü açılır menüsünde mevcut bir arayüz seçin."},access_code:{title:"Erişim kodu ayarlandı",fail:"Erişim kodu ayarlanmamış. Dilimleyiciye burada ayarladığınız aynı 8 karakterli erişim kodunun verilmesi gerekir."},target_printer:{title:"Hedef yazıcı",fail:"Hedef yazıcı seçilmemiş. Proxy modu iletmek için gerçek bir yazıcıya ihtiyaç duyar.",warn:"Hedef yazıcı şu anda çevrimdışı — yeniden bağlandığında proxy devam edecek."},port_ftps:{title:"Dosya yükleme servisi (port {{port}})",fail:"Bind IP'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyici dosya yükleyemez. Bu arayüzde bir port çakışması genellikle nedendir."},port_mqtt:{title:"Kontrol servisi (port {{port}})",fail:"Bind IP'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyici bağlanamaz veya durum gösteremez."},port_bind:{title:"Keşif servisi (port {{port}})",fail:"Bind IP'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyicinin keşif el sıkışması başarısız oluyor."},certificate:{title:"TLS sertifikası",pass:"Sertifika hazır. Bambuddy CA sertifikasının (yukarıda) dilimleyicinizin güven deposuna içe aktarıldığından emin olun.",fail:"Bu sanal yazıcı için TLS sertifikası eksik. Bambuddy veri dizininin yazılabilir olduğunu kontrol edin."}}},bugReport:{title:"Hata Bildir",description:"Açıklama",descriptionPlaceholder:"Ne ters gitti? Lütfen sorunu tanımlayın...",email:"E-posta (isteğe bağlı)",emailPlaceholder:"sizin@email.com",emailPrivacy:"Sağlanırsa, e-postanız GitHub sorunundaki daraltılmış bir bölümde dahil edilecek, böylece bakımcı takip edebilir.",screenshot:"Ekran Görüntüsü",uploadOrPaste:"Bir görüntü yükleyin, yapıştırın veya sürükleyin",dataCollectedSummary:"Rapora hangi veriler dahil edilir?",dataIncluded:"Dahil:",dataIncludedList:"Uygulama sürümü, OS, mimari, Python sürümü, veritabanı istatistikleri (yalnızca sayılar), yazıcı modelleri, nozul sayıları, firmware sürümleri, bağlantı durumu, entegrasyon durumu (Spoolman, MQTT, HA), hassas olmayan ayarlar, ağ arayüzü sayısı, Docker ayrıntıları, bağımlılık sürümleri.",dataNeverIncluded:"Asla dahil edilmez:",dataNeverIncludedList:"Yazıcı adları, seri numaraları, erişim kodları, parolalar, IP adresleri, e-posta adresleri, API anahtarları, belirteçler, webhook URL'leri, ana bilgisayar adları veya kullanıcı adları.",submit:"Gönder",startLogging:"Hata Ayıklama Kaydını Başlat",stepEnableLogging:"Hata ayıklama kaydı etkin",stepReproduce:"Sorunu şimdi yeniden oluşturun",stepStopLogging:"Durdur ve raporu gönder",stopAndSubmit:"Durdur ve Gönder",maxDuration:"{{minutes}} dk sonra otomatik durur",stoppingLogs:"Günlükler toplanıyor ve gönderiliyor...",submitting:"Hata raporu gönderiliyor...",submittingStepConnection:"Yazıcı bağlantı kontrolleri çalıştırılıyor",submittingStepVirtualPrinters:"Sanal yazıcı kurulum kontrolleri çalıştırılıyor",submittingStepLogScan:"Son günlüklerde bilinen sorunlar taranıyor",submittingStepSubmit:"Rapor GitHub'a gönderiliyor",submitSuccess:"Hata raporu başarıyla gönderildi!",submitFailed:"Hata raporu gönderilemedi",diagnosticChecking:"Yazıcı bağlantıları kontrol ediliyor...",diagnosticHealthy:"Bağlantı kontrolü başarılı — yazıcılarınızda sorun bulunamadı.",diagnosticSummary:"{{total}} yazıcıdan {{problems}} tanesinde bağlantı sorunu var",diagnosticIntro:"Bir veya daha fazla yazıcının sorununuza neden olabilecek bir bağlantı sorunu var. Düzeltmeyi görmek için aşağıda bir yazıcıyı genişletin — bunu çözmek, hata raporu olmadan sorunu çözebilir. Yine de aşağıda bir rapor gönderebilirsiniz.",logHealthSummary:"Günlüklerinizde bilinen sorunlar bulundu",logHealthIntro:"Son günlükler bilinen sorunlarla eşleşiyor. Aşağıdaki düzeltmeleri kontrol edin — bunları çözmek, hata raporu olmadan sorununuzu çözebilir. Yine de aşağıda bir rapor gönderebilirsiniz.",thankYou:"Teşekkürler!",submitted:"Hata raporunuz gönderildi.",viewIssue:"Sorunu Görüntüle",unexpectedError:"Beklenmedik bir hata oluştu"},failureDetection:{title:"AI Başarısızlık Algılama",description:"Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.",mlUrl:"Obico ML API URL'si",mlUrlHint:"Kendi barındırılan Obico ml_api konteynerinizin temel URL'si (örn. http://192.168.1.10:3333).",test:"Test",testSuccess:"ML API erişilebilir ve sağlıklı.",testFailed:"ML API'ye erişilemedi.",sensitivity:"Hassasiyet",sensitivityLow:"Düşük (daha az yanlış pozitif)",sensitivityMedium:"Orta (dengeli)",sensitivityHigh:"Yüksek (erken algıla, daha fazla yanlış pozitif)",sensitivityHint:"Uyarıları ve başarısızlıkları tetikleyen güven eşiklerini ayarlar.",action:"Algılanan başarısızlıkta eylem",actionNotify:"Yalnızca bildir",actionPause:"Baskıyı duraklat",actionPauseOff:"Duraklat ve gücü kes",pollInterval:"Sorgulama aralığı (saniye)",pollIntervalHint:"Baskı yaparken her yazıcının ne sıklıkta kontrol edileceği. Minimum 5sn, maksimum 120sn.",externalUrlMissing:"Harici URL ayarlanmamış.",externalUrlHint:"ML API, kamera anlık görüntüsünü URL üzerinden alır. ML API konteyneri Bambuddy'ye erişebilsin diye Genel ayarlarda Harici URL'yi ayarlayın.",perPrinterTitle:"İzlenen Yazıcılar",perPrinterHint:"Algılama servisinin hangi yazıcıları izleyeceğini seçin.",monitorAll:"Tüm bağlı yazıcıları izle",statusTitle:"Durum",serviceRunning:"Servis çalışıyor",thresholds:"Düşük / Yüksek eşikler",activePrinters:"Aktif baskılar",noActivePrints:"Şu anda çalışan baskı yok.",historyTitle:"Son Algılamalar",noHistory:"Henüz algılama yok."},makerworld:{title:"MakerWorld",description:"Bambu Handy uygulamasına gitmeden — doğrudan Bambuddy'den içe aktarmak ve yazdırmak için bir MakerWorld model URL'si yapıştırın.",pasteUrlHeader:"MakerWorld'den İçe Aktar",pasteUrlPlaceholder:"https://makerworld.com/en/models/… veya herhangi bir MakerWorld bağlantısını yapıştırın",resolveButton:"Çöz",signInRequiredTitle:"İndirme için Bambu Cloud girişi gerekli",signInRequiredBody:"Model ayrıntılarına anonim olarak göz atabilirsiniz, ancak MakerWorld 3MF dosyalarını indirmek için bir Bambu Cloud hesabı gerektirir.",openCloudSettings:"Bulut ayarlarını aç",untitledModel:"Adsız model",byCreator:"{{name}} tarafından",downloadsCount:"{{count}} indirme",licensePrefix:"Lisans",alreadyImported:"Zaten kütüphanede",openOnMakerworld:"MakerWorld'de aç",alreadyInLibrary:"Bu model zaten kütüphanenizde — Dosya Yöneticisi → MakerWorld'de bulun",importSuccess:"{{filename}} içe aktarıldı — Dosya Yöneticisi → MakerWorld'e kaydedildi",platesHeader:"Plakalar ({{count}})",plateDefaultName:"Plaka {{n}}",materialCount:"{{count}} filament",amsRequired:"AMS gerekli",slicedFor:"{{printer}} için dilimlendi",alsoCompatible:"Ayrıca uyumlu olarak işaretlendi: {{printers}}",importToLibrary:"Kaydet",sliceIn:"Kaydet ve {{slicer}}'de Dilimle",disclaimer:"MakerWorld entegrasyonu topluluk tarafından belgelenen API uç noktalarını kullanır. Bambuddy, MakerWorld veya Bambu Lab ile ilişkili veya onaylı değildir.",lastImportSuccess:"Kütüphanenize içe aktarıldı",lastImportAlreadyInLibrary:"Zaten kütüphanenizde",viewInLibrary:"Dosya Yöneticisinde Görüntüle",openInBambuStudio:"Bambu Studio'da Aç",openInOrcaSlicer:"OrcaSlicer'da Aç",importTo:"Dosya yöneticisine içe aktar",recentImportsHeader:"Son içe aktarımlar",phaseResolving:"Çözülüyor",phaseDownloading:"İndiriliyor",folderAuto:"MakerWorld (varsayılan)",importAll:"Tümünü içe aktar",importAllProgress:"{{current}}/{{total}} içe aktarılıyor",openGallery:"Görüntü galerisini aç",galleryPrev:"Önceki görüntü",galleryNext:"Sonraki görüntü",deleteImport:"Kütüphaneden kaldır",importDeleting:"Kaldırılıyor…",importDeleted:"Kütüphaneden kaldırıldı",confirmDelete:"{{filename}} kütüphaneden kaldırılsın mı? Bu, yerel dosyayı siler ancak plaka MakerWorld'den yeniden içe aktarılabilir.",errors:{resolveFailed:"O MakerWorld URL'si çözülemedi.",downloadFailed:"İndirme başarısız. Lütfen tekrar deneyin.",deleteFailed:"Dosya kütüphaneden kaldırılamadı."}},gcodeViewer:{back:"Geri",backToArchives:"Baskı Arşivlerine Dön",backToFiles:"Dosya Yöneticisine Dön"},libraryTrash:{title:"Çöp Kutusu",headerButton:"Çöp",headerTooltip:"Çöpe taşınan dosyaları görüntüle",backToFiles:"Dosya Yöneticisine Dön",subtitleAdmin:"Silinen dosyalar burada {{days}} gün kalır, ardından otomatik silinir. Bu görünüm tüm kullanıcılar için çöpteki dosyaları gösterir.",subtitleUser:"Silinen dosyalar burada {{days}} gün kalır, ardından otomatik silinir.",loading:"Çöp yükleniyor…",loadError:"Çöp yüklenemedi.",empty:"Çöp boş.",summary:"{{count}} dosya · {{size}}",emptyTrash:"Çöpü boşalt",restore:"Geri Yükle",purgeNow:"Şimdi sil",autoPurgeIn:"{{when}} içinde otomatik silinir",days:"gün",retentionLabel:"Otomatik sil",selectAll:"Tümünü seç",selectOne:"{{filename}} seç",selectionCount:"{{count}} seçildi",bulkRestore:"Seçilenleri geri yükle",bulkPurge:"Seçilenleri sil",col:{filename:"Dosya",folder:"Klasör",size:"Boyut",deleted:"Çöpe taşındı",autoPurge:"Otomatik silinir",owner:"Sahip",actions:"İşlemler"},confirm:{purgeTitle:"Kalıcı olarak silinsin mi?",purgeBody:"{{filename}} diskten silinecek ve geri yüklenemeyecek.",emptyTitle:"Çöp boşaltılsın mı?",emptyBody:"Tüm {{count}} dosya diskten silinecek. Bu geri alınamaz.",bulkPurgeTitle:"Seçili dosyalar kalıcı olarak silinsin mi?",bulkPurgeBody:"Seçili {{count}} dosya diskten silinecek ve geri yüklenemeyecek.",cta:"Kalıcı olarak sil"},toast:{restored:"Dosya geri yüklendi.",restoreFailed:"Dosya geri yüklenemedi.",purged:"Dosya kalıcı olarak silindi.",purgeFailed:"Dosya silinemedi.",emptied:"{{count}} dosya çöpten silindi.",emptyFailed:"Çöp boşaltılamadı.",retentionSaved:"Otomatik silme {{days}} gün olarak ayarlandı.",retentionFailed:"Saklama ayarı kaydedilemedi.",bulkRestored:"{{count}} dosya geri yüklendi.",bulkPurged:"{{count}} dosya silindi."}},libraryPurge:{title:"Eski dosyaları temizle",headerButton:"Eskileri temizle",headerTooltip:"Eski dosyaları toplu olarak çöpe taşı",description:"Kütüphanenizden eski dosyaları tek seferde temizleyin. Baskı geçmişi olan dosyalar son baskı tarihlerine göre, hiç yazdırılmamış dosyalar yükleme tarihlerine göre yaşlandırılır.",ageLabel:"Şundan eski dosyaları taşı",days:"gün",includeNeverPrinted:"Hiç yazdırılmamış dosyaları dahil et",effectsTitle:"Temizle'ye tıkladığınızda ne olur",effect1:"Eşleşen dosyalar Çöp'e taşınır — henüz diskten silinmezler.",effect2:"Saklama penceresi sona erene kadar Çöp'ten herhangi bir zamanda geri yükleyebilirsiniz.",effect3:"Saklama sonrası, çöp süpürücü onları kalıcı olarak diskten kaldırır.",effect4:"Harici (bağlı) klasörlerdeki dosyalar atlanır — Bambuddy asla sahibi olmadığı bayt'ları silmez.",previewLoading:"Kaç dosyanın eşleştiği kontrol ediliyor…",previewFailed:"Temizleme önizlenemedi.",previewSummary:"{{count}} dosya · {{size}} çöpe taşınacak",andMore:"…ve {{count}} daha",warning:"Çöpe taşınan dosyalar saklama penceresi sona erene kadar depolama alanını kullanmaya devam eder. Diski hemen boşaltmak için sonradan Çöp'ü boşaltın.",confirmCta:"{{count}} dosyayı çöpe taşı",purging:"Çöpe taşınıyor…",toast:{success:"{{count}} dosya çöpe taşındı.",failed:"Dosyalar temizlenemedi."}},libraryAutoPurge:{enableLabel:"Eski dosyaları otomatik temizle",enableDescription:"Yönetici temizliğini günde bir kez çalıştırır. Dosyalar önce Çöp'e gider — hemen silinmezler.",ageLabel:"Şundan eski dosyaları otomatik temizle",ageDescription:"Minimum 7 gün, maksimum 10 yıl. Manuel Temizle düğmesiyle aynı yaş kuralını kullanır.",days:"gün",includeNeverPrinted:"Hiç yazdırılmamış dosyaları dahil et",saveFailed:"Otomatik temizleme ayarları kaydedilemedi."},archivePurge:{headerButton:"Eskileri temizle",headerTooltip:"Eski arşivleri toplu olarak sil",title:"Eski arşivleri temizle",description:"Eski baskı geçmişini temizleyin. Her arşiv en son baskı tamamlamasına göre yaşlandırılır — bir arşivi yeniden yazdırmak yaşını yeniler, bu nedenle aktif çalışma asla temizlenmez.",ageLabel:"Son şu kadar günde yazdırılmamış arşivleri sil",days:"gün",effectsTitle:"Temizle'ye tıkladığınızda ne olur",effect1:"Eşleşen her arşiv listelerden gizlenir ve dosyaları diskten kaldırılır (3MF, küçük resim, zaman atlamalı video, kaynak 3MF, F3D tasarım dosyası, fotoğraflar).",effect2:"Arşiv satırı veritabanında kalır, böylece Hızlı İstatistikler filament, süre, maliyet ve enerji katkısını korur — tek arşiv silme varsayılanı ile aynı.",effect3:'Hızlı İstatistik katkısını da kaldırmak için aşağıdaki "Ayrıca istatistiklerden kaldır"ı işaretleyin (tek arşiv silme seçeneğiyle eşleşir). O yol geri alınamaz.',effect4:"Bir arşivi yeniden yazdırmak yaş saatini yeniler, bu nedenle hâlâ kullandığınız arşivler güvendedir.",purgeStatsLabel:"Ayrıca istatistiklerden kaldır",purgeStatsHint:"Eşleşen arşivleri Hızlı İstatistiklerden (filament, süre, maliyet, enerji) düşürür. Bu olmadan, Hızlı İstatistikler her katkıyı korur ve yalnızca dosyalar diskten ayrılır.",previewLoading:"Kaç arşivin eşleştiği kontrol ediliyor…",previewFailed:"Temizleme önizlenemedi.",previewSummary:"{{count}} arşiv · {{size}} kaldırılacak",andMore:"…ve {{count}} daha",warning:"Dosyalar diskten kaldırılır ve geri yüklenemez. Devam etmeden önce saklamak istediğiniz her şeyi indirin veya favoriye ekleyin.",confirmCta:"{{count}} arşivi kaldır",purging:"Kaldırılıyor…",toast:{success:"{{count}} arşiv kaldırıldı.",failed:"Arşivler temizlenemedi."}},archiveAutoPurge:{enableLabel:"Eski arşivleri otomatik temizle",enableDescription:"Günde bir kez, eşik içinde yazdırılmamışsa arşivleri listelerden gizler ve dosyalarını diskten kaldırır. Bir arşivi yeniden yazdırmak saati sıfırlar.",ageLabel:"Son şu kadar günde yazdırılmamış arşivleri otomatik sil",ageDescription:"Minimum 7 gün, maksimum 10 yıl. En son baskı tamamlamasına göre — bir arşivi yeniden yazdırmak yaşını yeniler. 3MF, küçük resim, zaman atlamalı video, kaynak 3MF, F3D ve fotoğrafları kaldırır.",days:"gün",purgeStatsLabel:"Ayrıca istatistiklerden kaldır",purgeStatsDescription:"Etkinleştirildiğinde, günlük süpürücü her temizlenen arşivi de Hızlı İstatistiklerden (filament, süre, maliyet, enerji) düşürür. Varsayılan kapalı — Hızlı İstatistikler katkıyı korur, yalnızca dosyalar diskten ayrılır.",runNow:"Arşivleri şimdi temizle",saveFailed:"Otomatik temizleme ayarları kaydedilemedi."},cameraTokens:{title:"Kamera API Belirteçleri",navTitle:"Kamera API belirteçleri",description:"Kamera akışını Home Assistant, Frigate, kiosklar veya kararlı bir URL'ye ihtiyaç duyan başka herhangi bir araca gömmek için uzun ömürlü belirteçler. Her belirteç yalnızca kamera akışı içindir ve herhangi bir zamanda iptal edilebilir.",loading:"Yükleniyor…",confirmRevoke:{title:"Bu belirteç iptal edilsin mi?",body:'"{{name}}" kullanan herhangi bir cihaz hemen erişimini kaybedecek. Bu geri alınamaz.',cancel:"İptal",confirm:"İptal Et"},create:{title:"Yeni belirteç oluştur",nameLabel:"Belirteç adı",namePlaceholder:"örn. Home Assistant",daysLabel:"Sona erene kadar gün",submit:"Oluştur",hint:"Maksimum ömür 365 gün. Belirteç değeri oluşturmada yalnızca bir kez gösterilir — şimdi kopyalayın."},created:{title:"Belirteç oluşturuldu — şimdi kopyalayın",warning:"Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.",copy:"Kopyala",dismiss:"Kaydettim"},list:{myTitle:"Belirteçlerim",allTitle:"Tüm kullanıcılar (yönetici görünümü)",empty:"Henüz belirteç yok.",name:"Ad",owner:"Sahip",prefix:"Önek",created:"Oluşturuldu",expires:"Sona Erer",lastUsed:"Son kullanım",revoke:"İptal Et",expired:"Süresi Doldu"},toast:{created:"Belirteç oluşturuldu",createFailed:"Belirteç oluşturulamadı",revoked:"Belirteç iptal edildi",revokeFailed:"Belirteç iptal edilemedi",loadFailed:"Belirteçler yüklenemedi",copied:"Panoya kopyalandı",copyFailed:"Kopyalama başarısız — manuel olarak seçip kopyalayın"}},forecast:{title:"Tahmin",noSpools:"Aktif makara bulunamadı. Tahmin verisini görmek için envanterinize makara ekleyin.",noUsageData:"Kullanım verisi mevcut değil — stok zaman çizelgesi öngörülemiyor.",sku:"SKU",material:"Malzeme",stock:"Stok",dailyRate:"Oran",daysLeft:"Kalan Gün",emptyBy:"Şu Tarihte Boş",reorderBy:"Şu Tarihte Sipariş",actions:"İşlemler",trend:"Trend",estimated:"Tahmini",noData:"Veri yok",timeframe:"Zaman Aralığı",chartTitle:"Öngörülen Stok — İlk 5 Malzeme",dashedLinesROP:"Kesik çizgiler = yeniden sipariş noktaları",stockLevel:"Stok Seviyesi",reorderPoint:"Yeniden Sipariş Noktası",safetyMargin:"Güvenlik Marjı",trendLegend:"Trend (geçmiş tabanlı, %95 servis seviyesi)",estimatedLegend:"Tahmini (ağırlık farkı)",noDataLegend:"Veri yok",ropLabel:"ROP",ssLabel:"SS",safetyStockLegend:"Güvenlik stoku",stockArrivalLegend:"Stok varışı",stockoutLegend:"Stok tükenmesi",alertCount_one:"{{count}} uyarı",alertCount_other:"{{count}} uyarı",order:"Sipariş",save:"Kaydet",cancel:"İptal",settingsSaved:"Ayarlar kaydedildi",failedSaveSettings:"Ayarlar kaydedilemedi",globalLeadTimeSaved:"Global teslim süresi kaydedildi",globalLeadTime:"Global teslim süresi",globalLeadTimeHint:"Global teslim süresi tabanı — tüm SKU'lar için yeniden sipariş noktası hesaplamasında kullanılır",skuLeadTimeOverride:"SKU Teslim Süresi Geçersiz Kılma",skuLeadTimeHint:"0 = global teslim süresini kullan. Bu SKU için geçersiz kılmak için >0 ayarlayın.",safetyMarginLabel:"Güvenlik Marjı",effectiveLeadTime:"Etkili Teslim Süresi",effectiveLeadTimeHint:"max(global {{global}}g, SKU {{sku}}g)",reorderPointHint:"d̄ × LT + güvenlik marjı — stok bu seviyeye geldiğinde sipariş ver",safetyMarginHint:"İstatistiksel güvenlik stoku (z=1.65 × σ × √LT) + kullanıcı tanımlı tampon",safetyMarginHintDays:"İstatistiksel güvenlik stokunun üzerine eklenen tampon.{{approx}}",safetyMarginHintDaysApprox:" ≈ mevcut oranda {{g}}g.",safetyMarginHintG:"İstatistiksel güvenlik stokunun üzerine eklenen sabit ağırlık tamponu.{{approx}}",safetyMarginHintGApprox:" ≈ mevcut oranda {{days}}g.",individualSpools:"Bireysel makaralar",labelWeight:"Etiket",spoolCount_one:"{{count}} makara",spoolCount_other:"{{count}} makara",stockBreakRisk:"Stok tükenme riski",stockBreakDetail:"{{days}}g kaldı, teslim süresi {{lt}}g.",stockBreakBefore:"Yenileme öncesi stok tükenmesi",reorderNow:"Şimdi sipariş ver",reorderTriggerPassed:"Tetikleyici tarih {{date}} geçti.",shoppingList:"Alışveriş Listesi",shoppingListItems_one:"({{count}} öğe)",shoppingListItems_other:"({{count}} öğe)",shoppingListEmpty:"Alışveriş listesi boş. Öğe eklemek için herhangi bir satırdaki sepet simgesine tıklayın.",addToCart:"Alışveriş listesine ekle",alertsSnoozed:"Bu SKU için uyarıları sustur",alertsEnabled:"Bu SKU için uyarıları aç",addedToCart:"Alışveriş listesine eklendi",failedAddItem:"Öğe eklenemedi",listView:"Liste",logisticsView:"Lojistik",qty:"Adet",weight:"Ağırlık",leadTime:"Teslim Süresi",expectedRestock:"Beklenen Yenileme",status:"Durum",note:"Not",pending:"Beklemede",purchased:"Satın Alındı",received:"Teslim Alındı",markPurchased:"Satın alındı olarak işaretle",markReceived:"Teslim alındı olarak işaretle — makaraları Stok envanterine ekler",resetToPending:"Beklemeye sıfırla",remove:"Kaldır",clearAll:"Tümünü temizle",downloadCsv:"CSV",addToCartTitle:"Alışveriş Listesine Ekle",byQuantity:"Miktara Göre",byDuration:"Süreye Göre",numberOfSpools:"Makara sayısı",lastHowManyDays:"Kaç gün dayanmalı?",noUsageQty:"Kullanım verisi yok — miktar 1 olarak ayarlandı.",noteOptional:"Not (isteğe bağlı)",notePlaceholder:"örn. X projesi için, acil…",addNSpools_one:"{{count}} makara ekle",addNSpools_other:"{{count}} makara ekle",onArrival:"Varışta",stockBreakIn:"{{days}}g içinde stok tükenmesi.",stockRunsOutBefore:"{{lt}}g teslim süresi dolmadan önce stok tükenir.",atRate:"{{rate}}g/gün oranında size gerekli",moreSpools_one:"{{count}} daha makara",moreSpools_other:"{{count}} daha makara",bridgeGap:"boşluğu kapatmak için.",noReadAccess:"Envanter tahminlerini görüntüleme izniniz yok.",noWriteAccess:"Tahmin ayarlarını değiştirme izniniz yok."}},zpe={en:{translation:Cpe},de:{translation:Npe},es:{translation:Tpe},fr:{translation:Ape},ja:{translation:jpe},it:{translation:Mpe},ko:{translation:Epe},"pt-BR":{translation:Dpe},"zh-CN":{translation:Fpe},"zh-TW":{translation:Lpe},tr:{translation:Rpe}};jo.use(wZ).use(npe).init({resources:zpe,fallbackLng:"en",supportedLngs:["en","de","es","fr","ja","it","ko","pt-BR","tr","zh-CN","zh-TW"],detection:{order:["localStorage","navigator","htmlTag"],lookupLocalStorage:"bambutrack_language",caches:["localStorage"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const h8=[{code:"en",name:"English",nativeName:"English"},{code:"de",name:"German",nativeName:"Deutsch"},{code:"es",name:"Spanish",nativeName:"Español"},{code:"fr",name:"French",nativeName:"Français"},{code:"ja",name:"Japanese",nativeName:"日本語"},{code:"it",name:"Italian",nativeName:"Italiano"},{code:"ko",name:"Korean",nativeName:"한국어"},{code:"pt-BR",name:"Portuguese (Brazil)",nativeName:"Português (Brasil)"},{code:"zh-CN",name:"Chinese (Simplified)",nativeName:"简体中文"},{code:"zh-TW",name:"Chinese (Traditional)",nativeName:"繁體中文"},{code:"tr",name:"Turkish",nativeName:"Türkçe"}];var f8="popstate";function Ipe(t={}){function e(n,i){let{pathname:s,search:o,hash:l}=n.location;return YL("",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||"default")}function a(n,i){return typeof i=="string"?i:Iw(i)}return Bpe(e,a,null,t)}function Un(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function jc(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function Ope(){return Math.random().toString(36).substring(2,10)}function g8(t,e){return{usr:t.state,key:t.key,idx:e}}function YL(t,e,a=null,n){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?Qy(e):e,state:a,key:e&&e.key||n||Ope()}}function Iw({pathname:t="/",search:e="",hash:a=""}){return e&&e!=="?"&&(t+=e.charAt(0)==="?"?e:"?"+e),a&&a!=="#"&&(t+=a.charAt(0)==="#"?a:"#"+a),t}function Qy(t){let e={};if(t){let a=t.indexOf("#");a>=0&&(e.hash=t.substring(a),t=t.substring(0,a));let n=t.indexOf("?");n>=0&&(e.search=t.substring(n),t=t.substring(0,n)),t&&(e.pathname=t)}return e}function Bpe(t,e,a,n={}){let{window:i=document.defaultView,v5Compat:s=!1}=n,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:v.location,delta:x})}function p(b,x){l="PUSH";let S=YL(v.location,b,x);d=u()+1;let k=g8(S,d),C=v.createHref(S);try{o.pushState(k,"",C)}catch(N){if(N instanceof DOMException&&N.name==="DataCloneError")throw N;i.location.assign(C)}s&&c&&c({action:l,location:v.location,delta:1})}function f(b,x){l="REPLACE";let S=YL(v.location,b,x);d=u();let k=g8(S,d),C=v.createHref(S);o.replaceState(k,"",C),s&&c&&c({action:l,location:v.location,delta:0})}function g(b){return Upe(b)}let v={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(f8,m),c=b,()=>{i.removeEventListener(f8,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 v}function Upe(t,e=!1){let a="http://localhost";typeof window<"u"&&(a=window.location.origin!=="null"?window.location.origin:window.location.href),Un(a,"No window.location.(origin|href) available to create URL");let n=typeof t=="string"?t:Iw(t);return n=n.replace(/ $/,"%20"),!e&&n.startsWith("//")&&(n=a+n),new URL(n,a)}function SZ(t,e,a="/"){return Hpe(t,e,a,!1)}function Hpe(t,e,a,n){let i=typeof e=="string"?Qy(e):e,s=Ym(i.pathname||"/",a);if(s==null)return null;let o=kZ(t);qpe(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(n)&&c)return;Un(u.relativePath.startsWith(n),`Absolute route path "${u.relativePath}" nested under path "${n}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(n.length)}let m=zm([n,u.relativePath]),p=a.concat(u);o.children&&o.children.length>0&&(Un(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${m}".`),kZ(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:Qpe(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===""||!o.path?.includes("?"))s(o,l);else for(let c of _Z(o.path))s(o,l,!0,c)}),e}function _Z(t){let e=t.split("/");if(e.length===0)return[];let[a,...n]=e,i=a.endsWith("?"),s=a.replace(/\?$/,"");if(n.length===0)return i?[s,""]:[s];let o=_Z(n.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 qpe(t){t.sort((e,a)=>e.score!==a.score?a.score-e.score:Xpe(e.routesMeta.map(n=>n.childrenIndex),a.routesMeta.map(n=>n.childrenIndex)))}var Vpe=/^:[\w-]+$/,Gpe=3,$pe=2,Wpe=1,Kpe=10,Ype=-2,b8=t=>t==="*";function Qpe(t,e){let a=t.split("/"),n=a.length;return a.some(b8)&&(n+=Ype),e&&(n+=$pe),a.filter(i=>!b8(i)).reduce((i,s)=>i+(Vpe.test(s)?Gpe:s===""?Wpe:Kpe),n)}function Xpe(t,e){return t.length===e.length&&t.slice(0,-1).every((n,i)=>n===e[i])?t[t.length-1]-e[e.length-1]:0}function Zpe(t,e,a=!1){let{routesMeta:n}=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 Jpe(t,e=!1,a=!0){jc(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 n=[],i="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(n.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(n.push({paramName:"*"}),i+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):a?i+="\\/*$":t!==""&&t!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,e?void 0:"i"),n]}function ehe(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return jc(!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 Ym(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let a=e.endsWith("/")?e.length-1:e.length,n=t.charAt(a);return n&&n!=="/"?null:t.slice(a)||"/"}var the=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function ahe(t,e="/"){let{pathname:a,search:n="",hash:i=""}=typeof t=="string"?Qy(t):t,s;return a?(a=a.replace(/\/\/+/g,"/"),a.startsWith("/")?s=x8(a.substring(1),"/"):s=x8(a,e)):s=e,{pathname:s,search:ihe(n),hash:she(i)}}function x8(t,e){let a=e.replace(/\/+$/,"").split("/");return t.split("/").forEach(i=>{i===".."?a.length>1&&a.pop():i!=="."&&a.push(i)}),a.length>1?a.join("/"):"/"}function xE(t,e,a,n){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(n)}]. Please separate it out to the \`to.${a}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function rhe(t){return t.filter((e,a)=>a===0||e.route.path&&e.route.path.length>0)}function kI(t){let e=rhe(t);return e.map((a,n)=>n===e.length-1?a.pathname:a.pathnameBase)}function _I(t,e,a,n=!1){let i;typeof t=="string"?i=Qy(t):(i={...t},Un(!i.pathname||!i.pathname.includes("?"),xE("?","pathname","search",i)),Un(!i.pathname||!i.pathname.includes("#"),xE("#","pathname","hash",i)),Un(!i.search||!i.search.includes("#"),xE("#","search","hash",i)));let s=t===""||i.pathname==="",o=s?"/":i.pathname,l;if(o==null)l=a;else{let m=e.length-1;if(!n&&o.startsWith("..")){let p=o.split("/");for(;p[0]==="..";)p.shift(),m-=1;i.pathname=p.join("/")}l=m>=0?e[m]:"/"}let c=ahe(i,l),d=o&&o!=="/"&&o.endsWith("/"),u=(s||o===".")&&a.endsWith("/");return!c.pathname.endsWith("/")&&(d||u)&&(c.pathname+="/"),c}var zm=t=>t.join("/").replace(/\/\/+/g,"/"),nhe=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),ihe=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,she=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t,ohe=class{constructor(t,e,a,n=!1){this.status=t,this.statusText=e||"",this.internal=n,a instanceof Error?(this.data=a.toString(),this.error=a):this.data=a}};function lhe(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}function che(t){return t.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var PZ=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function CZ(t,e){let a=t;if(typeof a!="string"||!the.test(a))return{absoluteURL:void 0,isExternal:!1,to:a};let n=a,i=!1;if(PZ)try{let s=new URL(window.location.href),o=a.startsWith("//")?new URL(s.protocol+a):new URL(a),l=Ym(o.pathname,e);o.origin===s.origin&&l!=null?a=l+o.search+o.hash:i=!0}catch{jc(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:n,isExternal:i,to:a}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var NZ=["POST","PUT","PATCH","DELETE"];new Set(NZ);var dhe=["GET",...NZ];new Set(dhe);var Xy=y.createContext(null);Xy.displayName="DataRouter";var jT=y.createContext(null);jT.displayName="DataRouterState";var uhe=y.createContext(!1),TZ=y.createContext({isTransitioning:!1});TZ.displayName="ViewTransition";var mhe=y.createContext(new Map);mhe.displayName="Fetchers";var phe=y.createContext(null);phe.displayName="Await";var Ol=y.createContext(null);Ol.displayName="Navigation";var DS=y.createContext(null);DS.displayName="Location";var Oc=y.createContext({outlet:null,matches:[],isDataRoute:!1});Oc.displayName="Route";var PI=y.createContext(null);PI.displayName="RouteError";var AZ="REACT_ROUTER_ERROR",hhe="REDIRECT",fhe="ROUTE_ERROR_RESPONSE";function ghe(t){if(t.startsWith(`${AZ}:${hhe}:{`))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 bhe(t){if(t.startsWith(`${AZ}:${fhe}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e=="object"&&e&&typeof e.status=="number"&&typeof e.statusText=="string")return new ohe(e.status,e.statusText,e.data)}catch{}}function xhe(t,{relative:e}={}){Un(Zy(),"useHref() may be used only in the context of a component.");let{basename:a,navigator:n}=y.useContext(Ol),{hash:i,pathname:s,search:o}=LS(t,{relative:e}),l=s;return a!=="/"&&(l=s==="/"?a:zm([a,s])),n.createHref({pathname:l,search:o,hash:i})}function Zy(){return y.useContext(DS)!=null}function rl(){return Un(Zy(),"useLocation() may be used only in the context of a component."),y.useContext(DS).location}var jZ="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function MZ(t){y.useContext(Ol).static||y.useLayoutEffect(t)}function Ii(){let{isDataRoute:t}=y.useContext(Oc);return t?Ehe():yhe()}function yhe(){Un(Zy(),"useNavigate() may be used only in the context of a component.");let t=y.useContext(Xy),{basename:e,navigator:a}=y.useContext(Ol),{matches:n}=y.useContext(Oc),{pathname:i}=rl(),s=JSON.stringify(kI(n)),o=y.useRef(!1);return MZ(()=>{o.current=!0}),y.useCallback((c,d={})=>{if(jc(o.current,jZ),!o.current)return;if(typeof c=="number"){a.go(c);return}let u=_I(c,JSON.parse(s),i,d.relative==="path");t==null&&e!=="/"&&(u.pathname=u.pathname==="/"?e:zm([e,u.pathname])),(d.replace?a.replace:a.push)(u,d.state,d)},[e,a,s,i,t])}var EZ=y.createContext(null);function Jy(){return y.useContext(EZ)}function vhe(t){let e=y.useContext(Oc).outlet;return y.useMemo(()=>e&&y.createElement(EZ.Provider,{value:t},e),[e,t])}function FS(){let{matches:t}=y.useContext(Oc),e=t[t.length-1];return e?e.params:{}}function LS(t,{relative:e}={}){let{matches:a}=y.useContext(Oc),{pathname:n}=rl(),i=JSON.stringify(kI(a));return y.useMemo(()=>_I(t,JSON.parse(i),n,e==="path"),[t,i,n,e])}function whe(t,e){return DZ(t,e)}function DZ(t,e,a,n,i){Un(Zy(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=y.useContext(Ol),{matches:o}=y.useContext(Oc),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||"";LZ(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=rl(),f;if(e){let S=typeof e=="string"?Qy(e):e;Un(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||"/",v=g;if(u!=="/"){let S=u.replace(/^\//,"").split("/");v="/"+g.replace(/^\//,"").split("/").slice(S.length).join("/")}let b=SZ(t,{pathname:v});jc(m||b!=null,`No routes matched location "${f.pathname}${f.search}${f.hash}" `),jc(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=Che(b&&b.map(S=>Object.assign({},S,{params:Object.assign({},c,S.params),pathname:zm([u,s.encodeLocation?s.encodeLocation(S.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:S.pathname]),pathnameBase:S.pathnameBase==="/"?u:zm([u,s.encodeLocation?s.encodeLocation(S.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:S.pathnameBase])})),o,a,n,i);return e&&x?y.createElement(DS.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...f},navigationType:"POP"}},x):x}function She(){let t=Mhe(),e=lhe(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),a=t instanceof Error?t.stack:null,n="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:n},s={padding:"2px 4px",backgroundColor:n},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),a?y.createElement("pre",{style:i},a):null,o)}var khe=y.createElement(She,null),FZ=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 a=bhe(t.digest);a&&(t=a)}let e=t!==void 0?y.createElement(Oc.Provider,{value:this.props.routeContext},y.createElement(PI.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?y.createElement(_he,{error:t},e):e}};FZ.contextType=uhe;var yE=new WeakMap;function _he({children:t,error:e}){let{basename:a}=y.useContext(Ol);if(typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){let n=ghe(e.digest);if(n){let i=yE.get(e);if(i)throw i;let s=CZ(n.location,a);if(PZ&&!yE.get(e))if(s.isExternal||n.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:n.replace}));throw yE.set(e,o),o}return y.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function Phe({routeContext:t,match:e,children:a}){let n=y.useContext(Xy);return n&&n.static&&n.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(n.staticContext._deepestRenderedBoundaryId=e.route.id),y.createElement(Oc.Provider,{value:t},a)}function Che(t,e=[],a=null,n=null,i=null){if(t==null){if(!a)return null;if(a.errors)t=a.matches;else if(e.length===0&&!a.initialized&&a.matches.length>0)t=a.matches;else return null}let s=t,o=a?.errors;if(o!=null){let u=s.findIndex(m=>m.route.id&&o?.[m.route.id]!==void 0);Un(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(a)for(let u=0;u=0?s=s.slice(0,c+1):s=[s[0]];break}}}let d=a&&n?(u,m)=>{n(u,{location:a.location,params:a.matches?.[0]?.params??{},unstable_pattern:che(a.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,g=!1,v=null,b=null;a&&(f=o&&m.route.id?o[m.route.id]:void 0,v=m.route.errorElement||khe,l&&(c<0&&p===0?(LZ("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 k;return f?k=v:g?k=b:m.route.Component?k=y.createElement(m.route.Component,null):m.route.element?k=m.route.element:k=u,y.createElement(Phe,{match:m,routeContext:{outlet:u,matches:x,isDataRoute:a!=null},children:k})};return a&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?y.createElement(FZ,{location:a.location,revalidation:a.revalidation,component:v,error:f,children:S(),routeContext:{outlet:null,matches:x,isDataRoute:!0},onError:d}):S()},null)}function CI(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Nhe(t){let e=y.useContext(Xy);return Un(e,CI(t)),e}function The(t){let e=y.useContext(jT);return Un(e,CI(t)),e}function Ahe(t){let e=y.useContext(Oc);return Un(e,CI(t)),e}function NI(t){let e=Ahe(t),a=e.matches[e.matches.length-1];return Un(a.route.id,`${t} can only be used on routes that contain a unique "id"`),a.route.id}function jhe(){return NI("useRouteId")}function Mhe(){let t=y.useContext(PI),e=The("useRouteError"),a=NI("useRouteError");return t!==void 0?t:e.errors?.[a]}function Ehe(){let{router:t}=Nhe("useNavigate"),e=NI("useNavigate"),a=y.useRef(!1);return MZ(()=>{a.current=!0}),y.useCallback(async(i,s={})=>{jc(a.current,jZ),a.current&&(typeof i=="number"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var y8={};function LZ(t,e,a){!e&&!y8[t]&&(y8[t]=!0,jc(!1,a))}y.memo(Dhe);function Dhe({routes:t,future:e,state:a,onError:n}){return DZ(t,void 0,a,n,e)}function wg({to:t,replace:e,state:a,relative:n}){Un(Zy()," may be used only in the context of a component.");let{static:i}=y.useContext(Ol);jc(!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(Oc),{pathname:o}=rl(),l=Ii(),c=_I(t,kI(s),o,n==="path"),d=JSON.stringify(c);return y.useEffect(()=>{l(JSON.parse(d),{replace:e,state:a,relative:n})},[l,d,n,e,a]),null}function RZ(t){return vhe(t.context)}function Er(t){Un(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Fhe({basename:t="/",children:e=null,location:a,navigationType:n="POP",navigator:i,static:s=!1,unstable_useTransitions:o}){Un(!Zy(),"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 a=="string"&&(a=Qy(a));let{pathname:d="/",search:u="",hash:m="",state:p=null,key:f="default"}=a,g=y.useMemo(()=>{let v=Ym(d,l);return v==null?null:{location:{pathname:v,search:u,hash:m,state:p,key:f},navigationType:n}},[l,d,u,m,p,f,n]);return jc(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(Ol.Provider,{value:c},y.createElement(DS.Provider,{children:e,value:g}))}function Lhe({children:t,location:e}){return whe(QL(t),e)}function QL(t,e=[]){let a=[];return y.Children.forEach(t,(n,i)=>{if(!y.isValidElement(n))return;let s=[...e,i];if(n.type===y.Fragment){a.push.apply(a,QL(n.props.children,s));return}Un(n.type===Er,`[${typeof n.type=="string"?n.type:n.type.name}] is not a component. All component children of must be a or `),Un(!n.props.index||!n.props.children,"An index route cannot have child routes.");let o={id:n.props.id||s.join("-"),caseSensitive:n.props.caseSensitive,element:n.props.element,Component:n.props.Component,index:n.props.index,path:n.props.path,middleware:n.props.middleware,loader:n.props.loader,action:n.props.action,hydrateFallbackElement:n.props.hydrateFallbackElement,HydrateFallback:n.props.HydrateFallback,errorElement:n.props.errorElement,ErrorBoundary:n.props.ErrorBoundary,hasErrorBoundary:n.props.hasErrorBoundary===!0||n.props.ErrorBoundary!=null||n.props.errorElement!=null,shouldRevalidate:n.props.shouldRevalidate,handle:n.props.handle,lazy:n.props.lazy};n.props.children&&(o.children=QL(n.props.children,s)),a.push(o)}),a}var NC="get",TC="application/x-www-form-urlencoded";function MT(t){return typeof HTMLElement<"u"&&t instanceof HTMLElement}function Rhe(t){return MT(t)&&t.tagName.toLowerCase()==="button"}function zhe(t){return MT(t)&&t.tagName.toLowerCase()==="form"}function Ihe(t){return MT(t)&&t.tagName.toLowerCase()==="input"}function Ohe(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function Bhe(t,e){return t.button===0&&(!e||e==="_self")&&!Ohe(t)}function XL(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,a)=>{let n=t[a];return e.concat(Array.isArray(n)?n.map(i=>[a,i]):[[a,n]])},[]))}function Uhe(t,e){let a=XL(t);return e&&e.forEach((n,i)=>{a.has(i)||e.getAll(i).forEach(s=>{a.append(i,s)})}),a}var L_=null;function Hhe(){if(L_===null)try{new FormData(document.createElement("form"),0),L_=!1}catch{L_=!0}return L_}var qhe=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function vE(t){return t!=null&&!qhe.has(t)?(jc(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${TC}"`),null):t}function Vhe(t,e){let a,n,i,s,o;if(zhe(t)){let l=t.getAttribute("action");n=l?Ym(l,e):null,a=t.getAttribute("method")||NC,i=vE(t.getAttribute("enctype"))||TC,s=new FormData(t)}else if(Rhe(t)||Ihe(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a