瀏覽代碼

fix(mqtt): report why a printer refused the connection instead of looping silently

    A printer with a wrong access code gave no explanation anywhere. The connect
    callback's failure branch was a bare `state.connected = False`, discarding the
    CONNACK reason code the printer had just sent, so the only trace was paho's
    follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
    which is exactly what a powered-off printer produces. In the report behind this
    fix one of three printers had been in that loop for the whole capture, and
    neither the log nor the support bundle could say why.

    Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
    reason codes 134 and 135. Both are now logged with the printer's own reason
    string and, for those two, the remedy: the access code is regenerated whenever
    LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
    The access code itself is never logged -- it would land in every bundle.

    The reason is kept on the client as a stable slug and plumbed through
    test_connection into the connection diagnostic, which now distinguishes two
    cases it previously conflated. "The printer refused our credentials" is
    asserted only when the printer said so; when all Bambuddy knows is that there
    is no session, the text hedges and names the alternatives (rebooting, or
    already at its limit of simultaneous connections). The old wording claimed the
    access code was most likely wrong in both cases.

    Frontend needed no change -- ConnectionDiagnostic already renders
    `<status>_<reason>` variants with fallback to the plain per-status text, so an
    unrecognised slug degrades to today's wording rather than a missing key.
maziggy 1 月之前
父節點
當前提交
1cda64c35a
共有 88 個文件被更改,包括 11950 次插入160 次删除
  1. 17 0
      CHANGELOG.md
  2. 2 1
      README.md
  3. 21 0
      backend/app/api/routes/archives.py
  4. 95 9
      backend/app/api/routes/library.py
  5. 2 0
      backend/app/api/routes/notifications.py
  6. 23 0
      backend/app/api/routes/obico.py
  7. 90 0
      backend/app/api/routes/projects.py
  8. 23 0
      backend/app/core/database.py
  9. 7 1
      backend/app/main.py
  10. 6 0
      backend/app/models/archive.py
  11. 2 0
      backend/app/models/notification.py
  12. 6 0
      backend/app/models/notification_template.py
  13. 3 0
      backend/app/models/project.py
  14. 2 0
      backend/app/schemas/archive.py
  15. 5 0
      backend/app/schemas/notification.py
  16. 13 0
      backend/app/schemas/project.py
  17. 11 0
      backend/app/schemas/slicer.py
  18. 4 0
      backend/app/services/archive.py
  19. 80 1
      backend/app/services/bambu_mqtt.py
  20. 193 0
      backend/app/services/design_settings.py
  21. 46 1
      backend/app/services/mqtt_relay.py
  22. 97 0
      backend/app/services/notification_service.py
  23. 16 8
      backend/app/services/obico_detection.py
  24. 1 0
      backend/app/services/print_scheduler.py
  25. 34 2
      backend/app/services/printer_diagnostic.py
  26. 51 0
      backend/app/services/printer_manager.py
  27. 13 1
      backend/tests/conftest.py
  28. 23 0
      backend/tests/integration/test_archives_api.py
  29. 100 0
      backend/tests/integration/test_design_settings_plates.py
  30. 74 1
      backend/tests/integration/test_obico_api.py
  31. 157 4
      backend/tests/integration/test_ownership_permissions.py
  32. 79 0
      backend/tests/integration/test_plate_clear_notification.py
  33. 224 0
      backend/tests/integration/test_projects_api.py
  34. 85 0
      backend/tests/unit/services/test_bambu_mqtt.py
  35. 222 0
      backend/tests/unit/services/test_notification_service.py
  36. 57 1
      backend/tests/unit/services/test_printer_diagnostic.py
  37. 191 0
      backend/tests/unit/test_design_settings.py
  38. 253 0
      backend/tests/unit/test_plate_clear_mqtt_notification.py
  39. 20 4
      backend/tests/unit/test_printer_manager_status_broadcast.py
  40. 10 1
      backend/tests/unit/test_scheduler_cleanup_library.py
  41. 18 1
      frontend/scripts/check-i18n-parity.mjs
  42. 185 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  43. 32 0
      frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx
  44. 132 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  45. 254 0
      frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx
  46. 5 0
      frontend/src/__tests__/mocks/handlers.ts
  47. 162 0
      frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx
  48. 175 0
      frontend/src/__tests__/pages/PrintersPageAiDetection.test.tsx
  49. 130 0
      frontend/src/__tests__/pages/PrintersPageCompactMetrics.test.tsx
  50. 82 0
      frontend/src/__tests__/pages/ProjectDetailPage.test.tsx
  51. 40 0
      frontend/src/__tests__/pages/StatsPage.test.tsx
  52. 42 0
      frontend/src/__tests__/utils/spoolFormValidation.test.ts
  53. 34 1
      frontend/src/api/client.ts
  54. 52 1
      frontend/src/components/AddNotificationModal.tsx
  55. 111 0
      frontend/src/components/AiDetectionModal.tsx
  56. 3 0
      frontend/src/components/FailureDetectionSettings.tsx
  57. 62 6
      frontend/src/components/FilamentTrends.tsx
  58. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  59. 96 1
      frontend/src/components/SliceModal.tsx
  60. 41 21
      frontend/src/components/SpoolFormModal.tsx
  61. 141 43
      frontend/src/components/spool-form/FilamentSection.tsx
  62. 23 2
      frontend/src/components/spool-form/types.ts
  63. 25 0
      frontend/src/components/spool-form/utils.ts
  64. 4 1
      frontend/src/i18n/index.ts
  65. 40 1
      frontend/src/i18n/locales/de.ts
  66. 40 1
      frontend/src/i18n/locales/en.ts
  67. 40 1
      frontend/src/i18n/locales/es.ts
  68. 40 1
      frontend/src/i18n/locales/fr.ts
  69. 40 1
      frontend/src/i18n/locales/it.ts
  70. 40 1
      frontend/src/i18n/locales/ja.ts
  71. 42 3
      frontend/src/i18n/locales/ko.ts
  72. 40 1
      frontend/src/i18n/locales/pt-BR.ts
  73. 40 1
      frontend/src/i18n/locales/ru.ts
  74. 40 1
      frontend/src/i18n/locales/tr.ts
  75. 6963 0
      frontend/src/i18n/locales/uk.ts
  76. 40 1
      frontend/src/i18n/locales/zh-CN.ts
  77. 40 1
      frontend/src/i18n/locales/zh-TW.ts
  78. 15 4
      frontend/src/pages/FileManagerPage.tsx
  79. 122 10
      frontend/src/pages/PrintersPage.tsx
  80. 80 1
      frontend/src/pages/ProjectDetailPage.tsx
  81. 19 0
      frontend/src/pages/ProjectsPage.tsx
  82. 3 1
      frontend/src/pages/StatsPage.tsx
  83. 27 15
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  84. 15 0
      frontend/src/types/plates.ts
  85. 1 0
      static/assets/index-D4bpNaiw.css
  86. 0 1
      static/assets/index-Di24iyOw.css
  87. 0 0
      static/assets/index-xPJs-OAQ.js
  88. 2 2
      static/index.html

File diff suppressed because it is too large
+ 17 - 0
CHANGELOG.md


+ 2 - 1
README.md

@@ -57,6 +57,7 @@
   <a href="https://hackaday.com/2026/06/13/bambuddy-says-bye-to-bambu-lab-cloud-services/"><img src="https://img.shields.io/badge/Hackaday-Read-F2A724?style=flat-square&labelColor=000000" alt="Hackaday"></a>
   <a href="https://www.xda-developers.com/finally-have-full-control-bambu-lab-printer-ditched-bambu-cloud/"><img src="https://img.shields.io/badge/XDA--Developers-Read-C8102E?style=flat-square" alt="XDA-Developers"></a>
   <a href="https://www.howtogeek.com/free-your-bambu-lab-3d-printer-from-the-cloud/"><img src="https://img.shields.io/badge/How--To%20Geek-Read-33A6CA?style=flat-square" alt="How-To Geek"></a>
+  <a href="https://www.makeuseof.com/free-browser-tool-beats-bambu-lab-at-own-game/"><img src="https://img.shields.io/badge/MakeUseOf-Read-E02D2D?style=flat-square" alt="MakeUseOf"></a>
   <a href="https://www.fabbaloo.com/news/bambuddy-launches-as-open-source-alternative-to-bambu-labs-cloud"><img src="https://img.shields.io/badge/Fabbaloo-Read-F77B0F?style=flat-square" alt="Fabbaloo"></a>
   <a href="https://itsfoss.com/news/bambuddy-self-hosted-bambu-lab-alternative/"><img src="https://img.shields.io/badge/It's%20FOSS-Read-00B5AD?style=flat-square" alt="It's FOSS"></a>
   <a href="https://www.igorslab.de/en/bambuddy-the-silent-alternative-to-the-bamboo-cloud/"><img src="https://img.shields.io/badge/Igor's%20Lab-Read-E10000?style=flat-square" alt="Igor's Lab"></a>
@@ -182,7 +183,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
 - Dual external spool support for H2D (Ext-L / Ext-R)
-- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 11 supported locales
+- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 13 supported locales
 - **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
 - Print success rates & trends
 - Filament usage tracking

+ 21 - 0
backend/app/api/routes/archives.py

@@ -29,6 +29,7 @@ from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveSta
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
+from backend.app.services.design_settings import overrides_from_config
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
@@ -41,6 +42,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/archives", tags=["archives"])
 
+# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
 
 def _safe_filename(filename: str) -> str:
     """Extract basename from a client-supplied filename, preventing path traversal.
@@ -570,6 +574,8 @@ async def list_archives_slim(
             PrintLogEntry.filament_color,
             PrintLogEntry.status,
             PrintLogEntry.cost,
+            PrintLogEntry.energy_kwh,
+            PrintLogEntry.energy_cost,
             PrintLogEntry.created_at,
         )
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@@ -612,6 +618,8 @@ async def list_archives_slim(
             "started_at": r.started_at,
             "completed_at": r.completed_at,
             "cost": r.cost,
+            "energy_kwh": r.energy_kwh,
+            "energy_cost": r.energy_cost,
             "quantity": 1,
             "created_at": r.created_at,
         }
@@ -3457,11 +3465,23 @@ async def get_archive_plates(
     # Printer / process preset names the 3MF was prepared with — used by the
     # SliceModal to default its dropdowns (#1325).
     embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
+    # Process settings the designer changed away from the stock preset (#2622),
+    # offered in the SliceModal for a cross-printer re-slice. Same payload the
+    # library plates endpoint returns — SliceModal reads one shape for both.
+    design_overrides: list[dict] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             embedded_presets = extract_embedded_presets_from_3mf(zf)
+            if _PROJECT_SETTINGS_PATH in namelist:
+                try:
+                    design_overrides = [
+                        o._asdict()
+                        for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
+                    ]
+                except (ValueError, OSError, KeyError):
+                    design_overrides = []
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -3723,6 +3743,7 @@ async def get_archive_plates(
         "has_gcode": has_gcode,
         "embedded_printer": embedded_presets["printer"],
         "embedded_process": embedded_presets["process"],
+        "design_overrides": design_overrides,
     }
 
 

+ 95 - 9
backend/app/api/routes/library.py

@@ -64,6 +64,11 @@ from backend.app.schemas.library import (
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
+from backend.app.services.design_settings import (
+    apply_design_overrides,
+    extract_design_process_overrides,
+    overrides_from_config,
+)
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
@@ -77,6 +82,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/library", tags=["library"])
 
+# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
 
 def _ensure_library_file_visible(
     library_file: LibraryFile | None,
@@ -1245,23 +1253,61 @@ async def update_folder(
     )
 
 
+async def _restricted_folder_delete_blocker(db: AsyncSession, folder: LibraryFolder) -> str | None:
+    """Why a library:delete_own user may NOT delete this folder, or None if they may.
+
+    Folders have no ownership tracking, so users without library:delete_all may
+    only delete folders that are truly empty — an empty folder contains nobody's
+    data (#1781). "Empty" must include trashed files: LibraryFile.folder_id
+    cascades on folder delete, so a folder holding another user's trashed file
+    would silently break trash restore.
+    """
+    if folder.is_external:
+        return "External folders can only be deleted by users with library:delete_all"
+    if folder.project_id is not None or folder.archive_id is not None:
+        return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
+
+    child_result = await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == folder.id))
+    if (child_result.scalar() or 0) > 0:
+        return "Only empty folders can be deleted without library:delete_all"
+
+    # Includes trashed files (no deleted_at filter) — see docstring.
+    file_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id))
+    if (file_result.scalar() or 0) > 0:
+        return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
+
+    return None
+
+
 @router.delete("/folders/{folder_id}")
 async def delete_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
 ):
     """Delete a folder and all its contents (cascade).
 
-    Note: Folders require library:delete_all permission since they don't have
-    ownership tracking.
+    Folders have no ownership tracking, so cascade deletion requires
+    library:delete_all. Users with only library:delete_own may delete empty,
+    non-external, non-linked folders (#1781).
     """
+    _, can_modify_all = auth_result
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
 
     if not folder:
         raise HTTPException(status_code=404, detail="Folder not found")
 
+    if not can_modify_all:
+        blocker = await _restricted_folder_delete_blocker(db, folder)
+        if blocker:
+            raise HTTPException(status_code=403, detail=blocker)
+
     # External folders: only remove DB records, never delete files from external path
     is_ext = folder.is_external
 
@@ -2621,6 +2667,17 @@ async def add_files_to_queue(
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
 
+    # Project attribution (#1897): a file queued from a project-linked folder
+    # inherits that project, so the resulting archive counts toward the
+    # project's progress. A file's own project link wins over its folder's.
+    folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None}
+    folder_projects: dict[int, int | None] = {}
+    if folder_ids:
+        folder_result = await db.execute(
+            select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids))
+        )
+        folder_projects = dict(folder_result.all())
+
     # Get max position for queue ordering
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
@@ -2658,6 +2715,8 @@ async def add_files_to_queue(
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
                 library_file_id=file_id,
+                project_id=lib_file.project_id
+                or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
             )
@@ -2723,11 +2782,23 @@ async def get_library_file_plates(
     # SliceModal to default its dropdowns (#1325). Initialised here so the
     # final return never raises NameError when the file isn't a valid zip.
     embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
+    # Process settings the designer changed away from the stock preset (#2622).
+    # Offered in the SliceModal so a cross-printer re-slice can carry them
+    # instead of silently losing them to the picked process profile.
+    design_overrides: list[dict] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             embedded_presets = extract_embedded_presets_from_3mf(zf)
+            if _PROJECT_SETTINGS_PATH in namelist:
+                try:
+                    design_overrides = [
+                        o._asdict()
+                        for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
+                    ]
+                except (ValueError, OSError, KeyError):
+                    design_overrides = []
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -2956,6 +3027,7 @@ async def get_library_file_plates(
         "is_multi_plate": len(plates) > 1,
         "embedded_printer": embedded_presets["printer"],
         "embedded_process": embedded_presets["process"],
+        "design_overrides": design_overrides,
     }
 
 
@@ -3606,6 +3678,21 @@ async def _run_slicer_with_fallback(
         # with a PVA slot loaded but never used.
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
 
+        # #2622: carry the designer's own process tweaks onto the picked preset.
+        # BambuStudio records exactly which keys deviate from the system preset
+        # in `different_settings_to_system`, so a MakerWorld author's 5 walls /
+        # 100% infill / 0.1mm first layer survive a re-slice for another printer
+        # instead of being flattened by --load-settings. Opt-in per key: only the
+        # keys the caller names are applied, and only if the source really lists
+        # them as changed. Runs after the #1881 support patch so an explicit
+        # design pick wins over the blanket support carry-over.
+        if request.design_overrides:
+            presets["process"] = apply_design_overrides(
+                presets["process"],
+                extract_design_process_overrides(primary_bytes),
+                request.design_overrides,
+            )
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only
@@ -4889,16 +4976,15 @@ async def bulk_delete(
             file.deleted_at = now
         deleted_files += 1
 
-    # Delete folders (cascade will handle contents)
-    # Note: Folders don't have ownership tracking currently, require *_all permission
+    # Delete folders (cascade will handle contents). Folders have no ownership
+    # tracking, so users without *_all permission may only delete empty,
+    # non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
     for folder_id in data.folder_ids:
-        if not can_modify_all:
-            # Users without *_all permission cannot delete folders
-            continue
-
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder = result.scalar_one_or_none()
         if folder:
+            if not can_modify_all and await _restricted_folder_delete_blocker(db, folder):
+                continue
             # Count files that will be deleted
             file_count_result = await db.execute(
                 select(func.count(LibraryFile.id)).where(

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -58,6 +58,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
+        "on_plate_clear_required": provider.on_plate_clear_required,
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
@@ -139,6 +140,7 @@ async def create_notification_provider(
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         # Build plate detection
         on_plate_not_empty=provider_data.on_plate_not_empty,
+        on_plate_clear_required=provider_data.on_plate_clear_required,
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete

+ 23 - 0
backend/app/api/routes/obico.py

@@ -37,6 +37,29 @@ async def get_status(
     }
 
 
+@router.get("/printer-status")
+async def get_printer_status(
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """Per-printer live classification for the printer cards (#1546).
+
+    Deliberately excludes configuration (ML URL, action, history) so users
+    with printers:read but no settings:read can still render the badge.
+    """
+    settings = await obico_detection_service._load_settings()
+    enabled_printers = settings["enabled_printers"]
+    # Error strings can embed configured URLs (ML API base, external URL), so
+    # they stay behind settings:read like the rest of the configuration.
+    can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
+    return {
+        "enabled": settings["enabled"],
+        # None = all printers are monitored
+        "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
+        "per_printer": obico_detection_service.get_per_printer(),
+        "last_error": obico_detection_service._last_error if can_see_error else None,
+    }
+
+
 @router.post("/test-connection")
 async def test_connection(
     req: TestConnectionRequest,

+ 90 - 0
backend/app/api/routes/projects.py

@@ -34,6 +34,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectCreate,
+    ProjectFileProgress,
     ProjectImport,
     ProjectListResponse,
     ProjectResponse,
@@ -262,6 +263,7 @@ async def list_projects(
                 status=project.status,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
                 tags=project.tags,
                 due_date=project.due_date,
@@ -304,6 +306,7 @@ async def create_project(
         color=data.color,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -326,6 +329,7 @@ async def create_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -374,6 +378,7 @@ async def list_templates(
                 status=project.status,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
                 tags=project.tags,
                 due_date=project.due_date,
@@ -415,6 +420,7 @@ async def create_project_from_template(
         color=template.color,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         tags=template.tags,
         priority=template.priority,
@@ -457,6 +463,7 @@ async def create_project_from_template(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -542,6 +549,7 @@ async def get_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -590,6 +598,10 @@ async def update_project(
         project.target_count = data.target_count
     if data.target_parts_count is not None:
         project.target_parts_count = data.target_parts_count
+    # Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
+    # alone (same #2536 semantics as tags/due_date below).
+    if "target_sets" in data.model_fields_set:
+        project.target_sets = data.target_sets
     if data.notes is not None:
         project.notes = data.notes
     # Sent-but-null clears the field; omitted leaves it alone. Guarding on
@@ -642,6 +654,7 @@ async def update_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -740,6 +753,76 @@ async def list_project_queue(
     return items
 
 
+@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
+async def get_project_file_progress(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+):
+    """Completed-run counts per library file inside a project (#1897).
+
+    Counts completed ``PrintLogEntry`` rows (same source as the aggregate
+    project stats) of archives attributed to this project, and maps each run to
+    one of the project's library files — the files living in folders linked to
+    the project, the same set the project detail page renders.
+
+    A run is attributed to exactly one file, by the strongest available match:
+    1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
+    2. content hash (covers historical rows),
+    3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
+    Files with no completed runs are omitted — the frontend treats absence as 0.
+    """
+    result = await db.execute(select(Project.id).where(Project.id == project_id))
+    if result.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    files_result = await db.execute(
+        select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
+        .join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        .where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
+    )
+    file_rows = files_result.all()
+    if not file_rows:
+        return []
+
+    # First match wins within each tier, so iteration order (file id) is stable
+    # when duplicates share a hash or filename.
+    by_id = {fid for fid, _, _ in file_rows}
+    by_hash: dict[str, int] = {}
+    by_name: dict[str, int] = {}
+    for fid, fhash, fname in file_rows:
+        if fhash and fhash not in by_hash:
+            by_hash[fhash] = fid
+        if fname not in by_name:
+            by_name[fname] = fid
+
+    runs_result = await db.execute(
+        select(
+            PrintArchive.library_file_id,
+            PrintArchive.content_hash,
+            PrintArchive.filename,
+            func.count(PrintLogEntry.id),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed")
+        .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
+    )
+
+    counts: dict[int, int] = {}
+    for lib_file_id, content_hash, filename, run_count in runs_result.all():
+        if lib_file_id in by_id:
+            fid = lib_file_id
+        elif content_hash and content_hash in by_hash:
+            fid = by_hash[content_hash]
+        elif filename in by_name:
+            fid = by_name[filename]
+        else:
+            continue
+        counts[fid] = counts.get(fid, 0) + run_count
+
+    return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
+
+
 @router.post("/{project_id}/add-archives")
 async def add_archives_to_project(
     project_id: int,
@@ -1402,6 +1485,7 @@ async def create_template_from_project(
         color=source.color,
         target_count=source.target_count,
         target_parts_count=source.target_parts_count,
+        target_sets=source.target_sets,
         notes=source.notes,
         tags=source.tags,
         priority=source.priority,
@@ -1444,6 +1528,7 @@ async def create_template_from_project(
         status=template.status,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         attachments=template.attachments,
         url=template.url,
@@ -1653,6 +1738,7 @@ async def export_project(
         "status": project.status,
         "target_count": project.target_count,
         "target_parts_count": project.target_parts_count,
+        "target_sets": project.target_sets,
         "notes": project.notes,
         "tags": project.tags,
         "due_date": project.due_date.isoformat() if project.due_date else None,
@@ -1704,6 +1790,7 @@ async def import_project(
         status=data.status,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -1766,6 +1853,7 @@ async def import_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -1829,6 +1917,7 @@ async def import_project_file(
         status=data.get("status", "active"),
         target_count=data.get("target_count"),
         target_parts_count=data.get("target_parts_count"),
+        target_sets=data.get("target_sets"),
         notes=data.get("notes"),
         tags=data.get("tags"),
         due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
@@ -1957,6 +2046,7 @@ async def import_project_file(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,

+ 23 - 0
backend/app/core/database.py

@@ -3787,6 +3787,29 @@ async def run_migrations(conn):
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
 
+    # Migration: per-file print progress inside a project (#1897).
+    # - print_archives.library_file_id: which library file a queued run was
+    #   dispatched from; nullable, no FK constraint added to existing tables
+    #   (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
+    #   via the ORM on fresh installs and tolerates dangling ids by matching
+    #   hash/filename as fallback anyway).
+    # - projects.target_sets: optional copies-per-file target. INTEGER is
+    #   spelled identically on SQLite and Postgres — no dialect branch.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
+    await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
+
+    # Migration: plate-clear-required notification opt-in (#2525). Off by
+    # default — it fires after every print, at the same moment as the
+    # print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
+    if is_sqlite():
+        await _safe_execute(
+            conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
+        )
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
+        )
+
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),

+ 7 - 1
backend/app/main.py

@@ -1242,7 +1242,13 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     try:
         printer_info = printer_manager.get_printer(printer_id)
         if printer_info:
-            await mqtt_relay.on_printer_status(printer_id, state, printer_info.name, printer_info.serial_number)
+            await mqtt_relay.on_printer_status(
+                printer_id,
+                state,
+                printer_info.name,
+                printer_info.serial_number,
+                printer_manager.is_awaiting_plate_clear(printer_id),
+            )
     except Exception:
         pass  # Don't fail status callback if MQTT fails
 

+ 6 - 0
backend/app/models/archive.py

@@ -12,6 +12,12 @@ class PrintArchive(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    # Which library file this run was dispatched from (#1897). Set by the queue
+    # scheduler when it archives a library-file print; older rows are matched by
+    # content_hash/filename instead. SET NULL so deleting a file keeps history.
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
 
     # File info
     filename: Mapped[str] = mapped_column(String(255))

+ 2 - 0
backend/app/models/notification.py

@@ -84,6 +84,8 @@ class NotificationProvider(Base):
 
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
+    # Off by default: fires after every print, alongside the print-complete alert (#2525)
+    on_plate_clear_required = Column(Boolean, default=False)  # Print ended, queue gated until plate is confirmed clear
 
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print

+ 6 - 0
backend/app/models/notification_template.py

@@ -85,6 +85,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Plate Not Empty - Print Paused",
         "body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
     },
+    {
+        "event_type": "plate_clear_required",
+        "name": "Plate Clear Required",
+        "title_template": "Plate Clear Required",
+        "body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
+    },
     {
         "event_type": "filament_low",
         "name": "Filament Low",

+ 3 - 0
backend/app/models/project.py

@@ -30,6 +30,9 @@ class Project(Base):
     target_parts_count: Mapped[int | None] = mapped_column(
         Integer, nullable=True
     )  # Optional target number of parts/objects
+    # Optional copies-per-file target (#1897): every printable file in the
+    # project's linked folders should be printed this many times ("sets").
+    target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 2 - 0
backend/app/schemas/archive.py

@@ -137,6 +137,8 @@ class ArchiveSlim(BaseModel):
     started_at: datetime | None
     completed_at: datetime | None
     cost: float | None
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
     quantity: int = 1
     created_at: datetime | None
 

+ 5 - 0
backend/app/schemas/notification.py

@@ -19,6 +19,7 @@ class ProviderType(StrEnum):
     DISCORD = "discord"
     WEBHOOK = "webhook"
     HOMEASSISTANT = "homeassistant"
+    BARK = "bark"
 
 
 class NotificationProviderBase(BaseModel):
@@ -62,6 +63,9 @@ class NotificationProviderBase(BaseModel):
 
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
+    on_plate_clear_required: bool = Field(
+        default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
+    )
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
@@ -146,6 +150,7 @@ class NotificationProviderUpdate(BaseModel):
 
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
+    on_plate_clear_required: bool | None = None
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None

+ 13 - 0
backend/app/schemas/project.py

@@ -26,6 +26,7 @@ class ProjectCreate(BaseModel):
     color: str | None = None
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -108,6 +110,7 @@ class ProjectResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     attachments: list | None = None
     tags: str | None = None
@@ -129,6 +132,13 @@ class ProjectResponse(BaseModel):
         from_attributes = True
 
 
+class ProjectFileProgress(BaseModel):
+    """Completed-run count for one library file inside a project (#1897)."""
+
+    file_id: int
+    completed_count: int
+
+
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
 
@@ -150,6 +160,7 @@ class ProjectListResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897); the shared edit dialog needs it
     budget: float | None = None
     # The edit dialog is shared with the project detail page and seeds its fields
     # from whichever project object it is handed, so the list payload has to carry
@@ -276,6 +287,7 @@ class ProjectExport(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None
+    target_sets: int | None = None
     notes: str | None
     tags: str | None
     due_date: datetime | None
@@ -294,6 +306,7 @@ class ProjectImport(BaseModel):
     status: str = "active"
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None

+ 11 - 0
backend/app/schemas/slicer.py

@@ -82,6 +82,17 @@ class SliceRequest(BaseModel):
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
+    design_overrides: list[str] | None = Field(
+        default=None,
+        description=(
+            "3MF only. Process setting keys from the source file's "
+            "``different_settings_to_system`` to carry onto the picked process "
+            "preset (#2622) — the designer's own wall count, infill, first-layer "
+            "height and so on, which ``--load-settings`` would otherwise discard. "
+            "Only keys the source actually lists as changed are applied; anything "
+            "else is ignored. ``None``/empty means a plain profile slice."
+        ),
+    )
     use_embedded_settings: bool = Field(
         default=False,
         description=(

+ 4 - 0
backend/app/services/archive.py

@@ -1143,6 +1143,7 @@ class ArchiveService:
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
+        library_file_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1155,6 +1156,8 @@ class ArchiveService:
                 stored with UUID names)
             project_id: Project to associate this archive with (optional, set when triggered
                 from the project view)
+            library_file_id: Library file this run was dispatched from (optional,
+                set by the queue scheduler — powers per-file project progress, #1897)
             subtask_id: MQTT-provided task identifier (optional). Used to match an
                 existing archive across a backend restart mid-print so the
                 original row can be resumed instead of cancelled (#972).
@@ -1314,6 +1317,7 @@ class ArchiveService:
             extra_data=metadata,
             created_by_id=created_by_id,
             project_id=project_id,
+            library_file_id=library_file_id,
             subtask_id=subtask_id,
             plate_id=plate_id,
         )

+ 80 - 1
backend/app/services/bambu_mqtt.py

@@ -40,6 +40,20 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
+# CONNACK reason codes that mean the printer actively refused our credentials,
+# as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
+# single-byte CONNACK return codes paho maps onto the v5 reason-code space:
+# return code 4 ("bad user name or password") -> 134, and 5 ("not authorized")
+# -> 135. Both mean the same thing in practice for a Bambu printer: the access
+# code (or, on some firmware, the serial used as the username) is wrong.
+_CONNACK_AUTH_REJECTED = frozenset({134, 135})
+
+# Short, stable slugs recorded on the client and surfaced to the connection
+# diagnostic as a `params.reason` variant. Deliberately not free text — the
+# frontend picks a localized message key off these.
+CONNECT_ERROR_AUTH_REJECTED = "auth_rejected"
+CONNECT_ERROR_REFUSED = "refused"
+
 
 def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
     """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
@@ -722,6 +736,18 @@ class BambuMQTTClient:
         # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
         self._has_a2l_am_unit: bool = False
 
+        # Why the last connection attempt was refused by the printer, or None
+        # when we have never seen a CONNACK failure since the last success.
+        # Without this a rejected access code was completely invisible: paho
+        # reports the follow-up disconnect as the generic "Unspecified error"
+        # and `_on_connect`'s failure branch used to log nothing at all, so a
+        # printer stuck in a reconnect loop looked identical whether it was
+        # powered off, on the wrong IP, or refusing our credentials (#2698).
+        # One of the CONNECT_ERROR_* slugs; the paired name is the paho reason
+        # string, kept for the log line only.
+        self.last_connect_error: str | None = None
+        self.last_connect_error_name: str | None = None
+
         # Request topic subscription tracking
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # topic by killing the TCP connection. We detect this and gracefully degrade.
@@ -972,6 +998,8 @@ class BambuMQTTClient:
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
             self.state.connected = True
+            self.last_connect_error = None
+            self.last_connect_error_name = None
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             # A dropped-and-restored MQTT session means the presumed power-off was
             # real (or at least that the printer restarted): there is nothing
@@ -1029,6 +1057,43 @@ class BambuMQTTClient:
                 self.on_state_change(self.state)
         else:
             self.state.connected = False
+            self._record_connect_refusal(rc)
+
+    def _record_connect_refusal(self, rc) -> None:
+        """Log and remember why the printer refused the MQTT connection.
+
+        The failure branch of ``_on_connect`` used to be a bare
+        ``connected = False``, which threw away the only signal that says
+        *why* a printer never comes online. The user-visible result was a
+        30-second reconnect loop logging nothing but paho's generic
+        ``MQTT disconnected: rc=Unspecified error`` — indistinguishable from a
+        powered-off printer, so "my printer won't print" reports could not be
+        triaged without a round trip (#2698).
+
+        Never logs the access code itself; the code is the likely culprit but
+        printing it would put a credential in every support bundle.
+        """
+        code = getattr(rc, "value", rc)
+        name = rc.getName() if hasattr(rc, "getName") else str(rc)
+        self.last_connect_error_name = name
+        if isinstance(code, int) and code in _CONNACK_AUTH_REJECTED:
+            self.last_connect_error = CONNECT_ERROR_AUTH_REJECTED
+            logger.warning(
+                "[%s] MQTT connection refused by the printer: %s (code %s). The access code "
+                "or serial number is wrong — the access code changes every time LAN Only or "
+                "Developer Mode is toggled, so re-read it from the printer's screen.",
+                self.serial_number,
+                name,
+                code,
+            )
+        else:
+            self.last_connect_error = CONNECT_ERROR_REFUSED
+            logger.warning(
+                "[%s] MQTT connection refused by the printer: %s (code %s).",
+                self.serial_number,
+                name,
+                code,
+            )
 
     def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
         """Handle SUBACK responses to detect request topic subscription rejection."""
@@ -1085,7 +1150,21 @@ class BambuMQTTClient:
             )
             return
 
-        logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
+        # Carry the last CONNACK refusal into the disconnect line. paho reports
+        # the drop that follows a refused CONNACK as "Unspecified error", so on
+        # its own this line says nothing useful about a printer that is looping
+        # on bad credentials — and this is the line that fills a support bundle
+        # (#2698).
+        if self.last_connect_error:
+            logger.warning(
+                "[%s] MQTT disconnected: rc=%s, flags=%s (last connection attempt was refused: %s)",
+                self.serial_number,
+                rc,
+                disconnect_flags,
+                self.last_connect_error_name,
+            )
+        else:
+            logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
 
         # Detect if request topic subscription caused the disconnect.
         # If we just subscribed and got disconnected before any SUBACK confirmation,

+ 193 - 0
backend/app/services/design_settings.py

@@ -0,0 +1,193 @@
+"""Carry a 3MF designer's own process tweaks across a re-slice (#2622).
+
+A MakerWorld model is often published with deliberate deviations from the stock
+Bambu process preset — 5 walls, 100% infill, a 0.1mm first layer. Re-slicing that
+file for a different printer used to drop every one of them: ``--load-settings``
+is authoritative, so the picked process preset wins over the 3MF's embedded
+``Metadata/project_settings.config``.
+
+We do not have to *compute* what the designer changed. BambuStudio already did,
+and wrote the answer into the file:
+
+    different_settings_to_system = [
+        "enable_support;inner_wall_speed;sparse_infill_density;...",   # [0]  process
+        "filament_change_length;filament_prime_volume",                # [1..N] filaments
+        "machine_start_gcode;bed_custom_model;...",                    # [-1] printer
+    ]
+
+The array is ``1 + len(filament_settings_id) + 1`` long — verified against real
+files at 2, 3 and 4 filament slots. Index 0 is exactly the set of process keys
+that differ from the system preset, which is the reporter's step 1 for free: no
+baseline resolution, no shipping BBL profiles into Bambuddy, and no new endpoint
+on the slicer sidecar (which exposes bundled presets by name only, with no way to
+flatten one).
+
+Delivery is the mechanism ``_patch_process_support_settings`` already proved in
+#1881: write the values into the process JSON that goes out as ``--load-settings``.
+For a "standard" preset pick that JSON is a ``{inherits: …}`` stub, so the keys we
+write are the *child* in the inherits chain and win over the flattened parent.
+
+Not every key is safe to carry, though. Real files put ``inner_wall_speed``,
+``outer_wall_speed`` and ``prime_tower_max_speed`` in that list — values tuned for
+the designer's machine that can be plain wrong, or out of range, on the target.
+Those are classified :data:`PRINTER_COUPLED` and offered unticked; the caller
+decides. Nothing is applied that the caller did not ask for by name.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import zipfile
+from io import BytesIO
+from typing import Any, NamedTuple
+
+logger = logging.getLogger(__name__)
+
+_PROJECT_SETTINGS = "Metadata/project_settings.config"
+
+
+class DesignOverride(NamedTuple):
+    """One process setting the designer changed away from the system preset."""
+
+    key: str
+    value: Any
+    printer_coupled: bool
+
+
+# Process keys whose sane value depends on the machine, not on the design intent.
+# The designer picked these for *their* printer's kinematics, chamber and hotend;
+# carrying them onto another model risks a slice that is merely slower/uglier —
+# or a hard range-validation reject from the CLI, which is how the very first
+# slicer spike died. Offered, but never pre-selected.
+#
+# Matching is by exact key OR by suffix/substring rule below, because Bambu's
+# process schema has dozens of per-feature speed keys and an exhaustive literal
+# list would rot on every slicer release.
+_PRINTER_COUPLED_EXACT: frozenset[str] = frozenset(
+    {
+        "default_acceleration",
+        "independent_support_layer_height",
+        "precise_z_height",
+        "travel_acceleration",
+        "enable_wrapping_detection",
+    }
+)
+
+# Substring rules for the families that are always machine-coupled. Kept
+# deliberately narrow: "speed", "acceleration"/"accel" and "jerk" are the
+# kinematic families, "fan"/"temperature" follow the hotend and chamber, and
+# "prime_tower" follows the target's toolchange hardware.
+_PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
+    # Prime-tower geometry (and whether there is one at all) follows the target's
+    # extruder count and bed, not the design — a real file carries five of these.
+    "prime_tower",
+    "_speed",
+    "speed_",
+    "acceleration",
+    "_accel",
+    "jerk",
+    "fan_speed",
+    "_temperature",
+    "temperature_",
+)
+
+
+def is_printer_coupled(key: str) -> bool:
+    """Whether carrying this process key across printer models is risky."""
+    if key in _PRINTER_COUPLED_EXACT:
+        return True
+    lowered = key.lower()
+    return any(token in lowered for token in _PRINTER_COUPLED_SUBSTRINGS)
+
+
+def _split_changed_keys(entry: Any) -> list[str]:
+    """Parse one ``different_settings_to_system`` entry into its key names."""
+    if not isinstance(entry, str):
+        return []
+    return [part.strip() for part in entry.split(";") if part.strip()]
+
+
+def extract_design_process_overrides(zip_bytes: bytes) -> list[DesignOverride]:
+    """Process settings the 3MF's designer changed away from the system preset.
+
+    Returns an empty list for anything that is not a BambuStudio-style 3MF
+    carrying both ``project_settings.config`` and a well-formed
+    ``different_settings_to_system`` — including OrcaSlicer files and older
+    exports that predate the field. Callers treat empty as "nothing to offer",
+    which is the pre-feature behaviour.
+    """
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
+            if _PROJECT_SETTINGS not in zf.namelist():
+                return []
+            config = json.loads(zf.read(_PROJECT_SETTINGS).decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return []
+    return overrides_from_config(config)
+
+
+def overrides_from_config(config: Any) -> list[DesignOverride]:
+    """``extract_design_process_overrides`` on an already-parsed config dict."""
+    if not isinstance(config, dict):
+        return []
+
+    changed = config.get("different_settings_to_system")
+    if not isinstance(changed, list) or not changed:
+        return []
+
+    # Sanity-check the layout before trusting index 0. The array should be
+    # [process, *filaments, printer]; a file whose length disagrees with its own
+    # filament count is one we do not understand, and guessing there could carry
+    # printer G-code into the process slot.
+    filaments = config.get("filament_settings_id")
+    if isinstance(filaments, list) and len(changed) != len(filaments) + 2:
+        logger.debug(
+            "3MF different_settings_to_system has %d entries for %d filaments "
+            "(expected %d) — skipping design-settings carry-over",
+            len(changed),
+            len(filaments),
+            len(filaments) + 2,
+        )
+        return []
+
+    overrides: list[DesignOverride] = []
+    # Index 0 is the process slot — see the layout in the module docstring. The
+    # length check above is what earns the right to index it blindly.
+    for key in _split_changed_keys(changed[0]):
+        if key not in config:
+            # Listed as changed but absent from the flattened config — nothing
+            # to carry. Seen with keys the slicer renamed between versions.
+            continue
+        overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
+
+    overrides.sort(key=lambda o: o.key)
+    return overrides
+
+
+def apply_design_overrides(process_json: str, overrides: list[DesignOverride], selected_keys: list[str]) -> str:
+    """Write the selected designer values into the outgoing process JSON.
+
+    ``selected_keys`` is authoritative — a key the caller did not name is not
+    applied even when it is present in ``overrides``. Returns ``process_json``
+    unchanged when nothing is selected or the JSON is unparseable, so a bad
+    input degrades to a plain profile slice rather than failing it.
+    """
+    if not selected_keys or not overrides:
+        return process_json
+
+    wanted = set(selected_keys)
+    by_key = {o.key: o.value for o in overrides if o.key in wanted}
+    if not by_key:
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    process_cfg.update(by_key)
+    logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
+    return json.dumps(process_cfg)

+ 46 - 1
backend/app/services/mqtt_relay.py

@@ -240,7 +240,14 @@ class MQTTRelayService:
     # Printer Events
     # =========================================================================
 
-    async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):
+    async def on_printer_status(
+        self,
+        printer_id: int,
+        state: Any,
+        printer_name: str,
+        printer_serial: str,
+        awaiting_plate_clear: bool = False,
+    ):
         """Publish printer status change (throttled to 1 update/sec per printer)."""
         if not self.enabled or not self.connected:
             return
@@ -275,6 +282,13 @@ class MQTTRelayService:
             "big_fan1_speed": state.big_fan1_speed,
             "big_fan2_speed": state.big_fan2_speed,
             "heatbreak_fan_speed": state.heatbreak_fan_speed,
+            # Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
+            # Web UI already receives via printer_state_to_dict, so an external
+            # automation can tell "finished" from "finished and still waiting for
+            # someone to clear the bed". Edge changes are also published on
+            # printers/{serial}/plate_clear — this topic only refreshes when the
+            # printer pushes telemetry, which stops entirely after Auto Off.
+            "awaiting_plate_clear": awaiting_plate_clear,
         }
 
         self._publish(
@@ -283,6 +297,37 @@ class MQTTRelayService:
             retain=True,
         )
 
+    async def on_plate_clear_state(
+        self,
+        printer_id: int,
+        printer_name: str,
+        printer_serial: str,
+        awaiting: bool,
+    ):
+        """Publish the plate-clear gate as it flips (#2525).
+
+        Retained, unlike the other per-printer event topics, because this is a
+        *state* an automation needs on subscribe rather than a moment it might
+        have missed. The status topic carries the same field, but only refreshes
+        when the printer pushes telemetry — after Auto Off cycles the printer the
+        retained status payload would sit at ``awaiting_plate_clear: false``
+        indefinitely while the gate is in fact still up.
+        """
+        if not self.enabled or not self.connected:
+            return
+
+        self._publish(
+            f"{self.topic_prefix}/printers/{printer_serial}/plate_clear",
+            {
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "printer_serial": printer_serial,
+                "awaiting": awaiting,
+                "timestamp": datetime.now(timezone.utc).isoformat(),
+            },
+            retain=True,
+        )
+
     async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
         """Publish printer came online event."""
         if not self.enabled or not self.connected:

+ 97 - 0
backend/app/services/notification_service.py

@@ -225,6 +225,8 @@ class NotificationService:
                 return await self._send_webhook(config, title, message)
             elif provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
+            elif provider_type == "bark":
+                return await self._send_bark(config, title, message)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
@@ -251,6 +253,48 @@ class NotificationService:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
+    async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+        """Send notification via Bark, the self-hostable iOS push service (#1495).
+
+        POSTs JSON to {server}/push. Defaults to the official api.day.app
+        relay; a self-hosted bark-server works by overriding the server URL.
+        """
+        server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
+        device_key = (config.get("device_key") or "").strip()
+
+        if not device_key:
+            return False, "Device key is required"
+
+        payload: dict[str, Any] = {
+            "device_key": device_key,
+            "title": title,
+            "body": message,
+        }
+        group = (config.get("group") or "").strip()
+        if group:
+            payload["group"] = group
+        sound = (config.get("sound") or "").strip()
+        if sound:
+            payload["sound"] = sound
+        level = (config.get("level") or "").strip()
+        if level in ("active", "timeSensitive", "critical", "passive"):
+            payload["level"] = level
+
+        client = await self._get_client()
+        response = await client.post(f"{server}/push", json=payload)
+
+        if response.status_code == 200:
+            # bark-server can report failures inside an HTTP 200 body
+            # ({"code": 400, "message": ...}), so the status alone isn't proof.
+            try:
+                body = response.json()
+            except ValueError:
+                body = None
+            if isinstance(body, dict) and body.get("code") not in (200, None):
+                return False, f"Bark error {body.get('code')}: {str(body.get('message'))[:200]}"
+            return True, "Message sent successfully"
+        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
     async def _send_ntfy(
         self,
         config: dict,
@@ -741,6 +785,24 @@ class NotificationService:
             "message": message,
         }
 
+        # Optional custom service-data (#1441), forwarded as HA's nested "data"
+        # object so mobile-app push options (priority, ttl, channel, group, ...)
+        # reach the notify service. Only included when configured — the default
+        # persistent_notification.create schema rejects unknown keys.
+        raw_data = config.get("data")
+        if raw_data:
+            if isinstance(raw_data, str):
+                try:
+                    parsed_data = json.loads(raw_data)
+                except json.JSONDecodeError as e:
+                    return False, f"Invalid JSON in the Data field: {e}"
+            else:
+                parsed_data = raw_data
+            if not isinstance(parsed_data, dict):
+                return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
+            if parsed_data:
+                payload["data"] = parsed_data
+
         client = await self._get_client()
         response = await client.post(url, json=payload, headers=headers)
 
@@ -794,6 +856,8 @@ class NotificationService:
                 )
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
+            elif provider.provider_type == "bark":
+                return await self._send_bark(config, title, message)
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
@@ -1317,6 +1381,39 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_plate_clear_required(
+        self,
+        printer_id: int,
+        printer_name: str,
+        db: AsyncSession,
+    ):
+        """Handle plate-clear-required event — a print ended and the queue is gated (#2525).
+
+        Distinct from ``on_plate_not_empty``, which is the camera check *before* a
+        print starts. This one fires on the rising edge of the Bambuddy-side
+        awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
+        and the next queued job can't dispatch until someone confirms the bed is
+        free. Off by default on every provider: it lands at the same moment as the
+        print-complete notification, so opting in is a deliberate choice.
+        """
+        providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
+        if not providers:
+            return
+
+        variables = {"printer": printer_name}
+
+        title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "plate_clear_required",
+            printer_id,
+            printer_name,
+            variables=variables,
+        )
+
     async def on_filament_low(
         self,
         printer_id: int,

+ 16 - 8
backend/app/services/obico_detection.py

@@ -320,6 +320,21 @@ class ObicoDetectionService:
 
     # ---- queries ----
 
+    def get_per_printer(self) -> dict:
+        """Live classification per actively monitored printer.
+
+        Only printers with a running, monitored print have a state entry, so
+        consumers get "show nothing" for idle printers for free.
+        """
+        return {
+            pid: {
+                "class": self._last_class.get(pid, "safe"),
+                "frame_count": state.frame_count,
+                "score": round(state.ewm_mean, 4),
+            }
+            for pid, state in self._states.items()
+        }
+
     def get_status(self, sensitivity: str = "medium") -> dict:
         # Report the thresholds for the configured sensitivity, not a hardcoded
         # "medium" — otherwise the Status panel always shows the medium row
@@ -329,14 +344,7 @@ class ObicoDetectionService:
         return {
             "is_running": self._task is not None and not self._task.done(),
             "last_error": self._last_error,
-            "per_printer": {
-                pid: {
-                    "class": self._last_class.get(pid, "safe"),
-                    "frame_count": state.frame_count,
-                    "score": round(state.ewm_mean, 4),
-                }
-                for pid, state in self._states.items()
-            },
+            "per_printer": self.get_per_printer(),
             "thresholds": {"low": low, "high": high},
             "history": list(self._history),
         }

+ 1 - 0
backend/app/services/print_scheduler.py

@@ -3122,6 +3122,7 @@ class PrintScheduler:
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     project_id=item.project_id,
+                    library_file_id=item.library_file_id,  # per-file project progress (#1897)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 if archive:

+ 34 - 2
backend/app/services/printer_diagnostic.py

@@ -16,6 +16,7 @@ import socket
 
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
 from backend.app.services.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
@@ -56,6 +57,21 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
         return False
 
 
+def _auth_reason_params(reason: str | None) -> dict:
+    """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
+
+    The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
+    is present and falls back to the plain per-status text otherwise, so an
+    unknown or absent slug degrades to today's generic wording rather than a
+    missing string. Only `auth_rejected` currently carries its own message:
+    that is the one case where the printer positively told us the credentials
+    were wrong, as opposed to us merely observing that we are not connected.
+    """
+    if reason == CONNECT_ERROR_AUTH_REJECTED:
+        return {"reason": CONNECT_ERROR_AUTH_REJECTED}
+    return {}
+
+
 def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
     """Return the model-specific camera diagnostic port and display protocol."""
     if not printer:
@@ -249,14 +265,30 @@ async def run_connection_diagnostic(
                 serial_number=serial_number,
                 access_code=access_code,
             )
-            checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
+            checks.append(
+                DiagnosticCheck(
+                    id="mqtt_auth",
+                    status="pass" if result.get("success") else "fail",
+                    params=_auth_reason_params(result.get("reason")),
+                )
+            )
         except Exception:
             logger.debug("test_connection failed during diagnostic", exc_info=True)
             checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
     elif state is not None:
         # Existing printer: trust the live MQTT state rather than opening a
         # second connection (Bambu printers tolerate few concurrent sessions).
-        checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
+        # `connected == False` alone does not say *why* — the live client keeps
+        # the last CONNACK refusal, so a rejected access code can be reported as
+        # such instead of as a generic failure the user has to guess at (#2698).
+        client = printer_manager.get_client(printer.id) if printer else None
+        checks.append(
+            DiagnosticCheck(
+                id="mqtt_auth",
+                status="pass" if state.connected else "fail",
+                params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
+            )
+        )
     else:
         checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
 

+ 51 - 0
backend/app/services/printer_manager.py

@@ -377,6 +377,13 @@ class PrinterManager:
         UI without it. Centralised here so every current AND future caller is
         covered without each one having to remember to broadcast.
         """
+        # Callers re-assert the current value routinely (the queue clears the gate
+        # on every dispatch, whether or not it was up), so the outward-facing
+        # emissions below are edge-triggered — an MQTT subscriber or a phone
+        # notification must not see a "plate cleared" for a plate that was never
+        # dirty. Persistence and the WebSocket broadcast stay unconditional: they
+        # are idempotent and predate this (#961/#1128).
+        changed = awaiting != (printer_id in self._awaiting_plate_clear)
         if awaiting:
             self._awaiting_plate_clear.add(printer_id)
         else:
@@ -386,6 +393,45 @@ class PrinterManager:
         if self._loop and self._loop.is_running():
             self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
             self._schedule_async(self._broadcast_status_change(printer_id))
+            if changed:
+                self._schedule_async(self._emit_plate_clear_change(printer_id, awaiting))
+
+    async def _emit_plate_clear_change(self, printer_id: int, awaiting: bool) -> None:
+        """Relay a plate-clear gate transition to MQTT and notifications (#2525).
+
+        The flag is Bambuddy-side, so nothing about it reaches an external
+        automation on its own — the printer's own MQTT push knows only
+        RUNNING/PAUSE/FAILED/FINISH/IDLE. Emitted from here rather than from the
+        three call sites so every current and future caller is covered, the same
+        reasoning as the WebSocket broadcast above.
+
+        Imports are local: ``mqtt_relay`` and ``notification_service`` both sit
+        above this module in the dependency order.
+        """
+        printer = self.get_printer(printer_id)
+        if not printer:
+            return
+
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            await mqtt_relay.on_plate_clear_state(printer_id, printer.name, printer.serial_number, awaiting)
+        except Exception as e:
+            logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
+
+        # Only the rising edge is worth a notification — "the bed is now free"
+        # is not an action item, and the queue clears the gate by itself.
+        if not awaiting:
+            return
+
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.services.notification_service import notification_service
+
+            async with async_session() as db:
+                await notification_service.on_plate_clear_required(printer_id, printer.name, db)
+        except Exception as e:
+            logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
 
     async def _broadcast_status_change(self, printer_id: int) -> None:
         """Emit a ``printer_status`` WebSocket update for this printer (#1128).
@@ -905,6 +951,11 @@ class PrinterManager:
                 "success": client.state.connected,
                 "state": client.state.state if client.state.connected else None,
                 "model": client.state.raw_data.get("device_model"),
+                # Why the probe failed, when the printer told us: one of the
+                # CONNECT_ERROR_* slugs, else None. Lets the add-printer flow
+                # and the connection diagnostic say "the printer rejected the
+                # access code" instead of an unqualified failure (#2698).
+                "reason": None if client.state.connected else client.last_connect_error,
             }
         finally:
             # Off-loop teardown — see docstring. paho's loop_stop() joins the

+ 13 - 1
backend/tests/conftest.py

@@ -203,8 +203,17 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
     test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
 
     async def override_get_db():
+        # Mirror production get_db (core/database.py): commit on success,
+        # rollback on error. Endpoints that rely on the request-scoped
+        # implicit commit (e.g. create_project, which only flushes) would
+        # otherwise silently lose their writes in tests (#1897).
         async with test_async_session() as session:
-            yield session
+            try:
+                yield session
+                await session.commit()
+            except BaseException:
+                await session.rollback()
+                raise
 
     app.dependency_overrides[get_db] = override_get_db
 
@@ -217,6 +226,9 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         patch("backend.app.core.database.async_session", test_async_session),
         patch("backend.app.core.auth.async_session", test_async_session),
         patch("backend.app.main.async_session", test_async_session),
+        # Obico endpoints load settings through the service's module-level binding;
+        # without this patch they'd read whatever DB the cwd resolves to (#1546).
+        patch("backend.app.services.obico_detection.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
     ):
         # Seed default groups for tests that need them

+ 23 - 0
backend/tests/integration/test_archives_api.py

@@ -1022,6 +1022,29 @@ class TestArchivesSlimAPI:
         assert "duplicates" not in item
         assert "duplicate_count" not in item
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_includes_energy_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Per-print smart-plug energy surfaces through /slim so the stats
+        page can include it in cost records and trends (#1432)."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="completed",
+            cost=1.50,
+            energy_kwh=0.421,
+            energy_cost=0.063,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["energy_kwh"] == 0.421
+        assert item["energy_cost"] == 0.063
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_slim_computes_actual_time(

+ 100 - 0
backend/tests/integration/test_design_settings_plates.py

@@ -0,0 +1,100 @@
+"""The plates endpoints must surface the designer's changed settings (#2622).
+
+Parsing is covered in ``unit/test_design_settings.py``. What is asserted here is
+the wiring: SliceModal reads ``design_overrides`` off the plates response, so a
+correct parser that never reaches the payload is a feature that silently does
+nothing.
+"""
+
+import json
+import zipfile
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+
+def _designed_3mf(path: Path, *, with_deviations: bool = True) -> None:
+    """A Bambu-style project 3MF, optionally carrying designer deviations."""
+    config = {
+        "print_settings_id": "0.20mm Standard @BBL A1",
+        "printer_settings_id": "Bambu Lab A1 0.4 nozzle",
+        "filament_settings_id": ["Bambu PLA Basic @BBL A1"],
+        "wall_loops": "5",
+        "outer_wall_speed": "200",
+        "machine_start_gcode": "G28 ; designer printer",
+        "different_settings_to_system": (
+            ["wall_loops;outer_wall_speed", "", "machine_start_gcode"] if with_deviations else ["", "", ""]
+        ),
+    }
+    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("Metadata/plate_1.gcode", "G0\n")
+        zf.writestr("Metadata/project_settings.config", json.dumps(config))
+
+
+@pytest.fixture
+def _patch_base_dir(monkeypatch, tmp_path):
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+class TestArchivePlatesDesignOverrides:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_the_process_deviations_with_classification(
+        self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
+    ):
+        _designed_3mf(_patch_base_dir / "designed.3mf")
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="designed.3mf", file_path="designed.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+        assert response.status_code == 200
+        overrides = response.json()["design_overrides"]
+        assert [o["key"] for o in overrides] == ["outer_wall_speed", "wall_loops"]
+        by_key = {o["key"]: o for o in overrides}
+        assert by_key["wall_loops"] == {"key": "wall_loops", "value": "5", "printer_coupled": False}
+        assert by_key["outer_wall_speed"]["printer_coupled"] is True
+        # The printer slot must never leak into the process list.
+        assert "machine_start_gcode" not in by_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_for_a_file_that_changes_nothing(
+        self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
+    ):
+        _designed_3mf(_patch_base_dir / "stock.3mf", with_deviations=False)
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filename="stock.3mf", file_path="stock.3mf")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+        assert response.status_code == 200
+        assert response.json()["design_overrides"] == []
+
+
+class TestLibraryPlatesDesignOverrides:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_the_process_deviations(self, async_client: AsyncClient, db_session, tmp_path):
+        from backend.app.models.library import LibraryFile
+
+        path = tmp_path / "designed.3mf"
+        _designed_3mf(path)
+        lib_file = LibraryFile(
+            filename="designed.3mf",
+            file_path=str(path),
+            file_type="3mf",
+            file_size=path.stat().st_size,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/plates")
+
+        assert response.status_code == 200
+        assert [o["key"] for o in response.json()["design_overrides"]] == ["outer_wall_speed", "wall_loops"]

+ 74 - 1
backend/tests/integration/test_obico_api.py

@@ -8,7 +8,8 @@ hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
 import pytest
 from httpx import AsyncClient
 
-from backend.app.services.obico_detection import _frame_cache, stash_frame
+from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
+from backend.app.services.obico_smoothing import PrintState
 
 FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
 
@@ -69,3 +70,75 @@ class TestObicoCachedFrame:
         response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
         assert response.status_code == 200
         assert "no-store" in response.headers.get("cache-control", "")
+
+
+class TestObicoPrinterStatus:
+    """The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
+
+    @pytest.fixture(autouse=True)
+    def clear_detection_state(self):
+        obico_detection_service._states.clear()
+        obico_detection_service._last_class.clear()
+        obico_detection_service._last_error = None
+        yield
+        obico_detection_service._states.clear()
+        obico_detection_service._last_class.clear()
+        obico_detection_service._last_error = None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_per_printer_classification(self, async_client: AsyncClient):
+        state = PrintState()
+        state.update(0.5)
+        obico_detection_service._states[1] = state
+        obico_detection_service._last_class[1] = "warning"
+
+        response = await async_client.get("/api/v1/obico/printer-status")
+        assert response.status_code == 200
+        data = response.json()
+        assert "enabled" in data
+        # None = all printers monitored (no obico_enabled_printers subset configured)
+        assert data["monitored_printers"] is None
+        entry = data["per_printer"]["1"]
+        assert entry["class"] == "warning"
+        assert entry["frame_count"] == 1
+        assert isinstance(entry["score"], float)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/obico/printer-status")
+        assert response.status_code == 200
+        assert response.json()["per_printer"] == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
+        """A configured obico_enabled_printers subset surfaces (as a sorted list) so
+        the frontend can show the idle badge only on monitored printers."""
+        update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
+        assert update.status_code == 200
+        try:
+            response = await async_client.get("/api/v1/obico/printer-status")
+            assert response.json()["monitored_printers"] == [1, 3]
+        finally:
+            await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_last_error_is_surfaced(self, async_client: AsyncClient):
+        """The badge modal shows the service's last error (auth disabled in the
+        test env, so the settings:read gate on the field is open)."""
+        obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
+        response = await async_client.get("/api/v1/obico/printer-status")
+        assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_does_not_leak_settings(self, async_client: AsyncClient):
+        """Unlike /obico/status, this endpoint is readable with printers:read only,
+        so it must not expose the ML URL or other configuration."""
+        response = await async_client.get("/api/v1/obico/printer-status")
+        data = response.json()
+        for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
+            assert key not in data

+ 157 - 4
backend/tests/integration/test_ownership_permissions.py

@@ -827,13 +827,106 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
 
         assert response.status_code == 403
 
+    # ========================================================================
+    # Folder deletion (#1781): folders have no ownership tracking, so users
+    # with only library:delete_own may delete empty, non-external, non-linked
+    # folders. Everything else still requires library:delete_all.
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_delete_empty_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """A user with library:delete_own can delete an empty folder (#1781)."""
+        folder = await library_folder_factory(name="EmptyFolder")
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_cannot_delete_empty_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """No delete permission at all still means no folder deletion."""
+        folder = await library_folder_factory(name="EmptyFolder")
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_folder_with_files(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """Non-empty folders still require library:delete_all."""
+        folder = await library_folder_factory(name="FullFolder")
+        await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_folder_with_trashed_file(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """Trashed files count as content: cascade would hard-drop them and
+        silently break trash restore for their owner."""
+        from datetime import datetime, timezone
+
+        folder = await library_folder_factory(name="TrashedContentFolder")
+        await library_file_factory(
+            folder_id=folder.id,
+            created_by_id=auth_setup["operator2_user"]["id"],
+            deleted_at=datetime.now(timezone.utc),
+        )
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory):
-        """Folders require *_all permission (no ownership tracking on folders)."""
-        folder = await library_folder_factory(name="TestFolder")
+    async def test_operator_cannot_delete_folder_with_subfolder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """A folder containing subfolders (even empty ones) is not empty."""
+        parent = await library_folder_factory(name="ParentFolder")
+        await library_folder_factory(name="ChildFolder", parent_id=parent.id)
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{parent.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_external_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """Deleting an external folder unmounts an operator-configured mount
+        for everyone — stays behind library:delete_all even when empty."""
+        folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
 
-        # Operator cannot delete folder (needs *_all)
         response = await async_client.delete(
             f"/api/v1/library/folders/{folder.id}",
             headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
@@ -841,6 +934,66 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
 
         assert response.status_code == 403
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_linked_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
+    ):
+        """Project/archive links are created via update_all, so unlinking by
+        deletion stays admin-only even for empty folders."""
+        from backend.app.models.project import Project
+
+        project = Project(name="LinkTestProject")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+
+        folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_delete_folder_with_contents(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """library:delete_all keeps full cascade deletion."""
+        folder = await library_folder_factory(name="AdminFolder")
+        await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_delete_operator_folders_empty_only(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
+        empty_folder = await library_folder_factory(name="BulkEmpty")
+        full_folder = await library_folder_factory(name="BulkFull")
+        await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            "/api/v1/library/bulk-delete",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["deleted_folders"] == 1
+        assert result["deleted_files"] == 0
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):

+ 79 - 0
backend/tests/integration/test_plate_clear_notification.py

@@ -0,0 +1,79 @@
+"""Integration tests for the plate-clear-required notification (#2525).
+
+The event is opt-in: it fires after every print, at the same moment as the
+print-complete alert, so a provider only receives it when the toggle is
+explicitly enabled.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.notification_service import notification_service
+
+
+class TestPlateClearNotificationDispatch:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sends_to_a_provider_that_opted_in(self, notification_provider_factory, db_session):
+        await notification_provider_factory(name="Opted In", on_plate_clear_required=True)
+
+        send = AsyncMock()
+        with patch.object(notification_service, "_send_to_providers", send):
+            await notification_service.on_plate_clear_required(1, "Workshop", db_session)
+
+        assert send.await_count == 1
+        providers = send.await_args.args[0]
+        assert [p.name for p in providers] == ["Opted In"]
+        assert send.await_args.args[4] == "plate_clear_required"
+        # _build_message_from_template folds in app_name/timestamp; the caller's
+        # own variable is what matters here.
+        assert send.await_args.kwargs["variables"]["printer"] == "Workshop"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_silent_for_a_provider_that_did_not_opt_in(self, notification_provider_factory, db_session):
+        await notification_provider_factory(name="Default Off", on_plate_clear_required=False)
+
+        send = AsyncMock()
+        with patch.object(notification_service, "_send_to_providers", send):
+            await notification_service.on_plate_clear_required(1, "Workshop", db_session)
+
+        send.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skips_a_provider_scoped_to_a_different_printer(self, notification_provider_factory, db_session):
+        await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=99)
+
+        send = AsyncMock()
+        with patch.object(notification_service, "_send_to_providers", send):
+            await notification_service.on_plate_clear_required(1, "Workshop", db_session)
+
+        send.assert_not_awaited()
+
+
+class TestPlateClearProviderField:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_off_on_create_and_round_trips_on_update(self, async_client: AsyncClient):
+        create = await async_client.post(
+            "/api/v1/notifications/",
+            json={
+                "name": "Plate Clear Test",
+                "provider_type": "ntfy",
+                "enabled": True,
+                "config": {"server": "https://ntfy.sh", "topic": "test-topic"},
+            },
+        )
+        assert create.status_code in (200, 201), create.text
+        provider_id = create.json()["id"]
+        assert create.json()["on_plate_clear_required"] is False
+
+        update = await async_client.patch(
+            f"/api/v1/notifications/{provider_id}",
+            json={"on_plate_clear_required": True},
+        )
+        assert update.status_code == 200, update.text
+        assert update.json()["on_plate_clear_required"] is True

+ 224 - 0
backend/tests/integration/test_projects_api.py

@@ -1393,3 +1393,227 @@ class TestProjectListEditableFields:
         result = response.json()
         assert result["tags"] is None
         assert result["due_date"] is None
+
+
+class TestProjectFileProgress:
+    """Per-file print progress inside a project (#1897).
+
+    Covers GET /projects/{id}/file-progress (attribution: library_file_id →
+    content hash → filename, completed runs only, project-scoped), the
+    target_sets field round-trip, and the add-to-queue project inheritance
+    that feeds the attribution chain.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            defaults = {"name": f"Progress Project {_counter[0]}"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def folder_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_folder(**kwargs):
+            from backend.app.models.library import LibraryFolder
+
+            _counter[0] += 1
+            defaults = {"name": f"ProgressFolder {_counter[0]}"}
+            defaults.update(kwargs)
+            folder = LibraryFolder(**defaults)
+            db_session.add(folder)
+            await db_session.commit()
+            await db_session.refresh(folder)
+            return folder
+
+        return _create_folder
+
+    @pytest.fixture
+    async def file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "filename": f"plate_{counter}.gcode.3mf",
+                "file_path": f"library/plate_{counter}.gcode.3mf",
+                "file_size": 1024,
+                "file_type": "3mf",
+            }
+            defaults.update(kwargs)
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_counts_by_library_file_id(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Runs stamped with library_file_id count toward that file even when
+        the archive's filename differs (rename after dispatch)."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        file_a = await file_factory(folder_id=folder.id)
+        file_b = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        for _ in range(2):
+            await archive_factory(
+                printer.id,
+                project_id=project.id,
+                library_file_id=file_a.id,
+                filename="renamed_on_dispatch.gcode.3mf",
+            )
+        await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        assert response.status_code == 200
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {file_a.id: 2, file_b.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hash_and_filename_fallback(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Historical archives without library_file_id match by content hash,
+        then by filename."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64)
+        named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf")
+        printer = await printer_factory()
+
+        # Hash match despite a different filename
+        await archive_factory(
+            printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf"
+        )
+        # Filename match with no hash on either side
+        await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf")
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {hashed_file.id: 1, named_file.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_only_completed_runs_count(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Failed runs and never-printed archives do not advance the count."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        lib_file = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id)
+        await archive_factory(
+            printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed"
+        )
+        await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {lib_file.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scoped_to_project(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Runs of the same file outside the project (no project / another
+        project) are excluded."""
+        project = await project_factory()
+        other_project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        lib_file = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id)
+        await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_project_404(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/projects/999999/file-progress")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_sets_roundtrip(self, async_client: AsyncClient):
+        """target_sets survives create, update, and explicit-null clearing."""
+        create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10})
+        assert create.status_code == 200
+        project = create.json()
+        assert project["target_sets"] == 10
+
+        update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4})
+        assert update.status_code == 200, update.json()
+        assert update.json()["target_sets"] == 4
+
+        cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None})
+        assert cleared.json()["target_sets"] is None
+
+        untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"})
+        assert untouched.json()["target_sets"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_inherits_folder_project(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path
+    ):
+        """Queueing a file from a project-linked folder attributes the queue
+        item (and thus the later archive) to that project; a root file stays
+        unattributed."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+
+        on_disk = tmp_path / "linked.gcode.3mf"
+        on_disk.write_bytes(b"fake sliced content")
+        linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk))
+
+        root_disk = tmp_path / "root.gcode.3mf"
+        root_disk.write_bytes(b"fake sliced content")
+        root_file = await file_factory(folder_id=None, file_path=str(root_disk))
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]}
+        )
+        assert response.status_code == 200
+        assert len(response.json()["added"]) == 2
+
+        result = await db_session.execute(
+            select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where(
+                PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id])
+            )
+        )
+        projects_by_file = dict(result.all())
+        assert projects_by_file[linked_file.id] == project.id
+        assert projects_by_file[root_file.id] is None

+ 85 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5,6 +5,7 @@ These tests focus on timelapse tracking during prints.
 """
 
 import json
+import logging
 import time
 
 import pytest
@@ -6653,3 +6654,87 @@ class TestKProfileResponseDoesNotClobberNozzle:
         mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
         mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+
+class TestConnectRefusalReporting:
+    """#2698: a refused CONNACK must leave a trace.
+
+    ``_on_connect``'s failure branch used to be a bare ``connected = False``.
+    A printer refusing our access code then looked exactly like one that was
+    powered off: paho reports the follow-up drop as the generic "Unspecified
+    error", so the support bundle from a 30-second reconnect loop carried no
+    hint of the real cause. Bambu speaks MQTT 3.1.1, whose CONNACK return codes
+    4 and 5 paho maps to reason codes 134 / 135.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _connack(v3_return_code):
+        from paho.mqtt.client import convert_connack_rc_to_reason_code
+
+        return convert_connack_rc_to_reason_code(v3_return_code)
+
+    def test_no_error_recorded_before_any_attempt(self, mqtt_client):
+        assert mqtt_client.last_connect_error is None
+        assert mqtt_client.last_connect_error_name is None
+
+    @pytest.mark.parametrize("v3_rc", [4, 5])
+    def test_credential_refusal_recorded(self, mqtt_client, v3_rc, caplog):
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_connect(None, None, None, self._connack(v3_rc))
+
+        assert mqtt_client.state.connected is False
+        assert mqtt_client.last_connect_error == "auth_rejected"
+        assert "refused" in caplog.text.lower()
+        # The remedy has to be in the log — that line is what a maintainer
+        # reads out of a support bundle.
+        assert "access code" in caplog.text.lower()
+        # Never leak the credential itself into a bundle.
+        assert "12345678" not in caplog.text
+
+    def test_non_credential_refusal_recorded_separately(self, mqtt_client, caplog):
+        # CONNACK 3 = server unavailable: a real refusal, but not about creds.
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_connect(None, None, None, self._connack(3))
+
+        assert mqtt_client.last_connect_error == "refused"
+        assert "access code" not in caplog.text.lower()
+
+    def test_successful_connect_clears_previous_error(self, mqtt_client):
+        mqtt_client._on_connect(None, None, None, self._connack(5))
+        assert mqtt_client.last_connect_error == "auth_rejected"
+
+        mock_client = type("MockClient", (), {"subscribe": lambda self, topic: (0, 1)})()
+        mqtt_client._on_connect(mock_client, None, None, 0)
+
+        assert mqtt_client.state.connected is True
+        assert mqtt_client.last_connect_error is None
+        assert mqtt_client.last_connect_error_name is None
+
+    def test_disconnect_line_carries_the_refusal(self, mqtt_client, caplog):
+        """The reconnect loop is what fills the log, so it must say why."""
+        mqtt_client._on_connect(None, None, None, self._connack(5))
+        caplog.clear()
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_disconnect(None, None)
+
+        assert "MQTT disconnected" in caplog.text
+        assert "refused" in caplog.text
+        assert "Not authorized" in caplog.text
+
+    def test_disconnect_line_unchanged_without_a_refusal(self, mqtt_client, caplog):
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_disconnect(None, None)
+
+        assert "MQTT disconnected" in caplog.text
+        assert "refused" not in caplog.text

+ 222 - 0
backend/tests/unit/services/test_notification_service.py

@@ -877,6 +877,113 @@ class TestHomeAssistantProvider:
             assert payload["title"] == "Test Title"
             assert payload["message"] == "Test Message"
 
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_custom_data_merged(self, service):
+        """Custom service-data (#1441) is forwarded as HA's nested "data" object
+        so mobile-app push options (priority, ttl, channel, ...) reach the
+        notify service."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            config = {
+                "service": "notify.mobile_app_myphone",
+                "data": '{"priority": "high", "ttl": 0, "channel": "3D Printing"}',
+            }
+            success, _ = await service._send_homeassistant(config, "Title", "Body", db=mock_db)
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            assert call_args[0][0] == "http://ha.local:8123/api/services/notify/mobile_app_myphone"
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert payload["data"] == {"priority": "high", "ttl": 0, "channel": "3D Printing"}
+            # ttl must survive as a number, not a string — that's why the
+            # field is JSON rather than key=value lines.
+            assert payload["data"]["ttl"] == 0
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_without_data_omits_key(self, service):
+        """Without configured data the payload carries no "data" key — the
+        default persistent_notification.create schema rejects unknown keys."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            success, _ = await service._send_homeassistant({}, "Title", "Body", db=mock_db)
+
+            assert success is True
+            payload = mock_client.post.call_args.kwargs.get("json") or mock_client.post.call_args[1].get("json")
+            assert "data" not in payload
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_invalid_data_rejected(self, service):
+        """Malformed JSON and non-object JSON in the data field fail loudly
+        instead of sending a half-built payload."""
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_client = AsyncMock()
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            success, message = await service._send_homeassistant(
+                {"data": "{priority: high}"}, "Title", "Body", db=mock_db
+            )
+            assert success is False
+            assert "Invalid JSON" in message
+
+            success, message = await service._send_homeassistant({"data": '["a", "b"]'}, "Title", "Body", db=mock_db)
+            assert success is False
+            assert "JSON object" in message
+
+            mock_client.post.assert_not_called()
+
     @pytest.mark.asyncio
     async def test_send_homeassistant_no_db_no_env(self, service):
         """Verify HA provider fails gracefully without DB or env vars."""
@@ -983,6 +1090,121 @@ class TestHomeAssistantProvider:
         mock_send.assert_called_once()
 
 
+class TestBarkProvider:
+    """Bark (iOS push) provider (#1495)."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    def _client_returning(self, status_code: int, json_body=None, text: str = ""):
+        mock_response = MagicMock()
+        mock_response.status_code = status_code
+        mock_response.text = text
+        if json_body is not None:
+            mock_response.json = MagicMock(return_value=json_body)
+        else:
+            mock_response.json = MagicMock(side_effect=ValueError("not json"))
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_send_bark_success_default_server(self, service):
+        """Minimal config posts to the official relay with device_key/title/body."""
+        mock_client = self._client_returning(200, {"code": 200, "message": "success"})
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            success, _ = await service._send_bark({"device_key": "abc123"}, "Title", "Body")
+
+        assert success is True
+        call_args = mock_client.post.call_args
+        assert call_args[0][0] == "https://api.day.app/push"
+        payload = call_args.kwargs.get("json")
+        assert payload == {"device_key": "abc123", "title": "Title", "body": "Body"}
+
+    @pytest.mark.asyncio
+    async def test_send_bark_options_and_custom_server(self, service):
+        """group/sound/level are forwarded; an unknown level is dropped rather
+        than sent; a self-hosted server URL (with trailing slash) is used."""
+        mock_client = self._client_returning(200, {"code": 200})
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            config = {
+                "device_key": "abc123",
+                "server": "https://bark.example.com/",
+                "group": "Bambuddy",
+                "sound": "minuet",
+                "level": "timeSensitive",
+            }
+            success, _ = await service._send_bark(config, "Title", "Body")
+
+        assert success is True
+        call_args = mock_client.post.call_args
+        assert call_args[0][0] == "https://bark.example.com/push"
+        payload = call_args.kwargs.get("json")
+        assert payload["group"] == "Bambuddy"
+        assert payload["sound"] == "minuet"
+        assert payload["level"] == "timeSensitive"
+
+        mock_client.post.reset_mock()
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            await service._send_bark({"device_key": "abc123", "level": "shouty"}, "Title", "Body")
+        assert "level" not in mock_client.post.call_args.kwargs.get("json")
+
+    @pytest.mark.asyncio
+    async def test_send_bark_missing_device_key(self, service):
+        mock_client = self._client_returning(200, {"code": 200})
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            success, message = await service._send_bark({}, "Title", "Body")
+
+        assert success is False
+        assert "Device key" in message
+        mock_client.post.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_send_bark_error_in_200_body(self, service):
+        """bark-server can wrap a failure in HTTP 200; the body code must win."""
+        mock_client = self._client_returning(200, {"code": 400, "message": "device token invalid"})
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
+
+        assert success is False
+        assert "device token invalid" in message
+
+    @pytest.mark.asyncio
+    async def test_send_bark_http_error(self, service):
+        mock_client = self._client_returning(400, None, text="failed to get device token")
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
+
+        assert success is False
+        assert "HTTP 400" in message
+
+    @pytest.mark.asyncio
+    async def test_send_to_provider_dispatches_bark(self, service):
+        provider = MagicMock()
+        provider.provider_type = "bark"
+        provider.config = json.dumps({"device_key": "abc123"})
+        provider.quiet_hours_enabled = False
+
+        with patch.object(service, "_send_bark", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+            success, _ = await service._send_to_provider(provider, "Title", "Message", db=AsyncMock())
+
+        assert success is True
+        mock_send.assert_called_once()
+
+
 class TestNotificationVariableFallbacks:
     """Tests for notification variable fallback values."""
 

+ 57 - 1
backend/tests/unit/services/test_printer_diagnostic.py

@@ -51,6 +51,7 @@ class _Env:
         state=None,
         test_connection_success=True,
         report_messages_since_connect: int | None = 5,
+        connect_error: str | None = None,
     ):
         self.ports = ports or _port_probe()
         self.in_docker = in_docker
@@ -61,17 +62,26 @@ class _Env:
         # ``None`` means get_client returns None (e.g. pre-add flow); an int
         # means there's a client with that counter value.
         self.report_messages_since_connect = report_messages_since_connect
+        # CONNACK-refusal slug the live client reports, or None when the last
+        # connection attempt was never refused (#2698).
+        self.connect_error = connect_error
         self._stack = ExitStack()
 
     def __enter__(self):
         manager = MagicMock()
         manager.get_status.return_value = self.state
-        manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
+        manager.test_connection = AsyncMock(
+            return_value={
+                "success": self.test_connection_success,
+                "reason": None if self.test_connection_success else self.connect_error,
+            }
+        )
         if self.report_messages_since_connect is None:
             manager.get_client.return_value = None
         else:
             client = MagicMock()
             client.report_messages_since_connect = self.report_messages_since_connect
+            client.last_connect_error = self.connect_error
             manager.get_client.return_value = client
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
@@ -248,6 +258,52 @@ class TestExistingPrinter:
         assert params == {}
 
 
+class TestAuthRejectedReason:
+    """#2698: "not connected" and "credentials refused" are different answers.
+
+    `state.connected == False` only says we have no session — the printer may
+    be rebooting, at its connection limit, or refusing the access code. When
+    the printer actually sent a CONNACK refusal the client records it, and the
+    check surfaces it as a `params.reason` variant so the UI can name the cause
+    instead of making the user guess. Without a recorded refusal the params
+    stay empty and the generic text is used.
+    """
+
+    def _params(self, result):
+        return next(c.params for c in result.checks if c.id == "mqtt_auth")
+
+    async def test_recorded_refusal_surfaces_reason(self):
+        with _Env(state=_state(connected=False), connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {"reason": "auth_rejected"}
+
+    async def test_disconnected_without_refusal_stays_generic(self):
+        with _Env(state=_state(connected=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {}
+
+    async def test_unknown_slug_falls_back_to_generic(self):
+        # `refused` has no dedicated message — degrade to the plain fail text
+        # rather than asking the frontend for a key that doesn't exist.
+        with _Env(state=_state(connected=False), connect_error="refused"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert self._params(result) == {}
+
+    async def test_connected_printer_carries_no_reason(self):
+        with _Env(state=_state(connected=True), connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "pass"
+        assert self._params(result) == {}
+
+    async def test_pre_add_probe_surfaces_reason(self):
+        with _Env(test_connection_success=False, connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {"reason": "auth_rejected"}
+
+
 class TestPreAddFlow:
     async def test_bad_credentials_fail_mqtt_auth(self):
         with _Env(test_connection_success=False):

+ 191 - 0
backend/tests/unit/test_design_settings.py

@@ -0,0 +1,191 @@
+"""Tests for carrying a 3MF designer's process tweaks across a re-slice (#2622).
+
+The layout asserted here — ``different_settings_to_system`` being
+``[process, *filaments, printer]`` — was verified against real BambuStudio files
+at 2, 3 and 4 filament slots before this was written. Getting the index wrong
+would carry ``machine_start_gcode`` from the designer's printer onto the user's,
+so the parser refuses any file whose array length disagrees with its own filament
+count rather than guessing.
+"""
+
+import io
+import json
+import zipfile
+
+from backend.app.services.design_settings import (
+    DesignOverride,
+    apply_design_overrides,
+    extract_design_process_overrides,
+    is_printer_coupled,
+    overrides_from_config,
+)
+
+
+def _config(**overrides) -> dict:
+    """A minimal project_settings.config with two filament slots."""
+    base = {
+        "print_settings_id": "0.20mm Standard @BBL A1",
+        "printer_settings_id": "Bambu Lab A1 0.4 nozzle",
+        "filament_settings_id": ["Bambu PLA Basic @BBL A1", "Bambu PLA Matte @BBL A1"],
+        "wall_loops": "5",
+        "sparse_infill_density": "100%",
+        "initial_layer_print_height": "0.1",
+        "outer_wall_speed": "200",
+        "machine_start_gcode": "G28 ; designer printer",
+        "different_settings_to_system": [
+            "wall_loops;sparse_infill_density;initial_layer_print_height;outer_wall_speed",
+            "",
+            "",
+            "machine_start_gcode",
+        ],
+    }
+    base.update(overrides)
+    return base
+
+
+def _3mf(config: dict | None, *, include_config: bool = True) -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        if include_config:
+            zf.writestr("Metadata/project_settings.config", json.dumps(config))
+    return buffer.getvalue()
+
+
+class TestClassification:
+    def test_geometry_and_quality_keys_are_portable(self):
+        for key in (
+            "wall_loops",
+            "sparse_infill_density",
+            "sparse_infill_pattern",
+            "initial_layer_print_height",
+            "layer_height",
+            "enable_support",
+            "brim_type",
+            "seam_position",
+            "ironing_type",
+        ):
+            assert is_printer_coupled(key) is False, key
+
+    def test_kinematic_and_thermal_keys_are_printer_coupled(self):
+        for key in (
+            "outer_wall_speed",
+            "inner_wall_speed",
+            "internal_solid_infill_speed",
+            "travel_acceleration",
+            "default_acceleration",
+            "default_jerk",
+            "overhang_fan_speed",
+            "nozzle_temperature",
+            "prime_tower_max_speed",
+            "prime_tower_width",
+            "prime_tower_rib_wall",
+            "prime_tower_infill_gap",
+            "enable_prime_tower",
+            "independent_support_layer_height",
+            "precise_z_height",
+        ):
+            assert is_printer_coupled(key) is True, key
+
+
+class TestExtraction:
+    def test_reads_the_process_slot_and_classifies_each_key(self):
+        overrides = extract_design_process_overrides(_3mf(_config()))
+
+        assert [o.key for o in overrides] == [
+            "initial_layer_print_height",
+            "outer_wall_speed",
+            "sparse_infill_density",
+            "wall_loops",
+        ]
+        by_key = {o.key: o for o in overrides}
+        assert by_key["wall_loops"].value == "5"
+        assert by_key["sparse_infill_density"].value == "100%"
+        assert by_key["wall_loops"].printer_coupled is False
+        assert by_key["outer_wall_speed"].printer_coupled is True
+
+    def test_never_surfaces_the_printer_slot(self):
+        # machine_start_gcode is listed in the printer entry, not the process
+        # one. Carrying it would push the designer's start G-code onto another
+        # machine — the exact failure the length check exists to prevent.
+        overrides = extract_design_process_overrides(_3mf(_config()))
+        assert "machine_start_gcode" not in {o.key for o in overrides}
+
+    def test_rejects_an_array_whose_length_contradicts_the_filament_count(self):
+        # Three entries for two filaments: the layout is not the one we know,
+        # so index 0 might not be the process slot. Refuse rather than guess.
+        cfg = _config(different_settings_to_system=["wall_loops", "", ""])
+        assert extract_design_process_overrides(_3mf(cfg)) == []
+
+    def test_skips_keys_absent_from_the_flattened_config(self):
+        cfg = _config(different_settings_to_system=["wall_loops;renamed_in_a_later_slicer", "", "", ""])
+        assert [o.key for o in extract_design_process_overrides(_3mf(cfg))] == ["wall_loops"]
+
+    def test_empty_process_entry_yields_nothing(self):
+        cfg = _config(different_settings_to_system=["", "", "", "machine_start_gcode"])
+        assert extract_design_process_overrides(_3mf(cfg)) == []
+
+    def test_files_without_the_field_yield_nothing(self):
+        cfg = _config()
+        del cfg["different_settings_to_system"]
+        assert extract_design_process_overrides(_3mf(cfg)) == []
+
+    def test_files_without_project_settings_yield_nothing(self):
+        assert extract_design_process_overrides(_3mf(None, include_config=False)) == []
+
+    def test_malformed_input_yields_nothing(self):
+        assert extract_design_process_overrides(b"not a zip") == []
+        assert overrides_from_config("not a dict") == []
+        assert overrides_from_config({"different_settings_to_system": "not a list"}) == []
+
+    def test_tolerates_a_missing_filament_list(self):
+        # No filament_settings_id to cross-check against — index 0 is still the
+        # documented process slot, so parse it rather than bailing out.
+        cfg = _config()
+        del cfg["filament_settings_id"]
+        assert [o.key for o in extract_design_process_overrides(_3mf(cfg))] == [
+            "initial_layer_print_height",
+            "outer_wall_speed",
+            "sparse_infill_density",
+            "wall_loops",
+        ]
+
+
+class TestApply:
+    def _overrides(self) -> list[DesignOverride]:
+        return extract_design_process_overrides(_3mf(_config()))
+
+    def test_writes_only_the_selected_keys(self):
+        process = json.dumps({"inherits": "0.20mm Standard @BBL X1C", "from": "system", "wall_loops": "2"})
+
+        patched = json.loads(apply_design_overrides(process, self._overrides(), ["wall_loops"]))
+
+        assert patched["wall_loops"] == "5"
+        # Not selected — the picked preset's own value must survive.
+        assert "sparse_infill_density" not in patched
+        assert "outer_wall_speed" not in patched
+        # The inherits stub is what makes the patch win over the flattened
+        # parent inside the sidecar; it must not be disturbed.
+        assert patched["inherits"] == "0.20mm Standard @BBL X1C"
+        assert patched["from"] == "system"
+
+    def test_a_key_the_source_never_flagged_is_ignored(self):
+        process = json.dumps({"inherits": "x"})
+
+        patched = json.loads(apply_design_overrides(process, self._overrides(), ["layer_height"]))
+
+        assert "layer_height" not in patched
+
+    def test_printer_coupled_keys_apply_when_explicitly_selected(self):
+        process = json.dumps({"inherits": "x"})
+
+        patched = json.loads(apply_design_overrides(process, self._overrides(), ["outer_wall_speed"]))
+
+        assert patched["outer_wall_speed"] == "200"
+
+    def test_no_selection_is_a_no_op(self):
+        process = json.dumps({"inherits": "x"})
+        assert apply_design_overrides(process, self._overrides(), []) == process
+
+    def test_unparseable_process_json_is_returned_untouched(self):
+        assert apply_design_overrides("{not json", self._overrides(), ["wall_loops"]) == "{not json"

+ 253 - 0
backend/tests/unit/test_plate_clear_mqtt_notification.py

@@ -0,0 +1,253 @@
+"""Tests for the plate-clear gate reaching MQTT and notifications (#2525).
+
+``awaiting_plate_clear`` is a Bambuddy-side flag (#961) — the printer's own MQTT
+push only ever reports RUNNING/PAUSE/FAILED/FINISH/IDLE, so an external
+automation had no way to tell "finished" from "finished and still waiting for
+someone to clear the bed". It now rides along on the retained per-printer status
+topic, gets its own retained topic on every transition, and can raise a
+notification on the rising edge.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.mqtt_relay import MQTTRelayService
+from backend.app.services.printer_manager import PrinterManager
+
+
+def _relay() -> MQTTRelayService:
+    relay = MQTTRelayService()
+    relay.enabled = True
+    relay.connected = True
+    relay.client = MagicMock()
+    return relay
+
+
+def _state() -> SimpleNamespace:
+    return SimpleNamespace(
+        connected=True,
+        state="FINISH",
+        progress=100,
+        remaining_time=0,
+        layer_num=250,
+        total_layers=250,
+        current_print="benchy.gcode.3mf",
+        subtask_name="benchy",
+        gcode_file="benchy.gcode",
+        temperatures={"nozzle": 40, "bed": 30},
+        wifi_signal="-50dBm",
+        chamber_light="off",
+        speed_level=2,
+        cooling_fan_speed=0,
+        big_fan1_speed=0,
+        big_fan2_speed=0,
+        heatbreak_fan_speed=0,
+    )
+
+
+def _published(relay: MQTTRelayService) -> list[tuple[str, dict, bool]]:
+    """Decode every publish as (topic, payload, retain)."""
+    import json
+
+    calls = []
+    for call in relay.client.publish.call_args_list:
+        topic = call.args[0]
+        payload = json.loads(call.args[1])
+        calls.append((topic, payload, call.kwargs.get("retain", False)))
+    return calls
+
+
+class TestStatusPayload:
+    @pytest.mark.asyncio
+    async def test_status_payload_carries_awaiting_plate_clear(self):
+        relay = _relay()
+
+        await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001", True)
+
+        topic, payload, retain = _published(relay)[0]
+        assert topic == "bambuddy/printers/01P00A000000001/status"
+        assert payload["awaiting_plate_clear"] is True
+        assert retain is True
+
+    @pytest.mark.asyncio
+    async def test_status_payload_defaults_to_not_awaiting(self):
+        relay = _relay()
+
+        await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001")
+
+        _, payload, _ = _published(relay)[0]
+        assert payload["awaiting_plate_clear"] is False
+        # The pre-existing telemetry fields must survive the addition.
+        assert payload["state"] == "FINISH"
+        assert payload["progress"] == 100
+
+
+class TestPlateClearTopic:
+    @pytest.mark.asyncio
+    async def test_publishes_retained_state_on_its_own_topic(self):
+        relay = _relay()
+
+        await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
+
+        topic, payload, retain = _published(relay)[0]
+        assert topic == "bambuddy/printers/01S00C000000003/plate_clear"
+        assert payload["awaiting"] is True
+        assert payload["printer_id"] == 3
+        assert payload["printer_name"] == "P1S"
+        assert payload["printer_serial"] == "01S00C000000003"
+        # Retained so a subscriber that connects later learns the current state
+        # instead of waiting for the next transition.
+        assert retain is True
+
+    @pytest.mark.asyncio
+    async def test_honours_the_configured_topic_prefix(self):
+        relay = _relay()
+        relay.topic_prefix = "farm/bambuddy"
+
+        await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", False)
+
+        topic, payload, _ = _published(relay)[0]
+        assert topic == "farm/bambuddy/printers/01S00C000000003/plate_clear"
+        assert payload["awaiting"] is False
+
+    @pytest.mark.asyncio
+    async def test_silent_when_relay_is_disabled(self):
+        relay = _relay()
+        relay.enabled = False
+
+        await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
+
+        relay.client.publish.assert_not_called()
+
+
+class TestEdgeTriggering:
+    """The setter is re-asserted routinely (the queue clears the gate on every
+    dispatch), so outward-facing emissions must fire on transitions only."""
+
+    def _manager(self) -> PrinterManager:
+        manager = PrinterManager()
+        loop = MagicMock()
+        loop.is_running.return_value = True
+        manager._loop = loop
+        return manager
+
+    def test_emits_on_the_rising_edge(self):
+        manager = self._manager()
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, True)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert len(emitted) == 1
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+    def test_does_not_re_emit_when_already_awaiting(self):
+        manager = self._manager()
+        manager._awaiting_plate_clear.add(7)
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, True)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert emitted == []
+        # Persistence and the WebSocket broadcast are idempotent and stay unconditional.
+        assert len(scheduled.call_args_list) == 2
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+    def test_does_not_emit_when_clearing_a_gate_that_was_never_up(self):
+        manager = self._manager()
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, False)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert emitted == []
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+    def test_emits_on_the_falling_edge(self):
+        manager = self._manager()
+        manager._awaiting_plate_clear.add(7)
+
+        with patch.object(manager, "_schedule_async") as scheduled:
+            manager.set_awaiting_plate_clear(7, False)
+
+        emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
+        assert len(emitted) == 1
+        for call in scheduled.call_args_list:
+            call.args[0].close()
+
+
+class TestEmitFanOut:
+    @pytest.mark.asyncio
+    async def test_rising_edge_publishes_and_notifies(self):
+        manager = PrinterManager()
+        manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
+
+        publish = AsyncMock()
+        notify = AsyncMock()
+        with (
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_plate_clear_required",
+                notify,
+            ),
+        ):
+            await manager._emit_plate_clear_change(7, True)
+
+        publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", True)
+        assert notify.await_count == 1
+        assert notify.await_args.args[:2] == (7, "X1C")
+
+    @pytest.mark.asyncio
+    async def test_falling_edge_publishes_but_does_not_notify(self):
+        manager = PrinterManager()
+        manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
+
+        publish = AsyncMock()
+        notify = AsyncMock()
+        with (
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_plate_clear_required",
+                notify,
+            ),
+        ):
+            await manager._emit_plate_clear_change(7, False)
+
+        publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", False)
+        notify.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_unknown_printer_is_a_no_op(self):
+        manager = PrinterManager()
+
+        publish = AsyncMock()
+        with patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish):
+            await manager._emit_plate_clear_change(999, True)
+
+        publish.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_mqtt_failure_does_not_block_the_notification(self):
+        manager = PrinterManager()
+        manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
+
+        notify = AsyncMock()
+        with (
+            patch(
+                "backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state",
+                AsyncMock(side_effect=RuntimeError("broker down")),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_plate_clear_required",
+                notify,
+            ),
+        ):
+            await manager._emit_plate_clear_change(7, True)
+
+        assert notify.await_count == 1

+ 20 - 4
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -105,6 +105,18 @@ def _fake_state(**overrides):
     return SimpleNamespace(**base)
 
 
+def _scheduled_names(mock) -> list[str]:
+    """Coroutine names passed to the patched ``_schedule_async``.
+
+    Asserting on names rather than a bare call count keeps this file pinned to
+    #1128's contract (persist + broadcast on every flag mutation) without
+    breaking every time another emission is hung off the same setter — #2525
+    added an edge-triggered MQTT/notification relay, which is covered by its
+    own test module.
+    """
+    return [call.args[0].__qualname__.rsplit(".", 1)[-1] for call in mock.call_args_list]
+
+
 class TestSchedulingFromSetAwaitingPlateClear:
     """The hook from the public flag-mutation method into the broadcast."""
 
@@ -119,8 +131,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
         with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
             manager.set_awaiting_plate_clear(7, True)
 
-        # Two coroutines: persist + broadcast. Order doesn't matter.
-        assert scheduled.call_count == 2
+        # Persist + broadcast, in either order.
+        names = _scheduled_names(scheduled)
+        assert "_persist_awaiting_plate_clear" in names
+        assert "_broadcast_status_change" in names
 
     def test_does_not_schedule_when_no_loop_attached(self, manager):
         """Sync unit-test path (no loop attached): nothing must be
@@ -159,8 +173,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
             scheduled.reset_mock()
             manager.set_awaiting_plate_clear(7, False)
 
-        # Each flip = persist + broadcast = 2 calls.
-        assert scheduled.call_count == 2
+        # The False flip persists and broadcasts just like the True flip did.
+        names = _scheduled_names(scheduled)
+        assert "_persist_awaiting_plate_clear" in names
+        assert "_broadcast_status_change" in names
 
 
 class TestBroadcastStatusChange:

+ 10 - 1
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -111,7 +111,15 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
     scheduler = PrintScheduler()
 
     async def archive_print(
-        self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None, plate_id=None
+        self,
+        *,
+        printer_id,
+        source_file,
+        original_filename,
+        created_by_id=None,
+        project_id=None,
+        plate_id=None,
+        library_file_id=None,
     ):
         if archive_failure:
             raise RuntimeError("archive copy failed")
@@ -132,6 +140,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
             print_time_seconds=120,
             status="completed",
             project_id=project_id,
+            library_file_id=library_file_id,
             created_by_id=created_by_id,
         )
         self.db.add(archive)

+ 18 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -125,7 +125,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Bark|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
 }
 
@@ -174,6 +174,7 @@ const DE_COGNATES = [
   'China', 'Proxy', 'Start',
   'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Score',  // #1546 AI detection modal — established DE loanword (Duden)
 ];
 
 // French cognates — many UI labels overlap with English exactly.
@@ -188,6 +189,7 @@ const FR_COGNATES = [
   'Copies', '{{n}} copies', 'max {{n}}',  // #1425 PR C — French uses these forms verbatim
   'round robin',  // borrowed English term used as-is in French tech contexts
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
+  'Score',  // #1546 AI detection modal — "le score" is standard French
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
   'Mode', 'Format', 'Default', 'Auto', 'Image', 'Audio', 'Video', 'Hex',
@@ -395,6 +397,20 @@ const RU_COGNATES = [
   '(25%, 50%, 75%)',
 ];
 
+// Ukrainian keeps only Latin brand names and placeholder/punctuation-only
+// formats verbatim; every prose string in uk.ts is translated.
+const UK_COGNATES = [
+  'MakerWorld: {{designer}}',                         // brand + placeholder
+  '{{printer}}: {{error}}',                           // pure placeholders
+  'Bambu Cloud', 'Orca Cloud',                        // brand names — same in every locale
+  'Sport (124%)', 'Ludicrous (166%)',                 // official Bambu speed-mode names
+  'email',                                            // OIDC claim name placeholder
+  '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+  '({{count}}/8)',
+  '(25%, 50%, 75%)',
+];
+
 const IDENTICAL_TO_EN_ALLOWED = {
   de: new Set(DE_COGNATES),
   fr: new Set(FR_COGNATES),
@@ -407,6 +423,7 @@ const IDENTICAL_TO_EN_ALLOWED = {
   'zh-TW': new Set(ZH_TW_COGNATES),
   tr: new Set(TR_COGNATES),
   ru: new Set(RU_COGNATES),
+  uk: new Set(UK_COGNATES),
 };
 
 // Pure comparison logic, exported so tests can verify each failure mode

+ 185 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -48,6 +48,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
     on_ams_ht_humidity_high: false,
     on_ams_ht_temperature_high: false,
     on_plate_not_empty: true,
+    on_plate_clear_required: false,
     on_bed_cooled: false,
     on_first_layer_complete: false,
     on_queue_job_added: false,
@@ -223,6 +224,76 @@ describe('AddNotificationModal — ntfy Priority (#990)', () => {
   });
 });
 
+describe('AddNotificationModal — plate clear required (#2525)', () => {
+  it('renders the toggle off by default', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    await screen.findByDisplayValue('My ntfy');
+
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i));
+    expect(toggle).toBeDefined();
+    expect(toggle).toHaveAttribute('aria-checked', 'false');
+  });
+
+  it('pre-fills the toggle from the existing provider value', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ on_plate_clear_required: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    await screen.findByDisplayValue('My ntfy');
+
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
+    expect(toggle).toHaveAttribute('aria-checked', 'true');
+  });
+
+  it('persists on_plate_clear_required on save', async () => {
+    let captured: unknown = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = await request.json();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+
+    await screen.findByDisplayValue('My ntfy');
+
+    const toggle = screen
+      .getAllByRole('switch')
+      .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
+    await user.click(toggle);
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+    const payload = captured as Record<string, unknown>;
+    expect(payload.on_plate_clear_required).toBe(true);
+  });
+
+  it('lists the event in the ntfy priority section once enabled', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ on_plate_clear_required: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    const sectionHeader = await screen.findByText(/ntfy priority/i);
+    const sectionRoot = sectionHeader.closest('div')!;
+    expect(within(sectionRoot).getByText(/plate clear required/i)).toBeInTheDocument();
+  });
+});
+
 describe('AddNotificationModal — stock alert toggles', () => {
   it('renders Inventory Alerts section with both stock alert toggles', async () => {
     render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
@@ -386,3 +457,117 @@ describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
     expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
   });
 });
+
+describe('AddNotificationModal — Home Assistant custom data (#1441)', () => {
+  const haProvider = () =>
+    buildProvider({
+      provider_type: 'homeassistant',
+      config: { service: 'notify.mobile_app_myphone' },
+    });
+
+  it('renders the Data (JSON) textarea for the homeassistant provider', async () => {
+    render(<AddNotificationModal provider={haProvider()} onClose={() => undefined} />);
+
+    await screen.findByDisplayValue('My ntfy');
+    expect(screen.getByText(/data \(json, optional\)/i)).toBeInTheDocument();
+    expect(screen.getByPlaceholderText(/"priority": "high"/)).toBeInTheDocument();
+  });
+
+  it('rejects malformed JSON in the Data field on save', async () => {
+    const patchSpy = vi.fn();
+    server.use(
+      http.patch('*/api/v1/notifications/1', () => {
+        patchSpy();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
+
+    const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
+    await user.type(textarea, '{{priority: high}');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    expect(await screen.findByText(/must be a valid JSON object/i)).toBeInTheDocument();
+    expect(patchSpy).not.toHaveBeenCalled();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('round-trips valid Data JSON into config on save', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
+
+    const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
+    await user.type(textarea, '{{"ttl": 0}');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).not.toBeNull();
+    expect(captured!.config).toMatchObject({
+      service: 'notify.mobile_app_myphone',
+      data: '{"ttl": 0}',
+    });
+  });
+});
+
+describe('AddNotificationModal — Bark provider (#1495)', () => {
+  it('offers Bark in the provider select and renders its config fields', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ provider_type: 'bark', config: { device_key: 'abc123' } })}
+        onClose={() => undefined}
+      />,
+    );
+
+    await screen.findByDisplayValue('My ntfy');
+    expect(screen.getByRole('option', { name: 'Bark' })).toBeInTheDocument();
+    expect(screen.getByText(/device key/i)).toBeInTheDocument();
+    expect(screen.getByPlaceholderText('https://api.day.app')).toBeInTheDocument();
+    expect(screen.getByText(/interruption level/i)).toBeInTheDocument();
+  });
+
+  it('round-trips Bark options into config on save', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ provider_type: 'bark', config: { device_key: 'abc123' } })}
+        onClose={onClose}
+      />,
+    );
+
+    const groupInput = await screen.findByPlaceholderText('Bambuddy');
+    await user.type(groupInput, 'Printers');
+    const levelRow = screen.getByText(/interruption level/i).closest('div')!;
+    await user.selectOptions(within(levelRow).getByRole('combobox'), 'critical');
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).not.toBeNull();
+    expect(captured!.config).toMatchObject({
+      device_key: 'abc123',
+      group: 'Printers',
+      level: 'critical',
+    });
+  });
+});

+ 32 - 0
frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx

@@ -143,6 +143,38 @@ describe('ConnectionDiagnosticModal', () => {
     spy.mockRestore();
   });
 
+  it('names a refused access code when the printer said so (#2698)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      checks: [{ id: 'mqtt_auth', status: 'fail', params: { reason: 'auth_rejected' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    // States the printer refused us, instead of the hedged "most likely wrong"
+    // text used when all we know is that there's no session.
+    expect(await screen.findByText(/refused Bambuddy's credentials/i)).toBeInTheDocument();
+    expect(screen.queryByText(/most likely wrong/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('hedges on the mqtt_auth failure when the printer gave no reason (#2698)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      checks: [{ id: 'mqtt_auth', status: 'fail', params: {} }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/most likely wrong/i)).toBeInTheDocument();
+    expect(screen.queryByText(/refused Bambuddy's credentials/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
   it('falls back to the generic skip text when no reason is present', async () => {
     const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
       ...PROBLEM_RESULT,

+ 132 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -332,6 +332,138 @@ describe('SliceModal', () => {
     expect(screen.queryByLabelText(/Use the file's built-in settings/)).toBeNull();
   });
 
+  // #2622: a MakerWorld 3MF designed for another printer carries the author's
+  // own process tweaks. BambuStudio records which keys deviate from the stock
+  // preset inside the file, so a cross-printer re-slice can carry them instead
+  // of losing them to the picked process profile.
+  const designedFor = {
+    file_id: 100,
+    filename: 'Designed.3mf',
+    plates: [],
+    is_multi_plate: false,
+    embedded_printer: 'Bambu Lab A1 0.4 nozzle',
+    embedded_process: '0.20mm Standard @BBL A1',
+    design_overrides: [
+      { key: 'wall_loops', value: '5', printer_coupled: false },
+      { key: 'sparse_infill_density', value: '100%', printer_coupled: false },
+      { key: 'outer_wall_speed', value: '200', printer_coupled: true },
+    ],
+  };
+
+  async function openDesignSection() {
+    const user = userEvent.setup();
+    await user.click(await screen.findByText(/Keep the designer's settings/));
+    return user;
+  }
+
+  it("carries the design's printer-independent settings by default (#2622)", async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    // Two of three pre-selected: the speed key is machine-coupled.
+    expect(await screen.findByText('2 of 3 selected')).toBeInTheDocument();
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
+    expect([...(payload.design_overrides ?? [])].sort()).toEqual(['sparse_infill_density', 'wall_loops']);
+  });
+
+  it('lists every changed setting with its value and flags the machine-coupled ones (#2622)', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await openDesignSection();
+
+    expect(screen.getByText('wall_loops')).toBeInTheDocument();
+    expect(screen.getByText('5')).toBeInTheDocument();
+    expect(screen.getByText('sparse_infill_density')).toBeInTheDocument();
+    expect(screen.getByText('100%')).toBeInTheDocument();
+    // The risky one is listed too — visible, explained, just not pre-ticked.
+    expect(screen.getByText('outer_wall_speed')).toBeInTheDocument();
+    expect(screen.getByText('printer-specific')).toBeInTheDocument();
+  });
+
+  it('lets the user opt a machine-coupled setting in and a safe one out (#2622)', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    const user = await openDesignSection();
+    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    const byKey = (key: string) =>
+      boxes.find((b) => b.closest('label')?.textContent?.includes(key)) as HTMLInputElement;
+
+    await user.click(byKey('outer_wall_speed'));
+    await user.click(byKey('wall_loops'));
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
+    expect([...(payload.design_overrides ?? [])].sort()).toEqual(['outer_wall_speed', 'sparse_infill_density']);
+  });
+
+  it('omits design_overrides entirely when the user unticks everything (#2622)', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    const user = await openDesignSection();
+    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    for (const box of boxes) {
+      if (box.checked) await user.click(box);
+    }
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    expect(mockApi.sliceLibraryFile.mock.calls[0][1]).not.toHaveProperty('design_overrides');
+  });
+
+  it('hides the section for a file that changes nothing (#2622)', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue({ ...designedFor, design_overrides: [] });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
+    expect(screen.queryByText(/Keep the designer's settings/)).toBeNull();
+  });
+
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
     const onClose = vi.fn();
     mockApi.sliceLibraryFile.mockResolvedValue({

+ 254 - 0
frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx

@@ -0,0 +1,254 @@
+/**
+ * Tests for #1905 — editing a spool that was created without a slicer preset.
+ *
+ * Covers:
+ * - edit/copy no longer demand a slicer preset, brand or subtype
+ * - picking a preset never overwrites identity fields the user already set
+ * - the Quick Add layout can't leak from create mode into an edit
+ * - brand/material dropdowns rank catalog pairings instead of filtering by them
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import type { InventorySpool } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
+    orcaCloudListProfiles: vi.fn().mockResolvedValue({ filament: [] }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getLocations: vi.fn().mockResolvedValue([]),
+    // Elegoo is only known for PLA here — the pairing that used to hide it
+    // from the brand list as soon as ASA was selected.
+    getColorCatalog: vi.fn().mockResolvedValue([
+      { manufacturer: 'Elegoo', color_name: 'Red', hex_color: 'FF0000', material: 'PLA' },
+      { manufacturer: 'Polymaker', color_name: 'Blue', hex_color: '0000FF', material: 'ASA' },
+    ]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getBuiltinFilaments: vi.fn().mockResolvedValue([
+      { filament_id: 'GFA05', name: 'Generic ASA' },
+    ]),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue(null),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
+    getAssignments: vi.fn().mockResolvedValue([]),
+    unassignSpool: vi.fn().mockResolvedValue({}),
+  },
+  ApiError: class ApiError extends Error {
+    status: number;
+    constructor(message: string, status: number) {
+      super(message);
+      this.status = status;
+    }
+  },
+}));
+
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { api } from '../../api/client';
+
+// A spool as produced by Quick Add / CSV import / an RFID scan: material only.
+const quickAddedSpool: InventorySpool = {
+  id: 7,
+  material: 'ASA',
+  subtype: null,
+  brand: null,
+  color_name: null,
+  rgba: '808080FF',
+  extra_colors: null,
+  effect_type: null,
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 0,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+} as unknown as InventorySpool;
+
+const elegooAsaSpool: InventorySpool = {
+  ...quickAddedSpool,
+  id: 8,
+  brand: 'Elegoo',
+  subtype: 'Basic',
+} as unknown as InventorySpool;
+
+describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('saves an edit of a preset-less spool without demanding a slicer preset', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={quickAddedSpool}
+        mode="edit"
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
+
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
+    expect(screen.queryByText('Slicer preset is required')).not.toBeInTheDocument();
+  });
+
+  it('copies a preset-less spool without demanding a slicer preset', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={quickAddedSpool}
+        mode="copy"
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument());
+
+    fireEvent.click(screen.getByRole('button', { name: 'Copy Spool' }));
+
+    await waitFor(() => expect(api.createSpool).toHaveBeenCalled());
+    expect(screen.queryByText('Slicer preset is required')).not.toBeInTheDocument();
+  });
+
+  it('still requires a slicer preset when creating a spool', async () => {
+    render(
+      <SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
+
+    fireEvent.click(screen.getByRole('button', { name: 'Add Spool' }));
+
+    await waitFor(() => expect(screen.getByText('Slicer preset is required')).toBeInTheDocument());
+    expect(api.createSpool).not.toHaveBeenCalled();
+  });
+
+  it('keeps the spool brand when a preset is picked during an edit', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={elegooAsaSpool}
+        mode="edit"
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
+
+    const presetInput = screen.getByPlaceholderText('Search filament presets...');
+    fireEvent.focus(presetInput);
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
+    fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
+
+    // parsePresetName('Generic ASA') yields brand "Generic" — it must not
+    // replace the manufacturer the spool already carries.
+    expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Elegoo');
+  });
+
+  it('auto-fills empty identity fields from the preset when creating', async () => {
+    render(
+      <SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
+
+    const presetInput = screen.getByPlaceholderText('Search filament presets...');
+    fireEvent.focus(presetInput);
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
+    fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
+
+    expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Generic');
+    expect(screen.getByPlaceholderText('Select material...')).toHaveValue('ASA');
+  });
+
+  it('offers brands the catalog does not pair with the selected material', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={quickAddedSpool}
+        mode="edit"
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
+
+    fireEvent.focus(screen.getByPlaceholderText('Search brand...'));
+
+    // Polymaker is the known ASA brand, Elegoo is only catalogued for PLA —
+    // both are selectable, the pairing only decides the order.
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Polymaker' })).toBeInTheDocument());
+    expect(screen.getByRole('button', { name: 'Elegoo' })).toBeInTheDocument();
+    expect(screen.getByText('Suggested')).toBeInTheDocument();
+    expect(screen.getByText('All')).toBeInTheDocument();
+  });
+
+  it('does not carry Quick Add layout from a create into a later edit', async () => {
+    const { rerender } = render(
+      <SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
+
+    // Turn Quick Add on — the preset field disappears.
+    fireEvent.click(screen.getByText('Quick Add (Stock)').closest('div')!.parentElement!.querySelector('button')!);
+    await waitFor(() =>
+      expect(screen.queryByPlaceholderText('Search filament presets...')).not.toBeInTheDocument(),
+    );
+
+    // Close, then reopen on an existing spool. The toggle only renders in
+    // create mode, so a leaked quickAdd would strand the edit form.
+    rerender(<SpoolFormModal isOpen={false} onClose={vi.fn()} mode="create" currencySymbol="$" />);
+    rerender(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={quickAddedSpool}
+        mode="edit"
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
+    expect(screen.getByPlaceholderText('Search filament presets...')).toBeInTheDocument();
+  });
+});

+ 5 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -535,6 +535,11 @@ export const handlers = [
       external_url_configured: false,
     })
   ),
+  http.get('/api/v1/obico/printer-status', () =>
+    HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null })
+  ),
+  // Per-file project print progress (#1897) — empty means "no completed runs"
+  http.get('/api/v1/projects/:id/file-progress', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),
   http.get('/api/v1/settings/check-ffmpeg', () =>
     HttpResponse.json({ available: false, version: null })

+ 162 - 0
frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx

@@ -0,0 +1,162 @@
+/**
+ * Tests for folder deletion permission gating in the File Manager tree (#1781).
+ *
+ * Users with only library:delete_own may delete empty, unlinked, non-external
+ * folders; everything else stays behind library:delete_all.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { setAuthToken } from '../../api/client';
+
+const mockFolders = [
+  {
+    id: 1,
+    name: 'EmptyOne',
+    parent_id: null,
+    file_count: 0,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+  {
+    id: 2,
+    name: 'HasFiles',
+    parent_id: null,
+    file_count: 3,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+  {
+    id: 3,
+    name: 'LinkedEmpty',
+    parent_id: null,
+    file_count: 0,
+    project_id: 1,
+    archive_id: null,
+    project_name: 'My Project',
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+];
+
+function mockAuthUser(permissions: string[]) {
+  setAuthToken('test-token', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () =>
+      HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+    ),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({
+        id: 7,
+        username: 'operator1',
+        is_admin: false,
+        permissions,
+      }),
+    ),
+  );
+}
+
+async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
+  const row = screen.getByText(folderName).parentElement!;
+  const buttons = within(row).getAllByRole('button');
+  // The kebab (MoreVertical) menu toggle is the last button in the row
+  await user.click(buttons[buttons.length - 1]);
+  return row;
+}
+
+describe('FileManager folder deletion gating (#1781)', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json(mockFolders)),
+      http.get('/api/v1/library/files', () => HttpResponse.json([])),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: 3,
+          total_folders: 3,
+          total_size_bytes: 1024,
+          disk_free_bytes: 10737418240,
+          disk_total_bytes: 107374182400,
+        }),
+      ),
+      http.get('/api/v1/projects/', () => HttpResponse.json([{ id: 1, name: 'My Project', color: '#00ae42' }])),
+      http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('enables delete on an empty folder for a delete_own user', async () => {
+    mockAuthUser(['library:read_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('EmptyOne')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'EmptyOne');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).not.toBeDisabled();
+  });
+
+  it('disables delete on a non-empty folder for a delete_own user, with empty-only tooltip', async () => {
+    mockAuthUser(['library:read_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'HasFiles');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).toBeDisabled();
+    expect(deleteButton).toHaveAttribute('title', 'You can only delete empty folders');
+  });
+
+  it('disables delete on a linked folder for a delete_own user, with no-permission tooltip', async () => {
+    mockAuthUser(['library:read_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('LinkedEmpty')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'LinkedEmpty');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).toBeDisabled();
+    expect(deleteButton).toHaveAttribute('title', 'You do not have permission to delete folders');
+  });
+
+  it('disables delete entirely for a user without any delete permission', async () => {
+    mockAuthUser(['library:read_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('EmptyOne')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'EmptyOne');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).toBeDisabled();
+    expect(deleteButton).toHaveAttribute('title', 'You do not have permission to delete folders');
+  });
+
+  it('keeps delete enabled on non-empty folders for a delete_all user', async () => {
+    mockAuthUser(['library:read_all', 'library:delete_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'HasFiles');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).not.toBeDisabled();
+  });
+});

+ 175 - 0
frontend/src/__tests__/pages/PrintersPageAiDetection.test.tsx

@@ -0,0 +1,175 @@
+/**
+ * Tests for the AI failure detection badge on printer cards (#1546).
+ *
+ * The badge reflects /obico/printer-status: always shown for printers in the
+ * monitored set while detection is enabled — gray "Idle" outside a monitored
+ * print, class-colored (safe/warning/failure) during one.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    is_active: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+  {
+    id: 2,
+    name: 'P1S Backup',
+    ip_address: '192.168.1.101',
+    serial_number: '00W00A123456789',
+    access_code: '87654321',
+    model: 'P1S',
+    enabled: true,
+    is_active: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'stainless_steel',
+    location: null,
+    auto_archive: true,
+    created_at: '2024-01-02T00:00:00Z',
+    updated_at: '2024-01-02T00:00:00Z',
+  },
+];
+
+const mockPrinterStatus = {
+  connected: true,
+  state: 'RUNNING',
+  awaiting_plate_clear: false,
+  progress: 42,
+  layer_num: 10,
+  total_layers: 100,
+  temperatures: { nozzle: 220, bed: 55, chamber: 30 },
+  remaining_time: 3600,
+  filename: 'benchy.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+describe('PrintersPage AI detection badge (#1546)', () => {
+  beforeEach(() => {
+    localStorage.removeItem('printerCardSize');
+
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)),
+      http.get('/api/v1/settings/ui-preferences', () =>
+        HttpResponse.json({
+          ams_humidity_good: 40,
+          ams_humidity_fair: 60,
+          ams_temp_good: 30,
+          ams_temp_fair: 35,
+        })
+      ),
+      http.get('/api/v1/queue/', () => HttpResponse.json([]))
+    );
+  });
+
+  it('shows the live class for a monitored print and Idle for other monitored printers', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: null,
+          per_printer: { '1': { class: 'warning', frame_count: 12, score: 0.31 } },
+          last_error: null,
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+
+    const badge = await screen.findByText('Warning');
+    expect(badge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection: Warning (score 0.310) - click for details'
+    );
+    // Printer 2 is monitored (null = all) but has no active print — gray Idle badge
+    const idleBadge = await screen.findByText('Idle');
+    expect(idleBadge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection enabled - monitoring starts with the next print - click for details'
+    );
+    expect(screen.getAllByText('Warning')).toHaveLength(1);
+  });
+
+  it('shows no badge for printers outside the monitored subset', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: [2],
+          per_printer: {},
+          last_error: null,
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+
+    // Printer 2 gets the Idle badge; printer 1 (not monitored) gets none
+    expect(await screen.findAllByText('Idle')).toHaveLength(1);
+  });
+
+  it('clicking the badge opens a modal with live status and the last error', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: null,
+          per_printer: { '1': { class: 'failure', frame_count: 30, score: 0.92 } },
+          last_error: 'ML API call failed for printer 1: connection refused',
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+    const user = userEvent.setup();
+
+    await user.click(await screen.findByText('Failure'));
+
+    expect(await screen.findByText('AI Failure Detection - X1 Carbon')).toBeInTheDocument();
+    expect(screen.getByText('0.920')).toBeInTheDocument();
+    expect(screen.getByText('30')).toBeInTheDocument();
+    expect(screen.getByText('Last error')).toBeInTheDocument();
+    expect(screen.getByText('ML API call failed for printer 1: connection refused')).toBeInTheDocument();
+  });
+
+  it('shows no badge when detection is disabled, even with stale per_printer state', async () => {
+    server.use(
+      http.get('/api/v1/obico/printer-status', () =>
+        HttpResponse.json({
+          enabled: false,
+          monitored_printers: null,
+          per_printer: { '1': { class: 'failure', frame_count: 30, score: 0.92 } },
+          last_error: null,
+        })
+      )
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Failure')).not.toBeInTheDocument();
+    expect(screen.queryByText('Idle')).not.toBeInTheDocument();
+  });
+});

+ 130 - 0
frontend/src/__tests__/pages/PrintersPageCompactMetrics.test.tsx

@@ -0,0 +1,130 @@
+/**
+ * Tests for the metrics line on the size-S (compact) printer card (#2674).
+ *
+ * Size S used to show a name, a connection pip and a progress bar — not enough
+ * to answer "which printer finishes first", which is the whole point of a
+ * wall-mounted fleet view. It now carries remaining time, ETA and layer
+ * progress, reusing the formatters the expanded card already uses.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    is_active: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const baseStatus = {
+  connected: true,
+  temperatures: { nozzle: 220, bed: 60, chamber: 35 },
+  filename: 'test_print.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+  hms_errors: [],
+};
+
+// 83 minutes remaining → formatDuration(83 * 60) === "1h 23m"
+const printingStatus = {
+  ...baseStatus,
+  state: 'RUNNING',
+  progress: 42,
+  layer_num: 120,
+  total_layers: 267,
+  remaining_time: 83,
+};
+
+const idleStatus = {
+  ...baseStatus,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  remaining_time: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+};
+
+function useStatus(status: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+  );
+}
+
+describe('PrintersPage — size S metrics line (#2674)', () => {
+  beforeEach(() => {
+    // Size S. The card size is read from localStorage on first render — and
+    // the test harness replaces localStorage with bare vi.fn() stubs
+    // (setup.ts), so setItem is a no-op and getItem has to be taught the
+    // value. Without this the page falls back to its size-M default and the
+    // compact branch never renders.
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+      key === 'printerCardSize' ? '1' : null,
+    );
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+  });
+
+  it('shows remaining time, ETA and layer progress while printing', async () => {
+    useStatus(printingStatus);
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('1h 23m')).toBeInTheDocument();
+    expect(screen.getByText('120/267')).toBeInTheDocument();
+    // ETA is rendered as "ETA <clock time>"; the clock time itself depends on
+    // when the test runs, so match the prefix.
+    expect(screen.getByText(/^ETA\s/)).toBeInTheDocument();
+  });
+
+  it('shows no metrics when the printer is idle', async () => {
+    useStatus(idleStatus);
+
+    render(<PrintersPage />);
+
+    // The progress placeholder confirms we are looking at the compact card.
+    expect(await screen.findByText('---%')).toBeInTheDocument();
+    expect(screen.queryByText(/^ETA\s/)).not.toBeInTheDocument();
+    expect(screen.queryByText('0/0')).not.toBeInTheDocument();
+  });
+
+  it('omits the ETA but keeps layers when the printer reports no remaining time', async () => {
+    useStatus({ ...printingStatus, remaining_time: 0 });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('120/267')).toBeInTheDocument();
+    expect(screen.queryByText(/^ETA\s/)).not.toBeInTheDocument();
+  });
+
+  it('omits layers when the printer reports no total layer count', async () => {
+    useStatus({ ...printingStatus, layer_num: 0, total_layers: 0 });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByText('1h 23m')).toBeInTheDocument();
+    expect(screen.queryByText('0/0')).not.toBeInTheDocument();
+  });
+});

+ 82 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -222,4 +222,86 @@ describe('ProjectDetailPage', () => {
       });
     });
   });
+  describe('per-file print progress (#1897)', () => {
+    it('shows X / N badges and the Complete Sets bar when target_sets is set', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id', () => {
+          return HttpResponse.json({ ...mockProject, target_sets: 10 });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
+            makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
+          ]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          return HttpResponse.json([{ file_id: 5, completed_count: 3 }]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      // Per-file badges: 3 / 10 for plate_1, 0 / 10 for the never-printed plate_2
+      await waitFor(() => {
+        expect(screen.getByTitle('3 of 10 completed prints')).toBeInTheDocument();
+      });
+      expect(screen.getByTitle('0 of 10 completed prints')).toBeInTheDocument();
+
+      // Complete sets = min across printable files = 0
+      expect(screen.getByText('Complete Sets')).toBeInTheDocument();
+      expect(
+        screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '0 / 10 sets')
+      ).toBeInTheDocument();
+    });
+
+    it('counts a complete set once every printable file reached the target', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id', () => {
+          return HttpResponse.json({ ...mockProject, target_sets: 2 });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
+            // STL is not printable and must not drag the set count to 0
+            makeFile({ id: 8, filename: 'source.stl', file_type: 'stl' }),
+            makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
+          ]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          // plate_1 overshot the target; capped at 2 for the set count
+          return HttpResponse.json([
+            { file_id: 5, completed_count: 3 },
+            { file_id: 6, completed_count: 2 },
+          ]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '2 / 2 sets')
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows a plain printed-count badge when no target_sets is set', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' })]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          return HttpResponse.json([{ file_id: 5, completed_count: 4 }]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('4\u00d7')).toBeInTheDocument();
+      });
+      // No sets bar without a target
+      expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
+    });
+  });
 });

+ 40 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -488,6 +488,46 @@ describe('StatsPage', () => {
       });
     });
 
+    it('Most Expensive includes per-print energy cost (#1432)', async () => {
+      // Cheap filament + expensive heated-chamber print must outrank a
+      // pricier-filament print once measured energy cost is added in.
+      server.use(
+        http.get('/api/v1/archives/slim', () =>
+          HttpResponse.json([
+            {
+              id: 20, created_at: '2024-03-01T10:00:00Z',
+              started_at: '2024-03-01T10:00:00Z',
+              completed_at: '2024-03-01T14:00:00Z',
+              print_name: 'PLA Filament Heavy', status: 'completed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#00FF00',
+              filament_used_grams: 200, actual_time_seconds: 14400,
+              print_time_seconds: 14000, cost: 5.00,
+              energy_kwh: null, energy_cost: null, quantity: 1,
+            },
+            {
+              id: 21, created_at: '2024-03-02T10:00:00Z',
+              started_at: '2024-03-02T10:00:00Z',
+              completed_at: '2024-03-02T20:00:00Z',
+              print_name: 'ABS Energy Hog', status: 'completed',
+              printer_id: 1, filament_type: 'ABS', filament_color: '#FF0000',
+              filament_used_grams: 100, actual_time_seconds: 36000,
+              print_time_seconds: 35000, cost: 4.00,
+              energy_kwh: 8.5, energy_cost: 2.55, quantity: 1,
+            },
+          ]),
+        ),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Most Expensive')).toBeInTheDocument();
+      });
+      // 4.00 + 2.55 = 6.55 beats 5.00 flat
+      expect(screen.getByText('$6.55')).toBeInTheDocument();
+      expect(screen.getAllByText('ABS Energy Hog').length).toBeGreaterThan(0);
+    });
+
     it('shows success streak record', async () => {
       render(<StatsPage />);
 

+ 42 - 0
frontend/src/__tests__/utils/spoolFormValidation.test.ts

@@ -93,4 +93,46 @@ describe('validateForm', () => {
       expect(result.errors.material).toBeDefined();
     });
   });
+
+  // #1905: a spool created by quick-add / CSV import / RFID scan has no preset,
+  // brand or subtype. Demanding them on every later edit blocked changes to
+  // unrelated fields, and the forced preset pick then rewrote the spool's
+  // manufacturer. Edit and copy now match what the backend requires: material.
+  describe('edit and copy modes', () => {
+    it('only requires material when editing', () => {
+      const result = validateForm(defaultFormData, false, false, 'edit');
+      expect(result.isValid).toBe(false);
+      expect(result.errors.material).toBeDefined();
+      expect(result.errors.slicer_filament).toBeUndefined();
+      expect(result.errors.brand).toBeUndefined();
+      expect(result.errors.subtype).toBeUndefined();
+    });
+
+    it('passes when editing a spool that only has material', () => {
+      const data = { ...defaultFormData, material: 'ASA' };
+      const result = validateForm(data, false, false, 'edit');
+      expect(result.isValid).toBe(true);
+      expect(Object.keys(result.errors)).toHaveLength(0);
+    });
+
+    it('passes when copying a spool that only has material', () => {
+      const data = { ...defaultFormData, material: 'ASA' };
+      const result = validateForm(data, false, false, 'copy');
+      expect(result.isValid).toBe(true);
+    });
+
+    it('still requires the full details in create mode', () => {
+      const data = { ...defaultFormData, material: 'ASA' };
+      const result = validateForm(data, false, false, 'create');
+      expect(result.isValid).toBe(false);
+      expect(result.errors.slicer_filament).toBeDefined();
+      expect(result.errors.brand).toBeDefined();
+      expect(result.errors.subtype).toBeDefined();
+    });
+
+    it('defaults to create mode when no mode is given', () => {
+      const data = { ...defaultFormData, material: 'ASA' };
+      expect(validateForm(data).isValid).toBe(false);
+    });
+  });
 });

+ 34 - 1
frontend/src/api/client.ts

@@ -727,6 +727,8 @@ export interface ArchiveSlim {
   started_at: string | null;
   completed_at: string | null;
   cost: number | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
   quantity: number;
   created_at: string;
 }
@@ -909,6 +911,7 @@ export interface Project {
   status: string;  // active, completed, archived
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
+  target_sets: number | null;  // Copies-per-file target (#1897)
   notes: string | null;
   attachments: ProjectAttachment[] | null;
   tags: string | null;
@@ -934,6 +937,12 @@ export interface ProjectAttachment {
   uploaded_at: string;
 }
 
+// Completed-run count for one library file inside a project (#1897)
+export interface ProjectFileProgress {
+  file_id: number;
+  completed_count: number;
+}
+
 export interface ArchivePreview {
   id: number;
   print_name: string | null;
@@ -951,6 +960,7 @@ export interface ProjectListItem {
   status: string;
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
+  target_sets: number | null;  // #1897 — the shared edit dialog seeds itself from this
   budget: number | null;
   tags: string | null;  // #2536 — the shared edit dialog seeds itself from this
   due_date: string | null;  // #2536
@@ -973,6 +983,7 @@ export interface ProjectCreate {
   color?: string;
   target_count?: number;
   target_parts_count?: number;
+  target_sets?: number;
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -989,6 +1000,7 @@ export interface ProjectUpdate {
   status?: string;
   target_count?: number;
   target_parts_count?: number;
+  target_sets?: number | null;  // #1897 — explicit null clears the copies-per-file target
   notes?: string;
   tags?: string | null;  // #2536 — explicit null clears the tags
   due_date?: string | null;  // #2536 — explicit null clears the due date
@@ -1060,6 +1072,7 @@ export interface ProjectExport {
   status: string;
   target_count: number | null;
   target_parts_count: number | null;
+  target_sets: number | null;  // #1897
   notes: string | null;
   tags: string | null;
   due_date: string | null;
@@ -1076,6 +1089,7 @@ export interface ProjectImport {
   status?: string;
   target_count?: number;
   target_parts_count?: number;
+  target_sets?: number;  // #1897
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -2450,7 +2464,7 @@ export interface Filament {
 }
 
 // Notification Provider types
-export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant';
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark';
 
 export interface NotificationProvider {
   id: number;
@@ -2479,6 +2493,7 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   on_plate_not_empty: boolean;
+  on_plate_clear_required: boolean;
   // Bed cooled
   on_bed_cooled: boolean;
   // First layer complete
@@ -2538,6 +2553,7 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  on_plate_clear_required?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
   // First layer complete
@@ -2590,6 +2606,7 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  on_plate_clear_required?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
   // First layer complete
@@ -2742,6 +2759,16 @@ export interface ObicoStatus {
   external_url_configured: boolean;
 }
 
+// Lightweight subset served under printers:read for the printer-card badge (#1546)
+export interface ObicoPrinterStatus {
+  enabled: boolean;
+  // null = all printers are monitored
+  monitored_printers: number[] | null;
+  per_printer: Record<string, { class: string; frame_count: number; score: number }>;
+  // null = no error, or the viewer lacks settings:read (error strings can embed config URLs)
+  last_error: string | null;
+}
+
 export interface ObicoTestConnection {
   ok: boolean;
   status_code: number | null;
@@ -5935,6 +5962,9 @@ export const api = {
     request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),
   getProjectArchives: (id: number, limit = 100, offset = 0) =>
     request<Archive[]>(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),
+  // Completed-run counts per library file (#1897); files with 0 runs are omitted
+  getProjectFileProgress: (id: number) =>
+    request<ProjectFileProgress[]>(`/projects/${id}/file-progress`),
   addArchivesToProject: (projectId: number, archiveIds: number[]) =>
     request<{ message: string }>(`/projects/${projectId}/add-archives`, {
       method: 'POST',
@@ -6466,6 +6496,9 @@ export const api = {
   getObicoStatus: () =>
     request<ObicoStatus>('/obico/status'),
 
+  getObicoPrinterStatus: () =>
+    request<ObicoPrinterStatus>('/obico/printer-status'),
+
   testObicoConnection: (url: string) =>
     request<ObicoTestConnection>('/obico/test-connection', {
       method: 'POST',

+ 52 - 1
frontend/src/components/AddNotificationModal.tsx

@@ -12,7 +12,7 @@ interface AddNotificationModalProps {
   onClose: () => void;
 }
 
-const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'callmebot', 'webhook', 'homeassistant'];
+const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'callmebot', 'webhook', 'homeassistant'];
 
 export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
   const { t } = useTranslation();
@@ -43,6 +43,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
   const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
+  const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
 
@@ -144,6 +145,21 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       }
     }
 
+    // HA custom service-data must be a JSON object (#1441)
+    if (providerType === 'homeassistant' && config.data?.trim()) {
+      let parsed: unknown;
+      try {
+        parsed = JSON.parse(config.data);
+      } catch {
+        setError(t('notifications.haDataInvalid'));
+        return;
+      }
+      if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+        setError(t('notifications.haDataInvalid'));
+        return;
+      }
+    }
+
     const finalConfig: Record<string, unknown> =
       providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
         ? { ...config, event_priorities: eventPriorities }
@@ -173,6 +189,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_maintenance_due: onMaintenanceDue,
       on_stock_reorder_alert: onStockReorderAlert,
       on_stock_break_alert: onStockBreakAlert,
+      on_plate_clear_required: onPlateClearRequired,
       on_bed_cooled: onBedCooled,
       on_first_layer_complete: onFirstLayerComplete,
     };
@@ -265,6 +282,21 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       case 'homeassistant':
         return [
           { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false },
+          { key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false },
+        ];
+      case 'bark':
+        return [
+          { key: 'device_key', label: 'Device Key', placeholder: 'Your Bark device key', type: 'text', required: true },
+          { key: 'server', label: 'Server URL', placeholder: 'https://api.day.app', type: 'text', required: false },
+          { key: 'group', label: 'Group', placeholder: 'Bambuddy', type: 'text', required: false },
+          { key: 'sound', label: 'Sound', placeholder: 'minuet', type: 'text', required: false },
+          { key: 'level', label: 'Interruption Level', type: 'select', required: false, options: [
+            { value: '', label: 'Default' },
+            { value: 'active', label: 'Active' },
+            { value: 'timeSensitive', label: 'Time Sensitive' },
+            { value: 'critical', label: 'Critical (bypasses Silent/Focus)' },
+            { value: 'passive', label: 'Passive (no sound)' },
+          ]},
         ];
       default:
         return [];
@@ -368,6 +400,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                       </option>
                     ))}
                   </select>
+                ) : field.type === 'textarea' ? (
+                  <textarea
+                    value={config[field.key] || ''}
+                    onChange={(e) => {
+                      setConfig({ ...config, [field.key]: e.target.value });
+                      setTestResult(null);
+                    }}
+                    placeholder={field.placeholder}
+                    rows={3}
+                    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 font-mono text-sm"
+                  />
                 ) : (
                   <input
                     type={field.type}
@@ -538,6 +581,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.plateClearRequiredDescription')}</span>
+                  </div>
+                  <Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
+                </div>
                 <div className="flex items-center justify-between col-span-2">
                   <div>
                     <span className="text-sm text-white">{t('notifications.bedCooled')}</span>
@@ -611,6 +661,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
+              if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });

+ 111 - 0
frontend/src/components/AiDetectionModal.tsx

@@ -0,0 +1,111 @@
+// AI Failure Detection modal (#1546) — opened from the printer card's AI badge.
+// Shows the live classification for this printer and the detection service's
+// last error, without leaving the Printers page (mirrors the HMS error modal).
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { X, ScanEye, AlertCircle, Settings } from 'lucide-react';
+
+interface AiDetectionModalProps {
+  printerName: string;
+  detection?: { class: string; frame_count: number; score: number };
+  // null = no error, or the viewer lacks settings:read (the backend withholds
+  // error strings from non-settings users because they can embed config URLs)
+  lastError: string | null;
+  onClose: () => void;
+}
+
+export function AiDetectionModal({ printerName, detection, lastError, onClose }: AiDetectionModalProps) {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const cls = detection
+    ? (detection.class === 'failure' || detection.class === 'warning' ? detection.class : 'safe')
+    : 'idle';
+  const statusColor =
+    cls === 'failure'
+      ? 'text-status-error'
+      : cls === 'warning'
+        ? 'text-status-warning'
+        : cls === 'safe'
+          ? 'text-status-ok'
+          : 'text-bambu-gray';
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <ScanEye className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">
+              {t('printers.aiDetection.modalTitle', { name: printerName })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-4 space-y-4 text-sm">
+          <div className="space-y-2">
+            <div className="flex justify-between">
+              <span className="text-bambu-gray">{t('printers.aiDetection.currentStatus')}</span>
+              <span className={`font-medium ${statusColor}`}>{t(`printers.aiDetection.${cls}`)}</span>
+            </div>
+            {detection ? (
+              <>
+                <div className="flex justify-between">
+                  <span className="text-bambu-gray">{t('printers.aiDetection.score')}</span>
+                  <span className="text-white font-mono">{detection.score.toFixed(3)}</span>
+                </div>
+                <div className="flex justify-between">
+                  <span className="text-bambu-gray">{t('printers.aiDetection.framesAnalyzed')}</span>
+                  <span className="text-white font-mono">{detection.frame_count}</span>
+                </div>
+              </>
+            ) : (
+              <p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>
+            )}
+          </div>
+
+          {lastError && (
+            <div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+              <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" />
+              <div className="min-w-0">
+                <div className="font-medium text-red-700 dark:text-red-400">
+                  {t('printers.aiDetection.lastError')}
+                </div>
+                <p className="text-red-700/80 dark:text-red-300/80 break-words mt-1">{lastError}</p>
+              </div>
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="p-4 border-t border-bambu-dark-tertiary flex items-center justify-end">
+          <button
+            onClick={() => navigate('/settings?tab=failure-detection')}
+            className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+          >
+            <Settings className="w-4 h-4" />
+            {t('printers.aiDetection.openSettings')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 3 - 0
frontend/src/components/FailureDetectionSettings.tsx

@@ -71,6 +71,9 @@ export function FailureDetectionSettings() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['settings'] });
       queryClient.invalidateQueries({ queryKey: ['obico-status'] });
+      // Printer-card AI badges (#1546) — refresh immediately on toggle instead
+      // of waiting for the cards' 10s poll.
+      queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] });
       showToast(t('settings.toast.settingsSaved'));
     },
   });

+ 62 - 6
frontend/src/components/FilamentTrends.tsx

@@ -36,16 +36,17 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
 
   // Calculate daily usage data
   const dailyData = useMemo(() => {
-    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; energy: number; prints: number }>();
 
     archives.forEach(archive => {
       const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       // Use local date string for grouping
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
 
-      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
+      existing.energy += archive.energy_kwh || 0;
       existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
     });
@@ -75,7 +76,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const hourlyData = useMemo(() => {
     if (spanDays > 7) return [];
 
-    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; energy: number; prints: number }>();
     const multiDay = spanDays > 1;
 
     archives.forEach(archive => {
@@ -83,9 +84,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       const h = date.getHours();
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`;
 
-      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
+      existing.energy += archive.energy_kwh || 0;
       existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
     });
@@ -107,7 +109,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const weeklyData = useMemo(() => {
     if (dailyData.length <= 60) return dailyData;
 
-    const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { week: string; filament: number; cost: number; energy: number; prints: number }>();
 
     dailyData.forEach(day => {
       const date = new Date(day.date);
@@ -115,9 +117,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       weekStart.setDate(date.getDate() - date.getDay());
       const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
-      const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += day.filament;
       existing.cost += day.cost;
+      existing.energy += day.energy;
       existing.prints += day.prints;
       dataMap.set(key, existing);
     });
@@ -237,6 +240,8 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
   const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
   const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
+  const totalEnergy = archives.reduce((sum, a) => sum + (a.energy_kwh || 0), 0);
+  const totalEnergyCost = archives.reduce((sum, a) => sum + (a.energy_cost || 0), 0);
   const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
   const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
 
@@ -323,6 +328,57 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
         </div>
       )}
 
+      {/* Energy Over Time Chart (#1432) — only when smart-plug per-print data exists */}
+      {totalEnergy > 0 && chartData.length > 0 && (
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <div className="flex items-center justify-between mb-4">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.energyOverTime')}</h4>
+            <span className="text-xs text-bambu-gray">
+              {totalEnergy.toFixed(3)} kWh · {currency}{totalEnergyCost.toFixed(2)}
+            </span>
+          </div>
+          <ResponsiveContainer width="100%" height={250}>
+            <AreaChart data={chartData}>
+              <defs>
+                <linearGradient id="colorEnergy" x1="0" y1="0" x2="0" y2="1">
+                  <stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3}/>
+                  <stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
+                </linearGradient>
+              </defs>
+              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+              <XAxis
+                dataKey="dateLabel"
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                interval="preserveStartEnd"
+              />
+              <YAxis
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                tickFormatter={(value) => `${value}kWh`}
+              />
+              <Tooltip
+                contentStyle={{
+                  backgroundColor: '#2d2d2d',
+                  border: '1px solid #3d3d3d',
+                  borderRadius: '8px',
+                }}
+                labelStyle={{ color: '#fff' }}
+                formatter={(value) => [`${Number(value ?? 0).toFixed(3)} kWh`, t('stats.energyUsed')]}
+              />
+              <Area
+                type="monotone"
+                dataKey="energy"
+                stroke="#f59e0b"
+                strokeWidth={2}
+                fillOpacity={1}
+                fill="url(#colorEnergy)"
+              />
+            </AreaChart>
+          </ResponsiveContainer>
+        </div>
+      )}
+
       {/* Bottom Charts */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
         {/* Filament Type Distribution */}

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -123,6 +123,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_print_complete && (
               <span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">{t('notifications.complete')}</span>
             )}
+            {provider.on_plate_clear_required && (
+              <span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs rounded">{t('notifications.plateClear')}</span>
+            )}
             {provider.on_print_failed && (
               <span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
             )}
@@ -288,6 +291,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.plateClearRequired')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.plateClearRequiredDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_plate_clear_required ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_plate_clear_required: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <div>
                     <p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>

+ 96 - 1
frontend/src/components/SliceModal.tsx

@@ -16,7 +16,7 @@ import {
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
-import type { PlateFilament } from '../types/plates';
+import type { DesignOverride, PlateFilament } from '../types/plates';
 import {
   presetCompatibility,
   buildCompatibilityIndex,
@@ -186,6 +186,16 @@ function formatElapsed(seconds: number): string {
   return `${h}h ${remM}m`;
 }
 
+// Render a slicer parameter value for the design-settings list. Bambu's process
+// schema stores everything as strings or arrays of strings, so this only has to
+// flatten arrays and keep scalars readable — no unit or type interpretation,
+// which would rot against every slicer release.
+function formatDesignValue(value: unknown): string {
+  if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
+  if (value == null) return '';
+  return String(value);
+}
+
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
@@ -227,6 +237,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // see canUseEmbedded below.
   const [useEmbedded, setUseEmbedded] = useState(false);
 
+  // #2622: process settings the designer changed away from the stock preset,
+  // carried onto the picked process profile so a cross-printer re-slice keeps
+  // the model's intended wall count / infill / first layer instead of losing
+  // them to --load-settings. Keys the file flags as machine-coupled (speeds,
+  // accelerations, prime-tower geometry) are listed but start unticked — those
+  // were tuned for the designer's printer and can be plain wrong on another.
+  const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
+  const [designExpanded, setDesignExpanded] = useState(false);
+
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
   const pipelinesQuery = useQuery({
@@ -385,6 +404,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // plates query resolves before the presets query (the latter is gated on
   // it), so these are known by the time the pre-pick effects run.
   const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
+  const designOverrides = useMemo<DesignOverride[]>(
+    () => platesQuery.data?.design_overrides ?? [],
+    [platesQuery.data],
+  );
   const embeddedProcess = platesQuery.data?.embedded_process ?? null;
 
   // "Slice as designed" is offered only when the source carries embedded
@@ -405,6 +428,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     if (!canUseEmbedded) setUseEmbedded(false);
   }, [canUseEmbedded]);
 
+  // Pre-tick the printer-independent design settings once the source's list
+  // arrives. Machine-coupled keys stay off until the user opts in explicitly.
+  useEffect(() => {
+    setDesignKeys(new Set(designOverrides.filter((o) => !o.printer_coupled).map((o) => o.key)));
+  }, [designOverrides]);
+
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when
   // that preset is available, else the first listed printer. Runs once when
   // presets first arrive; later re-renders preserve any manual choice.
@@ -505,6 +534,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       // them) but go unused when this flag is set — the slicer falls back on
       // the file's embedded project_settings.config instead.
       ...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
+      // Carried design settings are patched onto the resolved process JSON,
+      // which the embedded-settings path never sends — so they are mutually
+      // exclusive by construction (#2622).
+      ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
     };
   }
 
@@ -775,6 +808,68 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 selectedPrinterName={selectedPrinterName}
                 compatIndex={compatIndex}
               />
+              {/* Designer's process tweaks (#2622). BambuStudio records which
+                  keys deviate from the stock preset in the 3MF itself, so a
+                  re-slice for another printer can carry them instead of
+                  flattening them under --load-settings. Hidden entirely when
+                  the source lists none, and disabled in embedded mode where
+                  the process JSON these patch is never sent. */}
+              {designOverrides.length > 0 && (
+                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 p-3">
+                  <button
+                    type="button"
+                    onClick={() => setDesignExpanded((v) => !v)}
+                    className="flex w-full items-center justify-between gap-2 text-left"
+                  >
+                    <span className="text-sm text-white">
+                      {t('slice.designSettings')}
+                      <span className="block text-xs text-bambu-gray/70">
+                        {t('slice.designSettingsHint', { count: designOverrides.length })}
+                      </span>
+                    </span>
+                    <span className="shrink-0 text-xs text-bambu-gray">
+                      {t('slice.designSettingsSelected', { selected: designKeys.size, total: designOverrides.length })}
+                    </span>
+                  </button>
+                  {designExpanded && (
+                    <div className="mt-3 space-y-1.5 border-t border-bambu-dark-tertiary pt-3">
+                      {designOverrides.map((o) => (
+                        <label
+                          key={o.key}
+                          className={`flex items-start gap-2 text-xs ${useEmbedded ? 'opacity-50' : 'cursor-pointer'}`}
+                        >
+                          <input
+                            type="checkbox"
+                            checked={designKeys.has(o.key)}
+                            disabled={isEnqueuing || useEmbedded}
+                            onChange={(e) => {
+                              setDesignKeys((prev) => {
+                                const next = new Set(prev);
+                                if (e.target.checked) next.add(o.key);
+                                else next.delete(o.key);
+                                return next;
+                              });
+                            }}
+                            className="mt-0.5 shrink-0 cursor-pointer"
+                          />
+                          <span className="min-w-0 flex-1">
+                            <span className="font-mono text-bambu-gray">{o.key}</span>
+                            <span className="ml-1.5 break-all text-white">{formatDesignValue(o.value)}</span>
+                            {o.printer_coupled && (
+                              <span
+                                className="ml-1.5 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
+                                title={t('slice.designSettingsPrinterCoupledHint')}
+                              >
+                                {t('slice.designSettingsPrinterCoupled')}
+                              </span>
+                            )}
+                          </span>
+                        </label>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              )}
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}

+ 41 - 21
frontend/src/components/SpoolFormModal.tsx

@@ -6,9 +6,9 @@ import { api, ApiError } from '../api/client';
 import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
-import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
+import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
@@ -25,7 +25,7 @@ type TabId = 'filament' | 'pa-profile';
 
 const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
 
-export type SpoolFormMode = 'create' | 'edit' | 'copy';
+export type { SpoolFormMode };
 
 interface SpoolFormModalProps {
   isOpen: boolean;
@@ -300,21 +300,33 @@ export function SpoolFormModal({
     return map;
   }, [brandMaterialPairs]);
 
-  const availableBrands = useMemo(() => {
-    if (!formData.material) return baseAvailableBrands;
-    const materialKey = formData.material.toLowerCase();
-    const brandKeys = materialToBrands.get(materialKey);
-    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
-    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
-  }, [baseAvailableBrands, formData.material, materialToBrands]);
-
-  const availableMaterials = useMemo(() => {
-    if (!formData.brand) return baseAvailableMaterials;
-    const brandKey = formData.brand.toLowerCase();
-    const materialKeys = brandToMaterials.get(brandKey);
-    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
-    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
-  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
+  // #1905: the brand and material dropdowns used to be filtered down to the
+  // pairs seen in the color catalog / slicer presets, which hid perfectly valid
+  // combinations — "Elegoo" exists (as a PLA brand) but vanished from the list
+  // once ASA was selected, making the entry look impossible. Both lists now
+  // always offer everything we know about, plus whatever the spool already has
+  // stored (a custom brand saved earlier was missing from its own dropdown).
+  // The pairing knowledge survives as `suggestedBrands`/`suggestedMaterials`,
+  // which the dropdowns sort to the top instead of filtering by.
+  const availableBrands = useMemo(
+    () => withCurrentValue(baseAvailableBrands, formData.brand),
+    [baseAvailableBrands, formData.brand],
+  );
+
+  const availableMaterials = useMemo(
+    () => withCurrentValue(baseAvailableMaterials, formData.material),
+    [baseAvailableMaterials, formData.material],
+  );
+
+  const suggestedBrands = useMemo(
+    () => pairedOptions(availableBrands, formData.material, materialToBrands),
+    [availableBrands, formData.material, materialToBrands],
+  );
+
+  const suggestedMaterials = useMemo(
+    () => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
+    [availableMaterials, formData.brand, brandToMaterials],
+  );
 
   // Find selected preset option
   const selectedPresetOption = useMemo(
@@ -378,9 +390,14 @@ export function SpoolFormModal({
         setFormData(defaultFormData);
         setPresetInputValue('');
         setSelectedProfiles(new Set());
-        setQuickAdd(false);
-        setQuantity(1);
       }
+      // Reset on every open, not just the create path (#1905). The modal keeps
+      // its state while closed, and the Quick Add toggle only renders in create
+      // mode — so quick-adding a spool and then opening Edit left the edit form
+      // stuck in quick-add layout (no preset field, no PA-profile tab) with no
+      // control to switch back.
+      setQuickAdd(false);
+      setQuantity(1);
       setErrors({});
       setActiveTab('filament');
       setWeightTouched(false);
@@ -717,7 +734,7 @@ export function SpoolFormModal({
   if (!isOpen) return null;
 
   const handleSubmit = () => {
-    const validation = validateForm(formData, quickAdd, spoolmanMode);
+    const validation = validateForm(formData, quickAdd, spoolmanMode, mode);
     if (!validation.isValid) {
       setErrors(validation.errors);
       if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
@@ -896,7 +913,10 @@ export function SpoolFormModal({
                   filamentOptions={filamentOptions}
                   availableBrands={availableBrands}
                   availableMaterials={availableMaterials}
+                  suggestedBrands={suggestedBrands}
+                  suggestedMaterials={suggestedMaterials}
                   quickAdd={quickAdd}
+                  detailsRequired={!quickAdd && !spoolmanMode && mode === 'create'}
                   quantity={quantity}
                   onQuantityChange={setQuantity}
                   errors={errors}

+ 141 - 43
frontend/src/components/spool-form/FilamentSection.tsx

@@ -5,6 +5,30 @@ import type { FilamentSectionProps, FilamentOption } from './types';
 import { KNOWN_VARIANTS } from './constants';
 import { parsePresetName } from './utils';
 
+// The identity fields a slicer preset can auto-fill.
+type PresetFilledField = 'material' | 'brand' | 'subtype';
+
+// Split a dropdown's options into the ones the catalog/presets pair with the
+// other field's current value and everything else (#1905). Suggestions are only
+// a sort order — nothing is ever hidden, so an unusual-but-real combination
+// (Elegoo ASA) stays one click away.
+function splitSuggested(options: string[], suggested: string[]): { top: string[]; rest: string[] } {
+  if (suggested.length === 0) return { top: [], rest: options };
+  const keys = new Set(suggested.map(s => s.toLowerCase()));
+  return {
+    top: options.filter(o => keys.has(o.toLowerCase())),
+    rest: options.filter(o => !keys.has(o.toLowerCase())),
+  };
+}
+
+function GroupHeading({ label }: { label: string }) {
+  return (
+    <div className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-bambu-gray/70 bg-bambu-dark-tertiary/40">
+      {label}
+    </div>
+  );
+}
+
 export function FilamentSection({
   formData,
   updateField,
@@ -16,7 +40,10 @@ export function FilamentSection({
   filamentOptions,
   availableBrands,
   availableMaterials,
+  suggestedBrands,
+  suggestedMaterials,
   quickAdd,
+  detailsRequired,
   quantity,
   onQuantityChange,
   errors,
@@ -31,6 +58,10 @@ export function FilamentSection({
   const [materialSearch, setMaterialSearch] = useState('');
   const [labelInput, setLabelInput] = useState(String(formData.label_weight));
   const [isLabelFocused, setIsLabelFocused] = useState(false);
+  // Which identity fields the currently selected preset filled in (#1905).
+  // Anything outside this set was set by the user (or loaded from the spool
+  // being edited) and must survive picking a preset.
+  const [presetFilled, setPresetFilled] = useState<Set<PresetFilledField>>(new Set());
   const presetRef = useRef<HTMLDivElement>(null);
   const brandRef = useRef<HTMLDivElement>(null);
   const subtypeRef = useRef<HTMLDivElement>(null);
@@ -101,6 +132,16 @@ export function FilamentSection({
     });
   }, [materialSearch, availableMaterials]);
 
+  const brandGroups = useMemo(
+    () => splitSuggested(filteredBrands, suggestedBrands),
+    [filteredBrands, suggestedBrands],
+  );
+
+  const materialGroups = useMemo(
+    () => splitSuggested(filteredMaterials, suggestedMaterials),
+    [filteredMaterials, suggestedMaterials],
+  );
+
   useEffect(() => {
     if (!isLabelFocused) {
       setLabelInput(String(formData.label_weight));
@@ -113,13 +154,74 @@ export function FilamentSection({
     setPresetInputValue(option.displayName);
     setPresetDropdownOpen(false);
 
-    // Auto-fill material, brand, subtype from preset name
+    // Auto-fill material, brand and subtype from the preset name — but only
+    // where the value is still empty or was itself put there by the previously
+    // selected preset (#1905). Overwriting unconditionally silently rewrote the
+    // spool's own manufacturer ("Elegoo" → "Generic") whenever the user was
+    // forced to assign a preset, so the spool vanished from where they filed it.
+    // Switching between presets still replaces what the earlier one filled in.
     const parsed = parsePresetName(option.name);
-    if (parsed.material) updateField('material', parsed.material);
-    if (parsed.brand) updateField('brand', parsed.brand);
-    if (parsed.variant) updateField('subtype', parsed.variant);
+    const filled = new Set<PresetFilledField>();
+    const entries: [PresetFilledField, string][] = [
+      ['material', parsed.material],
+      ['brand', parsed.brand],
+      ['subtype', parsed.variant],
+    ];
+    for (const [field, value] of entries) {
+      if (!value) continue;
+      if (formData[field] && !presetFilled.has(field)) continue;
+      updateField(field, value);
+      filled.add(field);
+    }
+    setPresetFilled(filled);
   };
 
+  // A manual edit to an identity field takes it back out of the preset's hands.
+  const updateIdentityField = (field: PresetFilledField, value: string) => {
+    if (presetFilled.has(field)) {
+      setPresetFilled(prev => {
+        const next = new Set(prev);
+        next.delete(field);
+        return next;
+      });
+    }
+    updateField(field, value);
+  };
+
+  const renderBrandOption = (brand: string) => (
+    <button
+      key={brand}
+      type="button"
+      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+        formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+      }`}
+      onClick={() => {
+        updateIdentityField('brand', brand);
+        setBrandDropdownOpen(false);
+        setBrandSearch('');
+      }}
+    >
+      {brand}
+    </button>
+  );
+
+  const renderMaterialOption = (material: string) => (
+    <button
+      key={material}
+      type="button"
+      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+        formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+      }`}
+      onClick={() => {
+        updateIdentityField('material', material);
+        setMaterialDropdownOpen(false);
+        setMaterialSearch('');
+      }}
+    >
+      {material}
+    </button>
+  );
+
   return (
     <div className="space-y-4">
       {/* Cloud status indicator */}
@@ -139,7 +241,7 @@ export function FilamentSection({
       {!quickAdd && (
         <div>
           <label className="block text-sm font-medium text-bambu-gray mb-1">
-            {t('inventory.slicerPreset')} *
+            {t('inventory.slicerPreset')}{detailsRequired && ' *'}
           </label>
           <div className="relative" ref={presetRef}>
             <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
@@ -215,22 +317,20 @@ export function FilamentSection({
               {filteredMaterials.length === 0 ? (
                 <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
               ) : (
-                filteredMaterials.map((material) => (
-                  <button
-                    key={material}
-                    type="button"
-                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
-                      formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
-                    }`}
-                    onClick={() => {
-                      updateField('material', material);
-                      setMaterialDropdownOpen(false);
-                      setMaterialSearch('');
-                    }}
-                  >
-                    {material}
-                  </button>
-                ))
+                <>
+                  {materialGroups.top.length > 0 && (
+                    <>
+                      <GroupHeading label={t('inventory.suggestedOptions')} />
+                      {materialGroups.top.map(renderMaterialOption)}
+                    </>
+                  )}
+                  {materialGroups.rest.length > 0 && (
+                    <>
+                      {materialGroups.top.length > 0 && <GroupHeading label={t('inventory.allOptions')} />}
+                      {materialGroups.rest.map(renderMaterialOption)}
+                    </>
+                  )}
+                </>
               )}
               {/* Allow custom material */}
               {materialSearch && !filteredMaterials.includes(materialSearch) && (
@@ -238,7 +338,7 @@ export function FilamentSection({
                   type="button"
                   className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
                   onClick={() => {
-                    updateField('material', materialSearch);
+                    updateIdentityField('material', materialSearch);
                     setMaterialDropdownOpen(false);
                     setMaterialSearch('');
                   }}
@@ -257,7 +357,7 @@ export function FilamentSection({
       {/* Brand (dropdown with search) */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">
-          {t('inventory.brand')}{!quickAdd && ' *'}
+          {t('inventory.brand')}{detailsRequired && ' *'}
         </label>
           <div className="relative" ref={brandRef}>
             <input
@@ -280,22 +380,20 @@ export function FilamentSection({
                 {filteredBrands.length === 0 ? (
                   <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
                 ) : (
-                  filteredBrands.map(brand => (
-                    <button
-                      key={brand}
-                      type="button"
-                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
-                        formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
-                      }`}
-                      onClick={() => {
-                        updateField('brand', brand);
-                        setBrandDropdownOpen(false);
-                        setBrandSearch('');
-                      }}
-                    >
-                      {brand}
-                    </button>
-                  ))
+                  <>
+                    {brandGroups.top.length > 0 && (
+                      <>
+                        <GroupHeading label={t('inventory.suggestedOptions')} />
+                        {brandGroups.top.map(renderBrandOption)}
+                      </>
+                    )}
+                    {brandGroups.rest.length > 0 && (
+                      <>
+                        {brandGroups.top.length > 0 && <GroupHeading label={t('inventory.allOptions')} />}
+                        {brandGroups.rest.map(renderBrandOption)}
+                      </>
+                    )}
+                  </>
                 )}
                 {/* Allow custom brand */}
                 {brandSearch && !filteredBrands.includes(brandSearch) && (
@@ -303,7 +401,7 @@ export function FilamentSection({
                     type="button"
                     className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
                     onClick={() => {
-                      updateField('brand', brandSearch);
+                      updateIdentityField('brand', brandSearch);
                       setBrandDropdownOpen(false);
                       setBrandSearch('');
                     }}
@@ -322,7 +420,7 @@ export function FilamentSection({
       {/* Variant / Subtype */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">
-          {t('inventory.subtype')}{!quickAdd && ' *'}
+          {t('inventory.subtype')}{detailsRequired && ' *'}
         </label>
           <div className="relative" ref={subtypeRef}>
             <input
@@ -353,7 +451,7 @@ export function FilamentSection({
                         formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
                       }`}
                       onClick={() => {
-                        updateField('subtype', variant);
+                        updateIdentityField('subtype', variant);
                         setSubtypeDropdownOpen(false);
                         setSubtypeSearch('');
                       }}
@@ -367,7 +465,7 @@ export function FilamentSection({
                     type="button"
                     className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
                     onClick={() => {
-                      updateField('subtype', subtypeSearch);
+                      updateIdentityField('subtype', subtypeSearch);
                       setSubtypeDropdownOpen(false);
                       setSubtypeSearch('');
                     }}

+ 23 - 2
frontend/src/components/spool-form/types.ts

@@ -1,6 +1,11 @@
 
 import type { Printer, SpoolKProfile } from '../../api/client';
 
+// Which operation the spool form is performing. Lives here (rather than in
+// SpoolFormModal) so validateForm can key off it without a circular import;
+// SpoolFormModal re-exports it for existing consumers.
+export type SpoolFormMode = 'create' | 'edit' | 'copy';
+
 // Catalog color display type (moved from component)
 export interface CatalogDisplayColor {
   name: string;
@@ -112,7 +117,17 @@ export interface FilamentSectionProps extends SectionProps {
   filamentOptions: FilamentOption[];
   availableBrands: string[];
   availableMaterials: string[];
+  // Brands/materials the catalog and slicer presets know to pair with the other
+  // field's current value (#1905). These sort to the top under a "Suggested"
+  // heading — they are never used to hide the rest, because doing so made
+  // legitimate combinations (Elegoo ASA) look impossible to enter.
+  suggestedBrands: string[];
+  suggestedMaterials: string[];
   quickAdd: boolean;
+  // Whether preset/brand/subtype are mandatory for this submission — see
+  // validateForm. Drives the " *" markers so the form never advertises a
+  // requirement it won't enforce (#1905).
+  detailsRequired: boolean;
   quantity: number;
   onQuantityChange: (value: number) => void;
   errors?: Partial<Record<keyof SpoolFormData, string>>;
@@ -181,11 +196,17 @@ export function validateForm(
   formData: SpoolFormData,
   quickAdd = false,
   spoolmanMode = false,
+  mode: SpoolFormMode = 'create',
 ): ValidationResult {
   const errors: Partial<Record<keyof SpoolFormData, string>> = {};
 
-  // Quick-add and Spoolman mode only require material (unless a catalog entry is pre-selected)
-  if (quickAdd || spoolmanMode) {
+  // Quick-add and Spoolman mode only require material (unless a catalog entry
+  // is pre-selected). Edit and copy relax the same way (#1905): the spool
+  // already exists, and a row created by quick-add, CSV import or an RFID scan
+  // has no preset/brand/subtype — demanding them here blocked every later edit,
+  // even one that only changed the storage location. The backend only ever
+  // required material (SpoolCreate/SpoolUpdate in schemas/spool.py).
+  if (quickAdd || spoolmanMode || mode !== 'create') {
     if (!formData.material && !formData.spoolman_filament_id) {
       errors.material = 'Material is required';
     }

+ 25 - 0
frontend/src/components/spool-form/utils.ts

@@ -304,6 +304,31 @@ export function findPresetOption(
   return option;
 }
 
+// Keep the value a spool already carries selectable in its own dropdown (#1905).
+// A brand or material entered as a custom value isn't part of the color catalog
+// or any slicer preset, so without this the edit form offered no way back to it
+// once the user opened the dropdown.
+export function withCurrentValue(options: string[], current: string): string[] {
+  const trimmed = current.trim();
+  if (!trimmed || options.some(o => o.toLowerCase() === trimmed.toLowerCase())) return options;
+  return [...options, trimmed].sort((a, b) => a.localeCompare(b));
+}
+
+// Brands/materials the catalog and slicer presets pair with the other field's
+// current value (#1905). Used to rank the dropdown, never to filter it — the
+// pairs are incomplete (Elegoo ships ASA even though the catalog only knows its
+// PLA), and hiding the rest made valid entries look impossible.
+export function pairedOptions(
+  options: string[],
+  counterpart: string,
+  pairMap: Map<string, Set<string>>,
+): string[] {
+  if (!counterpart) return [];
+  const keys = pairMap.get(counterpart.toLowerCase());
+  if (!keys || keys.size === 0) return [];
+  return options.filter(o => keys.has(o.toLowerCase()));
+}
+
 // Recent colors management
 export function loadRecentColors(): ColorPreset[] {
   try {

+ 4 - 1
frontend/src/i18n/index.ts

@@ -14,6 +14,7 @@ import ptBR from './locales/pt-BR';
 import zhCN from './locales/zh-CN';
 import zhTW from './locales/zh-TW';
 import tr from './locales/tr';
+import uk from './locales/uk';
 import ru from './locales/ru';
 
 const resources = {
@@ -28,10 +29,11 @@ const resources = {
   'zh-CN': { translation: zhCN },
   'zh-TW': { translation: zhTW },
   tr: { translation: tr },
+  uk: { translation: uk },
   ru: { translation: ru },
 };
 
-const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'zh-CN', 'zh-TW'];
+const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'uk', 'zh-CN', 'zh-TW'];
 const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
 
 i18n
@@ -109,5 +111,6 @@ export const availableLanguages = [
   { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
   { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
   { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
+  { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
   { code: 'ru', name: 'Russian', nativeName: 'Русский' },
 ];

+ 40 - 1
frontend/src/i18n/locales/de.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen',
+    aiDetection: {
+      safe: 'Sicher',
+      warning: 'Warnung',
+      failure: 'Fehldruck',
+      idle: 'Bereit',
+      tooltip: 'KI-Fehlererkennung: {{status}} (Score {{score}}) - klicken für Details',
+      tooltipIdle: 'KI-Fehlererkennung aktiviert - Überwachung startet mit dem nächsten Druck - klicken für Details',
+      modalTitle: 'KI-Fehlererkennung - {{name}}',
+      currentStatus: 'Status',
+      score: 'Score',
+      framesAnalyzed: 'Analysierte Bilder',
+      idleHint: 'Derzeit wird kein Druck überwacht. Die Überwachung startet automatisch mit dem nächsten Druck.',
+      lastError: 'Letzter Fehler',
+      openSettings: 'Einstellungen öffnen',
+    },
     estimatedCompletion: 'Geschätzte Fertigstellungszeit',
     plateNumber: 'Platte {{number}}',
     slotOptions: 'Slot-Optionen',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: 'Kosten',
     avgPerPrint: 'Durchschnitt pro Druck',
     usageOverTime: 'Verbrauch im Zeitverlauf',
+    energyOverTime: 'Energie im Zeitverlauf',
     filamentByWeight: 'Gewicht',
     printDuration: 'Druckdauer',
     printerUtilization: 'Druckerauslastung',
@@ -3677,6 +3693,7 @@ export default {
     noPermissionRenameFolder: 'Sie haben keine Berechtigung, Ordner umzubenennen',
     noPermissionLinkFolder: 'Sie haben keine Berechtigung, Ordner zu verknüpfen',
     noPermissionDeleteFolder: 'Sie haben keine Berechtigung, Ordner zu löschen',
+    onlyEmptyFoldersDeletable: 'Sie können nur leere Ordner löschen',
     noPermissionPrint: 'Sie haben keine Berechtigung zum Drucken',
     noPermissionSlice: 'Sie haben keine Berechtigung, Dateien zu slicen',
     noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',
@@ -3767,6 +3784,9 @@ export default {
     targetParts: 'Ziel-Teile',
     targetPartsPlaceholder: 'z.B. 150',
     targetPartsHelp: 'Benötigte Objekte insgesamt',
+    targetSets: 'Kopien pro Datei',
+    targetSetsPlaceholder: 'z. B. 10',
+    targetSetsHelp: 'Wie oft jede druckbare Datei gedruckt werden soll',
     tagsLabel: 'Tags (kommagetrennt)',
     tagsPlaceholder: 'z.B. voron, funktional, geschenk',
     dueDate: 'Fälligkeitsdatum',
@@ -3842,6 +3862,9 @@ export default {
     progress: {
       platesProgress: 'Platten-Fortschritt',
       partsProgress: 'Teile-Fortschritt',
+      setsProgress: 'Vollständige Sätze',
+      sets: 'Sätze',
+      setsHint: 'Ein Satz ist vollständig, wenn jede druckbare Datei diese Anzahl erreicht hat',
       printJobs: 'Druckaufträge',
       parts: 'Teile',
       percentComplete: '{{percent}}% abgeschlossen',
@@ -3880,6 +3903,8 @@ export default {
       forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',
       fileCount: '{{count}} Datei(en)',
       empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
+      printedCount: '{{count}}-mal gedruckt',
+      progressTooltip: '{{done}} von {{target}} abgeschlossenen Drucken',
       noFiles: 'Keine Dateien in diesem Ordner.',
     },
     bom: {
@@ -4047,6 +4072,11 @@ export default {
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     useEmbedded: 'Eingebettete Einstellungen der Datei verwenden',
     useEmbeddedHint: 'So slicen, wie der Ersteller es angelegt hat (Wände, Füllung, Filament), statt mit den obigen Profilen. Verfügbar, weil dein Drucker zur Datei passt.',
+    designSettings: 'Einstellungen des Erstellers behalten',
+    designSettingsHint: 'Diese Datei ändert {{count}} Druckeinstellung(en) gegenüber dem Standardprofil.',
+    designSettingsSelected: '{{selected}} von {{total}} ausgewählt',
+    designSettingsPrinterCoupled: 'druckerspezifisch',
+    designSettingsPrinterCoupledHint: 'Auf den Drucker abgestimmt, für den die Datei erstellt wurde — auf deinem kann der Wert falsch oder unzulässig sein.',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
@@ -4305,6 +4335,8 @@ export default {
     searchBrand: 'Marke suchen...',
     useCustomBrand: '"{{brand}}" verwenden',
     useCustomMaterial: 'Benutzerdefiniertes Material verwenden: {{material}}',
+    suggestedOptions: 'Vorgeschlagen',
+    allOptions: 'Alle',
     colorName: 'Farbname',
     colorNamePlaceholder: 'Jadeweiß, Feuerrot...',
     color: 'Farbe',
@@ -5420,6 +5452,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5431,6 +5464,7 @@ export default {
       callmebot: 'Kostenlose WhatsApp-Benachrichtigungen über CallMeBot',
       webhook: 'Generischer HTTP-POST an beliebige URL',
       homeassistant: 'Dauerhafte Benachrichtigungen im Home Assistant Dashboard',
+      bark: 'iOS-Push-Benachrichtigungen über Bark (selbst hostbar)',
     },
     // NotificationProviderCard
     lastSuccess: 'Zuletzt: {{date}}',
@@ -5469,6 +5503,9 @@ export default {
     printStarted: 'Druck gestartet',
     plateNotEmpty: 'Platte nicht leer',
     plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
+    plateClearRequired: 'Platte freigeben',
+    plateClearRequiredDescription: 'Druck beendet, Warteschlange wartet auf Bestätigung',
+    plateClear: 'Platte freigeben',
     printCompleted: 'Druck abgeschlossen',
     bedCooledLabel: 'Bett abgekühlt',
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
@@ -5564,6 +5601,7 @@ export default {
     add: 'Hinzufügen',
     nameRequired: 'Name ist erforderlich',
     fieldRequired: '{{field}} ist erforderlich',
+    haDataInvalid: 'Das Datenfeld muss ein gültiges JSON-Objekt sein, z. B. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Telefonnummer',
     apiKey: 'API-Schlüssel',
@@ -6315,7 +6353,8 @@ export default {
       mqtt_auth: {
         title: 'Drucker-Zugangsdaten',
         pass: 'Der Drucker hat die Verbindung akzeptiert.',
-        fail: 'Der Drucker ist erreichbar, hat die Verbindung aber abgelehnt. Der Zugangscode oder die Seriennummer ist höchstwahrscheinlich falsch. Der Zugangscode ändert sich bei jedem Umschalten des Entwicklermodus — kopieren Sie ihn erneut vom Druckerbildschirm.',
+        fail: 'Der Drucker ist erreichbar, aber Bambuddy ist nicht mit ihm verbunden. Höchstwahrscheinlich ist der Zugangscode oder die Seriennummer falsch — der Zugangscode ändert sich bei jedem Umschalten von „Nur LAN“ oder des Entwicklermodus, kopieren Sie ihn also erneut vom Druckerbildschirm. Ein Drucker, der gerade neu startet oder bereits die maximale Anzahl gleichzeitiger Verbindungen erreicht hat, sieht genauso aus.',
+        fail_auth_rejected: 'Der Drucker hat die Zugangsdaten von Bambuddy abgelehnt. Der Zugangscode oder die Seriennummer ist falsch — der Zugangscode ändert sich bei jedem Umschalten von „Nur LAN“ oder des Entwicklermodus. Kopieren Sie ihn erneut vom Druckerbildschirm und speichern Sie ihn in den Druckereinstellungen.',
         skip: 'Nicht geprüft — der Drucker konnte nicht erreicht werden.',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/en.ts

@@ -676,6 +676,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Click to view HMS errors',
+    aiDetection: {
+      safe: 'Safe',
+      warning: 'Warning',
+      failure: 'Failure',
+      idle: 'Idle',
+      tooltip: 'AI Failure Detection: {{status}} (score {{score}}) - click for details',
+      tooltipIdle: 'AI Failure Detection enabled - monitoring starts with the next print - click for details',
+      modalTitle: 'AI Failure Detection - {{name}}',
+      currentStatus: 'Status',
+      score: 'Score',
+      framesAnalyzed: 'Frames analyzed',
+      idleHint: 'No print is being monitored right now. Monitoring starts automatically with the next print.',
+      lastError: 'Last error',
+      openSettings: 'Open settings',
+    },
     estimatedCompletion: 'Estimated completion time',
     plateNumber: 'Plate {{number}}',
     slotOptions: 'Slot options',
@@ -1484,6 +1499,7 @@ export default {
     periodCost: 'Period Cost',
     avgPerPrint: 'Avg per Print',
     usageOverTime: 'Usage Over Time',
+    energyOverTime: 'Energy Over Time',
     filamentByWeight: 'Weight',
     printDuration: 'Print Duration',
     printerUtilization: 'Printer Utilization',
@@ -3706,6 +3722,7 @@ export default {
     noPermissionRenameFolder: 'You do not have permission to rename folders',
     noPermissionLinkFolder: 'You do not have permission to link folders',
     noPermissionDeleteFolder: 'You do not have permission to delete folders',
+    onlyEmptyFoldersDeletable: 'You can only delete empty folders',
     noPermissionPrint: 'You do not have permission to print',
     noPermissionAddToQueue: 'You do not have permission to add to queue',
     noPermissionSlice: 'You do not have permission to slice files',
@@ -3796,6 +3813,9 @@ export default {
     targetParts: 'Target Parts',
     targetPartsPlaceholder: 'e.g., 150',
     targetPartsHelp: 'Total objects needed',
+    targetSets: 'Copies per File',
+    targetSetsPlaceholder: 'e.g., 10',
+    targetSetsHelp: 'Times each printable file should be printed',
     tagsLabel: 'Tags (comma-separated)',
     tagsPlaceholder: 'e.g., voron, functional, gift',
     dueDate: 'Due Date',
@@ -3871,6 +3891,9 @@ export default {
     progress: {
       platesProgress: 'Plates Progress',
       partsProgress: 'Parts Progress',
+      setsProgress: 'Complete Sets',
+      sets: 'sets',
+      setsHint: 'A set is complete once every printable file has reached this count',
       printJobs: 'print jobs',
       parts: 'parts',
       percentComplete: '{{percent}}% complete',
@@ -3909,6 +3932,8 @@ export default {
       forQuickAccess: 'to this project for quick access.',
       fileCount: '{{count}} file(s)',
       empty: 'No folders linked. Go to File Manager and link a folder to this project.',
+      printedCount: 'Printed {{count}} time(s)',
+      progressTooltip: '{{done}} of {{target}} completed prints',
       noFiles: 'No files in this folder.',
     },
     bom: {
@@ -4081,6 +4106,11 @@ export default {
     allPresetsRequired: 'All presets must be selected',
     useEmbedded: "Use the file's built-in settings",
     useEmbeddedHint: "Slice it the way the designer set it up (walls, infill, filament) instead of the profiles above. Offered because your printer matches the file's.",
+    designSettings: 'Keep the designer\'s settings',
+    designSettingsHint: 'This file changes {{count}} print setting(s) from the stock profile.',
+    designSettingsSelected: '{{selected}} of {{total}} selected',
+    designSettingsPrinterCoupled: 'printer-specific',
+    designSettingsPrinterCoupledHint: 'Tuned for the printer this file was designed for — may be wrong or out of range on yours.',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
@@ -4339,6 +4369,8 @@ export default {
     searchBrand: 'Search brand...',
     useCustomBrand: 'Use "{{brand}}"',
     useCustomMaterial: 'Use custom material: {{material}}',
+    suggestedOptions: 'Suggested',
+    allOptions: 'All',
     colorName: 'Color Name',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: 'Color',
@@ -5464,6 +5496,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5475,6 +5508,7 @@ export default {
       callmebot: 'Free WhatsApp notifications via CallMeBot',
       webhook: 'Generic HTTP POST to any URL',
       homeassistant: 'Persistent notifications in Home Assistant dashboard',
+      bark: 'iOS push notifications via Bark (self-hostable)',
     },
     // NotificationProviderCard
     lastSuccess: 'Last: {{date}}',
@@ -5513,6 +5547,9 @@ export default {
     printStarted: 'Print Started',
     plateNotEmpty: 'Plate Not Empty',
     plateNotEmptyDescription: 'Objects detected before print',
+    plateClearRequired: 'Plate Clear Required',
+    plateClearRequiredDescription: 'Print finished, queue waits for plate confirmation',
+    plateClear: 'Plate Clear',
     printCompleted: 'Print Completed',
     bedCooledLabel: 'Bed Cooled',
     bedCooledDescription: 'Bed cooled below threshold after print',
@@ -5608,6 +5645,7 @@ export default {
     add: 'Add',
     nameRequired: 'Name is required',
     fieldRequired: '{{field}} is required',
+    haDataInvalid: 'The Data field must be a valid JSON object, e.g. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Phone Number',
     apiKey: 'API Key',
@@ -6359,7 +6397,8 @@ export default {
       mqtt_auth: {
         title: 'Printer credentials',
         pass: 'The printer accepted the connection.',
-        fail: 'The printer is reachable but rejected the connection. The access code or serial number is most likely wrong. The access code changes every time Developer Mode is toggled — re-copy it from the printer screen.',
+        fail: 'The printer is reachable but Bambuddy is not connected to it. The access code or serial number is most likely wrong — the access code changes every time LAN Only or Developer Mode is toggled, so re-copy it from the printer screen. A printer that is rebooting, or already at its limit of simultaneous connections, can look the same.',
+        fail_auth_rejected: 'The printer refused Bambuddy\'s credentials. The access code or serial number is wrong — the access code changes every time LAN Only or Developer Mode is toggled, so re-copy it from the printer screen and save it in the printer settings.',
         skip: 'Not checked — the printer could not be reached.',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/es.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Haga clic para ver los errores HMS',
+    aiDetection: {
+      safe: 'Seguro',
+      warning: 'Advertencia',
+      failure: 'Fallo',
+      idle: 'Inactiva',
+      tooltip: 'Detección de fallos por IA: {{status}} (puntuación {{score}}) - haga clic para más detalles',
+      tooltipIdle: 'Detección de fallos por IA activada - la supervisión comienza con la próxima impresión - haga clic para más detalles',
+      modalTitle: 'Detección de fallos por IA - {{name}}',
+      currentStatus: 'Estado',
+      score: 'Puntuación',
+      framesAnalyzed: 'Fotogramas analizados',
+      idleHint: 'Ahora mismo no se supervisa ninguna impresión. La supervisión comienza automáticamente con la próxima impresión.',
+      lastError: 'Último error',
+      openSettings: 'Abrir ajustes',
+    },
     estimatedCompletion: 'Hora estimada de finalización',
     plateNumber: 'Cama {{number}}',
     slotOptions: 'Opciones de la ranura',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: 'Coste del periodo',
     avgPerPrint: 'Promedio por impresión',
     usageOverTime: 'Uso a lo largo del tiempo',
+    energyOverTime: 'Energía a lo largo del tiempo',
     filamentByWeight: 'Peso',
     printDuration: 'Duración de la impresión',
     printerUtilization: 'Uso de la impresora',
@@ -3680,6 +3696,7 @@ export default {
     noPermissionRenameFolder: 'No tiene permiso para renombrar carpetas',
     noPermissionLinkFolder: 'No tiene permiso para vincular carpetas',
     noPermissionDeleteFolder: 'No tiene permiso para eliminar carpetas',
+    onlyEmptyFoldersDeletable: 'Solo puede eliminar carpetas vacías',
     noPermissionPrint: 'No tiene permiso para imprimir',
     noPermissionAddToQueue: 'No tiene permiso para añadir a la cola',
     noPermissionSlice: 'No tiene permiso para laminar archivos',
@@ -3770,6 +3787,9 @@ export default {
     targetParts: 'Piezas objetivo',
     targetPartsPlaceholder: 'p. ej., 150',
     targetPartsHelp: 'Total de objetos necesarios',
+    targetSets: 'Copias por archivo',
+    targetSetsPlaceholder: 'p. ej., 10',
+    targetSetsHelp: 'Veces que debe imprimirse cada archivo imprimible',
     tagsLabel: 'Etiquetas (separadas por comas)',
     tagsPlaceholder: 'p. ej., voron, funcional, regalo',
     dueDate: 'Fecha límite',
@@ -3845,6 +3865,9 @@ export default {
     progress: {
       platesProgress: 'Progreso de camas',
       partsProgress: 'Progreso de piezas',
+      setsProgress: 'Juegos completos',
+      sets: 'juegos',
+      setsHint: 'Un juego está completo cuando cada archivo imprimible alcanza este número',
       printJobs: 'trabajos de impresión',
       parts: 'piezas',
       percentComplete: '{{percent}}% completado',
@@ -3883,6 +3906,8 @@ export default {
       forQuickAccess: 'a este proyecto para un acceso rápido.',
       fileCount: '{{count}} archivo(s)',
       empty: 'No hay carpetas vinculadas. Vaya al gestor de archivos y vincule una carpeta a este proyecto.',
+      printedCount: 'Impreso {{count}} veces',
+      progressTooltip: '{{done}} de {{target}} impresiones completadas',
       noFiles: 'No hay archivos en esta carpeta.',
     },
     bom: {
@@ -4050,6 +4075,11 @@ export default {
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     useEmbedded: 'Usar la configuración incorporada del archivo',
     useEmbeddedHint: 'Laminar tal como lo configuró el diseñador (perímetros, relleno, filamento) en lugar de los perfiles de arriba. Disponible porque tu impresora coincide con la del archivo.',
+    designSettings: 'Mantener los ajustes del diseñador',
+    designSettingsHint: 'Este archivo cambia {{count}} ajuste(s) de impresión respecto al perfil estándar.',
+    designSettingsSelected: '{{selected}} de {{total}} seleccionados',
+    designSettingsPrinterCoupled: 'específico de la impresora',
+    designSettingsPrinterCoupledHint: 'Ajustado para la impresora para la que se diseñó el archivo: puede ser incorrecto o quedar fuera de rango en la tuya.',
     enqueuing: 'Enviando el trabajo de laminado…',
     queued: 'En cola…',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',
@@ -4308,6 +4338,8 @@ export default {
     searchBrand: 'Buscar marca...',
     useCustomBrand: 'Usar "{{brand}}"',
     useCustomMaterial: 'Usar material personalizado: {{material}}',
+    suggestedOptions: 'Sugeridos',
+    allOptions: 'Todos',
     colorName: 'Nombre del color',
     colorNamePlaceholder: 'Blanco jade, Rojo fuego...',
     color: 'Color',
@@ -5429,6 +5461,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5440,6 +5473,7 @@ export default {
       callmebot: 'Notificaciones de WhatsApp gratuitas mediante CallMeBot',
       webhook: 'POST HTTP genérico a cualquier URL',
       homeassistant: 'Notificaciones persistentes en el panel de Home Assistant',
+      bark: 'Notificaciones push de iOS mediante Bark (autoalojable)',
     },
     // NotificationProviderCard
     lastSuccess: 'Última: {{date}}',
@@ -5478,6 +5512,9 @@ export default {
     printStarted: 'Impresión iniciada',
     plateNotEmpty: 'Cama no vacía',
     plateNotEmptyDescription: 'Objetos detectados antes de la impresión',
+    plateClearRequired: 'Confirmar cama libre',
+    plateClearRequiredDescription: 'Impresión terminada, la cola espera confirmación',
+    plateClear: 'Cama libre',
     printCompleted: 'Impresión completada',
     bedCooledLabel: 'Cama enfriada',
     bedCooledDescription: 'La cama se enfrió por debajo del umbral tras la impresión',
@@ -5573,6 +5610,7 @@ export default {
     add: 'Añadir',
     nameRequired: 'El nombre es obligatorio',
     fieldRequired: '{{field}} es obligatorio',
+    haDataInvalid: 'El campo Data debe ser un objeto JSON válido, p. ej. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Número de teléfono',
     apiKey: 'Clave API',
@@ -6324,7 +6362,8 @@ export default {
       mqtt_auth: {
         title: 'Credenciales de la impresora',
         pass: 'La impresora aceptó la conexión.',
-        fail: 'La impresora es accesible pero rechazó la conexión. Lo más probable es que el código de acceso o el número de serie sean incorrectos. El código de acceso cambia cada vez que se conmuta el modo desarrollador — vuelva a copiarlo de la pantalla de la impresora.',
+        fail: 'La impresora es accesible pero Bambuddy no está conectado a ella. Lo más probable es que el código de acceso o el número de serie sean incorrectos — el código de acceso cambia cada vez que se conmuta el modo Solo LAN o el modo desarrollador, así que vuelva a copiarlo de la pantalla de la impresora. Una impresora que se está reiniciando, o que ya alcanzó su límite de conexiones simultáneas, se ve igual.',
+        fail_auth_rejected: 'La impresora rechazó las credenciales de Bambuddy. El código de acceso o el número de serie son incorrectos — el código de acceso cambia cada vez que se conmuta el modo Solo LAN o el modo desarrollador, así que vuelva a copiarlo de la pantalla de la impresora y guárdelo en la configuración de la impresora.',
         skip: 'No comprobado — no se pudo alcanzar la impresora.',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/fr.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS',
+    aiDetection: {
+      safe: 'Sûr',
+      warning: 'Avertissement',
+      failure: 'Échec',
+      idle: 'Inactif',
+      tooltip: 'Détection d\'échec par IA : {{status}} (score {{score}}) - cliquez pour les détails',
+      tooltipIdle: 'Détection d\'échec par IA activée - la surveillance démarre à la prochaine impression - cliquez pour les détails',
+      modalTitle: 'Détection d\'échec par IA - {{name}}',
+      currentStatus: 'Statut',
+      score: 'Score',
+      framesAnalyzed: 'Images analysées',
+      idleHint: 'Aucune impression n\'est surveillée actuellement. La surveillance démarre automatiquement à la prochaine impression.',
+      lastError: 'Dernière erreur',
+      openSettings: 'Ouvrir les paramètres',
+    },
     estimatedCompletion: 'Fin estimée',
     plateNumber: 'Plaque {{number}}',
     slotOptions: 'Options du slot',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: 'Coût',
     avgPerPrint: 'Moy. par impression',
     usageOverTime: 'Utilisation dans le temps',
+    energyOverTime: 'Énergie dans le temps',
     filamentByWeight: 'Poids',
     printDuration: 'Durée d\'impression',
     printerUtilization: 'Utilisation imprimante',
@@ -3666,6 +3682,7 @@ export default {
     noPermissionRenameFolder: 'Pas d\'autorisation renommage',
     noPermissionLinkFolder: 'Pas d\'autorisation lien',
     noPermissionDeleteFolder: 'Pas d\'autorisation suppression dossier',
+    onlyEmptyFoldersDeletable: 'Vous ne pouvez supprimer que les dossiers vides',
     noPermissionPrint: 'Pas d\'autorisation impression',
     noPermissionSlice: 'Vous n\'avez pas la permission de découper des fichiers',
     noPermissionAddToQueue: 'Pas d\'autorisation file',
@@ -3756,6 +3773,9 @@ export default {
     targetParts: 'Pièces cibles',
     targetPartsPlaceholder: 'ex: 150',
     targetPartsHelp: 'Nombre total d\'objets',
+    targetSets: 'Copies par fichier',
+    targetSetsPlaceholder: 'ex. 10',
+    targetSetsHelp: 'Nombre d\'impressions de chaque fichier imprimable',
     tagsLabel: 'Tags (séparés par virgules)',
     tagsPlaceholder: 'ex: voron, cadeau',
     dueDate: 'Échéance',
@@ -3831,6 +3851,9 @@ export default {
     progress: {
       platesProgress: 'Progression Plateaux',
       partsProgress: 'Progression Pièces',
+      setsProgress: 'Jeux complets',
+      sets: 'jeux',
+      setsHint: 'Un jeu est complet quand chaque fichier imprimable atteint ce nombre',
       printJobs: 'jobs d\'impression',
       parts: 'pièces',
       percentComplete: '{{percent}}% terminé',
@@ -3869,6 +3892,8 @@ export default {
       forQuickAccess: 'pour un accès rapide.',
       fileCount: '{{count}} fichier(s)',
       empty: 'Aucun dossier lié.',
+      printedCount: 'Imprimé {{count}} fois',
+      progressTooltip: '{{done}} sur {{target}} impressions terminées',
       noFiles: 'Aucun fichier dans ce dossier.',
     },
     bom: {
@@ -4036,6 +4061,11 @@ export default {
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     useEmbedded: 'Utiliser les réglages intégrés du fichier',
     useEmbeddedHint: "Slicer tel que le concepteur l'a configuré (parois, remplissage, filament) au lieu des profils ci-dessus. Proposé car votre imprimante correspond à celle du fichier.",
+    designSettings: 'Conserver les réglages du concepteur',
+    designSettingsHint: 'Ce fichier modifie {{count}} réglage(s) d\'impression par rapport au profil standard.',
+    designSettingsSelected: '{{selected}} sur {{total}} sélectionnés',
+    designSettingsPrinterCoupled: 'spécifique à l\'imprimante',
+    designSettingsPrinterCoupledHint: 'Réglé pour l\'imprimante visée par le fichier — peut être incorrect ou hors plage sur la vôtre.',
     enqueuing: 'Envoi du travail de découpage…',
     queued: 'En file d\'attente…',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
@@ -4294,6 +4324,8 @@ export default {
     searchBrand: 'Chercher marque...',
     useCustomBrand: 'Utiliser "{{brand}}"',
     useCustomMaterial: 'Utiliser un matériau personnalisé : {{material}}',
+    suggestedOptions: 'Suggérés',
+    allOptions: 'Tous',
     colorName: 'Nom de couleur',
     colorNamePlaceholder: 'Blanc Jade, Rouge Feu...',
     color: 'Couleur',
@@ -5410,6 +5442,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5421,6 +5454,7 @@ export default {
       callmebot: 'Notifications WhatsApp gratuites via CallMeBot',
       webhook: 'POST HTTP générique vers n\'importe quelle URL',
       homeassistant: 'Notifications persistantes dans le tableau de bord Home Assistant',
+      bark: 'Notifications push iOS via Bark (auto-hébergeable)',
     },
     // NotificationProviderCard
     lastSuccess: 'Dernier : {{date}}',
@@ -5459,6 +5493,9 @@ export default {
     printStarted: 'Impression démarrée',
     plateNotEmpty: 'Plateau non vide',
     plateNotEmptyDescription: 'Objets détectés avant l\'impression',
+    plateClearRequired: 'Plateau à libérer',
+    plateClearRequiredDescription: 'Impression terminée, la file attend la confirmation',
+    plateClear: 'Plateau libre',
     printCompleted: 'Impression terminée',
     bedCooledLabel: 'Plateau refroidi',
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
@@ -5554,6 +5591,7 @@ export default {
     add: 'Ajouter',
     nameRequired: 'Le nom est requis',
     fieldRequired: '{{field}} est requis',
+    haDataInvalid: 'Le champ Data doit être un objet JSON valide, p. ex. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Numéro de téléphone',
     apiKey: 'Clé API',
@@ -6305,7 +6343,8 @@ export default {
       mqtt_auth: {
         title: 'Identifiants de l\'imprimante',
         pass: 'L\'imprimante a accepté la connexion.',
-        fail: 'L\'imprimante est accessible mais a refusé la connexion. Le code d\'accès ou le numéro de série est très probablement incorrect. Le code d\'accès change chaque fois que le mode développeur est activé/désactivé — recopiez-le depuis l\'écran de l\'imprimante.',
+        fail: 'L\'imprimante est accessible mais Bambuddy n\'y est pas connecté. Le code d\'accès ou le numéro de série est très probablement incorrect — le code d\'accès change chaque fois que le mode LAN uniquement ou le mode développeur est activé/désactivé, recopiez-le donc depuis l\'écran de l\'imprimante. Une imprimante en cours de redémarrage, ou ayant déjà atteint sa limite de connexions simultanées, produit le même résultat.',
+        fail_auth_rejected: 'L\'imprimante a refusé les identifiants de Bambuddy. Le code d\'accès ou le numéro de série est incorrect — le code d\'accès change chaque fois que le mode LAN uniquement ou le mode développeur est activé/désactivé. Recopiez-le depuis l\'écran de l\'imprimante et enregistrez-le dans les paramètres de l\'imprimante.',
         skip: 'Non vérifié — l\'imprimante n\'a pas pu être jointe.',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/it.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Clicca per vedere errori HMS',
+    aiDetection: {
+      safe: 'Sicuro',
+      warning: 'Avviso',
+      failure: 'Guasto',
+      idle: 'Inattiva',
+      tooltip: 'Rilevamento guasti con IA: {{status}} (punteggio {{score}}) - clicca per i dettagli',
+      tooltipIdle: 'Rilevamento guasti con IA attivo - il monitoraggio inizia con la prossima stampa - clicca per i dettagli',
+      modalTitle: 'Rilevamento guasti con IA - {{name}}',
+      currentStatus: 'Stato',
+      score: 'Punteggio',
+      framesAnalyzed: 'Fotogrammi analizzati',
+      idleHint: 'Al momento nessuna stampa è monitorata. Il monitoraggio inizia automaticamente con la prossima stampa.',
+      lastError: 'Ultimo errore',
+      openSettings: 'Apri impostazioni',
+    },
     estimatedCompletion: 'Tempo completamento stimato',
     plateNumber: 'Piastra {{number}}',
     slotOptions: 'Opzioni slot',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: 'Costo',
     avgPerPrint: 'Media per stampa',
     usageOverTime: 'Utilizzo nel tempo',
+    energyOverTime: 'Energia nel tempo',
     filamentByWeight: 'Peso',
     printDuration: 'Durata stampa',
     printerUtilization: 'Utilizzo stampante',
@@ -3665,6 +3681,7 @@ export default {
     noPermissionRenameFolder: 'Non hai il permesso di rinominare cartelle',
     noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',
     noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',
+    onlyEmptyFoldersDeletable: 'Puoi eliminare solo cartelle vuote',
     noPermissionPrint: 'Non hai il permesso di stampare',
     noPermissionSlice: 'Non hai il permesso di sezionare i file',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',
@@ -3755,6 +3772,9 @@ export default {
     targetParts: 'Parti target',
     targetPartsPlaceholder: 'es., 150',
     targetPartsHelp: 'Totale oggetti necessari',
+    targetSets: 'Copie per file',
+    targetSetsPlaceholder: 'es. 10',
+    targetSetsHelp: 'Quante volte stampare ogni file stampabile',
     tagsLabel: 'Tag (separati da virgola)',
     tagsPlaceholder: 'es., voron, funzionale, regalo',
     dueDate: 'Data scadenza',
@@ -3830,6 +3850,9 @@ export default {
     progress: {
       platesProgress: 'Avanzamento piatti',
       partsProgress: 'Avanzamento parti',
+      setsProgress: 'Set completi',
+      sets: 'set',
+      setsHint: 'Un set è completo quando ogni file stampabile raggiunge questo numero',
       printJobs: 'job di stampa',
       parts: 'parti',
       percentComplete: '{{percent}}% completato',
@@ -3868,6 +3891,8 @@ export default {
       forQuickAccess: 'a questo progetto per accesso rapido.',
       fileCount: '{{count}} file',
       empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
+      printedCount: 'Stampato {{count}} volte',
+      progressTooltip: '{{done}} di {{target}} stampe completate',
       noFiles: 'Nessun file in questa cartella.',
     },
     bom: {
@@ -4035,6 +4060,11 @@ export default {
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     useEmbedded: 'Usa le impostazioni integrate del file',
     useEmbeddedHint: 'Slicia come impostato dal designer (pareti, riempimento, filamento) invece dei profili sopra. Disponibile perché la tua stampante corrisponde a quella del file.',
+    designSettings: 'Mantieni le impostazioni del progettista',
+    designSettingsHint: 'Questo file modifica {{count}} impostazione/i di stampa rispetto al profilo standard.',
+    designSettingsSelected: '{{selected}} di {{total}} selezionate',
+    designSettingsPrinterCoupled: 'specifico della stampante',
+    designSettingsPrinterCoupledHint: 'Tarato per la stampante per cui è stato progettato il file: sulla tua può essere errato o fuori intervallo.',
     enqueuing: 'Invio lavoro di slicing…',
     queued: 'In coda…',
     failed: 'Slicing fallito. Controlla i log del sidecar.',
@@ -4293,6 +4323,8 @@ export default {
     searchBrand: 'Cerca marchio...',
     useCustomBrand: 'Usa "{{brand}}"',
     useCustomMaterial: 'Usa materiale personalizzato: {{material}}',
+    suggestedOptions: 'Suggeriti',
+    allOptions: 'Tutti',
     colorName: 'Nome Colore',
     colorNamePlaceholder: 'Bianco Giada, Rosso Fuoco...',
     color: 'Colore',
@@ -5409,6 +5441,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5420,6 +5453,7 @@ export default {
       callmebot: 'Notifiche WhatsApp gratuite tramite CallMeBot',
       webhook: 'POST HTTP generico verso qualsiasi URL',
       homeassistant: 'Notifiche persistenti nella dashboard di Home Assistant',
+      bark: 'Notifiche push iOS tramite Bark (auto-ospitabile)',
     },
     // NotificationProviderCard
     lastSuccess: 'Ultimo: {{date}}',
@@ -5458,6 +5492,9 @@ export default {
     printStarted: 'Stampa avviata',
     plateNotEmpty: 'Piatto non vuoto',
     plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
+    plateClearRequired: 'Conferma piatto libero',
+    plateClearRequiredDescription: 'Stampa finita, la coda attende conferma',
+    plateClear: 'Piatto libero',
     printCompleted: 'Stampa completata',
     bedCooledLabel: 'Piatto raffreddato',
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
@@ -5553,6 +5590,7 @@ export default {
     add: 'Aggiungi',
     nameRequired: 'Il nome è obbligatorio',
     fieldRequired: '{{field}} è obbligatorio',
+    haDataInvalid: 'Il campo Data deve essere un oggetto JSON valido, ad es. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Numero di telefono',
     apiKey: 'Chiave API',
@@ -6304,7 +6342,8 @@ export default {
       mqtt_auth: {
         title: 'Credenziali stampante',
         pass: 'La stampante ha accettato la connessione.',
-        fail: 'La stampante è raggiungibile ma ha rifiutato la connessione. Il codice di accesso o il numero di serie è molto probabilmente errato. Il codice di accesso cambia ogni volta che la modalità sviluppatore viene attivata/disattivata — ricopialo dallo schermo della stampante.',
+        fail: 'La stampante è raggiungibile ma Bambuddy non è connesso ad essa. Il codice di accesso o il numero di serie è molto probabilmente errato — il codice di accesso cambia ogni volta che la modalità Solo LAN o la modalità sviluppatore viene attivata/disattivata, quindi ricopialo dallo schermo della stampante. Una stampante in fase di riavvio, o che ha già raggiunto il limite di connessioni simultanee, appare allo stesso modo.',
+        fail_auth_rejected: 'La stampante ha rifiutato le credenziali di Bambuddy. Il codice di accesso o il numero di serie è errato — il codice di accesso cambia ogni volta che la modalità Solo LAN o la modalità sviluppatore viene attivata/disattivata. Ricopialo dallo schermo della stampante e salvalo nelle impostazioni della stampante.',
         skip: 'Non verificato — impossibile raggiungere la stampante.',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/ja.ts

@@ -671,6 +671,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'クリックしてHMSエラーを表示',
+    aiDetection: {
+      safe: '安全',
+      warning: '警告',
+      failure: '失敗',
+      idle: '待機中',
+      tooltip: 'AI 失敗検出: {{status}}(スコア {{score}})- クリックで詳細を表示',
+      tooltipIdle: 'AI 失敗検出が有効 - 次の印刷から監視を開始します - クリックで詳細を表示',
+      modalTitle: 'AI 失敗検出 - {{name}}',
+      currentStatus: 'ステータス',
+      score: 'スコア',
+      framesAnalyzed: '解析フレーム数',
+      idleHint: '現在監視中の印刷はありません。次の印刷から自動的に監視を開始します。',
+      lastError: '最後のエラー',
+      openSettings: '設定を開く',
+    },
     estimatedCompletion: '完了予定時刻',
     plateNumber: 'プレート {{number}}',
     slotOptions: 'スロットオプション',
@@ -1467,6 +1482,7 @@ export default {
     periodCost: 'コスト',
     avgPerPrint: '1印刷あたりの平均',
     usageOverTime: '時間推移',
+    energyOverTime: 'エネルギー使用量の推移',
     filamentByWeight: '重量',
     printDuration: '印刷時間分布',
     printerUtilization: 'プリンター稼働率',
@@ -3677,6 +3693,7 @@ export default {
     noPermissionRenameFolder: 'フォルダー名を変更する権限がありません',
     noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',
     noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',
+    onlyEmptyFoldersDeletable: '空のフォルダーのみ削除できます',
     noPermissionPrint: '印刷する権限がありません',
     noPermissionSlice: 'ファイルをスライスする権限がありません',
     noPermissionAddToQueue: 'キューに追加する権限がありません',
@@ -3767,6 +3784,9 @@ export default {
     targetParts: '目標パーツ数',
     targetPartsPlaceholder: '例: 50',
     targetPartsHelp: '必要なオブジェクトの総数',
+    targetSets: 'ファイルごとの部数',
+    targetSetsPlaceholder: '例: 10',
+    targetSetsHelp: '各印刷可能ファイルを印刷する回数',
     tagsLabel: 'タグ(カンマ区切り)',
     tagsPlaceholder: 'カンマ区切りのタグ',
     dueDate: '期限',
@@ -3842,6 +3862,9 @@ export default {
     progress: {
       platesProgress: 'プレート進捗',
       partsProgress: 'パーツ進捗',
+      setsProgress: '完成セット',
+      sets: 'セット',
+      setsHint: 'すべての印刷可能ファイルがこの回数に達するとセットが完成します',
       printJobs: '印刷ジョブ',
       parts: 'パーツ',
       percentComplete: '{{percent}}% 完了',
@@ -3880,6 +3903,8 @@ export default {
       forQuickAccess: 'してクイックアクセスできるようにします。',
       fileCount: '{{count}}ファイル',
       empty: '<空>',
+      printedCount: '{{count}}回印刷済み',
+      progressTooltip: '{{target}}回中{{done}}回の印刷が完了',
       noFiles: 'このフォルダにファイルはありません。',
     },
     bom: {
@@ -4047,6 +4072,11 @@ export default {
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     useEmbedded: 'ファイルに埋め込まれた設定を使用',
     useEmbeddedHint: '上のプロファイルではなく、設計者が設定したとおり(ウォール、インフィル、フィラメント)にスライスします。お使いのプリンターがファイルと一致するため利用できます。',
+    designSettings: '設計者の設定を保持',
+    designSettingsHint: 'このファイルは標準プロファイルから {{count}} 個の印刷設定を変更しています。',
+    designSettingsSelected: '{{total}} 個中 {{selected}} 個を選択',
+    designSettingsPrinterCoupled: 'プリンター固有',
+    designSettingsPrinterCoupledHint: 'このファイルが対象とするプリンター向けに調整された値です。お使いのプリンターでは不適切または範囲外になる場合があります。',
     enqueuing: 'スライスジョブを送信中…',
     queued: '待機中…',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',
@@ -4305,6 +4335,8 @@ export default {
     searchBrand: 'ブランドを検索...',
     useCustomBrand: '「{{brand}}」を使用',
     useCustomMaterial: 'カスタム素材を使用: {{material}}',
+    suggestedOptions: 'おすすめ',
+    allOptions: 'すべて',
     colorName: '色名',
     colorNamePlaceholder: 'ジェイドホワイト、ファイアレッド...',
     color: '色',
@@ -5421,6 +5453,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5432,6 +5465,7 @@ export default {
       callmebot: 'CallMeBot経由の無料WhatsApp通知',
       webhook: '任意のURLへの汎用HTTP POST',
       homeassistant: 'Home Assistantダッシュボードの永続通知',
+      bark: 'Bark経由のiOSプッシュ通知(セルフホスト可能)',
     },
     // NotificationProviderCard
     lastSuccess: '最終: {{date}}',
@@ -5470,6 +5504,9 @@ export default {
     printStarted: '印刷開始',
     plateNotEmpty: 'プレートが空でない',
     plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',
+    plateClearRequired: 'プレート確認が必要',
+    plateClearRequiredDescription: '印刷完了、キューはプレート確認を待機中',
+    plateClear: 'プレート確認',
     printCompleted: '印刷完了',
     bedCooledLabel: 'ベッド冷却済み',
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
@@ -5565,6 +5602,7 @@ export default {
     add: '追加',
     nameRequired: '名前は必須です',
     fieldRequired: '{{field}}は必須です',
+    haDataInvalid: 'Dataフィールドは有効なJSONオブジェクトである必要があります(例: {"priority": "high", "ttl": 0})',
     // Config field labels
     phoneNumber: '電話番号',
     apiKey: 'APIキー',
@@ -6316,7 +6354,8 @@ export default {
       mqtt_auth: {
         title: 'プリンター認証情報',
         pass: 'プリンターが接続を受け入れました。',
-        fail: 'プリンターには到達できますが、接続を拒否されました。アクセスコードまたはシリアル番号が間違っている可能性が高いです。アクセスコードは開発者モードを切り替えるたびに変わります — プリンター画面から再度コピーしてください。',
+        fail: 'プリンターには到達できますが、Bambuddy は接続されていません。アクセスコードまたはシリアル番号が間違っている可能性が高いです — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わるため、プリンター画面から再度コピーしてください。再起動中のプリンターや、同時接続数の上限に達しているプリンターでも同じ表示になります。',
+        fail_auth_rejected: 'プリンターが Bambuddy の認証情報を拒否しました。アクセスコードまたはシリアル番号が間違っています — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わります。プリンター画面から再度コピーし、プリンター設定に保存してください。',
         skip: '未確認 — プリンターに到達できませんでした。',
       },
       developer_mode: {

+ 42 - 3
frontend/src/i18n/locales/ko.ts

@@ -628,6 +628,21 @@ export default {
       chamber: '챔버 팬'
     },
     clickToViewHmsErrors: 'HMS 오류 보기 클릭',
+    aiDetection: {
+      safe: '안전',
+      warning: '경고',
+      failure: '실패',
+      idle: '대기 중',
+      tooltip: 'AI 실패 감지: {{status}} (점수 {{score}}) - 클릭하여 자세히 보기',
+      tooltipIdle: 'AI 실패 감지 활성화됨 - 다음 인쇄부터 모니터링을 시작합니다 - 클릭하여 자세히 보기',
+      modalTitle: 'AI 실패 감지 - {{name}}',
+      currentStatus: '상태',
+      score: '점수',
+      framesAnalyzed: '분석된 프레임',
+      idleHint: '현재 모니터링 중인 인쇄가 없습니다. 다음 인쇄부터 자동으로 모니터링을 시작합니다.',
+      lastError: '마지막 오류',
+      openSettings: '설정 열기'
+    },
     estimatedCompletion: '예상 완료 시간',
     plateNumber: '플레이트 {{number}}',
     slotOptions: '슬롯 옵션',
@@ -1398,6 +1413,7 @@ export default {
     periodCost: '기간 비용',
     avgPerPrint: '인쇄당 평균',
     usageOverTime: '시간별 사용량',
+    energyOverTime: '시간별 에너지 사용량',
     filamentByWeight: '무게',
     printDuration: '인쇄 시간',
     printerUtilization: '프린터 사용률',
@@ -3489,6 +3505,7 @@ export default {
     noPermissionRenameFolder: '폴더 이름 변경 권한이 없습니다',
     noPermissionLinkFolder: '폴더 연결 권한이 없습니다',
     noPermissionDeleteFolder: '폴더 삭제 권한이 없습니다',
+    onlyEmptyFoldersDeletable: '빈 폴더만 삭제할 수 있습니다',
     noPermissionPrint: '인쇄 권한이 없습니다',
     noPermissionAddToQueue: '대기열 추가 권한이 없습니다',
     noPermissionSlice: '파일 슬라이싱 권한이 없습니다',
@@ -3575,6 +3592,9 @@ export default {
     targetParts: '목표 부품',
     targetPartsPlaceholder: '예: 150',
     targetPartsHelp: '필요한 총 개체 수',
+    targetSets: '파일당 복사본 수',
+    targetSetsPlaceholder: '예: 10',
+    targetSetsHelp: '각 인쇄 가능한 파일을 인쇄할 횟수',
     tagsLabel: '태그 (쉼표로 구분)',
     tagsPlaceholder: '예: voron, 기능성, 선물',
     dueDate: '마감일',
@@ -3643,6 +3663,9 @@ export default {
     progress: {
       platesProgress: '플레이트 진행률',
       partsProgress: '부품 진행률',
+      setsProgress: '완성된 세트',
+      sets: '세트',
+      setsHint: '모든 인쇄 가능한 파일이 이 횟수에 도달하면 세트가 완성됩니다',
       printJobs: '인쇄 작업',
       parts: '부품',
       percentComplete: '{{percent}}% 완료',
@@ -3681,6 +3704,8 @@ export default {
       forQuickAccess: '빠른 접근을 위해 이 프로젝트에 연결합니다.',
       fileCount: '{{count}}개 파일',
       empty: '연결된 폴더가 없습니다. 파일 관리자로 이동하여 폴더를 이 프로젝트에 연결하세요.',
+      printedCount: '{{count}}회 인쇄됨',
+      progressTooltip: '{{target}}회 중 {{done}}회 인쇄 완료',
       noFiles: '이 폴더에 파일이 없습니다.'
     },
     bom: {
@@ -3836,6 +3861,11 @@ export default {
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
     useEmbedded: '파일에 포함된 설정 사용',
     useEmbeddedHint: '위 프로필 대신 디자이너가 설정한 대로(벽, 내부 채움, 필라멘트) 슬라이싱합니다. 프린터가 파일과 일치하여 사용할 수 있습니다.',
+    designSettings: '디자이너 설정 유지',
+    designSettingsHint: '이 파일은 기본 프로파일에서 {{count}}개의 출력 설정을 변경합니다.',
+    designSettingsSelected: '{{total}}개 중 {{selected}}개 선택됨',
+    designSettingsPrinterCoupled: '프린터 전용',
+    designSettingsPrinterCoupledHint: '이 파일이 대상으로 한 프린터에 맞춘 값입니다. 사용 중인 프린터에서는 잘못되었거나 범위를 벗어날 수 있습니다.',
     enqueuing: '슬라이싱 작업 제출 중…',
     queued: '대기 중…',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',
@@ -4094,6 +4124,8 @@ export default {
     searchBrand: '브랜드 검색...',
     useCustomBrand: '"{{brand}}" 사용',
     useCustomMaterial: '사용자 지정 재료 사용: {{material}}',
+    suggestedOptions: '추천',
+    allOptions: '전체',
     colorName: '색상 이름',
     colorNamePlaceholder: '제이드 화이트, 파이어 레드...',
     color: '색상',
@@ -5145,7 +5177,8 @@ export default {
       email: '이메일',
       discord: 'Discord',
       webhook: 'Webhook',
-      homeassistant: 'Home Assistant'
+      homeassistant: 'Home Assistant',
+      bark: 'Bark'
     },
     providerDescriptions: {
       email: 'SMTP 이메일 알림',
@@ -5155,7 +5188,8 @@ export default {
       pushover: '간단하고 신뢰할 수 있는 푸시 알림',
       callmebot: 'CallMeBot을 통한 무료 WhatsApp 알림',
       webhook: '모든 URL에 일반 HTTP POST',
-      homeassistant: 'Home Assistant 대시보드의 지속적인 알림'
+      homeassistant: 'Home Assistant 대시보드의 지속적인 알림',
+      bark: 'Bark를 통한 iOS 푸시 알림(셀프 호스팅 가능)'
     },
     lastSuccess: '마지막: {{date}}',
     error: '오류',
@@ -5190,6 +5224,9 @@ export default {
     printStarted: '인쇄 시작됨',
     plateNotEmpty: '플레이트 비어 있지 않음',
     plateNotEmptyDescription: '인쇄 전 개체 감지됨',
+    plateClearRequired: '플레이트 비움 확인 필요',
+    plateClearRequiredDescription: '인쇄 완료, 대기열이 플레이트 확인을 기다림',
+    plateClear: '플레이트 확인',
     printCompleted: '인쇄 완료됨',
     bedCooledLabel: '베드 냉각됨',
     bedCooledDescription: '인쇄 후 베드가 임계값 이하로 냉각됨',
@@ -5278,6 +5315,7 @@ export default {
     add: '추가',
     nameRequired: '이름이 필요합니다',
     fieldRequired: '{{field}}이(가) 필요합니다',
+    haDataInvalid: 'Data 필드는 유효한 JSON 객체여야 합니다(예: {"priority": "high", "ttl": 0})',
     phoneNumber: '전화번호',
     apiKey: 'API 키',
     serverUrl: '서버 URL',
@@ -6385,7 +6423,8 @@ export default {
       mqtt_auth: {
         title: '프린터 자격증명',
         pass: '프린터가 연결을 수락했습니다.',
-        fail: '프린터에 연결됐지만 연결을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다. 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다 — 프린터 화면에서 다시 복사하세요.',
+        fail: '프린터에 도달할 수 있지만 Bambuddy가 연결되지 않았습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경되므로 프린터 화면에서 다시 복사하세요. 재부팅 중이거나 이미 동시 연결 한도에 도달한 프린터도 똑같이 보입니다.',
+        fail_auth_rejected: '프린터가 Bambuddy의 자격증명을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다. 프린터 화면에서 다시 복사한 뒤 프린터 설정에 저장하세요.',
         skip: '확인하지 않음 — 프린터에 연결할 수 없었습니다.'
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: 'Clique para ver erros do HMS',
+    aiDetection: {
+      safe: 'Seguro',
+      warning: 'Aviso',
+      failure: 'Falha',
+      idle: 'Ocioso',
+      tooltip: 'Detecção de Falhas por IA: {{status}} (pontuação {{score}}) - clique para detalhes',
+      tooltipIdle: 'Detecção de Falhas por IA ativada - o monitoramento começa na próxima impressão - clique para detalhes',
+      modalTitle: 'Detecção de Falhas por IA - {{name}}',
+      currentStatus: 'Status',
+      score: 'Pontuação',
+      framesAnalyzed: 'Quadros analisados',
+      idleHint: 'Nenhuma impressão está sendo monitorada no momento. O monitoramento começa automaticamente na próxima impressão.',
+      lastError: 'Último erro',
+      openSettings: 'Abrir configurações',
+    },
     estimatedCompletion: 'Tempo estimado de conclusão',
     plateNumber: 'Placa {{number}}',
     slotOptions: 'Opções de slot',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: 'Custo',
     avgPerPrint: 'Média por impressão',
     usageOverTime: 'Uso ao longo do tempo',
+    energyOverTime: 'Energia ao longo do tempo',
     filamentByWeight: 'Peso',
     printDuration: 'Duração da impressão',
     printerUtilization: 'Utilização da impressora',
@@ -3665,6 +3681,7 @@ export default {
     noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',
     noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
     noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
+    onlyEmptyFoldersDeletable: 'Você só pode excluir pastas vazias',
     noPermissionPrint: 'Você não tem permissão para imprimir',
     noPermissionSlice: 'Você não tem permissão para fatiar arquivos',
     noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
@@ -3755,6 +3772,9 @@ export default {
     targetParts: 'Peças Alvo',
     targetPartsPlaceholder: 'ex., 150',
     targetPartsHelp: 'Total de objetos necessários',
+    targetSets: 'Cópias por arquivo',
+    targetSetsPlaceholder: 'ex.: 10',
+    targetSetsHelp: 'Quantas vezes cada arquivo imprimível deve ser impresso',
     tagsLabel: 'Tags (separadas por vírgula)',
     tagsPlaceholder: 'ex., voron, funcional, presente',
     dueDate: 'Data de Vencimento',
@@ -3830,6 +3850,9 @@ export default {
     progress: {
       platesProgress: 'Progresso das Placas',
       partsProgress: 'Progresso das Peças',
+      setsProgress: 'Conjuntos completos',
+      sets: 'conjuntos',
+      setsHint: 'Um conjunto está completo quando cada arquivo imprimível atinge esse número',
       printJobs: 'Trabalhos de Impressão',
       parts: 'Peças',
       percentComplete: '{{percent}}% concluído',
@@ -3868,6 +3891,8 @@ export default {
       forQuickAccess: 'a este projeto para acesso rápido.',
       fileCount: '{{count}} arquivo(s)',
       empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
+      printedCount: 'Impresso {{count}} vez(es)',
+      progressTooltip: '{{done}} de {{target}} impressões concluídas',
       noFiles: 'Nenhum arquivo nesta pasta.',
     },
     bom: {
@@ -4035,6 +4060,11 @@ export default {
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     useEmbedded: 'Usar as configurações incorporadas do arquivo',
     useEmbeddedHint: 'Fatiar como o designer configurou (paredes, preenchimento, filamento) em vez dos perfis acima. Disponível porque sua impressora corresponde à do arquivo.',
+    designSettings: 'Manter as configurações do designer',
+    designSettingsHint: 'Este arquivo altera {{count}} configuração(ões) de impressão em relação ao perfil padrão.',
+    designSettingsSelected: '{{selected}} de {{total}} selecionadas',
+    designSettingsPrinterCoupled: 'específico da impressora',
+    designSettingsPrinterCoupledHint: 'Ajustado para a impressora para a qual o arquivo foi projetado — pode estar incorreto ou fora de faixa na sua.',
     enqueuing: 'Enviando trabalho de fatiamento…',
     queued: 'Na fila…',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
@@ -4293,6 +4323,8 @@ export default {
     searchBrand: 'Pesquisar marca...',
     useCustomBrand: 'Usar "{{brand}}"',
     useCustomMaterial: 'Usar material personalizado: {{material}}',
+    suggestedOptions: 'Sugeridos',
+    allOptions: 'Todos',
     colorName: 'Nome da Cor',
     colorNamePlaceholder: 'Branco Jade, Vermelho Fogo...',
     color: 'Cor',
@@ -5409,6 +5441,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5420,6 +5453,7 @@ export default {
       callmebot: 'Notificações gratuitas via WhatsApp pelo CallMeBot',
       webhook: 'POST HTTP genérico para qualquer URL',
       homeassistant: 'Notificações persistentes no painel do Home Assistant',
+      bark: 'Notificações push do iOS via Bark (auto-hospedável)',
     },
     // NotificationProviderCard
     lastSuccess: 'Último: {{date}}',
@@ -5458,6 +5492,9 @@ export default {
     printStarted: 'Impressão Iniciada',
     plateNotEmpty: 'Mesa Não Vazia',
     plateNotEmptyDescription: 'Objetos detectados antes da impressão',
+    plateClearRequired: 'Confirmar mesa livre',
+    plateClearRequiredDescription: 'Impressão concluída, a fila aguarda confirmação',
+    plateClear: 'Mesa livre',
     printCompleted: 'Impressão Concluída',
     bedCooledLabel: 'Mesa Resfriada',
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
@@ -5553,6 +5590,7 @@ export default {
     add: 'Adicionar',
     nameRequired: 'Nome é obrigatório',
     fieldRequired: '{{field}} é obrigatório',
+    haDataInvalid: 'O campo Data deve ser um objeto JSON válido, por ex. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Número de Telefone',
     apiKey: 'Chave da API',
@@ -6304,7 +6342,8 @@ export default {
       mqtt_auth: {
         title: 'Credenciais da impressora',
         pass: 'A impressora aceitou a conexão.',
-        fail: 'A impressora está acessível mas recusou a conexão. O código de acesso ou o número de série provavelmente está incorreto. O código de acesso muda toda vez que o Modo Desenvolvedor é alternado — copie-o novamente da tela da impressora.',
+        fail: 'A impressora está acessível mas o Bambuddy não está conectado a ela. O código de acesso ou o número de série provavelmente está incorreto — o código de acesso muda toda vez que o modo Somente LAN ou o Modo Desenvolvedor é alternado, então copie-o novamente da tela da impressora. Uma impressora reiniciando, ou que já atingiu seu limite de conexões simultâneas, aparece do mesmo jeito.',
+        fail_auth_rejected: 'A impressora recusou as credenciais do Bambuddy. O código de acesso ou o número de série está incorreto — o código de acesso muda toda vez que o modo Somente LAN ou o Modo Desenvolvedor é alternado. Copie-o novamente da tela da impressora e salve-o nas configurações da impressora.',
         skip: 'Não verificado — não foi possível alcançar a impressora.',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/ru.ts

@@ -633,6 +633,21 @@ export default {
       chamber: "Вентилятор камеры",
     },
     clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",
+    aiDetection: {
+      safe: "Безопасно",
+      warning: "Предупреждение",
+      failure: "Сбой",
+      idle: "Ожидает",
+      tooltip: "Обнаружение сбоев с помощью ИИ: {{status}} (оценка {{score}}) - нажмите для подробностей",
+      tooltipIdle: "Обнаружение сбоев с помощью ИИ включено - мониторинг начнётся со следующей печати - нажмите для подробностей",
+      modalTitle: "Обнаружение сбоев с помощью ИИ - {{name}}",
+      currentStatus: "Статус",
+      score: "Оценка",
+      framesAnalyzed: "Проанализировано кадров",
+      idleHint: "Сейчас ни одна печать не отслеживается. Мониторинг начнётся автоматически со следующей печати.",
+      lastError: "Последняя ошибка",
+      openSettings: "Открыть настройки",
+    },
     estimatedCompletion: "Расчётное время завершения",
     plateNumber: "Пластина {{number}}",
     slotOptions: "Параметры слота",
@@ -1395,6 +1410,7 @@ export default {
     periodCost: "Стоимость за период",
     avgPerPrint: "В среднем на печать",
     usageOverTime: "Расход по времени",
+    energyOverTime: "Энергия по времени",
     filamentByWeight: "По весу",
     printDuration: "Продолжительность печати",
     printerUtilization: "Загрузка принтеров",
@@ -3481,6 +3497,7 @@ export default {
     noPermissionRenameFolder: "У вас нет прав на переименование папок",
     noPermissionLinkFolder: "У вас нет прав на связывание папок",
     noPermissionDeleteFolder: "У вас нет прав на удаление папок",
+    onlyEmptyFoldersDeletable: "Вы можете удалять только пустые папки",
     noPermissionPrint: "У вас нет прав на запуск печати",
     noPermissionAddToQueue: "У вас нет прав на добавление в очередь",
     noPermissionSlice: "У вас нет прав на нарезку файлов",
@@ -3567,6 +3584,9 @@ export default {
     targetParts: "План по деталям",
     targetPartsPlaceholder: "например, 150",
     targetPartsHelp: "Общее требуемое количество объектов",
+    targetSets: "Копий на файл",
+    targetSetsPlaceholder: "напр. 10",
+    targetSetsHelp: "Сколько раз нужно напечатать каждый печатаемый файл",
     tagsLabel: "Метки (через запятую)",
     tagsPlaceholder: "например, voron, функциональное, подарок",
     dueDate: "Срок",
@@ -3635,6 +3655,9 @@ export default {
     progress: {
       platesProgress: "Выполнение по пластинам",
       partsProgress: "Выполнение по деталям",
+      setsProgress: "Полные комплекты",
+      sets: "комплектов",
+      setsHint: "Комплект готов, когда каждый печатаемый файл достиг этого количества",
       printJobs: "заданий печати",
       parts: "деталей",
       percentComplete: "Выполнено {{percent}}%",
@@ -3673,6 +3696,8 @@ export default {
       forQuickAccess: "с этим проектом для быстрого доступа.",
       fileCount: "Файлов: {{count}}",
       empty: "Связанных папок нет. Откройте файловый менеджер и свяжите папку с проектом.",
+      printedCount: "Напечатано {{count}} раз",
+      progressTooltip: "{{done}} из {{target}} завершённых печатей",
       noFiles: "В этой папке нет файлов.",
     },
     bom: {
@@ -3832,6 +3857,11 @@ export default {
     allPresetsRequired: "Необходимо выбрать все профили",
     useEmbedded: "Использовать встроенные настройки файла",
     useEmbeddedHint: "Нарезать так, как задумал автор модели (стенки, заполнение, филамент), вместо профилей выше. Доступно, потому что ваш принтер совпадает с указанным в файле.",
+    designSettings: "Сохранить настройки автора",
+    designSettingsHint: "Этот файл меняет {{count}} настроек печати по сравнению со стандартным профилем.",
+    designSettingsSelected: "Выбрано {{selected}} из {{total}}",
+    designSettingsPrinterCoupled: "зависит от принтера",
+    designSettingsPrinterCoupledHint: "Значение подобрано для принтера, под который создан файл, — на вашем оно может быть неверным или вне допустимого диапазона.",
     enqueuing: "Отправка задания на нарезку…",
     queued: "В очереди…",
     failed: "Ошибка нарезки. Проверьте журналы вспомогательного сервиса слайсера.",
@@ -4084,6 +4114,8 @@ export default {
     searchBrand: "Поиск бренда…",
     useCustomBrand: "Использовать «{{brand}}»",
     useCustomMaterial: "Использовать свой материал: {{material}}",
+    suggestedOptions: "Рекомендуемые",
+    allOptions: "Все",
     colorName: "Название цвета",
     colorNamePlaceholder: "Jade White, Fire Red…",
     color: "Цвет",
@@ -5133,6 +5165,7 @@ export default {
       discord: "Discord",
       webhook: "Вебхук",
       homeassistant: "Home Assistant",
+      bark: "Bark",
     },
     providerDescriptions: {
       email: "Уведомления по электронной почте через SMTP",
@@ -5143,6 +5176,7 @@ export default {
       callmebot: "Бесплатные уведомления WhatsApp через CallMeBot",
       webhook: "Универсальный HTTP POST-запрос на любой URL",
       homeassistant: "Постоянные уведомления на панели Home Assistant",
+      bark: "iOS push-уведомления через Bark (можно разместить у себя)",
     },
     lastSuccess: "Последняя отправка: {{date}}",
     error: "Ошибка",
@@ -5177,6 +5211,9 @@ export default {
     printStarted: "Печать началась",
     plateNotEmpty: "Пластина не пуста",
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
+    plateClearRequired: "Требуется подтверждение стола",
+    plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
+    plateClear: "Стол свободен",
     printCompleted: "Печать завершена",
     bedCooledLabel: "Стол остыл",
     bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
@@ -5265,6 +5302,7 @@ export default {
     add: "Добавить",
     nameRequired: "Укажите название",
     fieldRequired: "Поле «{{field}}» обязательно",
+    haDataInvalid: 'Поле Data должно быть корректным JSON-объектом, напр. {"priority": "high", "ttl": 0}',
     phoneNumber: "Номер телефона",
     apiKey: "Ключ API",
     serverUrl: "URL сервера",
@@ -5946,7 +5984,8 @@ export default {
       mqtt_auth: {
         title: "Учётные данные принтера",
         pass: "Принтер принял подключение.",
-        fail: "Принтер доступен, но отклонил подключение. Скорее всего, неверны код доступа или серийный номер. Код доступа изменяется при каждом переключении режима разработчика — снова скопируйте его с экрана принтера.",
+        fail: "Принтер доступен, но Bambuddy к нему не подключён. Скорее всего, неверны код доступа или серийный номер — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика, поэтому снова скопируйте его с экрана принтера. Так же выглядит принтер, который перезагружается или уже исчерпал лимит одновременных подключений.",
+        fail_auth_rejected: "Принтер отклонил учётные данные Bambuddy. Код доступа или серийный номер неверны — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика. Снова скопируйте его с экрана принтера и сохраните в настройках принтера.",
         skip: "Не проверено — принтер недоступен.",
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/tr.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS hataları
     clickToViewHmsErrors: 'HMS hatalarını görüntülemek için tıklayın',
+    aiDetection: {
+      safe: 'Güvenli',
+      warning: 'Uyarı',
+      failure: 'Başarısızlık',
+      idle: 'Boşta',
+      tooltip: 'AI Başarısızlık Algılama: {{status}} (puan {{score}}) - ayrıntılar için tıklayın',
+      tooltipIdle: 'AI Başarısızlık Algılama etkin - izleme bir sonraki baskıyla başlar - ayrıntılar için tıklayın',
+      modalTitle: 'AI Başarısızlık Algılama - {{name}}',
+      currentStatus: 'Durum',
+      score: 'Puan',
+      framesAnalyzed: 'Analiz edilen kareler',
+      idleHint: 'Şu anda izlenen bir baskı yok. İzleme bir sonraki baskıyla otomatik olarak başlar.',
+      lastError: 'Son hata',
+      openSettings: 'Ayarları aç',
+    },
     estimatedCompletion: 'Tahmini tamamlanma süresi',
     plateNumber: 'Plaka {{number}}',
     slotOptions: 'Yuva seçenekleri',
@@ -1469,6 +1484,7 @@ export default {
     periodCost: 'Dönem Maliyeti',
     avgPerPrint: 'Baskı Başına Ort.',
     usageOverTime: 'Zaman İçinde Kullanım',
+    energyOverTime: 'Zaman İçinde Enerji',
     filamentByWeight: 'Ağırlık',
     printDuration: 'Baskı Süresi',
     printerUtilization: 'Yazıcı Kullanımı',
@@ -3673,6 +3689,7 @@ export default {
     noPermissionRenameFolder: 'Klasörleri yeniden adlandırma izniniz yok',
     noPermissionLinkFolder: 'Klasörleri bağlama izniniz yok',
     noPermissionDeleteFolder: 'Klasörleri silme izniniz yok',
+    onlyEmptyFoldersDeletable: 'Yalnızca boş klasörleri silebilirsiniz',
     noPermissionPrint: 'Yazdırma izniniz yok',
     noPermissionAddToQueue: 'Kuyruğa ekleme izniniz yok',
     noPermissionSlice: 'Dosyaları dilimleme izniniz yok',
@@ -3762,6 +3779,9 @@ export default {
     targetParts: 'Hedef Parçalar',
     targetPartsPlaceholder: 'örn., 150',
     targetPartsHelp: 'Gereken toplam nesne',
+    targetSets: 'Dosya başına kopya',
+    targetSetsPlaceholder: 'örn. 10',
+    targetSetsHelp: 'Her yazdırılabilir dosyanın kaç kez yazdırılacağı',
     tagsLabel: 'Etiketler (virgülle ayrılmış)',
     tagsPlaceholder: 'örn., voron, fonksiyonel, hediye',
     dueDate: 'Son Tarih',
@@ -3832,6 +3852,9 @@ export default {
     progress: {
       platesProgress: 'Plaka İlerlemesi',
       partsProgress: 'Parça İlerlemesi',
+      setsProgress: 'Tamamlanan setler',
+      sets: 'set',
+      setsHint: 'Her yazdırılabilir dosya bu sayıya ulaştığında bir set tamamlanır',
       printJobs: 'baskı işi',
       parts: 'parça',
       percentComplete: '%{{percent}} tamamlandı',
@@ -3870,6 +3893,8 @@ export default {
       forQuickAccess: 'hızlı erişim için bu projeye.',
       fileCount: '{{count}} dosya',
       empty: 'Bağlı klasör yok. Dosya Yöneticisine gidin ve bu projeye bir klasör bağlayın.',
+      printedCount: '{{count}} kez yazdırıldı',
+      progressTooltip: '{{target}} yazdırmadan {{done}} tanesi tamamlandı',
       noFiles: 'Bu klasörde dosya yok.',
     },
     bom: {
@@ -4037,6 +4062,11 @@ export default {
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
     useEmbedded: 'Dosyanın yerleşik ayarlarını kullan',
     useEmbeddedHint: 'Yukarıdaki profiller yerine tasarımcının ayarladığı gibi (duvarlar, dolgu, filament) dilimle. Yazıcınız dosyayla eşleştiği için sunuluyor.',
+    designSettings: 'Tasarımcının ayarlarını koru',
+    designSettingsHint: 'Bu dosya standart profile göre {{count}} baskı ayarını değiştiriyor.',
+    designSettingsSelected: '{{total}} ayardan {{selected}} tanesi seçili',
+    designSettingsPrinterCoupled: 'yazıcıya özel',
+    designSettingsPrinterCoupledHint: 'Dosyanın tasarlandığı yazıcıya göre ayarlanmış — sizinkinde yanlış veya aralık dışı olabilir.',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     queued: 'Kuyrukta…',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',
@@ -4295,6 +4325,8 @@ export default {
     searchBrand: 'Marka ara...',
     useCustomBrand: '"{{brand}}" kullan',
     useCustomMaterial: 'Özel malzeme kullan: {{material}}',
+    suggestedOptions: 'Önerilen',
+    allOptions: 'Tümü',
     colorName: 'Renk Adı',
     colorNamePlaceholder: 'Yeşim Beyazı, Ateş Kırmızısı...',
     color: 'Renk',
@@ -5382,6 +5414,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     providerDescriptions: {
       email: 'SMTP e-posta bildirimleri',
@@ -5392,6 +5425,7 @@ export default {
       callmebot: "CallMeBot üzerinden ücretsiz WhatsApp bildirimleri",
       webhook: 'Herhangi bir URL\'ye genel HTTP POST',
       homeassistant: 'Home Assistant gösterge panelinde kalıcı bildirimler',
+      bark: 'Bark ile iOS anlık bildirimleri (kendi sunucunuzda barındırılabilir)',
     },
     lastSuccess: 'Son: {{date}}',
     error: 'Hata',
@@ -5426,6 +5460,9 @@ export default {
     printStarted: 'Baskı Başladı',
     plateNotEmpty: 'Plaka Boş Değil',
     plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
+    plateClearRequired: 'Tabla onayı gerekli',
+    plateClearRequiredDescription: 'Baskı bitti, kuyruk tabla onayını bekliyor',
+    plateClear: 'Tabla onayı',
     printCompleted: 'Baskı Tamamlandı',
     bedCooledLabel: 'Tabla Soğudu',
     bedCooledDescription: 'Baskıdan sonra tabla eşiğin altına soğudu',
@@ -5514,6 +5551,7 @@ export default {
     add: 'Ekle',
     nameRequired: 'Ad gerekli',
     fieldRequired: '{{field}} gerekli',
+    haDataInvalid: 'Data alanı geçerli bir JSON nesnesi olmalıdır, örn. {"priority": "high", "ttl": 0}',
     phoneNumber: 'Telefon Numarası',
     apiKey: 'API Anahtarı',
     serverUrl: 'Sunucu URL',
@@ -6255,7 +6293,8 @@ export default {
       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.',
+        fail: 'Yazıcıya erişilebiliyor ancak Bambuddy ona bağlı değil. Büyük olasılıkla erişim kodu veya seri numarası yanlış — erişim kodu, Yalnızca LAN veya Geliştirici Modu her açılıp kapatıldığında değişir, bu yüzden yazıcı ekranından yeniden kopyalayın. Yeniden başlamakta olan veya eşzamanlı bağlantı sınırına ulaşmış bir yazıcı da aynı görünür.',
+        fail_auth_rejected: 'Yazıcı, Bambuddy\'nin kimlik bilgilerini reddetti. Erişim kodu veya seri numarası yanlış — erişim kodu, Yalnızca LAN veya Geliştirici Modu her açılıp kapatıldığında değişir. Yazıcı ekranından yeniden kopyalayın ve yazıcı ayarlarına kaydedin.',
         skip: 'Kontrol edilmedi — yazıcıya erişilemedi.',
       },
       developer_mode: {

+ 6963 - 0
frontend/src/i18n/locales/uk.ts

@@ -0,0 +1,6963 @@
+export default {
+  // Navigation
+  nav: {
+    printers: "Принтери",
+    archives: "Архіви",
+    queue: "Черга друку",
+    stats: "Статистика",
+    profiles: "Профілі",
+    maintenance: "Технічне обслуговування",
+    projects: "Проєкти",
+    inventory: "Філамент",
+    files: "Менеджер файлів",
+    makerworld: "MakerWorld",
+    notifications: "Сповіщення",
+    settings: "Налаштування",
+    system: "Система",
+    collapseSidebar: "Згорнути бічну панель",
+    expandSidebar: "Розгорнути бічну панель",
+    update: "Оновити",
+    updateAvailable: "Доступне оновлення: v{{version}}",
+    updateAvailableBanner: "Доступна версія {{version}}!",
+    viewUpdate: "Переглянути оновлення",
+    viewOnGithub: "Переглянути на GitHub",
+    keyboardShortcuts: "Комбінації клавіш (?)",
+    switchToLight: "Перейти у світлий режим",
+    switchToDark: "Перейти у темний режим",
+    switchToSystem: "Перейти в системний режим",
+    smartSwitches: "Розумні перемикачі",
+    logout: "Вийти",
+    installApp: "Встановити додаток",
+    installAppSuccess: "Bambuddy встановлено",
+  },
+
+  // Common
+  common: {
+    save: "Зберегти",
+    saving: "Збереження...",
+    cancel: "Скасувати",
+    delete: "Видалити",
+    edit: "Редагувати",
+    add: "Додати",
+    close: "Закрити",
+    confirm: "Підтвердити",
+    loading: "Завантаження...",
+    error: "Помилка",
+    errorLoading: "Помилка завантаження даних",
+    retry: "Повторити",
+    success: "Успіх",
+    warning: "Попередження",
+    enabled: "Увімкнено",
+    disabled: "Вимкнено",
+    yes: "Так",
+    no: "Ні",
+    on: "Увімкнено",
+    off: "Вимкнено",
+    all: "Усі",
+    none: "Жодного",
+    search: "Пошук",
+    filter: "Фільтр",
+    sort: "Сортувати",
+    refresh: "Оновити",
+    download: "Завантажити",
+    upload: "Вивантажити",
+    uploading: "Вивантаження...",
+    uploadFailed: "Не вдалося вивантажити",
+    actions: "Дії",
+    status: "Статус",
+    name: "Назва",
+    description: "Опис",
+    date: "Дата",
+    time: "Час",
+    hours: "год",
+    minutes: "хв",
+    seconds: "с",
+    days: "дн.",
+    enable: "Увімкнути",
+    disable: "Вимкнути",
+    permissions: "Дозволи",
+    noPrinters: "Принтери не налаштовані",
+    noData: "Немає даних",
+    linkNotFound: "Посилання не знайдено",
+    required: "Обов’язковий",
+    optional: "Необов’язково",
+    dismiss: "Закрити",
+    apply: "Застосувати",
+    reset: "Скинути",
+    export: "Експортувати",
+    import: "Імпортувати",
+    clear: "Очистити",
+    selectAll: "Вибрати всі",
+    deselectAll: "Зняти вибір із усіх",
+    noChange: "— Без змін —",
+    unchanged: "Без змін",
+    unassigned: "Не призначено",
+    unknown: "Невідомо",
+    unknownError: "Невідома помилка",
+    today: "Сьогодні",
+    tomorrow: "Завтра",
+    asap: "Якнайшвидше",
+    overdue: "Протерміновано",
+    now: "Зараз",
+    collapse: "Згорнути",
+    expand: "Розгорнути",
+    previous: "Попередній",
+    next: "Далі",
+    viewArchive: "Переглянути архів",
+    viewInFileManager: "Переглянути в менеджері файлів",
+    addedBy: "Додав {{username}}",
+    prints: "друків",
+    more: "+ ще {{count}}",
+    ascending: "За зростанням",
+    descending: "За спаданням",
+    back: "Назад",
+    copy: "Копіювати",
+    copied: "Скопійовано!",
+    printer: "Принтер",
+    remove: "Видалити",
+    type: "Тип",
+    print: "Друкувати",
+    rename: "Перейменувати",
+    move: "Перемістити",
+    create: "Створити",
+    duplicate: "Дублювати",
+    left: "Ліворуч",
+    right: "Праворуч",
+  },
+
+  // Printers page
+  printers: {
+    title: "Принтери",
+    addPrinter: "Додати принтер",
+    addPreflight: {
+      checking: "Перевірка підключення...",
+      warning: "Деякі перевірки підключення завершилися невдало. Принтер може відображатися як офлайн. Перегляньте результати нижче, усуньте можливі проблеми або все одно збережіть принтер.",
+      back: "Назад",
+      saveAnyway: "Усе одно зберегти",
+    },
+    editPrinter: "Редагувати принтер",
+    deletePrinter: "Видалити принтер",
+    printerName: "Назва принтера",
+    serialNumber: "Серійний номер",
+    ipAddress: "IP-адреса / ім’я хоста",
+    accessCode: "Код доступу",
+    model: "Модель",
+    nozzleCount: "Кількість сопел",
+    autoArchive: "Автоархів",
+    status: {
+      available: "Доступний",
+      idle: "Неактивний",
+      printing: "Друк",
+      paused: "Призупинено",
+      offline: "Не в мережі",
+      problem: "Проблема",
+      error: "Помилка",
+      finished: "Готово",
+      unknown: "Невідомий",
+    },
+    temperatures: {
+      nozzle: "Сопло",
+      bed: "Стіл",
+      chamber: "Камера",
+    },
+    heaterHistory: {
+      title: "Історія нагрівання",
+      openLabel: "Переглянути історію нагрівання",
+      nozzle: "Сопло",
+      nozzle2: "Сопло 2",
+      bed: "Стіл",
+      chamber: "Камера",
+      error: "Не вдалося завантажити історію",
+      empty: "Дані ще не записані",
+    },
+    progress: "{{percent}}% виконано",
+    timeRemaining: "Залишилося {{time}}",
+    deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"?",
+    maintenanceOk: "Технічне обслуговування в порядку",
+    maintenanceWarning: "{{count}} попередження",
+    maintenanceWarning_plural: "{{count}} попередження",
+    maintenanceDue: "Потрібне обслуговування: {{count}}",
+    maintenanceDue_plural: "Потрібне обслуговування: {{count}}",
+    // Sort options
+    sort: {
+      name: "Назва",
+      status: "Статус",
+      model: "Модель",
+      location: "Розташування",
+      eta: "Орієнтовне завершення",
+      ascending: "Сортувати за зростанням",
+      descending: "Сортувати за спаданням",
+    },
+    // Card size
+    cardSize: {
+      small: "Маленькі картки",
+      medium: "Середні картки",
+      large: "Великі картки",
+      extraLarge: "Дуже великі картки",
+    },
+    pageView: {
+      openCamWallPage: "Відкрити панель камер як окрему сторінку",
+      cards: "Картки",
+      camWall: "Панель камер",
+    },
+    camWall: {
+      page: {
+        tokenRejected:
+          "Це посилання на панель камер більше не дійсне. Можливо, термін дії токена минув або токен було відкликано.",
+        loadFailed: "Не вдалося завантажити список принтерів.",
+      },
+      noPrinters: "Немає принтерів для показу",
+      noSignal: "Немає сигналу",
+      live: "Наживо",
+      snap: "Знімок",
+      off: "Вимкнено",
+      summary: "{{live}} наживо, {{snap}} знімки, {{total}} всього",
+      layer: "Шар {{cur}}/{{total}}",
+      timeLeft: "Залишилося {{time}}",
+      statusMode: {
+        off: "Вимкнено",
+        compact: "Компактний",
+        full: "Повний",
+      },
+      settings: {
+        title: "Налаштування Cam Wall",
+        maxLive: "Максимальна кількість прямих трансляцій",
+        maxLiveHint: "Кількість плиток, що одночасно показують пряму трансляцію. Решта оновлюються як знімки.",
+        snapshotInterval: "Інтервал знімка (секунди)",
+        snapshotIntervalHint: "Як часто плитки без прямої трансляції отримують новий знімок.",
+        statusOverlay: "Накладення стану",
+        statusOverlayHint: "Компактний: лише значок стану. Повний: значок, прогрес, шар і час, що залишився.",
+      },
+    },
+    // Controls
+    hideOffline: "Приховати офлайн-принтери",
+    nextAvailable: "Наступний доступний",
+    powerOn: "Увімкнути",
+    offlinePrintersWithPlugs: "Офлайн-принтери з розумними розетками",
+    noPrintersConfigured: "Принтерів ще не налаштовано",
+    search: "Пошук принтерів...",
+    noSearchResults: "Немає принтерів, які відповідають вашому пошуку або фільтрам",
+    filter: {
+      allStatuses: "Усі статуси",
+      allLocations: "Усі розташування",
+    },
+    toolbar: {
+      filters: "Фільтри",
+      view: "Вигляд",
+      actions: "Дії",
+    },
+    // Printer card
+    readyToPrint: "Готовий до друку",
+    external: "Зовнішній",
+    extL: "Ext-L",
+    extR: "Ext-R",
+    deleteArchives: "Видалити архіви друку",
+    noLabel: "Без етикетки",
+    printPreview: "Попередній перегляд",
+    width: "Ширина",
+    height: "Висота",
+    noObjectsFound: "Об’єктів не знайдено",
+    objectsLoadedOnPrintStart: "Об’єкти завантажуються після початку друку",
+    willBeSkipped: "Буде пропущено",
+    name: "Назва",
+    serialCannotBeChanged: "Серійний номер не можна змінити",
+    locationHelp: "Використовується для групування принтерів і фільтрації завдань у черзі",
+    // WiFi signal strength
+    wifiSignal: {
+      veryWeak: "Дуже слабкий",
+      weak: "Слабкий",
+      fair: "Задовільний",
+      good: "Добрий",
+      excellent: "Чудово",
+    },
+    // Maintenance
+    maintenanceUpToDate: "Усі роботи з технічного обслуговування виконано — натисніть, щоб переглянути",
+    // Maintenance Mode (#1476) — operator-flipped "out of service" state.
+    // Distinct from the scheduled-maintenance dashboard above; this one
+    // wraps the backend is_active flag and stops MQTT + queue dispatch.
+    maintenance: {
+      title: "На технічному обслуговуванні",
+      subtitle: "Роботу цього принтера призупинено: він не підключається, не отримує завдань із черги й не надсилає сповіщень.",
+      pillLabel: "Технічне обслуговування",
+      exitButton: "Вийти з технічного обслуговування",
+      menuEnter: "Увійти в режим обслуговування",
+      menuExit: "Вийти з режиму обслуговування",
+      toastEntered: "{{name}} зараз у режимі обслуговування",
+      toastExited: "{{name}} знову в мережі",
+      confirmMidPrintTitle: "Перевести принтер у режим обслуговування під час друку?",
+      confirmMidPrintMessage: "{{name}} зараз друкує. Перехід у режим обслуговування відключить MQTT і припинить відстеження перебігу друку та сповіщення про завершення цього завдання. Продовжити?",
+      editFieldLabel: "Режим обслуговування",
+      editFieldHelp: "Коли режим увімкнено, принтер відключається від MQTT, не отримує завдань із черги й не надсилає сповіщень. Використовуйте цей режим під час ремонту, паралельного розгортання Bambuddy або тимчасового виведення принтера з роботи.",
+    },
+    // Chamber light
+    chamberLightOn: "Увімкнути світло в камері",
+    chamberLightOff: "Вимкнути світло в камері",
+    // Files
+    files: "Файли",
+    browseFiles: "Переглянути файли принтера",
+    // Smart plug
+    autoOffAfterPrint: "Автоматичне вимкнення після друку",
+    autoOffExecuted: "Автоматичне вимкнення виконано — увімкніть принтер, щоб скинути стан",
+    // HMS errors
+    hmsErrors: "Помилки HMS",
+    viewHmsErrors: "Переглянути помилки HMS ({{count}})",
+    // Actions
+    resume: "Відновити",
+    pause: "Призупинити",
+    stop: "Зупинити",
+    camera: "Камера",
+    skipObject: "Пропустити об’єкт",
+    reconnect: "Підключитися знову",
+    forceRefresh: "Примусово оновити",
+    forceRefreshSuccess: "Оновлення запитано",
+    mqttDebug: "Налагодження MQTT",
+    printerInformation: "Інформація про принтер",
+    copyToClipboard: "Копіювати",
+    copied: "Скопійовано!",
+    state: "Стан",
+    wifiSignalLabel: "Сигнал Wi‑Fi",
+    developerMode: "Режим розробника",
+    enabled: "Увімкнено",
+    disabled: "Вимкнено",
+    addedOn: "Додано",
+    sdCard: "SD-картка",
+    inserted: "Вставлено",
+    notInserted: "Не вставлено",
+    totalPrintHours: "Загальний час друку",
+    activeNozzle: "Активне сопло: {{nozzle}}",
+    nozzleRack: "Стійка сопел",
+    nozzleDocked: "У док-станції",
+    nozzleMounted: "Установлено",
+    nozzleActive: "Активний",
+    nozzleIdle: "Неактивний",
+    nozzleDiameter: "Діаметр",
+    nozzleType: "Тип",
+    nozzleStatus: "Статус",
+    nozzleFilament: "Філамент",
+    nozzleWear: "Знос",
+    nozzleMaxTemp: "Макс. температура",
+    nozzleSerial: "Серійний номер",
+    nozzleHardenedSteel: "Загартована сталь",
+    nozzleStainlessSteel: "Нержавіюча сталь",
+    nozzleTungstenCarbide: "Карбід вольфраму",
+    nozzleFlow: "Потік",
+    nozzleHighFlow: "Високий потік",
+    nozzleStandardFlow: "Стандартний",
+    // Firmware
+    firmwareUpdate: "Оновлення прошивки",
+    firmwareInstructions: "На сенсорному екрані принтера перейдіть до",
+    firmwareNav: "Перейдіть до",
+    settings: "Налаштування",
+    firmware: "Прошивка",
+    // Discovery
+    discoverPrinters: "Знайти принтери",
+    searching: "Пошук...",
+    manualEntry: "Ввести вручну",
+    addFromCloud: "Додати з хмари",
+    // Toast messages
+    toast: {
+      printerDeleted: "Принтер видалено",
+      missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
+      assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
+      assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
+      assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
+      printerAdded: "Додано принтер",
+      printerUpdated: "Принтер оновлено",
+      failedToDelete: "Не вдалося видалити принтер",
+      failedToAdd: "Не вдалося додати принтер",
+      connectionFailedNotAdded: "Не вдалося підключитися до принтера. Перевірте IP, серійний номер і код доступу, а також підтвердьте, що режим лише для LAN увімкнено. Принтер не додано.",
+      failedToUpdate: "Не вдалося оновити принтер",
+      commandSent: "Команда надіслана",
+      failedToSendCommand: "Не вдалося надіслати команду",
+      turnedOn: "{{name}} увімкнено",
+      failedToPowerOn: "Не вдалося ввімкнути {{name}}",
+      scriptTriggered: "Сценарій запущено",
+      printStopped: "Друк зупинено",
+      printPaused: "Друк призупинено",
+      printResumed: "Друк відновлено",
+      referenceDeleted: "Еталонне зображення видалено",
+      detectionAreaSaved: "Область виявлення збережено",
+      failedToRunScript: "Не вдалося запустити сценарій",
+      failedToStopPrint: "Не вдалося зупинити друк",
+      failedToPausePrint: "Не вдалося призупинити друк",
+      failedToResumePrint: "Не вдалося відновити друк",
+      failedToControlChamberLight: "Не вдалося керувати світлом камери",
+      failedToSetSpeed: "Не вдалося встановити швидкість друку",
+      failedToUpdateSetting: "Не вдалося оновити налаштування",
+      failedToSkipObjects: "Не вдалося пропустити об’єкти",
+      failedToRereadRfid: "Не вдалося перечитати RFID",
+      failedToCheckPlate: "Не вдалося перевірити друкарську пластину",
+      failedToUpdateLabel: "Не вдалося оновити мітку",
+      failedToDeleteReference: "Не вдалося видалити еталонне зображення",
+      failedToSaveDetectionArea: "Не вдалося зберегти область виявлення",
+      plateCheckEnabled: "Перевірку друкарської пластини ввімкнено",
+      plateCheckDisabled: "Перевірку друкарської пластини вимкнено",
+      calibrationSaved: "Калібрування збережено!",
+      calibrationFailed: "Не вдалося виконати калібрування",
+      rfidRereadInitiated: "Розпочато повторне зчитування RFID",
+      loadInitiated: "Завантаження філаменту...",
+      unloadInitiated: "Вивантаження філаменту...",
+      failedToLoad: "Не вдалося завантажити філамент",
+      failedToUnload: "Не вдалося вивантажити філамент",
+    },
+    // Connection status
+    connection: {
+      connected: "Підключено",
+      offline: "Не в мережі",
+    },
+    plateStatus: {
+      markCleared: "Позначити друкарську пластину як очищену",
+      cleared: "Друкарську пластину очищено",
+      notCleared: "Друкарську пластину не очищено",
+      inUse: "Друкарська пластина використовується",
+    },
+    // Queue info
+    queue: {
+      inQueue: "У черзі: {{count}}",
+      inQueue_plural: "У черзі: {{count}}",
+    },
+    // Controls section
+    controls: "Елементи керування",
+    // RFID
+    rfid: {
+      reread: "Перечитати RFID",
+    },
+    // AMS load/unload (#891)
+    ams: {
+      load: "Завантажити",
+      unload: "Вивантажити",
+    },
+    bedJog: {
+      limitWarning: "Під час ручного переміщення межі ходу не контролюються: через помилку прошивки Bambu віддалені команди ігнорують програмні кінцеві обмежувачі. Переміщуйте стіл обережно, щоб уникнути зіткнення.",
+      title: "Керування переміщенням",
+      bed: "Стіл",
+      step: "Крок (мм)",
+      up: "Перемістити стіл угору",
+      down: "Перемістити стіл униз",
+      disabledWhilePrinting: "Вимкнено під час друку",
+      notHomedTitle: "Принтер не відкалібровано за початковою позицією",
+      notHomedMessage: "Після останнього друку принтер не виконував пошук початкової позиції. Для безпечного позиціювання спочатку запустіть автопозиціювання: принтер припаркує друкувальну головку та визначить початкові координати X, Y і Z. Або продовжте переміщення без нього — програмні кінцеві обмежувачі буде обійдено.",
+      homeZ: "Запустити автопозиціювання",
+      moveAnyway: "Усе одно перемістити",
+      homingStarted: "Виконується автопозиціювання принтера…",
+    },
+    // Permissions
+    permission: {
+      noAdd: "Ви не маєте дозволу додавати принтери",
+      noEdit: "Ви не маєте дозволу редагувати принтери",
+      noDelete: "Ви не маєте дозволу на видалення принтерів",
+      noControl: "Ви не маєте дозволу керувати принтерами",
+      noFiles: "Ви не маєте дозволу на доступ до файлів принтера",
+      noAmsRfid: "Ви не маєте дозволу на повторне читання AMS RFID",
+      noSmartPlugControl: "Ви не маєте дозволу керувати розумними розетками",
+      noCamera: "У вас немає дозволу на перегляд камер",
+    },
+    // Add/Edit modal
+    modal: {
+      addTitle: "Додати принтер",
+      editTitle: "Редагувати принтер",
+      myPrinter: "Мій принтер",
+      selectModel: "Вибрати модель…",
+      locationGroup: "Розташування/група (необов’язково)",
+      locationPlaceholder: "наприклад, майстерня, офіс, підвал",
+      autoArchiveLabel: "Автоматичне архівування завершених роздруківок",
+      fromPrinterSettings: "З налаштувань принтера",
+      modelOptional: "Модель (необов’язково)",
+      saveChanges: "Зберегти зміни",
+    },
+    // Skip objects
+    skipObjects: {
+      tooltip: "Пропустити об’єкти",
+      onlyWhilePrinting: "Пропустити об’єкти (лише під час друку)",
+      requiresMultiple: "Пропустити об’єкти (потрібно щонайменше 2 об’єкти)",
+      title: "Пропустити об’єкти",
+      matchIdsInfo: "Зіставте ідентифікатори з дисплеєм принтера",
+      printerShowsIds: "На екрані принтера показано ідентифікатори об’єктів на друкарській пластині",
+      skipSelected: "Пропустити вибране",
+      skipping: "Пропускання…",
+      noObjectsSelected: "Об’єкти не вибрано",
+      selectObjectsToSkip: "Виберіть об’єкти, які потрібно пропустити під час поточного друку",
+      skipped: "пропущено",
+      objectsSkipped: "Об’єкти пропущено",
+      activeCount: "Активних: {{count}}",
+      waitForLayer: "Щоб пропускати об’єкти, дочекайтеся другого шару або пізніше (зараз шар {{layer}})",
+      skip: "Пропустити",
+      confirmTitle: "Пропустити об’єкт?",
+      confirmMessage: "Ви впевнені, що бажаєте пропустити \"{{name}}\"? Це неможливо скасувати.",
+      confirmAllMessage: "Вибрано всі об’єкти, що залишилися. Це зупинить завдання друку. Продовжити?",
+      confirmMultipleMessage: "Пропустити вибрані об’єкти ({{count}})? Цю дію неможливо скасувати.",
+    },
+    // Confirm modals
+    confirm: {
+      deleteTitle: "Видалити принтер",
+      deleteMessage: "Ви впевнені, що хочете видалити \"{{name}}\"? Це призведе до видалення всіх налаштувань підключення.",
+      deleteArchivesNote: "Усю історію друку для цього принтера буде остаточно видалено.",
+      keepArchivesNote: "Історія друку буде збережена, але більше не пов’язана з цим принтером.",
+      stopTitle: "Зупинити друк",
+      stopMessage: "Ви впевнені, що хочете зупинити поточний друк на \"{{name}}\"? Це призведе до скасування завдання друку.",
+      stopButton: "Зупинити друк",
+      pauseTitle: "Призупинити друк",
+      pauseMessage: "Ви впевнені, що хочете призупинити поточний друк на \"{{name}}\"?",
+      pauseButton: "Призупинити друк",
+      resumeTitle: "Відновити друк",
+      resumeMessage: "Ви впевнені, що бажаєте відновити друк на \"{{name}}\"?",
+      resumeButton: "Відновити друк",
+      powerOnTitle: "Увімкнути принтер",
+      powerOnMessage: "Увімкнути живлення принтера «{{name}}»?",
+      powerOnButton: "Увімкнути",
+      powerOffTitle: "Вимкнути принтер",
+      powerOffMessage: "Вимкнути живлення принтера «{{name}}»?",
+      powerOffWarning: "УВАГА: «{{name}}» зараз друкує. Вимкнення живлення перерве друк і може пошкодити принтер. Продовжити?",
+      powerOffButton: "Вимкнути",
+      haToggleTitle: "Перемкнути \"{{name}}\"",
+      haToggleMessage: "Перемкнути об’єкт Home Assistant {{entity}}? Це може вимкнути живлення, якщо воно зараз увімкнено.",
+      haToggleWarning: "УВАГА: \"{{name}}\" зараз друкується! Перемикання {{entity}} може вимкнути живлення та перервати друк. Продовжити?",
+      haToggleButton: "Перемкнути",
+    },
+    // Bulk actions
+    bulk: {
+      select: "Вибрати",
+      selectAll: "Вибрати усі",
+      selectByLocation: "Вибрати за розташуванням",
+      selected: "Вибрано: {{count}}",
+      actions: {
+        stop: "Зупинити",
+        pause: "Призупинити",
+        resume: "Відновити",
+        clearPlate: "Очистити стіл",
+        clearHMS: "Очистити сповіщення",
+      },
+      confirm: {
+        stopTitle: "Зупинити завдання друку: {{count}}",
+        stopMessage: "Активний друк буде скасовано на вибраних принтерах ({{count}}). Цю дію неможливо скасувати.",
+        stopButton: "Зупинити все",
+        pauseTitle: "Призупинити завдання друку: {{count}}",
+        pauseMessage: "Активний друк буде призупинено на вибраних принтерах ({{count}}).",
+        pauseButton: "Призупинити все",
+        clearPlateTitle: "Очистити столи ({{count}})",
+        clearPlateMessage: "Столи вибраних принтерів ({{count}}) буде позначено очищеними, що може запустити завдання з черги.",
+        clearPlateButton: "Очистити все",
+      },
+      success: "Дію «{{action}}» виконано на принтерах: {{count}}",
+      partial: "{{succeeded}} вдалося, {{failed}} не вдалося",
+      noneApplicable: "Немає вибраних принтерів у належному стані для цієї дії",
+      selectByState: "Вибрати за станом",
+    },
+    // Discovery
+    discovery: {
+      title: "Знайти принтери",
+      searching: "Пошук...",
+      scanning: "Сканування...",
+      scanProgress: "Сканування... {{scanned}}/{{total}}",
+      foundPrinters: "Знайдено принтерів: {{count}}",
+      noPrintersFound: "Принтерів не знайдено",
+      noPrintersFoundSubnet: "Немає принтерів у вказаній підмережі.",
+      noPrintersFoundNetwork: "У мережі не знайдено принтерів.",
+      allConfigured: "Усі знайдені принтери вже налаштовані.",
+      alreadyAdded: "Вже додано",
+      select: "Вибрати",
+      manualEntry: "Ввести вручну",
+      addFromCloud: "Додати з хмари",
+      subnetToScan: "Підмережа для сканування",
+      dockerNote: "Виявлено Docker. Введіть підмережу принтера в нотації CIDR. У docker-compose.yml потрібен параметр network_mode: host.",
+      scanSubnet: "Сканувати підмережу для принтерів",
+      discoverNetwork: "Знайти принтери в мережі",
+      scanningSubnet: "Сканування підмережі для принтерів Bambu...",
+      scanningNetwork: "Сканування мережі...",
+      serialRequired: "Потрібен серійний номер",
+      unknown: "Невідомий",
+      failedToStart: "Не вдалося запустити пошук",
+      customSubnetOption: "Спеціальна підмережа...",
+      customSubnetLabel: "Спеціальна підмережа (CIDR)",
+      customSubnetNote: "Укажіть власну підмережу, якщо принтер перебуває в іншій мережі, ніж сервер. Порти FTP (990) і MQTT (8883) мають бути доступні через маршрутизатор.",
+    },
+    // AMS Drying
+    drying: {
+      start: "Почати сушіння",
+      stop: "Зупинити сушіння",
+      temperature: "Температура",
+      duration: "Тривалість",
+      hours: "години",
+      timeRemaining: "Залишилося {{time}}",
+      active: "Сушіння",
+      targetSummary: "{{filament}} при {{temp}}°C",
+      notSupported: "Сушіння не підтримується",
+      powerRequired: "Підключіть адаптер живлення AMS, щоб увімкнути сушіння",
+      startingDrying: "Запуск сушіння…",
+      toastCommandSent: "Надіслано команду сушіння",
+      toastStopped: "Сушіння зупинено",
+      toastNotStarted: "Принтер прийняв команду, але AMS так і не почав сушити. Перевірте, чи підключено адаптер живлення AMS і чи принтер неактивний.",
+      screenOnly: "Сушінням AMS на цьому принтері можна керувати лише з екрана принтера (обмеження Bambu)",
+      stoppingDrying: "Припинення сушіння...",
+      rotateTray: "Обертати котушку під час сушіння",
+      rotateUnavailableReason: "Недоступно: філамент із цього слота AMS завантажено в інструментальну головку. Котушка заблокована подавальною трубкою й не може обертатися. Спочатку вивантажте філамент.",
+    },
+    // AMS Filament Backup status badge (printer-wide auto-switch to another spool)
+    amsBackup: {
+      titleOn: "Резервне перемикання філаменту AMS увімкнено. Натисніть, щоб вимкнути.",
+      titleOff: "Резервне перемикання філаменту AMS вимкнено. Натисніть, щоб увімкнути.",
+      titleUnknown: "Стан резервної котушки AMS недоступний на цьому принтері.",
+      toastEnabled: "Резервну котушку AMS ввімкнено",
+      toastDisabled: "Резервну котушку AMS вимкнено",
+      modalTitle: "Резервне перемикання філаменту AMS",
+      modalHelp: "Коли філамент в активному слоті закінчується, принтер по черзі перемикається на слоти з таким самим профілем і кольором у вказаному порядку.",
+      modalNoSlots: "Філамент не завантажено.",
+      modalNoPairs: "Немає резервних пар — немає двох слотів з однаковим профілем і кольором філаменту.",
+      extruderRightShort: "Р",
+      extruderLeftShort: "Л",
+      stateOn: "Увімкнено",
+      stateOff: "Вимкнено",
+      stateUnknown: "Не підтримується на цьому принтері",
+    },
+    activeJobSlot: {
+      title: "Цей слот відповідає філаменту {{n}} в поточному завданні друку",
+      ariaLabel: "Слот {{n}} поточного завдання друку",
+    },
+    expectedSlot: {
+      title: "Принтер очікує філамент у цьому слоті",
+      ariaLabel: "Очікуваний слот для філаменту {{n}}",
+      label: "{{ams}} · Слот {{slot}}",
+      external: "Зовнішня котушка",
+    },
+    // Filaments section
+    filaments: "Філаменти",
+    // Camera
+    openCameraOverlay: "Відкрити накладання камери",
+    openCameraWindow: "Відкрити камеру в новому вікні",
+    // Firmware
+    firmwareUpdateAvailable: "Доступне оновлення прошивки: {{current}} → {{latest}}",
+    firmwareUpToDate: "Прошивка {{version}} актуальна",
+    firmwareUpdateButton: "Оновити",
+    // Plate detection
+    plateDetection: {
+      noPermission: "Ви не маєте дозволу на оновлення принтерів",
+      enabledClick: "Перевірку друкарської пластини ввімкнено — натисніть, щоб вимкнути",
+      disabledClick: "Перевірку друкарської пластини вимкнено — натисніть, щоб увімкнути",
+      manageCalibration: "Керувати калібруванням перевірки пластини",
+      calibrationRequired: "Потрібне калібрування",
+      calibrationInstructions: "Переконайтеся, що робоча пластина <strong>повністю порожня</strong>, а потім натисніть «Калібрувати».",
+      calibrationDescription: "Під час калібрування створюється еталонне зображення порожньої пластини. Наступні перевірки порівнюватимуть з ним зображення, щоб виявляти об’єкти.",
+      calibrationTip: "<strong>Порада:</strong> Ви можете зберегти до 5 калібрувань для різних пластин. Під час перевірки система автоматично використовує найкращий збіг.",
+      plateEmpty: "Друкарська пластина виглядає порожньою",
+      objectsDetected: "На пластині виявлено об’єкти",
+      confidence: "Впевненість",
+      difference: "Різниця",
+      analysisPreview: "Попередній перегляд аналізу:",
+      analysisLegend: "Зелена рамка = зона виявлення, червона накладка = відмінності від калібрування",
+      savedReferences: "Збережені еталонні зображення ({{count}}/{{max}})",
+      deleteReference: "Видалити еталонне зображення",
+      labelPlaceholder: "Мітка...",
+      clickToEdit: "{{label}} - Натисніть, щоб редагувати",
+      clickToAddLabel: "Натисніть, щоб додати мітку",
+    },
+    // Speed
+    speed: {
+      title: "Швидкість друку",
+      silent: "Тихий (50%)",
+      standard: "Стандарт (100%)",
+      sport: "Sport (124%)",
+      ludicrous: "Ludicrous (166%)",
+    },
+    airduct: {
+      title: "Режим повітропроводу",
+      cooling: "Охолодження",
+      heating: "Опалення",
+    },
+    noSdCard: "Немає SD-картки",
+    door: {
+      open: "Відчинено",
+      closed: "Закрито",
+    },
+    // Fans
+    fans: {
+      partCooling: "Вентилятор охолодження моделі",
+      auxiliary: "Допоміжний вентилятор",
+      chamber: "Камерний вентилятор",
+    },
+    // HMS errors
+    clickToViewHmsErrors: "Натисніть, щоб переглянути помилки HMS",
+    aiDetection: {
+      safe: "Безпечно",
+      warning: "Попередження",
+      failure: "Помилка",
+      idle: "Очікування",
+      tooltip: "Виявлення помилок за допомогою ШІ: {{status}} (оцінка {{score}}) — натисніть, щоб переглянути деталі",
+      tooltipIdle: "Виявлення помилок за допомогою ШІ ввімкнено — моніторинг почнеться з наступним друком — натисніть, щоб переглянути деталі",
+      modalTitle: "Виявлення помилок за допомогою ШІ — {{name}}",
+      currentStatus: "Стан",
+      score: "Оцінка",
+      framesAnalyzed: "Проаналізовано кадрів",
+      idleHint: "Зараз жоден друк не відстежується. Моніторинг автоматично почнеться з наступним друком.",
+      lastError: "Остання помилка",
+      openSettings: "Відкрити налаштування",
+    },
+    estimatedCompletion: "Орієнтовний час виконання",
+    plateNumber: "Пластина {{number}}",
+    slotOptions: "Опції слота",
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: "Назва AMS",
+      friendlyNamePlaceholder: "наприклад, Основна AMS",
+      serialNumber: "Серійний номер",
+      firmwareVersion: "Прошивка",
+      save: "Зберегти",
+      clear: "Очистити",
+      noEditPermission: "Ви не маєте дозволу на перейменування одиниць AMS.",
+    },
+    // Firmware modal
+    firmwareModal: {
+      title: "Оновлення прошивки",
+      titleUpToDate: "Інформація про прошивку",
+      currentVersion: "Поточна:",
+      latestVersion: "Найновіша:",
+      releaseNotes: "Примітки до випуску",
+      checkingPrereqs: "Перевірка передумов...",
+      sdCardReady: "SD-картка готова. Натисніть нижче, щоб вивантажити на неї прошивку.",
+      uploadedSuccess: "Прошивку вивантажено на картку SD!",
+      applyInstructions: "Щоб застосувати оновлення до свого принтера:",
+      step1: "На сенсорному екрані принтера перейдіть до <strong>Налаштування</strong>",
+      step2: "Перейдіть до розділу <strong>Прошивка</strong>",
+      step3: "Виберіть <strong>Оновити з SD-картки</strong>",
+      step4: "Оновлення займе 10-20 хвилин",
+      done: "Готово",
+      starting: "Запуск…",
+      uploadFirmware: "Вивантажити прошивку",
+      uploadFailed: "Не вдалося почати вивантаження: {{error}}",
+      uploadedToast: "Прошивку вивантажено! Запустіть оновлення з екрана принтера.",
+      availableVersions: "Доступні версії",
+      usable: "Можна використовувати",
+      unavailable: "Недоступна",
+      installed: "Установлена",
+      newerBadge: "новіший",
+      olderBadge: "старіша",
+      currentBadge: "поточна",
+    },
+    accessCodePlaceholder: "Залиште порожнім, щоб не змінювати",
+    // ROI editor
+    roi: {
+      title: "Область виявлення (ROI)",
+      xStart: "Початок X",
+      yStart: "Початок Y",
+      width: "Ширина",
+      height: "Висота",
+      instruction: "Відрегулюйте область виявлення, щоб сфокусуватися на робочій пластині. Зелене поле в попередньому перегляді показує поточну область.",
+    },
+    developerModeWarning: "Режим розробника LAN не ввімкнено на: {{names}}. Деякі функції можуть не працювати.",
+    howToEnable: "Як увімкнути",
+    incompatibleFile: "Цей файл нарізано для {{slicedFor}}, а модель цього принтера — {{printerModel}}",
+    dropNotPrintable: "Можна роздрукувати лише файли .gcode і .gcode.3mf.",
+    dropToPrint: "Відпустіть для друку",
+    cannotPrint: "Принтер зайнятий",
+  },
+
+  // Archives page
+  archives: {
+    title: "Архіви друку",
+    no3mfBanner: {
+      title: "Деякі нещодавні роздруківки не вдалося заархівувати з мініатюрами",
+      body: "Слайсер не залишив файл .gcode.3mf на SD-картці принтера, тому Bambuddy не зміг отримати мініатюру або метадані слайсера. Зазвичай це стається, коли в слайсері вимкнено параметр «Зберігати надіслані файли на зовнішньому накопичувачі» (вкладка «Пристрій» у Bambu Studio або OrcaSlicer).",
+      docsLink: "Переглянути крок 4 встановлення",
+      dismissLabel: "Відхилити це повідомлення",
+    },
+    searchPlaceholder: "Пошук в архівах...",
+    filterByPrinter: "Фільтрувати за принтером",
+    filterByStatus: "Фільтрувати за статусом",
+    sortBy: "Сортувати за",
+    sortNewest: "Спочатку найновіші",
+    sortOldest: "Спочатку найстаріші",
+    sortName: "Назва",
+    sortDuration: "Тривалість",
+    sortLargest: "Спочатку найбільші",
+    sortSmallest: "Спочатку найменші",
+    sortSize: "Розмір",
+    noArchives: "Архіви не знайдено",
+    noArchivesSearch: "Жоден архів не відповідає вашому пошуку",
+    originalPrintNotVisible: "Оригінальний друк не відображається — спробуйте очистити фільтри",
+    noArchivesYet: "Архівів ще немає",
+    prints: "друків",
+    pagination: {
+      showing: "Показано",
+      to: "до",
+      of: "з",
+      show: "Показати",
+      page: "Сторінка",
+      all: "Усі",
+    },
+    loadingArchives: "Завантаження архівів...",
+    releaseToUpload: "Відпустіть, щоб вивантажити",
+    showAll: "Показати все",
+    showFavoritesOnly: "Показати лише вибране",
+    gridView: "Вигляд сітки",
+    listView: "Перегляд списку",
+    calendarView: "Перегляд календаря",
+    logView: "Журнал друку",
+    manageTags: "Керування тегами",
+    showFailedPrints: "Показати невдалі друки",
+    hideFailedPrints: "Приховати невдалі друки",
+    hideDuplicates: "Приховати дублікати",
+    viewOriginalPrint: "Клацніть, щоб переглянути оригінальний друк (#{{id}})",
+    printTime: "Час друку",
+    filamentUsed: "Використано філаменту",
+    cost: "Вартість",
+    preview: "Попередній перегляд",
+    deleteArchive: "Видалити архів",
+    deleteConfirm: "Ви впевнені, що хочете видалити цей архів?",
+    favorite: "В обраному",
+    unfavorite: "Видалити з вибраного",
+    viewDetails: "Переглянути деталі",
+    status: {
+      completed: "Завершено",
+      failed: "Невдало",
+      stopped: "Зупинено",
+    },
+    toast: {
+      source3mfAttached: "Вихідний файл 3MF долучено: {{filename}}",
+      failedUploadSource3mf: "Не вдалося вивантажити вихідний файл 3MF",
+      source3mfRemoved: "Вихідний файл 3MF видалено",
+      failedRemoveSource3mf: "Не вдалося видалити вихідний файл 3MF",
+      f3dAttached: "F3D долучено: {{filename}}",
+      failedUploadF3d: "Не вдалося вивантажити F3D",
+      f3dRemoved: "F3D видалено",
+      failedRemoveF3d: "Не вдалося видалити F3D",
+      timelapseAttached: "Додано покадрову зйомку: {{filename}}",
+      timelapseAlreadyAttached: "Таймлапс уже долучено",
+      noMatchingTimelapse: "Відповідного таймлапсу не знайдено",
+      failedScanTimelapse: "Не вдалося відсканувати покадрову зйомку",
+      failedAttachTimelapse: "Не вдалося долучити таймлапс",
+      timelapseRemoved: "Таймлапс видалено",
+      failedRemoveTimelapse: "Не вдалося видалити таймлапс",
+      timelapseUploaded: "Таймлапс вивантажено: {{filename}}",
+      failedUploadTimelapse: "Не вдалося вивантажити таймлапс",
+      archiveDeleted: "Архів видалено",
+      failedDeleteArchive: "Не вдалося видалити архів",
+      addedToFavorites: "Додано до обраних",
+      removedFromFavorites: "Видалено з вибраного",
+      projectUpdated: "Проєкт оновлений",
+      failedUpdateProject: "Не вдалося оновити проєкт",
+      linkCopied: "Посилання скопійовано в буфер обміну",
+      failedCopyLink: "Не вдалося скопіювати посилання",
+      photoDeleted: "Фото видалено",
+      failedDeletePhoto: "Не вдалося видалити фото",
+      failedDeleteArchives: "Не вдалося видалити архіви",
+      failedUpdateFavorites: "Не вдалося оновити вибране",
+      exportDownloaded: "Експорт завантажено",
+      exportFailed: "Помилка експорту",
+    },
+    menu: {
+      print: "Друкувати",
+      openInBambuStudio: "Відкрити у слайсері",
+      slice: "Нарізати",
+      externalLink: "Зовнішнє посилання",
+      viewOnMakerWorld: "Переглянути на MakerWorld",
+      preview3d: "3D попередній перегляд",
+      viewTimelapse: "Переглянути таймлапс",
+      scanForTimelapse: "Знайти таймлапс",
+      uploadTimelapse: "Вивантажити таймлапс",
+      removeTimelapse: "Видалити таймлапс",
+      downloadSource3mf: "Завантажити вихідний файл 3MF",
+      uploadSource3mf: "Вивантажити вихідний файл 3MF",
+      replaceSource3mf: "Замінити вихідний файл 3MF",
+      removeSource3mf: "Видалити вихідний файл 3MF",
+      uploadF3d: "Вивантажити F3D",
+      replaceF3d: "Замінити F3D",
+      downloadF3d: "Завантажити F3D",
+      removeF3d: "Видалити F3D",
+      download: "Завантажити",
+      copyDownloadLink: "Копіювати посилання для завантаження",
+      qrCode: "QR-код",
+      viewPhotos: "Переглянути фото",
+      viewPhotosCount: "Переглянути фото ({{count}})",
+      projectPage: "Сторінка проєкту",
+      addToFavorites: "Додати в обране",
+      removeFromFavorites: "Видалити з вибраного",
+      edit: "Редагувати",
+      printLog: "Журнал друку",
+      goToProject: "Перейти до проєкту: {{name}}",
+      addToProject: "Додати до проєкту",
+      removeFromProject: "Видалити з проєкту",
+      loading: "Завантаження...",
+      noProjectsAvailable: "Немає доступних проєктів",
+      searchProjects: "Пошук проєктів…",
+      select: "Вибрати",
+      deselect: "Зняти вибір",
+      delete: "Видалити",
+    },
+    permission: {
+      noReprint: "Ви не маєте дозволу на повторний друк цього архіву",
+      noAddToQueue: "Ви не маєте дозволу на додавання в чергу",
+      noUpdateArchives: "Ви не маєте дозволу на оновлення архівів",
+      noUploadFiles: "Ви не маєте дозволу на вивантаження файлів",
+      noDownload: "У вас немає дозволу на завантаження архівів",
+      noCopyLink: "Ви не маєте дозволу копіювати посилання для завантаження",
+      noDelete: "Ви не маєте дозволу на видалення цього архіву",
+      noEdit: "Ви не маєте дозволу редагувати цей запис",
+      noCreate: "Ви не маєте дозволу на створення архівів",
+    },
+    platePicker: {
+      title: "Вибрати пластину для попереднього перегляду",
+      hint: "Цей архів містить кілька пластин. Виберіть одну, щоб відкрити її в переглядачі G-коду.",
+      plateLabel: "Пластина {{index}}",
+      objectCount: "Об’єктів: {{count}}",
+      objectCount_plural: "Об’єктів: {{count}}",
+      noGcode: "Цей архів не містить нарізаного G-коду для перегляду. Спочатку відкрийте його в Bambu Studio та виконайте нарізання.",
+    },
+    card: {
+      previousPlate: "Попередня друкарська пластина",
+      nextPlate: "Наступна пластина",
+      plateNumber: "Пластина {{index}}",
+      moreOptions: "Клацніть правою кнопкою миші, щоб отримати додаткові параметри",
+      addToFavorites: "Додати в обране",
+      removeFromFavorites: "Видалити з вибраного",
+      cancelled: "скасовано",
+      failed: "не вдалося",
+      duplicate: "дублікат",
+      duplicateTitle: "Ця модель була надрукована раніше",
+      openSource3mf: "Відкрити вихідний файл 3MF у Bambu Studio (клацніть правою кнопкою, щоб переглянути додаткові дії)",
+      downloadF3d: "Завантажити файл дизайну Fusion 360",
+      viewTimelapse: "Переглянути сповільнену зйомку",
+      viewPhoto: "Переглянути 1 фото",
+      viewPhotos: "Переглянути фото: {{count}}",
+      openFolder: "Відкрити папку: {{name}}",
+      slicedFile: "Нарізаний файл - готовий до друку",
+      sourceFile: "Лише вихідний файл – зіставлення AMS недоступне",
+      gcode: "G-КОД",
+      source: "ДЖЕРЕЛО",
+      project: "Проєкт: {{name}}",
+      runsBadge: "Друків: {{count}}",
+      runsBadgeTitle: "Усього друків: {{count}} — успішних: {{successful}}, невдалих: {{failed}}. Натисніть, щоб переглянути повний журнал друку.",
+      estimated: "Приблизно: {{time}}",
+      actual: "Фактично: {{time}}",
+      accuracy: "Точність: {{percent}}%",
+      filament: "{{weight}} г",
+      layer: "{{count}} шар",
+      layers: "Шарів: {{count}}",
+      object: "{{count}} об’єкт",
+      objects: "Об’єктів: {{count}}",
+      slicedFor: "Нарізаний для {{model}}",
+      uploadedBy: "Вивантажив",
+      noPermissionReprint: "Ви не маєте дозволу на передрук",
+      noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
+      noPermissionEdit: "Ви не маєте прав на редагування архівів",
+      noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
+      openInBambuStudio: "Відкрити у слайсері",
+      openInBambuStudioToSlice: "Відкрити у слайсері для нарізання",
+      slice: "Нарізати",
+      externalLink: "Зовнішнє посилання",
+      makerWorld: "MakerWorld: {{designer}}",
+      viewProject: "Переглянути проєкт",
+      noExternalLink: "Немає зовнішнього посилання",
+      preview3d: "3D попередній перегляд",
+      download: "Завантажити",
+      edit: "Редагувати",
+      delete: "Видалити",
+    },
+    runLog: {
+      title: "Журнал друку",
+      modalTitle: "Журнал друку — {{name}}",
+      modalTitleFallback: "цей архів",
+      empty: "Для цього архіву ще не зареєстровано жодної події друку.",
+      col: {
+        date: "Дата",
+        status: "Статус",
+        duration: "Тривалість",
+        filament: "Філамент",
+        cost: "Вартість",
+      },
+      status: {
+        completed: "Виконано",
+        failed: "Не вдалося",
+        cancelled: "Скасовано",
+        stopped: "Зупинено",
+        skipped: "Пропущено",
+        printing: "Друк",
+      },
+    },
+    modal: {
+      deleteArchive: "Видалити архів",
+      deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"? Цю дію не можна скасувати.",
+      deleteButton: "Видалити",
+      deletePurgeStats: "Також видалити цей друк із швидкої статистики (філамент, час, вартість, енергія)",
+      deleteQueueItemsWarning: "Елементи черги {{count}}, пов’язані з цим архівом, також буде видалено.",
+      deleteBlockedByPrinting: "Неможливо видалити — наразі друкуються елементи черги {{count}}. Спочатку зупиніть друк, а потім повторіть спробу.",
+      removeSource3mf: "Видалити джерело 3MF",
+      removeSource3mfConfirm: "Ви впевнені, що хочете видалити вихідний файл 3MF з \"{{name}}\"? Це призведе до видалення вихідного файлу проєкту слайсера.",
+      removeButton: "Видалити",
+      removeF3d: "Видалити F3D",
+      removeF3dConfirm: "Ви впевнені, що хочете видалити файл дизайну Fusion 360 із \"{{name}}\"?",
+      removeTimelapse: "Видалити Timelapse",
+      removeTimelapseConfirm: "Ви впевнені, що хочете видалити покадрове відео з \"{{name}}\"?",
+      timelapse: "{{name}} — таймлапс",
+      selectTimelapse: "Вибрати таймлапс",
+      selectTimelapseDesc: "Автоматичний збіг не знайдено. Виберіть покадрову зйомку для цього друку:",
+      deleteArchives: "Видалити архіви",
+      deleteArchivesConfirm: "Видалити вибрані архіви ({{count}})? Цю дію неможливо скасувати.",
+      deleteCount: "Видалити {{count}}",
+    },
+    page: {
+      title: "Архіви",
+      printsCount: "Показано друків: {{filtered}} із {{total}}",
+      dropFilesHere: "Перетягніть файли .3mf сюди",
+      releaseToUpload: "Відпустіть, щоб вивантажити",
+      only3mfSupported: "Підтримуються лише файли .3mf.",
+      close: "Закрити",
+      selected: "Вибрано {{count}}.",
+      selectAll: "Вибрати усі",
+      tags: "Теги",
+      project: "Проєкт",
+      favorite: "В обраному",
+      delete: "Видалити",
+      toggledFavorites: "Оновлено вибране для архівів: {{count}}",
+      failedUpdateFavorites: "Не вдалося оновити вибране",
+      archivesDeleted: "Видалено архівів: {{count}}",
+      failedDeleteArchives: "Не вдалося видалити архіви",
+      photoDeleted: "Фото видалено",
+      failedDeletePhoto: "Не вдалося видалити фото",
+    },
+    list: {
+      name: "Назва",
+      printer: "Принтер",
+      date: "Дата",
+      size: "Розмір",
+      actions: "Дії",
+      hasTimelapse: "Має таймлапс",
+    },
+    log: {
+      date: "Дата",
+      printName: "Назва друку",
+      printer: "Принтер",
+      user: "Користувач",
+      status: "Статус",
+      duration: "Тривалість",
+      filament: "Філамент",
+      allPrinters: "Усі принтери",
+      allUsers: "Усі користувачі",
+      allStatuses: "Усі статуси",
+      cancelled: "Скасовано",
+      skipped: "Пропущено",
+      dateFrom: "Від",
+      dateTo: "До",
+      noEntries: "Записів журналу друку не знайдено",
+      showing: "Показано {{count}} з {{total}} записів",
+      rowsPerPage: "Рядків",
+      page: "Сторінка",
+      prev: "Назад",
+      next: "Далі",
+      clearLog: "Очистити журнал",
+      clearLogTitle: "Очистити журнал друку",
+      clearLogConfirm: "Усі записи журналу друку буде остаточно видалено. Архіви та елементи черги залишаться без змін. Цю дію неможливо скасувати. Продовжити?",
+      clearLogButton: "Очистити все",
+      cleared: "Очищено записів журналу: {{count}}",
+      clearFailed: "Не вдалося очистити журнал друку",
+      deleteEntryTitle: "Видалити запис журналу друку",
+      deleteEntryConfirm: "Запис буде видалено з журналу, а пов’язані з ним філамент, час і вартість більше не враховуватимуться у швидкій статистиці. Відповідний архів, якщо він є, залишиться без змін. Цю дію неможливо скасувати.",
+      entryDeleted: "Запис журналу друку видалено",
+      entryDeleteFailed: "Не вдалося видалити запис журналу друку",
+      editEntryTitle: "Редагувати запис журналу друку",
+      editEntryDescription: "Класифікуйте цей запуск друку. Віджет «Аналіз невдач» групує дані за цими значеннями, тому зміни одразу відобразяться у статистиці.",
+      entryUpdated: "Запис журналу друку оновлено",
+      entryUpdateFailed: "Не вдалося оновити запис журналу друку",
+      statuses: {
+        completed: "Виконано",
+        failed: "Не вдалося",
+        stopped: "Зупинено",
+        cancelled: "Скасовано",
+        skipped: "Пропущено",
+      },
+    },
+  },
+
+  // Sticky upload-progress toast (#1625 follow-up — restored the
+  // legacy bg-dispatch toast UI for the scheduler-driven dispatch path).
+  // Strings mirror 0b43ac0d's backgroundDispatch namespace.
+  dispatchToast: {
+    untitled: "Завдання друку",
+    startingPrints: "Початок друку",
+    progressSummary: "{{complete}}/{{total}} завершено • Обробка: {{processing}}",
+    expandDetails: "Розгорнути подробиці надсилання",
+    collapseDetails: "Згорнути відомості про відправлення",
+    awaitingPrinter: "Очікування принтера…",
+    status: {
+      processing: "Обробка",
+      completed: "Виконано",
+      failed: "Не вдалося",
+    },
+    failed: {
+      generic: "Не вдалося відправити",
+      upload_failed: "Не вдалося вивантажити файл на принтер",
+      start_command_failed: "Принтер відхилив команду запуску",
+    },
+    dismiss: "Відхилити",
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C). Lists every Slicer Pipeline run
+  // across every pipeline with status + pipeline filters and pagination.
+  // Each row expands to per-copy job status; partial-failure runs get a
+  // Retry-failed button; in-flight runs get a Cancel button.
+  pipelineRuns: {
+    title: "Запуски конвеєрів",
+    loading: "Завантаження…",
+    empty: "Запусків конвеєрів ще немає.",
+    filter: {
+      pipeline: "Конвеєр",
+      status: "Статус",
+      target: "Ціль",
+      all: "Усі",
+      allPipelines: "Усі конвеєри",
+      allStatus: "Усі статуси",
+      allTargets: "Усі цілі",
+      clear: "Очистити фільтри",
+      noMatches: "Жоден запуск не відповідає поточним фільтрам.",
+    },
+    totalCount_one: "{{n}} запуск",
+    totalCount_other: "Запусків: {{n}}",
+    copies: "Копій: {{n}}",
+    failedCount: "Невдалих: {{n}}",
+    copyN: "Копія {{n}}",
+    retryFailed: "Повторити невдалі",
+    retryOf: "повтор запуску №{{n}}",
+    pagination: "{{start}}–{{end}} з {{total}}",
+    cancelledByUser: "Скасовано користувачем",
+    toast: {
+      cancelled: "Запуск скасовано",
+      cancelFailed: "Не вдалося скасувати",
+      retryStarted: "Розпочато повторну спробу",
+      retryFailed: "Не вдалося розпочати повторну спробу",
+      cleared: "Видалено запусків: {{n}}",
+      clearFailed: "Не вдалося очистити",
+    },
+    clearLog: "Очистити журнал",
+    clearConfirmTitle: "Очистити журнал?",
+    clearConfirmBody: "Видалити всі завершені, невдалі, скасовані та частково невдалі запуски конвеєрів? Активні запуски буде збережено. Цю дію неможливо скасувати.",
+    clearConfirmAction: "Очистити",
+    jobStatus: {
+      pending: "в очікуванні",
+      awaiting_printer: "в очікуванні принтера",
+      queued: "в черзі",
+      printing: "друк",
+      completed: "завершено",
+      failed: "не вдалося",
+      cancelled: "скасовано",
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: "Черга друку",
+    subtitle: "Плануйте завдання друку та керуйте ними",
+    filamentShort: {
+      rowBadge: "Недостатньо філаменту для призначеної котушки",
+      rowTooltip: "Планувальник відправлення позначив цей елемент. Натисніть «Запустити», щоб переглянути нестачу для кожного слота й вирішити, чи друкувати попри неї.",
+      confirmTitle: "Недостатня кількість філаменту",
+      confirmIntro: "На призначеній котушці недостатньо філаменту принаймні для одного слота. Усе одно друкувати?",
+      lineItem: "Слот {{slot}}: потрібно {{required}} г, {{remaining}} г залишилося",
+      unknown: "невідомо",
+      printAnyway: "Усе одно друкувати",
+    },
+    // Print modal
+    editQueueItem: "Редагувати елемент черги",
+    selectAllPlates: "Вибрати всі пластини ({{count}})",
+    deselectAll: "Зняти вибір із усіх",
+    printQueued: "Друк у черзі",
+    printQueuedWillStartWhenIdle: "Розпочнеться, коли принтер звільниться",
+    itemsQueued: "Додано до черги: {{count}}",
+    sending: "Надсилання...",
+    sendingProgress: "Надсилання {{current}}/{{total}}...",
+    adding: "Додавання...",
+    addingProgress: "Додавання {{current}}/{{total}}...",
+    savingProgress: "Збереження {{current}}/{{total}}...",
+    clearQueue: "Очистити чергу",
+    clearHistory: "Очистити історію",
+    emptyQueue: "Черга порожня",
+    position: "Позиція",
+    scheduledTime: "Запланований час",
+    moveUp: "Перемістити вгору",
+    moveDown: "Перемістити вниз",
+    startNow: "Почати зараз",
+    printingInProgress: "Виконується друк...",
+    viewArchive: "Переглянути архів",
+    viewInFileManager: "Переглянути в менеджері файлів",
+    itemCount: "Елементів: {{count}}",
+    itemCount_plural: "Елементів: {{count}}",
+    dragToReorder: "Перетягніть, щоб змінити порядок (лише для завдань «Якнайшвидше»)",
+    reorderHint: "Позиція впливає лише на завдання «Якнайшвидше». Заплановані завдання запускаються у встановлений час.",
+    sjf: {
+      label: "SJF",
+      tooltip: "SJF (спочатку найкоротше завдання) — планувальник надає пріоритет коротшим завданням друку",
+    },
+    addedBy: "Додав {{name}}",
+    nextInQueue: "Наступний у черзі",
+    clearPlateSuccess: "Пластина очищена — готова до наступного друку",
+    plateNumber: "Пластина {{index}}",
+    // Batch / quantity
+    quantity: "Кількість",
+    quantityHint: "Буде створено елементів черги: {{count}}",
+    activeBatches: "Активні партії",
+    batchProgress: "{{completed}} з {{total}} завершено",
+    cancelBatch: "Скасувати решту",
+    batchCancelled: "Решту елементів партії скасовано",
+    cancelBatchConfirmTitle: "Скасувати партію",
+    cancelBatchConfirmMessage: "Скасувати всі елементи цієї партії, що ще очікують?",
+    batch: {
+      defaultName: "Партія",
+      label: "Елементів: {{count}}",
+      label_plural: "Елементів: {{count}}",
+      pendingCount: "Очікують: {{count}}",
+      pendingCount_plural: "Очікують: {{count}}",
+      expand: "Розгорнути партію",
+      collapse: "Згорнути партію",
+      groupAsBatch: "Згрупувати в партію…",
+      groupAsBatchDescription: "Об’єднати вибрані елементи ({{count}}) в одну згортану партію.",
+      nameLabel: "Назва партії",
+      namePlaceholder: "напр. П’ятничні подарунки",
+      create: "Створити партію",
+      ungroup: "Розгрупувати",
+      ungroupConfirmTitle: "Розгрупувати партію?",
+      ungroupConfirmMessage: "Елементи залишатимуться в черзі, але більше не групуватимуться разом.",
+      dragGroup: "Перетягнути групу",
+    },
+    // Tabs
+    tabs: {
+      queue: "Черга",
+      history: "Історія",
+      timeline: "Хронологія",
+      pipelines: "Запуски конвеєрів",
+    },
+    // Layout toggle on the Queue tab — distinct from the sort dropdown
+    // (those control order; these control whether items render as one flat
+    // list or grouped under per-printer section headers).
+    layout: {
+      flatList: "Список",
+      byPrinter: "За принтером",
+      groupByPrinter: "Групувати за принтером",
+    },
+    // History tab empty state
+    history: {
+      emptyTitle: "Історії ще немає",
+      emptyDescription: "Тут відображатимуться завершені, скасовані та невдалі роздруківки.",
+      showMore: "Показати більше",
+      showingCount: "Показано {{shown}} з {{total}}",
+    },
+    // Drag ghost label when multi-dragging
+    dragGhost: {
+      multiCount: "Елементів: {{count}}",
+      batch: "{{name}} (копія {{count}})",
+      batch_plural: "{{name}} ({{count}} копій)",
+    },
+    // Sections
+    sections: {
+      currentlyPrinting: "Наразі друкується",
+      queued: "У черзі",
+      history: "Історія",
+    },
+    // Status
+    status: {
+      pending: "В очікуванні",
+      waiting: "Очікування",
+      printing: "Друк",
+      paused: "Призупинено",
+      completed: "Виконано",
+      failed: "Не вдалося",
+      skipped: "Пропущено",
+      cancelled: "Скасовано",
+    },
+    // Summary cards
+    summary: {
+      printing: "Друк",
+      queued: "У черзі",
+      totalTime: "Загальний час у черзі",
+      totalWeight: "Загальна вага черги",
+      history: "Історія",
+    },
+    // Filters
+    filter: {
+      allPrinters: "Усі принтери",
+      unassigned: "Не призначено",
+      allStatus: "Усі статуси",
+      allLocations: "Усі розташування",
+      any: "Будь-який",
+    },
+    // Sort
+    sort: {
+      byPosition: "Сортувати за позицією",
+      byName: "Сортувати за назвою",
+      byPrinter: "Сортувати за принтером",
+      bySchedule: "Сортувати за розкладом",
+      byDate: "Сортувати за датою",
+      ascendingOldest: "За зростанням (найстаріші спочатку)",
+      descendingNewest: "За спаданням (спочатку найновіші)",
+    },
+    // Badges
+    badges: {
+      staged: "Очікує ручного запуску",
+      requiresPrevious: "Потрібне успішне завершення попереднього завдання",
+      autoPowerOff: "Автоматичне вимкнення",
+      gcodeInjection: "G-код",
+    },
+    // Empty state
+    empty: {
+      title: "Друк не заплановано",
+      description: "Заплануйте друк зі сторінки «Архіви» за допомогою параметра «Розклад» у контекстному меню або перетягніть файли, щоб почати.",
+    },
+    // Time
+    time: {
+      asap: "Якнайшвидше",
+      overdue: "Прострочено",
+      now: "Зараз",
+      lessThanMinute: "Менше ніж за хвилину",
+      inMinutes: "Через {{count}} хв",
+      inHours: "Через {{count}} год",
+    },
+    // Actions
+    actions: {
+      startPrint: "Розпочати друк",
+      stopPrint: "Зупинити друк",
+      requeue: "Поставити в чергу знову",
+    },
+    // Bulk edit
+    bulkEdit: {
+      title: "Редагувати {{count}} елемент",
+      title_plural: "Редагувати елементи ({{count}})",
+      description: "Лише змінені параметри будуть застосовані до вибраних елементів.",
+      printer: "Принтер",
+      noChange: "— Без змін —",
+      queueOptions: "Параметри черги",
+      staged: "Очікує ручного запуску",
+      autoPowerOff: "Автоматичне вимкнення після друку",
+      requirePrevious: "Вимагати успішного завершення попереднього завдання",
+      printOptions: "Параметри друку",
+      bedLevelling: "Вирівнювання столу",
+      flowCalibration: "Калібрування потоку",
+      vibrationCalibration: "Калібрування вібрації",
+      layerInspection: "Перевірка першого шару",
+      timelapse: "Таймлапс",
+      useAms: "Використовувати AMS",
+      nozzleOffsetCali: "Калібрування зсуву сопла",
+      applyChanges: "Застосувати зміни",
+      selectAll: "Вибрати усі",
+      deselectAll: "Зняти вибір із усіх",
+      selected: "Вибрано: {{count}}",
+      editSelected: "Редагувати вибране",
+      cancelSelected: "Скасувати вибране",
+    },
+    // Confirmations
+    confirm: {
+      cancelTitle: "Скасувати запланований друк",
+      cancelMessage: "Ви впевнені, що бажаєте скасувати \"{{name}}\"?",
+      stopTitle: "Зупинити друк",
+      stopMessage: "Ви впевнені, що бажаєте зупинити поточний друк \"{{name}}\"? Це призведе до скасування завдання друку на принтері.",
+      removeTitle: "Видалити з історії",
+      removeMessage: "Ви впевнені, що бажаєте видалити \"{{name}}\" з історії черги?",
+      clearHistoryTitle: "Очистити історію",
+      clearHistoryMessage: "Видалити всі елементи з історії ({{count}})?",
+      cancelButton: "Скасувати друк",
+      stopButton: "Зупинити друк",
+      thisPrint: "цей друк",
+      thisItem: "цей елемент",
+    },
+    // Toast messages
+    toast: {
+      cancelled: "Пункт черги скасовано",
+      cancelFailed: "Не вдалося скасувати елемент",
+      removed: "Елемент черги видалено",
+      removeFailed: "Не вдалося видалити елемент",
+      stopped: "Друк зупинено",
+      stopFailed: "Не вдалося зупинити друк",
+      released: "Друк відправлено в чергу",
+      startFailed: "Не вдалося почати друк",
+      reorderFailed: "Не вдалося змінити порядок черги",
+      historyCleared: "Очищено елементів історії: {{count}}",
+      clearHistoryFailed: "Не вдалося очистити історію",
+      updateFailed: "Не вдалося оновити елементи",
+      bulkCancelled: "Скасовано елементів: {{count}}",
+      bulkCancelFailed: "Не вдалося скасувати елементи",
+      batchCreated: "Партію «{{name}}» створено",
+      batchCreateFailed: "Не вдалося створити партію",
+      batchUngrouped: "Розгруповано елементів: {{count}}",
+      batchUngroupFailed: "Не вдалося розгрупувати партію",
+      resumedAfterFailure: "Відновлено чергу — {{restored}} завдання відновлено до стану очікування",
+      resumeAfterFailureFailed: "Не вдалося відновити чергу",
+    },
+    resumeAfterFailure: {
+      banner: "{{printer}} заблоковано через помилку попереднього друку — завдання {{count}} пропущено",
+      bannerHint: "Усуньте проблему з принтером, а потім відновіть чергу, щоб повернути пропущені завдання й зняти блокування.",
+      button: "Відновити після невдачі",
+      confirmTitle: "Відновити чергу після помилки?",
+      confirmMessage: "Повернути пропущені завдання ({{count}}) для {{printer}} до стану очікування та зняти блокування після невдалого попереднього друку? Переконайтеся, що принтер готовий.",
+    },
+    // Timeline view
+    timeline: {
+      listView: "Список",
+      timelineView: "Хронологія",
+      unassigned: "Не призначено",
+      noData: "На цей день немає запланованих роздруківок",
+      nothingCommitted: "У цьому часовому проміжку немає підтверджених розкладів. Завдання з ручним запуском, завдання в очікуванні та завдання «Якнайшвидше» для вільних принтерів тут не показано. Установіть запланований час або запустіть відкладене завдання.",
+      allDoneBy: "Орієнтовне завершення всіх друків: {{time}}",
+      staged: "Очікує ручного запуску",
+      filterAll: "Показати все",
+      filterPrinting: "Друк",
+      filterQueued: "У черзі",
+      time: {
+        anyMoment: "будь-якої миті",
+        minutesLeft: "Залишилося {{minutes}} хв",
+        hoursLeft: "Залишилося {{hours}} год",
+        hoursMinutesLeft: "Залишилося {{hours}} год {{minutes}} хв",
+      },
+      day: {
+        previous: "Попередній день",
+        next: "Наступний день",
+        today: "Сьогодні",
+      },
+      // Rolling-24h Gantt window
+      window: {
+        back12h: "Назад 12 годин",
+        forward12h: "Уперед на 12 годин",
+        now: "Зараз",
+      },
+      printerColumnHeader: "Принтер",
+    },
+    // Permissions
+    permissions: {
+      noStopPrint: "Ви не маєте дозволу зупиняти друк",
+      noStartPrint: "Ви не маєте дозволу розпочинати друк",
+      noEdit: "Ви не маєте дозволу редагувати цей елемент черги",
+      noCancel: "Ви не маєте дозволу скасувати цей елемент черги",
+      noRequeue: "Ви не маєте дозволу повторно ставити елементи в чергу",
+      noRemove: "Ви не маєте дозволу на видалення цього елемента черги",
+      noClearHistory: "Ви не маєте дозволу очистити всю історію",
+      noEditItems: "Ви не маєте дозволу редагувати елементи черги",
+      noCancelItems: "Ви не маєте дозволу на скасування елементів черги",
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: "Статистика",
+    subtitle: "Перетягніть віджети, щоб змінити порядок. Натисніть значок ока, щоб приховати.",
+    overview: "Огляд",
+    totalPrints: "Загальна кількість друків",
+    successRate: "Коефіцієнт успішності",
+    totalPrintTime: "Загальний час друку",
+    printTime: "Час друку",
+    totalFilament: "Загальна кількість використаного філаменту",
+    filamentUsed: "Використано філаменту",
+    filamentCost: "Вартість філаменту",
+    totalCost: "Загальна вартість",
+    energyUsed: "Використана енергія",
+    energyCost: "Вартість енергії",
+    energyWarmingUpTooltip: "Відстеження енергії все ще збирає щогодинні знімки. Підсумкові показники за діапазон дат стануть точними, коли існує принаймні один знімок до вибраного діапазону. Ранні значення можуть бути заниженими.",
+    averagePrintTime: "Середній час друку",
+    printsPerDay: "Друків на день",
+    byPrinter: "За принтером",
+    printsByPrinter: "Друки за принтерами",
+    byMaterial: "За матеріалом",
+    byMonth: "За місяцями",
+    last7Days: "Останні 7 днів",
+    last30Days: "Останні 30 днів",
+    last90Days: "Останні 90 днів",
+    allTime: "За весь час",
+    // Widgets
+    quickStats: "Швидка статистика",
+    printActivity: "Активність друку",
+    filamentTypes: "Типи філаментів",
+    filamentTrends: "Тенденції філаменту",
+    failureAnalysis: "Аналіз невдач",
+    timeAccuracy: "Точність оцінки часу",
+    successful: "Успішно:",
+    failed: "Невдало:",
+    cancelled: "Скасовано:",
+    perfectEstimate: "100% = ідеальна оцінка",
+    noTimeAccuracyData: "Даних про точність часу ще немає",
+    noFilamentData: "Немає даних про філамент",
+    noPrinterData: "Дані принтера відсутні",
+    noPrintData: "Немає даних про друк",
+    noPrintDataLast30Days: "Немає даних про друк за останні 30 днів",
+    failureReasons: "Причини невдач",
+    topFailureReasons: "Основні причини невдач",
+    failedPrintsCount: "Невдалих друків: {{failed}} із {{total}}",
+    lastWeekRate: "Минулого тижня: {{rate}}%",
+    // Actions
+    resetLayout: "Скинути макет",
+    recalculateCosts: "Перерахувати витрати",
+    recalculateCostsHint: "Перерахувати вартість усіх архівних друків за поточними цінами на філамент",
+    exportStats: "Експортувати статистику",
+    exportAsCsv: "Експортувати як CSV",
+    exportAsExcel: "Експортувати як Excel",
+    hiddenCount: "Приховано: {{count}}",
+    // Toast
+    exportDownloaded: "Експорт завантажено",
+    exportFailed: "Помилка експорту",
+    layoutReset: "Розташування віджетів скинуто",
+    recalculatedCosts: "Перераховано вартість архівів: {{count}}",
+    recalculateFailed: "Не вдалося перерахувати витрати",
+    // Loading
+    loadingStats: "Завантаження статистики...",
+    // Permissions
+    noPermissionResetLayout: "Ви не маєте дозволу на скидання макета",
+    noPermissionRecalculate: "Ви не маєте дозволу на перерахунок витрат",
+    noPrintDataInRange: "У вибраному діапазоні немає даних про друк",
+    periodFilament: "Філамент за період",
+    periodCost: "Вартість періоду",
+    avgPerPrint: "Середнє на друк",
+    usageOverTime: "Використання в часі",
+    energyOverTime: "Споживання енергії в часі",
+    filamentByWeight: "Вага",
+    printDuration: "Тривалість друку",
+    printerUtilization: "Використання принтера",
+    filamentSuccess: "Успіх за матеріалом",
+    printHabits: "Звички друку",
+    printTimeOfDay: "Розподіл друків за часом доби",
+    colorDistribution: "Розподіл кольору",
+    noColorData: "Немає даних про колір",
+    records: "Записи",
+    longestPrint: "Найтриваліший друк",
+    heaviestPrint: "Найважчий друк",
+    mostExpensivePrint: "Найдорожчий",
+    busiestDay: "Найзавантаженіший день",
+    successStreak: "Серія успішних друків",
+    streakPrint: "успішний друк поспіль",
+    streakPrints: "Успішних друків поспіль: {{count}}",
+    printerStats: "Статистика принтера",
+    hours: "год",
+    avgPrints: "Середня кількість друків",
+    noArchiveData: "Немає даних про друк",
+    filamentByTime: "Час",
+    avgWeight: "Середня вага",
+    avgTime: "Середній час",
+    filamentByPrints: "Друки",
+    timeframe: {
+      'today': "Сьогодні",
+      'this-week': "Цей тиждень",
+      'this-month': "Цей місяць",
+      'last-7': "Останні 7 днів",
+      'last-30': "Останні 30 днів",
+      'last-90': "Останні 90 днів",
+      'this-year': "Цього року",
+      'all-time': "За весь час",
+      'custom': "Власний діапазон",
+      from: "Від",
+      to: "До",
+    },
+    // User filter
+    allUsers: "Усі користувачі",
+    noUser: "Немає користувача (система)",
+    filterByUser: "Фільтрувати за користувачем",
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: "Технічне обслуговування",
+    overview: "Огляд",
+    allOk: "Усі роботи з технічного обслуговування виконано",
+    dueCount: "Потрібне обслуговування: {{count}}",
+    dueCount_plural: "Потрібне обслуговування: {{count}}",
+    warningCount: "{{count}} попередження",
+    warningCount_plural: "{{count}} попередження",
+    totalPrintTime: "Загальний час друку",
+    nextMaintenance: "Наступне технічне обслуговування",
+    nothingDue: "Обслуговування не потрібне",
+    tasks: "Завдання",
+    lastPerformed: "Востаннє виконано",
+    interval: "Інтервал",
+    hoursRemaining: "Залишилося {{hours}} год",
+    hoursOverdue: "Прострочено на {{hours}} год",
+    markDone: "Позначити як виконане",
+    performMaintenance: "Виконати технічне обслуговування",
+    history: "Історія",
+    noHistory: "Історії обслуговування немає",
+    editPrintHours: "Змінити години друку",
+    currentHours: "Поточна кількість годин",
+    // Tabs
+    statusTab: "Статус",
+    settingsTab: "Налаштування",
+    // Status
+    overdueCount: "{{count}} прострочено",
+    dueSoonCount: "{{count}} незабаром",
+    dueSoon: "Незабаром",
+    allGood: "Все добре",
+    overdueBy: "Прострочено на {{duration}}",
+    dueIn: "Через {{duration}}",
+    timeLeft: "Залишилося {{duration}}",
+    // Duration formats
+    day: "1 день",
+    days: "{{count}} днів",
+    week: "1 тиждень",
+    weeks: "{{count}} тижнів",
+    month: "1 місяць",
+    months: "{{count}} місяців",
+    year: "1 рік",
+    // Settings
+    maintenanceTypes: "Види технічного обслуговування",
+    maintenanceTypesDescription: "Системні та власні завдання технічного обслуговування",
+    addCustomType: "Додати спеціальний тип",
+    restoreDefaults: "Відновити завдання за замовчуванням",
+    intervalType: "Тип інтервалу",
+    intervalValue: "Інтервал ({{type}})",
+    icon: "значок",
+    documentationLink: "Посилання на документацію (необов’язково)",
+    assignToPrinters: "Призначити принтерам",
+    selectAtLeastOnePrinter: "Вибрати принаймні один принтер",
+    addType: "Додати тип",
+    custom: "Власне",
+    printHours: "Години друку",
+    calendarDays: "Календарні дні",
+    exampleName: "наприклад, Замінити HEPA-фільтр",
+    viewDocumentation: "Переглянути документацію",
+    timeBasedInterval: "Часовий інтервал",
+    // Interval overrides
+    intervalOverrides: "Перевизначення інтервалів",
+    intervalOverridesDescription: "Налаштувати інтервали для конкретних принтерів",
+    // Printer assignment
+    assignedToPrinters: "Призначено для принтерів:",
+    noPrintersAssigned: "Принтерів не призначено",
+    addPrinterShort: "Додати:",
+    printersAssignedClick: "Призначено принтерів: {{count}} — натисніть, щоб керувати",
+    removeFromPrinter: "Видалити з цього принтера",
+    // Types
+    types: {
+      lubricateCarbonRods: "Змастити карбонові стрижні",
+      lubricateRails: "Змастити лінійні напрямні",
+      cleanNozzle: "Очистити сопло/хотенд",
+      checkBelts: "Перевірити натяг ременів",
+      cleanBuildPlate: "Очистити друкарську пластину",
+      checkExtruder: "Перевірити шестерні екструдера",
+      checkCooling: "Перевірити вентилятори охолодження",
+      generalInspection: "Загальний огляд",
+      cleanCarbonRods: "Очистити карбонові стрижні",
+      lubricateSteelRods: "Змастити сталеві стрижні",
+      cleanSteelRods: "Очистити сталеві стрижні",
+      cleanLinearRails: "Очистити лінійні напрямні",
+      checkPtfeTube: "Перевірити PTFE-трубку",
+      replaceHepaFilter: "Замінити HEPA-фільтр",
+      replaceCarbonFilter: "Замінити вугільний фільтр",
+      lubricateLeftNozzleRail: "Змастити ліву напрямну сопла",
+    },
+    // Toast
+    maintenanceComplete: "Технічне обслуговування позначено як завершене",
+    typeUpdated: "Оновлено тип обслуговування",
+    typeDeleted: "Тип обслуговування видалено",
+    defaultsRestored: "Відновлено завдання за замовчуванням {{count}}",
+    printHoursUpdated: "Години друку оновлено",
+    printerAssigned: "Принтер призначено",
+    printerRemoved: "Принтер видалено",
+    // Confirmation
+    deleteTypeConfirm: "Видалити \"{{name}}\"?",
+    deleteSystemTypeTitle: "Видалити завдання обслуговування за умовчанням?",
+    deleteSystemTypeMessage: "Ви впевнені, що бажаєте видалити стандартне завдання обслуговування \"{{name}}\"?",
+    // Permissions
+    noPermissionUpdate: "Ви не маєте дозволу на оновлення елементів обслуговування",
+    noPermissionPerform: "Ви не маєте дозволу на проведення технічного обслуговування",
+    noPermissionEditTypes: "Ви не маєте дозволу редагувати типи обслуговування",
+    noPermissionDeleteTypes: "Ви не маєте дозволу на видалення типів обслуговування",
+    noPermissionEditHours: "Ви не маєте дозволу редагувати години друку",
+    noPermissionRemovePrinter: "Ви не маєте дозволу на видалення призначень принтера",
+    noPermissionAssignPrinter: "Ви не маєте дозволу призначати принтери",
+    noPermissionEditIntervals: "Ви не маєте дозволу редагувати інтервали",
+    // Configure link
+    configureSettings: "Налаштувати типи та інтервали технічного обслуговування",
+  },
+
+  // Settings page
+  settings: {
+    title: "Налаштування",
+    general: "Загальні",
+    // Tab names
+    tabs: {
+      general: "Загальні",
+      smartPlugs: "Розумні розетки",
+      notifications: "Сповіщення",
+      queue: "Робочий процес",
+      queueDispatch: "Черга та відправка",
+      queuePipelines: "Конвеєри",
+      filament: "Філамент",
+      network: "Мережа",
+      apiKeys: "Ключі API",
+      virtualPrinter: "Віртуальний принтер",
+      spoolbuddy: "SpoolBuddy",
+      failureDetection: "Виявлення несправностей",
+      users: "Автентифікація",
+      backup: "Резервне копіювання",
+      emailAuth: "Автентифікація електронної пошти",
+      ldap: "LDAP",
+      twoFa: "Двофакторна автентифікація",
+      oidc: "SSO / OIDC",
+      security: "Безпека",
+    },
+    spoolbuddy: {
+      infoTitle: "Пристрої SpoolBuddy",
+      infoBody: "Термінали SpoolBuddy автоматично реєструються за допомогою сигналів стану. Скасуйте реєстрацію пристрою, якщо він більше не використовується або після збою служби залишився застарілий дублікат.",
+      duplicatesTitle: "Зареєстровано пристроїв: {{count}}",
+      duplicatesBody: "Інтерфейс термінала використовує лише перший зареєстрований пристрій. Якщо один із записів є застарілим дублікатом після збою, скасуйте його реєстрацію. Пристрій у мережі зареєструється знову під час наступного сигналу стану.",
+      empty: "Ще немає зареєстрованих пристроїв SpoolBuddy.",
+      online: "Онлайн",
+      offline: "Не в мережі",
+      unregister: "Скасувати реєстрацію",
+      unregisterSuccess: "Реєстрацію пристрою скасовано",
+      unregisterError: "Не вдалося скасувати реєстрацію пристрою",
+      confirmTitle: "Скасувати реєстрацію пристрою SpoolBuddy?",
+      confirmBody: "Пристрій «{{hostname}}» ({{deviceId}}) буде видалено з бази даних. Якщо він у мережі, то зареєструється знову під час наступного сигналу стану.",
+      ipAddress: "IP-адреса",
+      firmware: "Прошивка",
+      lastSeen: "Востаннє в мережі",
+      daemonUptime: "Час роботи демона",
+      systemUptime: "Час роботи системи",
+      never: "Ніколи",
+      nfc: "NFC",
+      scale: "Ваги",
+      cpuTemp: "Температура ЦП",
+      cpuLoad: "Завантаження ЦП",
+      memory: "Пам’ять",
+      disk: "Диск",
+      // Device actions
+      update: "Оновити",
+      updateConfirmTitle: "Оновити демон Spoolbuddy?",
+      updateConfirmBody: "Запустити оновлення програмного забезпечення на \"{{hostname}}\"? Щойно оновлення буде застосовано, демон перезапуститься.",
+      restartBrowser: "Перезапустити браузер",
+      restartBrowserConfirmTitle: "Перезапустити веб-переглядач термінала?",
+      restartBrowserConfirmBody: "Перезапустити веб-переглядач кіоску на \"{{hostname}}\"? Дисплей на короткий час згасне.",
+      restartDaemon: "Перезапустити службу",
+      restartDaemonConfirmTitle: "Перезапустити демон Spoolbuddy?",
+      restartDaemonConfirmBody: "Перезапустити демон Spoolbuddy на \"{{hostname}}\"? Пристрій вийде з мережі на кілька секунд.",
+      reboot: "Перезавантажити",
+      rebootConfirmTitle: "Перезавантажити пристрій?",
+      rebootConfirmBody: "Перезавантажити «{{hostname}}»? Пристрій буде офлайн приблизно хвилину.",
+      shutdown: "Вимкнути",
+      shutdownConfirmTitle: "Вимкнути пристрій?",
+      shutdownConfirmBody: "Вимкнути \"{{hostname}}\"? Щоб увімкнути його, вам знадобиться фізичний доступ.",
+      commandConfirm: "Підтвердити",
+      commandQueued: "Команда в черзі",
+      commandError: "Не вдалося надіслати команду",
+    },
+    // LDAP settings
+    ldap: {
+      title: "Автентифікація LDAP",
+      enabledDesc: "Автентифікація LDAP увімкнена",
+      disabledDesc: "Автентифікація LDAP вимкнена",
+      disabledHint: "Налаштуйте та збережіть налаштування LDAP нижче, а потім увімкніть.",
+      enabled: "LDAP автентифікація ввімкнена",
+      disabled: "LDAP автентифікація вимкнена",
+      feature1: "Користувачі можуть входити за допомогою облікових даних LDAP.",
+      feature2: "Обліковий запис локального адміністратора залишається резервним",
+      feature3: "Групи LDAP зіставляються з групами BamBuddy під час входу",
+      serverConfig: "Налаштування сервера LDAP",
+      serverUrl: "URL сервера",
+      serverUrlHint: "Використовуйте ldaps:// для SSL або ldap:// із StartTLS",
+      security: "Безпека",
+      securityHint: "StartTLS оновлює звичайне підключення до TLS. LDAPS використовує TLS із самого початку.",
+      bindDn: "DN прив’язки (службовий обліковий запис)",
+      bindPassword: "Пароль прив’язки",
+      searchBase: "Базовий DN пошуку",
+      userFilter: "Фільтр пошуку користувача",
+      userFilterHint: "{username} буде замінено на ім’я користувача, введене під час входу. Для OpenLDAP використовуйте (uid={username}).",
+      advanced: "Розширені налаштування",
+      autoProvision: "Автоматично створювати користувачів",
+      autoProvisionHint: "Автоматично створювати обліковий запис BamBuddy після першого входу LDAP.",
+      defaultGroup: "Група за замовчуванням",
+      defaultGroupNone: "— Немає (без резервної групи) —",
+      defaultGroupHint: "Запасна група призначається, коли користувач LDAP автентифікується, але його немає в списку жодної зіставленої групи LDAP. Залиште порожнім, щоб залишити непризначених користувачів без дозволів.",
+      groupMapping: "Зіставлення груп (JSON)",
+      groupMappingHint: "Зіставте DN груп LDAP із групами BamBuddy. Доступні групи: ",
+      testConnection: "Перевірити підключення",
+      settingsSaved: "Налаштування LDAP збережено",
+      errors: {
+        serverRequired: "Потрібна URL-адреса сервера LDAP",
+        searchBaseRequired: "Потрібен базовий DN пошуку",
+        enableAuthFirst: "Спочатку ввімкніть автентифікацію",
+        configureLdapFirst: "Спочатку збережіть налаштування LDAP.",
+      },
+    },
+    // Email settings
+    email: {
+      smtpSettings: "Налаштування SMTP",
+      smtpHost: "Сервер SMTP",
+      smtpPort: "Порт SMTP",
+      security: "Безпека",
+      authentication: "Автентифікація",
+      username: "Ім’я користувача",
+      password: "Пароль",
+      fromEmail: "Адреса відправника",
+      fromName: "Ім’я відправника",
+      testConnection: "Перевірити підключення SMTP",
+      testRecipient: "Електронна адреса тестового одержувача",
+      sendTest: "Надіслати тестовий електронний лист",
+      sending: "Надсилання...",
+      save: "Зберегти налаштування",
+      saving: "Збереження...",
+      advancedAuth: "Розширена автентифікація",
+      advancedAuthEnabled: "Розширену автентифікацію ввімкнено",
+      advancedAuthEnabledDesc: "Функції керування користувачами на основі електронної пошти активні. Нові користувачі отримають автоматично згенеровані паролі електронною поштою, і користувачі зможуть скинути свої паролі за допомогою функції «Забутий пароль».",
+      advancedAuthDisabled: "Розширену автентифікацію вимкнено",
+      advancedAuthDisabledDesc: "Увімкніть розширену автентифікацію, щоб активувати функції електронної пошти для керування користувачами.",
+      enable: "Увімкнути",
+      disable: "Вимкнути",
+      feature1: "Паролі генеруються автоматично та надсилаються новим користувачам електронною поштою",
+      feature2: "Користувачі можуть входити за іменем користувача або електронною адресою",
+      feature3: "Доступна функція «Забув пароль».",
+      feature4: "Адміністратори можуть скинути паролі користувачів електронною поштою",
+      // Error messages
+      errors: {
+        requiredFields: "Заповніть усі обов’язкові поля",
+        usernameRequired: "Якщо ввімкнено автентифікацію, потрібне ім’я користувача",
+        enterTestEmail: "Введіть тестову електронну адресу",
+        smtpServerAndEmail: "Перед перевіркою вкажіть SMTP-сервер і адресу відправника",
+        usernamePasswordRequired: "Якщо ввімкнено автентифікацію, потрібні ім’я користувача та пароль",
+        configureSmtpFirst: "Спочатку налаштуйте та перевірте параметри SMTP",
+        enableAuthFirst: "Щоб використовувати функції електронної пошти, спочатку ввімкніть автентифікацію.",
+      },
+      // Success messages
+      success: {
+        settingsSaved: "Налаштування SMTP успішно збережено",
+      },
+      // Security options
+      securityOptions: {
+        starttls: "STARTTLS (порт 587)",
+        ssl: "SSL/TLS (порт 465)",
+        none: "Без шифрування (порт 25)",
+      },
+      // Authentication options
+      authOptions: {
+        enabled: "Увімкнено",
+        disabled: "Вимкнено",
+      },
+    },
+    appearance: "Зовнішній вигляд",
+    notifications: "Сповіщення",
+    smartPlugs: "Розумні розетки",
+    spoolman: "Spoolman",
+    updates: "Оновлення",
+    language: "Мова",
+    languageDescription: "Виберіть бажану мову",
+    theme: "Тема",
+    themeLight: "Світла",
+    themeDark: "Темна",
+    themeSystem: "Системна",
+    defaultView: "Початкове подання",
+    defaultViewDescription: "Сторінка, яка відображатиметься під час відкриття програми",
+    checkForUpdates: "Перевірити наявність оновлень",
+    autoUpdate: "Автоматичне оновлення",
+    currentVersion: "Поточна версія",
+    latestVersion: "Остання версія",
+    upToDate: "У вас установлено останню версію",
+    updateAvailable: "Доступне оновлення",
+    // Notifications
+    notificationLanguage: "Мова сповіщень",
+    notificationLanguageDescription: "Мова для push-повідомлень",
+    bedCooledThreshold: "Поріг охолодження столу",
+    bedCooledThresholdDescription: "Температура, нижче якої стіл вважається охолодженим після друку",
+    userNotificationsEnabled: "Сповіщення користувача",
+    userNotificationsEnabledDescription: "Увімкніть меню сповіщень користувача та сповіщення електронною поштою про події завдань друку. Потрібна розширена автентифікація.",
+    userNotificationsDisabledHint: "Увімкніть розширену автентифікацію, щоб використовувати сповіщення користувачів.",
+    notificationProviders: "Служби сповіщень",
+    addProvider: "Додати службу",
+    editProvider: "Редагувати службу",
+    providerType: "Тип служби",
+    testNotification: "Перевірити сповіщення",
+    testSuccess: "Тестове сповіщення успішно надіслано",
+    testFailed: "Не вдалося надіслати тестове сповіщення",
+    quietHours: "Період тиші",
+    quietHoursDescription: "Не надсилати сповіщень у цей період",
+    quietHoursStart: "Початок",
+    quietHoursEnd: "Кінець",
+    events: {
+      title: "Події сповіщень",
+      printStart: "Друк розпочато",
+      printComplete: "Друк завершено",
+      printFailed: "Помилка друку",
+      printStopped: "Друк зупинено",
+      printProgress: "Етапи виконання",
+      printProgressDescription: "Сповіщати після досягнення 25%, 50% і 75%",
+      printerOffline: "Принтер не в мережі",
+      printerError: "Помилка принтера",
+      filamentLow: "Мало філаменту",
+      maintenanceDue: "Потрібне технічне обслуговування",
+      maintenanceDueDescription: "Повідомити, коли потрібне обслуговування",
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: "Розумні розетки",
+      add: "Додати розумну розетку",
+      edit: "Редагувати розумну розетку",
+      name: "Назва",
+      ipAddress: "IP-адреса",
+      linkedPrinter: "Пов’язаний принтер",
+      autoOn: "Автоматичне ввімкнення",
+      autoOnDescription: "Увімкнути на початку друку",
+      autoOff: "Автоматичне вимкнення",
+      autoOffDescription: "Вимкнути після завершення друку",
+      offDelay: "Затримка вимкнення",
+      offDelayMinutes: "Хвилини після завершення друку",
+      offDelayTemp: "Коли температура сопла нижча за задану",
+      currentState: "Поточний стан",
+      turnOn: "Увімкнути",
+      turnOff: "Вимкнути",
+    },
+    // Filament Tracking Mode
+    filamentTracking: "Облік філаменту",
+    filamentTrackingDesc: "Виберіть спосіб обліку котушок філаменту. Можна використовувати вбудований інвентар або підключити зовнішній сервер Spoolman.",
+    autoAddUnknownRfid: "Автоматично додавати невідомі RFID-котушки",
+    autoAddUnknownRfidDesc: "Автоматично створювати запис інвентарю, коли виявлено котушку з невідомою RFID-міткою. Вимкніть, якщо ви заздалегідь реєструєте нові котушки вручну, щоб уникнути дублікатів.",
+    filamentChecks: "Перевірки філаменту",
+    disableFilamentWarnings: "Вимкнути попередження про філамент",
+    disableFilamentWarningsDesc: "Не показувати попередження про недостатню кількість філаменту під час запуску друку або додавання до черги",
+    preferLowestFilament: "Віддавати перевагу котушці з найменшим залишком",
+    preferLowestFilamentDesc: "Якщо підходять кілька котушок, використовувати котушку з найменшим залишком філаменту",
+    preferLowestFilamentBackupNote: "Діє лише тоді, коли на принтері ввімкнено AMS Filament Backup. Інакше після вичерпання вибраної котушки принтер не зможе перейти на другу.",
+    trackingModeBuiltIn: "Вбудований інвентар",
+    trackingModeBuiltInDesc: "Автоматичне зіставлення за RFID та облік використання",
+    trackingModeSpoolmanDesc: "Зовнішній сервер керування філаментом",
+    builtInFeatureRfid: "Автоматично виявляє RFID-котушки Bambu Lab в AMS",
+    builtInFeatureUsage: "Обліковує витрати філаменту для кожного друку",
+    builtInFeatureCatalog: "Керування котушками, кольорами та профілями K-фактора",
+    builtInFeatureThirdParty: "Котушки сторонніх виробників можна пов’язувати з котушками інвентарю",
+    amsSyncButton: "Синхронізувати масу з AMS",
+    amsSyncTitle: "Синхронізація маси котушок з AMS",
+    amsSyncMessage: "Масу всіх котушок інвентарю буде перезаписано відповідно до поточних значень залишку у відсотках, отриманих з AMS підключених принтерів. Скористайтеся цим для відновлення пошкоджених даних про масу. Принтери мають бути в мережі.",
+    amsSyncing: "Синхронізація...",
+    amsSyncSuccess: "Синхронізовано: {{synced}}; пропущено: {{skipped}}",
+    amsSyncError: "Не вдалося синхронізувати масу з AMS",
+    spoolmanAmsSyncButton: "Синхронізувати масу котушок Spoolman з AMS",
+    spoolmanAmsSyncTitle: "Синхронізація маси котушок Spoolman з AMS",
+    spoolmanAmsSyncMessage: "Масу всіх котушок Spoolman буде оновлено відповідно до поточних значень залишку в AMS підключених принтерів. Принтери мають бути в мережі.",
+    spoolmanAmsSyncing: "Синхронізація...",
+    spoolmanAmsSyncSuccess: "Синхронізовано: {{synced}}; пропущено: {{skipped}}",
+    spoolmanAmsSyncError: "Не вдалося синхронізувати масу котушок Spoolman з AMS",
+    spoolmanAmsSyncErrorUnreachable: "Не вдалося синхронізувати масу котушок Spoolman: сервер Spoolman недоступний",
+    spoolmanAmsSyncErrorNotConfigured: "Не вдалося синхронізувати масу котушок Spoolman: сервер Spoolman не налаштовано",
+    spoolmanNotConfigured: "Spoolman не налаштовано",
+    // Spoolman filament catalog section in spool catalog settings
+    spoolmanFilamentCatalogTitle: "Каталог філаментів Spoolman",
+    spoolmanFilamentCatalogDesc: "Назви філаментів і вага тари з вашого екземпляра Spoolman. Тут можна редагувати назву та вагу котушки; усі інші властивості керуються безпосередньо в Spoolman.",
+    // Spoolman settings
+    spoolmanUrl: "Spoolman URL",
+    spoolmanUrlHint: "URL вашого сервера Spoolman (наприклад, http://localhost:7912)",
+    spoolmanConnected: "Підключено",
+    spoolmanDisconnected: "Відключено",
+    status: "Статус",
+    connect: "Підключитися",
+    disconnect: "Від’єднатися",
+    howSyncWorks: "Як працює синхронізація",
+    syncInfoRfidOnly: "Синхронізуються лише офіційні котушки Bambu Lab із RFID.",
+    syncInfoAutoCreate: "Нові котушки автоматично створюються в Spoolman під час першої синхронізації",
+    syncInfoThirdPartySkipped: "Котушки не від Bambu Lab — сторонні або повторно заправлені — пропускаються",
+    linkingExistingSpools: "Зв’язування існуючих котушок",
+    linkingExistingSpoolsDesc: "Щоб пов’язати наявні котушки Spoolman з AMS, наведіть вказівник на слот AMS і натисніть «Пов’язати зі Spoolman».",
+    syncMode: "Режим синхронізації",
+    syncModeAuto: "Автоматичний",
+    syncModeManual: "Тільки вручну",
+    syncModeAutoDesc: "Дані AMS синхронізуються автоматично після виявлення змін",
+    syncModeManualDesc: "Синхронізувати лише вручну",
+    syncAmsData: "Синхронізувати дані AMS",
+    syncAmsDataDesc: "Вручну синхронізувати дані AMS принтера зі Spoolman",
+    allPrinters: "Усі принтери",
+    // Default printer
+    noDefaultPrinter: "Немає умовчання (запитувати щоразу)",
+    // Sidebar
+    sidebarOrder: "Порядок бічної панелі",
+    // Camera
+    saveThumbnails: "Зберегти мініатюри",
+    captureFinishPhoto: "Зробити фото після завершення",
+    noPrintersConfigured: "Принтери не налаштовані",
+    // Archive settings
+    archiveMode: {
+      always: "Завжди створювати запис архіву",
+      never: "Ніколи не створювати запис архіву",
+      ask: "Запитувати щоразу",
+    },
+    // Updates
+    checkForUpdatesLabel: "Перевірити наявність оновлень",
+    checkPrinterFirmware: "Перевірити прошивку принтера",
+    includeBetaUpdates: "Включати бета-версії",
+    includeBetaUpdatesDesc: "Повідомляти про бета-версії та попередні версії під час перевірки оновлень",
+    localLogin: {
+      disable: "Вимкнути локальний вхід за іменем користувача й паролем",
+      disableHint: "Якщо ввімкнено, увійти можна буде лише через постачальників SSO. На LDAP це не впливає. Щоб зберегти резервний спосіб входу, установіть на сервері BAMBUDDY_LOCAL_LOGIN=true.",
+    },
+    // Queue
+    enableRetry: "Увімкнути повторну спробу",
+    // Home Assistant
+    homeAssistantDescription: "Керуйте розумними розетками через Home Assistant",
+    environmentManagedLabel: "(Кероване середовище)",
+    autoEnabledViaEnv: "Автоматично вмикається через змінні середовища",
+    urlFromEnvReadOnly: "Значення, встановлене змінною середовища HA_URL (лише читання)",
+    tokenFromEnvReadOnly: "Значення, установлене змінною середовища HA_TOKEN (лише читання)",
+    // MQTT
+    mqttConnectedTo: "Підключено до",
+    // Prometheus
+    prometheusDescription: "Надавати дані принтерів у форматі Prometheus",
+    // Smart plugs empty state
+    noSmartPlugsTitle: "Розумні розетки не налаштовано",
+    noSmartPlugsDescription: "Додайте розумну розетку на базі Tasmota, щоб відстежувати споживання енергії та автоматизувати керування живленням.",
+    // Notifications empty state
+    noProvidersTitle: "Постачальники не налаштовані",
+    noProvidersDescription: "Додайте постачальника, щоб отримувати сповіщення.",
+    noTemplatesAvailable: "Немає доступних шаблонів. Перезапустіть серверну частину, щоб створити стандартні шаблони.",
+    // API permissions
+    apiPermissionView: "Перегляд стану принтера та черги",
+    apiPermissionEdit: "Додавання та видалення елементів із черги друку",
+    // API keys
+    apiKeysEmptyTitle: "Немає ключів API",
+    apiKeysEmptyDescription: "Створіть ключ API для інтеграції із зовнішніми службами.",
+    // Users
+    noUsersFound: "Користувачів не знайдено",
+    noGroupsFound: "Групи не знайдено",
+    noGroupsAvailable: "Немає доступних груп",
+    passwordsDoNotMatch: "Паролі не збігаються",
+    systemGroupWarning: "Назви системних груп не можна змінювати",
+    // Auth disabled
+    authDisabledTitle: "Автентифікація вимкнена",
+    authDisabledFeature1: "Вимагати входу для доступу до системи",
+    authDisabledFeature2: "Створювати кількох користувачів із дозволами на основі груп",
+    authDisabledFeature3: "Керувати доступом за допомогою понад 50 окремих дозволів",
+    // User deletion
+    userHasCreated: "Цей користувач створив:",
+    userItemsQuestion: "Що зробити з цими елементами?",
+    deleteUserConfirm: "Ви впевнені, що хочете видалити цього користувача?",
+    actionCannotBeUndone: "Цю дію не можна скасувати.",
+    // Smart plugs
+    addFirstSmartPlug: "Додати першу розумну розетку",
+    // Notifications
+    providers: "Постачальники",
+    log: "Журнал",
+    testAll: "Перевірити все",
+    testResults: "Результати тестування",
+    testPassedCount: "{{count}} пройдено",
+    testFailedCount: "{{count}} не вдалося",
+    messageTemplates: "Шаблони повідомлень",
+    messageTemplatesDescription: "Налаштуйте текст сповіщень для кожної події.",
+    // API Keys section
+    apiKeys: "Ключі API",
+    apiKeysDescription: "Створіть ключі API для зовнішніх інтеграцій і веб-хуків.",
+    createKey: "Створити ключ",
+    apiKeyCreated: "Ключ API створено успішно",
+    apiKeyCopyWarning: "Скопіюйте цей ключ зараз — його більше не буде показано!",
+    useInApiBrowser: "Використати в оглядачі API",
+    apiKeyQrButton: "QR-код",
+    apiKeyQrTitle: "Відскануйте для налаштування",
+    apiKeyQrCaption: "Скануйте за допомогою мобільного додатка, щоб додати цей сервер і ключ API.",
+    apiKeyQrWarning: "Містить ваш секретний ключ API. Не передавайте його іншим і не зберігайте знімки екрана там, де їх можуть побачити сторонні.",
+    createNewApiKey: "Створити новий ключ API",
+    keyName: "Назва ключа",
+    keyNamePlaceholder: "наприклад, Home Assistant, OctoPrint",
+    readStatus: "Читати статус",
+    readStatusDescription: "Перегляд стану принтера та черги",
+    manageQueue: "Керувати чергою",
+    manageQueueDescription: "Додавання та видалення елементів із черги друку",
+    controlPrinter: "Керування принтером",
+    controlPrinterDescription: "Призупинення, відновлення та зупинка друку",
+    manageLibrary: "Керувати бібліотекою",
+    manageLibraryDescription: "Вивантажувати, перейменовувати й видаляти файли бібліотеки; імпортувати моделі з MakerWorld",
+    manageInventory: "Керувати інвентарем",
+    manageInventoryDescription: "Створення, оновлення та видалення котушок і записів інвентарю. Потрібно для кіосків SpoolBuddy: сканування NFC, покази ваг і системні команди кіоску.",
+    manageMaintenance: "Керувати технічним обслуговуванням",
+    manageMaintenanceDescription: "Реєстрація завершеного технічного обслуговування, скидання лічильників, редагування інтервалів і керування каталогом технічного обслуговування. Підходить для автоматизації Home Assistant, яка записує «Я очистив сопло», не надаючи ширшого контролю над принтером.",
+    manageArchives: "Керувати архівами",
+    manageArchivesDescription: "Редагувати та видаляти архіви друку, у тому числі видаляти старі друки. Не включає очищення їхнього статистичного внеску. Підходить для автоматизації, яка обрізає історію друку.",
+    manageProjects: "Керувати проєктами",
+    manageProjectsDescription: "Створюйте, оновлюйте та видаляйте проєкти, додавайте до них архіви. Підходить для автоматизації, яка організовує друк у проєкти.",
+    libraryBadge: "Бібліотека",
+    inventoryBadge: "Інвентар",
+    maintenanceBadge: "Технічне обслуговування",
+    archivesBadge: "Архіви",
+    projectsBadge: "Проєкти",
+    cloudAccess: "Дозволити доступ до хмари",
+    cloudAccessDescription: "Дозволити читати від вашого імені профілі та філаменти Bambu Cloud. Потрібен вхід у Bambu Cloud.",
+    cloudBadge: "Хмара",
+    updateEnergyCost: "Оновлювати ціну електроенергії",
+    updateEnergyCostDescription: "Дозволити цьому ключу надсилати нову ціну електроенергії за кВт·год до /settings/electricity-price. Це корисно для автоматизацій Home Assistant із динамічними тарифами (Tibber, Octopus тощо). Це єдине поле налаштувань, яке можна змінювати за допомогою ключа API.",
+    energyCostBadge: "Енергія",
+    legacyKey: "Застарілий",
+    legacyKeyTooltip: "Створено до отримання права власності на користувача; повторно створити, щоб використовувати доступ до хмари",
+    unnamedKey: "Безіменний ключ",
+    lastUsed: "Останнє використання",
+    read: "Читання",
+    control: "Керування",
+    createFirstKey: "Створити перший ключ",
+    webhookEndpoints: "Кінцеві точки вебхуків",
+    webhookApiKeyHint: "Використовуйте свій ключ API у заголовку X-API-Key.",
+    webhook: {
+      getAllStatus: "Отримати весь стан принтера",
+      getSpecificStatus: "Отримати певний стан принтера",
+      addToQueue: "Додати до черги друку",
+      pausePrint: "Призупинити друк",
+      resumePrint: "Відновити друк",
+      stopPrint: "Зупинити друк",
+    },
+    apiBrowser: "Оглядач API",
+    apiBrowserDescription: "Переглядайте й перевіряйте всі доступні кінцеві точки API.",
+    apiKeyForTesting: "Ключ API для тестування",
+    apiKeyPlaceholder: "Вставте сюди свій ключ API, щоб перевірити автентифіковані кінцеві точки...",
+    apiKeyHint: "Цей ключ буде надіслано як заголовок X-API-Key із запитами.",
+    deleteApiKeyTitle: "Видалити ключ API",
+    deleteApiKeyMessage: "Ви впевнені, що хочете видалити цей ключ API? Будь-які інтеграції за допомогою цього ключа перестануть працювати.",
+    deleteKey: "Видалити ключ",
+    // Filament tab
+    amsDisplayThresholds: "Пороги індикаторів AMS",
+    amsThresholdsDescription: "Налаштуйте колірні пороги для індикаторів вологості та температури AMS.",
+    humidity: "Вологість",
+    goodGreen: "Добре (зелений)",
+    fairOrange: "Задовільний (помаранчевий)",
+    aboveFairBad: "Значення вище задовільного порога відображається червоним (погано)",
+    fairAlsoDryingThreshold: "Цей поріг також використовується для запуску автоматичного сушіння, якщо його ввімкнено",
+    temperature: "Температура",
+    goodBlue: "Добре (синій)",
+    aboveFairHot: "Значення вище задовільного порога відображається червоним (гаряче)",
+    historyRetention: "Збереження історії",
+    keepSensorHistory: "Зберігати історію датчиків для",
+    historyRetentionDescription: "Старіші дані про вологість і температуру буде автоматично видалено",
+    defaultPrintOptions: "Параметри друку за умовчанням",
+    defaultPrintOptionsDescription: "Установіть типові значення параметрів для нових завдань друку. Їх можна змінити для окремого завдання в діалоговому вікні друку.",
+    defaultBedLevelling: "Вирівнювання столу",
+    defaultBedLevellingDesc: "Автоматичне вирівнювання столу перед друком",
+    defaultFlowCali: "Калібрування потоку",
+    defaultFlowCaliDesc: "Калібрувати потік екструзії",
+    defaultVibrationCali: "Калібрування вібрації",
+    defaultVibrationCaliDesc: "Зменшувати артефакти від вібрації",
+    defaultLayerInspect: "Перевірка першого шару",
+    defaultLayerInspectDesc: "Перевірка першого шару за допомогою ШІ",
+    defaultTimelapse: "Таймлапс",
+    defaultTimelapseDesc: "Записувати таймлапс",
+    defaultNozzleOffsetCali: "Калібрування зсуву сопла",
+    defaultNozzleOffsetCaliDesc: "Калібрувати зміщення сопел між екструдерами",
+    tempFanPresetsTitle: "Профілі температури та вентилятора",
+    tempFanPresetsDescription: "Налаштуйте значення швидкого вибору в спливних панелях температури й швидкості вентилятора на картці принтера. Кнопка «Вимкнути» відображається завжди.",
+    tempFanPresetsNozzle: "Температура сопла",
+    tempFanPresetsBed: "Температура столу",
+    tempFanPresetsChamber: "Температура камери",
+    tempFanPresetsFan: "Швидкість вентилятора",
+    tempFanPresetsReset: "Відновити налаштування за замовчуванням",
+    concurrentUploadsTitle: "Паралельні вивантаження",
+    concurrentUploadsDescription: "Кількість принтерів, на які черга може одночасно надсилати файли. Передавання на принтери відбувається повільно — великий файл може передаватися кілька хвилин — і кожен принтер чекає своєї черги. Для великого парку збільшення цього значення скорочує очікування останнього принтера в партії. Зменште його, якщо мережа або сервер Bambuddy не справляється з паралельними передаваннями.",
+    concurrentUploadsLabel: "Кількість принтерів для одночасного передавання",
+    concurrentUploadsHelp: "1 надсилає на один принтер за раз (стара поведінка). За замовчуванням 4.",
+    staggeredStart: "Поетапний запуск",
+    staggeredStartDescription: "Типовий розмір групи та інтервал для поетапного запуску партії на кількох принтерах. Значення можна змінити для кожної партії у вікні друку.",
+    preheatTitle: "Попереднє нагрівання й термовитримка",
+    preheatDescription: "Нагрівати стіл — і камеру, якщо це підтримується — та витримувати задану температуру перед кожним друком із черги. Це корисно для інженерних філаментів, як-от PA й ABS, на принтерах без активного нагрівача камери: стіл нагріває камеру тепловим випромінюванням, доки триває термовитримка. Цільова температура столу зчитується з файлу друку; поведінка камери залежить від моделі принтера.",
+    preheatEnabled: "Увімкнути попереднє нагрівання й термовитримку",
+    preheatEnabledDesc: "Якщо вимкнено, завдання з черги надсилаються на друк негайно. Для кожного елемента черги це налаштування можна перевизначити окремо.",
+    preheatFilamentTargetsLabel: "Цільова температура камери за типом філаменту (°C)",
+    preheatFilamentTargetsHint: "Bambuddy вибирає найвищу цільову температуру серед завантажених слотів AMS. Для друку лише з PLA визначається значення 0, тому етап нагрівання камери автоматично пропускається.",
+    preheatFilamentTargetsReset: "Відновити налаштування за замовчуванням",
+    preheatFilamentTargetsDefaultRow: "Інше / не зіставлене",
+    preheatMaxWait: "Максимальний час очікування (секунди)",
+    preheatMaxWaitHelp: "Максимальна тривалість нагрівання камери перед переходом до наступного етапу.",
+    preheatSoak: "Термовитримка (с)",
+    preheatSoakHelp: "Час термовитримки після досягнення цільової температури або завершення максимального часу очікування.",
+    preheatHardwareTitle: "Особливості за моделями принтерів:",
+    preheatHardwareDetail: "H2C/H2D/H2D Pro/H2S/X2D/X1E активно нагрівають камеру командою M141. X1C/P2S зчитують температуру камери, але покладаються на нагрівання тепловим випромінюванням від столу. P1S/P1P/A1/A1 Mini не мають датчика камери — для них застосовується лише таймер термовитримки.",
+    preheatPerItemDesc: "Нагрівати стіл і камеру перед початком цього друку. Типове значення успадковується з перемикача «Налаштування → Робочий процес».",
+    preheatOverride_inherit: "Успадкувати",
+    preheatOverride_on: "Увімкнено",
+    preheatOverride_off: "Вимкнено",
+    calibrationMode_off: "Вимкнено",
+    calibrationMode_on: "Увімкнено",
+    calibrationMode_auto: "Авто",
+    preheatTargetOverride: "Інша цільова температура камери (°C; порожньо — типове значення для філаменту)",
+    plateClear: "Підтвердження очищення друкарської пластини",
+    requirePlateClear: "Вимагати підтвердження очищення друкарської пластини",
+    requirePlateClearDescription: "Якщо ввімкнено, планувальник перед запуском наступного завдання на принтері із завершеним друком очікує підтвердження, що друкарську пластину очищено. Вимкнення також приховує індикатор стану пластини та кнопку «Позначити пластину як очищену» на картках принтерів.",
+    gcodeInjection: "Вставлення G-коду",
+    gcodeInjectionDescription: "Налаштуйте власний G-код для вставлення на початку та/або наприкінці друку в системах автоматичного друку, як-от Farmloop, SwapMod, AutoClear і Printflow 3D. Фрагменти налаштовуються для кожної моделі принтера та застосовуються, коли для елемента черги ввімкнено «Вставляти G-код».",
+    gcodeInjectionNoPrinters: "Принтерів не знайдено. Додайте принтери, щоб налаштувати фрагменти G-коду.",
+    gcodeStartLabel: "Початковий G-код",
+    gcodeEndLabel: "Завершальний G-код",
+    gcodeStartPlaceholder: "G-код додається перед початком друку...",
+    gcodeEndPlaceholder: "G-код додається після завершення друку...",
+    staggerGroupSize: "Розмір групи",
+    staggerGroupSizeHelp: "Кількість принтерів, що одночасно запускаються в кожній групі",
+    staggerInterval: "Інтервал (хвилини)",
+    staggerIntervalHelp: "Затримка між стартом кожної групи",
+    queueDrying: "Автоматичне сушіння між завданнями черги",
+    queueDryingDescription: "Автоматично сушити філамент в AMS, коли принтер простоює між завданнями з черги. Сушіння запускається відповідно до заданого вище порога вологості.",
+    queueDryingEnabled: "Увімкнути автосушку",
+    queueDryingEnabledDescription: "Автоматичний запуск AMS сушіння, коли принтер неактивний і вологість перевищує порогове значення",
+    queueDryingBlock: "Чекати завершення сушіння",
+    queueDryingBlockDescription: "Не запускати завдання з черги до завершення сушіння. Якщо вимкнено, друк має пріоритет над сушінням.",
+    ambientDryingEnabled: "Фонове сушіння",
+    ambientDryingEnabledDescription: "Автоматичне сушіння філаменту на неактивних принтерах, коли вологість перевищує порогове значення, навіть без друку в черзі.",
+    printDryingEnabled: "Продовжувати сушіння під час друку",
+    printDryingEnabledDescription: "Дозволити автоматичному сушінню тривати під час друку на підтримуваному обладнанні (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L з новими версіями прошивки). Для захисту котушок температура сушіння автоматично обмежується значенням на 5 °C нижчим за профіль для режиму простою.",
+    dryingPresets: "Профілі сушіння",
+    dryingPresetsDescription: "Температура та тривалість для кожного типу філаменту. AMS 2 Pro використовує нижчі температури, AMS-HT підтримує вищі температури.",
+    dryingFilament: "Філамент",
+    humidityThresholds: "Пороги вологості",
+    humidityThresholdsDescription: "Пороги вологості за типом філаменту для автоматичного сушіння та попереджень. Для змішаного завантаження в одному AMS використовується найнижчий поріг.",
+    humidityThresholdCol: "Поріг",
+    humidityThresholdDefault: "За замовчуванням (невідомі типи)",
+    printModal: "Вікно друку",
+    expandCustomMapping: "Розгорнути настроюване зіставлення за умовчанням",
+    expandCustomMappingDescription: "Під час друку на кількох принтерах показувати розгорнуте зіставлення для кожного принтера AMS.",
+    // User management
+    authentication: "Автентифікація",
+    authEnabledDescription: "Ваш екземпляр захищено автентифікацією користувача",
+    authDisabledDescription: "Увімкніть запит входу та керування доступом користувачів",
+    authDisabledMessage: "Увімкніть автентифікацію, щоб створювати облікові записи користувачів, керувати дозволами та захищати свій екземпляр Bambuddy.",
+    enableAuthentication: "Увімкнути автентифікацію",
+    currentUser: "Поточний користувач",
+    changePassword: "Змінити пароль",
+    admin: "адмін",
+    users: "Користувачі",
+    addUser: "Додати користувача",
+    groups: "Групи",
+    addGroup: "Додати групу",
+    system: "система",
+    noDescription: "Без опису",
+    userCount: "{{count}} користувачів",
+    permissionCount: "Дозволи {{count}}.",
+    createUser: "Створити користувача",
+    username: "Ім’я користувача",
+    enterUsername: "Введіть ім’я користувача",
+    password: "Пароль",
+    enterPassword: "Введіть пароль",
+    passwordRequirements: "Принаймні 8 символів, один великий, один малий, одна цифра та один спеціальний символ.",
+    confirmPassword: "Підтвердити пароль",
+    confirmPasswordPlaceholder: "Підтвердити пароль",
+    // Title tooltips
+    viewReleaseOnGitHub: "Переглянути випуск на GitHub",
+    turnAllPlugsOn: "Увімкнути усі розетки",
+    turnAllPlugsOff: "Вимкнути усі розетки",
+    // Modal: Clear logs
+    clearNotificationLogs: "Очистити журнали сповіщень",
+    clearLogsMessage: "Це назавжди видалить усі журнали сповіщень старше 30 днів. Цю дію не можна скасувати.",
+    clearLogs: "Очистити журнали",
+    // Modal: Reset UI
+    resetUiPreferences: "Скинути налаштування UI.",
+    resetUiPreferencesMessage: "Це скине всі параметри UI до значень за замовчуванням: порядок бічної панелі, тему, макет інформаційної панелі, режими перегляду та параметри сортування. Це НЕ вплине на ваші принтери, архіви та налаштування сервера. Після очищення сторінка перезавантажиться.",
+    resetPreferences: "Скинути налаштування",
+    // Modal: Delete group
+    deleteGroupTitle: "Видалити групу",
+    deleteGroupMessage: "Ви впевнені, що хочете видалити цю групу? Користувачі цієї групи втратять ці дозволи.",
+    deleteGroup: "Видалити групу",
+    // Modal: Disable auth
+    disableAuthenticationTitle: "Вимкнути автентифікацію",
+    disableAuthenticationMessage: "Ви впевнені, що бажаєте вимкнути автентифікацію? Це зробить ваш екземпляр Bambuddy доступним без входу. Усі користувачі залишаться в базі даних, але автентифікацію буде вимкнено.",
+    disableAuthentication: "Вимкнути автентифікацію",
+    // Additional settings
+    configureBambuddy: "Налаштувати Bambuddy",
+    systemDefault: "Системне за замовчуванням",
+    archiveSettings: "Налаштування архіву",
+    newWindow: "Нове вікно",
+    embeddedOverlay: "Вбудоване накладання",
+    preferredSlicer: "Бажаний слайсер",
+    preferredSlicerDescription: "Слайсер для нарізання в застосунку через допоміжний API-сервіс",
+    openInSlicerLabel: "Відкрити у слайсері",
+    openInSlicerInherit: "Такий самий, як API-слайсер",
+    openInSlicerDescription: "Настільний слайсер, який відкриває кнопка «Відкрити в слайсері». Залиште «Такий самий, як API-слайсер», щоб успадкувати вибір, або виберіть інший слайсер для локального використання.",
+    orcaslicerKnownIssuesWarning: "OrcaSlicer 2.3.2 / 2.4.0-dev має відомі помилки CLI, через які не вдається нарізати багато створених Bambu файлів 3MF. Див. проблеми upstream №12426 — аварійне завершення для розфарбованих файлів із кількома екструдерами — та №13386 — відхилення через сувору перевірку діапазонів параметрів. До появи виправлень upstream рекомендовано використовувати Bambu Studio.",
+    useSlicerApi: "Використовувати API слайсера",
+    useSlicerApiDescription: "Якщо ввімкнено, команда «Нарізати» відкриває вбудоване вікно слайсера й викликає допоміжний сервіс slicer-API. Якщо вимкнено — типове значення — файл передається настільному слайсеру через URI-схему.",
+    slicerCard: "Слайсер",
+    orcaslicerApiUrl: "URL допоміжного сервісу OrcaSlicer",
+    bambuStudioApiUrl: "URL допоміжного сервісу Bambu Studio",
+    slicerApiUrlDescription: "URL контейнера допоміжного сервісу slicer-API. Залиште поле порожнім, щоб використовувати типові значення зі змінних середовища SLICER_API_URL / BAMBU_STUDIO_API_URL.",
+    slicerBundlesRemoved: {
+      title: "Пакети профілів слайсера (вилучено)",
+      description: "Імпорт пакетів профілів принтера (.bbscfg) вилучено. Експорт пакетів Bambu Studio містить лише змінені користувачем профілі, тому імпорт не надавав стандартних профілів процесу й філаменту, а під час нарізання використовувалися вбудовані налаштування.",
+      alternatives: "Використовуйте імпорт окремого профілю для власних профілів або синхронізацію через Bambu Cloud / Orca Cloud. Стандартні профілі автоматично надходять із допоміжного сервісу слайсера.",
+      lookupOrder: "Порядок пошуку профілів під час нарізання: 1) імпортовані локально, 2) Orca Cloud, 3) Bambu Cloud, 4) стандартні профілі слайсера.",
+    },
+    externalCameras: "Зовнішні камери",
+    costTracking: "Відстеження витрат",
+    printsOnly: "Лише друк",
+    totalConsumption: "Загальне споживання",
+    dataManagement: "Управління даними",
+    storageUsage: "Використання сховища",
+    storageUsageDescription: "Розподіл використання даних за категоріями",
+    storageUsageTotal: "Всього",
+    storageUsageErrors: "Помилки",
+    storageUsageOtherBreakdown: "Інше (включає статичні ресурси, сценарії та файли конфігурації)",
+    storageUsageSystem: "система",
+    storageUsageData: "Дані",
+    storageUsageUnavailable: "Інформація про використання сховища недоступна",
+    clearNotificationLogsDescription: "Видалити журнали сповіщень старші 30 днів",
+    resetUiPreferencesDescription: "Скинути порядок бічної панелі, тему, режими перегляду та параметри макета. Принтери, архіви та налаштування не впливають.",
+    enableHomeAssistant: "Увімкнути Home Assistant",
+    enableMqtt: "Увімкнути MQTT",
+    useTls: "Використовуйте TLS",
+    enableMetricsEndpoint: "Увімкнути кінцеву точку показників",
+    availableMetrics: "Доступні показники",
+    editUser: "Редагувати користувача",
+    deleteUserTitle: "Видалити користувача",
+    groupName: "Назва групи",
+    // Placeholders
+    leaveEmptyForAnonymous: "Залиште порожнім для анонімного",
+    leaveEmptyForNoAuth: "Залиште порожнім, щоб не було автентифікації",
+    enterNewPassword: "Введіть новий пароль",
+    confirmNewPassword: "Підтвердити новий пароль",
+    enterGroupName: "Введіть назву групи",
+    enterDescriptionOptional: "Введіть опис (необов’язково)",
+    enterCurrentPassword: "Введіть поточний пароль",
+    enterNewPasswordMin6: "Введіть новий пароль (мінімум 6 символів)",
+    toast: {
+      keyCopied: "Ключ скопійовано в буфер обміну",
+      copyFailed: "Не вдалося скопіювати ключ",
+      keyAddedToBrowser: "Ключ додано в браузер API.",
+      clearLogsFailed: "Не вдалося очистити журнали",
+      uiPreferencesReset: "UI налаштування скинуто. Оновлення...",
+      authDisabled: "Автентифікацію успішно вимкнено",
+      authDisableFailed: "Не вдалося вимкнути автентифікацію",
+      apiKeyCreated: "Ключ API створено",
+      apiKeyDeleted: "Ключ API видалено",
+      userCreated: "Користувача створено успішно",
+      userUpdated: "Користувача успішно оновлено",
+      userDeleted: "Користувача успішно видалено",
+      groupCreated: "Групу створено успішно",
+      groupUpdated: "Групу успішно оновлено",
+      groupDeleted: "Групу успішно видалено",
+      fillRequiredFields: "Будь ласка, заповніть усі необхідні поля",
+      passwordsDoNotMatch: "Паролі не збігаються",
+      passwordTooShort: "Пароль має бути не менше 8 символів",
+      passwordNeedsUppercase: "Пароль має містити принаймні одну велику літеру",
+      passwordNeedsLowercase: "Пароль має містити принаймні одну малу літеру",
+      passwordNeedsDigit: "Пароль має містити хоча б одну цифру",
+      passwordNeedsSpecial: "Пароль повинен містити хоча б один спеціальний символ",
+      enterGroupName: "Будь ласка, введіть назву групи",
+      settingsSaved: "Налаштування збережено",
+      noPermissionUpdate: "Ви не маєте дозволу змінювати налаштування",
+      cameraSettingsSaved: "Налаштування камери збережено",
+      enterCameraUrl: "Будь ласка, введіть камеру URL",
+      passwordChanged: "Пароль успішно змінено",
+      connectionFailed: "Помилка підключення",
+      testFailed: "Тест не вдалося",
+      cameraConnected: "Камера підключена{{resolution}}",
+    },
+    testConnection: "Тестове підключення",
+    catalog: {
+      spoolCatalog: "Каталог котушок",
+      spoolCatalogDescription: "Вага порожніх котушок за брендом і типом. Використовується для автоматичного пошуку ваги під час додавання котушок.",
+      searchCatalog: "Пошук у каталозі...",
+      addNewEntry: "Додати новий запис",
+      namePlaceholder: "Назва (наприклад, Bambu Lab - пластик)",
+      weight: "вага",
+      type: "Тип",
+      default: "За замовчуванням",
+      custom: "Власний",
+      noMatch: "Немає записів, що відповідають вашому пошуку",
+      empty: "Немає записів у каталозі",
+      deleteEntry: "Видалити запис",
+      deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"?",
+      resetCatalog: "Скинути каталог",
+      resetConfirm: "Скинути каталог до стандартних? Це видалить усі спеціальні записи.",
+      loadFailed: "Не вдалося завантажити каталог котушок",
+      nameWeightRequired: "Назва й маса обов’язкові",
+      entryAdded: "Запис додано",
+      addFailed: "Не вдалося додати запис",
+      entryUpdated: "Запис оновлено",
+      updateFailed: "Не вдалося оновити запис",
+      entryDeleted: "Запис видалено",
+      deleteFailed: "Не вдалося видалити запис",
+      resetSuccess: "Каталог відновлено до типових значень",
+      resetFailed: "Не вдалося скинути каталог",
+      exported: "Експортовано записів: {{count}}",
+      imported: "Імпортовано: {{added}}; пропущено: {{skipped}}",
+      importFailed: "Не вдалося імпортувати: недійсний формат JSON.",
+      exportTooltip: "Експортувати каталог до JSON",
+      importTooltip: "Імпорт каталогу з JSON",
+      resetTooltip: "Відновити налаштування за замовчуванням",
+      selectedCount: "Вибрано: {{count}}",
+      deleteSelected: "Видалити вибране",
+      bulkDeleteConfirm: "Видалити вибрані записи ({{count}})?",
+      bulkDeleted: "Видалено записів: {{count}}",
+      bulkDeleteFailed: "Не вдалося видалити записи",
+      material: "матеріал",
+      spoolWeight: "Вага котушки",
+      color: "Колір",
+      updateSpoolWeight: "Оновити вагу котушки",
+      filamentUpdated: "Філамент оновлено",
+      filamentUpdateFailed: "Не вдалося оновити філамент",
+      filamentUpdateInvalid: "Недійсні дані філаменту",
+      keepExistingSpoolWeight: "Зберегти попередню вагу для наявних котушок",
+      keepExistingSpoolWeightDesc: "Котушки, створені з цим типом філаменту, зберігають стару вагу тари. Нові котушки використовують оновлене значення.",
+      applyToAllSpools: "Застосувати до всіх котушок",
+      applyToAllSpoolsDesc: "Усі розрахунки ваги для цього типу філаменту одразу використовують нову вагу тари.",
+    },
+    colorCatalog: {
+      title: "Каталог кольорів",
+      description: "Кольори філаменту за виробником і матеріалом. Використовується для автоматичного пошуку кольору під час додавання котушок.",
+      searchColors: "Пошук кольорів...",
+      allManufacturers: "Усі виробники",
+      addNewColor: "Додати новий колір",
+      manufacturer: "Виробник",
+      colorName: "Назва кольору",
+      hex: "Hex-код",
+      materialOptional: "Матеріал (необов’язково)",
+      showing: "Показано {{filtered}} із {{total}} кольорів",
+      noMatch: "Жоден колір не відповідає вашому запиту",
+      empty: "Кольори в каталозі відсутні",
+      deleteColor: "Видалити колір",
+      deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"?",
+      resetCatalog: "Скинути каталог кольорів",
+      resetConfirm: "Скинути каталог до стандартних? Це видалить усі власні кольори.",
+      sync: "Синхронізувати",
+      starting: "Запуск…",
+      syncTooltip: "Синхронізація з FilamentColors.xyz (2000+ кольорів, може зайняти хвилину)",
+      loadFailed: "Не вдалося завантажити каталог кольорів",
+      fieldsRequired: "Необхідно вказати виробника, назву кольору та шістнадцятковий колір",
+      colorAdded: "Додано колір",
+      addFailed: "Не вдалося додати колір",
+      colorUpdated: "Колір оновлено",
+      updateFailed: "Не вдалося оновити колір",
+      colorDeleted: "Колір видалено",
+      deleteFailed: "Не вдалося видалити колір",
+      resetSuccess: "Каталог кольорів відновлено до типових значень",
+      resetFailed: "Не вдалося скинути каталог",
+      syncUpToDate: "Вже оновлено (кольори {{count}} перевірено)",
+      syncComplete: "Додано: {{added}}; уже існувало: {{skipped}}",
+      syncError: "Помилка синхронізації",
+      syncFailed: "Не вдалося синхронізувати з FilamentColors.xyz",
+      exported: "Експортовано кольорів: {{count}}",
+      imported: "Імпортовано: {{added}}; пропущено: {{skipped}}",
+      importFailed: "Не вдалося імпортувати: недійсний формат JSON.",
+      selectedCount: "Вибрано: {{count}}",
+      deleteSelected: "Видалити вибране",
+      bulkDeleteConfirm: "Ви впевнені, що бажаєте видалити кольори {{count}}?",
+      bulkDeleted: "Видалено кольорів: {{count}}",
+      bulkDeleteFailed: "Не вдалося видалити кольори",
+    },
+    // General tab
+    dateFormat: "Формат дати",
+    dateFormatUs: "США (MM/DD/YYYY)",
+    dateFormatEu: "ЄС (DD/MM/YYYY)",
+    dateFormatIso: "ISO (РРРР-ММ-ДД)",
+    timeFormat: "Формат часу",
+    timeFormat12: "12 годин (15:30)",
+    timeFormat24: "24 години (15:30)",
+    defaultPrinter: "Принтер за умовчанням",
+    defaultPrinterDescription: "Попередньо вибирати цей принтер для вивантажень, повторного друку та інших операцій.",
+    slicerBambuStudio: "Bambu Studio",
+    slicerOrcaSlicer: "OrcaSlicer",
+    sidebarOrderDescription: "Використовуйте бічну панель, щоб змінити порядок елементів, скинути видимість і керувати спеціальними посиланнями.",
+    setDefault: "Установити за замовчуванням",
+    sidebarOrderSetDefaultHint: "За замовчуванням застосовує поточний порядок меню до користувачів, які не налаштували свій.",
+    sidebarDefaultSet: "Встановлено стандартний порядок меню.",
+    sidebarDefaultCleared: "Порядок меню за замовчуванням видалено.",
+    sidebarDefaultFailed: "Не вдалося встановити порядок меню за замовчуванням.",
+    reset: "Скинути",
+    // Appearance
+    darkMode: "Темний режим",
+    lightMode: "Світловий режим",
+    active: "(активний)",
+    background: "Фон",
+    accent: "Акцент",
+    style: "Стиль",
+    bgNeutral: "Нейтральний",
+    bgWarm: "Теплий",
+    bgCool: "круто",
+    bgOled: "OLED Чорний",
+    bgSlate: "Шиферно-блакитний",
+    bgForest: "Зелений ліс",
+    accentGreen: "Зелений",
+    accentTeal: "Чирок",
+    accentBlue: "Синій",
+    accentOrange: "Помаранчевий",
+    accentPurple: "Фіолетовий",
+    accentRed: "Червоний",
+    styleClassic: "класичний",
+    styleGlow: "Світіння",
+    styleVibrant: "Яскравий",
+    themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
+    // Archive
+    autoArchivePrints: "Автоматично архівувати друки",
+    autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
+    saveThumbnailsDescription: "Витягніть і збережіть зображення попереднього перегляду з файлів 3MF.",
+    captureFinishPhotoDescription: "Зробіть фотографію з камери принтера після завершення друку. Bambuddy записує короткий проміжок часу під час друку, щоб фотографію можна було отримати з моменту, коли стіл опускається; файл уповільненої зйомки зберігається, якщо ви ввімкнули уповільнену зйомку для цього друку, інакше він автоматично видаляється після зйомки фотографії.",
+    ffmpegNotInstalled: "ffmpeg не встановлено",
+    ffmpegRequired: "Для зйомки камерою потрібен ffmpeg. Встановіть його за допомогою <brew>brew install ffmpeg</brew> (macOS) або <apt>apt install ffmpeg</apt> (Linux).",
+    // Camera
+    camera: "Камера",
+    cameraViewMode: "Режим перегляду камери",
+    cameraOverlayDescription: "Камера відкривається в накладці зі змінним розміром на головному екрані",
+    cameraWindowDescription: "Камера відкривається в окремому вікні браузера",
+    externalCamerasDescription: "Налаштуйте зовнішні камери замість вбудованої камери принтера. Підтримує потоки MJPEG, знімки RTSP, HTTP і камери USB (V4L2). Якщо ввімкнено, зовнішня камера використовується для перегляду в реальному часі та готових фотографій.",
+    cameraPlaceholderUsb: "Шлях пристрою (/dev/video0)",
+    cameraPlaceholderUrl: "Камера URL (rtsp://... або http://...)",
+    cameraTypeMjpeg: "Потік MJPEG",
+    cameraTypeRtsp: "RTSP Потік",
+    cameraTypeSnapshot: "HTTP Знімок",
+    cameraTypeUsb: "USB Камера (V4L2)",
+    cameraSnapshotUrl: "URL знімка (необов’язково)",
+    cameraSnapshotUrlPlaceholder: "http://192.168.1.61:1984/api/frame.jpeg?src=printer",
+    cameraSnapshotUrlHelp: "Одиночний кадр URL використовується для ескізів сповіщень, фінішних фотографій, шарових кадрів уповільненої зйомки та виявлення пластин. Уповільнена зйомка та виявлення пластин потребують окремого перемикача для кожного принтера — це URL є лише джерелом зображення, з якого вони беруть участь, коли активні. Залиште поле порожнім для запису з прямої трансляції вище. Корисно для камер go2rtc (/api/frame.jpeg) і IP із спеціальною кінцевою точкою знімка.",
+    cameraRotation: "Обертання",
+    test: "Тест",
+    connected: "Підключено",
+    disconnected: "Відключено",
+    // Cost tracking
+    currency: "Валюта",
+    defaultFilamentCost: "Вартість філаменту за замовчуванням (за кг)",
+    electricityCost: "Вартість електроенергії за kWh",
+    energyDisplayMode: "Режим відображення енергії",
+    energyModePrintDescription: "Інформаційна панель показує суму енергії, використаної під час друку",
+    energyModeTotalDescription: "На приладовій панелі показано енергію від розумних розеток протягом усього терміну служби",
+    // File Manager
+    fileManager: "Менеджер файлів",
+    createArchiveEntry: "Створити архівний запис під час друку",
+    createArchiveEntryDescription: "Під час друку з диспетчера файлів можна додатково створити запис архіву",
+    lowDiskSpaceWarning: "Попередження про брак місця на диску",
+    lowDiskSpaceDescription: "Показувати попередження, коли вільний простір на диску стає нижче цього порогу",
+    // Updates
+    printerFirmware: "Прошивка принтера",
+    checkFirmwareDescription: "Перевірте наявність оновлень прошивки принтера від Bambu Lab",
+    bambuddySoftware: "Bambuddy Програмне забезпечення",
+    autoCheckDescription: "Автоматично перевіряти наявність нових версій під час запуску",
+    checkNow: "Перевір зараз",
+    updateAvailableVersion: "Доступне оновлення: v{{version}}",
+    releaseNotes: "Примітки до випуску",
+    updateViaDocker: "Оновити через Docker Написати:",
+    updateViaHomeAssistant: "Оновленнями керує супервізор Home Assistant. Відкрийте «Налаштування» → «Додатки» → Bambuddy у Home Assistant, щоб установити нову версію.",
+    updateViaWindowsInstaller: "Інсталяції Windows оновлюються шляхом повторного запуску інсталятора. Завантажте нову версію нижче — ваші дані, налаштування та принтери збережуться.",
+    downloadWindowsInstaller: "Завантажити інсталятор для v{{version}}",
+    installUpdate: "Встановити оновлення",
+    latestVersionRunning: "Ви використовуєте останню версію",
+    failedToCheckUpdates: "Не вдалося перевірити наявність оновлень: {{error}}",
+    // Data Management
+    backupRestore: "Резервне копіювання та відновлення",
+    backupRestoreDescription: "Експорт /import налаштувань і конфігурація GitHub резервного копіювання",
+    goToBackup: "Перейдіть до Резервного копіювання",
+    // Network tab
+    externalUrl: "Зовнішній URL",
+    externalUrlDescription: "Зовнішній URL, де Bambuddy доступний. Використовується для зображень сповіщень і зовнішньої інтеграції.",
+    bambuddyUrl: "Bambuddy URL",
+    externalUrlHint: "Включити протокол і порт (наприклад, http://192.168.1.100:8000)",
+    ftpRetry: "FTP Спробуйте ще раз",
+    ftpRetryDescription: "Повторювати операції FTP за ненадійного Wi-Fi принтера. Застосовується до завантаження 3MF і таймлапсів, вивантаження завдань друку та оновлення прошивки.",
+    autoRetryDescription: "Автоматично повторити невдалі операції FTP.",
+    retryAttempts: "Повторні спроби",
+    retryDelay: "Затримка повторної спроби",
+    connectionTimeout: "Час очікування підключення",
+    time_one: "час {{count}}.",
+    time_other: "{{count}} разів",
+    second_one: "{{count}} секунда",
+    second_other: "{{count}} секунд",
+    nSeconds: "{{count}} секунд",
+    increaseForWeakWifi: "Підвищення для принтерів із слабким WiFi",
+    // Home Assistant
+    homeAssistant: "Home Assistant",
+    homeAssistantFullDescription: "Підключіться до Home Assistant, щоб керувати розумними розетками через REST API HA. Підтримує об’єкти switch, light, input_boolean і script.",
+    homeAssistantUrl: "Home Assistant URL",
+    longLivedAccessToken: "Довгостроковий токен доступу",
+    haTokenHint: "Створіть токен у HA: Профіль → Довгострокові токени доступу → Створити токен",
+    connectionSuccessful: "Підключення успішне",
+    connectionFailed: "Збій підключення",
+    haConnectionSuccess: "Успішно підключено до Home Assistant.",
+    haConnectionFailed: "Не вдалося підключитися до Home Assistant.",
+    // MQTT
+    mqttPublishing: "MQTT Видавництво",
+    mqttDescription: "Публікуйте події BamBuddy зовнішньому брокеру MQTT для інтеграції з Node-RED, Home Assistant та іншими системами автоматизації.",
+    mqttEnableDescription: "Публікувати події зовнішньому MQTT брокеру",
+    brokerHostname: "Ім’я хоста брокера",
+    port: "Порт",
+    usernameOptional: "Ім’я користувача (необов’язково)",
+    passwordOptional: "Пароль (необов’язково)",
+    topicPrefix: "Префікс теми",
+    topicPrefixHint: "Теми будуть: {{prefix}}/printers/<serial>/status тощо.",
+    // Prometheus
+    prometheusMetrics: "Prometheus Метрики",
+    prometheusEndpointDescription: "Показати показники принтера на <code>/api/v1/metrics</code> для моніторингу Prometheus/Grafana.",
+    bearerTokenOptional: "Токен Bearer (необов’язково)",
+    bearerTokenHint: "Якщо встановлено, запити мають містити <code>Авторизація: носій <token></code>",
+    metricsConnectionStatus: "Статус підключення",
+    metricsPrinterState: "Стан принтера (неактивний /printing/etc)",
+    metricsPrintProgress: "Прогрес друку 0-100%",
+    metricsBedTemp: "Температура столу",
+    metricsNozzleTemp: "Температура сопла",
+    metricsPrintsTotal: "Загальна кількість друків за результатами",
+    metricsMore: "...і багато іншого (шари, вентилятори, черга, використання філаментів)",
+    // Smart Plugs
+    smartPlugsDescription: "Підключіть розумні розетки (Tasmota або Home Assistant), щоб автоматизувати керування живленням і відстежувати споживання електроенергії вашими принтерами.",
+    allOn: "Все включено",
+    allOff: "Все вимкнено",
+    addSmartPlug: "Додати розумну розетку",
+    energySummary: "Відновити енергетики",
+    currentPower: "Поточна потужність",
+    plugsOnline: "Розумні розетки в мережі: {{reachable}}/{{total}}",
+    today: "Сьогодні",
+    yesterday: "вчора",
+    total: "Всього",
+    enablePlugsForSummary: "Увімкніть розетки, щоб переглянути підсумок енергії",
+    addNotificationProvider: "додати",
+    // Users
+    systemBadge: "(Система)",
+    creating: "Створення...",
+    changing: "Зміна...",
+    deleteUserAndItems: "Видалити користувача ТА його елементи",
+    deleteUserKeepItems: "Видалити користувача, зберегти елементи (стати без власника)",
+    ok: "добре",
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: "Програма Authenticator (TOTP)",
+      totpDesc: "Використовуйте програму автентифікації, наприклад Google Authenticator Aegis або Authy.",
+      emailOtpTitle: "Одноразовий код електронною поштою",
+      emailOtpDesc: "Надішліть одноразовий код на {{email}} під час входу.",
+      emailOtpNoEmail: "Щоб увімкнути цей метод, додайте адресу електронної пошти до свого облікового запису.",
+      addEmailFirst: "У вашому обліковому записі немає адреси електронної пошти. Попросіть адміністратора додати її, перш ніж вмикати одноразові коди електронною поштою.",
+      setupTotp: "Налаштувати додаток Authenticator",
+      setupAuthApp: "Налаштувати додаток Authenticator",
+      setupInstructions: "Відскануйте наведений нижче QR-код за допомогою програми автентифікації, а потім підтвердьте кодом.",
+      manualEntry: "Не можете сканувати? Введіть цей секрет вручну:",
+      scannedContinue: "Код відскановано — продовжити",
+      enterCodeToConfirm: "Введіть 6-значний код із програми автентифікації, щоб підтвердити налаштування.",
+      activate: "Активувати",
+      disableTotp: "Вимкнути автентифікатор",
+      disableConfirmHint: "Введіть дійсний код TOTP або резервний код, щоб вимкнути автентифікатор.",
+      totpDisabled: "Програму Authenticator вимкнено.",
+      emailOtpEnabled: "Одноразові коди електронною поштою ввімкнено.",
+      emailOtpDisabled: "Одноразові коди електронною поштою вимкнено.",
+      smtpRequired: "Спочатку налаштуйте та перевірте параметри SMTP.",
+      invalidCode: "Невірний код. Спробуйте ще раз.",
+      enableEmailOtp: "Увімкнути одноразові коди електронною поштою",
+      disableEmailOtp: "Вимкнути одноразові коди електронною поштою",
+      emailSetupEnterCode: "Код підтвердження надіслано на вашу адресу електронної пошти. Введіть його нижче, щоб підтвердити доступ до цієї поштової скриньки.",
+      verifyAndEnable: "Перевірити й увімкнути",
+      emailDisablePasswordHint: "Введіть пароль свого облікового запису, щоб підтвердити вимкнення OTP електронної пошти.",
+      passwordPlaceholder: "Введіть свій пароль",
+      backupCodesTitle: "Зберегти резервні коди",
+      backupCodesWarning: "Збережіть ці коди в безпечному місці. Кожен код можна використати лише один раз, і він більше не відображатиметься.",
+      backupCodesRemaining: "Залишилося резервних кодів: {{count}}",
+      savedCodes: "Я зберіг свої коди",
+      regenBackup: "Створити нові резервні коди",
+      regenBackupHint: "Введіть поточний код TOTP, щоб створити 10 нових резервних кодів. Усі існуючі резервні коди стануть недійсними.",
+      newBackupCodes: "Нові резервні коди",
+      linkedAccounts: "Пов’язані облікові записи SSO.",
+      linkedAccountsDesc: "Ці зовнішні постачальники ідентифікаційної інформації пов’язані з вашим обліковим записом.",
+      oidcUnlinked: "Обліковий запис від’єднано.",
+    },
+
+    // Session Policy (#1706) — admin-configurable session lifetime ceiling.
+    sessionPolicy: {
+      title: "Політика сеансу",
+      description: "Максимальна тривалість сеансу для входу нових користувачів. Уже випущені токени зберігають початковий термін дії.",
+      preset24h: "24 години",
+      preset7d: "7 днів",
+      preset30d: "30 днів",
+      customHoursLabel: "Тривалість користувацького сеансу в годинах",
+      hoursSuffix: "години",
+      warning: "Довші сеанси знижують захист від автоматичного виходу. Рекомендовано лише для надійних розгортань для одного користувача.",
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: "Постачальники SSO / OIDC",
+      desc: "Налаштуйте постачальників OpenID Connect, щоб дозволити єдиний вхід через зовнішніх постачальників ідентифікації.",
+      addProvider: "Додати постачальника",
+      newProvider: "Новий постачальник",
+      empty: "Постачальників OIDC ще не налаштовано.",
+      created: "Постачальник створено.",
+      updated: "Постачальника оновлено.",
+      deleted: "Постачальника видалено.",
+      refreshIcon: "Значок оновлення",
+      removeIcon: "Видалити значок",
+      iconRefreshed: "Значок оновлено.",
+      iconRemoved: "Значок видалено.",
+      iconFetchFailed: "Не вдалося отримати значок за URL постачальника.",
+      deleteTitle: "Видалити постачальника",
+      deleteMessage: "Видалити «{{name}}»? Усі пов’язані облікові записи користувачів буде від’єднано.",
+      form: {
+        name: "Відображуване ім’я",
+        issuerUrl: "URL емітента",
+        clientId: "ID клієнта",
+        clientSecret: "Секрет клієнта",
+        scopes: "Області доступу",
+        iconUrl: "URL значка (необов’язково)",
+        enabled: "Увімкнено",
+        autoCreate: "Автоматичне створення користувачів",
+        autoCreateDesc: "Автоматично створювати локальний обліковий запис під час першого входу.",
+        autoLink: "Автоматичне пов’язування наявних облікових записів",
+        autoLinkDesc: "Автоматично пов’язувати наявні локальні облікові записи за збігом адреси електронної пошти під час першого входу.",
+        secretHint: "залиште порожнім, щоб зберегти поточне значення",
+        secretPlaceholder: "новий секрет",
+        emailClaim: "Поле електронної пошти",
+        emailClaimDesc: "Поле JWT, що використовується як адреса електронної пошти. Для Azure Entra ID, який не надсилає email_verified, використовуйте preferred_username або upn. Указуйте лише назви надійних полів.",
+        emailClaimPlaceholder: "email",
+        emailClaimCustomClaimAutoLinkWarning: "Власне поле можна безпечно використовувати для автоматичного пов’язування, лише якщо його значенням керує адміністратор організації, наприклад upn або preferred_username в Azure Entra ID. Не вмикайте автоматичне пов’язування, якщо IdP дозволяє користувачам самостійно задавати це поле.",
+        requireEmailVerified: "Вимагати підтвердження електронної пошти",
+        requireEmailVerifiedDesc: "Приймати поле електронної пошти лише тоді, коли постачальник позначив адресу як підтверджену.",
+        requireEmailVerifiedWarning: "Попередження: адресу електронної пошти буде прийнято навіть без підтвердження. Використовуйте це лише з надійними постачальниками.",
+        requireEmailVerifiedAutoLink: "Щоб змінити це налаштування, спочатку вимкніть автоматичне пов’язування.",
+        defaultGroup: "Група за замовчуванням",
+        defaultGroupDesc: "Група, яку призначають автоматично створеним користувачам. Якщо її не задано, використовується група «Глядачі».",
+        defaultGroupViewersFallback: "Глядачі (за замовчуванням)",
+        autologin: "Автовхід",
+        autologinDesc: "Одразу перенаправляти неавтентифікованих відвідувачів до цього постачальника. Автоматичний вхід можна ввімкнути лише для одного постачальника.",
+      },
+    },
+
+    encryption: {
+      title: "Стан шифрування MFA",
+      enabledFromEnv: "Шифрування даних у сховищі ввімкнено — ключ отримано зі змінної середовища MFA_ENCRYPTION_KEY",
+      enabledFromFile: "Шифрування даних у сховищі ввімкнено — ключ завантажено з каталогу даних",
+      enabledGenerated: "Шифрування даних у сховищі ввімкнено з автоматично створеним ключем",
+      notConfigured: "Шифрування даних у сховищі не налаштовано",
+      notConfiguredDesc: "Секрети TOTP і client_secret постачальників OIDC зберігаються як відкритий текст. Установіть MFA_ENCRYPTION_KEY або перезапустіть Bambuddy з доступним для запису каталогом даних, щоб ключ було створено автоматично.",
+      allEncrypted: "Усі секрети MFA зашифровано у сховищі.",
+      legacyRowsLabel: "Застарілі рядки відкритого тексту",
+      encryptedRowsLabel: "Зашифровані рядки",
+      legacyRowsWarning: "Виявлено незашифрованих застарілих записів: {{count}}. Повторно збережіть постачальника OIDC або заново зареєструйте застосунок автентифікації користувача, щоб перенести дані до зашифрованого сховища.",
+      backupHint: "Автоматично згенерований ключ зберігається в DATA_DIR/.mfa_encryption_key і включено до локальних резервних ZIP-файлів. Зберігайте свої резервні копії в безпеці або явно встановіть MFA_ENCRYPTION_KEY.",
+      decryptionBrokenTitle: "Відсутній ключ шифрування",
+      decryptionBrokenError: "Не вдається розшифрувати записів: {{count}}, оскільки ключ шифрування більше недоступний. Для відновлення поверніть попередній MFA_ENCRYPTION_KEY або DATA_DIR/.mfa_encryption_key.",
+      migrationErrorWarning: "{{count}} застарілих рядків не вдалося повторно зашифрувати під час запуску. Перевірте журнали сервера та перезапустіть Bambuddy, щоб повторити спробу.",
+    },
+
+    // Slicer Pipeline limits (#1425 PR C). Admin-tunable cap that constrains
+    // the copies input in the Run-with-pipeline modal. Lives on the Workflow
+    // tab's Queue & Dispatch sub-tab.
+    pipelineLimits: {
+      title: "Межі конвеєра слайсера",
+      maxCopiesLabel: "Максимальна кількість копій за один запуск",
+      maxCopiesDesc: "Верхня межа копій, які оператори можуть запитувати під час запуску конвеєра. Обмеження на стороні сервера становить 1000.",
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: "Конвеєри слайсера",
+      subtitle: "Багаторазові набори профілів — принтер, процес, філаменти й тип столу. Збережіть набір у вікні нарізання та застосовуйте його до наступного файлу одним натисканням.",
+      loading: "Завантаження конвеєрів…",
+      loadError: "Не вдалося завантажити конвеєри.",
+      confirmDelete: "Видалити цей конвеєр? Це неможливо скасувати.",
+      staleWarning: "Одного або кількох пов’язаних профілів більше не існує. Щоб виправити це, повторно збережіть конвеєр у вікні нарізання.",
+      empty: {
+        title: "Конвеєрів ще немає.",
+        howto: "Відкрийте вікно нарізання для будь-якого файлу, виберіть принтер, процес, філаменти й тип столу, а потім натисніть «Зберегти як конвеєр». Збережені конвеєри з’являться тут.",
+      },
+      field: {
+        name: "Назва конвеєра",
+        description: "опис",
+        targetPrinter: "Цільовий принтер",
+        noTarget: "— Немає цілі —",
+        // PR C
+        targetKind: "Цільовий тип",
+        targetKindSpecific: "Конкретний принтер",
+        targetKindClass: "Клас принтера",
+        targetModelClass: "Модель принтера",
+        fanoutStrategy: "Стратегія розподілу",
+        fanout: {
+          max_parallel: "Максимально паралельно — розподіляти між усіма відповідними вільними принтерами",
+          round_robin: "По колу — почергово використовувати відповідні принтери",
+          fill_one_first: "Спочатку один принтер — призначити всі копії одному принтеру",
+        },
+        // Short labels for the inline target chip on each pipeline card.
+        // The verbose ones above explain the strategy in the editor; the
+        // card just needs a compact reminder of which one is in use.
+        fanoutShort: {
+          max_parallel: "паралельний",
+          round_robin: "по колу",
+          fill_one_first: "спочатку один",
+        },
+      },
+      action: {
+        save: "зберегти",
+        cancel: "Скасувати",
+        rename: "Перейменувати",
+        delete: "Видалити",
+      },
+      slot: {
+        printer: "Принтер",
+        process: "процес",
+        filament: "Філамент",
+        filamentN: "Філамент {{n}}",
+        filamentAll: "Усі {{n}} слотів",
+        bed: "Стіл",
+      },
+      // PR C polish — grouped sections in the card body, plus the
+      // panel-level search + filter row.
+      group: {
+        profiles: "Профілі",
+        filaments: "філаменти",
+      },
+      searchPlaceholder: "Шукати конвеєри…",
+      filterTargetType: "Фільтрувати за типом цілі",
+      filterTarget: "Фільтрувати за ціллю",
+      filter: {
+        all: "Всі цілі",
+        noTarget: "Ціль не встановлена",
+        count: "{{shown}} / {{total}}",
+        noMatches: "Жоден конвеєр не відповідає поточним фільтрам.",
+      },
+      toast: {
+        saved: "Конвеєр збережено",
+        saveFailed: "Не вдалося зберегти",
+        deleted: "Конвеєр видалено",
+        deleteFailed: "Не вдалося видалити",
+      },
+      // PR B target binding + last-run summary
+      noTargetHint: "Щоб запустити конвеєр, виберіть цільовий принтер",
+      noTargetWarning: "Перед запуском цього конвеєра встановіть цільовий принтер.",
+      runs: {
+        lastRun: "Останній запуск",
+        status: {
+          queued: "в черзі",
+          slicing: "нарізка",
+          dispatching: "надсилання",
+          in_progress: "друк",
+          completed: "завершено",
+          failed: "не вдалося",
+          partial_failure: "частково невдало",
+          cancelled: "скасовано",
+        },
+      },
+    },
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: "Друк розпочато",
+      body: "{{printer}}: розпочато друк {{filename}}",
+    },
+    printCompleted: {
+      title: "Друк завершено",
+      body: "{{printer}}: {{filename}} завершено успішно",
+    },
+    printFailed: {
+      title: "Помилка друку",
+      body: "{{printer}}: не вдалося надрукувати {{filename}}",
+    },
+    printStopped: {
+      title: "Друк зупинено",
+      body: "{{printer}}: {{filename}} було зупинено",
+    },
+    printProgress: {
+      title: "Перебіг друку",
+      body: "{{printer}}: {{filename}} виконано {{percent}}%.",
+    },
+    printerOffline: {
+      title: "Принтер не в мережі",
+      body: "{{printer}} не в мережі",
+    },
+    printerError: {
+      title: "Помилка принтера",
+      body: "{{printer}}: {{error}}",
+    },
+    filamentLow: {
+      title: "Закінчується філамент",
+      body: "{{printer}}: філамент майже закінчився",
+    },
+    maintenanceDue: {
+      title: "Технічне обслуговування",
+      body: "{{printer}}: потрібна увага — {{items}}",
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: "Щось пішло не так",
+    networkError: "Помилка мережі. Перевірте підключення.",
+    notFound: "Не знайдено",
+    unauthorized: "Доступ не авторизовано",
+    serverError: "Помилка сервера",
+    validationError: "Перевірте введені дані",
+    printerConnectionFailed: "Не вдалося підключитися до принтера",
+    saveFailed: "Не вдалося зберегти зміни",
+    deleteFailed: "Не вдалося видалити",
+    loadFailed: "Не вдалося завантажити дані",
+  },
+
+  // HMS Errors modal
+  hmsErrors: {
+    title: "Помилки - {{name}}",
+    noErrors: "Помилок немає",
+    viewOnWiki: "Переглянути на Bambu Lab Wiki",
+    unknownCode: "Невідомий код HMS — подробиці дивіться у вікі Bambu Lab.",
+    clearInstructions: "Усуньте помилки на принтері, щоб вони зникли тут.",
+    clearErrors: "Очистити помилки",
+    clearSuccess: "Помилки HMS видалено",
+    clearFailed: "Не вдалося видалити помилки HMS",
+    actionSuccess: "Команду надіслано принтеру",
+    actionFailed: "Не вдалося надіслати дію",
+    runoutExpectedSlot: "Філамент закінчився в {{ranOut}}. Тепер принтер очікує сумісний філамент у {{expected}}. Вставте котушку в {{expected}}, а потім виберіть «Повторити».",
+    runoutExpectedSlotOnly: "Принтер очікує сумісний філамент у {{expected}}. Вставте туди котушку, а потім виберіть «Повторити».",
+    runoutSlotUnknown: "Філамент закінчився, тому друк призупинено. Bambuddy не може визначити, якого слота тепер очікує принтер — перевірте екран принтера.",
+    actions: {
+      RESUME_PRINTING: "Відновити друк",
+      RESUME_PRINTING_DEFECTS: "Відновити (допустимі недоліки)",
+      RESUME_PRINTING_PROBELM_SOLVED: "Відновити (проблему вирішено)",
+      STOP_PRINTING: "Зупинити друк",
+      CHECK_ASSISTANT: "Перевірити помічника",
+      FILAMENT_EXTRUDED: "Філамент екструдовано — продовжити",
+      RETRY_FILAMENT_EXTRUDED: "Ще не екструдовано — повторити",
+      CONTINUE: "Готово, продовжити",
+      LOAD_VIRTUAL_TRAY: "Завантажити філамент",
+      OK_BUTTON: "OK",
+      FILAMENT_LOAD_RESUME: "Філамент завантажено, відновити",
+      JUMP_TO_LIVEVIEW: "Перейти до перегляду наживо",
+      NO_REMINDER_NEXT_TIME: "Більше не нагадувати",
+      REFRESH_NOZZLE: "Перевірити знову",
+      IGNORE_NO_REMINDER_NEXT_TIME: "Ігнорувати. Не нагадувати наступного разу",
+      IGNORE_RESUME: "Ігнорувати й відновити друк",
+      PROBLEM_SOLVED_RESUME: "Проблему вирішено — відновити друк",
+      TURN_OFF_FIRE_ALARM: "Зрозуміло, вимкнути пожежну сигналізацію",
+      RETRY_PROBLEM_SOLVED: "Повторити (проблему вирішено)",
+      CANCLE: "Скасувати",
+      STOP_DRYING: "Зупинити сушіння",
+      PROCEED: "Продовжити",
+      OK_JUMP_RACK: "OK",
+      ABORT: "Перервати",
+      DISABLE_PURIFICATION: "Вимкнути очищення для цього друку",
+      DONT_REMIND_NEXT_TIME: "Більше не нагадувати",
+      DBL_CHECK_CANCEL: "Скасувати",
+      DBL_CHECK_DONE: "Готово",
+      DBL_CHECK_RETRY: "Повторити",
+      DBL_CHECK_RESUME: "Відновити",
+      DBL_CHECK_OK: "Підтвердити",
+      REMOVE_CLOSE_BTN: "Закрити",
+    }
+  },
+
+  // MQTT Debug modal
+  mqttDebug: {
+    title: "Журнал налагодження MQTT",
+    searchPlaceholder: "Шукати тему або корисне навантаження…",
+    noMessages: "Ще не зареєстровано жодного повідомлення",
+    startLoggingHint: "Натисніть «Почати журналювання», щоб розпочати запис MQTT повідомлень",
+    noMessagesMatch: "Жодне повідомлення не відповідає вашому фільтру",
+    adjustFilterHint: "Спробуйте змінити критерії пошуку або фільтра",
+    incoming: "Вхідні",
+    outgoing: "Вихідні",
+    loggingStopped: "Запис припинено",
+    loggingActive: "Логування активне – повідомлення автоматично оновлюватимуться",
+    startLogging: "Розпочати журналювання",
+    stopLogging: "Зупинити журналювання",
+    clearLog: "Очистити журнал",
+    topic: "Тема",
+    timestamp: "Мітка часу",
+    direction: "Напрямок",
+    all: "Усі",
+  },
+
+  // Printer File Manager modal (printer internal storage)
+  printerFiles: {
+    title: "Менеджер файлів",
+    storageUsed: "Використовується:",
+    storageFree: "Вільно:",
+    filterPlaceholder: "Фільтр файлів...",
+    deleteButton: "Видалити",
+    deleteFiles: "Видалити файли {{count}}.",
+    deleteFileConfirm: "Видалити \"{{name}}\"? Це неможливо скасувати.",
+    deleteFilesConfirm: "Видалити вибрані файли {{count}}? Це неможливо скасувати.",
+    noFiles: "Немає файлів на принтері",
+    loadingFiles: "Завантаження файлів...",
+    failedToLoad: "Не вдалося завантажити файли",
+    toast: {
+      filesDeleted: "Видалено файлів: {{count}}",
+      deleteFailed: "Не вдалося видалити: {{error}}",
+    },
+  },
+
+  // Confirmations
+  confirm: {
+    delete: "Ви впевнені, що хочете це видалити?",
+    unsavedChanges: "У вас є незбережені зміни. Ви впевнені, що хочете піти?",
+    clearQueue: "Ви впевнені, що бажаєте очистити чергу?",
+  },
+
+  // Login page
+  login: {
+    title: "Вхід у Bambuddy",
+    subtitle: "Увійти у свій обліковий запис",
+    username: "Ім’я користувача",
+    usernamePlaceholder: "Введіть своє ім’я користувача",
+    usernameOrEmail: "Ім’я користувача або електронна пошта",
+    usernameOrEmailPlaceholder: "Ім’я користувача або електронна пошта",
+    password: "Пароль",
+    passwordPlaceholder: "Введіть свій пароль",
+    signIn: "Увійти",
+    signingIn: "Вхід...",
+    rememberMe: "Запам’ятати мене",
+    forgotPassword: "Забули пароль?",
+    autologinFailed: "Помилка автоматичного входу SSO. Щоб продовжити, виберіть постачальника нижче.",
+    localDisabledNotice: "Локальний вхід вимкнено. Використовуйте одного з наведених нижче постачальників SSO.",
+    loginSuccess: "Успішно ввійшли",
+    loginFailed: "Помилка входу",
+    enterCredentials: "Будь ласка, введіть ім’я користувача та пароль",
+    enterEmail: "Будь ласка, введіть адресу електронної пошти",
+    oidcLoginFailed: "Не вдалося ввійти через OIDC",
+    oidcErrors: {
+      providerError: "Постачальник ідентифікації повернув помилку",
+      missingParameters: "У зворотному виклику OIDC відсутні необхідні параметри",
+      invalidState: "Стан OIDC недійсний або вже використовувався",
+      stateExpired: "OIDC сеанс входу закінчився — спробуйте ще раз",
+      providerNotFound: "OIDC постачальника не знайдено",
+      discoveryFailed: "Не вдалося отримати документ конфігурації OIDC",
+      invalidDiscovery: "Документ конфігурації OIDC недійсний",
+      networkError: "Помилка мережі під час обміну токена OIDC",
+      badResponse: "Неочікувана відповідь під час обміну токеном OIDC.",
+      noIdToken: "Постачальник OIDC не повернув ID-токен",
+      validationFailed: "Не вдалося перевірити токен OIDC",
+      nonceMismatch: "Не збігається nonce OIDC — можлива атака повторного використання",
+      missingSubClaim: "У токені OIDC відсутнє поле sub",
+      noLinkedAccount: "Із цим ідентифікатором OIDC не пов’язано жодного локального облікового запису",
+      accountInactive: "Ваш акаунт неактивний",
+      userResolutionFailed: "Не вдалося визначити ваш обліковий запис",
+      internalError: "Під час входу в систему OIDC сталася внутрішня помилка",
+      tokenExchangeFailed: "Помилка обміну токеном OIDC.",
+    },
+    forgotPasswordTitle: "Забули пароль",
+    forgotPasswordMessage: "Якщо ви забули свій пароль, зверніться до системного адміністратора, щоб скинути його.",
+    forgotPasswordEmailMessage: "Введіть адресу електронної пошти, і ми надішлемо вам новий пароль.",
+    emailAddress: "Адреса електронної пошти",
+    emailPlaceholder: "your.email@example.com",
+    cancel: "Скасувати",
+    sending: "Надсилання...",
+    sendResetEmail: "Надіслати лист для скидання пароля",
+    howToReset: "Як скинути пароль:",
+    resetStep1: "Зверніться до адміністратора Bambuddy.",
+    resetStep2: "Попросіть скинути ваш пароль у розділі «Керування користувачами»",
+    resetStep3: "Вони можуть встановити для вас новий тимчасовий пароль",
+    resetStep4: "Увійдіть із новим паролем і змініть його в налаштуваннях",
+    gotIt: "Зрозуміло",
+    resetPassword: {
+      title: "Встановити новий пароль",
+      subtitle: "Введіть і підтвердьте свій новий пароль нижче.",
+      newPassword: "Новий пароль",
+      newPasswordPlaceholder: "Мінімум 8 символів",
+      confirmPassword: "Підтвердити пароль",
+      confirmPasswordPlaceholder: "Повторіть новий пароль",
+      saving: "Збереження…",
+      submit: "Встановити новий пароль",
+      backToLogin: "Назад до входу",
+      passwordsDoNotMatch: "Паролі не збігаються",
+      passwordTooShort: "Пароль має бути не менше 8 символів",
+      resetFailed: "Помилка скидання пароля. Можливо, термін дії посилання закінчився.",
+    },
+    twoFA: {
+      title: "Двофакторна автентифікація",
+      subtitle: "Ваш обліковий запис захищено 2FA. Введіть код підтвердження нижче.",
+      methodAuthenticator: "Програма автентифікатора",
+      methodEmail: "Код електронної пошти",
+      methodBackup: "Резервний код",
+      instructionsTotp: "Відкрийте програму автентифікації та введіть 6-значний код для Bambuddy.",
+      instructionsEmail: "На вашу електронну адресу надіслано 6-значний код. Термін дії закінчується через 10 хвилин.",
+      instructionsEmailNotSent: "Натисніть кнопку нижче, щоб отримати код підтвердження електронною поштою.",
+      instructionsBackup: "Введіть один із ваших 8-символьних резервних кодів відновлення. Кожен код можна використати лише один раз.",
+      sendCodeButton: "Надіслати код електронною поштою",
+      sendingCode: "Надсилання...",
+      resendCode: "Повторно надіслати код",
+      codeLabel: "Код підтвердження",
+      backupCodeLabel: "Резервний код",
+      codePlaceholder: "000000",
+      backupCodePlaceholder: "XXXXXXXX",
+      verifyButton: "Підтвердити",
+      verifyingButton: "Перевірка...",
+      backToLogin: "← Назад до входу",
+      orContinueWith: "або продовжити",
+      signInWith: "Увійти за допомогою {{provider}}",
+      enterCode: "Будь ласка, введіть код підтвердження",
+      sendCodeFailed: "Не вдалося надіслати код підтвердження",
+      invalidCode: "Невірний код. Спробуйте ще раз.",
+    },
+
+  },
+
+  // Setup page
+  setup: {
+    title: "Налаштування Bambuddy",
+    subtitle: "Налаштуйте автентифікацію для свого екземпляра Bambuddy.",
+    enableAuth: "Увімкнути автентифікацію",
+    adminAccount: "Обліковий запис адміністратора",
+    adminAccountDesc: "Якщо користувачі-адміністратори вже існують, автентифікацію буде ввімкнено за допомогою наявних облікових записів адміністраторів. Залиште поля нижче порожніми, щоб використовувати наявних адміністраторів, або введіть нові облікові дані, щоб створити нового користувача адміністратора.",
+    adminUsername: "Ім’я користувача адміністратора",
+    adminPassword: "Пароль адміністратора",
+    optionalIfAdminExists: "(необов’язково, якщо є користувачі з правами адміністратора)",
+    adminUsernamePlaceholder: "Введіть ім’я користувача адміністратора (необов’язково)",
+    adminPasswordPlaceholder: "Введіть пароль адміністратора (необов’язково)",
+    confirmPassword: "Підтвердити пароль",
+    confirmPasswordPlaceholder: "Підтвердити пароль адміністратора",
+    settingUp: "Налаштування...",
+    completeSetup: "Завершити налаштування",
+    toast: {
+      authEnabledAdminCreated: "Увімкнено автентифікацію та створено адміністратора",
+      authEnabledExistingAdmins: "Увімкнено автентифікацію за допомогою наявних користувачів адміністратора",
+      setupCompleted: "Налаштування завершено",
+      enterBothCredentials: "Будь ласка, введіть ім’я користувача та пароль адміністратора або залиште обидва поля порожніми, щоб використовувати наявних користувачів адміністратора",
+      passwordsDoNotMatch: "Паролі не збігаються",
+      passwordTooShort: "Пароль має бути не менше 6 символів",
+    },
+  },
+
+  // Password change
+  changePassword: {
+    title: "Змінити пароль",
+    currentPassword: "Поточний пароль",
+    currentPasswordPlaceholder: "Введіть поточний пароль",
+    newPassword: "Новий пароль",
+    newPasswordPlaceholder: "Введіть новий пароль (мінімум 6 символів)",
+    confirmPassword: "Підтвердити новий пароль",
+    confirmPasswordPlaceholder: "Підтвердити новий пароль",
+    passwordsDoNotMatch: "Паролі не збігаються",
+    passwordTooShort: "Пароль має бути не менше 6 символів",
+    changing: "Зміна...",
+    success: "Пароль успішно змінено",
+    failed: "Не вдалося змінити пароль",
+  },
+
+  // Plate detection alert
+  plateAlert: {
+    title: "Друк призупинено!",
+    message: "Об’єкти, виявлені на будівельній пластині. Друк було автоматично призупинено. Очистіть пластину та відновіть друк.",
+    understand: "Я розумію",
+  },
+
+  // Camera page
+  camera: {
+    title: "Вид з камери",
+    invalidPrinterId: "Недійсний ідентифікатор принтера",
+    live: "Наживо",
+    snapshot: "знімок",
+    restartStream: "Перезапустити потік",
+    refreshSnapshot: "Оновити знімок",
+    fullscreen: "Повний екран",
+    exitFullscreen: "Вийти з повноекранного режиму",
+    connectingToCamera: "Підключення до камери...",
+    capturingSnapshot: "Зйомка знімка...",
+    connectionLost: "З’єднання втрачено",
+    connectionFailed: "Помилка підключення камери",
+    reconnecting: "Повторне підключення через {{countdown}}с... (спроба {{attempt}}/{{max}})",
+    reconnectNow: "Повторно підключіться зараз",
+    cameraUnavailable: "Камера недоступна",
+    cameraUnavailableDesc: "Переконайтеся, що принтер увімкнено та підключено.",
+    noCamera: "Камера недоступна",
+    retry: "Повторити",
+    cameraStream: "Потік камери",
+    zoomOut: "Зменшити масштаб",
+    zoomIn: "Збільшити",
+    resetZoom: "Скинути масштаб",
+    recording: "Запис",
+    startRecording: "Почати запис",
+    stopRecording: "Зупинити запис",
+    chamberLight: "Перемкнути освітлення камери",
+    unavailable: "Камера недоступна",
+    diagnose: {
+      button: "Діагностувати",
+      modalTitle: "Діагностика камери",
+      running: "Виконується діагностика...",
+      runFailed: "Не вдалося запустити діагностику: {{error}}",
+      retry: "Запустити знову",
+      stage: {
+        tcp_reachable: "Доступність мережі",
+        first_frame: "Захоплення кадру",
+        live_stream_active: "Активна пряма трансляція",
+      },
+      summary: {
+        all_ok: "Камера працює. Діагностика пройшла всі етапи успішно.",
+        live_stream_active_healthy: "Камера зараз транслює з останніми кадрами — перевірка не потрібна.",
+        printer_unreachable: "Принтер недоступний. Перевірте адресу IP, підключення до мережі та чи принтер увімкнено.",
+        camera_port_closed: "Принтер доступний, але порт камери закритий. Переконайтеся, що в налаштуваннях принтера ввімкнено лише режим LAN і режим розробника.",
+        no_frame: "Підключено до камери, але кадри не отримано. Повторіть спробу або перевірте, чи камеру ввімкнено в налаштуваннях принтера.",
+        unknown_failure: "Не вдалося діагностувати камеру з невідомої причини. Подробиці перевірте в журналі підтримки.",
+      },
+      meta: {
+        protocol: "Протокол",
+        port: "Порт",
+        profile: "Профіль",
+      },
+    },
+  },
+
+  // Groups management
+  groups: {
+    title: "Керування групами",
+    subtitle: "Керуйте групами дозволів для контролю доступу",
+    backToSettings: "Назад до налаштувань",
+    createGroup: "Створити групу",
+    noPermission: "Ви не маєте дозволу на доступ до цієї сторінки.",
+    system: "система",
+    noDescription: "Без опису",
+    usersCount: "{{count}} користувачів",
+    permissionsCount: "Дозволів: {{count}}",
+    edit: "Редагувати",
+    delete: "Видалити",
+    toast: {
+      created: "Групу створено успішно",
+      updated: "Групу успішно оновлено",
+      deleted: "Групу успішно видалено",
+      enterGroupName: "Будь ласка, введіть назву групи",
+    },
+    modal: {
+      editGroup: "Редагувати групу",
+      createGroup: "Створити групу",
+      cancel: "Скасувати",
+      saving: "Збереження...",
+      creating: "Створення...",
+      saveChanges: "Зберегти зміни",
+    },
+    form: {
+      groupName: "Назва групи",
+      groupNamePlaceholder: "Введіть назву групи",
+      systemGroupWarning: "Назви системних груп не можна змінювати",
+      description: "опис",
+      descriptionPlaceholder: "Введіть опис (необов’язково)",
+      permissions: "Дозволи (вибрано {{count}})",
+    },
+    deleteModal: {
+      title: "Видалити групу",
+      message: "Ви впевнені, що хочете видалити цю групу? Користувачі цієї групи втратять ці дозволи.",
+      confirm: "Видалити групу",
+    },
+    editor: {
+      title: "Редагувати групу",
+      createTitle: "Створити групу",
+      search: "Шукати дозволи...",
+      selectAll: "Вибрати усі",
+      clearAll: "Очистити все",
+      permissionsSelected: "Вибрано: {{count}}",
+      noResults: "Немає дозволів, що відповідають вашому пошуку",
+      websocketHint: "Необхідний для оновлень у реальному часі. Без нього інтерфейс повертається до періодичного опитування.",
+    },
+  },
+
+  // Users management
+  users: {
+    title: "Керування користувачами",
+    subtitle: "Керуйте користувачами та їхнім доступом до вашого екземпляра Bambuddy.",
+    backToSettings: "Назад до налаштувань",
+    createUser: "Створити користувача",
+    noPermission: "Ви не маєте дозволу на доступ до цієї сторінки.",
+    admin: "адмін",
+    noGroups: "Немає груп",
+    active: "Активний",
+    inactive: "Неактивний",
+    edit: "Редагувати",
+    delete: "Видалити",
+    system: "система",
+    noGroupsAvailable: "Немає доступних груп",
+    table: {
+      username: "Ім’я користувача",
+      groups: "Групи",
+      status: "Статус",
+      actions: "Дії",
+    },
+    toast: {
+      created: "Користувача створено успішно",
+      updated: "Користувача успішно оновлено",
+      deleted: "Користувача успішно видалено",
+      fillRequired: "Будь ласка, заповніть усі необхідні поля",
+      passwordsDoNotMatch: "Паролі не збігаються",
+      passwordTooShort: "Пароль має бути не менше 6 символів",
+      ldapProvisioned: "Користувача LDAP «{{username}}» підготовлено",
+    },
+    modal: {
+      createUser: "Створити користувача",
+      editUser: "Редагувати користувача",
+      cancel: "Скасувати",
+      creating: "Створення...",
+      saving: "Збереження...",
+      saveChanges: "Зберегти зміни",
+      advancedAuthSubtitle: "з розширеною автентифікацією",
+      // LDAP manual provisioning (#1298)
+      tabsAriaLabel: "Джерело користувача",
+      localTab: "Локальний",
+      ldapTab: "LDAP",
+      ldapSearchLabel: "Пошук у каталозі",
+      ldapSearchPlaceholder: "Введіть ім’я користувача, ім’я або електронну адресу...",
+      ldapMinChars: "Введіть принаймні 2 символи для пошуку",
+      ldapTypeToSearch: "Почніть вводити текст для пошуку в каталозі LDAP.",
+      ldapSearching: "Пошук у каталозі...",
+      ldapNoResults: "У каталозі немає відповідних користувачів",
+      ldapSearchError: "Помилка пошуку каталогу. Перевірте стан сервера LDAP.",
+      ldapAlreadyProvisioned: "Уже підготовлено",
+      ldapSelectedLabel: "Вибране",
+      ldapProvision: "Підготувати користувача",
+      ldapProvisioning: "Підготовка...",
+      ldapErrorProvision: "Не вдалося підготувати користувача. Перевірте стан сервера LDAP і повторіть спробу.",
+    },
+    form: {
+      username: "Ім’я користувача",
+      usernamePlaceholder: "Введіть ім’я користувача",
+      email: "Електронна пошта",
+      emailPlaceholder: "user@example.com",
+      password: "Пароль",
+      passwordPlaceholder: "Введіть пароль",
+      confirmPassword: "Підтвердити пароль",
+      confirmPasswordPlaceholder: "Підтвердити пароль",
+      newPasswordPlaceholder: "Введіть новий пароль",
+      confirmNewPasswordPlaceholder: "Підтвердити новий пароль",
+      leaveBlankToKeep: "залиште порожнім, щоб зберегти поточне значення",
+      groups: "Групи",
+      optional: "необов’язково",
+      autoGeneratedPassword: "Надійний пароль буде автоматично згенеровано та надіслано користувачеві електронною поштою.",
+      passwordManagedByAdvancedAuth: "Пароль керується розширеною автентифікацією. Використовуйте «Скинути пароль», щоб надіслати новий пароль користувачеві електронною поштою.",
+      resetPassword: "Скинути пароль",
+      resettingPassword: "Скидання пароля...",
+    },
+    deleteModal: {
+      title: "Видалити користувача",
+      message: "Ви впевнені, що хочете видалити цього користувача? Цю дію не можна скасувати.",
+      confirm: "Видалити користувача",
+    },
+  },
+
+  // Stream overlay
+  streamOverlay: {
+    title: "Накладання потоку",
+    invalidPrinterId: "Недійсний ідентифікатор принтера",
+    cameraStream: "Потік камери",
+    progress: "Прогрес",
+    eta: "ETA",
+    printerIdle: "Принтер неактивний",
+    printerOffline: "Принтер не в мережі",
+    status: {
+      printing: "Друк",
+      paused: "Призупинено",
+      finished: "Готово",
+      failed: "Не вдалося",
+      idle: "Неактивний",
+      unknown: "Невідомий",
+    },
+  },
+
+  // Profiles
+  profiles: {
+    title: "Профілі",
+    subtitle: "Керуйте профілями слайсера та калібруваннями Pressure Advance",
+    tabs: {
+      bambuCloud: "Bambu Cloud",
+      orcaCloud: "Orca Cloud",
+      local: "Локальні профілі",
+      kprofiles: "K-профілі",
+    },
+    orcaCloud: {
+      connectedAs: "Підключено як",
+      connectedShort: "Підключено до Orca Cloud",
+      logout: "Відключити",
+      noLogoutPermission: "Ви не маєте дозволу на відключення",
+      noConnectPermission: "Ви не маєте дозволу на підключення до Orca Cloud",
+      retry: "Повторити",
+      connectButton: "Підключити Orca Cloud",
+      connect: {
+        title: "Підключитися до Orca Cloud",
+        description: "Увійти у свій обліковий запис Orca Cloud, щоб синхронізувати свої профілі слайсера в Bambuddy.",
+      },
+      device: {
+        title: "Підтвердити Bambuddy в Orca Cloud",
+        instruction: "Відкрийте Orca Cloud і підтвердьте цей код. Bambuddy підключається автоматично, коли ви підтверджуєте.",
+        codeLabel: "Ваш код сполучення",
+        openButton: "Відкрити сторінку підтвердження Orca Cloud",
+        manualHint: "Або перейдіть до {{url}} і введіть код вище.",
+        waiting: "Чекаємо на ваше схвалення…",
+        cancel: "Скасувати",
+      },
+      profiles: {
+        title: "Ваші профілі Orca Cloud ({{count}})",
+        refresh: "Оновити",
+        empty: "У вашому обліковому записі Orca Cloud ще не знайдено профілів.",
+      },
+      toast: {
+        disconnected: "Відключено від Orca Cloud",
+      },
+      errors: {
+        startFailed: "Не вдалося розпочати підключення Orca Cloud.",
+        denied: "Сполучення було відхилено в Orca Cloud.",
+        expired: "Термін дії коду сполучення минув. Натисніть Підключитися, щоб повторити спробу.",
+        pollFailed: "Втрачено з’єднання під час очікування підтвердження. Спробуйте ще раз.",
+      },
+    },
+    localProfiles: {
+      title: "Локальні профілі",
+      subtitle: "Імпорт і керування профілями слайсера з OrcaSlicer",
+      import: "Імпортувати профілі",
+      importDesc: "Перетягніть сюди файли .bbscfg, .bbsflmt, .orca_filament, .zip або .json",
+      importing: "Імпортування…",
+      search: "Шукати локальні профілі…",
+      noPresets: "Локальних профілів ще немає",
+      noSearchResults: "Профілів за вашим запитом не знайдено",
+      badge: "Локальний",
+      edit: "Редагувати",
+      delete: "Видалити",
+      cancel: "Скасувати",
+      deleteConfirmTitle: "Видалити профіль",
+      deleteConfirm: "Видалити цей профіль? Цю дію неможливо скасувати.",
+      source: "Джерело",
+      inheritsFrom: "Успадковує",
+      filamentType: "Тип",
+      vendor: "Виробник",
+      compatiblePrinters: "Принтери",
+      nozzleTemp: "Температура сопла",
+      cost: "Вартість",
+      density: "Щільність",
+      pressureAdvance: "Випередження тиску",
+      filament: "Філамент",
+      process: "Процес",
+      printer: "Принтер",
+      toast: {
+        importSuccess: "Імпортовано профілів: {{count}}",
+        importSkipped: "Пропущено профілів-дублікатів: {{count}}",
+        importError: "Помилок під час імпорту: {{count}}",
+        deleted: "Профіль видалено",
+        updated: "Профіль оновлено",
+      },
+    },
+    connectedAs: "Підключено як",
+    signInExpiredTitle: "Термін входу в Bambu Cloud минув",
+    signInExpiredBody: "Bambu Lab більше не приймає збережений токен. Увійдіть ще раз, щоб відновити хмарні профілі, імпорт MakerWorld і перевірку прошивки.",
+    logout: "Вийти",
+    noLogoutPermission: "Ви не маєте дозволу на вихід",
+    failedToLoad: "Не вдалося завантажити профілі",
+    retry: "Повторити",
+    time: {
+      justNow: "Щойно",
+      minsAgo: "{{count}}хв тому",
+      hoursAgo: "{{count}} год тому",
+      daysAgo: "{{count}} дн. тому",
+    },
+    toast: {
+      loggedOut: "Вихід виконано",
+    },
+    login: {
+      title: "Підключитися до Bambu Cloud",
+      subtitle: "Синхронізуйте профілі слайсера між пристроями",
+      email: "Електронна пошта",
+      password: "Пароль",
+      region: "Регіон",
+      regionGlobal: "Глобальний",
+      regionChina: "Китай",
+      verificationCode: "Код підтвердження",
+      totpCode: "Код автентифікатора",
+      checkEmail: "Перевірте свою електронну пошту ({{email}}), щоб отримати 6-значний код",
+      enterTotpHint: "Введіть 6-значний код із програми автентифікації",
+      accessToken: "Токен доступу",
+      accessTokenHint: "Вставте токен доступу Bambu Cloud. Облікові записи в китайському регіоні мають використовувати цей спосіб, оскільки вони прив’язані до телефона й вхід електронною поштою недоступний. У вікі описано, як отримати токен із файлів cookie MakerWorld.",
+      back: "Назад",
+      loginButton: "Увійти",
+      verifyButton: "Підтвердити",
+      setTokenButton: "Установити токен",
+      useToken: "Натомість використати токен доступу",
+      useEmail: "Увійти за допомогою електронної пошти",
+      toast: {
+        loggedIn: "Успішно ввійшли",
+        codeSent: "Код підтвердження надіслано на вашу електронну адресу",
+        enterTotp: "Введіть код із програми автентифікації",
+        tokenSet: "Токен успішно встановлено",
+      },
+    },
+    presets: {
+      myPreset: "Мій профіль (можна редагувати)",
+      duplicate: "Дублювати",
+      editable: "Можна редагувати",
+      failedToLoadDetails: "Не вдалося завантажити відомості про профіль",
+      deleteConfirm: "Видалити цей профіль?",
+      deleteWarning: "Це назавжди видалить \"{{name}}\" із Bambu Cloud. Це неможливо скасувати.",
+      noDuplicatePermission: "Ви не маєте дозволу на дублювання стилів",
+      noEditPermission: "Ви не маєте дозволу редагувати стилі",
+      noDeletePermission: "Ви не маєте дозволу на видалення стилів",
+      types: {
+        filament: "Попереднє налаштування філаменту",
+        printer: "Попереднє налаштування принтера",
+        process: "Попереднє налаштування процесу",
+      },
+      toast: {
+        deleted: "Попереднє налаштування видалено",
+        created: "Попереднє налаштування створено",
+        updated: "Попереднє налаштування оновлено",
+        duplicated: "Профіль продубльовано",
+        fieldAdded: "Додано поле \"{{key}}\".",
+        exported: "Попереднє налаштування експортовано",
+      },
+      baseLabel: "База: {{name}}",
+      currentLabel: "Поточний профіль: {{name}}",
+      newPreset: "Нове налаштування",
+      editPreset: "Редагувати профіль",
+      duplicatePreset: "Дублювати профіль",
+      createNewPreset: "Створити новий профіль",
+      customizeSettings: "Налаштуйте параметри для нового стилю",
+      compareWithBase: "Порівняти з базовим профілем",
+      compare: "Порівняйте",
+      // CreatePresetModal - Basic Info
+      basePreset: "Базове передналаштування",
+      selectBasePreset: "Виберіть базове налаштування...",
+      presetName: "Назва профілю",
+      myCustomPreset: "Мій власний профіль",
+      inheritsFrom: "Успадковує від",
+      dropJsonToImport: "Відпустіть JSON, щоб імпортувати",
+      // CreatePresetModal - Tabs
+      tabs: {
+        common: "Основні",
+        allFields: "Всі поля",
+      },
+      // CreatePresetModal - All Fields Tab
+      availableFields: "Доступні поля",
+      searchFieldsPlaceholder: "Шукати поля...",
+      noMatchingFields: "Немає відповідних полів",
+      allFieldsAdded: "Всі поля додано",
+      addCustomField: "Додати спеціальне поле",
+      yourOverrides: "Ваші перевизначення",
+      noOverridesYet: "Перевизначення ще немає",
+      clickFieldsToAdd: "Натисніть поля ліворуч, щоб додати їх",
+      saveAsTemplate: "Зберегти як шаблон",
+      jsonTip: "Порада: перетягніть файл .json у будь-яке місце на цьому модалі, щоб імпортувати налаштування",
+    },
+    cloudView: {
+      searchPlaceholder: "Шукати профілі...",
+      templates: "Шаблони",
+      refresh: "Оновити",
+      newPreset: "Нове налаштування",
+      clearFilters: "Очистити фільтри",
+      // Compare mode
+      compareMode: "Режим порівняння",
+      selectAnotherPreset: "Вибрати інший попередній набір {{type}}.",
+      clickTwoPresets: "Клацніть два стилі одного типу, щоб порівняти",
+      selectFirst: "1. Виберіть перший",
+      selectSecond: "2. Виберіть другий",
+      compareNow: "Порівняти зараз",
+      // Status row
+      lastSynced: "Остання синхронізація:",
+      showingCount: "Показано профілів: {{showing}} із {{total}}",
+      noPresetsFound: "Попередніх налаштувань не знайдено",
+      // Column headers
+      columns: {
+        filament: "Філамент",
+        process: "процес",
+        printer: "Принтер",
+      },
+      noFilamentPresets: "Немає профілів філаменту",
+      noProcessPresets: "Немає профілів процесу",
+      noPrinterPresets: "Немає профілів принтера",
+      // Filters
+      filters: {
+        type: "Тип",
+        owner: "Власник",
+        printer: "Принтер",
+        nozzle: "Сопло",
+        filament: "Філамент",
+        layer: "Шар",
+        all: "Усі",
+        myPresets: "Мої профілі",
+        builtIn: "Вбудований",
+        process: "процес",
+      },
+      // Permissions
+      noTemplatesPermission: "Ви не маєте дозволу на керування шаблонами",
+      noRefreshPermission: "Ви не маєте дозволу оновлювати профілі",
+      noCreatePermission: "Ви не маєте дозволу створювати профілі",
+    },
+    templates: {
+      title: "Швидкі шаблони",
+      noTemplates: "Шаблонів ще немає",
+      createFirst: "Створіть шаблони в редакторі профілів",
+      typeFilter: "тип:",
+      deleteTitle: "Видалити шаблон",
+      deleteWarning: "Цю дію не можна скасувати",
+      deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"?",
+      namePlaceholder: "Назва шаблону",
+      descriptionPlaceholder: "опис",
+      settingsJson: "Налаштування (JSON)",
+      fieldsCount: "Полів: {{count}}",
+      shownInModals: "Показано в модальних формах",
+      hiddenInModals: "Приховано в модальних формах",
+      apply: "Застосувати",
+      toast: {
+        deleted: "Шаблон видалено",
+        updated: "Шаблон оновлено",
+        created: "Шаблон створено",
+        applied: "Шаблон застосовано",
+      },
+    },
+  },
+
+  // Support/Debug
+  support: {
+    debugLoggingActive: "Журнал налагодження активний",
+    manageLogs: "Керувати",
+    collectItem7: "Підключення принтерів і версії прошивок",
+    collectItem8: "Статус інтеграції (Spoolman, MQTT, HA)",
+    collectItem9: "Мережеві інтерфейси (лише підмережі)",
+    collectItem10: "Версії пакетів Python",
+    collectItem11: "Перевірки справності бази даних",
+    collectItem12: "Відомості про середовище Docker",
+    bundleGenerating: "Створення пакета...",
+    bundleStepConnection: "Запуск перевірки підключення принтера",
+    bundleStepVirtualPrinters: "Виконання перевірок налаштування віртуального принтера",
+    bundleStepLogScan: "Сканування останніх журналів на наявність відомих проблем",
+    bundleStepBuild: "Створення ZIP-пакета підтримки",
+  },
+
+  // File manager
+  fileManager: {
+    title: "Менеджер файлів",
+    subtitle: "Упорядковуйте файли друку та керуйте ними",
+    uploadFiles: "Вивантажити файли",
+    newFolder: "Нова папка",
+    folderName: "Назва папки",
+    folderNamePlaceholder: "наприклад, функціональні частини",
+    renameFile: "Перейменувати файл",
+    renameFolder: "Перейменувати папку",
+    invalidFilenameChar: "Символ «{{char}}» не можна використовувати в назвах файлів для друку. SD-картка принтера не приймає такі символи: < > : \" / \\ | ? *",
+    moveFiles: "Перемістити файли: {{count}}",
+    rootNoFolder: "Корінь (без папки)",
+    current: "поточний",
+    linkFolder: "Пов’язати папку",
+    linkFolderDescription: "Пов’язати «{{name}}» із проєктом або архівом для швидкого доступу.",
+    project: "Проєкт",
+    archive: "Архів",
+    noProjectsFound: "Проєкти не знайдено",
+    noArchivesFound: "Архіви не знайдено",
+    unlink: "Від’єднати",
+    link: "Пов’язати",
+    dragDropFiles: "Перетягніть файли сюди",
+    dropFilesHere: "Перетягніть файли сюди",
+    releaseToUpload: "Відпустіть, щоб вивантажити",
+    orClickToBrowse: "або натисніть, щоб переглянути",
+    allFileTypesSupported: "Підтримуються всі типи файлів. Файли ZIP будуть розпаковані.",
+    zipFilesDetected: "Виявлено файли ZIP.",
+    zipExtractOptions: "Файли ZIP будуть розпаковані. Виберіть спосіб обробки структури папок:",
+    preserveZipStructure: "Зберегти структуру папок із ZIP",
+    createFolderFromZip: "Створити папку з назви ZIP-файлу",
+    stlThumbnailGeneration: "Створення мініатюр STL",
+    zipMayContainStl: "Файли ZIP можуть містити файли STL. Мініатюри можна генерувати під час вилучення.",
+    thumbnailsCanBeGenerated: "Ескізи можна створити для файлів STL. Обробка великих моделей може зайняти більше часу.",
+    generateThumbnailsForStl: "Створювати мініатюри для файлів STL",
+    threemfDetected: "Виявлено файли 3MF.",
+    threemfExtractionInfo: "Модель принтера, матеріал, колір і параметри друку будуть автоматично витягнуті з файлів 3MF.",
+    willBeExtracted: "Буде розпаковано",
+    filesExtracted: "Розпаковано файлів: {{count}}",
+    uploadComplete: "Вивантаження завершено. Успішно: {{succeeded}}",
+    uploadFailed: "Не вдалося вивантажити",
+    zipFilesFailed: "Не вдалося обробити файлів: {{count}}",
+    uploading: "Вивантаження...",
+    changeLink: "Змінити посилання...",
+    linkTo: "Посилання на...",
+    linkToProjectOrArchive: "Посилання на проєкт або архів",
+    generateThumbnail: "Створити мініатюру",
+    generateThumbnails: "Створити мініатюри",
+    generateThumbnailsForMissing: "Створити відсутні мініатюри файлів STL",
+    gridView: "Вигляд сітки",
+    listView: "Перегляд списку",
+    lowDiskSpaceWarning: "Попередження про брак місця на диску",
+    lowDiskSpaceDetails: "Вільно лише {{free}} із {{total}}. У налаштуваннях задано поріг {{threshold}} ГБ.",
+    files: "Файли",
+    folders: "Папки",
+    size: "Розмір",
+    free: "Вільно",
+    allFiles: "Усі файли",
+    allExternal: "Зовнішні",
+    externalIsEmpty: "Немає зовнішніх файлів",
+    externalEmptyDescription: "Тут відображатимуться файли у ваших пов’язаних зовнішніх папках.",
+    wrap: "Перенесення",
+    enableTextWrapping: "Увімкнути перенесення тексту",
+    disableTextWrapping: "Вимкнути перенесення тексту",
+    collapse: "Згорнути",
+    collapseFoldersByDefault: "За замовчуванням згорнути папки",
+    expandFoldersByDefault: "Розгорнути папки за замовчуванням",
+    folderSort: "Сортування папок",
+    folderSortByName: "За назвою",
+    folderSortByActivity: "За останніми діями",
+    dragToResizeTooltip: "Перетягніть, щоб змінити розмір, двічі клацніть, щоб скинути",
+    searchFiles: "Пошук файлів...",
+    searchSubfoldersHint: "Включаючи вкладені папки",
+    readme: {
+      truncated: "Усічений",
+      show: "Показати README",
+      hide: "Приховати README",
+      label: "README",
+    },
+    tags: {
+      title: "Теги",
+      subtitle: "Файли міток для наскрізної фільтрації — іграшки, безпечні для дітей, лише PETG, будь-що.",
+      manage: "Теги",
+      manageTitle: "Керувати каталогом тегів",
+      add: "Новий тег",
+      edit: "Перейменувати тег",
+      name: "Назва",
+      fileCount: "Файли",
+      empty: "Тегів ще немає. Створіть його, щоб розпочати маркування файлів.",
+      noMatches: "Немає відповідних тегів.",
+      createPlaceholder: "напр. іграшки, безпечні для дітей, petg",
+      createButton: "Створити",
+      nameRequired: "Потрібно вказати назву.",
+      searchPlaceholder: "Фільтр тегів...",
+      created: "Тег створено.",
+      updated: "Тег перейменовано.",
+      deleted: "Тег видалено.",
+      saveFailed: "Не вдалося зберегти тег.",
+      deleteFailed: "Не вдалося видалити тег.",
+      applyFailed: "Не вдалося застосувати теги.",
+      applyAdd: "Додати теги",
+      applyRemove: "Видалити теги",
+      applyAddSuccess: "Додано теги {{count}} до файлів {{files}}.",
+      applyRemoveSuccess: "Видалено теги {{count}} у файлах {{files}}.",
+      actionAdd: "Додати до вибраних файлів",
+      actionRemove: "Видалити з вибраних файлів",
+      tagAction: "Тег",
+      bulkTitle: "Позначити вибрані файли ({{count}})",
+      bulkTooltip: "Додати або видалити теги для кожного вибраного файлу.",
+      noPermission: "Ви не маєте дозволу позначати файли тегами.",
+      filterLabel: "Фільтрування за:",
+      clearAll: "Очистити все",
+      confirmDelete: "Видалити тег \"{{name}}\"?",
+      confirmDeleteMessage: "Це видалить тег з каталогу. Файли зберігають інші теги.",
+      confirmDeleteInUseMessage: "Цей тег призначено файлам ({{count}}). Його буде видалено з усіх файлів, але самі файли залишаться без змін.",
+      editAria: "Редагувати {{name}}",
+      deleteAria: "Видалити {{name}}",
+    },
+    allTypes: "Усі типи",
+    prints: "Друки",
+    ascending: "За зростанням",
+    descending: "За спаданням",
+    showModified: "Показати змінені дати",
+    hideModified: "Приховати змінені дати",
+    lastModified: "Востаннє змінено",
+    resultsCount: "{{showing}} з {{total}} файлів",
+    selectAll: "Вибрати усі",
+    deselectAll: "Зняти вибір із усіх",
+    selected: "Вибрано: {{count}}",
+    adding: "Додавання...",
+    loadingFiles: "Завантаження файлів...",
+    folderIsEmpty: "Папка порожня",
+    noFilesYet: "Файлів ще немає",
+    folderEmptyDescription: "Щоб почати, вивантажте файли або перемістіть їх до цієї папки.",
+    noFilesDescription: "Вивантажте файли, щоб упорядковувати матеріали для друку.",
+    noMatchingFiles: "Немає відповідних файлів",
+    noMatchingFilesDescription: "Жоден файл не відповідає вашим поточним критеріям пошуку чи фільтру.",
+    clearFilters: "Очистити фільтри",
+    printedCount: "Надруковано {{count}}x",
+    uploadedBy: "Вивантажено",
+    deleteFolder: "Видалити папку",
+    deleteFile: "Видалити файл",
+    deleteFilesCount: "Видалити файли {{count}}.",
+    deleteFolderConfirm: "Ви впевнені, що хочете видалити цю папку? Усі файли всередині також буде видалено.",
+    deleteFileConfirm: "Ви впевнені, що хочете видалити цей файл?",
+    deleteFilesConfirm: "Ви впевнені, що бажаєте видалити вибрані файли {{count}}? Цю дію не можна скасувати.",
+    deleting: "Видалення...",
+    noPermissionRenameFolder: "Ви не маєте дозволу на перейменування папок",
+    noPermissionLinkFolder: "Ви не маєте дозволу на пов’язування папок",
+    noPermissionDeleteFolder: "Ви не маєте дозволу на видалення папок",
+    onlyEmptyFoldersDeletable: "Можна видаляти лише порожні папки",
+    noPermissionPrint: "Ви не маєте дозволу на друк",
+    noPermissionAddToQueue: "Ви не маєте дозволу на додавання в чергу",
+    noPermissionSlice: "Ви не маєте дозволу нарізати файли",
+    noPermissionDownload: "У вас немає дозволу на завантаження файлів",
+    noPermissionRenameFile: "Ви не маєте дозволу на перейменування цього файлу",
+    noPermissionGenerateThumbnail: "Ви не маєте дозволу створювати мініатюри",
+    noPermissionDeleteFile: "Ви не маєте дозволу на видалення цього файлу",
+    noPermissionCreateFolder: "Ви не маєте дозволу на створення папок",
+    noPermissionUpload: "Ви не маєте дозволу на вивантаження файлів",
+    noPermissionMoveFiles: "Ви не маєте дозволу на переміщення файлів",
+    noPermissionDeleteFiles: "Ви не маєте дозволу на видалення файлів",
+    // External folder
+    linkExternal: "Зовнішнє посилання",
+    linkExternalFolder: "Посилання на зовнішню папку",
+    linkExternalFolderDescription: "Змонтуйте каталог хоста (NAS, USB, спільний мережевий ресурс) у файловому менеджері. Файли не копіюються — доступ до них здійснюється безпосередньо з початкового шляху.",
+    externalFolderNamePlaceholder: "наприклад, NAS Друкує",
+    externalPath: "Шлях хоста",
+    externalPathHelp: "Абсолютний шлях до каталогу на хості Docker. Має бути прив’язано до контейнера.",
+    readOnly: "Тільки для читання",
+    readOnlyHelp: "забороняє вивантаження й видалення",
+    showHiddenFiles: "Показати приховані файли (dotfiles)",
+    externalFolder: "Зовнішня папка",
+    scanFolder: "Сканувати",
+    toast: {
+      folderCreated: "Папка створена",
+      folderDeleted: "Папку видалено",
+      fileDeleted: "Файл видалено",
+      filesDeleted: "Видалені файли {{count}}.",
+      filesMoved: "Файли переміщено",
+      folderLinked: "Папку пов’язано",
+      folderUnlinked: "Папку від’єднано",
+      externalFolderLinked: "Зовнішня папка пов’язана та просканована",
+      folderScanned: "Сканування завершено: {{added}} додано, {{removed}} видалено",
+      addedToQueue: "Додано до черги файлів: {{count}}",
+      addedToQueuePartial: "Додано: {{added}}; не вдалося додати: {{failed}}",
+      failedToAddToQueue: "Не вдалося додати файли: {{error}}",
+      fileRenamed: "Файл перейменовано",
+      folderRenamed: "Папку перейменовано",
+      thumbnailsGenerated: "Створено мініатюри {{count}}",
+      thumbnailsGeneratedPartial: "Створено мініатюри {{succeeded}}, {{failed}} не вдалося",
+      noStlMissingThumbnails: "Немає файлів STL без мініатюр",
+      failedToGenerateThumbnails: "Не вдалося створити мініатюри: {{error}}",
+      thumbnailGenerated: "Створено мініатюру",
+      failedToGenerateThumbnail: "Не вдалося створити мініатюру: {{error}}",
+    },
+  },
+
+  // Projects
+  projects: {
+    title: "Проєкти",
+    subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
+    newProject: "Новий проєкт",
+    editProject: "Редагувати проєкт",
+    deleteProject: "Видалити проєкт",
+    projectName: "Назва проєкту",
+    description: "опис",
+    noProjects: "Проєктів ще немає",
+    noProjectsFiltered: "Немає проєктів {{status}}.",
+    noProjectsFilteredHelp: "У вас немає {{status}} проєктів. Проєкти з’являться тут, коли їхній статус зміниться.",
+    createFirst: "Створіть свій перший проєкт, щоб почати впорядковувати пов’язані роздруківки, відстежувати прогрес і керувати збірками.",
+    createFirstButton: "Створіть свій перший проєкт",
+    create: "Створити",
+    files: "Файли",
+    prints: "Принти",
+    plates: "пластини",
+    parts: "частин",
+    lastModified: "Остання зміна",
+    deleteConfirm: "Ви впевнені, що хочете видалити цей проєкт? Архіви та елементи черги буде від’єднано, але не видалено.",
+    addFiles: "Додати файли",
+    removeFile: "Видалити файл",
+    viewDetails: "Переглянути деталі",
+    // Modal fields
+    namePlaceholder: "наприклад, Voron 2.4 Build",
+    descriptionPlaceholder: "Додатковий опис...",
+    urlLabel: "URL",
+    urlPlaceholder: "https://makerworld.com/...",
+    urlInvalid: "URL має починатися з http:// або https://",
+    openExternalUrl: "Відкритий проєкт URL",
+    coverImageLabel: "Фото обкладинки",
+    coverImageAlt: "Обкладинка проєкту",
+    coverImageUpload: "Вивантажити",
+    coverImageReplace: "Замінити",
+    coverImageRemove: "видалити",
+    color: "Колір",
+    targetPlates: "Цільові пластини",
+    targetPlatesPlaceholder: "наприклад, 25",
+    targetPlatesHelp: "Кількість завдань друку",
+    targetParts: "Цільові частини",
+    targetPartsPlaceholder: "наприклад, 150",
+    targetPartsHelp: "Загальна кількість необхідних об’єктів",
+    targetSets: "Копій кожного файлу",
+    targetSetsPlaceholder: "наприклад, 10",
+    targetSetsHelp: "Кількість друків кожного придатного до друку файлу",
+    tagsLabel: "Теги (розділені комами)",
+    tagsPlaceholder: "напр., ворон, функціональний, подарунок",
+    dueDate: "Термін виконання",
+    priority: "Пріоритет",
+    priorityLow: "Низький",
+    priorityNormal: "нормальний",
+    priorityHigh: "Високий",
+    priorityUrgent: "Терміново",
+    // Status
+    statusActive: "Активний",
+    statusCompleted: "Виконано",
+    statusArchived: "Архівовано",
+    done: "Готово",
+    completed: "завершено",
+    failed: "не вдалося",
+    inQueue: "в черзі",
+    noPrintsYet: "Ще немає відбитків",
+    // Footer stats
+    printJobs: "Завдання друку (пластини)",
+    partsPrinted: "Надруковані частини",
+    failedParts: "Вийшли з ладу деталі",
+    // Actions
+    import: "Імпорт",
+    export: "Експорт",
+    importProject: "Імпорт проєкту",
+    exportAll: "Експортуйте всі проєкти",
+    loading: "Завантаження проєктів...",
+    // Permissions
+    noEditPermission: "Ви не маєте дозволу на редагування проєктів",
+    noDeletePermission: "Ви не маєте дозволу на видалення проєктів",
+    noCreatePermission: "Ви не маєте дозволу на створення проєктів",
+    noImportPermission: "Ви не маєте дозволу на імпорт проєктів",
+    noExportPermission: "Ви не маєте дозволу на експорт проєктів",
+    // Toast
+    toast: {
+      created: "Проєкт створено",
+      updated: "Проєкт оновлений",
+      deleted: "Проєкт видалено",
+      imported: "Проєкт імпортовано",
+      multipleImported: "{{count}} проєкти імпортовані",
+      importFailed: "Помилка імпорту",
+      exported: "Експортовані проєкти (лише метадані)",
+    },
+  },
+
+  // Project detail page
+  projectDetail: {
+    notFound: "Проєкт не знайдено",
+    backToProjects: "Назад до проєктів",
+    export: "Експорт",
+    exportProject: "Експортний проєкт",
+    noExportPermission: "Ви не маєте дозволу на експорт проєктів",
+    noEditPermission: "Ви не маєте дозволу на редагування проєктів",
+    partOf: "частина:",
+    priorityLabel: "Пріоритет:",
+    noPrints: "У цьому проєкті ще немає роздруківок",
+    status: {
+      active: "Активний",
+      completed: "Виконано",
+      archived: "Архівовано",
+    },
+    priority: {
+      low: "Низький",
+      normal: "нормальний",
+      high: "Високий",
+      urgent: "Терміново",
+    },
+    dueDate: {
+      overdue: "Прострочена",
+      today: "Термін виконання сьогодні",
+      daysLeft: "Залишилося {{count}} днів",
+    },
+    progress: {
+      platesProgress: "Пластини Прогрес",
+      partsProgress: "Прогрес запчастин",
+      setsProgress: "Завершені комплекти",
+      sets: "комплектів",
+      setsHint: "Комплект вважається завершеним, коли кожен придатний до друку файл надруковано стільки разів",
+      printJobs: "завдання друку",
+      parts: "частин",
+      percentComplete: "{{percent}}% виконано",
+      remaining: "Залишилося {{count}}.",
+    },
+    stats: {
+      printJobs: "Завдання друку",
+      total: "всього",
+      failed: "{{count}} не вдалося",
+      partsPrinted: "Надруковані частини {{count}}.",
+      printTime: "Час друку",
+      filamentUsed: "Філамент, що використовується",
+    },
+    cost: {
+      title: "Відстеження витрат",
+      filamentCost: "Вартість філаменту",
+      energy: "Енергія",
+      totalCost: "Загальна вартість",
+      total: "Всього",
+      includesBom: "в т.ч. BOM",
+      budget: "Бюджет",
+      remaining: "Залишилося",
+    },
+    subProjects: {
+      title: "Підпроєкти ({{count}})",
+    },
+    notes: {
+      title: "Примітки",
+      noEditPermission: "Ви не маєте дозволу редагувати нотатки",
+      placeholder: "Додайте примітки щодо цього проєкту...",
+      empty: "Приміток ще немає. Натисніть Редагувати, щоб додати примітки.",
+    },
+    files: {
+      title: "Файли",
+      linkFolders: "Зв’язувати папки з файлового менеджера",
+      forQuickAccess: "до цього проєкту для швидкого доступу.",
+      fileCount: "Файлів: {{count}}",
+      empty: "Немає пов’язаних папок. Перейдіть до файлового менеджера та зв’яжіть папку з цим проєктом.",
+      printedCount: "Надруковано разів: {{count}}",
+      progressTooltip: "Завершено друків: {{done}} із {{target}}",
+      noFiles: "У цій папці немає файлів.",
+    },
+    bom: {
+      title: "Перелік матеріалів",
+      acquired: "{{completed}}/{{total}} придбано",
+      showAll: "Показати все",
+      hideDone: "Сховати готове",
+      addPart: "Додати частину",
+      noAddPermission: "Ви не маєте дозволу додавати частини",
+      partNamePlaceholder: "Назва деталі (наприклад, гвинти M3x8)",
+      partName: "Назва частини",
+      qty: "кількість",
+      price: "Ціна ({{currency}})",
+      sourcingUrlPlaceholder: "URL джерела (необов’язково)",
+      remarksPlaceholder: "Примітки (необов’язково)",
+      deletePart: "Видалити частину",
+      deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"?",
+      noUpdatePermission: "Ви не маєте дозволу на оновлення частин",
+      noEditPermission: "Ви не маєте дозволу редагувати частини",
+      noDeletePermission: "Ви не маєте дозволу на видалення частин",
+      totalCost: "Загальна вартість:",
+      empty: "Немає частин у описі матеріалів. Додайте обладнання, електроніку чи інші компоненти, щоб відстежувати, що потрібно отримати.",
+    },
+    timeline: {
+      title: "Хронологія діяльності",
+      empty: "Активності ще немає.",
+    },
+    template: {
+      saveAsTemplate: "Зберегти як шаблон",
+      noCreatePermission: "Ви не маєте дозволу на створення шаблонів",
+    },
+    queue: {
+      title: "Черга",
+      viewAll: "Переглянути всі",
+      printing: "Друкується: {{count}}",
+      queued: "{{count}} у черзі",
+    },
+    prints: {
+      title: "Друки ({{count}})",
+    },
+    toast: {
+      projectUpdated: "Проєкт оновлений",
+      partAdded: "Частина додана",
+      partRemoved: "Частину видалено",
+      exportFailed: "Помилка експорту",
+      projectExported: "Проєкт експортовано",
+      templateCreated: "Шаблон створено",
+    },
+  },
+
+  // System info
+  system: {
+    title: "Інформація про систему",
+    version: "Версія",
+    uptime: "Час роботи",
+    cpuUsage: "CPU Використання",
+    memoryUsage: "Використання пам’яті",
+    diskUsage: "Використання диска",
+    networkInfo: "Інформація про мережу",
+    logs: "Журнали",
+    debugMode: "Режим налагодження",
+    enableDebug: "Увімкнути журнал налагодження",
+    disableDebug: "Вимкнути ведення журналу налагодження",
+    downloadLogs: "Завантажити журнали",
+    clearLogs: "Очистити журнали",
+    dockerInfo: "Docker Інформація",
+    containerName: "Назва контейнера",
+    imageName: "Назва зображення",
+    platform: "Платформа",
+    architecture: "Архітектура",
+  },
+
+  // Sponsor surface (System page)
+  sponsors: {
+    sectionTitle: "Незалежний і фінансований громадою",
+    tagline: "Bambuddy є безкоштовним і залишається таким, тому що люди вирішили його підтримати. Ні VC, ні блокування в хмарі.",
+    viewSupporters: "Переглянути прихильників",
+    toastPrints: "Завершено друків із Bambuddy: {{count}}. Bambuddy залишається безкоштовним завдяки своїм прихильникам.",
+    toastCost: "За допомогою Bambuddy обліковано філаменту на {{total}}. Дізнайтеся, хто допомагає проєкту залишатися незалежним.",
+    toastArchives: "Заархівовано друків із Bambuddy: {{count}}. Дізнайтеся, хто допомагає проєкту залишатися незалежним.",
+    toastAnniversary: "Рік з Bambuddy! Подивіться, хто зберігає незалежність проєкту.",
+    toastVersionUpdate: "Оновлено до v{{version}}. Bambuddy залишається безкоштовним завдяки своїм прихильникам.",
+    toastBusiness: "Використовуєте Bambuddy на принтерах ({{count}})? Для команд доступний план підтримки: пріоритетні виправлення, виставлення рахунків і прямий зв’язок із супровідником.",
+    businessCta: "Bambuddy для бізнесу",
+    businessTitle: "Bambuddy для бізнесу",
+    businessTagline: "Ви використовуєте принтери {{count}}. Пріоритетна підтримка, комерційне ліцензування та виставлення рахунків доступні для команд і друкарень.",
+  },
+
+  // Library (K Profiles)
+  library: {
+    title: "Бібліотека філаментів",
+    addFilament: "Додати філамент",
+    editFilament: "Редагувати філамент",
+    deleteFilament: "Видалити філамент",
+    vendor: "Продавець",
+    material: "матеріал",
+    color: "Колір",
+    kFactor: "Фактор K",
+    temperature: "температура",
+    noFilaments: "У бібліотеці немає філаментів",
+    deleteConfirm: "Ви впевнені, що хочете видалити цю філамент?",
+    importFromPrinter: "Імпорт із принтера",
+    exportToFile: "Експорт у файл",
+    // Slicer Pipelines run from the File Manager (#1425 PR B). The Run-with-pipeline
+    // modal is a two-step dialog: pick a pipeline, then either fire (eligibility ok)
+    // or confirm-and-fire (eligibility report shown). Lives in components/RunWithPipelineModal.tsx.
+    runWithPipeline: {
+      actionLabel: "Запустити з конвеєром",
+      noPermission: "Ви не маєте дозволу на запуск конвеєрів",
+      modalTitle: "Запустити з конвеєром",
+      confirmTitle: "Підтвердити запуск",
+      confirmIntro: "Під час попередньої перевірки виявлено проблеми з цим запуском",
+      sourceHint: "Джерело",
+      pipelineHint: "Конвеєр",
+      targetHint: "Ціль",
+      pipelineListAria: "Наявні конвеєри",
+      runAnyway: "Усе одно запустити",
+      loading: "Завантаження…",
+      empty: "Збережених конвеєрів ще немає. Відкрийте вікно нарізання й натисніть «Зберегти як конвеєр», щоб створити його.",
+      noTarget: "Цільовий принтер не встановлено",
+      noTargetMessage: "Для цього конвеєра не задано цільовий принтер. Відкрийте конвеєр у налаштуваннях і виберіть принтер.",
+      // PR C — copies input + class-targeted pipelines.
+      copies: "Копії",
+      copiesHint: "максимум {{n}}",
+      classTarget: "Будь-який {{model}}",
+      toast: {
+        started: "Пуск конвеєра розпочато",
+        failed: "Не вдалося розпочати запуск",
+      },
+      issue: {
+        printerNotSet: "У цьому конвеєрі не встановлено цільовий принтер.",
+        printerNotFound: "Цільовий принтер більше не існує.",
+        printerDisabled: "Цільовий принтер вимкнено.",
+        printerOffline: "Цільовий принтер не в мережі.",
+        filamentType: "Слот філаменту {{slot}}: очікується {{expected}}, в AMS — {{actual}}",
+        filamentColor: "Слот філаменту {{slot}}: колір не збігається (очікується {{expected}}, в AMS — {{actual}})",
+        amsSlotMissing: "Слот AMS {{slot}} недоступний на цьому принтері",
+        filamentUnverified: "Філамент для слота {{slot}} походить із хмарного або стандартного профілю, тому його неможливо перевірити заздалегідь.",
+        // PR C — class targeting
+        noClassMatches: "Жоден принтер у цій інсталяції не належить до цільового класу моделей конвеєра ({{expected}}).",
+        classNotSet: "Ціллю конвеєра вибрано клас принтерів, але модель не задано.",
+      },
+    },
+  },
+
+  // Slice (slicer-API integration via SliceModal)
+  slice: {
+    title: "Нарізання моделі",
+    action: "Нарізати",
+    actionAll: "Нарізати всі пластини ({{count}})",
+    actionAllTitle: "Нарізати всі пластини в один багатопластинний результат (один архів). Вибір філаменту охоплює всі слоти, визначені проєктом.",
+    allPlatesToggle: "Нарізати всі пластини ({{count}})",
+    slicing: "Нарізання…",
+    printer: "Профіль принтера",
+    process: "Профіль процесу",
+    filament: "Профіль філаменту",
+    filamentSlot: "Філамент {{index}} ({{type}})",
+    selectPreset: "— Виберіть профіль —",
+    loadingPresets: "Завантаження профілів…",
+    analyzingPlateFilaments: "Аналіз філаментів пластини…",
+    analyzingPlateFilamentsHint: "Виконується попереднє нарізання, щоб визначити, які слоти AMS використовує ця пластина. Результат кешується, тому повторне відкриття відбувається миттєво.",
+    previewToast: "Аналіз {{name}} — {{elapsed}}",
+    previewWithProgress: "Аналіз {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
+    notUsedByPlate: "— не використовується на цій пластині",
+    noPresetsForSlot: "Немає профілів",
+    otherPrinters: "Інші принтери",
+    presetsLoadFailed: "Не вдалося завантажити профілі. Спочатку імпортуйте їх у розділі Налаштування → Профілі.",
+    refreshPresets: "Оновити",
+    refreshPresetsTitle: "Оновити профілі — отримати найновіші хмарні та вбудовані списки (скористайтеся після видалення профілю в Bambu Studio або Bambu Handy)",
+    allPresetsRequired: "Потрібно вибрати всі профілі",
+    useEmbedded: "Використати вбудовані налаштування файлу",
+    useEmbeddedHint: "Нарізати модель із налаштуваннями автора файлу — стінками, заповненням і філаментом — замість профілів вище. Ця можливість доступна, оскільки модель принтера відповідає файлу.",
+    designSettings: "Зберегти налаштування автора",
+    designSettingsHint: "Цей файл змінює {{count}} налаштувань друку порівняно зі стандартним профілем.",
+    designSettingsSelected: "Вибрано {{selected}} із {{total}}",
+    designSettingsPrinterCoupled: "залежить від принтера",
+    designSettingsPrinterCoupledHint: "Значення підібрано для принтера, під який створено файл, — на вашому воно може бути неправильним або поза допустимим діапазоном.",
+    enqueuing: "Надсилання завдання нарізання…",
+    queued: "У черзі…",
+    failed: "Не вдалося виконати нарізання. Перевірте журнали слайсера.",
+    startedToast: "Нарізання {{name}} у фоновому режимі…",
+    queuedToast: "У черзі: {{name}} — {{elapsed}}",
+    runningToast: "Нарізання {{name}} — {{elapsed}}",
+    runningWithProgress: "{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
+    runningWithProgressMultiPlate: "Пластина {{plateIndex}} з {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
+    completedToast: "Нарізання {{name}} завершено",
+    failedTitle: "Помилка нарізання",
+    failedToast: "Помилка нарізання {{name}}: {{detail}}",
+    tier: {
+      local: "Імпортовані",
+      cloud: "Bambu Cloud",
+      orcaCloud: "Orca Cloud",
+      standard: "Стандартний",
+    },
+    cloud: {
+      notAuthenticated: "Увійдіть у Bambu Cloud (Налаштування → Профілі → Bambu Cloud), щоб переглянути хмарні профілі.",
+      expired: "Сеанс Bambu Cloud завершився — увійдіть знову, щоб оновити хмарні профілі.",
+      unreachable: "Bambu Cloud зараз недоступний. Локальні та стандартні профілі й надалі працюють.",
+    },
+    orcaCloud: {
+      notAuthenticated: "Увійдіть до Orca Cloud (Профілі → Orca Cloud), щоб переглянути профілі Orca.",
+      expired: "Сеанс Orca Cloud завершився — увійдіть ще раз, щоб оновити профілі Orca.",
+      unreachable: "Orca Cloud зараз недоступний. Інші профілі й надалі працюють.",
+    },
+    bedType: {
+      label: "Друкарська пластина",
+      auto: "Авто (використовувати профіль процесу)",
+      coolPlate: "Холодна друкарська пластина",
+      coolPlateSuperTack: "Cool Plate SuperTack",
+      engineering: "Інженерна пластина",
+      highTemp: "Високотемпературна пластина",
+      texturedPEI: "Текстурована PEI-пластина",
+      smoothPEI: "Гладка PEI-пластина",
+    },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: "Конвеєр",
+      applyAria: "Застосувати конвеєр",
+      applyPrompt: "Застосувати конвеєр…",
+      empty: "Немає збережених конвеєрів",
+      saveButton: "Зберегти як конвеєр",
+      saveTitle: "Зберегти поточний вибір чотирьох слотів як конвеєр для повторного використання",
+      namePlaceholder: "Назва конвеєра",
+      nameAria: "Нова назва конвеєра",
+      toast: {
+        applied: "Застосовано \"{{name}}\"",
+        saved: "Конвеєр збережено",
+        saveFailed: "Не вдалося зберегти",
+      },
+    },
+  },
+
+  // Spoolman
+  spoolman: {
+    title: "Spoolman Інтеграція",
+    enabled: "Spoolman Увімкнено",
+    url: "Spoolman URL",
+    connected: "Підключено",
+    disconnected: "Не підключено",
+    testConnection: "Тестове підключення",
+    sync: "Синхронізувати",
+    syncing: "Синхронізація...",
+    lastSync: "Остання синхронізація",
+    linkToSpoolman: "Посилання на Spoolman",
+    openInSpoolman: "Відкрити в Spoolman",
+    unlinkSpool: "Від’єднати котушка",
+    unlinkConfirmTitle: "Скасувати призначення котушки?",
+    unlinkConfirmMessage: "Це призведе до видалення котушки з цього слота. Самі дані котушки залишаться незмінними.",
+    selectSpool: "Вибрати Spool",
+    noUnlinkedSpools: "Немає непризначених котушок",
+    linkSuccess: "Котушку успішно призначено",
+    linkFailed: "Не вдалося призначити котушку",
+    unlinkSuccess: "Призначення котушки успішно скасовано",
+    unlinkFailed: "Не вдалося скасувати призначення котушки",
+    linkedSpool: "Призначена котушка",
+    spoolId: "Ідентифікатор котушки",
+    fillSourceLabel: "(Spoolman)",
+    weight: "вага",
+    remaining: "Залишилося",
+    disableWeightSync: "Вимкнути синхронізацію приблизної ваги AMS.",
+    disableWeightSyncDesc: "Не оновлюйте залишкову ємність із оцінок AMS. Використовуйте це, якщо ви віддаєте перевагу відстеженню використання Spoolman, а не оцінкам на основі відсотка AMS. Нові котушки все ще використовуватимуть оцінку AMS як початкову вагу.",
+    reportPartialUsage: "Повідомити про часткове використання для невдалого друку",
+    reportPartialUsageDesc: "Якщо друк не вдається або скасовується, повідомте приблизну кількість філаменту, використаної до цього моменту, на основі виконання шару.",
+  },
+
+  locations: {
+    title: "Місця зберігання",
+    subtitle: "Керуйте полицями, шухлядами та іншими фізичними місцями для зберігання своїх котушок",
+    add: "Додати місцезнаходження",
+    addShort: "додати",
+    edit: "Редагувати місцезнаходження",
+    name: "Назва",
+    spools: "Котушки",
+    empty: "Місць зберігання ще немає. Створіть свою першу полицю або ящик.",
+    manage: "Розташування",
+    createPlaceholder: "напр. Полиця А, ящик 1",
+    nameRequired: "Потрібно вказати назву місця",
+    created: "Розташування створено",
+    updated: "Розташування оновлено",
+    deleted: "Розташування видалено",
+    saveFailed: "Не вдалося зберегти місцезнаходження",
+    deleteFailed: "Не вдалося видалити місцезнаходження",
+    deleteBlocked: "Видаліть усі котушки з цього місця перед видаленням",
+    confirmDelete: "Видалити \"{{name}}\"?",
+    confirmDeleteMessage: "Це місце буде видалено з каталогу. Котушки спочатку потрібно перемістити.",
+  },
+
+  // Inventory
+  inventory: {
+    title: "Облік котушок",
+    subtitle: "Керуйте своїми котушками",
+    addToInventory: "Додати до обліку",
+    addToInventoryPending: "Додавання...",
+    addToInventorySuccess: "Котушку додано до обліку",
+    addToInventoryFailed: "Не вдалося додати котушку до обліку",
+    unknownSpoolTitle: "Виявлено нову котушку філаменту",
+    unknownSpoolMessage: "У {{location}} виявлено котушку з невідомою RFID-міткою. Додати її до обліку?",
+    unknownSpoolSlot: "Слот",
+    bulk: {
+      selectAllVisible: "Вибрати усі видимі",
+      selectRow: "Вибрати рядок",
+      selectGroup: "Вибрати групу",
+      selectionCount: "Вибрано {{count}}.",
+      edit: "Редагувати",
+      printLabels: "Друк етикеток",
+      resetUsage: "Скинути використання",
+      restore: "Відновити",
+      archive: "Архівувати",
+      delete: "Видалити",
+      clearSelection: "Очистити вибір",
+      editTitle: "Масове редагування котушок",
+      editSubtitle: "Застосовується до вибраних котушок {{count}}. Оновлюються лише ті поля, які ви позначите.",
+      editHint: "Введіть значення в поле, щоб позначити його для оновлення: буде надіслано лише позначені поля. Порожнє поле не змінює котушки; очистити поле можна лише під час редагування окремої котушки.",
+      useCustom: "Використати «{{value}}»",
+      toggleField: "Перемкнути оновлення для цього поля",
+      changeCount: "Буде оновлено полів: {{count}}",
+      applyPending: "Застосування...",
+      applyButton: "Застосувати до котушок ({{count}})",
+      deleteTitle: "Видалити вибрані котушки",
+      archiveTitle: "Архівувати вибрані котушки",
+      restoreTitle: "Відновити вибрані котушки",
+      resetUsageTitle: "Скинути використання на вибраних котушках",
+      deleteMessage: "Назавжди видалити котушки {{count}}? Це неможливо скасувати.",
+      archiveMessage: "Архівувати котушки {{count}}? Пізніше їх можна відновити.",
+      restoreMessage: "Відновити заархівовані котушки {{count}}?",
+      resetUsageMessage: "Скинути лічильник «Всього спожито» на котушках {{count}}? Залишок ваги зберігається.",
+      updateSuccess: "Котушок оновлено: {{count}}",
+      updateFailed: "Помилка масового оновлення",
+      updatePartial: "Оновлено: {{ok}}; не вдалося оновити: {{failed}}",
+      updateAllFailed: "Не вдалося оновити жодну з вибраних котушок ({{count}}). Вибір збережено для повторної спроби.",
+      deleteSuccess: "Котушок видалено: {{count}}",
+      deleteFailed: "Не вдалося масово видалити",
+      deletePartial: "Видалено: {{ok}}; не вдалося видалити: {{failed}}",
+      deleteAllFailed: "Не вдалося видалити жодну з вибраних котушок ({{count}}). Вибір збережено для повторної спроби.",
+      archiveSuccess: "Котушок заархівовано: {{count}}",
+      archiveFailed: "Помилка масового архівування",
+      archivePartial: "Заархівовано: {{ok}}; не вдалося заархівувати: {{failed}}",
+      archiveAllFailed: "Не вдалося заархівувати жодну з вибраних котушок ({{count}}). Вибір збережено для повторної спроби.",
+      restoreSuccess: "Котушок відновлено: {{count}}",
+      restoreFailed: "Помилка масового відновлення",
+      restorePartial: "Відновлено: {{ok}}; не вдалося відновити: {{failed}}",
+      restoreAllFailed: "Не вдалося відновити жодну з вибраних котушок ({{count}}). Вибір збережено для повторної спроби.",
+      invalidHex: "Введіть 6 шістнадцяткових символів (RRGGBB) або 8 (RRGGBBAA). Інакше поле не застосовуватиметься.",
+    },
+    spoolmanMixedContentTitle: "Spoolman не може Завантажити через HTTPS — змішаний вміст заблоковано вашим браузером",
+    spoolmanMixedContentBody: "Bambuddy обслуговується через HTTPS (через ваш зворотний проксі), але ваш Spoolman URL усе ще звичайний HTTP. Браузери блокують змішаний вміст для безпеки, тому вбудований Spoolman UI не може відтворюватися. Щоб це працювало, Spoolman має бути доступним через HTTPS.",
+    spoolmanMixedContentFixReverseProxy: "Розмістіть Spoolman за тим самим зворотним проксі, що й Bambuddy (Traefik / Nginx / Caddy) з HTTPS, а потім оновіть Spoolman URL у налаштуваннях до нової адреси HTTPS.",
+    spoolmanMixedContentFixOpenNewTab: "Як обхідний шлях, відкрийте Spoolman у новій вкладці браузера поверх HTTP — правила змішаного вмісту застосовуються лише до вбудованих фреймів, тому окрема вкладка все ще працює.",
+    spoolmanOpenInNewTab: "Відкрийте Spoolman у новій вкладці",
+    labels: {
+      title: "Друк етикеток на котушках",
+      selectedCount: "Вибрано {{count}}.",
+      pickSpools: "Виберіть котушки для друку етикеток:",
+      monochrome: "Монохромний (чорно-білий принтер)",
+      monochromeHint: "Прибирає зразок кольору та розширює область тексту",
+      searchPlaceholder: "Шукайте назву, бренд або #ID",
+      filterByMaterial: "матеріал:",
+      allMaterials: "все",
+      selectVisible: "Вибрати все видиме ({{count}})",
+      deselectVisible: "Скасувати вибір видимого",
+      clearAll: "Очистити все",
+      noSpoolsToShow: "Немає котушок для показу. Налаштуйте фільтр і повторіть спробу.",
+      noMatches: "Жодна котушка не відповідає поточному пошуку чи фільтру.",
+      printOne: "Друк етикетки для цієї котушки",
+      printLabels: "Друк етикеток…",
+      bulkTitle: "Виберіть котушки для друку етикеток серед показаних ({{count}})",
+      noSpoolsTitle: "Немає котушок для маркування",
+      error: "Не вдалося створити мітки: {{msg}}",
+      sortBy: {
+        label: "Сортувати:",
+        id: "За ідентифікатором",
+        color: "За кольором",
+      },
+      templates: {
+        amsHolderSmall: {
+          label: "Тримач AMS — малий (74 × 33 мм)",
+          hint: "Одна етикетка на сторінці; відповідає етикетці для друку від MakerWorld моделі 752566 (AMS Filament Label Holder).",
+        },
+        amsHolderLarge: {
+          label: "Тримач AMS — великий (75 × 55 мм)",
+          hint: "Одна етикетка на сторінці; підходить для варіанту тримача етикеток із філаментом AMS із вкладенням картону. Досить місткий для зразка, бренду, матеріалу, ідентифікатора та QR-коду.",
+        },
+        box40x30: {
+          label: "Етикетка коробки (40 × 30 мм)",
+          hint: "Одна етикетка на сторінці; звичайний розмір рулону DK/Brother, підходить для етикеток у пакетах із філаментом та контейнерах для зберігання.",
+        },
+        box: {
+          label: "Етикетка коробки (62 × 29 мм)",
+          hint: "Одна етикетка на сторінці; розміром для маленьких етикеток Brother PT/QL і Dymo.",
+        },
+        averyL7160: {
+          label: "Avery L7160 — аркуш А4 (38,1 × 63,5 мм × 21)",
+          hint: "Європейський аркуш; 21 етикетка на сторінці A4.",
+        },
+        avery5160: {
+          label: "Avery 5160 — аркуш US Letter (25,4 × 66,7 мм × 30)",
+          hint: "Аркуш американського формату; 30 етикеток на сторінці Letter.",
+        },
+      },
+    },
+    csv: {
+      importButton: "Імпорт CSV",
+      exportButton: "Експорт CSV",
+      modalTitle: "Імпорт котушок з CSV",
+      selectFile: "Виберіть файл CSV або перетягніть його сюди",
+      dragHint: "Заголовок: material (обов’язково), brand, subtype, color_name, rgba, …",
+      parsing: "Читання файлу…",
+      previewError: "Не вдалося прочитати файл CSV.",
+      validCount: "{{count}} дійсний",
+      errorCount: "{{count}} помилка",
+      skippedCount: "{{count}} пропущено",
+      colRow: "Рядок",
+      colStatus: "Статус",
+      colColor: "Колір",
+      colorResolved: "Кольорове заповнення з каталогу",
+      colorCrossMaterial: "Колір узятий з іншого матеріалу — точної відповідності в каталозі немає",
+      duplicateExisting: "Котушка з таким матеріалом, брендом і кольором вже є — вона ще буде імпортована як нова котушка",
+      spoolmanHint: "У режимі Spoolman використовуйте вбудовані засоби імпорту й експорту Spoolman.",
+      importValidRows: "Імпорт {{count}} дійсних рядків",
+      noValidRows: "Немає дійсних рядків",
+      importing: "Імпорт…",
+      importSuccess: "Імпортовано котушок: {{count}}",
+      importError: "Помилка імпорту",
+      exportError: "Помилка експорту",
+    },
+    addSpool: "Додати котушку",
+    editSpool: "Редагувати котушку",
+    copySpool: "Копіювати котушку",
+    material: "матеріал",
+    selectMaterial: "Виберіть матеріал...",
+    subtype: "Підтип",
+    brand: "Бренд",
+    searchBrand: "Шукати бренд...",
+    useCustomBrand: "Використовуйте \"{{brand}}\"",
+    useCustomMaterial: "Використовуйте спеціальний матеріал: {{material}}",
+    suggestedOptions: "Рекомендовані",
+    allOptions: "Усі",
+    colorName: "Назва кольору",
+    colorNamePlaceholder: "Нефритовий білий, вогненно-червоний...",
+    color: "Колір",
+    hexColor: "Шістнадцятковий колір",
+    pickColor: "Вибрати індивідуальний колір",
+    labelWeight: "Номінальна вага філаменту",
+    coreWeight: "Вага порожньої котушки",
+    searchSpoolWeight: "Шукати вагу котушки...",
+    weightUsed: "Використано",
+    currentWeight: "Вага, що залишилася",
+    measuredWeight: "Виміряна вага",
+    spoolName: "Котушка",
+    costPerKg: "Вартість за кг",
+    storageLocation: "Місце зберігання",
+    storageLocationPlaceholder: "напр. Полиця А, ящик 1",
+    openInInventory: "Відкрити в інвентарі",
+    measuredWeightError: "Виміряна вага має бути між {{min}}g і {{max}}g.",
+    slicerFilament: "Філамент для нарізки",
+    slicerFilamentName: "Назва профілю слайсера",
+    slicerPreset: "Попереднє налаштування слайсера",
+    searchPresets: "Шукати профілі філаменту...",
+    selectedPreset: "Вибране",
+    noPresetsFound: "Попередніх налаштувань не знайдено",
+    tempOverrides: "Перевизначення температури",
+    note: "Примітка",
+    notePlaceholder: "Будь-які додаткові зауваження щодо цієї котушки...",
+    // Per-spool category + low-stock threshold override (#729)
+    category: "Категорія",
+    categoryPlaceholder: "напр. Виробництво, прототип, клієнт А",
+    categoryNone: "Без категорії",
+    // #1400: storage-location filter chip — `storageLocation` label is
+    // already defined above for the spool-edit field, reused here for the
+    // dropdown header. `storageLocationNone` is new (the "no location set"
+    // group, mirrors `categoryNone`).
+    storageLocationNone: "Місцезнаходження не встановлено",
+    lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
+    lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).",
+    // RFID button rename (was "Delete Tag" — confusing because it sounds like a
+    // taxonomy delete; this clears the RFID tag/UUID off the spool record)
+    clearRfid: "Очистити тег RFID.",
+    rfidCleared: "Тег RFID видалено",
+    archive: "Архівувати",
+    restore: "Відновити",
+    noSpools: "Ще немає котушок. Щоб почати, додайте свою першу котушку.",
+    noAvailableSpools: "Немає котушок. Додайте котушку до свого інвентарю або спершу скасуйте призначення іншого слота.",
+    kProfiles: "K-профілі",
+    addKProfile: "Додати K-профіль",
+    assignSpool: "Призначити котушку",
+    unassignSpool: "Скасувати призначення",
+    assignSuccess: "Котушку призначено, слот AMS налаштовано",
+    assignPendingInsert: "Котушку призначено. Слот буде налаштовано після її встановлення.",
+    assignFailed: "Не вдалося призначити котушку",
+    selectSpool: "Виберіть котушку, щоб призначити цьому слоту",
+    assigned: "Призначено",
+    assigning: "Призначення...",
+    searchSpools: "Пошук котушок...",
+    showAllSpools: "Показати всі котушки",
+    spoolmanSpools: "Котушки Spoolman",
+    allMaterials: "Усі матеріали",
+    filterByBrand: "Фільтрувати за брендом...",
+    showArchived: "Показати архівні",
+    quickAdd: "Швидке додавання (запас)",
+    quantity: "Кількість",
+    stock: "Запас",
+    configured: "Налаштовано",
+    spoolsCreated: "Створено котушок: {{count}}",
+    spoolsPartiallyCreated: "{{created}} з {{total}} котушок створено (деякі не вдалося)",
+    spoolCreated: "Котушку створено",
+    spoolUpdated: "Котушку оновлено",
+    spoolDeleted: "Котушку видалено",
+    deepLinkSpoolNotFound: "Котушку не знайдено",
+    deepLinkFetchFailed: "Не вдалося завантажити котушку — спробуйте ще раз",
+    spoolArchived: "Котушку заархівовано",
+    spoolRestored: "Котушку відновлено",
+    kProfileSaveFailed: "Не вдалося зберегти налаштування K-профілю",
+    syncWeightSpoolNotFound: "Котушку не знайдено — можливо, її видалено",
+    syncWeightSpoolmanUnreachable: "Spoolman недоступний — спробуйте пізніше",
+    syncWeightFailed: "Не вдалося синхронізувати вагу",
+    spoolmanUnreachable: "Spoolman недоступний — спробуйте пізніше",
+    deleteSpoolNotFound: "Котушку не знайдено — можливо, її вже видалено",
+    deleteFailed: "Не вдалося видалити котушку",
+    archiveSpoolNotFound: "Котушку не знайдено — можливо, її вже видалено",
+    archiveFailed: "Не вдалося заархівувати котушку",
+    restoreSpoolNotFound: "Котушку не знайдено — можливо, її вже видалено",
+    restoreFailed: "Не вдалося відновити котушку",
+    saveFailed: "Не вдалося зберегти зміни",
+    tagClearFailed: "Не вдалося очистити тег",
+    deleteConfirm: "Ви впевнені, що хочете видалити цю котушку? Це неможливо скасувати.",
+    archiveConfirm: "Ви впевнені, що хочете заархівувати цю котушку?",
+    advancedSettings: "Розширені налаштування",
+    // Tabs
+    filamentInfoTab: "Інформація про філамент",
+    paProfileTab: "Профіль PA",
+    filamentInfo: "Філамент",
+    additional: "Додатково",
+    // Cloud
+    loadingPresets: "Завантаження хмарних профілів...",
+    cloudConnected: "Хмара підключена",
+    cloudNotConnected: "Хмара не підключена (за умовчанням)",
+    // Colors
+    recentColors: "Останні",
+    searchColors: "Пошук кольорів...",
+    searchResults: "Результати пошуку",
+    allColors: "Всі кольори",
+    commonColors: "Поширені кольори",
+    showLess: "Показувати менше",
+    showAll: "Показати все",
+    noColorsFound: "Жоден колір не відповідає вашому запиту",
+    noResults: "Збігів не знайдено",
+    // Multi-color gradient + visual effect (#1154)
+    extraColorsLabel: "Додаткові кольори",
+    extraColorsPlaceholder: "EC984C,#6CD4BC,A66EB9,D87694",
+    extraColorsHint: "Вставте від 2 до 8 шістнадцяткових знаків, розділених комами. Візуалізація як градієнт.",
+    extraColorsInvalid: "Проігнорований недійсний шістнадцятковий код: {{tokens}}",
+    colorEffectLabel: "Ефект",
+    colorEffect: {
+      none: "Жодного",
+      // Surface effects
+      sparkle: "Блиск",
+      wood: "Деревина",
+      marble: "Мармур",
+      glow: "Світіння",
+      matte: "Матовий",
+      // Sheen / finish variants
+      silk: "Шовк",
+      galaxy: "Галактика",
+      rainbow: "Веселка",
+      metal: "метал",
+      translucent: "Напівпрозорий",
+      // Multi-color structural variants
+      gradient: "Градієнт",
+      dualColor: "Подвійний колір",
+      triColor: "Три кольори",
+      multicolor: "Багатобарвність",
+    },
+    // PA Profiles
+    selectMaterialFirst: "Спочатку виберіть матеріал на вкладці «Інформація про філамент».",
+    noPrintersConfigured: "Принтери не налаштовані. Додайте принтери для використання профілів PA.",
+    matchingFilter: "Зіставлення",
+    anyBrand: "Будь-який бренд",
+    anyVariant: "Будь-який варіант",
+    autoSelect: "Автоматичний вибір",
+    matches: "збіги",
+    match: "збіг",
+    noMatches: "Немає збігів",
+    connected: "Підключено",
+    offline: "Не в мережі",
+    printerOffline: "Принтер не в мережі. Підключіться, щоб переглянути профілі калібрування.",
+    noKProfilesMatch: "Немає K-профілів, що відповідають вибраному філаменту.",
+    leftNozzle: "Ліве сопло",
+    rightNozzle: "Праве сопло",
+    profilesSelected: "вибрані профілі калібрування",
+    // Stats & enhanced table
+    totalInventory: "Загальний запас",
+    totalConsumed: "Всього спожито",
+    byMaterial: "За матеріалом",
+    inPrinter: "У принтері",
+    lowStock: "Низький запас",
+    sinceTracking: "З початку відстеження",
+    resetConsumedCounter: "Скинути лічильник",
+    resetConsumedCounterTooltip: "Обнулити лічильник спожитих грамів для цієї котушки. Залишкова вага не зміниться.",
+    resetConsumedCounterConfirm: "Скинути лічильник спожитих грамів на 0? Майбутні роздруківки знову відстежуватимуться з нуля. Сама котушка, розрахунок її залишкової ваги та ваші налаштування не змінюються.",
+    resetAllConsumedCounters: "Обнулити всі лічильники",
+    resetAllConsumedCountersTooltip: "Обнулити лічильник спожитих грамів для кожної котушки. Залишкова вага не зміниться.",
+    resetAllConsumedCountersConfirm: "Скинути лічильники спожитих грамів до 0 для всіх котушок ({{count}}, включно з архівними)? Статистику «Всього спожито» буде очищено, а облік майбутніх друків почнеться з нуля. Самі котушки та їхня залишкова вага не зміняться.",
+    consumedCounterReset: "Скидання лічильника",
+    allConsumedCountersReset: "Скинуто лічильники котушок: {{count}}",
+    resetConsumedCounterFailed: "Не вдалося скинути лічильник",
+    loadedInAms: "Завантажено в AMS/Ext",
+    remaining: "Залишилося",
+    weightCheck: "Перевірка ваги",
+    lastWeighed: "Останнє зважування",
+    neverWeighed: "Ще не зважувалася",
+    search: "Пошук котушок...",
+    showing: "Показ",
+    to: "до",
+    of: "з",
+    show: "Показати",
+    spools: "котушки",
+    spool: "котушка",
+    page: "Сторінка",
+    noSpoolsMatch: "Результатів не знайдено",
+    noSpoolsMatchDesc: "Спробуйте налаштувати пошук або фільтри, щоб знайти те, що ви шукаєте.",
+    active: "Активний",
+    archived: "Архівовано",
+    all: "все",
+    used: "Використані",
+    new: "Нові",
+    clearFilters: "Очистити фільтри",
+    table: "Таблиця",
+    cards: "картки",
+    net: "Нетто",
+    // Grouping
+    groupSimilar: "Групувати",
+    groupedSpools: "Однакових котушок: {{count}}",
+    groupedRows: "рядки",
+    // Column config
+    columns: "Стовпці",
+    configureColumns: "Налаштувати стовпці",
+    configureColumnsDesc: "Перетягніть, щоб змінити порядок стовпців, або використовуйте стрілки. Перемкніть видимість за допомогою значка ока.",
+    visible: "видно",
+    reset: "Скинути",
+    cancel: "Скасувати",
+    applyChanges: "Застосувати зміни",
+    moveUp: "Перемістити вгору",
+    moveDown: "Перемістити вниз",
+    hideColumn: "Сховати стовпець",
+    showColumn: "Показати стовпець",
+    // Tag linking
+    linkToSpool: "Прив’язати до котушки",
+    tagLinked: "Тег пов’язано з котушкою",
+    tagLinkFailed: "Не вдалося зв’язати тег",
+    tagAlreadyLinked: "Тег уже пов’язано з іншою котушкою",
+    unknownTag: "Виявлено невідомий тег RFID.",
+    // Usage history
+    usageHistory: "Історія використання",
+    noUsageHistory: "Використання ще не зареєстровано",
+    printName: "Назва друку",
+    weightConsumed: "Витрачена вага",
+    clearHistory: "Очистити",
+    historyCleared: "Історію використання очищено",
+    fillSourceLabel: "(Інвентар)",
+    lowStockThresholdError: "Порогове значення має бути від 0,1 до 99,9",
+    assignMismatchTitle: "Невідповідність матеріалу",
+    assignMismatchMessage: "Вибраний матеріал котушки \"{{spoolMaterial}}\" не відповідає матеріалу лотка \"{{trayMaterial}}\" для {{location}}. Все одно призначити?",
+    assignMismatchConfirm: "Все одно призначити",
+    assignPartialMismatchMessage: "Матеріал котушки \"{{spoolMaterial}}\" схожий на \"{{trayMaterial}}\" у {{location}}, але не зовсім відповідає. Ви бажаєте продовжити?",
+    assignProfileMismatchMessage: "Профіль котушки «{{spoolProfile}}» не відповідає профілю лотка «{{trayProfile}}» у {{location}}. Ви бажаєте продовжити?",
+    assignReconfigureNote: "Слот AMS буде переналаштовано для використання профілю котушки.",
+    // Spoolman filament catalog picker
+    spoolmanFilamentCatalog: "Каталог філаментів Spoolman",
+    pickFromSpoolmanCatalog: "Вибрати з каталогу Spoolman…",
+    spoolmanFilamentSelected: "Філамент вибрано з каталогу Spoolman",
+    spoolmanFilamentUnlinked: "Посилання на каталог філаментів видалено",
+    noSpoolmanFilaments: "У каталозі Spoolman не знайдено філаментів",
+    spoolmanFilamentColorSwatch: "Колір філаменту",
+    spoolWeightManagedBySpoolman: "Вага порожньої котушки керується залежно від типу філаменту в Spoolman",
+    spoolmanCatalogLoadFailed: "Не вдалося завантажити каталог філаментів Spoolman",
+  },
+
+  // Timelapse
+  timelapse: {
+    title: "Таймлапс",
+    create: "Створити Timelapse",
+    download: "Завантажити",
+    delete: "Видалити",
+    preview: "Попередній перегляд",
+    frameRate: "Частота кадрів",
+    quality: "якість",
+    processing: "Обробка...",
+    noTimelapses: "Немає таймлапсів",
+  },
+
+  // AMS
+  ams: {
+    title: "AMS",
+    slot: "Слот",
+    empty: "Порожній",
+    emptySlot: "Порожній слот",
+    slotEmpty: "Порожній",
+    slotUnconfigured: "?",
+    emptySlotReset: "Філамент не призначено",
+    unknown: "Невідомий",
+    humidity: "Вологість",
+    temperature: "температура",
+    filamentType: "Тип філаменту",
+    filamentColor: "Колір",
+    remaining: "Залишилося",
+    history: "AMS Історія",
+    noHistory: "Історія недоступна",
+    configureSlot: "Налаштувати слот",
+    externalSpool: "Зовнішня котушка",
+    profile: "Профіль",
+    kFactor: "Фактор K",
+    fill: "Рівень заповнення",
+    configure: "Налаштувати",
+    used: "використовується",
+    remainingUnit: "залишилося",
+  },
+
+  // Print modal
+  printModal: {
+    selectPrinter: "Вибрати принтер",
+    selectPlate: "Вибрати пластину",
+    filamentMapping: "Зіставлення філаментів",
+    plateN: "Пластина {{n}}",
+    plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
+    totalCost: "Загальна вартість:",
+    slotRemainingShort: " — залишилося {{grams}} г",
+    printSettings: "Налаштування друку",
+    bedLeveling: "Вирівнювання столу",
+    flowCalibration: "Калібрування потоку",
+    vibrationCalibration: "Калібрування вібрації",
+    layerInspection: "Перевірка першого шару",
+    timelapse: "Таймлапс",
+    cancel: "Скасувати",
+    noPrintersAvailable: "Немає доступних принтерів",
+    printerBusy: "Принтер зайнятий",
+    printerOffline: "Принтер не в мережі",
+    sameTypeDifferentColor: "Одного типу, іншого кольору",
+    filamentTypeNotLoaded: "Тип філаменту не завантажено",
+    whenToPrint: "Коли друкувати",
+    asap: "Якнайшвидше",
+    queue: "Черга",
+    schedule: "За розкладом",
+    dateTime: "Дата й час",
+    invalidDateTime: "Введіть дійсну дату й час",
+    openCalendar: "Відкрити календар",
+    requireManualStart: "Потрібен ручний запуск",
+    requirePreviousSuccess: "Починати, лише якщо попередній друк вдався",
+    autoOffAfter: "Вимкнути принтер після завершення",
+    helpAsap: "Завдання буде додано на початок черги й розпочнеться, щойно відповідний принтер звільниться.",
+    helpSchedule: "Друк розпочнеться в запланований час, якщо принтер буде вільний. Якщо принтер зайнятий, завдання чекатиме, доки він звільниться.",
+    helpQueue: "Завдання буде додано в кінець черги.",
+    leftNozzle: "Л",
+    rightNozzle: "Р",
+    leftNozzleTooltip: "Ліве сопло",
+    rightNozzleTooltip: "Праве сопло",
+    filamentOverride: "Перевизначення філаменту",
+    filamentOverrideHint: "Необов’язково замініть філаменти для призначення на основі моделі. Планувальник зіставлятиметься з вибраними філаментами замість вихідних значень 3MF.",
+    originalFilament: "Оригінал",
+    overrideWith: "Перевизначити за допомогою",
+    resetToOriginal: "Скинути до оригіналу",
+    insufficientFilamentTitle: "Недостатньо філаменту",
+    waitingForAmsStatus: "Очікування статусу AMS від {{printer}}…",
+    insufficientFilamentMessage: "У деяких призначених котушках залишилося менше філаменту, ніж потрібно для друку:",
+    insufficientFilamentLine: "{{printer}} - {{slot}}: потрібно {{required}}g, залишилося {{remaining}}g",
+    printAnyway: "Усе одно друкувати",
+    forceColorMatch: "Примусовий збіг кольорів",
+    staggerPrinterStarts: "Запускати принтери почергово",
+    staggerGroupSize: "Розмір групи",
+    staggerInterval: "Інтервал (хв)",
+    staggerPreview: "Принтерів: {{printers}} → груп: {{groups}}, по {{size}}; інтервал запуску — {{interval}} хв",
+    staggerLastGroup: "остання група: {{count}}",
+    staggerTotal: "всього: {{minutes}} хв",
+    staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
+    gcodeInjection: "Додати G-код автоматичного друку",
+  },
+
+  // Backup
+  backup: {
+    includesEncryptionKey: "Локальні резервні копії включають файл ключа шифрування MFA (DATA_DIR/.mfa_encryption_key), тому резервна копія ZIP є автономною. Вважайте ZIP конфіденційним — кожен, хто має файл, може розшифрувати секрети клієнта OIDC і секрети TOTP, що зберігаються в ньому.",
+    title: "Резервне копіювання та відновлення",
+    createBackup: "Створити резервну копію",
+    restoreBackup: "Відновити резервну копію",
+    restoreDescription: "Замінити всі дані даними з файлу резервної копії",
+    downloadBackup: "Завантажити резервну копію",
+    uploadBackup: "Вивантажити резервну копію",
+    lastBackup: "Остання резервна копія",
+    autoBackup: "Автоматичне резервне копіювання",
+    backupNow: "Створити резервну копію зараз",
+    restoreWarning: "Увага: відновлення резервної копії перезапише всі поточні дані.",
+    includeArchives: "Включати архіви",
+    includeSettings: "Включити налаштування",
+    includeProfiles: "Включити профілі",
+    backupSuccess: "Резервну копію створено успішно",
+    restoreSuccess: "Резервну копію успішно відновлено",
+    backupFailed: "Помилка резервного копіювання",
+    restoreFailed: "Не вдалося відновити",
+    restoreNote: "Віртуальний принтер буде зупинено під час відновлення",
+
+    // GitHub Backup
+    githubBackup: "Резервне копіювання Git",
+    enabled: "Увімкнено",
+    cloudLoginRequired: "Потрібно ввійти в Bambu Cloud. Перейдіть до Профілі → Хмарні профілі, щоб увімкнути резервне копіювання до GitHub.",
+    cloudLoginRequiredShort: "Потрібен вхід у хмару",
+    githubDescription: "Автоматично синхронізуйте свої профілі з приватним репозиторієм GitHub для резервного копіювання та історії версій.",
+    repoIsPrivate: "Репозиторій приватний — безпечно створювати резервні копії.",
+    repoIsPublicWarning: "Репозиторій є ПУБЛІЧНИМ. Резервні копії Bambuddy містять облікові дані MQTT, токени Home Assistant, токени Prometheus, вашу електронну пошту Bambu Cloud і коди доступу до принтера через K-профілі. Збереження блокується, доки ви не зробите репозиторій приватним у налаштуваннях постачальника.",
+    repoVisibilityUnknown: "Не вдалося визначити видимість репозиторію. Bambuddy створює резервні копії лише в підтверджено приватних репозиторіях, тому збереження буде заблоковано.",
+    repositoryUrl: "URL репозиторію",
+    repoUrlPlaceholderGitHub: "https://github.com/username/repo-name",
+	repoUrlPlaceholderGitea: "https://gitea.example.com/username/repo-name",
+    repoUrlPlaceholderForgejo: "https://forgejo.example.com/username/repo-name",
+    repoUrlPlaceholderGitLab: "https://gitlab.com/username/repo-name",
+    allowInsecureHttp: "Дозволити незахищений HTTP",
+    allowInsecureHttpHint: "Увімкнути для саморозміщених екземплярів у приватних мережах без TLS",
+    personalAccessToken: "Персональний токен доступу",
+    tokenSaved: "(збережено)",
+    enterNewToken: "Введіть новий токен для оновлення",
+    tokenHint: "Токен із деталізованими правами та дозволом на читання й запис вмісту",
+    branch: "Гілка",
+    provider: "Постачальник Git",
+    providerGitHub: "GitHub",
+    providerGitLab: "GitLab",
+	providerGitea: "Gitea",
+    providerForgejo: "Forgejo",
+    manualOnly: "Тільки вручну",
+    hourly: "щогодини",
+    daily: "Щодня",
+    weekly: "Щотижня",
+    includeInBackup: "Включити в резервну копію",
+    kProfiles: "K-профілі",
+    kProfilesDescription: "Калібрування Pressure Advance (PA) від підключених принтерів",
+    noPrintersConnected: "Немає підключених принтерів",
+    printersConnected: "{{connected}}/{{total}} підключено",
+    cloudProfiles: "Хмарні профілі",
+    cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud",
+    appSettings: "Налаштування програми",
+    appSettingsDescription: "Конфігурація Bambuddy (повна база даних)",
+    spoolInventory: "Облік котушок",
+    spoolInventoryDescription: "Котушки з філаментом, історія використання та відстеження витрат",
+    printArchives: "Архіви друку",
+    printArchivesDescription: "Метадані історії друку (без файлів gcode/3MF)",
+    lastBackupAt: "Остання резервна копія:",
+    noBackupsYet: "Резервних копій ще немає",
+    next: "Наступне виконання:",
+    startingBackup: "Початок резервного копіювання...",
+    test: "Тест",
+    enableBackup: "Увімкнути резервне копіювання",
+    testConnection: "Тестове підключення",
+    enterRepoUrl: "Введіть URL репозиторію",
+    enterRepoAndToken: "Введіть URL репозиторію та токен доступу",
+    repoRequired: "Потрібно вказати URL репозиторію",
+    tokenRequired: "Потрібен токен доступу",
+    githubBackupEnabled: "GitHub резервне копіювання ввімкнено",
+    tokenUpdated: "Токен оновлено",
+    settingsSaved: "Налаштування збережено",
+    failedToSave: "Не вдалося зберегти: {{message}}",
+    backupCompleteFiles: "Резервне копіювання завершено – файли {{count}} оновлено",
+    backupSkippedNoChanges: "Резервне копіювання пропущено – без змін",
+    backupFailed2: "Помилка резервного копіювання: {{message}}",
+    clearedLogs: "Очищено журнали {{count}}.",
+    failedToClearLogs: "Не вдалося очистити журнали: {{message}}",
+
+    // History
+    history: "історія",
+    clear: "Очистити",
+    date: "Дата",
+    status: "Статус",
+    commit: "Коміт",
+
+    // Local Backup
+    localBackup: "Локальна резервна копія",
+    localBackupDescription: "Створити повну резервну копію даних Bambuddy, включно з базою даних, архівами, вивантаженнями та всіма файлами.",
+    downloadBackupLabel: "Завантажити резервну копію",
+    completeBackupZip: "Повне резервне копіювання: база даних + усі файли (ZIP)",
+    download: "Завантажити",
+    preparingBackup: "Підготовка резервної копії...",
+    creatingArchive: "Створення резервного архіву... Для великих архівів це може зайняти деякий час.",
+    downloadingFile: "Завантаження файлу резервної копії...",
+    backupDownloaded: "Резервну копію успішно завантажено",
+    failedToCreateBackup: "Не вдалося створити резервну копію: {{message}}",
+    restore: "Відновити",
+    restoreReplacesAll: "Відновлення замінює всі дані.",
+    restoreReplacesAllDetail: "Вашу поточну базу даних і файли буде повністю замінено. Після відновлення потрібен перезапуск.",
+    restoreConfirmTitle: "Відновити резервну копію",
+    restoreConfirmMessage: "Ви впевнені, що бажаєте відновити з \"{{filename}}\"? Це повністю замінить вашу поточну базу даних і всі файли. Після відновлення програму потрібно буде перезапустити.",
+    restoreConfirmButton: "Відновити резервну копію",
+    uploadingFile: "Вивантаження файлу резервної копії...",
+    backupRestoredRestart: "Резервну копію відновлено. Перезапустіть Bambuddy.",
+    failedToRestore: "Не вдалося відновити резервну копію. Будь ласка, перевірте формат файлу.",
+    reloadNow: "Перезавантажити зараз",
+    creatingBackup: "Створення резервної копії",
+    restoringBackup: "Відновлення з резервної копії",
+    preparing: "Підготовка...",
+    processing: "Обробка...",
+    doNotClosePage: "Будь ласка, не закривайте цю сторінку та не виходьте з неї. Для великих резервних копій ця операція може зайняти кілька хвилин.",
+
+    // RestoreModal
+    restoring: "Відновлення...",
+    restoreComplete: "Відновлення завершено",
+    restoreFailed2: "Не вдалося відновити",
+    importSettings: "Імпорт налаштувань із файлу резервної копії",
+    pleaseWaitRestoring: "Зачекайте, поки ваші дані відновляться",
+    selectBackupFile: "Натисніть, щоб вибрати файл резервної копії (.json або .zip)",
+    duplicateHandling: "Як працює обробка дублікатів:",
+    matchPrinters: "Принтери",
+    matchPrintersBy: "відповідає серійному номеру",
+    matchSmartPlugs: "Розумні розетки",
+    matchSmartPlugsBy: "зіставляються за IP-адресою",
+    matchNotificationProviders: "Постачальники сповіщень",
+    matchNotificationProvidersBy: "відповідність за назвою",
+    matchFilaments: "філаменти",
+    matchFilamentsBy: "відповідність за назвою + типом + брендом",
+    matchArchives: "Архіви",
+    matchArchivesBy: "відповідає хешу вмісту (завжди пропускається)",
+    matchPendingUploads: "Вивантаження, що очікують",
+    matchPendingUploadsBy: "зіставляються за назвою файлу",
+    matchSettingsTemplates: "Налаштування та шаблони",
+    matchSettingsTemplatesBy: "завжди перезаписується",
+    replaceExisting: "Замінити існуючі дані",
+    keepExisting: "Зберегти наявні дані",
+    overwriteDescription: "Замінити наявні елементи даними з резервної копії",
+    keepDescription: "Відновлюйте лише елементи, яких ще не існує",
+    overwriteCaution: "Увага:",
+    overwriteWarning: "Перезапис замінить ваші поточні конфігурації даними з резервної копії. Коди доступу до принтера ніколи не перезаписуються з міркувань безпеки.",
+    cancel: "Скасувати",
+    processingBackup: "Обробка файлу резервної копії...",
+    itemsRestored: "Елементів відновлено",
+    itemsSkipped: "Пропущено елементів",
+    restored: "Відновлено",
+    skippedAlreadyExist: "Пропущено (вже існує)",
+    filesCategory: "Файли (3MF, мініатюри тощо)",
+    andMore: "…і ще {{count}}",
+    newApiKeysGenerated: "Створено нові API ключі",
+    keysShownOnce: "Ці ключі відображаються лише один раз. Скопіюйте їх зараз!",
+    copy: "Копіювати",
+    noDataFound: "У файлі резервної копії не знайдено даних для відновлення.",
+    close: "Закрити",
+
+    // Scheduled local backups (#884)
+    scheduledBackup: "Резервне копіювання за розкладом",
+    scheduledBackupDescription: "Автоматично створюйте резервні знімки за розкладом. Вихідний каталог можна підключити до NAS або зовнішнього накопичувача.",
+    frequency: "Частота",
+    backupTime: "час",
+    retention: "Термін зберігання",
+    retentionDescription: "Кількість резервних копій, які потрібно зберегти",
+    outputPath: "Вихідний шлях",
+    outputPathPlaceholder: "За замовчуванням: {{path}}",
+    outputPathDescription: "Залиште порожнім для розташування за умовчанням",
+    runNow: "Запустити зараз",
+    backupFiles: "Файли резервних копій",
+    noScheduledBackups: "Резервних копій ще немає",
+    deleteBackup: "Видалити",
+    deleteBackupConfirm: "Видалити цей файл резервної копії?",
+    backupRunning: "Виконується резервне копіювання...",
+    scheduledBackupComplete: "Резервне копіювання успішно завершено",
+    scheduledBackupFailed: "Помилка резервного копіювання",
+    nextBackup: "Наступна резервна копія",
+    backupSize: "Розмір",
+    localTimeHint: "Місцевий час ({{tz}})",
+    defaultPathLabel: "За замовчуванням:",
+    // Backup output-path probe (#2544)
+    pathCheck: {
+      title: "Bambuddy не може писати в цей каталог",
+      howToFix: "Як виправити:",
+      sandboxed: "Служба Bambuddy не може писати в {{path}}. Його модуль systemd працює з ProtectSystem=strict, що робить кожен каталог за межами каталогів встановлення, даних і журналу доступними лише для читання для служби — навіть той, у який ви можете писати з власної оболонки.",
+      read_only: "{{path}} знаходиться у файловій системі лише для читання.",
+      permission_denied: "Bambuddy не має права записувати в {{path}}. Перевірте власника каталогу та дозволи.",
+      no_space: "У файловій системі, де розташовано {{path}}, закінчилося місце.",
+      not_a_directory: "{{path}} існує, але не є каталогом.",
+      missing: "{{path}} не існує і не може бути створений.",
+      error: "Bambuddy не може писати в {{path}}.",
+      ephemeralTitle: "Ці резервні копії не збережуться після перезапуску контейнера",
+      container_ephemeral: "{{path}} розташовано всередині контейнера Bambuddy, а не на хості. Резервні копії в цьому каталозі буде втрачено після повторного створення контейнера. Змонтуйте каталог із хоста:",
+    },
+
+    // Category labels
+    categories: {
+      settings: "Налаштування",
+      notification_providers: "Постачальники сповіщень",
+      notification_templates: "Шаблони сповіщень",
+      smart_plugs: "Розумні розетки",
+      printers: "Принтери",
+      filaments: "філаменти",
+      maintenance_types: "Види технічного обслуговування",
+      archives: "Архіви",
+      projects: "Проєкти",
+      pending_uploads: "Вивантаження, що очікують",
+      external_links: "Зовнішні посилання",
+      api_keys: "Ключі API",
+    },
+  },
+
+  // Tags
+  tags: {
+    title: "Теги",
+    addTag: "Додати тег",
+    editTag: "Редагувати тег",
+    deleteTag: "Видалити тег",
+    tagName: "Назва тегу",
+    tagColor: "Колір мітки",
+    noTags: "Без тегів",
+    deleteConfirm: "Ви впевнені, що бажаєте видалити цей тег?",
+    manageTags: "Керування тегами",
+  },
+
+  // Upload modal (archives)
+  uploadModal: {
+    title: "Вивантажити файли 3MF",
+    dragDrop: "Перетягніть файли .3mf сюди",
+    or: "або",
+    browseFiles: "Огляд файлів",
+    extractionInfo: "Модель принтера буде автоматично витягнуто з метаданих файлу 3MF.",
+    uploaded: "вивантажено",
+    failed: "не вдалося",
+    uploading: "Вивантаження...",
+    upload: "Вивантажити",
+    uploadFailed: "Не вдалося вивантажити",
+  },
+
+  // Edit archive modal
+  // Edit Archive Modal
+  editArchive: {
+    title: "Редагувати архів",
+    name: "Назва",
+    namePlaceholder: "Друкування імені",
+    printer: "Принтер",
+    noPrinter: "Немає принтера",
+    project: "Проєкт",
+    noProject: "Жодного проєкту",
+    itemsPrinted: "Надруковано об’єктів",
+    itemsPrintedHelp: "Кількість елементів, виготовлених у цьому завданні друку",
+    notes: "Примітки",
+    notesPlaceholder: "Додайте примітки щодо цього друку...",
+    externalLink: "Зовнішнє посилання",
+    externalLinkPlaceholder: "https://printables.com/model/...",
+    externalLinkHelp: "Посилання на Printables, Thingiverse або інше джерело",
+    tags: "Теги",
+    tagsPlaceholder: "Додати теги...",
+    addMoreTags: "Додати більше тегів...",
+    matchingTags: "Відповідає \"{{query}}\"",
+    existingTags: "Існуючі теги",
+    clickToAdd: "(натисніть, щоб додати)",
+    status: "Статус",
+    failureReason: "Причина невдачі",
+    selectReason: "Виберіть причину...",
+    photos: "Фотографії надрукованих результатів",
+    photosHelp: "Натисніть +, щоб додати фотографії результату друку",
+    printResult: "Роздрукувати результат",
+    saving: "Збереження...",
+    // Failure reasons
+    failureReasons: {
+      adhesionFailure: "Порушення адгезії",
+      spaghettiDetached: "Спагетті / Відр",
+      layerShift: "Зсув шару",
+      cloggedNozzle: "Засмічене сопло",
+      filamentRunout: "Биття філаменту",
+      warping: "Викривлення",
+      stringing: "Нанизування",
+      underExtrusion: "Підвидавлювання",
+      powerFailure: "Збій живлення",
+      userCancelled: "Користувач скасовано",
+      other: "інше",
+    },
+    // Archive statuses
+    statuses: {
+      completed: "Виконано",
+      failed: "Не вдалося",
+      aborted: "Скасовано",
+      printing: "Друк",
+    },
+  },
+
+  // K-Profiles
+  kProfiles: {
+    title: "K-профілі",
+    noPrintersConfigured: "Принтери не налаштовані",
+    addPrinterInSettings: "Додайте принтер у налаштуваннях, щоб керувати K-профілями",
+    noActivePrinters: "Немає активних принтерів",
+    enablePrinterConnection: "Увімкніть підключення до принтера, щоб переглянути його K-профілі",
+    loadingProfiles: "Завантаження K-профілів...",
+    printerOffline: "Принтер не в мережі",
+    printerOfflineDesc: "Вибраний принтер не підключено. Увімкніть його, щоб переглянути K-профілі.",
+    noMatchingProfiles: "Немає відповідних профілів",
+    noMatchingProfilesDesc: "Немає профілів, які відповідають вашим критеріям пошуку",
+    noKProfiles: "Без K-профілів",
+    noKProfilesDesc: "Не знайдено профілів підвищення тиску для сопла {{diameter}} мм",
+    createFirstProfile: "Створити перший профіль",
+    // Controls
+    printer: "Принтер",
+    nozzle: "Сопло",
+    refresh: "Оновити",
+    addProfile: "Додати профіль",
+    export: "Експорт",
+    import: "Імпорт",
+    select: "Вибрати",
+    selectAll: "Вибрати усі",
+    delete: "Видалити",
+    // Filters
+    searchPlaceholder: "Пошук за назвою або філаментом...",
+    allExtruders: "Всі екструдери",
+    leftOnly: "Тільки зліва",
+    rightOnly: "Тільки право",
+    allFlow: "Весь потік",
+    hfOnly: "Тільки HF",
+    sOnly: "Тільки S",
+    sortName: "Сортувати за назвою",
+    sortKValue: "Сортувати: К-значення",
+    sortFilament: "Сорт: філамент",
+    // Dual extruder labels
+    leftExtruder: "Лівий екструдер",
+    rightExtruder: "Правий екструдер",
+    // Modal
+    modal: {
+      addTitle: "Додати K-профіль",
+      editTitle: "Редагувати K-профіль",
+      profileName: "Назва профілю",
+      profileNamePlaceholder: "Мій профіль PLA.",
+      kValue: "К-значення",
+      kValuePlaceholder: "0,020",
+      kValueHelp: "Типовий діапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
+      filament: "Філамент",
+      selectFilament: "Виберіть філамент...",
+      noFilamentsHelp: "Філаменти не знайдено. Спочатку створіть K-профіль у Bambu Studio.",
+      flowType: "Тип потоку",
+      highFlow: "Сопло з високим потоком",
+      standard: "Стандартний",
+      nozzleSize: "Розмір сопла",
+      extruder: "Екструдер",
+      extruders: "Екструдери",
+      left: "Ліворуч",
+      right: "правильно",
+      notes: "Примітки (зберігаються локально)",
+      notesPlaceholder: "Додайте примітки щодо цього профілю...",
+      notesHelp: "Примітки зберігаються в Bambuddy, а не на принтері",
+      syncing: "Синхронізація з принтером...",
+      savingExtruder: "Збереження в екструдер {{current}}/{{total}}...",
+      pleaseWait: "Будь ласка, зачекайте",
+    },
+    // Delete confirmation
+    deleteConfirm: {
+      title: "Видалити профіль",
+      cannotUndo: "Це неможливо скасувати",
+      message: "Ви впевнені, що хочете видалити \"{{name}}\" із принтера?",
+    },
+    // Bulk delete
+    bulkDelete: {
+      title: "Видалити профілі",
+      cannotUndo: "Це неможливо скасувати",
+      message: "Ви впевнені, що бажаєте видалити {{count}} вибрані профілі з принтера?",
+    },
+    // Toast
+    toast: {
+      profileSaved: "K-профіль збережено",
+      profilesSaved: "K-профіль збережено в екструдери {{count}}.",
+      selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
+      profileDeleted: "K-профіль видалено",
+      profilesDeleted: "Видалені профілі {{count}}.",
+      exportedProfiles: "Експортовані профілі {{count}}.",
+      importedProfiles: "Імпортовано {{count}} з {{total}} профілів",
+      noProfilesToExport: "Немає профілів для експорту",
+      invalidFileFormat: "Недійсний формат файлу",
+      failedToParseImport: "Не вдалося проаналізувати імпортований файл",
+      failedToSaveBatch: "Не вдалося зберегти K-профілі",
+      noteSaved: "Нотатку збережено",
+      failedToSaveNote: "Не вдалося зберегти нотатку",
+    },
+    // Permissions
+    permission: {
+      noRead: "Ви не маєте дозволу оновлювати профілі",
+      noCreate: "Ви не маєте дозволу додавати профілі",
+      noUpdate: "Ви не маєте дозволу на оновлення K-профілів",
+      noDelete: "Ви не маєте дозволу на видалення K-профілів",
+      noExport: "Ви не маєте дозволу на експорт профілів",
+      noImport: "Ви не маєте дозволу на імпорт профілів",
+    },
+  },
+
+  // Virtual Printer
+  virtualPrinter: {
+    title: "Віртуальний принтер",
+    running: "Працює",
+    stopped: "Зупинено",
+    description: {
+      default: "Увімкніть віртуальний принтер, який відображатиметься в Bambu Studio та OrcaSlicer. Надіслані на нього файли буде одразу заархівовано без друку.",
+      proxy: "Увімкніть проксі, який передає трафік слайсера на реальний принтер і дає змогу друкувати віддалено через будь-яку мережу.",
+    },
+    enable: {
+      title: "Увімкнути віртуальний принтер",
+      visibleInSlicer: "Відображається як \"Bambuddy\" у виявленні слайсера",
+      proxyingTo: "Проксування до {{name}}",
+      notActive: "Неактивний",
+    },
+    model: {
+      title: "Модель принтера",
+      description: "Виберіть модель принтера для емуляції.",
+      restartWarning: "Зміна моделі перезапустить віртуальний принтер",
+    },
+    accessCode: {
+      title: "Код доступу",
+      isSet: "Код доступу встановлено",
+      notSet: "Немає встановленого коду доступу – потрібно для ввімкнення",
+      placeholder: "Введіть 8-символьний код",
+      placeholderChange: "Введіть новий код для зміни",
+      hint: "Має бути рівно 8 символів. Використовується слайсерами для автентифікації.",
+      charCount: "({{count}}/8)",
+      inheritedFromTarget: "Успадковано від цільового принтера",
+      derivedFromTargetHint: "Використовує код доступу цільового принтера. Міст пересилає автентифікацію слайсера на справжній принтер, тому коди мають збігатися — відредагуйте код доступу принтера, щоб змінити це значення.",
+      reveal: "Показати код доступу",
+      hide: "Приховати код доступу",
+    },
+    targetPrinter: {
+      title: "Цільовий принтер",
+      configured: "Ціль проксі налаштовано",
+      notConfigured: "Цільовий принтер не вибрано – потрібен для режиму проксі",
+      placeholder: "Виберіть принтер...",
+      hint: "Виберіть принтер, на який проксі передаватиме трафік слайсера. Принтер має працювати в режимі LAN.",
+      noPrinters: "Принтери не налаштовані. Щоб використовувати режим проксі, спочатку додайте принтер.",
+    },
+    remoteInterface: {
+      title: "Перевизначення мережевого інтерфейсу",
+      configured: "Перевизначення інтерфейсу активне",
+      optional: "Необов’язково – використовуйте, якщо автоматичне визначення IP є неправильним (наприклад, кілька мережевих карток, Docker, VPN)",
+      placeholder: "Автоматичне визначення (за замовчуванням)...",
+      hint: "Заміна адреси IP, яка оголошується через SSDP і використовується в сертифікаті TLS. Корисно, коли Bambuddy має кілька мережевих інтерфейсів.",
+    },
+    mode: {
+      title: "Режим",
+      archive: "Архів",
+      archiveDesc: "Одразу архівувати файли",
+      review: "Перевірка",
+      reviewDesc: "Перевіряти перед архівуванням",
+      queue: "Черга",
+      queueDesc: "Архівувати та додати в чергу",
+      proxy: "Проксі",
+      proxyDesc: "Пересилати на фізичний принтер",
+    },
+    autoDispatch: {
+      title: "Автоматичний запуск",
+      description: "Автоматично запускати завдання після додавання до черги. Якщо вимкнено, завдання чекатимуть ручного запуску.",
+    },
+    queueForceColorMatch: {
+      title: "Примусовий збіг кольорів",
+      description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
+    },
+    gcodeInjection: {
+      title: "Вставлення G-коду",
+      description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",
+    },
+    tailscaleDisabled: {
+      title: "Інтеграція з Tailscale",
+      description: "Увімкніть, щоб позначити цей віртуальний принтер як доступний через Tailscale. Буде показано Tailscale-адресу хоста, яку потрібно ввести в слайсері. Імпорт сертифіката ЦС залишається обов’язковим; цей перемикач не впливає на сертифікати.",
+    },
+    setupRequired: {
+      title: "Потрібне налаштування",
+      description: "Для роботи функції віртуального принтера потрібна додаткова конфігурація системи. Це включає переадресацію портів, правила брандмауера та параметри, що стосуються платформи.",
+      readGuide: "Перед увімкненням прочитайте посібник із налаштування",
+    },
+    archiveNameSource: {
+      title: "Джерело назви архіву",
+      description: "Виберіть, як називати нові архіви, що надходять через віртуальний принтер. «Метадані» використовують вбудоване слайсером поле Title з 3MF (типовий варіант). «Назва файлу» використовує назву, яку Bambu Studio надіслала через FTP. За наявності поля Title Bambu Studio замінює ним назву, введену у вікні «Надіслати на принтер», тому обидва режими часто дають однаковий результат.",
+      metadata: "Метадані",
+      filename: "Назва файлу",
+    },
+    caCert: {
+      title: "Сертифікат слайсера",
+      description: "Віртуальні принтери використовують сертифікат TLS, підписаний ЦС Bambuddy. Імпортуйте цей сертифікат ЦС у довірче сховище вашого слайсера, щоб він прийняв з’єднання — не потрібно копіювати його з командного рядка.",
+      copy: "Копіювати",
+      copied: "Скопійовано",
+      download: "Завантажити",
+      fingerprint: "SHA-256",
+    },
+    howItWorks: {
+      title: "Як це працює",
+      step1: "У тій самій локальній мережі віртуальні принтери автоматично з’являються в слайсері (Bambu Studio або OrcaSlicer). З інших мереж додайте їх вручну за IP-адресою та кодом доступу.",
+      step2: "У режимах архівування, перевірки та черги натисніть «Надіслати» у слайсері, щоб вивантажити файли 3MF до Bambuddy. Слайсер покаже повідомлення про успішний друк, але файл буде лише збережено, а не надруковано.",
+      step3: "У режимі проксі-сервера віртуальний принтер передає весь трафік на справжній принтер — друк починається негайно, ніби підключений безпосередньо.",
+    },
+    status: {
+      title: "Деталі статусу",
+      printerName: "Назва принтера",
+      model: "Модель",
+      serialNumber: "Серійний номер",
+      mode: "Режим",
+      pendingFiles: "Файли, що очікують на розгляд",
+      targetPrinter: "Цільовий принтер",
+      ftpPort: "Порт FTP",
+      mqttPort: "Порт MQTT",
+      ftpConnections: "Підключення FTP",
+      mqttConnections: "Підключення MQTT",
+    },
+    toast: {
+      updated: "Налаштування віртуального принтера оновлено",
+      failedToUpdate: "Не вдалося оновити налаштування",
+      copyFailed: "Не вдалося скопіювати — спробуйте вибрати текст вручну",
+      accessCodeRequired: "Спочатку встановіть код доступу",
+      targetPrinterRequired: "Спочатку виберіть цільовий принтер",
+      bindIpRequired: "Спочатку встановіть прив’язку IP.",
+      accessCodeEmpty: "Код доступу не може бути порожнім",
+      accessCodeLength: "Код доступу має містити рівно 8 символів",
+      targetCodeChangedRebind: "Код доступу тепер відповідає новому цільовому принтеру. Повторно додайте цей пристрій у свій слайсер, щоб отримати новий код.",
+      created: "Створено віртуальний принтер",
+      failedToCreate: "Не вдалося створити віртуальний принтер",
+      deleted: "Віртуальний принтер видалено",
+      failedToDelete: "Не вдалося видалити віртуальний принтер",
+    },
+    list: {
+      title: "Віртуальні принтери",
+      add: "додати",
+      addFirst: "Додати віртуальний принтер",
+      empty: "Віртуальні принтери не налаштовані. Додайте один, щоб почати.",
+    },
+    bindIp: {
+      title: "Інтерфейс прив’язки",
+      placeholder: "Виберіть інтерфейс...",
+      hint: "Мережевий інтерфейс, до якого прив’язуватиметься цей віртуальний принтер. Для кожного принтера він має бути унікальним.",
+    },
+    proxy: {
+      accessCodeHint: "У режимі проксі використовуйте код доступу цільового принтера в слайсері. З’єднання прозоро пересилається на справжній принтер.",
+    },
+    addDialog: {
+      title: "Додати віртуальний принтер",
+      name: "Назва",
+      hint: "Ви можете налаштувати код доступу, цільовий принтер та інші параметри після створення.",
+      create: "Створити",
+    },
+    deleteConfirm: {
+      title: "Видалити віртуальний принтер",
+      message: "Ви впевнені, що хочете видалити «{{name}}»? Усі служби цього принтера буде зупинено.",
+    },
+  },
+
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: "Відкрити у слайсері",
+    tabs: {
+      model: "3D-модель",
+      gcode: "Попередній перегляд G-коду",
+    },
+    notAvailable: "недоступно",
+    notSliced: "не нарізано",
+    plates: "Пластини",
+    allPlates: "Усі пластини",
+    plateNumber: "Пластина {{number}}",
+    plateCount: "Пластин: {{count}}",
+    plateCount_other: "Пластин: {{count}}",
+    objectCount: "Об’єктів: {{count}}",
+    objectCount_other: "Об’єктів: {{count}}",
+    filamentCount: "Філаментів: {{count}}",
+    filamentCount_other: "Філаментів: {{count}}",
+    eta: "ETA {{minutes}} хв",
+    noPreview: "Попередній перегляд цього файлу недоступний",
+    pagination: {
+      pageOf: "Сторінка {{current}} з {{total}}",
+      prev: "Назад",
+      next: "Далі",
+    },
+    errors: {
+      failedToLoad: "Не вдалося завантажити файл",
+      noMeshes: "У файлі 3MF не знайдено жодної сітки",
+      unsupportedFormat: "Непідтримуваний формат файлу",
+    },
+  },
+
+  // Maintenance type descriptions (built-in)
+  maintenanceDescriptions: {
+    lubricateCarbonRods: "Нанесіть мастило на карбонові стрижні для плавного руху",
+    lubricateRails: "Нанесіть мастило на лінійні рейки для плавного руху",
+    cleanNozzle: "Очистіть нагрівач і сопло, щоб запобігти засміченню",
+    checkBelts: "Перевірте натяг ременя для точного друку",
+    cleanBuildPlate: "Очистіть будівельну пластину для кращого зчеплення",
+    checkExtruder: "Перевірте шестерні екструдера на знос",
+    checkCooling: "Переконайтеся, що вентилятори охолодження працюють належним чином",
+    generalInspection: "Загальний огляд принтера",
+    cleanCarbonRods: "Очистіть карбонові стрижні, щоб зменшити тертя",
+    lubricateSteelRods: "Нанесіть мастило на сталеві стрижні для плавного руху",
+    cleanSteelRods: "Очистіть сталеві стрижні, щоб зменшити тертя",
+    cleanLinearRails: "Протріть лінійні рейки, щоб видалити пил і сміття",
+    checkPtfeTube: "Перевірте трубку PTFE на знос або пошкодження",
+    replaceHepaFilter: "Замініть фільтр HEPA для забезпечення якості повітря",
+    replaceCarbonFilter: "Замініть фільтр з активованим вугіллям",
+    lubricateLeftNozzleRail: "Змастіть ліву рейку сопла (серія H2)",
+  },
+
+  // Smart Plugs
+  smartPlugs: {
+    offline: "Не в мережі",
+    admin: "Адміністрування",
+    openPlugAdminPage: "Відкрити сторінку адміністратора розетки",
+    deleteSmartPlug: "Видалити розумну розетку",
+    turnOnSmartPlug: "Увімкнути розумну розетку",
+    turnOffSmartPlug: "Вимкнути розумну розетку",
+    turnOn: "Увімкнути",
+    turnOff: "Вимкнути",
+    addSmartPlug: {
+      scanningNetwork: "Сканування мережі...",
+      chooseEntity: "Виберіть сутність…",
+      connectionFailed: "Помилка підключення",
+      searchEntities: "Пошук сутностей...",
+      searchPowerSensors: "Пошук датчиків потужності...",
+      searchEnergySensors: "Пошук датчиків енергії...",
+      placeholders: {
+        plugName: "Розетка у вітальні",
+        mqttStateOnValue: "ON, true, 1",
+        mqttSameAsPower: "Та сама тема, що й для потужності, або інша",
+      },
+    },
+    // SmartPlugCard
+    linkedTo: "Пов’язано з:",
+    monitorOnly: "Лише моніторинг",
+    alerts: "Оповіщення",
+    scheduleOn: "Увімкнення: {{time}}",
+    scheduleOff: "Вимкнення: {{time}}",
+    on: "Увімкнено",
+    off: "Вимкнено",
+    power: "Потужність",
+    kwhToday: "кВт·год сьогодні",
+    settings: "Налаштування",
+    automationSettings: "Налаштування автоматизації",
+    showInSwitchbar: "Показати на панелі перемикачів",
+    quickAccessSidebar: "Швидкий доступ із бічної панелі",
+    enabled: "Увімкнено",
+    enableAutomation: "Увімкнути автоматизацію для цієї розетки",
+    autoOn: "Автоматичне ввімкнення",
+    autoOnDescription: "Увімкнути на початку друку",
+    autoOff: "Автоматичне вимкнення",
+    autoOffDescription: "Вимкнути після завершення друку (одноразово)",
+    autoOffPersistent: "Залишити ввімкненим",
+    autoOffPersistentDescription: "Залишати ввімкненою між друками, а не лише після одного завдання",
+    controlsPrinterPower: "Живить принтер",
+    controlsPrinterPowerDescription: "Вимкніть цей параметр, якщо розетка живить лише аксесуар — наприклад, вентилятор фільтра або освітлення. Інакше вимкнення розетки позначатиме принтер як недоступний.",
+    autoOffAfterDrying: "Автоматичне вимкнення після сушіння",
+    autoOffAfterDryingDescription: "Вимкнути після завершення сушіння AMS",
+    delayAfterDryingMinutes: "Затримка після сушіння (хв)",
+    turnOffDelayMode: "Режим затримки вимкнення",
+    time: "Час",
+    temp: "Температура",
+    delayMinutes: "Затримка (хвилин)",
+    tempThreshold: "Температурний поріг (°C)",
+    tempThresholdDescription: "Вимикається, коли сопло охолоне нижче цієї температури",
+    edit: "Редагувати",
+    deleteConfirm: "Ви впевнені, що хочете видалити \"{{name}}\"? Це неможливо скасувати.",
+    turnOnConfirm: "Ви впевнені, що бажаєте ввімкнути \"{{name}}\"?",
+    turnOffConfirm: "Ви впевнені, що бажаєте вимкнути \"{{name}}\"? Це призведе до відключення живлення підключеного пристрою.",
+    failedToTurn: "Не вдалося змінити стан «{{name}}» ({{action}})",
+    unknown: "Невідомий",
+    // AddSmartPlugModal
+    addTitle: "Додати розумну розетку",
+    editTitle: "Редагувати розумну розетку",
+    stopScanning: "Зупинити сканування",
+    discoverTasmota: "Виявити пристрої Tasmota",
+    foundDevices: "Знайдено {{count}} пристроїв - натисніть, щоб вибрати:",
+    noDevicesFound: "У вашій мережі не знайдено пристроїв Tasmota.",
+    haNotConfigured: "Home Assistant не налаштовано. Налаштуйте його в",
+    haSettingsPath: "Налаштування → Мережа → Home Assistant",
+    selectEntity: "Вибрати сутність *",
+    ipAddress: "IP Адреса *",
+    nameLabel: "Назва *",
+    username: "Ім’я користувача",
+    password: "Пароль",
+    authHint: "Залиште поле пустим, якщо ваш пристрій Tasmota не вимагає автентифікації",
+    linkToPrinter: "Прив’язати до принтера",
+    noPrinter: "Без принтера (лише ручне керування)",
+    linkingDescription: "Прив’язування дає змогу автоматично вмикати й вимикати розетку на початку та після завершення друку",
+    powerAlerts: "Сповіщення про живлення",
+    alertAbove: "Сповіщати, якщо вище (Вт)",
+    alertBelow: "Сповіщати, якщо нижче (Вт)",
+    alertDescription: "Сповіщати, коли споживана потужність перетинає ці пороги. Залиште відповідне поле порожнім, щоб вимкнути сповіщення для цього напрямку.",
+    dailySchedule: "Щоденний розклад",
+    turnOnAt: "Увімкнути о",
+    turnOffAt: "Вимкнути о",
+    scheduleDescription: "Щодня автоматично вмикати й вимикати розетку в зазначений час. Залиште поле порожнім, щоб пропустити відповідну дію.",
+    showOnPrinterCard: "Показати на картці принтера",
+    displayOnPrinterCard: "Показувати кнопку на картці принтера",
+    connectedResult: "Підключено!",
+    deviceLabel: "Пристрій: {{name}} - ",
+    stateLabel: "Стан: {{state}}",
+    test: "Тест",
+    delete: "Видалити",
+    save: "Зберегти",
+    add: "Додати",
+    cancel: "Скасувати",
+    failedToStartScan: "Не вдалося почати сканування",
+    nameRequired: "Необхідно вказати ім’я",
+    entityRequired: "Сутність необхідна для розеток Home Assistant.",
+    mqttTopicRequired: "Для моніторингу потужності, енергії або стану потрібно налаштувати принаймні одну тему MQTT",
+    loadingEntities: "Завантаження об’єктів...",
+    loading: "Завантаження...",
+    failedToLoadEntities: "Не вдалося завантажити сутності: {{error}}",
+    noEntitiesMatching: "Не знайдено об’єктів, що відповідають \"{{search}}\"",
+    noEntitiesAvailable: "Немає доступних сутностей",
+    searchingEntities: "Пошук у всіх об’єктах ({{count}} знайдено)",
+    showingEntities: "Показано сутності switch, light та input_boolean (доступно: {{count}})",
+    energyMonitoringOptional: "Моніторинг енергії (необов’язково)",
+    energyMonitoringHint: "Знайдіть і виберіть датчики, що надають дані про потужність або споживання енергії.",
+    powerSensorW: "Датчик потужності (Вт)",
+    energyTodayKwh: "Споживання за сьогодні (кВт·год)",
+    totalEnergyKwh: "Загальне споживання енергії (кВт·год)",
+    noMatchingSensors: "Немає відповідних датчиків",
+    none: "Жодного",
+    mqttNotConfigured: "MQTT-брокер не налаштований. Укажіть адресу брокера в розділі",
+    mqttSettingsPath: "Налаштування → Мережа → Публікація MQTT",
+    mqttNotConfiguredSuffix: "(вмикати публікацію не потрібно — достатньо вказати параметри брокера).",
+    mqttMonitorOnlyDescription: "MQTT-розетки отримують дані про потужність і споживання енергії через підписку MQTT. Керування ввімкненням і вимкненням недоступне — використовуйте MQTT-брокер або систему домашньої автоматизації.",
+    powerMonitoring: "Моніторинг потужності",
+    energyMonitoring: "Моніторинг споживання енергії",
+    stateMonitoring: "Моніторинг стану",
+    optional: "необов’язково",
+    topic: "Тема",
+    jsonPath: "Шлях JSON",
+    multiplier: "Множник",
+    onValue: "Значення «увімкнено»",
+    mqttPowerHint: "Шлях JSON витягує значення з корисного навантаження JSON (наприклад, \"power_l1\"). Залиште поле порожнім, якщо тема публікує безпосередньо числові значення.\nДля перетворення mW→W використовуйте множник 0.001, а для kW→W — 1000.",
+    mqttEnergyHint: "Шлях JSON витягує значення з корисного навантаження JSON. Залиште поле порожнім для безпосередньо переданих значень.\nДля перетворення Wh→kWh використовуйте множник 0.001, а для MWh→kWh — 1000.",
+    mqttStateHint: "Шлях JSON витягує значення з корисного навантаження JSON. Залиште поле порожнім для безпосередньо переданих значень.\nЗначення «увімкнено»: точний рядок, що означає стан «увімкнено». Залиште поле порожнім для автоматичного визначення (ON, true, 1).",
+    // REST smart plug
+    restControl: "Керування",
+    restOnUrl: "Увімкнути URL",
+    restOffUrl: "Вимкнути URL",
+    restOnBody: "Тіло запиту ввімкнення",
+    restOffBody: "Тіло запиту вимкнення",
+    restMethod: "Метод HTTP",
+    restHeaders: "Спеціальні заголовки (JSON)",
+    restStatusUrl: "URL стану",
+    restStatusPath: "Шлях JSON до стану",
+    restStatusOnValue: "Значення «увімкнено»",
+    restPowerUrl: "URL даних про потужність",
+    restPowerPath: "Шлях JSON до потужності",
+    restPowerMultiplier: "Множник потужності",
+    restEnergyUrl: "URL даних про енергію",
+    restEnergyPath: "Шлях JSON до споживання за сьогодні",
+    restEnergyTotalPath: "Шлях JSON до загального споживання",
+    restEnergyTotalMultiplier: "Множник загального споживання",
+    restEnergyTotalPathHint: "наприклад, aenergy.total",
+    restEnergyTotalHint:
+      "Багато розеток, зокрема всі моделі Shelly, повідомляють лише загальний лічильник споживання, який ніколи не скидається. Його слід указувати саме тут, а не в полі вище: якщо трактувати це значення як споживання за сьогодні, воно не скинеться опівночі, а показники «Учора» та «Усього» залишатимуться порожніми. Bambuddy сам обчислює показники за сьогодні й учора на основі цього лічильника; для їх появи потрібні один-два дні даних. Shelly передає значення у ват-годинах, тому використовуйте множник 0.001.",
+    restEnergyMultiplier: "Множник енергії",
+    restUrlRequired: "Для REST-розетки потрібно вказати принаймні один URL — для ввімкнення або вимкнення.",
+    restHeadersHint: "наприклад, {\"Authorization\": \"Bearer your-token\"}",
+    restBodyHint: "напр. ON, {\"state\": \"on\"}",
+    restStatusHint: "URL для опитування поточного стану",
+    restPathHint: "наприклад, state або data.power.status",
+    restPowerUrlHint: "Окремий URL для даних про потужність; якщо поле порожнє, використовується URL стану",
+    restEnergyUrlHint: "Окремий URL для даних про енергію; якщо поле порожнє, використовується URL стану",
+    restEnergyHint: "Для кожного значення можна використовувати окремий URL або URL стану як резервний. Для перетворення одиниць використовуйте множники, наприклад 0.001 для перетворення Wh у kWh.",
+    testConnection: "Тестове підключення",
+    connectionSuccess: "Підключення успішне",
+    noSwitchesInSwitchbar: "На панелі перемикачів немає розеток",
+    enableSwitchbarHint: "Увімкніть «Показувати на панелі перемикачів» у розділі «Налаштування → Розумні розетки».",
+  },
+
+  // Notifications
+  notifications: {
+    // Provider types
+    providerTypes: {
+      callmebot: "CallMeBot/WhatsApp",
+      ntfy: "ntfy",
+      pushover: "Pushover",
+      telegram: "Telegram",
+      email: "Електронна пошта",
+      discord: "Discord",
+      webhook: "Webhook",
+      homeassistant: "Home Assistant",
+      bark: "Bark",
+    },
+    // Provider descriptions
+    providerDescriptions: {
+      email: "Сповіщення електронною поштою SMTP",
+      telegram: "Сповіщення через бота Telegram.",
+      discord: "Надіслати на канал Discord через webhook",
+      ntfy: "Безкоштовні push-сповіщення з можливістю самостійного розгортання",
+      pushover: "Прості та надійні push-повідомлення",
+      callmebot: "Безкоштовні сповіщення WhatsApp через CallMeBot",
+      webhook: "Універсальний HTTP POST-запит на будь-який URL",
+      homeassistant: "Постійні сповіщення на інформаційній панелі Home Assistant.",
+      bark: "Push-сповіщення iOS через Bark із можливістю самостійного розгортання",
+    },
+    // NotificationProviderCard
+    lastSuccess: "Останнє успішне надсилання: {{date}}",
+    error: "Помилка",
+    printer: "Принтер:",
+    allPrinters: "Всі принтери",
+    sendTestNotification: "Надіслати тестове сповіщення",
+    eventSettings: "Налаштування події",
+    enabled: "Увімкнено",
+    sendFromProvider: "Надсилати сповіщення від цього постачальника",
+    // Event categories
+    printEvents: "Події друку",
+    printerStatus: "Стан принтера",
+    amsAlarms: "Попередження AMS",
+    amsHtAlarms: "Попередження AMS-HT",
+    printQueue: "Черга друку",
+    // Event tags (badges)
+    start: "Початок",
+    plateCheck: "Перевірка пластини",
+    complete: "Завершено",
+    failed: "Помилка",
+    stopped: "Зупинено",
+    progress: "Прогрес",
+    offline: "Не в мережі",
+    lowFilament: "Закінчується філамент",
+    maintenance: "Технічне обслуговування",
+    amsHumidity: "AMS Вологість",
+    amsTemp: "AMS Темп",
+    amsHtHumidity: "AMS-HT Вологість",
+    amsHtTemp: "Температура AMS-HT",
+    bedCooled: "Стіл охолов",
+    firstLayer: "Перший шар",
+    quiet: "Тихо",
+    digest: "Дайджест {{time}}",
+    // Event labels (expanded settings)
+    printStarted: "Друк розпочато",
+    plateNotEmpty: "Друкарська пластина не порожня",
+    plateNotEmptyDescription: "Перед друком виявлено об’єкти",
+    plateClearRequired: "Потрібно очистити пластину",
+    plateClearRequiredDescription: "Друк завершено, черга очікує підтвердження очищення пластини",
+    plateClear: "Очищення пластини",
+    printCompleted: "Друк завершено",
+    bedCooledLabel: "Стіл охолов",
+    bedCooledDescription: "Після друку стіл охолов нижче заданого порога",
+    firstLayerCompleteLabel: "Перший шар завершено",
+    firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
+    missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
+    missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
+    printFailed: "Помилка друку",
+    printStopped: "Друк зупинено",
+    progressMilestones: "Віхи прогресу",
+    progressMilestonesDescription: "Сповіщати на 25%, 50%, 75%",
+    printerOffline: "Принтер не в мережі",
+    printerError: "Помилка принтера",
+    aiFailureDetection: "Виявлення помилок за допомогою ШІ",
+    aiFailureDetectionDescription: "Сповіщати, коли ШІ Obico виявляє можливу помилку друку",
+    lowFilamentLabel: "Закінчується філамент",
+    maintenanceDue: "Настав час технічного обслуговування",
+    maintenanceDueDescription: "Повідомити, коли потрібне обслуговування",
+    amsHumidityHigh: "AMS Висока вологість",
+    amsHumidityHighDescription: "Звичайна AMS вологість перевищує порогове значення",
+    amsTemperatureHigh: "AMS Висока температура",
+    amsTemperatureHighDescription: "Звичайна AMS температура перевищує порогове значення",
+    amsHtHumidityHigh: "AMS-HT Висока вологість",
+    amsHtHumidityHighDescription: "AMS-HT вологість перевищує порогове значення",
+    amsHtTemperatureHigh: "Висока температура AMS-HT",
+    amsHtTemperatureHighDescription: "Температура AMS-HT перевищує порогове значення",
+    // Inventory stock alert events
+    inventoryAlerts: "Сповіщення про запаси",
+    stockReorderAlert: "Сповіщення про необхідність поповнення",
+    stockReorderAlertDescription: "Запас SKU досяг точки повторного замовлення",
+    stockBreakAlert: "Сповіщення про ризик вичерпання запасу",
+    stockBreakAlertDescription: "Запас закінчиться до того, як надійде поповнення",
+    // Queue events
+    jobAdded: "Завдання додано",
+    jobAddedDescription: "Завдання додано до черги",
+    jobAssigned: "Завдання призначено",
+    jobAssignedDescription: "Завдання на основі моделі, призначене принтеру",
+    jobStarted: "Завдання розпочато",
+    jobStartedDescription: "Розпочато друк завдання черги",
+    jobWaiting: "Завдання очікує",
+    jobWaitingDescription: "Завдання очікує на філамент або принтер",
+    jobSkipped: "Завдання пропущено",
+    jobSkippedDescription: "Завдання пропущено (попереднє не вдалося)",
+    jobFailed: "Помилка завдання",
+    jobFailedDescription: "Не вдалося розпочати завдання",
+    queueComplete: "Черга завершена",
+    queueCompleteDescription: "Усі завдання черги завершено",
+    // Quiet hours
+    quietHours: "Тихі години",
+    noNotificationsDuring: "Жодних сповіщень у ці години",
+    editProviderToChangeQuietHours: "Змініть постачальника, щоб змінити години тиші",
+    // Daily digest
+    dailyDigest: "Щоденний дайджест",
+    batchNotifications: "Групуйте сповіщення в єдиний щоденний підсумок",
+    sendAt: "Надсилати о {{time}}",
+    editProviderToChangeDigestTime: "Відредагуйте постачальника, щоб змінити час дайджесту",
+    // Actions
+    edit: "Редагувати",
+    deleteProvider: "Видалити постачальника сповіщень",
+    deleteConfirm: "Видалити «{{name}}»? Цю дію неможливо скасувати.",
+    delete: "Видалити",
+    // AddNotificationModal
+    addTitle: "Додати постачальника сповіщень",
+    editTitle: "Редагувати постачальника сповіщень",
+    nameLabel: "Назва *",
+    namePlaceholder: "Мої сповіщення",
+    providerTypeLabel: "Тип постачальника *",
+    configuration: "Конфігурація",
+    testConfiguration: "Перевірити конфігурацію",
+    printerFilter: "Фільтр принтера",
+    onlyFromPrinter: "Надсилати лише сповіщення про події з цього принтера",
+    quietHoursDnd: "Тихі години (не турбувати)",
+    quietStart: "Початок",
+    quietEnd: "Кінець",
+    dailyDigestLabel: "Щоденний дайджест",
+    sendDigestAt: "Надсилати дайджест о",
+    digestCollected: "Події буде зібрано й надіслано в цей час одним зведенням",
+    notificationEvents: "Події сповіщень",
+    progressPercent: "(25%, 50%, 75%)",
+    bedCooledAfterPrint: "(після завершення друку)",
+    // Per-event ntfy priority (#990)
+    eventPriority: {
+      sectionTitle: "Пріоритет ntfy",
+      helpNtfy: "Виберіть пріоритет для кожної ввімкненої події. ntfy використовує їх для ескалації сповіщень (звук, видимість, поведінка push). Для рівнів, які тут не встановлено, використовується сервер ntfy за замовчуванням.",
+      min: "Мін",
+      low: "Низький",
+      default: "За замовчуванням",
+      high: "Високий",
+      urgent: "Терміново",
+    },
+    cancel: "Скасувати",
+    save: "зберегти",
+    add: "додати",
+    nameRequired: "Необхідно вказати ім’я",
+    fieldRequired: "{{field}} — обов’язкове поле.",
+    haDataInvalid: "Поле «Дані» має містити дійсний об’єкт JSON, наприклад {\"priority\": \"high\", \"ttl\": 0}",
+    // Config field labels
+    phoneNumber: "Номер телефону",
+    apiKey: "Ключ API",
+    serverUrl: "Сервер URL",
+    topic: "Тема",
+    authToken: "Токен авторизації",
+    userKey: "Ключ користувача",
+    appToken: "Токен застосунку",
+    priority: "Пріоритет",
+    pushoverRetry: "Інтервал повторення екстреного сповіщення (с)",
+    pushoverExpire: "Термін дії екстреного сповіщення (с)",
+    botToken: "Токен бота",
+    chatId: "Ідентифікатор чату",
+    smtpServer: "Сервер SMTP",
+    smtpPort: "Порт SMTP",
+    security: "Безпека",
+    authentication: "Автентифікація",
+    username: "Ім’я користувача",
+    password: "Пароль",
+    fromEmail: "Адреса відправника",
+    toEmail: "Адреса одержувача",
+    webhookUrl: "Webhook URL",
+    payloadFormat: "Формат корисного навантаження",
+    authorization: "Авторизація",
+    titleFieldName: "Назва поля заголовка",
+    messageFieldName: "Назва поля повідомлення",
+    // NotificationTemplateEditor
+    editTemplate: "Редагувати шаблон: {{name}}",
+    titleLabel: "Заголовок",
+    bodyLabel: "Текст",
+    titlePlaceholder: "Назва сповіщення...",
+    bodyPlaceholder: "Текст сповіщення...",
+    availableVariables: "Доступні змінні",
+    clickToInsert: "Натисніть, щоб вставити в текст у позиції курсора",
+    livePreview: "Попередній перегляд",
+    hide: "Сховати",
+    show: "Показати",
+    loadingPreview: "Завантаження попереднього перегляду...",
+    enterTemplateContent: "Введіть вміст шаблону, щоб побачити попередній перегляд",
+    titlePreview: "Назва:",
+    bodyPreview: "Текст:",
+    resetToDefault: "Відновити типові значення",
+    titleRequired: "Необхідно вказати назву",
+    bodyRequired: "Текст є обов’язковим",
+    // NotificationLogViewer
+    notificationLog: "Журнал повідомлень",
+    showFailedOnly: "Лише невдалі",
+    last24Hours: "Останні 24 години",
+    last7Days: "Останні 7 днів",
+    last30Days: "Останні 30 днів",
+    last90Days: "Останні 90 днів",
+    justNow: "Щойно",
+    noFailedNotifications: "Немає невдалих сповіщень",
+    noNotificationsLogged: "Сповіщення не зареєстровано",
+    unknownProvider: "Невідомий постачальник",
+    logTitle: "Назва",
+    logMessage: "повідомлення",
+    logError: "Помилка",
+    logProvider: "Постачальник: {{type}}",
+    logTime: "Час: {{time}}",
+    refresh: "Оновити",
+    clearOld: "Очистити старі",
+    statsSummary: "За останні {{days}} дн.:",
+    statsNotifications: "сповіщення",
+    statsSent: "{{count}} надіслано",
+    statsFailed: "не вдалося надіслати: {{count}}",
+    // Event type labels (for log viewer)
+    eventTypes: {
+      print_start: "Друк розпочато",
+      print_complete: "Друк завершено",
+      print_failed: "Помилка друку",
+      print_stopped: "Друк зупинено",
+      print_progress: "Прогрес",
+      printer_offline: "Принтер не в мережі",
+      printer_error: "Помилка принтера",
+      filament_low: "Закінчується філамент",
+      maintenance_due: "Технічне обслуговування",
+      test: "Тест",
+    },
+    // User email notification preferences
+    userEmail: {
+      title: "Сповіщення",
+      emailNotifications: "Сповіщення електронною поштою",
+      emailNotificationsDesc: "Отримуйте сповіщення електронною поштою про власні завдання друку. Електронні листи надсилаються за допомогою системних параметрів SMTP, налаштованих у розширеній автентифікації.",
+      sendingTo: "Сповіщення будуть надіслані на адресу",
+      noEmailWarning: "У вашому обліковому записі немає адреси електронної пошти. Зверніться до адміністратора, щоб її додати.",
+      printJobNotifications: "Повідомлення про завдання друку",
+      printJobNotificationsDesc: "Виберіть, які події викликають сповіщення електронною поштою про завдання друку, які ви надсилаєте.",
+      printJobStarts: "Початок завдання друку",
+      printJobStartsDesc: "Отримуйте сповіщення, коли розпочнеться ваше завдання друку.",
+      printJobFinishes: "Завершення завдання друку",
+      printJobFinishesDesc: "Отримуйте сповіщення, коли ваше завдання друку буде успішно завершено.",
+      printErrors: "Помилки друку",
+      printErrorsDesc: "Отримуйте сповіщення, якщо завдання друку завершується невдало або під час нього виникає помилка.",
+      printJobStops: "Зупинення завдання друку",
+      printJobStopsDesc: "Отримуйте сповіщення, коли ваше завдання друку скасовано або зупинено.",
+      saveSuccess: "Параметри сповіщень збережено.",
+      saveError: "Не вдалося зберегти налаштування сповіщень.",
+    },
+  },
+
+  // Rich Text Editor
+  richTextEditor: {
+    bold: "жирний",
+    italic: "Курсив",
+    underline: "Підкреслити",
+    bulletList: "Список маркерів",
+    numberedList: "Нумерований список",
+    alignLeft: "Вирівняти по лівому краю",
+    alignCenter: "Вирівняти по центру",
+    alignRight: "Вирівняти праворуч",
+    addLink: "Додати посилання",
+    removeLink: "Видалити посилання",
+  },
+
+  // External Links
+  externalLinks: {
+    title: "Посилання на бічній панелі",
+    sidebarLayout: "Бічна панель",
+    sidebarLayoutDescription: "Показуйте або приховуйте вбудовані сторінки, додавайте зовнішні посилання та перетягуйте елементи, щоб змінити порядок навігації на бічній панелі.",
+    systemPages: "Bambuddy сторінок",
+    externalLinks: "Зовнішні посилання",
+    visibleInSidebar: "Видно на бічній панелі",
+    hiddenFromSidebar: "Приховано від бічної панелі",
+    requiredInSidebar: "Обов’язково на бічній панелі",
+    hidePage: "Приховати сторінку",
+    showPage: "Показати сторінку",
+    settingsCannotBeHidden: "Налаштування не можна приховати",
+    noLinksConfigured: "Зовнішні посилання не налаштовано",
+    deleteLink: "Видалити посилання",
+    removeCustomIcon: "Видалити спеціальний значок",
+    openInNewTab: "Відкрити в новій вкладці",
+    placeholders: {
+      linkName: "Моє посилання",
+    },
+  },
+
+  // Keyboard Shortcuts Modal
+  keyboardShortcuts: {
+    title: "Комбінації клавіш",
+    navigation: "Навігація",
+    archivesSection: "Архіви",
+    kProfilesSection: "K-профілі",
+    generalSection: "Загальний",
+    shortcuts: {
+      goToPrinters: "Перейдіть до Принтери",
+      goToArchives: "Перейдіть до архіву",
+      goToQueue: "Перейдіть до черги",
+      goToStats: "Перейти до статистики",
+      goToProfiles: "Перейдіть до Cloud Profiles",
+      goToSettings: "Перейдіть до Налаштувань",
+      focusSearch: "Пошук по фокусу",
+      openUploadModal: "Відкрити вікно вивантаження",
+      clearSelection: "Очистити виділення / розмити введення",
+      contextMenu: "Контекстне меню на картках",
+      refreshProfiles: "Оновити профілі",
+      newProfile: "Новий профіль",
+      exitSelectionMode: "Вийти з режиму вибору",
+      showHelp: "Покажіть цю довідку",
+    },
+    footer: "Натисніть Esc або клацніть зовні, щоб закрити",
+  },
+
+  // Notification Log
+  notificationLog: {
+    title: "Журнал повідомлень",
+    events: {
+      printStarted: "Друк розпочато",
+      printComplete: "Друк завершено",
+      printFailed: "Помилка друку",
+      printStopped: "Друк зупинено",
+      progress: "Прогрес",
+      printerOffline: "Принтер не в мережі",
+      printerError: "Помилка принтера",
+      lowFilament: "Закінчується філамент",
+      maintenanceDue: "Технічне обслуговування",
+      test: "Тест",
+    },
+    timeAgo: {
+      justNow: "Просто зараз",
+      minutesAgo: "{{minutes}}хв тому",
+      hoursAgo: "{{hours}} год тому",
+    },
+  },
+
+  // Restore/Backup Modal
+  restoreBackup: {
+    title: "Відновити резервну копію",
+    restoring: "Відновлення...",
+    restoreComplete: "Відновлення завершено",
+    restoreFailed: "Не вдалося відновити",
+    importSettings: "Імпорт налаштувань із файлу резервної копії",
+    pleaseWait: "Зачекайте, поки ваші дані відновляться",
+    clickToSelect: "Натисніть, щоб вибрати файл резервної копії (.json або .zip)",
+    howDuplicateHandling: "Як працює обробка дублікатів:",
+    categories: {
+      printers: "Принтери",
+      smartPlugs: "Розумні розетки",
+      notificationProviders: "Постачальники сповіщень",
+      filaments: "філаменти",
+      archives: "Архіви",
+      pendingUploads: "Вивантаження, що очікують",
+      settingsTemplates: "Налаштування та шаблони",
+    },
+    matchingInfo: {
+      printers: "відповідає серійному номеру",
+      smartPlugs: "збігається з адресою IP.",
+      notificationProviders: "відповідність за назвою",
+      filaments: "відповідність за назвою + типом + брендом",
+      archives: "відповідає хешу вмісту",
+      pendingUploads: "відповідає імені файлу",
+      settingsTemplates: "завжди перезаписується",
+    },
+    replaceExisting: "Замінити існуючі дані",
+    keepExisting: "Зберегти наявні дані",
+    replaceDescription: "Замінити елементи, які вже існують, резервними копіями",
+    keepDescription: "Відновлюйте лише елементи, яких ще не існує",
+    caution: "Увага:",
+    cautionText: "Під час перезапису поточні конфігурації буде замінено даними з резервної копії. З міркувань безпеки коди доступу до принтерів ніколи не перезаписуються.",
+    itemsRestored: "Елементів відновлено",
+    itemsSkipped: "Елементи пропущені",
+    restored: "Відновлено",
+    skipped: "Пропущено (вже існує)",
+    filesLabel: "Файли (3MF, мініатюри тощо)",
+    newApiKeysGenerated: "Створено нові API ключі",
+    newApiKeysWarning: "Ці ключі буде показано лише один раз. Скопіюйте їх зараз!",
+    processingBackup: "Обробка файлу резервної копії...",
+    noDataFound: "У файлі резервної копії не знайдено даних для відновлення.",
+    failedToRestore: "Не вдалося відновити резервну копію. Будь ласка, перевірте формат файлу.",
+  },
+
+  // Backup Export Modal
+  backupExport: {
+    title: "Експорт резервної копії",
+    selectData: "Вибрати дані для включення",
+    selectAll: "Вибрати усі",
+    selectNone: "Вибрати Немає",
+    categoryDescriptions: {
+      settings: "Мова, тема, параметри оновлення",
+      notifications: "ntfy, Pushover, Discord тощо.",
+      templates: "Користувацькі шаблони повідомлень",
+      smartPlugs: "Конфігурації розетки Tasmota.",
+      externalLinks: "Посилання на бічній панелі на зовнішні служби",
+      printers: "Інформація про принтер (без кодів доступу)",
+      plateDetection: "Еталонні зображення порожньої пластини",
+      filaments: "Види та вартість філаменти",
+      maintenance: "Спеціальні графіки технічного обслуговування",
+      archives: "Усі дані для друку + файли (3MF, мініатюри, фотографії)",
+      projects: "Проєкти, елементи специфікації та вкладення",
+      pendingUploads: "Вивантаження на віртуальний принтер очікують на розгляд",
+      apiKeys: "Ключі Webhook API (нові ключі створюються під час імпорту)",
+    },
+    requiresPrinters: "Необхідно вибрати принтери",
+    zipFileWarning: "Буде створено файл ZIP.",
+    zipFileDescription: "До архіву ввійдуть усі файли 3MF, мініатюри, таймлапси та фотографії. Створення може тривати певний час, а отриманий файл може мати великий розмір.",
+    includeAccessCodes: "Додати коди доступу",
+    includeAccessCodesDescription: "Для перенесення на іншу машину",
+    includeAccessCodesWarning: "Коди доступу буде збережено у відкритому вигляді. Надійно захистіть файл резервної копії!",
+    categoriesSelected: "Вибрано категорії {{selectedCount}}.",
+  },
+
+  // Pending Uploads Panel
+  pendingUploads: {
+    placeholders: {
+      notes: "Додайте примітки щодо цього друку...",
+    },
+    discardUpload: "Відхилити вивантаження",
+    archiveAllUploads: "Архівувати всі вивантаження",
+    discardAllUploads: "Відхилити всі вивантаження",
+    archive: "Архів",
+    timeAgo: {
+      justNow: "Просто зараз",
+      minutesAgo: "{{minutes}}хв тому",
+      hoursAgo: "{{hours}} год тому",
+      daysAgo: "{{days}} дн. тому",
+    },
+  },
+
+  // API Browser
+  apiBrowser: {
+    placeholders: {
+      requestBody: "JSON тіло запиту...",
+      searchEndpoints: "Пошук кінцевих точок...",
+    },
+  },
+
+  // Configure AMS Slot Modal
+  configureAmsSlot: {
+    title: "Налаштувати слот AMS.",
+    slotConfigured: "Слот налаштовано!",
+    configuringSlot: "Налаштування слота:",
+    slotLabel: "{{ams}} Слот {{slot}}",
+    searchPresets: "Шукати профілі...",
+    colorPlaceholder: "Назва або шістнадцяткова назва кольору (наприклад, коричневий, FF8800)",
+    clearCustomColor: "Очистити спеціальний колір",
+    noCloudPresets: "Хмарних профілів немає. Увійдіть у Bambu Cloud для синхронізації.",
+    noPresetsAvailable: "Профілів немає. Увійдіть у Bambu Cloud або імпортуйте локальні профілі.",
+    noMatchingPresets: "Відповідних стилів не знайдено.",
+    custom: "Власний",
+    builtin: "Вбудований",
+    orcaCloud: "Orca Cloud",
+    bambuCloud: "Бамбукова хмара",
+    settingsSentToPrinter: "Налаштування надіслано на принтер",
+    filamentProfile: "Профіль філаменту",
+    kProfileLabel: "Профіль K (випередження тиску)",
+    filteringFor: "Фільтрування за: {{material}}",
+    noKProfile: "Немає профілю K (використовуйте значення за замовчуванням 0,020)",
+    noMatchingKProfiles: "Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.",
+    selectFilamentFirst: "Спочатку виберіть профіль філаменту",
+    kFromCalibration: "K={{value}} від калібрування принтера",
+    customColorLabel: "Власний колір (необов’язково)",
+    presetColors: "{{name}} кольори:",
+    showLessColors: "Показувати менше кольорів",
+    showMoreColors: "Показати більше кольорів",
+    clear: "Очистити",
+    hexLabel: "Шістнадцятковий: #{{hex}}",
+    resetting: "Скидання...",
+    resetSlot: "Скинути слот",
+    cancel: "Скасувати",
+    configuring: "Налаштування...",
+    configureSlot: "Налаштувати слот",
+  },
+
+  // Git Backup Settings
+  githubBackup: {
+    title: "Резервне копіювання Git",
+    history: "історія",
+    downloadBackup: "Завантажити резервну копію",
+    restoreBackup: "Відновити резервну копію",
+    noBackupsYet: "Резервних копій ще немає",
+  },
+
+  // Email Settings
+  emailSettings: {
+    placeholders: {
+      fromName: "BamBuddy",
+    },
+  },
+
+  // Tag Management Modal
+  tagManagement: {
+    searchTags: "Пошук тегів...",
+    renameTag: "Перейменувати тег",
+    deleteTag: "Видалити тег",
+  },
+
+  // Notification Template Editor
+  notificationTemplates: {
+    placeholders: {
+      title: "Назва сповіщення...",
+      body: "Текст сповіщення...",
+    },
+  },
+
+  // Batch Tag Modal
+  batchTag: {
+    placeholders: {
+      newTag: "Введіть новий тег...",
+    },
+  },
+
+  // Photo Gallery Modal
+  photoGallery: {
+    deletePhoto: "Видалити фото",
+  },
+
+  // Filament Hover Card
+  filamentHoverCard: {
+    copySpoolUuid: "Копіювати UUID буфера",
+  },
+
+  // K Profiles View
+  kProfilesView: {
+    hasNote: "Має примітку",
+    copyProfile: "Копіювати профіль",
+  },
+
+  // Layout/Navigation
+  layout: {
+    openMenu: "Відкрити меню",
+    noPermissionSystemInfo: "У вас немає дозволу на перегляд системної інформації",
+  },
+
+  // Dashboard
+  dashboard: {
+    dragToReorder: "Перетягніть, щоб змінити порядок",
+    hideWidget: "Сховати віджет",
+  },
+
+  // Notification Provider Card
+  notificationProviderCard: {
+    deleteNotificationProvider: "Видалити постачальника сповіщень",
+  },
+
+  // File Manager Modal
+  fileManagerModal: {
+    closeFileManager: "Закрити файловий менеджер",
+    sortFiles: "Сортування файлів",
+    goToParentFolder: "Перейти до батьківської папки",
+    threeView: "3D перегляд",
+  },
+
+  // Embedded Camera Viewer
+  embeddedCameraViewer: {
+    refreshStream: "Оновити потік",
+    close: "Закрити",
+    zoomOut: "Зменшити масштаб",
+    resetZoom: "Скинути масштаб",
+    zoomIn: "Збільшити",
+    dragToResize: "Перетягніть, щоб змінити розмір",
+  },
+
+  // Timelapse Viewer
+  timelapseViewer: {
+    skipBack5s: "Перейти на 5 секунд назад",
+    skipForward5s: "Перейти вперед на 5 с",
+  },
+
+  // Notification Providers
+  notificationProviders: {
+    descriptions: {
+      email: "Сповіщення електронною поштою SMTP",
+      telegram: "Сповіщення через бота Telegram.",
+      discord: "Надіслати на канал Discord через webhook",
+      ntfy: "Безкоштовні push-сповіщення з можливістю самостійного розгортання",
+      pushover: "Прості та надійні push-повідомлення",
+      callmebot: "Безкоштовні сповіщення WhatsApp через CallMeBot",
+      webhook: "Універсальний HTTP POST-запит на будь-який URL",
+    },
+  },
+
+  // Log Viewer
+  logViewer: {
+    searchPlaceholder: "Шукати повідомлення або назву реєстратора...",
+    noLogEntries: "Записів у журналі не знайдено",
+  },
+
+  // Switchbar Popover
+  switchbarPopover: {
+    noSwitchesInSwitchbar: "На панелі перемикачів немає",
+  },
+
+  // Project Page Modal
+  projectPageModal: {
+    placeholders: {
+      title: "Назва",
+      designer: "Дизайнер",
+      license: "Ліцензія",
+      description: "Введіть опис...",
+      profileTitle: "Назва профілю",
+      profileDescription: "Опис профілю...",
+    },
+  },
+
+  // Spoolman Settings
+  spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: "-",
+    waiting: "Очікування",
+    justNow: "Просто зараз",
+    now: "Зараз",
+    minsAgo: "{{count}}хв тому",
+    inMins: "в {{count}}м",
+    hoursAgo: "{{count}} год тому",
+    inHours: "в {{count}} год",
+    daysAgo: "{{count}} дн. тому",
+    inDays: "в {{count}}d",
+  },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: "Приладова панель",
+      ams: "AMS",
+      inventory: "Інвентар",
+      writeTag: "Записати",
+      settings: "Налаштування",
+    },
+    status: {
+      nfcReady: "NFC Готовий",
+      nfcOff: "NFC Вимк",
+      offline: "Не в мережі",
+      online: "Онлайн",
+      noPrinters: "Немає принтерів",
+      deviceOffline: "Пристрій не в мережі",
+      waitingConnection: "Очікування підключення пристрою...",
+      systemReady: "Система готова",
+      status: "Статус",
+    },
+    dashboard: {
+      readyToScan: "Готовий до сканування",
+      idleMessage: "Покладіть котушку на ваги, щоб ідентифікувати її",
+      nfcHint: "Тег NFC буде прочитано автоматично",
+      device: "пристрій",
+      syncWeight: "Синхронізувати вагу",
+      weightSynced: "Синхронізовано!",
+      unknownTag: "Невідомий тег",
+      newTag: "Виявлено новий тег",
+      onScale: "на вагах",
+      linkSpool: "Прив’язати до котушки",
+      linkTagTitle: "Прив’язати тег до котушки",
+      linkTag: "Прив’язати тег",
+      selectSpool: "Виберіть котушку, до якої потрібно прив’язати цей тег:",
+      noUntagged: "Не знайдено котушок без тегів",
+      tagDetected: "Тег виявлено",
+      noTag: "Без тегу",
+      tagId: "Тег",
+      grossWeight: "Вага брутто",
+      spoolSize: "Розмір котушки",
+      close: "Закрити",
+      currentSpool: "Поточна котушка",
+      plateReady: "Пластина готова: {{name}}",
+      plateReadyLabel: "Пластини готові до очищення",
+      plateClearAction: "Очистити",
+      plateClearedToast: "Друкарська пластина позначена як очищена",
+      plateClearFailed: "Не вдалося позначити пластину як очищену",
+    },
+    modal: {
+      spoolDetected: "Виявлено котушку",
+      assignToAms: "Призначити AMS",
+      syncWeight: "Синхронізувати вагу",
+      weightSynced: "Синхронізовано!",
+      syncing: "Синхронізація...",
+      newTagDetected: "Виявлено новий тег",
+      addToInventory: "Додати в інвентар",
+      assignToAmsTitle: "Призначити AMS",
+      selectSlot: "Вибрати слот",
+      assign: "Призначити",
+      assigning: "Призначення...",
+      assignSuccess: "Призначено!",
+      assignPendingInsert: "Призначений. Слот буде налаштовано, коли ви вставите котушку.",
+      assignError: "Не вдалося призначити котушку. Спробуйте ще раз.",
+      noPrinterSelected: "Виберіть принтер...",
+      noAmsDetected: "AMS не виявлено на цьому принтері",
+      slot: "Слот",
+    },
+    weight: {
+      noReading: "Немає показань",
+      stable: "Стабільно",
+      measuring: "Вимірювання…",
+      tare: "Тарувати",
+      calibrate: "Відкалібрувати",
+    },
+    spool: {
+      remaining: "Залишилося",
+      material: "матеріал",
+      brand: "Бренд",
+      color: "Колір",
+      coreWeight: "Сердечник",
+      labelWeight: "Етикетка",
+      scaleWeight: "Показання ваг",
+      netWeight: "Нетто",
+      lastUsed: "Останнє використання",
+    },
+    ams: {
+      noData: "AMS не виявлено",
+      connectAms: "Підключіть AMS, щоб побачити слоти для філаментів",
+      noPrinter: "Принтер не вибрано",
+      selectPrinter: "Виберіть принтер у верхній панелі",
+      printerDisconnected: "Принтер відключений",
+      humidity: "Вологість",
+      level: "Рівень",
+      active: "Активний",
+      slot: "Слот",
+      empty: "Порожній",
+    },
+    inventory: {
+      search: "Пошук котушок...",
+      empty: "Немає котушок в інвентарі",
+      noResults: "Немає відповідних котушок",
+      spools: "котушки",
+      addSpool: "Додати котушку",
+    },
+    settings: {
+      // Tabs
+      tabDevice: "пристрій",
+      tabDisplay: "Дисплей",
+      tabScale: "Ваги",
+      tabUpdates: "Оновлення",
+      // Device tab
+      nfcReader: "NFC Читач",
+      type: "Тип",
+      connection: "Підключення",
+    notConnected: "Н/Д",
+      deviceInfo: "Інформація про пристрій",
+      hostname: "Хост",
+      uptime: "Час роботи",
+    systemConfig: "Серверна частина та автентифікація",
+      backendUrl: "Bambuddy Сервер URL",
+      apiToken: "API Токен",
+      apiTokenPlaceholder: "Введіть API-токен.",
+      saveConfig: "Зберегти конфігурацію",
+      systemQueued: "Конфігурацію в черзі.",
+      nfcDiagnostic: "NFC Діагностика",
+      scaleDiagnostic: "Діагностика ваг",
+      readTagDiagnostic: "Прочитайте діагностику тегів",
+      testNfc: "Тестовий читач",
+      testScale: "Перевірити точність",
+      testReadTag: "Прочитати тег",
+      systemFieldsRequired: "Потрібен бекенд URL.",
+      // Display tab
+      brightness: "Яскравість",
+      saved: "Збережено",
+      noBacklight: "Підсвічування DSI не виявлено. Для керування яскравістю потрібен дисплей DSI.",
+      screenBlank: "Час очікування порожнього екрана",
+      screenBlankDesc: "Екран вимикається після бездіяльності. Торкніться, щоб розбудити.",
+      displayNote: "Яскравість використовується як програмний фільтр.",
+      // Scale tab
+      scaleCalibration: "Калібрування ваг",
+      currentWeight: "Поточна вага",
+      tareOffset: "Зміщення тари",
+      calFactor: "Коефіцієнт калібрування",
+      knownWeight: "Відома маса",
+      calStep1: "Приберіть усі предмети з ваг і натисніть «Установити нуль».",
+      calStep2: "Покладіть на ваги предмет відомої маси.",
+      setZero: "Встановити нуль",
+      calibrateNow: "Відкалібрувати",
+      calibrated: "Відкалібровано",
+      tareSet: "Надіслано команду тарування. Очікування пристрою...",
+      tareComplete: "Тарування завершено!",
+      tareTimedOut: "Час очікування тарування минув — чи запущено демон SpoolBuddy?",
+      tareFailed: "Не вдалося надіслати команду тарування",
+      zeroSet: "Нульову точку встановлено. Покладіть на ваги предмет відомої маси.",
+      calibrationDone: "Калібрування завершено!",
+      calibrationFailed: "Помилка калібрування",
+      lastCalibrated: "Останнє калібрування",
+      stable: "Стабільно",
+      settling: "Стабілізація…",
+      firmware: "Прошивка",
+      scale: "Ваги",
+      noDevice: "Пристрій SpoolBuddy не знайдено",
+      // Updates tab
+      daemonVersion: "Демон версія",
+      currentVersion: "поточний",
+      versionPending: "В очікуванні демона...",
+      checking: "Перевірка...",
+      checkUpdates: "Перевірте наявність оновлень",
+      updateAvailable: "Доступне оновлення",
+      updateInstructions: "Оновлення через SSH: запустіть сценарій встановлення SpoolBuddy для оновлення.",
+      upToDate: "В актуальному стані",
+      includeBeta: "Включати бета-версії",
+    },
+    writeTag: {
+      tabExisting: "Наявна котушка",
+      tabNew: "Нова котушка",
+      tabReplace: "Замінити тег",
+      searchPlaceholder: "Пошук за матеріалом, кольором, брендом...",
+      noUntaggedSpools: "Немає котушок без тегів",
+      noTaggedSpools: "Немає котушок із тегами",
+      selectSpool: "Виберіть котушку, а потім помістіть порожній NTAG на зчитувач",
+      placeTag: "Помістіть NTAG на зчитувач",
+      tagReady: "Тег виявлено — готовий до запису",
+      writeTag: "Записати тег",
+      replaceTag: "Замінити тег",
+      writing: "Записування тегу…",
+      waiting: "Очікування SpoolBuddy...",
+      writeSuccess: "Тег успішно записано!",
+      writeFailed: "Помилка запису",
+      queueFailed: "Не вдалося поставити команду запису в чергу",
+      tryAgain: "Спробуйте знову",
+      cancel: "Скасувати",
+      replaceWarning: "Старий тег буде від’єднано від котушки, а замість нього буде прив’язано новий.",
+      deviceOffline: "SpoolBuddy не в мережі",
+      material: "матеріал",
+      colorName: "Назва кольору",
+      color: "Колір",
+      brand: "Бренд",
+      weight: "Вага (г)",
+      createSpool: "Створити котушку",
+      creating: "Створення...",
+      spoolCreated: "Котушку створено! Можна записувати тег.",
+      createFailed: "Не вдалося створити котушку",
+      incompleteDataWarning: "Тег записано з неповними даними Spoolman.",
+    },
+    quickMenu: {
+      printerPower: "Живлення принтера",
+      systemControls: "система",
+      restartDaemon: "Перезапустити демон",
+      restartBrowser: "Перезапустити браузер",
+      reboot: "Перезавантажити",
+      shutdown: "Вимкнути",
+      swipeToClose: "Проведіть пальцем вниз, щоб закрити",
+      confirmTitle: "Підтвердити",
+      confirmShutdown: "Вимкнути SpoolBuddy? Щоб увімкнути його знову, знадобиться фізичний доступ до пристрою.",
+      confirmReboot: "Перезавантажити SpoolBuddy?",
+      confirmRestartDaemon: "Перезапустити демон SpoolBuddy? NFC і ваги будуть тимчасово недоступні.",
+      confirmRestartBrowser: "Перезапустити веб-переглядач кіоску? Дисплей на короткий час погасне.",
+      confirm: "Підтвердити",
+      confirmPlugOn: "Увімкнути {{name}}?",
+      confirmPlugOff: "Вимкнути {{name}}?",
+      turnOn: "Увімкнути",
+      turnOff: "Вимкнути",
+    },
+  },
+
+  diagnostic: {
+    modalTitle: "Діагностика підключення — {{name}}",
+    running: "Виконується діагностика...",
+    runningElapsed: "Виконується діагностика… ({{elapsed}} с)",
+    waitingForReportHint: "Очікуємо, доки принтер опублікує звіт про стан — це може тривати до {{max}} с.",
+    runFailed: "Не вдалося запустити діагностику: {{error}}",
+    retry: "Запустити знову",
+    runButton: "Запустити діагностику",
+    sectionTitle: "Діагностика підключення",
+    sectionDescription: "Перевірте, чому принтер не підключається або не друкує — доступність порту, режим розробника LAN, режим мережі Docker та облікові дані.",
+    noPrinters: "Принтери не налаштовані.",
+    overall: {
+      ok: "Проблем не виявлено — з’єднання принтера справне.",
+      warnings: "Принтер має працювати, але деякі параметри потребують уваги.",
+      problems: "Знайдено проблеми, які пояснюють, чому принтер не підключається або не друкує.",
+    },
+    check: {
+      port_mqtt: {
+        title: "Порт керування (MQTT 8883)",
+        pass: "Доступний — принтер приймає керівні з’єднання.",
+        fail: "Порт 8883 недоступний. Принтер може бути вимкнений, мати іншу IP-адресу або бути заблокований брандмауером. Перевірте IP-адресу принтера й переконайтеся, що порт 8883 не заблоковано.",
+      },
+      port_ftps: {
+        title: "Порт передачі файлів (FTPS 990)",
+        pass: "Доступний — файли друку можна буде надсилати.",
+        warn: "Порт 990 недоступний. Моніторинг може й надалі працювати, але надсилання завдань друку на принтер завершуватиметься помилкою. Переконайтеся, що порт 990 не заблоковано.",
+      },
+      external_storage: {
+        title: "Зберігання надісланих файлів у зовнішньому сховищі (крок установлення 4)",
+        pass: "Принтер повідомляє, що цю опцію ввімкнено — надіслані файли зберігатимуться на картці SD, а архіви матимуть мініатюри та метадані слайсера.",
+        fail: "Принтер повідомляє, що цей параметр вимкнено. Увімкніть «Зберігати надіслані файли в зовнішньому сховищі». У новіших версіях прошивки (P2S 01.02 / Bambu Studio 2.6+) перемикач розташований у налаштуваннях друку на принтері; у старіших — на вкладці «Пристрій» у Bambu Studio або OrcaSlicer. Без цього параметра в кожному архівованому друці будуть відсутні мініатюра та метадані слайсера.",
+        skip: "Не перевірено — потрібне активне MQTT-з’єднання. У старіших слайсерах, де цей параметр існує лише в самому слайсері, принтер його не повідомляє. Тому перевірка може бути успішною навіть за вимкненого параметра — перевірте крок установлення 4 вручну.",
+        skip_unsupported_model: "Ця модель має слот для SD-картки, але не дає змоги ввімкнути цей параметр: поточна прошивка принтерів серії P1 не показує перемикач у Bambu Studio, а сам принтер не має екрана. Виправляти нічого не потрібно; доки Bambu Lab не додасть підтримку в прошивці, в архівованих друках можуть бути відсутні мініатюри та метадані слайсера.",
+      },
+      port_rtsps: {
+        title: "Порт камери ({{protocol}} {{port}})",
+        pass: "Доступно — потік камери працюватиме.",
+        warn: "Порт {{port}} недоступний. Перегляд з камери в реальному часі не працюватиме. Це не впливає на друк.",
+      },
+      network_mode: {
+        title: "Мережевий режим Docker",
+        pass: "Працює в режимі хост-мережі.",
+        warn: "Bambuddy працює в мостовому мережевому режимі Docker. Для виявлення принтерів і роботи віртуального принтера потрібен режим мережі хоста — повторно створіть контейнер із параметром \"network_mode: host\".",
+        skip: "Не працює в Docker — не застосовується.",
+      },
+      subnet: {
+        title: "Мережева підмережа",
+        pass: "Принтер і Bambuddy знаходяться в одній підмережі.",
+        warn: "Принтер ({{printer_ip}}) і Bambuddy ({{host_ip}}) перебувають у різних підмережах. Вони можуть не мати зв’язку між собою, якщо маршрутизацію між підмережами не налаштовано.",
+        skip: "Не вдалося визначити підмережу — пропущено.",
+      },
+      mqtt_auth: {
+        title: "Облікові дані принтера",
+        pass: "Принтер прийняв підключення.",
+        fail: "Принтер доступний, але Bambuddy до нього не під’єднано. Найімовірніше, указано неправильний код доступу або серійний номер — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника, тож знову скопіюйте його з екрана принтера. Так само виглядає принтер, який перезавантажується або вже вичерпав ліміт одночасних з’єднань.",
+        fail_auth_rejected: "Принтер відхилив облікові дані Bambuddy. Код доступу або серійний номер неправильний — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника. Знову скопіюйте його з екрана принтера та збережіть у налаштуваннях принтера.",
+        skip: "Не позначено — принтер недоступний.",
+      },
+      developer_mode: {
+        title: "Режим розробника LAN",
+        pass: "Режим розробника ввімкнено.",
+        fail: "Режим розробника на принтері ВИМКНЕНО. Увімкніть його в налаштуваннях LAN принтера й підтвердьте вибір кнопкою OK. Без цього друк не запускатиметься.",
+        skip: "Неможливо перевірити — потрібне живе підключення до принтера.",
+      },
+      printer_publishing: {
+        title: "Принтер публікує дані про стан",
+        pass: "Принтер публікує оновлення статусу — AMS, філаменти та K-профілі правильно відображатимуться на слайсері.",
+        fail: "MQTT-брокер прийняв з’єднання, але принтер не опублікував жодного звіту про стан. Майже завжди причиною є неправильний серійний номер або неправильний регістр символів: тема device/<serial>/report чутлива до регістру. Звірте серійний номер у налаштуваннях Bambuddy із номером на екрані принтера.",
+        skip: "Неможливо перевірити — потрібне живе підключення до принтера.",
+      },
+    },
+  },
+
+  systemHealth: {
+    sectionTitle: "Стан системи",
+    sectionDescription: "Сканує останні журнали на наявність відомих проблем, які зазвичай можна вирішити самостійно, перш ніж вони перетворяться на запит у службу підтримки.",
+    rescan: "Пересканувати",
+    clean: "В останніх записах журналу {{times}} не знайдено відомих проблем.",
+    logUnavailable: "Журналування файлів вимкнено, тому журнали неможливо сканувати. Увімкніть журналювання файлів, щоб використовувати цю перевірку.",
+    learnMore: "Як виправити",
+    fixLabel: "Виправити:",
+    occurrences: "Виявлено {{times}} разів — востаннє о {{lastSeen}}",
+    category: {
+      layer8: "Ви можете це виправити",
+      environment: "Середовище виконання",
+      bug: "Будь ласка, повідомте про це",
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: "Принтер відхилив код доступу",
+        cause: "Принтер відмовився від входу для передавання файлів. Код доступу неправильний або він змінився після перемикання режиму розробника.",
+        fix: "Повторно скопіюйте код доступу з екрана принтера (налаштування LAN) і оновіть його в налаштуваннях принтера в Bambuddy.",
+      },
+      'ftp-connection-timeout': {
+        name: "Час очікування з’єднання для передачі файлів минув",
+        cause: "Bambuddy не вдалося отримати доступ до порту передачі файлів принтера (FTPS 990). Порт заблоковано, або принтер вимкнено або знаходиться в іншій підмережі.",
+        fix: "Переконайтеся, що ніщо не блокує порт 990 між Bambuddy і принтером, і що обидва знаходяться в одній мережі.",
+      },
+      'ftp-ssl-error': {
+        name: "Помилка захищеного з’єднання для передавання файлів",
+        cause: "Не вдалося виконати TLS-узгодження із сервером передавання файлів принтера. Частою причиною є брандмауер або застаріла прошивка принтера.",
+        fix: "Оновіть прошивку принтера та переконайтеся, що брандмауер або проксі-сервер не перехоплюють з’єднання через порт 990.",
+      },
+      'mqtt-connection-flapping': {
+        name: "Підключення до принтера постійно падає",
+        cause: "Керуюче з’єднання (MQTT 8883) неодноразово роз’єднується та знову під’єднується — зазвичай через слабкий мережевий шлях або частково заблокований порт.",
+        fix: "Перевірте сигнал Wi-Fi на принтері, віддайте перевагу дротовому підключенню та переконайтеся, що порт 8883 надійно доступний.",
+      },
+      'camera-connection-refused': {
+        name: "Потік з камери недоступний",
+        cause: "Не вдається отримати доступ до камери в прямому ефірі через порт RTSPS 322. Порт заблоковано, або на принтері вимкнено камеру або LAN liveview.",
+        fix: "Увімкніть камеру та LAN liveview на принтері та переконайтеся, що порт 322 не заблоковано. Це не впливає на друк.",
+      },
+      'database-locked': {
+        name: "Конфлікт запису до бази даних",
+        cause: "Під навантаженням SQLite повертає помилки \"database is locked\". Це часто трапляється, коли одночасно працюють кілька принтерів.",
+        fix: "Переключіть Bambuddy на зовнішню базу даних PostgreSQL. Див. посібник PostgreSQL у документації.",
+      },
+    },
+  },
+
+  vpDiagnostic: {
+    title: "Перевірка налаштувань — {{name}}",
+    runButton: "Запустити перевірку налаштувань",
+    running: "Виконується перевірка налаштувань...",
+    runFailed: "Не вдалося виконати перевірку налаштувань: {{error}}",
+    retry: "Запустити знову",
+    overall: {
+      ok: "Усі перевірки пройшли — цей віртуальний принтер налаштовано правильно.",
+      warnings: "Віртуальний принтер має працювати, але деякі параметри потребують уваги.",
+      problems: "Знайдено проблеми, які пояснюють, чому слайсер не бачить або не використовує цей віртуальний принтер.",
+    },
+    check: {
+      enabled: {
+        title: "Віртуальний принтер увімкнено",
+        fail: "Цей віртуальний принтер вимкнено. Увімкніть його, щоб зробити його видимим.",
+      },
+      running: {
+        title: "Служби запущені",
+        fail: "Віртуальний принтер увімкнено, але його служби не працюють. Перевірте журнал Bambuddy — зазвичай запуску заважає конфлікт IP-адреси прив’язки або помилка дозволів.",
+      },
+      bind_interface: {
+        title: "Прив’язка мережевого інтерфейсу",
+        fail: "Інтерфейс прив’язки не встановлено або більше не існує на цьому хості. Виберіть поточний інтерфейс у спадному списку «Інтерфейс прив’язки».",
+      },
+      access_code: {
+        title: "Код доступу встановлено",
+        fail: "Код доступу не встановлено. У слайсері потрібно вказати той самий 8-символьний код доступу, який задано тут.",
+      },
+      target_printer: {
+        title: "Цільовий принтер",
+        fail: "Цільовий принтер не вибрано. У режимі проксі потрібен фізичний принтер, якому пересилатимуться завдання.",
+        warn: "Цільовий принтер наразі не в мережі — проксі-сервер відновиться після повторного підключення.",
+      },
+      port_ftps: {
+        title: "Служба вивантаження файлів (порт {{port}})",
+        fail: "На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може вивантажити файли. Зазвичай причиною є конфлікт порту на цьому інтерфейсі.",
+      },
+      port_mqtt: {
+        title: "Служба керування (порт {{port}})",
+        fail: "На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може підключитися або показати стан.",
+      },
+      port_bind: {
+        title: "Служба виявлення (порт {{port}})",
+        fail: "На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може завершити процедуру виявлення.",
+      },
+      certificate: {
+        title: "Сертифікат TLS",
+        pass: "Сертифікат готовий. Переконайтеся, що наведений вище сертифікат центру сертифікації Bambuddy імпортовано до сховища довірених сертифікатів слайсера.",
+        fail: "Сертифікат TLS для цього віртуального принтера відсутній. Перевірте, чи доступний для запису каталог даних Bambuddy.",
+      },
+    },
+  },
+
+  bugReport: {
+    title: "Повідомити про помилку",
+    description: "опис",
+    descriptionPlaceholder: "Що пішло не так? Будь ласка, опишіть проблему...",
+    email: "Електронна адреса (необов’язково)",
+    emailPlaceholder: "your@email.com",
+    emailPrivacy: "Якщо надано, вашу електронну адресу буде включено до згорнутого розділу проблеми GitHub, щоб спеціаліст із супроводу міг відслідковувати.",
+    screenshot: "Скріншот",
+    uploadOrPaste: "Вивантажити, вставити або перетягнути зображення",
+    dataCollectedSummary: "Які дані містяться у звіті?",
+    dataIncluded: "У комплекті:",
+    dataIncludedList: "Версія додатка, OS, архітектура, версія Python, статистика бази даних (лише для підрахунку), моделі принтерів, кількість сопел, версії прошивки, статус підключення, статус інтеграції (Spoolman, MQTT, HA), неконфіденційні налаштування, кількість мережевих інтерфейсів, Docker деталі, версії залежностей.",
+    dataNeverIncluded: "Ніколи не додаються:",
+    dataNeverIncludedList: "Назви принтерів, серійні номери, коди доступу, паролі, IP-адреси, адреси електронної пошти, ключі API, токени, URL вебхуків, імена хостів або користувачів.",
+    submit: "Надіслати",
+    startLogging: "Розпочати журнал налагодження",
+    stepEnableLogging: "Журнал налагодження ввімкнено",
+    stepReproduce: "Відтворіть проблему зараз",
+    stepStopLogging: "Зупинити та подати звіт",
+    stopAndSubmit: "Зупинити та надіслати",
+    maxDuration: "Автоматична зупинка через {{minutes}} хв",
+    stoppingLogs: "Збір журналів і надсилання...",
+    submitting: "Надсилання звіту про помилку...",
+    submittingStepConnection: "Запуск перевірки підключення принтера",
+    submittingStepVirtualPrinters: "Виконання перевірок налаштування віртуального принтера",
+    submittingStepLogScan: "Сканування останніх журналів на наявність відомих проблем",
+    submittingStepSubmit: "Надсилання звіту GitHub",
+    submitSuccess: "Звіт про помилку успішно надіслано!",
+    submitFailed: "Не вдалося надіслати звіт про помилку",
+    diagnosticChecking: "Перевірка підключень принтера...",
+    diagnosticHealthy: "Перевірку підключення пройдено — проблем із принтерами не виявлено.",
+    diagnosticSummary: "Проблеми з підключенням мають {{problems}} із {{total}} принтерів",
+    diagnosticIntro: "Один або кілька принтерів мають проблему з підключенням, яка може спричиняти описану несправність. Розгорніть запис принтера нижче, щоб переглянути спосіб виправлення. Усунення цієї проблеми може допомогти без створення звіту про помилку, але за потреби звіт усе одно можна надіслати нижче.",
+    logHealthSummary: "У ваших журналах знайдено відомі проблеми",
+    logHealthIntro: "Останні журнали відповідають відомим проблемам. Перегляньте наведені нижче виправлення — їх вирішення може вирішити вашу проблему без повідомлення про помилку. Ви все ще можете надіслати звіт нижче.",
+    thankYou: "дякую!",
+    submitted: "Ваш звіт про помилку надіслано.",
+    viewIssue: "Переглянути випуск",
+    unexpectedError: "Сталася неочікувана помилка",
+  },
+  failureDetection: {
+    title: "Виявлення помилок за допомогою ШІ",
+    description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
+    mlUrl: "URL Obico ML API",
+    mlUrlHint: "База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).",
+    test: "Тест",
+    testSuccess: "ML API доступний і працює належним чином.",
+    testFailed: "Не вдалося підключитися до ML API.",
+    sensitivity: "Чутливість",
+    sensitivityLow: "Низький (менше помилкових спрацьовувань)",
+    sensitivityMedium: "Середній (збалансований)",
+    sensitivityHigh: "Високий (виявляти рано, більше помилкових спрацьовувань)",
+    sensitivityHint: "Налаштовує пороги впевненості, за яких формуються попередження та фіксуються помилки.",
+    action: "Дія в разі виявлення помилки",
+    actionNotify: "Лише сповіщати",
+    actionPause: "Призупинити друк",
+    actionPauseOff: "Призупинити друк і вимкнути живлення",
+    pollInterval: "Інтервал опитування (секунди)",
+    pollIntervalHint: "Як часто перевіряти кожен принтер під час друку. Мінімум 5 с, максимум 120 с.",
+    externalUrlMissing: "Зовнішній URL не встановлено.",
+    externalUrlHint: "ML API отримує знімок із камери за URL. Укажіть зовнішній URL у загальних налаштуваннях, щоб контейнер ML API міг підключитися до Bambuddy.",
+    perPrinterTitle: "Принтери під спостереженням",
+    perPrinterHint: "Виберіть, які принтери перевіряє служба виявлення.",
+    monitorAll: "Відстежувати всі підключені принтери",
+    statusTitle: "Статус",
+    serviceRunning: "Служба працює",
+    thresholds: "Нижній / верхній поріг",
+    activePrinters: "Активні завдання друку",
+    noActivePrints: "Зараз немає роздруківок.",
+    historyTitle: "Останні виявлення",
+    noHistory: "Виявлень ще немає.",
+  },
+
+  makerworld: {
+    title: "MakerWorld",
+    description: "Вставте модель MakerWorld URL, щоб імпортувати та роздрукувати її безпосередньо з Bambuddy — не виходячи з програми Bambu Handy.",
+    pasteUrlHeader: "Імпортувати з MakerWorld",
+    pasteUrlPlaceholder: "https://makerworld.com/en/models/… або вставте будь-яке посилання MakerWorld.",
+    resolveButton: "Розв’язати",
+    signInRequiredTitle: "Для завантаження потрібно увійти в Bambu Cloud",
+    signInRequiredBody: "Ви можете переглядати деталі моделі анонімно, але для MakerWorld потрібен обліковий запис Bambu Cloud для завантаження файлів 3MF.",
+    signInExpiredTitle: "Термін входу в Bambu Cloud минув",
+    signInExpiredBody: "Ви все ще ввійшли в Bambuddy, але Bambu Lab більше не приймає збережений токен, тому завантаження не вдасться. Знову ввійдіть у Bambu Cloud.",
+    openCloudSettings: "Відкрити налаштування Cloud",
+    untitledModel: "Модель без назви",
+    byCreator: "від {{name}}",
+    downloadsCount: "{{count}} завантажень",
+    licensePrefix: "Ліцензія",
+    alreadyImported: "Вже в бібліотеці",
+    openOnMakerworld: "Відкрити MakerWorld",
+    alreadyInLibrary: "Ця модель уже є у вашій бібліотеці — знайдіть її в Менеджері файлів → MakerWorld",
+    importSuccess: "Імпортовано {{filename}} — збережено в менеджері файлів → MakerWorld",
+    platesHeader: "Пластини ({{count}})",
+    plateDefaultName: "Пластина {{n}}",
+    materialCount: "Філаментів: {{count}}",
+    amsRequired: "Потрібен AMS",
+    slicedFor: "Нарізано для {{printer}}",
+    alsoCompatible: "Також позначено як сумісний: {{printers}}",
+    importToLibrary: "Зберегти",
+    sliceIn: "Зберегти й нарізати в {{slicer}}",
+    disclaimer: "Інтеграція MakerWorld використовує кінцеві точки API, задокументовані спільнотою. Bambuddy не є філією та не підтримується MakerWorld або Bambu Lab.",
+    lastImportSuccess: "Імпортовано до вашої бібліотеки",
+    lastImportAlreadyInLibrary: "Уже у вашій бібліотеці",
+    viewInLibrary: "Переглянути в менеджері файлів",
+    openInBambuStudio: "Відкрити в Bambu Studio",
+    openInOrcaSlicer: "Відкрити в OrcaSlicer",
+    importTo: "Імпорт до файлового менеджера",
+    recentImportsHeader: "Недавній імпорт",
+    phaseResolving: "Розв’язування",
+    phaseDownloading: "Завантаження",
+    folderAuto: "MakerWorld (за замовчуванням)",
+    importAll: "Імпортувати все",
+    importAllProgress: "Імпорт {{current}}/{{total}}",
+    openGallery: "Відкрити галерею зображень",
+    galleryPrev: "Попереднє зображення",
+    galleryNext: "Наступне зображення",
+    deleteImport: "Видалити з бібліотеки",
+    importDeleting: "Видалення…",
+    importDeleted: "Видалено з бібліотеки",
+    confirmDelete: "Видалити {{filename}} з бібліотеки? Локальний файл буде видалено, але цю друкарську пластину можна буде повторно імпортувати з MakerWorld.",
+    errors: {
+      resolveFailed: "Не вдалося вирішити MakerWorld URL.",
+      downloadFailed: "Помилка завантаження. Спробуйте ще раз.",
+      deleteFailed: "Не вдалося видалити файл із бібліотеки.",
+    },
+  },
+  gcodeViewer: {
+    back: "Назад",
+    backToArchives: "Назад до друку архівів",
+    backToFiles: "Назад до файлового менеджера",
+  },
+  libraryTrash: {
+    title: "Кошик",
+    headerButton: "Кошик",
+    headerTooltip: "Переглянути файли, переміщені в кошик",
+    backToFiles: "Назад до файлового менеджера",
+    subtitleAdmin: "Видалені файли залишаються тут протягом {{days}} днів, а потім автоматично видаляються. У цьому режимі перегляду відображаються файли, видалені в кошик для всіх користувачів.",
+    subtitleUser: "Видалені файли залишаються тут протягом {{days}} днів, а потім автоматично видаляються.",
+    loading: "Завантаження кошика…",
+    loadError: "Не вдалося завантажити кошик.",
+    empty: "Кошик порожній.",
+    summary: "Файлів: {{count}} · {{size}}",
+    emptyTrash: "Очистити кошик",
+    restore: "Відновити",
+    purgeNow: "Видалити зараз",
+    autoPurgeIn: "Автоматичне видалення через {{when}}",
+    days: "днів",
+    retentionLabel: "Автоматичне видалення після",
+    selectAll: "Вибрати все",
+    selectOne: "Вибрати {{filename}}",
+    selectionCount: "Вибрано {{count}}.",
+    bulkRestore: "Відновити вибране",
+    bulkPurge: "Видалити вибране",
+    col: {
+      filename: "Файл",
+      folder: "Папка",
+      size: "Розмір",
+      deleted: "Переміщено в кошик",
+      autoPurge: "Автоматичне видалення",
+      owner: "Власник",
+      actions: "Дії",
+    },
+    confirm: {
+      purgeTitle: "Видалити остаточно?",
+      purgeBody: "{{filename}} буде видалено з диска та не підлягає відновленню.",
+      emptyTitle: "Очистити кошик?",
+      emptyBody: "З диска буде остаточно видалено всі файли ({{count}}). Цю дію неможливо скасувати.",
+      bulkPurgeTitle: "Видалити вибрані файли назавжди?",
+      bulkPurgeBody: "Вибрані файли ({{count}}) буде видалено з диска без можливості відновлення.",
+      cta: "Видалити остаточно",
+    },
+    toast: {
+      restored: "Файл відновлено.",
+      restoreFailed: "Не вдалося відновити файл.",
+      purged: "Файл видалено остаточно.",
+      purgeFailed: "Не вдалося видалити файл.",
+      emptied: "Файлів остаточно видалено з кошика: {{count}}.",
+      emptyFailed: "Не вдалося очистити кошик.",
+      retentionSaved: "Для автоматичного видалення встановлено {{days}} днів.",
+      retentionFailed: "Не вдалося зберегти налаштування збереження.",
+      bulkRestored: "Відновлено файлів: {{count}}",
+      bulkPurged: "Файлів остаточно видалено: {{count}}.",
+    },
+  },
+  libraryPurge: {
+    title: "Очистити старі файли",
+    headerButton: "Очищення старого",
+    headerTooltip: "Масове переміщення старих файлів у кошик",
+    description: "Однією дією перемістіть старі файли з бібліотеки до кошика. Вік файлів з історією друку визначається за датою останнього друку, а файлів, які ще не друкувалися, — за датою вивантаження.",
+    ageLabel: "Перемістити файли, старші за",
+    days: "днів",
+    includeNeverPrinted: "Включати файли, які ніколи не друкувались",
+    effectsTitle: "Що відбудеться після натискання «Очистити»",
+    effect1: "Відповідні файли переміщуються в кошик — вони ще не видалені з диска.",
+    effect2: "Ви можете будь-коли відновити їх із кошика, доки не закінчиться період зберігання.",
+    effect3: "Після завершення строку зберігання служба очищення кошика остаточно видалить файли з диска.",
+    effect4: "Файли у зовнішніх (зв’язаних) папках пропускаються — Bambuddy ніколи не видаляє байти, які йому не належать.",
+    previewLoading: "Перевірка кількості файлів…",
+    previewFailed: "Не вдалося попередньо переглянути очищення.",
+    previewSummary: "Файли {{count}} · {{size}} буде переміщено до кошика",
+    andMore: "…і {{count}} більше",
+    warning: "Файли в кошику й надалі займають місце, доки не завершиться строк зберігання. Щоб звільнити місце на диску негайно, після цього очистьте кошик.",
+    confirmCta: "Перемістити до кошика ({{count}})",
+    purging: "Переміщення в кошик…",
+    toast: {
+      success: "Переміщено до кошика файлів: {{count}}",
+      failed: "Не вдалося очистити файли.",
+    },
+  },
+  libraryAutoPurge: {
+    enableLabel: "Автоматичне очищення старих файлів",
+    enableDescription: "Запускає очищення адміністратора один раз на день. Файли спочатку потрапляють у кошик — вони не видаляються одразу.",
+    ageLabel: "Автоматичне очищення файлів, старших за",
+    ageDescription: "Мінімум 7 днів, максимум 10 років. Використовує те саме вікове правило, що й ручна кнопка очищення.",
+    days: "днів",
+    includeNeverPrinted: "Включати файли, які ніколи не друкувались",
+    saveFailed: "Не вдалося зберегти налаштування автоматичного очищення.",
+  },
+  archivePurge: {
+    headerButton: "Очищення старого",
+    headerTooltip: "Масове видалення старих архівів",
+    title: "Очистити старі архіви",
+    description: "Очистити стару історію друку. Кожен архів старіє відповідно до останнього завершення друку — повторний друк архіву оновлює його вік, тому активна робота ніколи не очищається.",
+    ageLabel: "Видалити архіви, не надруковані в останньому",
+    days: "днів",
+    effectsTitle: "Що відбудеться після натискання «Очистити»",
+    effect1: "Кожен відповідний архів приховано зі списків, а його файли видалено з диска (3MF, ескіз, покадрова зйомка, вихідний 3MF, F3D файл дизайну, фотографії).",
+    effect2: "Запис архіву залишиться в базі даних, тому «Швидка статистика» й надалі враховуватиме філамент, час, вартість та енергію — як і за типового видалення окремого архіву.",
+    effect3: "Позначте нижче «Також видалити зі статистики», щоб вилучити внесок цих архівів зі «Швидкої статистики» — так само, як у відповідній опції видалення окремого архіву. Цю дію неможливо скасувати.",
+    effect4: "Повторний друк архіву оновлює його віковий годинник, тому архіви, якими ви все ще користуєтеся, залишаються в безпеці.",
+    purgeStatsLabel: "Також видалити зі статистики",
+    purgeStatsHint: "Вилучає внесок відповідних архівів зі «Швидкої статистики» — філамент, час, вартість та енергію. Якщо параметр вимкнено, статистичні дані зберігаються, а з диска видаляються лише файли.",
+    previewLoading: "Перевірка кількості архівів, що збігаються…",
+    previewFailed: "Не вдалося попередньо переглянути очищення.",
+    previewSummary: "Архіви {{count}} · {{size}} буде видалено",
+    andMore: "…і {{count}} більше",
+    warning: "Файли видаляються з диска і не можуть бути відновлені. Перш ніж продовжити, завантажте або додайте до вибраного все, що хочете зберегти.",
+    confirmCta: "Видалити архіви ({{count}})",
+    purging: "Видалення…",
+    toast: {
+      success: "Видалено архівів: {{count}}",
+      failed: "Не вдалося очистити архіви.",
+    },
+  },
+  archiveAutoPurge: {
+    enableLabel: "Автоматичне очищення старих архівів",
+    enableDescription: "Один раз на день приховує архіви зі списків і видаляє їх файли з диска, якщо вони не були надруковані в межах порогу. Повторний друк архіву скидає годинник.",
+    ageLabel: "Автоматичне видалення архівів, не надрукованих в останньому",
+    ageDescription: "Мінімум 7 днів, максимум 10 років. На основі останнього завершення друку — повторний друк архіву оновлює його вік. Видаляє 3MF, мініатюру, покадрову зйомку, джерело 3MF, F3D і фотографії.",
+    days: "днів",
+    purgeStatsLabel: "Також видалити зі статистики",
+    purgeStatsDescription: "Якщо параметр увімкнено, щоденне очищення також вилучатиме внесок кожного видаленого архіву зі «Швидкої статистики» — філамент, час, вартість та енергію. Типово параметр вимкнено: статистичні дані зберігаються, а з диска видаляються лише файли.",
+    runNow: "Очистити архіви зараз",
+    saveFailed: "Не вдалося зберегти налаштування автоматичного очищення.",
+  },
+  cameraTokens: {
+    scope: {
+      camera_stream: "Потік камери",
+      camwall: "Панель камер",
+      overlay: "Потокове накладання",
+    },
+    title: "API-токени камери",
+    navTitle: "API-токени камери",
+    description:
+      "Довгострокові токени для вбудовування потоку камери в Home Assistant, Frigate, кіоски або інші засоби, яким потрібен постійний URL. Під час створення виберіть область доступу; токен можна відкликати будь-коли.",
+    loading: "Завантаження…",
+    confirmRevoke: {
+      title: "Відкликати цей токен?",
+      body: "Усі пристрої, що використовують «{{name}}», негайно втратять доступ. Цю дію неможливо скасувати.",
+      cancel: "Скасувати",
+      confirm: "Відкликати",
+    },
+    create: {
+      scopeLabel: "Область застосування",
+      hintCameraStream:
+        "Токен потоку камери дає змогу отримувати лише потоки з камер і знімки. Використовуйте його для Home Assistant, Frigate або іншого засобу, що вбудовує одну камеру.",
+      hintCamWall:
+        "Токен Cam Wall відкриває /camwall на екрані без входу. Він може бачити назву та стан кожного принтера, а також потоки з камери. Він не може бачити імена файлів, адреси чи коди доступу.",
+      hintOverlay:
+        "Токен потокового накладання відкриває /overlay/{printerId} на екрані без входу — для OBS або будь-якої прямої трансляції. Він дає змогу переглядати потік із камери одного принтера та стан друку в реальному часі, зокрема назву файлу, показану на екрані. Він не надає доступу до адрес чи кодів доступу.",
+      title: "Створити новий токен",
+      nameLabel: "Назва токена",
+      namePlaceholder: "напр. Home Assistant",
+      daysLabel: "Днів до закінчення терміну дії",
+      submit: "Створити",
+      hint:
+        "Максимальний строк дії — 365 днів. Значення токена буде показано лише один раз під час створення — скопіюйте його зараз.",
+    },
+    created: {
+      camWallUrlTitle: "Cam Wall URL для цього дисплея",
+      camWallUrlHint:
+        "Відкрийте цей URL на потрібному екрані. Будь-хто, хто отримає URL, зможе переглядати панель камер, тому захищайте його як ключ доступу. Щоб негайно припинити доступ із цього екрана, відкличте токен.",
+      overlayUrlTitle: "Накладання URL для OBS",
+      overlayUrlHint:
+        "Додайте цей URL в OBS як джерело браузера. Замініть число в /overlay/1 номером принтера з його URL на сторінці «Принтери». Будь-хто, хто отримає URL, зможе переглядати трансляцію, тому захищайте його як ключ доступу. Щоб негайно припинити доступ, відкличте токен.",
+      title: "Токен створено — скопіюйте його зараз",
+      warning:
+        "Це єдиний раз, коли токен буде показано. Після закриття цього діалогового вікна переглянути його знову буде неможливо.",
+      copy: "Копіювати",
+      dismiss: "Я зберіг токен",
+    },
+    list: {
+      scope: "Область застосування",
+      myTitle: "Мої токени",
+      allTitle: "Усі користувачі (перегляд адміністратора)",
+      empty: "Токенів ще немає.",
+      name: "Назва",
+      owner: "Власник",
+      prefix: "Префікс",
+      created: "Створено",
+      expires: "Термін дії закінчується",
+      lastUsed: "Останнє використання",
+      revoke: "Відкликати",
+      expired: "Термін дії минув",
+    },
+    toast: {
+      created: "Токен створено",
+      createFailed: "Не вдалося створити токен",
+      revoked: "Токен відкликано",
+      revokeFailed: "Не вдалося відкликати токен",
+      loadFailed: "Не вдалося завантажити токени",
+      copied: "Скопійовано в буфер обміну",
+      copyFailed: "Не вдалося копіювати — виберіть і скопіюйте вручну",
+    },
+  },
+  // Forecast & Inventory Intelligence
+  forecast: {
+    title: "Прогноз",
+    noSpools: "Не знайдено активних котушок. Додайте котушки до свого інвентарю, щоб переглянути прогнозні дані.",
+    noUsageData: "Немає даних про використання — неможливо спроєктувати хронологію запасів.",
+    // Table headers
+    sku: "SKU",
+    material: "матеріал",
+    stock: "Запас",
+    dailyRate: "Добове споживання",
+    daysLeft: "Залишилося днів",
+    emptyBy: "Вичерпається до",
+    reorderBy: "Замовити до",
+    actions: "Дії",
+    // Rate tier badges
+    trend: "Тренд",
+    estimated: "Приблизно",
+    noData: "Немає даних",
+    // Timeframe
+    timeframe: "Період",
+    // Chart
+    chartTitle: "Прогнозований запас — топ-5 матеріалів",
+    dashedLinesROP: "Пунктирні лінії — точки повторного замовлення",
+    stockLevel: "Рівень запасів",
+    reorderPoint: "Точка повторного замовлення",
+    safetyMargin: "Запас безпеки",
+    // Legend labels
+    trendLegend: "Тенденція (на основі історії, рівень обслуговування 95%)",
+    estimatedLegend: "Розрахункова (дельта ваги)",
+    noDataLegend: "Немає даних",
+    ropLabel: "ROP",
+    ssLabel: "SS",
+    safetyStockLegend: "Страховий запас",
+    stockArrivalLegend: "Надходження запасів",
+    stockoutLegend: "Дефіцит",
+    // Alerts toolbar
+    alertCount_one: "Сповіщень: {{count}}",
+    alertCount_other: "Сповіщень: {{count}}",
+    order: "Замовлення",
+    // Settings
+    save: "Зберегти",
+    cancel: "Скасувати",
+    settingsSaved: "Налаштування збережено",
+    failedSaveSettings: "Не вдалося зберегти налаштування",
+    globalLeadTimeSaved: "Загальний строк постачання збережено",
+    globalLeadTime: "Загальний строк постачання",
+    globalLeadTimeHint: "Мінімальний строк постачання для розрахунку точки повторного замовлення всіх SKU",
+    skuLeadTimeOverride: "Інший строк постачання",
+    skuLeadTimeHint: "0 — використовувати загальний строк. Установіть значення понад 0, щоб перевизначити його для цього SKU.",
+    safetyMarginLabel: "Запас безпеки",
+    effectiveLeadTime: "Розрахунковий строк постачання",
+    effectiveLeadTimeHint: "max(глобальний {{global}}d, SKU {{sku}}d)",
+    reorderPointHint: "d̄ × LT + страховий запас — замовляйте, коли запас досягне цього рівня",
+    safetyMarginHint: "Статистичний страховий запас (z=1,65 × σ × √LT) + визначений користувачем буфер",
+    safetyMarginHintDays: "Буфер додано поверх статистичного страхового запасу.{{approx}}",
+    safetyMarginHintDaysApprox: " ≈ {{g}} г за поточного рівня споживання.",
+    safetyMarginHintG: "Буфер фіксованої ваги, доданий поверх статистичного страхового запасу.{{approx}}",
+    safetyMarginHintGApprox: " ≈ {{days}} дн. за поточного рівня споживання.",
+    individualSpools: "Окремі котушки",
+    labelWeight: "Вага за етикеткою",
+    spoolCount_one: "Котушок: {{count}}",
+    spoolCount_other: "Котушок: {{count}}",
+    // Alerts
+    stockBreakRisk: "Ризик вичерпання запасу",
+    stockBreakDetail: "Залишилося {{days}} дн.; строк постачання — {{lt}} дн.",
+    stockBreakBefore: "Запас вичерпається до поповнення",
+    reorderNow: "Замовити зараз",
+    reorderTriggerPassed: "Дата замовлення {{date}} минула.",
+    // Shopping list
+    shoppingList: "Список покупок",
+    shoppingListItems_one: "(позицій: {{count}})",
+    shoppingListItems_other: "(позицій: {{count}})",
+    shoppingListEmpty: "Список покупок порожній. Натисніть значок кошика в будь-якому рядку, щоб додати позиції.",
+    addToCart: "Додати до списку покупок",
+    alertsSnoozed: "Вимкнути сповіщення для цього SKU",
+    alertsEnabled: "Увімкнути сповіщення для цього SKU",
+    addedToCart: "Додано до списку покупок",
+    failedAddItem: "Не вдалося додати елемент",
+    listView: "Список",
+    logisticsView: "Логістика",
+    qty: "Кількість",
+    weight: "Вага",
+    leadTime: "Строк постачання",
+    expectedRestock: "Очікуване поповнення запасів",
+    status: "Статус",
+    note: "Примітка",
+    pending: "В очікуванні",
+    purchased: "Придбано",
+    received: "Отримано",
+    markPurchased: "Позначити як придбане",
+    markReceived: "Позначити як отримане — додає котушки до запасу",
+    resetToPending: "Скинути до очікування",
+    remove: "Видалити",
+    clearAll: "Очистити все",
+    downloadCsv: "CSV",
+    // Add to cart modal
+    addToCartTitle: "Додати до списку покупок",
+    byQuantity: "За кількістю",
+    byDuration: "За тривалістю",
+    numberOfSpools: "Кількість котушок",
+    lastHowManyDays: "Скільки днів має тривати?",
+    noUsageQty: "Немає даних про використання — кількість встановлено на 1.",
+    noteOptional: "Примітка (необов’язково)",
+    notePlaceholder: "напр. для проєкту X, терміново…",
+    addNSpools_one: "Додати котушки ({{count}})",
+    addNSpools_other: "Додати котушки ({{count}})",
+    // Cart logistics
+    onArrival: "Після прибуття",
+    stockBreakIn: "Запас вичерпається через {{days}} дн.",
+    stockRunsOutBefore: "Запас закінчиться раніше, ніж мине строк постачання {{lt}} дн.",
+    atRate: "За споживання {{rate}} г/день потрібно",
+    moreSpools_one: "Додаткових котушок: {{count}}",
+    moreSpools_other: "Додаткових котушок: {{count}}",
+    bridgeGap: "щоб покрити нестачу.",
+    // Permissions
+    noReadAccess: "Ви не маєте дозволу переглядати прогнози запасів.",
+    noWriteAccess: "Ви не маєте дозволу змінювати налаштування прогнозу.",
+  },
+};

+ 40 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: '点击查看 HMS 错误',
+    aiDetection: {
+      safe: '安全',
+      warning: '警告',
+      failure: '失败',
+      idle: '空闲',
+      tooltip: 'AI 故障检测:{{status}}(评分 {{score}})- 点击查看详情',
+      tooltipIdle: 'AI 故障检测已启用 - 下次打印时开始监控 - 点击查看详情',
+      modalTitle: 'AI 故障检测 - {{name}}',
+      currentStatus: '状态',
+      score: '评分',
+      framesAnalyzed: '已分析帧数',
+      idleHint: '当前没有正在监控的打印。下次打印时将自动开始监控。',
+      lastError: '最近错误',
+      openSettings: '打开设置',
+    },
     estimatedCompletion: '预计完成时间',
     plateNumber: '板 {{number}}',
     slotOptions: '槽位选项',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: '期间成本',
     avgPerPrint: '每次打印平均',
     usageOverTime: '随时间的使用量',
+    energyOverTime: '随时间的能耗',
     filamentByWeight: '重量',
     printDuration: '打印时长',
     printerUtilization: '打印机利用率',
@@ -3665,6 +3681,7 @@ export default {
     noPermissionRenameFolder: '您没有重命名文件夹的权限',
     noPermissionLinkFolder: '您没有链接文件夹的权限',
     noPermissionDeleteFolder: '您没有删除文件夹的权限',
+    onlyEmptyFoldersDeletable: '您只能删除空文件夹',
     noPermissionPrint: '您没有打印的权限',
     noPermissionSlice: '您没有切片文件的权限',
     noPermissionAddToQueue: '您没有添加到队列的权限',
@@ -3755,6 +3772,9 @@ export default {
     targetParts: '目标零件数',
     targetPartsPlaceholder: '例如:150',
     targetPartsHelp: '所需零件总数',
+    targetSets: '每个文件的份数',
+    targetSetsPlaceholder: '例如 10',
+    targetSetsHelp: '每个可打印文件需要打印的次数',
     tagsLabel: '标签(逗号分隔)',
     tagsPlaceholder: '例如:voron、功能件、礼物',
     dueDate: '截止日期',
@@ -3830,6 +3850,9 @@ export default {
     progress: {
       platesProgress: '板进度',
       partsProgress: '零件进度',
+      setsProgress: '完整套数',
+      sets: '套',
+      setsHint: '当每个可打印文件都达到此次数时,即完成一套',
       printJobs: '打印任务',
       parts: '零件',
       percentComplete: '{{percent}}% 完成',
@@ -3868,6 +3891,8 @@ export default {
       forQuickAccess: '到此项目以便快速访问。',
       fileCount: '{{count}} 个文件',
       empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
+      printedCount: '已打印 {{count}} 次',
+      progressTooltip: '已完成 {{done}}/{{target}} 次打印',
       noFiles: '此文件夹中没有文件。',
     },
     bom: {
@@ -4035,6 +4060,11 @@ export default {
     allPresetsRequired: '必须选择所有预设',
     useEmbedded: '使用文件的内置设置',
     useEmbeddedHint: '按设计者的设置(壁、填充、耗材)切片,而非上方的配置文件。因您的打印机与文件匹配而可用。',
+    designSettings: '保留设计者的设置',
+    designSettingsHint: '此文件相对标准配置修改了 {{count}} 项打印设置。',
+    designSettingsSelected: '已选择 {{selected}} / {{total}}',
+    designSettingsPrinterCoupled: '与打印机相关',
+    designSettingsPrinterCoupledHint: '该值是为此文件面向的打印机调校的,在你的打印机上可能不正确或超出范围。',
     enqueuing: '提交切片任务中…',
     queued: '已排队…',
     failed: '切片失败。请检查切片器 sidecar 日志。',
@@ -4293,6 +4323,8 @@ export default {
     searchBrand: '搜索品牌...',
     useCustomBrand: '使用"{{brand}}"',
     useCustomMaterial: '使用自定义材料:{{material}}',
+    suggestedOptions: '推荐',
+    allOptions: '全部',
     colorName: '颜色名称',
     colorNamePlaceholder: '翡翠白、烈焰红...',
     color: '颜色',
@@ -5409,6 +5441,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5420,6 +5453,7 @@ export default {
       callmebot: '通过 CallMeBot 免费发送 WhatsApp 通知',
       webhook: '通用 HTTP POST 到任意 URL',
       homeassistant: 'Home Assistant 仪表板中的持久通知',
+      bark: '通过 Bark 推送 iOS 通知(可自建服务器)',
     },
     // NotificationProviderCard
     lastSuccess: '上次:{{date}}',
@@ -5458,6 +5492,9 @@ export default {
     printStarted: '打印已开始',
     plateNotEmpty: '热床非空',
     plateNotEmptyDescription: '打印前检测到物体',
+    plateClearRequired: '需要确认热床已清空',
+    plateClearRequiredDescription: '打印完成,队列等待热床确认',
+    plateClear: '热床确认',
     printCompleted: '打印已完成',
     bedCooledLabel: '热床已冷却',
     bedCooledDescription: '打印后热床温度降至阈值以下',
@@ -5553,6 +5590,7 @@ export default {
     add: '添加',
     nameRequired: '名称为必填项',
     fieldRequired: '{{field}}为必填项',
+    haDataInvalid: 'Data 字段必须是有效的 JSON 对象,例如 {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: '电话号码',
     apiKey: 'API 密钥',
@@ -6303,7 +6341,8 @@ export default {
       mqtt_auth: {
         title: '打印机凭据',
         pass: '打印机已接受连接。',
-        fail: '打印机可达,但拒绝了连接。访问代码或序列号很可能有误。每次切换开发者模式时访问代码都会更改 — 请从打印机屏幕重新复制。',
+        fail: '打印机可达,但 Bambuddy 未与其建立连接。访问代码或序列号很可能有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改,请从打印机屏幕重新复制。正在重启或已达到同时连接数上限的打印机看起来也是这样。',
+        fail_auth_rejected: '打印机拒绝了 Bambuddy 的凭据。访问代码或序列号有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改。请从打印机屏幕重新复制,并保存到打印机设置中。',
         skip: '未检查 — 无法连接到打印机。',
       },
       developer_mode: {

+ 40 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -672,6 +672,21 @@ export default {
     },
     // HMS errors
     clickToViewHmsErrors: '點選檢視 HMS 錯誤',
+    aiDetection: {
+      safe: '安全',
+      warning: '警告',
+      failure: '失敗',
+      idle: '空閒',
+      tooltip: 'AI 故障檢測:{{status}}(評分 {{score}})- 點擊查看詳情',
+      tooltipIdle: 'AI 故障檢測已啟用 - 下次列印時開始監控 - 點擊查看詳情',
+      modalTitle: 'AI 故障檢測 - {{name}}',
+      currentStatus: '狀態',
+      score: '評分',
+      framesAnalyzed: '已分析幀數',
+      idleHint: '目前沒有正在監控的列印。下次列印時將自動開始監控。',
+      lastError: '最近錯誤',
+      openSettings: '開啟設定',
+    },
     estimatedCompletion: '預計完成時間',
     plateNumber: '板 {{number}}',
     slotOptions: '槽位選項',
@@ -1468,6 +1483,7 @@ export default {
     periodCost: '期間成本',
     avgPerPrint: '每次列印平均',
     usageOverTime: '隨時間的使用量',
+    energyOverTime: '隨時間的能耗',
     filamentByWeight: '重量',
     printDuration: '列印時長',
     printerUtilization: '印表機利用率',
@@ -3665,6 +3681,7 @@ export default {
     noPermissionRenameFolder: '您沒有重新命名資料夾的權限',
     noPermissionLinkFolder: '您沒有連結資料夾的權限',
     noPermissionDeleteFolder: '您沒有刪除資料夾的權限',
+    onlyEmptyFoldersDeletable: '您只能刪除空資料夾',
     noPermissionPrint: '您沒有列印的權限',
     noPermissionSlice: '您沒有切片檔案的權限',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',
@@ -3755,6 +3772,9 @@ export default {
     targetParts: '目標零件數',
     targetPartsPlaceholder: '例如:150',
     targetPartsHelp: '所需零件總數',
+    targetSets: '每個檔案的份數',
+    targetSetsPlaceholder: '例如 10',
+    targetSetsHelp: '每個可列印檔案需要列印的次數',
     tagsLabel: '標籤(逗號分隔)',
     tagsPlaceholder: '例如:voron、功能件、禮物',
     dueDate: '截止日期',
@@ -3830,6 +3850,9 @@ export default {
     progress: {
       platesProgress: '板進度',
       partsProgress: '零件進度',
+      setsProgress: '完整套數',
+      sets: '套',
+      setsHint: '當每個可列印檔案都達到此次數時,即完成一套',
       printJobs: '列印任務',
       parts: '零件',
       percentComplete: '{{percent}}% 完成',
@@ -3868,6 +3891,8 @@ export default {
       forQuickAccess: '到此項目以便快速存取。',
       fileCount: '{{count}} 個檔案',
       empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。',
+      printedCount: '已列印 {{count}} 次',
+      progressTooltip: '已完成 {{done}}/{{target}} 次列印',
       noFiles: '此資料夾中沒有檔案。',
     },
     bom: {
@@ -4035,6 +4060,11 @@ export default {
     allPresetsRequired: '必須選擇所有預設',
     useEmbedded: '使用檔案的內建設定',
     useEmbeddedHint: '依設計者的設定(外牆、填充、耗材)切片,而非上方的設定檔。因您的印表機與檔案相符而可用。',
+    designSettings: '保留設計者的設定',
+    designSettingsHint: '此檔案相對標準設定檔修改了 {{count}} 項列印設定。',
+    designSettingsSelected: '已選擇 {{selected}} / {{total}}',
+    designSettingsPrinterCoupled: '與印表機相關',
+    designSettingsPrinterCoupledHint: '此值是為該檔案面向的印表機調校的,在你的印表機上可能不正確或超出範圍。',
     enqueuing: '提交切片任務中…',
     queued: '已排隊…',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',
@@ -4293,6 +4323,8 @@ export default {
     searchBrand: '搜尋品牌...',
     useCustomBrand: '使用"{{brand}}"',
     useCustomMaterial: '使用自訂材料:{{material}}',
+    suggestedOptions: '推薦',
+    allOptions: '全部',
     colorName: '顏色名稱',
     colorNamePlaceholder: '翡翠白、烈焰紅...',
     color: '顏色',
@@ -5409,6 +5441,7 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
+      bark: 'Bark',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -5420,6 +5453,7 @@ export default {
       callmebot: '透過 CallMeBot 免費傳送 WhatsApp 通知',
       webhook: '通用 HTTP POST 到任意 URL',
       homeassistant: 'Home Assistant 儀表板中的持久通知',
+      bark: '透過 Bark 推送 iOS 通知(可自架伺服器)',
     },
     // NotificationProviderCard
     lastSuccess: '上次:{{date}}',
@@ -5458,6 +5492,9 @@ export default {
     printStarted: '列印已開始',
     plateNotEmpty: '熱床非空',
     plateNotEmptyDescription: '列印前偵測到物體',
+    plateClearRequired: '需要確認熱床已清空',
+    plateClearRequiredDescription: '列印完成,佇列等待熱床確認',
+    plateClear: '熱床確認',
     printCompleted: '列印已完成',
     bedCooledLabel: '熱床已冷卻',
     bedCooledDescription: '列印後熱床溫度降至閾值以下',
@@ -5553,6 +5590,7 @@ export default {
     add: '新增',
     nameRequired: '名稱為必填項',
     fieldRequired: '{{field}}為必填項',
+    haDataInvalid: 'Data 欄位必須是有效的 JSON 物件,例如 {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: '電話號碼',
     apiKey: 'API 金鑰',
@@ -6303,7 +6341,8 @@ export default {
       mqtt_auth: {
         title: '印表機認證資訊',
         pass: '印表機已接受連線。',
-        fail: '印表機可達,但拒絕了連線。存取碼或序號很可能有誤。每次切換開發者模式時存取碼都會變更 — 請從印表機螢幕重新複製。',
+        fail: '印表機可達,但 Bambuddy 未與其建立連線。存取碼或序號很可能有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更,請從印表機螢幕重新複製。正在重新啟動或已達到同時連線數上限的印表機看起來也是這樣。',
+        fail_auth_rejected: '印表機拒絕了 Bambuddy 的認證資訊。存取碼或序號有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更。請從印表機螢幕重新複製,並儲存到印表機設定中。',
         skip: '未檢查 — 無法連線到印表機。',
       },
       developer_mode: {

+ 15 - 4
frontend/src/pages/FileManagerPage.tsx

@@ -568,6 +568,17 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
   const isExternal = folder.is_external;
+  // #1781: users with only library:delete_own may delete empty, unlinked,
+  // non-external folders. The backend enforces the same rule and additionally
+  // counts trashed files (invisible here), so a 403 can still come back.
+  const canDeleteFolder =
+    hasPermission('library:delete_all') ||
+    (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked);
+  const deleteDisabledTooltip = canDeleteFolder
+    ? undefined
+    : hasPermission('library:delete_own') && !isExternal && !isLinked
+      ? t('fileManager.onlyEmptyFoldersDeletable')
+      : t('fileManager.noPermissionDeleteFolder');
 
   return (
     <div>
@@ -669,11 +680,11 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
                 </button>
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:delete_all') ? 'text-red-700 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canDeleteFolder ? 'text-red-700 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
-                  onClick={() => { if (hasPermission('library:delete_all')) { onDelete(folder.id); setShowActions(false); } }}
-                  disabled={!hasPermission('library:delete_all')}
-                  title={!hasPermission('library:delete_all') ? t('fileManager.noPermissionDeleteFolder') : undefined}
+                  onClick={() => { if (canDeleteFolder) { onDelete(folder.id); setShowActions(false); } }}
+                  disabled={!canDeleteFolder}
+                  title={deleteDisabledTooltip}
                 >
                   <Trash2 className="w-3.5 h-3.5" />
                   {t('common.delete')}

+ 122 - 10
frontend/src/pages/PrintersPage.tsx

@@ -81,6 +81,7 @@ import {
   MoreHorizontal,
   SlidersHorizontal,
   Stethoscope,
+  ScanEye,
   LineChart as LineChartIcon,
   LayoutGrid,
   MonitorPlay,
@@ -101,6 +102,7 @@ import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { CameraWall } from '../components/CameraWall';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
+import { AiDetectionModal } from '../components/AiDetectionModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { AmsBackupModal } from '../components/AmsBackupModal';
@@ -1772,6 +1774,9 @@ function PrinterCard({
   bedTempPresets = BED_TEMP_DEFAULTS,
   chamberTempPresets = CHAMBER_TEMP_DEFAULTS,
   fanSpeedPresets = FAN_SPEED_DEFAULTS,
+  aiDetectionEnabled = false,
+  aiDetection,
+  aiLastError = null,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -1810,6 +1815,9 @@ function PrinterCard({
   bedTempPresets?: readonly [number, number, number];
   chamberTempPresets?: readonly [number, number, number];
   fanSpeedPresets?: readonly [number, number, number];
+  aiDetectionEnabled?: boolean;
+  aiDetection?: { class: string; frame_count: number; score: number };
+  aiLastError?: string | null;
 }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -1826,6 +1834,8 @@ function PrinterCard({
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [haToggleConfirm, setHaToggleConfirm] = useState<SmartPlug | null>(null);
   const [showHMSModal, setShowHMSModal] = useState(false);
+  // #1546: AI failure detection modal — opens from the AI badge.
+  const [showAiModal, setShowAiModal] = useState(false);
   // #1762: AMS Filament Backup status / control modal — opens from the badge.
   const [amsBackupModalOpen, setAmsBackupModalOpen] = useState(false);
   const [showStopConfirm, setShowStopConfirm] = useState(false);
@@ -3429,6 +3439,39 @@ function PrinterCard({
                   </button>
                 );
               })()}
+              {/* AI failure detection badge (#1546) — always shown while detection
+                  is enabled for this printer, like the other health badges. Gray
+                  "Idle" outside a monitored print, class-colored during one. */}
+              {aiDetectionEnabled && (() => {
+                const cls = aiDetection
+                  ? (aiDetection.class === 'failure' || aiDetection.class === 'warning' ? aiDetection.class : 'safe')
+                  : 'idle';
+                const colorClass =
+                  cls === 'failure'
+                    ? 'bg-status-error/20 text-status-error'
+                    : cls === 'warning'
+                      ? 'bg-status-warning/20 text-status-warning'
+                      : cls === 'safe'
+                        ? 'bg-status-ok/20 text-status-ok'
+                        : 'bg-bambu-dark-tertiary text-bambu-gray';
+                return (
+                  <button
+                    onClick={() => setShowAiModal(true)}
+                    className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${colorClass}`}
+                    title={
+                      aiDetection
+                        ? t('printers.aiDetection.tooltip', {
+                            status: t(`printers.aiDetection.${cls}`),
+                            score: aiDetection.score.toFixed(3),
+                          })
+                        : t('printers.aiDetection.tooltipIdle')
+                    }
+                  >
+                    <ScanEye className="w-3 h-3" />
+                    {t(`printers.aiDetection.${cls}`)}
+                  </button>
+                );
+              })()}
               {/* Maintenance Status Indicator */}
               {maintenanceInfo && (
                 <button
@@ -3635,18 +3678,55 @@ function PrinterCard({
                     ? 'bg-status-warning'
                     : 'bg-bambu-green';
 
+                const hasCompactEta = isActiveCompactPrint && status.remaining_time != null && status.remaining_time > 0;
+                const hasCompactLayers =
+                  isActiveCompactPrint &&
+                  status.layer_num != null &&
+                  status.total_layers != null &&
+                  status.total_layers > 0;
+
                 return (
-                  <div className="relative mt-2 flex items-center gap-2">
-                    <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-bambu-dark-tertiary">
-                      <div
-                        className={`${compactProgressClass} h-1.5 rounded-full transition-all`}
-                        style={{ width: `${compactProgress}%` }}
-                      />
+                  <>
+                    <div className="relative mt-2 flex items-center gap-2">
+                      <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-bambu-dark-tertiary">
+                        <div
+                          className={`${compactProgressClass} h-1.5 rounded-full transition-all`}
+                          style={{ width: `${compactProgress}%` }}
+                        />
+                      </div>
+                      <span className={`w-9 shrink-0 text-right text-[11px] leading-none ${isActiveCompactPrint ? 'text-white' : 'text-bambu-gray'}`}>
+                        {isActiveCompactPrint ? `${Math.round(compactProgress)}%` : '---%'}
+                      </span>
                     </div>
-                    <span className={`w-9 shrink-0 text-right text-[11px] leading-none ${isActiveCompactPrint ? 'text-white' : 'text-bambu-gray'}`}>
-                      {isActiveCompactPrint ? `${Math.round(compactProgress)}%` : '---%'}
-                    </span>
-                  </div>
+                    {/* #2674: size S showed only a name, a pip and a progress bar — not
+                        enough to answer "which printer finishes first", which is what a
+                        wall-mounted fleet view is for. One line of the metrics the
+                        expanded card already renders, using the same formatters and the
+                        same ETA styling so S and M read alike. The row keeps its height
+                        when idle so cards don't shift as prints start and stop. */}
+                    <div className="mt-1 flex min-h-[14px] items-center gap-2 overflow-hidden text-[11px] leading-none text-bambu-gray">
+                      {hasCompactEta && (
+                        <>
+                          <span className="flex shrink-0 items-center gap-1">
+                            <Clock className="w-3 h-3" />
+                            {formatDuration(status.remaining_time! * 60)}
+                          </span>
+                          <span
+                            className="shrink-0 font-medium text-bambu-green"
+                            title={t('printers.estimatedCompletion')}
+                          >
+                            ETA {formatETA(status.remaining_time!, timeFormat, t)}
+                          </span>
+                        </>
+                      )}
+                      {hasCompactLayers && (
+                        <span className="flex min-w-0 items-center gap-1 truncate">
+                          <Layers className="w-3 h-3 shrink-0" />
+                          {status.layer_num}/{status.total_layers}
+                        </span>
+                      )}
+                    </div>
+                  </>
                 );
               })()
             ) : (
@@ -6286,6 +6366,16 @@ function PrinterCard({
         />
       )}
 
+      {/* AI failure detection modal (#1546) */}
+      {showAiModal && (
+        <AiDetectionModal
+          printerName={printer.name}
+          detection={aiDetection}
+          lastError={aiLastError}
+          onClose={() => setShowAiModal(false)}
+        />
+      )}
+
       {/* AMS Filament Backup status / control modal (#1762) */}
       {amsBackupModalOpen && status && (
         <AmsBackupModal
@@ -7882,6 +7972,22 @@ export function PrintersPage() {
     staleTime: 60 * 1000, // 1 minute
   });
 
+  // Live AI failure detection state for the card badges (#1546). Matches the
+  // 10s refetch of the Failure Detection settings panel.
+  const { data: obicoPrinterStatus } = useQuery({
+    queryKey: ['obico-printer-status'],
+    queryFn: api.getObicoPrinterStatus,
+    refetchInterval: 10000,
+  });
+  // Badge visibility: detection enabled AND this printer in the monitored set
+  // (monitored_printers null = all). Live per-print state is passed separately.
+  const isAiMonitored = useCallback(
+    (printerId: number) =>
+      obicoPrinterStatus?.enabled === true &&
+      (obicoPrinterStatus.monitored_printers === null || obicoPrinterStatus.monitored_printers.includes(printerId)),
+    [obicoPrinterStatus],
+  );
+
   // Fetch Spoolman status to enable link spool feature
   const { data: spoolmanStatus } = useQuery({
     queryKey: ['spoolman-status'],
@@ -8854,6 +8960,9 @@ export function PrintersPage() {
                       isSelected={selectedPrinterIds.has(printer.id)}
                       onToggleSelect={toggleSelect}
                       onOpenCompactCard={openCompactCard}
+                      aiDetectionEnabled={isAiMonitored(printer.id)}
+                      aiDetection={obicoPrinterStatus?.enabled ? obicoPrinterStatus.per_printer[String(printer.id)] : undefined}
+                      aiLastError={obicoPrinterStatus?.last_error ?? null}
                     />
                   ))}
                 </div>
@@ -8903,6 +9012,9 @@ export function PrintersPage() {
               isSelected={selectedPrinterIds.has(printer.id)}
               onToggleSelect={toggleSelect}
               onOpenCompactCard={openCompactCard}
+              aiDetectionEnabled={isAiMonitored(printer.id)}
+              aiDetection={obicoPrinterStatus?.enabled ? obicoPrinterStatus.per_printer[String(printer.id)] : undefined}
+              aiLastError={obicoPrinterStatus?.last_error ?? null}
             />
           ))}
         </div>

+ 80 - 1
frontend/src/pages/ProjectDetailPage.tsx

@@ -268,6 +268,29 @@ export function ProjectDetailPage() {
     return map;
   }, [allProjectFiles]);
 
+  // Per-file completed-run counts (#1897); a file absent from the response has 0
+  const { data: fileProgress } = useQuery({
+    queryKey: ['project-file-progress', projectId],
+    queryFn: () => api.getProjectFileProgress(projectId),
+    enabled: projectId > 0,
+  });
+
+  const progressByFileId = useMemo(() => {
+    const map = new Map<number, number>();
+    for (const row of fileProgress ?? []) map.set(row.file_id, row.completed_count);
+    return map;
+  }, [fileProgress]);
+
+  // Complete sets (#1897): the number of finished assemblies — the minimum
+  // completed count across the project's printable files, capped at the target.
+  const completeSets = useMemo(() => {
+    const target = project?.target_sets;
+    if (!target || !allProjectFiles) return null;
+    const printable = allProjectFiles.filter((f) => isSlicedFilename(f.filename));
+    if (printable.length === 0) return null;
+    return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target)));
+  }, [project?.target_sets, allProjectFiles, progressByFileId]);
+
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
@@ -536,7 +559,7 @@ export function ProjectDetailPage() {
       </div>
 
       {/* Progress bars (if targets set) */}
-      {(project.target_count || project.target_parts_count) && (
+      {(project.target_count || project.target_parts_count || project.target_sets) && (
         <Card>
           <CardContent className="p-4 space-y-4">
             {/* Plates progress */}
@@ -599,6 +622,27 @@ export function ProjectDetailPage() {
                 </div>
               </div>
             )}
+            {/* Complete sets progress (#1897): min per-file completed count */}
+            {project.target_sets ? (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.progress.setsProgress')}</span>
+                  <span className="text-sm font-medium text-white">
+                    {completeSets ?? 0} / {project.target_sets} {t('projectDetail.progress.sets')}
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(((completeSets ?? 0) / project.target_sets) * 100, 100)}%`,
+                      backgroundColor: (completeSets ?? 0) >= project.target_sets ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <p className="text-xs text-bambu-gray/70 mt-1">{t('projectDetail.progress.setsHint')}</p>
+              </div>
+            ) : null}
           </CardContent>
         </Card>
       )}
@@ -953,6 +997,41 @@ export function ProjectDetailPage() {
                                 </span>
                               </div>
 
+                              {/* Per-file print progress (#1897) */}
+                              {printable && (() => {
+                                const done = progressByFileId.get(file.id) ?? 0;
+                                const target = project.target_sets;
+                                if (!target) {
+                                  // No copies-per-file target — show a plain printed-count badge
+                                  return done > 0 ? (
+                                    <span
+                                      className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-bambu-dark text-bambu-gray"
+                                      title={t('projectDetail.files.printedCount', { count: done })}
+                                    >
+                                      {done}×
+                                    </span>
+                                  ) : null;
+                                }
+                                const pct = Math.min((done / target) * 100, 100);
+                                const textColor =
+                                  done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray';
+                                const barColor =
+                                  done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray';
+                                return (
+                                  <div
+                                    className="shrink-0 w-20"
+                                    title={t('projectDetail.files.progressTooltip', { done, target })}
+                                  >
+                                    <p className={`text-xs font-medium text-right ${textColor}`}>
+                                      {done} / {target}
+                                    </p>
+                                    <div className="h-1 bg-bambu-dark rounded-full overflow-hidden mt-1">
+                                      <div className={`h-full ${barColor}`} style={{ width: `${pct}%` }} />
+                                    </div>
+                                  </div>
+                                );
+                              })()}
+
                               {/* Print actions for sliced files */}
                               {printable && (
                                 <div className="flex items-center gap-1 shrink-0">

+ 19 - 0
frontend/src/pages/ProjectsPage.tsx

@@ -61,6 +61,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
+  const [targetSets, setTargetSets] = useState(project?.target_sets?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
   const [tags, setTags] = useState(project?.tags || '');
   const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || '');
@@ -120,6 +121,8 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
       target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
+      // Null clears the copies-per-file target on edit (#1897); undefined omits on create.
+      target_sets: project ? (targetSets ? parseInt(targetSets, 10) : null) : (targetSets ? parseInt(targetSets, 10) : undefined),
       // Null clears the stored value on edit; undefined omits the key on create.
       // Sending undefined on edit would make an emptied field un-clearable.
       tags: project ? (tags.trim() || null) : (tags.trim() || undefined),
@@ -299,6 +302,22 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
             </div>
           </div>
 
+          {/* Copies-per-file target (#1897) */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('projects.targetSets')}
+            </label>
+            <input
+              type="number"
+              value={targetSets}
+              onChange={(e) => setTargetSets(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder={t('projects.targetSetsPlaceholder')}
+              min="1"
+            />
+            <p className="text-xs text-bambu-gray mt-1">{t('projects.targetSetsHelp')}</p>
+          </div>
+
           {/* Tags */}
           <div>
             <label className="block text-sm font-medium text-white mb-1">

+ 3 - 1
frontend/src/pages/StatsPage.tsx

@@ -882,7 +882,9 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
       });
     }
 
-    const costliest = findMax(a => a.cost);
+    // Filament + measured energy (#1432); prints without a smart plug have
+    // energy_cost null and compete on filament cost alone.
+    const costliest = findMax(a => (a.cost ?? 0) + (a.energy_cost ?? 0));
     if (costliest.archive) {
       result.push({
         icon: DollarSign, iconColor: 'text-green-600 dark:text-green-400', label: t('stats.mostExpensivePrint'),

+ 27 - 15
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -27,8 +27,10 @@ import {
   fetchPrinterCalibrations,
   findPresetOption,
   loadRecentColors,
+  pairedOptions,
   parsePresetName,
   saveRecentColor,
+  withCurrentValue,
 } from '../../components/spool-form/utils';
 import { MATERIALS } from '../../components/spool-form/constants';
 
@@ -613,21 +615,28 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
     return map;
   }, [brandMaterialPairs]);
 
-  const availableBrands = useMemo(() => {
-    if (!formData.material) return baseAvailableBrands;
-    const materialKey = formData.material.toLowerCase();
-    const brandKeys = materialToBrands.get(materialKey);
-    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
-    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
-  }, [baseAvailableBrands, formData.material, materialToBrands]);
-
-  const availableMaterials = useMemo(() => {
-    if (!formData.brand) return baseAvailableMaterials;
-    const brandKey = formData.brand.toLowerCase();
-    const materialKeys = brandToMaterials.get(brandKey);
-    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
-    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
-  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
+  // #1905: offer every known brand/material and rank the catalog-paired ones
+  // first, rather than filtering the others out — same behaviour as the
+  // Inventory spool form, which this page mirrors field for field.
+  const availableBrands = useMemo(
+    () => withCurrentValue(baseAvailableBrands, formData.brand),
+    [baseAvailableBrands, formData.brand],
+  );
+
+  const availableMaterials = useMemo(
+    () => withCurrentValue(baseAvailableMaterials, formData.material),
+    [baseAvailableMaterials, formData.material],
+  );
+
+  const suggestedBrands = useMemo(
+    () => pairedOptions(availableBrands, formData.material, materialToBrands),
+    [availableBrands, formData.material, materialToBrands],
+  );
+
+  const suggestedMaterials = useMemo(
+    () => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
+    [availableMaterials, formData.brand, brandToMaterials],
+  );
 
   const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
     setFormData(prev => ({ ...prev, [key]: value }));
@@ -913,7 +922,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
               filamentOptions={filamentOptions}
               availableBrands={availableBrands}
               availableMaterials={availableMaterials}
+              suggestedBrands={suggestedBrands}
+              suggestedMaterials={suggestedMaterials}
               quickAdd={quickAdd}
+              detailsRequired={!quickAdd}
               quantity={quantity}
               onQuantityChange={setQuantity}
               errors={errors}

+ 15 - 0
frontend/src/types/plates.ts

@@ -38,6 +38,21 @@ export interface PlateMetadata {
 interface EmbeddedPresets {
   embedded_printer?: string | null;
   embedded_process?: string | null;
+  // Process settings the designer changed away from the stock preset, read
+  // from the 3MF's own `different_settings_to_system` (#2622). Offered in the
+  // SliceModal so a re-slice for another printer can carry them instead of
+  // losing them to the picked process profile. Empty for STL, OrcaSlicer
+  // files, and older exports that predate the field.
+  design_overrides?: DesignOverride[];
+}
+
+// One process setting the designer deviated on. `printer_coupled` marks the
+// values that only make sense on the machine they were tuned for (speeds,
+// accelerations, prime-tower geometry) — offered, but never pre-selected.
+export interface DesignOverride {
+  key: string;
+  value: unknown;
+  printer_coupled: boolean;
 }
 
 export interface ArchivePlatesResponse extends EmbeddedPresets {

File diff suppressed because it is too large
+ 1 - 0
static/assets/index-D4bpNaiw.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-Di24iyOw.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-xPJs-OAQ.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-FqCjQymn.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
+    <script type="module" crossorigin src="/assets/index-xPJs-OAQ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff