diff --git a/CHANGELOG.md b/CHANGELOG.md index 42815a09e..4e1238541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.5b2] - Unreleased ### Fixed +- **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again. - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled. - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push. - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo). diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 2dd1efaa3..916a36775 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -37,7 +37,11 @@ from backend.app.schemas.print_queue import ( from backend.app.services.filament_deficit import compute_deficit_for_queue_item from backend.app.services.filament_requirements import overrides_for_plate from backend.app.services.notification_service import notification_service -from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id +from backend.app.utils.printer_models import ( + is_gcode_compatible, + normalize_printer_model, + normalize_printer_model_id, +) from backend.app.utils.threemf_tools import ( extract_bed_type_from_3mf, extract_filament_usage_from_3mf, @@ -466,6 +470,22 @@ async def add_to_queue( except InvalidFilenameError as e: raise HTTPException(400, str(e)) from e + # Cross-model safety gate (#2578): a G-code 3MF sliced for one model must + # not be queued for dispatch to an incompatible model. The UI can no longer + # produce such rows, but API-created rows must be rejected here too — the + # scheduler assigns model-based items to hardware with no human in the loop. + if target_model_norm: + sliced_for = None + if archive: + sliced_for = archive.sliced_for_model + elif library_file and library_file.file_metadata: + sliced_for = library_file.file_metadata.get("sliced_for_model") + if not is_gcode_compatible(sliced_for, target_model_norm): + raise HTTPException( + 400, + f"File was sliced for {sliced_for} and cannot be dispatched to {target_model_norm} printers", + ) + # Extract filament types for model-based assignment (used by scheduler for validation) required_filament_types = None file_path = None @@ -1099,6 +1119,23 @@ async def update_queue_item( if not result.scalars().first(): raise HTTPException(400, f"No active printers for model: {update_data['target_model']}") + # Cross-model safety gate (#2578) — same check as the create route, so + # a mismatched target can't be introduced by editing either. + sliced_for = None + if item.archive_id: + result = await db.execute(select(PrintArchive.sliced_for_model).where(PrintArchive.id == item.archive_id)) + sliced_for = result.scalar_one_or_none() + elif item.library_file_id: + result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id)) + lib = result.scalar_one_or_none() + if lib and lib.file_metadata: + sliced_for = lib.file_metadata.get("sliced_for_model") + if not is_gcode_compatible(sliced_for, update_data["target_model"]): + raise HTTPException( + 400, + f"File was sliced for {sliced_for} and cannot be dispatched to {update_data['target_model']} printers", + ) + # Serialize ams_mapping to JSON for TEXT column storage if "ams_mapping" in update_data: update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 86b3bbe3b..5dd0fc384 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -43,7 +43,7 @@ from backend.app.services.printer_manager import ( ) from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.utils.filename import derive_remote_filename -from backend.app.utils.printer_models import normalize_printer_model +from backend.app.utils.printer_models import is_gcode_compatible, normalize_printer_model logger = logging.getLogger(__name__) @@ -290,6 +290,13 @@ class PrintScheduler: result = await db.execute( select(PrintQueueItem) .where(PrintQueueItem.status == "pending") + # archive/library_file are read by the cross-model gate + # (#2578); eager-load once per pass instead of a lazy-load + # (which would raise in async) per item. + .options( + selectinload(PrintQueueItem.archive), + selectinload(PrintQueueItem.library_file), + ) .order_by( PrintQueueItem.printer_id, PrintQueueItem.target_model, @@ -302,6 +309,10 @@ class PrintScheduler: result = await db.execute( select(PrintQueueItem) .where(PrintQueueItem.status == "pending") + .options( + selectinload(PrintQueueItem.archive), + selectinload(PrintQueueItem.library_file), + ) .order_by(PrintQueueItem.printer_id, PrintQueueItem.position) ) items = list(result.scalars().all()) @@ -568,15 +579,34 @@ class PrintScheduler: # Merge: keep original types for non-overridden slots, add override types effective_types = sorted(set(required_types or []) | set(override_types)) - printer_id, waiting_reason = await self._find_idle_printer_for_model( - db, - item.target_model, - busy_printers, - effective_types, - item.target_location, - filament_overrides=filament_overrides, - require_plate_clear=require_plate_clear, - ) + # Cross-model safety gate (#2578): never hand a 3MF sliced + # for an incompatible model to a printer, no matter how the + # row got into the DB (old rows, direct API writes). Held + # as pending with an actionable waiting_reason — the user + # fixes it by editing the item's target model. + sliced_for = None + if item.archive: + sliced_for = item.archive.sliced_for_model + elif item.library_file and item.library_file.file_metadata: + sliced_for = item.library_file.file_metadata.get("sliced_for_model") + + if not is_gcode_compatible(sliced_for, item.target_model): + printer_id = None + waiting_reason = ( + f"File was sliced for {sliced_for}, which is not compatible with " + f"{item.target_model} — edit the item and fix its target model" + ) + skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1 + else: + printer_id, waiting_reason = await self._find_idle_printer_for_model( + db, + item.target_model, + busy_printers, + effective_types, + item.target_location, + filament_overrides=filament_overrides, + require_plate_clear=require_plate_clear, + ) # Update waiting_reason if changed and send notification when first waiting if item.waiting_reason != waiting_reason: diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py index 2087a9133..02fca3214 100644 --- a/backend/app/utils/printer_models.py +++ b/backend/app/utils/printer_models.py @@ -281,6 +281,41 @@ def get_rod_type(model: str | None) -> str | None: return None +# G-code interchange families (#2578). A sliced 3MF may target a different +# model ONLY within its family: same kinematics, build volume and G-code +# dialect. X1/P1 series are the one proven-interchangeable group (256mm +# CoreXY, single nozzle — mixed farms intentionally run X1-sliced jobs on +# P1S/P1P). Everything else is exact-match only; extend deliberately, never +# by assumption — a wrong entry here dispatches G-code onto hardware it was +# not sliced for. +# Short display names only (uppercase, no spaces) — is_gcode_compatible() +# resolves internal codes (C11, O1D, ...) to short names before lookup. +GCODE_COMPAT_FAMILIES = (frozenset(["X1", "X1C", "X1E", "P1P", "P1S"]),) + + +def is_gcode_compatible(sliced_for_model: str | None, target_model: str | None) -> bool: + """Return True when G-code sliced for one model may be dispatched to the other. + + Unknown/missing metadata on either side returns True — we can only + validate what the 3MF declares, and legacy files without + ``sliced_for_model`` must keep working. + """ + if not sliced_for_model or not target_model: + return True + + def _norm(model: str) -> str: + # Internal codes (e.g. "C11") → short names first, so "C11" vs "X1C" + # compares equal instead of leaning on family membership. + resolved = PRINTER_MODEL_ID_MAP.get(model.strip(), model) + return resolved.strip().upper().replace(" ", "").replace("-", "") + + a = _norm(sliced_for_model) + b = _norm(target_model) + if a == b: + return True + return any(a in family and b in family for family in GCODE_COMPAT_FAMILIES) + + def normalize_printer_model_id(model_id: str | None) -> str | None: """Convert printer_model_id (internal code) to normalized short name. diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py index ab279a4e6..7e48d230f 100644 --- a/backend/tests/integration/test_print_queue_api.py +++ b/backend/tests/integration/test_print_queue_api.py @@ -848,6 +848,22 @@ class TestQueueLibraryFileSupport: assert result["library_file_name"] == "Library Print 1" assert result["print_time_seconds"] == 3600 + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_library_file_rejects_cross_model_mismatch( + self, async_client: AsyncClient, printer_factory, library_file_factory, db_session + ): + """Cross-model gate (#2578) also reads sliced_for_model from library file metadata.""" + await printer_factory(model="H2D") + lib_file = await library_file_factory(file_metadata={"print_name": "Mismatch", "sliced_for_model": "X1C"}) + + response = await async_client.post( + "/api/v1/queue/", + json={"target_model": "H2D", "library_file_id": lib_file.id}, + ) + assert response.status_code == 400 + assert "sliced for X1C" in response.json()["detail"] + @pytest.mark.asyncio @pytest.mark.integration async def test_add_to_queue_library_file_with_options( @@ -1401,6 +1417,90 @@ class TestTargetLocationFeature: result = response.json() assert result["target_location"] is None + # ------------------------------------------------------------------ + # Cross-model dispatch gate (#2578): a G-code 3MF sliced for one model + # must not be queued for model-based dispatch to an incompatible model. + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_rejects_cross_model_mismatch( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """X1C-sliced archive + target_model=H2D must be rejected (#2578).""" + await printer_factory(model="H2D") + archive = await archive_factory(sliced_for_model="X1C") + + response = await async_client.post( + "/api/v1/queue/", + json={"target_model": "H2D", "archive_id": archive.id}, + ) + assert response.status_code == 400 + assert "sliced for X1C" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_allows_gcode_family_target( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """X1C-sliced G-code on a P1S is an intentional mixed-farm workflow — + same kinematics/volume family, must stay allowed.""" + await printer_factory(model="P1S") + archive = await archive_factory(sliced_for_model="X1C") + + response = await async_client.post( + "/api/v1/queue/", + json={"target_model": "P1S", "archive_id": archive.id}, + ) + assert response.status_code == 200 + assert response.json()["target_model"] == "P1S" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_without_sliced_metadata_not_blocked( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Legacy archives without sliced_for_model can't be validated — must keep working.""" + await printer_factory(model="H2D") + archive = await archive_factory() # no sliced_for_model + + response = await async_client.post( + "/api/v1/queue/", + json={"target_model": "H2D", "archive_id": archive.id}, + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_cross_model_mismatch( + self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session + ): + """Editing an item must not be able to introduce an incompatible target either.""" + await printer_factory(model="X1C") + await printer_factory(model="H2D") + archive = await archive_factory(sliced_for_model="X1C") + item = await queue_item_factory(printer_id=None, target_model="X1C", archive_id=archive.id) + + response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "H2D"}) + assert response.status_code == 400 + assert "sliced for X1C" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_can_fix_stale_mismatched_target( + self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session + ): + """A pre-fix DB row with a wrong target (the reporter's rows 78-82) must be + repairable by editing the target back to the sliced-for model.""" + await printer_factory(model="X1C") + archive = await archive_factory(sliced_for_model="X1C") + # Stale mismatched row written directly to the DB (bypasses the API gate) + item = await queue_item_factory(printer_id=None, target_model="H2D", archive_id=archive.id) + + response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "X1C"}) + assert response.status_code == 200 + assert response.json()["target_model"] == "X1C" + class TestAbortedStatusNormalisation: """Tests for issue #558: 'aborted' queue status causes 500 error.""" diff --git a/backend/tests/unit/test_scheduler_model_mismatch.py b/backend/tests/unit/test_scheduler_model_mismatch.py new file mode 100644 index 000000000..6f6c9cc52 --- /dev/null +++ b/backend/tests/unit/test_scheduler_model_mismatch.py @@ -0,0 +1,230 @@ +"""Cross-model dispatch gate (#2578). + +A queue row can carry a target_model that does not match the model its 3MF was +sliced for (pre-fix UI wrote such rows silently; direct API writes could too). +G-code is only interchangeable within an explicit family — everything else must +be held back at the dispatch boundary, because model-based assignment has no +human in the loop to catch it. + +Covers the pure helper (``is_gcode_compatible``) and the scheduler behaviour: +a mismatched pending item is never offered a printer and gets an actionable +``waiting_reason`` instead of being dispatched. +""" + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +import backend.app.models # noqa: F401 - populate Base.metadata +from backend.app.core.database import Base +from backend.app.models.archive import PrintArchive +from backend.app.models.library import LibraryFile +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.printer import Printer +from backend.app.services.print_scheduler import PrintScheduler +from backend.app.utils.printer_models import is_gcode_compatible + +# --------------------------------------------------------------------------- +# is_gcode_compatible +# --------------------------------------------------------------------------- + + +def test_same_model_is_compatible(): + assert is_gcode_compatible("X1C", "X1C") + assert is_gcode_compatible("H2D", "H2D") + + +def test_x1_p1_family_is_interchangeable(): + # Intentional mixed-farm workflow: X1-sliced jobs on P1S/P1P and back. + assert is_gcode_compatible("X1C", "P1S") + assert is_gcode_compatible("X1C", "P1P") + assert is_gcode_compatible("P1S", "X1E") + assert is_gcode_compatible("X1", "P1P") + + +def test_cross_family_is_blocked(): + # The reporter's case: X1C-sliced G-code targeted at an H2D. + assert not is_gcode_compatible("X1C", "H2D") + assert not is_gcode_compatible("P1S", "H2D") + assert not is_gcode_compatible("X1C", "A1") + assert not is_gcode_compatible("A1", "A1 Mini") + assert not is_gcode_compatible("H2D", "H2S") + assert not is_gcode_compatible("X1C", "P2S") + + +def test_unknown_metadata_is_failsafe_compatible(): + # Legacy files without sliced_for_model can't be validated — never block. + assert is_gcode_compatible(None, "H2D") + assert is_gcode_compatible("X1C", None) + assert is_gcode_compatible(None, None) + assert is_gcode_compatible("", "H2D") + + +def test_normalization_spaces_dashes_case(): + assert is_gcode_compatible("x1c", "X1C") + assert is_gcode_compatible("A1 Mini", "A1-MINI") + assert is_gcode_compatible("H2D Pro", "H2DPRO") + + +def test_internal_codes_resolve_to_short_names(): + # slice_info printer_model_id codes compare equal to their short names. + assert is_gcode_compatible("C11", "X1C") + assert is_gcode_compatible("O1D", "H2D") + assert is_gcode_compatible("C11", "P1S") # X1C → family with P1S + assert not is_gcode_compatible("C11", "H2D") + + +# --------------------------------------------------------------------------- +# Scheduler: mismatched rows are held back, not dispatched +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def queue_db(): + """In-memory DB seeded with one idle H2D printer.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_maker = async_sessionmaker(engine, expire_on_commit=False) + + async with session_maker() as db: + db.add( + Printer( + id=1, + name="H2D-1", + serial_number="H2D0001", + ip_address="10.0.0.1", + access_code="x", + model="H2D", + is_active=True, + ) + ) + await db.commit() + + try: + yield SimpleNamespace(session_maker=session_maker) + finally: + await engine.dispose() + + +async def _add_model_item(ctx, *, target_model, sliced_for_model=None, library_meta=None): + """Seed one pending model-based queue item, archive- or library-backed.""" + async with ctx.session_maker() as db: + if library_meta is not None: + source = LibraryFile( + filename="job.3mf", + file_path="/library/job.3mf", + file_size=10, + file_type="3mf", + file_metadata=library_meta, + ) + db.add(source) + await db.flush() + item = PrintQueueItem(library_file_id=source.id, target_model=target_model, status="pending", position=1) + else: + source = PrintArchive( + filename="job.3mf", + file_path="archives/job.3mf", + file_size=10, + status="completed", + sliced_for_model=sliced_for_model, + ) + db.add(source) + await db.flush() + item = PrintQueueItem(archive_id=source.id, target_model=target_model, status="pending", position=1) + db.add(item) + await db.commit() + return item.id + + +async def _run_check_queue(ctx, scheduler, finder, waiting_notification): + patches = [ + patch("backend.app.services.print_scheduler.async_session", ctx.session_maker), + patch("backend.app.core.database.async_session", ctx.session_maker), + patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)), + patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)), + patch( + "backend.app.services.notification_service.notification_service.on_queue_job_waiting", + waiting_notification, + ), + patch.object(scheduler, "_find_idle_printer_for_model", finder), + patch.object(scheduler, "_check_auto_drying", AsyncMock()), + ] + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + return await scheduler.check_queue() + + +async def _get_item(ctx, item_id): + async with ctx.session_maker() as db: + return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one() + + +@pytest.mark.asyncio +async def test_mismatched_item_is_held_and_never_offered_a_printer(queue_db): + """target_model=H2D on an X1C-sliced archive: the matcher must not even run.""" + item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model="X1C") + scheduler = PrintScheduler() + finder = AsyncMock(return_value=(1, None)) # would happily offer the H2D + waiting = AsyncMock() + + await _run_check_queue(queue_db, scheduler, finder, waiting) + + finder.assert_not_awaited() + item = await _get_item(queue_db, item_id) + assert item.status == "pending" + assert item.printer_id is None + assert "sliced for X1C" in item.waiting_reason + # Actionable reason → the user is notified once on transition + waiting.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_mismatched_library_item_is_held(queue_db): + """Same gate for library-file-backed rows (metadata JSON, not a column).""" + item_id = await _add_model_item(queue_db, target_model="H2D", library_meta={"sliced_for_model": "P1S"}) + scheduler = PrintScheduler() + finder = AsyncMock(return_value=(1, None)) + + await _run_check_queue(queue_db, scheduler, finder, AsyncMock()) + + finder.assert_not_awaited() + item = await _get_item(queue_db, item_id) + assert item.status == "pending" + assert "sliced for P1S" in item.waiting_reason + + +@pytest.mark.asyncio +async def test_compatible_and_unknown_items_still_reach_the_matcher(queue_db): + """Family-compatible (X1C→P1S would be, but here same-model) and + metadata-less rows must keep flowing to the printer matcher.""" + item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model="H2D") + scheduler = PrintScheduler() + # Matcher returns no printer so the pass stops after the gate — all we + # assert is that the gate let the item through. + finder = AsyncMock(return_value=(None, "Busy: H2D-1 (Printing)")) + + await _run_check_queue(queue_db, scheduler, finder, AsyncMock()) + + finder.assert_awaited_once() + item = await _get_item(queue_db, item_id) + assert item.status == "pending" + assert item.waiting_reason == "Busy: H2D-1 (Printing)" + + +@pytest.mark.asyncio +async def test_legacy_item_without_metadata_reaches_the_matcher(queue_db): + item_id = await _add_model_item(queue_db, target_model="H2D", sliced_for_model=None) + scheduler = PrintScheduler() + finder = AsyncMock(return_value=(None, "Busy: H2D-1 (Printing)")) + + await _run_check_queue(queue_db, scheduler, finder, AsyncMock()) + + finder.assert_awaited_once() + item = await _get_item(queue_db, item_id) + assert item.status == "pending" diff --git a/frontend/src/__tests__/utils/printer.test.ts b/frontend/src/__tests__/utils/printer.test.ts index 1d4be01b7..fc5642605 100644 --- a/frontend/src/__tests__/utils/printer.test.ts +++ b/frontend/src/__tests__/utils/printer.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; -import { getPrinterImage } from '../../utils/printer'; +import { getPrinterImage, isGcodeCompatible } from '../../utils/printer'; describe('getPrinterImage', () => { describe('X2D (#988)', () => { @@ -97,3 +97,42 @@ describe('getPrinterImage', () => { }); }); }); + +// Mirrors backend/tests/unit/test_scheduler_model_mismatch.py — the frontend +// table must stay in sync with backend GCODE_COMPAT_FAMILIES (#2578). +describe('isGcodeCompatible', () => { + it('accepts same model', () => { + expect(isGcodeCompatible('X1C', 'X1C')).toBe(true); + expect(isGcodeCompatible('H2D', 'H2D')).toBe(true); + }); + + it('accepts the X1/P1 interchange family', () => { + expect(isGcodeCompatible('X1C', 'P1S')).toBe(true); + expect(isGcodeCompatible('X1C', 'P1P')).toBe(true); + expect(isGcodeCompatible('P1S', 'X1E')).toBe(true); + expect(isGcodeCompatible('X1', 'P1P')).toBe(true); + }); + + it('rejects cross-family targets', () => { + // The reporter's case: X1C-sliced G-code targeted at an H2D (#2578) + expect(isGcodeCompatible('X1C', 'H2D')).toBe(false); + expect(isGcodeCompatible('P1S', 'H2D')).toBe(false); + expect(isGcodeCompatible('X1C', 'A1')).toBe(false); + expect(isGcodeCompatible('A1', 'A1 Mini')).toBe(false); + expect(isGcodeCompatible('H2D', 'H2S')).toBe(false); + expect(isGcodeCompatible('X1C', 'P2S')).toBe(false); + }); + + it('is fail-safe on unknown metadata', () => { + expect(isGcodeCompatible(null, 'H2D')).toBe(true); + expect(isGcodeCompatible('X1C', null)).toBe(true); + expect(isGcodeCompatible(undefined, undefined)).toBe(true); + expect(isGcodeCompatible('', 'H2D')).toBe(true); + }); + + it('normalizes case, spaces and dashes', () => { + expect(isGcodeCompatible('x1c', 'X1C')).toBe(true); + expect(isGcodeCompatible('A1 Mini', 'A1-MINI')).toBe(true); + expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true); + }); +}); diff --git a/frontend/src/components/PrintModal/PrinterSelector.tsx b/frontend/src/components/PrintModal/PrinterSelector.tsx index ddae23c8d..f0b5186f9 100644 --- a/frontend/src/components/PrintModal/PrinterSelector.tsx +++ b/frontend/src/components/PrintModal/PrinterSelector.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import { api, type PrinterStatus } from '../../api/client'; import { getColorName } from '../../utils/colors'; +import { isGcodeCompatible } from '../../utils/printer'; import { normalizeColorForCompare, colorsAreSimilar, @@ -409,10 +410,15 @@ export function PrinterSelector({ onClick={() => { onAssignmentModeChange!('model'); onMultiSelect([]); - // Pre-select the sliced-for model if available, otherwise first model + // Pre-select the sliced-for model when it has printers. NEVER + // silently fall back to another model (#2578): uniqueModels is + // alphabetically sorted, so the old `uniqueModels[0]` default + // quietly targeted e.g. H2D for an X1C-sliced file whenever the + // metadata hadn't loaded yet. Leave it unset and let the user + // pick from the dropdown instead. const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel) ? slicedForModel - : uniqueModels[0]; + : null; onTargetModelChange!(defaultModel); }} className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${ @@ -422,7 +428,12 @@ export function PrinterSelector({ }`} > - Any {slicedForModel || 'Model'} + {/* In model mode the label must reflect the ACTUAL scheduler + target, not the slice metadata — an edited queue item can + target a different model than the file was sliced for (#2578). */} + + Any {(assignmentMode === 'model' ? targetModel : null) || slicedForModel || 'Model'} + )} @@ -430,29 +441,58 @@ export function PrinterSelector({ {/* Model selection and location filter (when in model mode) */} {assignmentMode === 'model' && modelAssignmentAvailable && (
- {/* Model selector — only show when sliced model is unknown */} - {!slicedForModel && ( -
- - { + onTargetModelChange!(e.target.value || null); + // Clear location when model changes + if (onTargetLocationChange) { + onTargetLocationChange(null); + } + }} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm" + > + + {uniqueModels.map((model) => { + const compatible = isGcodeCompatible(slicedForModel, model); + return ( + - ))} - -
+ ); + })} + +
+ + {/* Cross-model state on an existing row: same file/target mismatch + the specific-printer path already warns about (#2578). */} + {slicedForModel && targetModel && targetModel !== slicedForModel && ( + isGcodeCompatible(slicedForModel, targetModel) ? ( +
+ + + File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer + +
+ ) : ( +
+ + + File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers — + select a compatible model + +
+ ) )} {/* Location filter (only show when target model is selected and locations exist) */} diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index f9e464e5a..ee26b6e48 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -17,6 +17,7 @@ import { } from '../../hooks/useFilamentMapping'; import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping'; import { getColorName } from '../../utils/colors'; +import { isGcodeCompatible } from '../../utils/printer'; import { getCurrencySymbol } from '../../utils/currency'; import { getBedTypeInfo } from '../../utils/bedType'; import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date'; @@ -576,6 +577,17 @@ export function PrintModal({ } }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]); + // The sliced-for metadata loads async. If the user switched to model mode + // before it arrived, the target is still empty (we never silently default + // to another model, #2578) — fill it with the sliced-for model once known, + // provided an active printer of that model exists. + useEffect(() => { + if (assignmentMode !== 'model' || targetModel || !slicedForModel) return; + if (printers?.some((p) => p.is_active && p.model === slicedForModel)) { + setTargetModel(slicedForModel); + } + }, [assignmentMode, targetModel, slicedForModel, printers]); + // Auto-expand per-printer mapping when setting is enabled and multiple printers selected // Only applies once per printer on initial selection, not when user unchecks useEffect(() => { @@ -781,6 +793,12 @@ export function PrintModal({ showToast('Please select a target printer model', 'error'); return; } + // Cross-model safety gate (#2578) — mirrors the backend's 400 so the user + // gets inline feedback instead of a failed request. + if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) { + showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error'); + return; + } setIsSubmitting(true); // Calculate total API calls: plates × printers (or 1 for model-based) @@ -1070,6 +1088,8 @@ export function PrintModal({ // Need valid printer/model selection if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false; if (assignmentMode === 'model' && !targetModel) return false; + // Cross-model mismatch cannot be queued (#2578) + if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false; // For multi-plate files, need at least one plate selected if (isMultiPlate && selectedPlates.size === 0) return false; @@ -1085,6 +1105,7 @@ export function PrintModal({ selectedPrinters.length, assignmentMode, targetModel, + slicedForModel, isMultiPlate, selectedPlates.size, isPending, diff --git a/frontend/src/utils/printer.ts b/frontend/src/utils/printer.ts index a58bcce18..4fc45248a 100644 --- a/frontend/src/utils/printer.ts +++ b/frontend/src/utils/printer.ts @@ -18,6 +18,28 @@ export function getPrinterImage(model: string | null | undefined): string { return '/img/printers/default.png'; } +// G-code interchange families (#2578). Mirrors backend GCODE_COMPAT_FAMILIES +// in backend/app/utils/printer_models.py — keep the two in sync. A sliced 3MF +// may target a different model ONLY within its family; everything else is +// exact-match only. +const GCODE_COMPAT_FAMILIES: ReadonlyArray> = [ + new Set(['X1', 'X1C', 'X1E', 'P1P', 'P1S']), +]; + +/** True when G-code sliced for one model may be dispatched to the other. + * Unknown/missing metadata on either side returns true (can't validate). */ +export function isGcodeCompatible( + slicedForModel: string | null | undefined, + targetModel: string | null | undefined, +): boolean { + if (!slicedForModel || !targetModel) return true; + const norm = (m: string) => m.trim().toUpperCase().replace(/[\s-]/g, ''); + const a = norm(slicedForModel); + const b = norm(targetModel); + if (a === b) return true; + return GCODE_COMPAT_FAMILIES.some((family) => family.has(a) && family.has(b)); +} + export function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } { if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 }; if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 }; diff --git a/static/assets/index-CssD7OeM.js b/static/assets/index-Cf3DYu3Y.js similarity index 90% rename from static/assets/index-CssD7OeM.js rename to static/assets/index-Cf3DYu3Y.js index a61690661..a3dcc0f48 100644 --- a/static/assets/index-CssD7OeM.js +++ b/static/assets/index-Cf3DYu3Y.js @@ -41,11 +41,11 @@ 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 (bugün)`,restEnergyTotalPath:`Enerji JSON yolu (toplam sayaç)`,restEnergyTotalMultiplier:`Toplam sayaç çarpanı`,restEnergyTotalPathHint:`örn. aenergy.total`,restEnergyTotalHint:`Birçok priz — tüm Shelly'ler dahil — yalnızca hiç sıfırlanmayan bir toplam sayaç bildirir. Bu değer yukarıdaki alana değil, buraya girilmelidir: bugünün tüketimi olarak okunursa gece yarısı sıfırlanmaz, Dün ve Toplam da boş kalır. Bambuddy Bugün ve Dün değerlerini bundan hesaplar; bunun için bir iki günlük ölçüm gerekir. Shelly watt-saat bildirdiğinden çarpan olarak 0.001 kullanın.`,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ı`,aiFailureDetection:`AI Hata Tespiti`,aiFailureDetectionDescription:`Obico AI olası bir baskı hatası tespit ettiğinde bildir`,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:{title:`Kenar çubuğu bağlantıları`,sidebarLayout:`Kenar çubuğu`,sidebarLayoutDescription:`Yerleşik sayfaları gösterin veya gizleyin, harici bağlantılar ekleyin ve kenar çubuğu gezinmesini yeniden sıralamak için öğeleri sürükleyin.`,systemPages:`Bambuddy sayfaları`,externalLinks:`Harici bağlantılar`,visibleInSidebar:`Kenar çubuğunda görünür`,hiddenFromSidebar:`Kenar çubuğunda gizli`,requiredInSidebar:`Kenar çubuğunda gerekli`,hidePage:`Sayfayı gizle`,showPage:`Sayfayı göster`,settingsCannotBeHidden:`Ayarlar gizlenemez`,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.`,skip_unsupported_model:`Bu modelde SD yuvası var ancak seçeneği açmanın bir yolu yok — mevcut P1 serisi bellenim, Bambu Studio'da bu anahtarı göstermiyor ve yazıcının ekranı yok. Burada düzeltilecek bir şey yok; Bambu Lab bellenim desteği ekleyene kadar arşivlenen baskılarda küçük resimler ve dilimleyici meta verileri eksik olabilir.`},port_rtsps:{title:`Kamera portu ({{protocol}} {{port}})`,pass:`Erişilebilir — kamera akışı çalışacak.`,warn:`Port {{port}} 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.`,signInExpiredTitle:`Bambu Cloud oturumu sona erdi`,signInExpiredBody:`Bambuddy oturumunuz açık kalmaya devam ediyor, ancak Bambu Lab kayıtlı belirteci artık kabul etmediği için indirmeler başarısız olacak. Bambu Cloud oturumunu yeniden açın.`,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:{scope:{camera_stream:`Kamera akışı`,camwall:`Kamera duvarı`},title:`Kamera API Belirteçleri`,navTitle:`Kamera API belirteçleri`,description:`Kamera akışını Home Assistant, Frigate, kiosk ekranları veya sabit bir adres gerektiren başka araçlara gömmek için uzun ömürlü belirteçler. Kapsam oluşturma sırasında seçilir; belirteç istediğiniz zaman 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:{scopeLabel:`Kapsam`,hintCameraStream:`Kamera akışı belirteci yalnızca kamera akışlarını ve anlık görüntüleri alabilir. Home Assistant, Frigate veya tek bir kamerayı gömen her şey için kullanın.`,hintCamWall:`Kamera duvarı belirteci, oturum açmadan bir ekranda /camwall adresini açar. Her yazıcının adını ve durumunu, ayrıca kamera akışlarını görebilir. Dosya adlarını, adresleri veya erişim kodlarını göremez.`,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:{camWallUrlTitle:`Bu ekran için kamera duvarı adresi`,camWallUrlHint:`Bu adresi ekranda açın. Adresi okuyabilen herkes duvarı izleyebilir, bu yüzden onu bir anahtar gibi görün; ekranın erişimini kesmek için belirteci iptal edin.`,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:{scope:`Kapsam`,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.`}}}},Tt=[`en`,`de`,`es`,`fr`,`ja`,`it`,`ko`,`pt-BR`,`tr`,`zh-CN`,`zh-TW`],Et=`bambuddy_appliance_locale_consumed`;De.use(Ct).use(Ke).init({resources:wt,fallbackLng:`en`,supportedLngs:Tt,detection:{order:[`localStorage`,`navigator`,`htmlTag`],lookupLocalStorage:`bambutrack_language`,caches:[`localStorage`]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});function Dt(){if(typeof window>`u`||!window.localStorage)return;let e=window.localStorage;typeof e.getItem!=`function`||typeof e.setItem!=`function`||e.getItem(Et)||fetch(`/api/v1/system/appliance`).then(e=>e.ok?e.json():null).then(t=>{!t||typeof t.locale!=`string`||Tt.includes(t.locale)&&(De.changeLanguage(t.locale),e.setItem(Et,`1`))}).catch(()=>{})}Dt();var Ot=[{code:`en`,name:`English`,nativeName:`English`},{code:`de`,name:`German`,nativeName:`Deutsch`},{code:`es`,name:`Spanish`,nativeName:`Español`},{code:`fr`,name:`French`,nativeName:`Français`},{code:`ja`,name:`Japanese`,nativeName:`日本語`},{code:`it`,name:`Italian`,nativeName:`Italiano`},{code:`ko`,name:`Korean`,nativeName:`한국어`},{code:`pt-BR`,name:`Portuguese (Brazil)`,nativeName:`Português (Brasil)`},{code:`zh-CN`,name:`Chinese (Simplified)`,nativeName:`简体中文`},{code:`zh-TW`,name:`Chinese (Traditional)`,nativeName:`繁體中文`},{code:`tr`,name:`Turkish`,nativeName:`Türkçe`}],kt=`modulepreload`,At=function(e){return`/`+e},jt={},Mt=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=At(t,n),t in jt)return;jt[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:kt,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},G=`popstate`;function Nt(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function Pt(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return zt(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:Bt(t)}return Ht(t,n,null,e)}function Ft(e,t){if(e===!1||e==null)throw Error(t)}function It(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function Lt(){return Math.random().toString(36).substring(2,10)}function Rt(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function zt(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?Vt(t):t,state:n,key:t&&t.key||r||Lt(),mask:i}}function Bt({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function Vt(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function Ht(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=Nt(e)?e:zt(h.location,e,t);n&&n(r,e),l=u()+1;let d=Rt(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=Nt(e)?e:zt(h.location,e,t);n&&n(r,e),l=u();let i=Rt(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return Ut(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(G,d),c=e,()=>{i.removeEventListener(G,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function Ut(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),Ft(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:Bt(t);return i=i.replace(/ $/,`%20`),!n&&i.startsWith(`//`)&&(i=r+i),new URL(i,r)}function Wt(e,t,n=`/`){return Gt(e,t,n,!1)}function Gt(e,t,n,r,i){let a=dn((typeof t==`string`?Vt(t):t).pathname||`/`,n);if(a==null)return null;let o=i??qt(e),s=null,c=un(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;Ft(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=bn([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(Ft(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),Jt(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:an(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of Yt(e.path))a(e,t,!0,n)}),t}function Yt(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=Yt(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function Xt(e){e.sort((e,t)=>e.score===t.score?on(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var Zt=/^:[\w-]+$/,Qt=3,$t=2,en=1,tn=10,nn=-2,rn=e=>e===`*`;function an(e,t){let n=e.split(`/`),r=n.length;return n.some(rn)&&(r+=nn),t&&(r+=$t),n.filter(e=>!rn(e)).reduce((e,t)=>e+(Zt.test(t)?Qt:t===``?en:tn),r)}function on(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function sn(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function ln(e,t=!1,n=!0){It(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function un(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return It(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function dn(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var fn=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function pn(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?Vt(e):e,a;return n?(n=yn(n),a=n.startsWith(`/`)?mn(n.substring(1),`/`):mn(n,t)):a=t,{pathname:a,search:Cn(r),hash:wn(i)}}function mn(e,t){let n=xn(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function hn(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function gn(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function _n(e){let t=gn(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function vn(e,t,n,r=!1){let i;typeof e==`string`?i=Vt(e):(i={...e},Ft(!i.pathname||!i.pathname.includes(`?`),hn(`?`,`pathname`,`search`,i)),Ft(!i.pathname||!i.pathname.includes(`#`),hn(`#`,`pathname`,`hash`,i)),Ft(!i.search||!i.search.includes(`#`),hn(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=pn(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var yn=e=>e.replace(/\/\/+/g,`/`),bn=e=>yn(e.join(`/`)),xn=e=>e.replace(/\/+$/,``),Sn=e=>xn(e).replace(/^\/*/,`/`),Cn=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,wn=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Tn=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function En(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Dn(e){return bn(e.map(e=>e.route.path).filter(Boolean))||`/`}var On=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function kn(e,t){let n=e;if(typeof n!=`string`||!fn.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(On)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=dn(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{It(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var An=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(An);var jn=[`GET`,...An];new Set(jn);var Mn=y.createContext(null);Mn.displayName=`DataRouter`;var Nn=y.createContext(null);Nn.displayName=`DataRouterState`;var Pn=y.createContext(!1);function Fn(){return y.useContext(Pn)}var In=y.createContext({isTransitioning:!1});In.displayName=`ViewTransition`;var Ln=y.createContext(new Map);Ln.displayName=`Fetchers`;var Rn=y.createContext(null);Rn.displayName=`Await`;var zn=y.createContext(null);zn.displayName=`Navigation`;var Bn=y.createContext(null);Bn.displayName=`Location`;var Vn=y.createContext({outlet:null,matches:[],isDataRoute:!1});Vn.displayName=`Route`;var Hn=y.createContext(null);Hn.displayName=`RouteError`;var Un=`REACT_ROUTER_ERROR`,Wn=`REDIRECT`,Gn=`ROUTE_ERROR_RESPONSE`;function Kn(e){if(e.startsWith(`${Un}:${Wn}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function qn(e){if(e.startsWith(`${Un}:${Gn}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new Tn(t.status,t.statusText,t.data)}catch{}}function Jn(e,{relative:t}={}){Ft(Yn(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=y.useContext(zn),{hash:i,pathname:a,search:o}=ar(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:bn([n,a])),r.createHref({pathname:s,search:o,hash:i})}function Yn(){return y.useContext(Bn)!=null}function Xn(){return Ft(Yn(),`useLocation() may be used only in the context of a component.`),y.useContext(Bn).location}var Zn=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function Qn(e){y.useContext(zn).static||y.useLayoutEffect(e)}function $n(){let{isDataRoute:e}=y.useContext(Vn);return e?wr():er()}function er(){Ft(Yn(),`useNavigate() may be used only in the context of a component.`);let e=y.useContext(Mn),{basename:t,navigator:n}=y.useContext(zn),{matches:r}=y.useContext(Vn),{pathname:i}=Xn(),a=JSON.stringify(_n(r)),o=y.useRef(!1);return Qn(()=>{o.current=!0}),y.useCallback((r,s={})=>{if(It(o.current,Zn),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=vn(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:bn([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var tr=y.createContext(null);function nr(){return y.useContext(tr)}function rr(e){let t=y.useContext(Vn).outlet;return y.useMemo(()=>t&&y.createElement(tr.Provider,{value:e},t),[t,e])}function ir(){let{matches:e}=y.useContext(Vn);return e[e.length-1]?.params??{}}function ar(e,{relative:t}={}){let{matches:n}=y.useContext(Vn),{pathname:r}=Xn(),i=JSON.stringify(_n(n));return y.useMemo(()=>vn(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function or(e,t){return sr(e,t)}function sr(e,t,n){Ft(Yn(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=y.useContext(zn),{matches:i}=y.useContext(Vn),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Er(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let u=Xn(),d;if(t){let e=typeof t==`string`?Vt(t):t;Ft(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):Wt(e,{pathname:p});It(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),It(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=mr(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:bn([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:bn([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?y.createElement(Bn.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function cr(){let e=Cr(),t=En(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=y.createElement(y.Fragment,null,y.createElement(`p`,null,`💿 Hey developer 👋`),y.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,y.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,y.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),y.createElement(y.Fragment,null,y.createElement(`h2`,null,`Unexpected Application Error!`),y.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?y.createElement(`pre`,{style:i},n):null,o)}var lr=y.createElement(cr,null),ur=class extends y.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=qn(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:y.createElement(Vn.Provider,{value:this.props.routeContext},y.createElement(Hn.Provider,{value:e,children:this.props.component}));return this.context?y.createElement(fr,{error:e},t):t}};ur.contextType=Pn;var dr=new WeakMap;function fr({children:e,error:t}){let{basename:n}=y.useContext(zn);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=Kn(t.digest);if(e){let r=dr.get(t);if(r)throw r;let i=kn(e.location,n);if(On&&!dr.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw dr.set(t,n),n}return y.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function pr({routeContext:e,match:t,children:n}){let r=y.useContext(Mn);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(Vn.Provider,{value:e},n)}function mr(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);Ft(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Dn(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||lr,o&&(s<0&&c===0?(Er(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?y.createElement(n.route.Component,null):n.route.element?n.route.element:e,y.createElement(pr,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?y.createElement(ur,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function hr(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function gr(e){let t=y.useContext(Mn);return Ft(t,hr(e)),t}function _r(e){let t=y.useContext(Nn);return Ft(t,hr(e)),t}function vr(e){let t=y.useContext(Vn);return Ft(t,hr(e)),t}function yr(e){let t=vr(e),n=t.matches[t.matches.length-1];return Ft(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function br(){return yr(`useRouteId`)}function xr(){let e=_r(`useNavigation`);return y.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function Sr(){let{matches:e,loaderData:t}=_r(`useMatches`);return y.useMemo(()=>e.map(e=>Kt(e,t)),[e,t])}function Cr(){let e=y.useContext(Hn),t=_r(`useRouteError`),n=yr(`useRouteError`);return e===void 0?t.errors?.[n]:e}function wr(){let{router:e}=gr(`useNavigate`),t=yr(`useNavigate`),n=y.useRef(!1);return Qn(()=>{n.current=!0}),y.useCallback(async(r,i={})=>{It(n.current,Zn),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Tr={};function Er(e,t,n){!t&&!Tr[e]&&(Tr[e]=!0,It(!1,n))}y.memo(Dr);function Dr({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return sr(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Or({to:e,replace:t,state:n,relative:r}){Ft(Yn(),` may be used only in the context of a component.`);let{static:i}=y.useContext(zn);It(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=y.useContext(Vn),{pathname:o}=Xn(),s=$n(),c=vn(e,_n(a),o,r===`path`),l=JSON.stringify(c);return y.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function kr(e){return rr(e.context)}function Ar(e){Ft(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function jr({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){Ft(!Yn(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=y.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=Vt(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=y.useMemo(()=>{let e=dn(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return It(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:y.createElement(zn.Provider,{value:c},y.createElement(Bn.Provider,{children:t,value:h}))}function Mr({children:e,location:t}){return or(Nr(e),t)}y.Component;function Nr(e,t=[]){let n=[];return y.Children.forEach(e,(e,r)=>{if(!y.isValidElement(e))return;let i=[...t,r];if(e.type===y.Fragment){n.push.apply(n,Nr(e.props.children,i));return}Ft(e.type===Ar,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),Ft(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Nr(e.props.children,i)),n.push(a)}),n}var Pr=`get`,Fr=`application/x-www-form-urlencoded`;function Ir(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Lr(e){return Ir(e)&&e.tagName.toLowerCase()===`button`}function Rr(e){return Ir(e)&&e.tagName.toLowerCase()===`form`}function zr(e){return Ir(e)&&e.tagName.toLowerCase()===`input`}function Br(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Vr(e,t){return e.button===0&&(!t||t===`_self`)&&!Br(e)}function Hr(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Ur(e,t){let n=Hr(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Wr=null;function Gr(){if(Wr===null)try{new FormData(document.createElement(`form`),0),Wr=!1}catch{Wr=!0}return Wr}var Kr=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function qr(e){return e!=null&&!Kr.has(e)?(It(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Fr}"`),null):e}function Jr(e,t){let n,r,i,a,o;if(Rr(e)){let o=e.getAttribute(`action`);r=o?dn(o,t):null,n=e.getAttribute(`method`)||Pr,i=qr(e.getAttribute(`enctype`))||Fr,a=new FormData(e)}else if(Lr(e)||zr(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a